Repository navigation
feat(tui): richer /resume session browser #1047
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
dbbc0bb
f848ead
5c9dde8
ed47185
c6bc43a
3221231
7f09e63
3b6e303
402be16
48e347f
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -8,7 +8,6 @@ import ( | |
| "path/filepath" | ||
| "runtime" | ||
| "strings" | ||
| "time" | ||
|
|
||
| "github.com/Gitlawb/zero/internal/agent" | ||
| "github.com/Gitlawb/zero/internal/execution" | ||
|
|
@@ -360,28 +359,10 @@ func (m model) formatResumeSummary(session sessions.Metadata, eventCount int) st | |
| }) | ||
| } | ||
|
|
||
| // sessionWhen formats a session's RFC3339 timestamp for the picker: a precise | ||
| // clock time (with seconds) for today so same-minute sessions stay distinct, the | ||
| // month/day and time earlier this year, else the date. Empty on a parse error. | ||
| func sessionWhen(timestamp string, now time.Time) string { | ||
| parsed, err := time.Parse(time.RFC3339, strings.TrimSpace(timestamp)) | ||
| if err != nil { | ||
| return "" | ||
| } | ||
| parsed, now = parsed.Local(), now.Local() | ||
| switch { | ||
| case parsed.Year() == now.Year() && parsed.YearDay() == now.YearDay(): | ||
| return parsed.Format("15:04:05") | ||
| case parsed.Year() == now.Year(): | ||
| return parsed.Format("Jan _2 15:04") | ||
| default: | ||
| return parsed.Format("2006-01-02") | ||
| } | ||
| } | ||
|
|
||
| // newSessionPicker builds the interactive /resume picker (mirrors /model & /provider): | ||
| // one row per resumable session — title (Label) + id and relative age (Meta). Returns | ||
| // nil when there are no resumable sessions so the caller falls back to the text path. | ||
| // one row per resumable session — age + title (Label), session id (Value), and a | ||
| // project/model/size line (Detail). Returns nil when there are no resumable | ||
| // sessions so the caller falls back to the text path. | ||
| func (m model) newSessionPicker() *commandPicker { | ||
| if m.sessionStore == nil { | ||
| return nil | ||
|
|
@@ -414,13 +395,17 @@ func (m model) newSessionPicker() *commandPicker { | |
| // Lead with a fixed-width timestamp so titles form one scannable column. | ||
| // The raw id remains the selection/search value but stays out of the row: | ||
| // rendering it consumed half the picker and truncated the useful title. | ||
| label := displayValue(meta.Title, "untitled") | ||
| if when := sessionWhen(meta.UpdatedAt, now); when != "" { | ||
| // Session metadata is persisted, user-controlled text — a title from | ||
| // --session-title or /rename can legally contain newlines, which would | ||
| // break the picker's fixed row geometry. | ||
| label := displayValue(sanitizeCardField(meta.Title), "untitled") | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
| if when := relativeAge(meta.UpdatedAt, now); when != "" { | ||
| label = sessionPickerLabel(when, label) | ||
| } | ||
| items = append(items, pickerItem{ | ||
| Label: label, | ||
| Value: meta.SessionID, | ||
| Label: label, | ||
| Value: meta.SessionID, | ||
| Detail: m.sessionPickerDetail(meta), | ||
| }) | ||
| } | ||
| if len(items) == 0 { | ||
|
|
@@ -435,12 +420,45 @@ func (m model) newSessionPicker() *commandPicker { | |
| } | ||
| } | ||
|
|
||
| const sessionPickerTimeWidth = len("Jan 02 15:04") | ||
| const sessionPickerTimeWidth = len("2006-01-02") | ||
|
|
||
| func sessionPickerLabel(when, title string) string { | ||
| return fmt.Sprintf("%-*s %s", sessionPickerTimeWidth, when, title) | ||
| } | ||
|
|
||
| // sessionPickerDetail composes the faint second line under a /resume row: | ||
| // the session's project directory (~/-contracted), the model it ran on, and a | ||
| // short status chip when the session is not a plain mainline run (forks, | ||
| // tagged sessions such as btw side-chats). Missing fields are simply omitted — | ||
| // the picker is workspace-scoped, so the project column is a confirmation, | ||
| // not a disambiguator. | ||
| func (m model) sessionPickerDetail(meta sessions.Metadata) string { | ||
| parts := make([]string, 0, 3) | ||
| if project := displayPath(m.cwd, meta.Cwd); project != "" { | ||
| parts = append(parts, project) | ||
| } | ||
| if modelID := strings.TrimSpace(meta.ModelID); modelID != "" { | ||
| parts = append(parts, modelID) | ||
| } | ||
|
Comment on lines
+437
to
+442
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Session working directories, model IDs, and tags are added to the new detail row without removing embedded newlines or terminal control characters. If persisted metadata contains a newline—for example, from a valid Unix directory name—the terminal renders extra rows, but mouse hit-testing and the visibility budget still assume at most two rows per item. This can make later clicks select the wrong session and cause the overlay to exceed its intended height. Convert these fields to safe single-line values before composing |
||
| if status := sessionPickerStatus(meta); status != "" { | ||
| parts = append(parts, status) | ||
| } | ||
| return sanitizeCardField(strings.Join(parts, " · ")) | ||
| } | ||
|
|
||
| // sessionPickerStatus is the concise status chip for a /resume row: an | ||
| // explicit tag when the session carries one, otherwise a marker for forked | ||
| // sessions. Plain mainline sessions get none — their shape is the default. | ||
| func sessionPickerStatus(meta sessions.Metadata) string { | ||
| if tag := strings.TrimSpace(meta.Tag); tag != "" { | ||
| return tag | ||
| } | ||
| if meta.SessionKind == sessions.SessionKindFork { | ||
| return "fork" | ||
| } | ||
| return "" | ||
| } | ||
|
|
||
| // sessionHasResumableContent reports whether a session has anything worth | ||
| // resuming: a tool call/result, or a non-user message with real content (not the | ||
| // no-output guardrail stop). Empty/failed runs return false and are hidden from | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The new behavior lets either line of a two-line picker item select its session, but there is no regression test that clicks the detail line. Existing mouse coverage exercises only single-line, ungrouped, unscrolled items, while the rendering test checks visibility without clicking. The repository requires every behavior change to have a regression test, including its failure path, so this requirement must be satisfied before merging by testing that a click on the second rendered line selects the corresponding session.
Context Used: AGENTS.md (source)
Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!