From dbbc0bbd0f18006da324a6a9e24d8cf5ed915e07 Mon Sep 17 00:00:00 2001 From: anandh8x Date: Sat, 12 Sep 2026 18:21:31 +0530 Subject: [PATCH 01/10] feat(tui): support an optional detail line on picker rows Add a faint second line under picker items so richer surfaces can show secondary metadata (project, model, size) without crowding the label. The line renders on the selection band, joins the search haystack, and only appears at medium width and above; detail rows halve the visible window so the overlay keeps its height, and mouse hit-testing maps a click on either line to the same item. --- internal/tui/mouse.go | 13 ++++++++++--- internal/tui/picker.go | 7 +++++-- internal/tui/view.go | 39 ++++++++++++++++++++++++++++++++++++++- 3 files changed, 53 insertions(+), 6 deletions(-) diff --git a/internal/tui/mouse.go b/internal/tui/mouse.go index f52c0ab10..7e613e363 100644 --- a/internal/tui/mouse.go +++ b/internal/tui/mouse.go @@ -513,10 +513,11 @@ func (m *model) selectGenericPickerAtMouse(msg tea.MouseMsg) (mouseSelectionTarg return mouseSelectionTarget{}, false } } - maxVisible := minInt(pickerOverlayMaxVisible, len(m.picker.items)) + maxVisible := pickerMaxVisible(m.picker, width) selected := clampInt(m.picker.selected, 0, len(m.picker.items)-1) start := selectableListStart(len(m.picker.items), maxVisible, selected) visible := m.picker.items[start : start+maxVisible] + details := pickerShowsDetails(m.picker, width) // y=0 titled top border, y=1 search line, y=2 separator; rows begin at y=3. line := 3 lastGroup := "" @@ -528,12 +529,18 @@ func (m *model) selectGenericPickerAtMouse(msg tea.MouseMsg) (mouseSelectionTarg line++ lastGroup = item.Group } - if hit.y == line { + // A rendered detail line belongs to its item: a click anywhere on the + // two-line block selects the same row. + rowLines := 1 + if details && item.Detail != "" { + rowLines = 2 + } + if hit.y >= line && hit.y < line+rowLines { index := start + offset m.picker.selected = index return mouseSelectionTarget{Scope: "picker", Kind: int(m.picker.kind), Value: item.Value, Index: index}, true } - line++ + line += rowLines } return mouseSelectionTarget{}, false } diff --git a/internal/tui/picker.go b/internal/tui/picker.go index 2a0da566c..b18b405b6 100644 --- a/internal/tui/picker.go +++ b/internal/tui/picker.go @@ -34,12 +34,15 @@ const ( // pickerItem is one selectable row: Label is shown, Value is passed to the // underlying command handler when chosen. Meta is the optional right-aligned // readout (ctx window · capabilities); the dot flags mark provider locality -// for model rows (accent = remote, blue = local). +// for model rows (accent = remote, blue = local). Detail is an optional faint +// second line under the row (project · model · size for sessions), rendered +// only on medium-and-wider tiers where the extra line reads cleanly. type pickerItem struct { Group string Label string Value string Meta string + Detail string Provider string // display tag (catalog id / locality) // OwnerProvider is the saved provider profile name a model belongs to, so the // /model picker can switch providers when a model from a non-active provider is @@ -167,7 +170,7 @@ func (p *commandPicker) applyQuery() { // the joined haystack, and a fuzzy subsequence is the last-resort match. func scorePickerItem(item pickerItem, query string) (int, bool) { label := strings.ToLower(item.Label) - hay := strings.ToLower(strings.Join([]string{item.Group, item.Label, item.Value, item.Meta}, " ")) + hay := strings.ToLower(strings.Join([]string{item.Group, item.Label, item.Value, item.Meta, item.Detail}, " ")) switch { case label == query: return 0, true diff --git a/internal/tui/view.go b/internal/tui/view.go index c32f3b93c..4ca5343b6 100644 --- a/internal/tui/view.go +++ b/internal/tui/view.go @@ -773,7 +773,7 @@ func (m model) pickerOverlay(width int) string { overlayWidth = width } innerWidth := maxInt(1, overlayWidth-4) - maxVisible := minInt(pickerOverlayMaxVisible, len(m.picker.items)) + maxVisible := pickerMaxVisible(m.picker, width) start := 0 visible := []pickerItem{} if len(m.picker.items) > 0 { @@ -781,6 +781,7 @@ func (m model) pickerOverlay(width int) string { start = selectableListStart(len(m.picker.items), maxVisible, m.picker.selected) visible = m.picker.items[start : start+maxVisible] } + showDetails := pickerShowsDetails(m.picker, width) lines := make([]string, 0, len(visible)+7) title := strings.TrimSpace(m.picker.title) @@ -821,6 +822,12 @@ func (m model) pickerOverlay(width int) string { gap := innerWidth - lipgloss.Width(left) - lipgloss.Width(right) line := left + surface(zeroTheme.ink).Render(strings.Repeat(" ", maxInt(1, gap))) + right lines = append(lines, fitStyledLine(line, innerWidth)) + if showDetails && item.Detail != "" { + // The detail line sits under the row on the same selection band, + // indented to the label column so it reads as part of the item. + detail := surface(zeroTheme.faint).Render(" " + item.Detail) + lines = append(lines, fitStyledLine(detail, innerWidth)) + } } if len(visible) == 0 { if m.picker.loading { @@ -845,6 +852,36 @@ func (m model) pickerOverlay(width int) string { return centerRenderedBlock(styledBlockFillTitle(overlayWidth, title, lines, zeroTheme.lineStrong, lipgloss.NewStyle()), width) } +// pickerShowsDetails reports whether the overlay renders each row's optional +// Detail line: only when a row actually carries one and the terminal is at +// least medium width, where the two-line treatment reads cleanly. Narrower +// tiers keep the single-line list fallback. +func pickerShowsDetails(p *commandPicker, width int) bool { + if p == nil || widthTier(width) < tierMedium { + return false + } + for _, item := range p.items { + if item.Detail != "" { + return true + } + } + return false +} + +// pickerMaxVisible caps the visible window in items. When detail lines render, +// each item costs two rows, so the budget halves to keep the overlay the same +// height as the single-line list. +func pickerMaxVisible(p *commandPicker, width int) int { + if p == nil { + return 0 + } + budget := pickerOverlayMaxVisible + if pickerShowsDetails(p, width) { + budget = pickerOverlayMaxVisible / 2 + } + return minInt(budget, len(p.items)) +} + // themePickerOverlay keeps candidate rendering inside the picker. Moving through // themes therefore shows their hierarchy and code treatment without repainting the // transcript, terminal canvas, or any other active UI. From f848ead1f3f1dbe6332a9a005dc485aaab2e1068 Mon Sep 17 00:00:00 2001 From: anandh8x Date: Sat, 12 Sep 2026 18:22:42 +0530 Subject: [PATCH 02/10] feat(tui): show project, model, and size under /resume rows Each session row now carries a detail line with the project directory (~-contracted), the model that ran it, and its event count, plus a status chip for forks and tagged sessions. The line also joins the filter haystack, so searching by project path or model name works. Narrow terminals keep the existing single-line list. --- internal/tui/session.go | 41 +++++++++++++++++++++++++++++++++++++++-- 1 file changed, 39 insertions(+), 2 deletions(-) diff --git a/internal/tui/session.go b/internal/tui/session.go index 4aec00e58..624dbab0e 100644 --- a/internal/tui/session.go +++ b/internal/tui/session.go @@ -419,8 +419,9 @@ func (m model) newSessionPicker() *commandPicker { 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 { @@ -441,6 +442,42 @@ 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, its +// size in events, 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, 4) + if project := displayPath(m.cwd, meta.Cwd); project != "" { + parts = append(parts, project) + } + if modelID := strings.TrimSpace(meta.ModelID); modelID != "" { + parts = append(parts, modelID) + } + if meta.EventCount > 0 { + parts = append(parts, fmt.Sprintf("%d events", meta.EventCount)) + } + if status := sessionPickerStatus(meta); status != "" { + parts = append(parts, status) + } + return 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 From 5c9dde80610f09f3de8cc4fdfdff87dd908541c0 Mon Sep 17 00:00:00 2001 From: anandh8x Date: Sat, 12 Sep 2026 18:27:31 +0530 Subject: [PATCH 03/10] feat(tui): show relative last-activity age in /resume rows Replace the absolute timestamp column with relative age ("2h ago", "12d ago") so recency reads at a glance; distant history still shows the calendar date. Also adds coverage for the detail line: rendering at wide vs narrow tiers, status chips, and filtering by model. --- internal/tui/command_center.go | 13 +++- internal/tui/model_test.go | 14 ++-- internal/tui/session.go | 33 +++------ internal/tui/session_test.go | 126 +++++++++++++++++++++++++++++++++ 4 files changed, 151 insertions(+), 35 deletions(-) diff --git a/internal/tui/command_center.go b/internal/tui/command_center.go index 571626b8a..e6c06248d 100644 --- a/internal/tui/command_center.go +++ b/internal/tui/command_center.go @@ -375,7 +375,9 @@ func sanitizeCardField(value string) string { return strings.ReplaceAll(value, "\x00", "") } -// relativeAge renders an RFC3339 timestamp as a short age ("2h ago"); "" +// relativeAge renders an RFC3339 timestamp as a short age ("2h ago"), falling +// back to the month/day this year and the bare date for sessions older than a +// month, so deep history reads as a calendar date rather than "400d ago"; "" // when the timestamp does not parse, so the card simply omits it. func relativeAge(timestamp string, now time.Time) string { parsed, err := time.Parse(time.RFC3339, strings.TrimSpace(timestamp)) @@ -385,14 +387,19 @@ func relativeAge(timestamp string, now time.Time) string { age := now.Sub(parsed) switch { case age < time.Minute: - return "just now" + return "now" case age < time.Hour: return fmt.Sprintf("%dm ago", int(age.Minutes())) case age < 24*time.Hour: return fmt.Sprintf("%dh ago", int(age.Hours())) - default: + case age < 30*24*time.Hour: return fmt.Sprintf("%dd ago", int(age.Hours()/24)) } + parsed, now = parsed.Local(), now.Local() + if parsed.Year() == now.Year() { + return parsed.Format("Jan _2") + } + return parsed.Format("2006-01-02") } // handleModelCommand applies a model switch against the ACTIVE provider (the diff --git a/internal/tui/model_test.go b/internal/tui/model_test.go index b81f3a6cc..f55d0f28b 100644 --- a/internal/tui/model_test.go +++ b/internal/tui/model_test.go @@ -1087,16 +1087,16 @@ func TestResumeCommandListsRecentSessions(t *testing.T) { } func TestSessionPickerLabelAlignsTitles(t *testing.T) { - today := sessionPickerLabel("20:47:50", "Today title") - older := sessionPickerLabel("Jul 24 10:47", "Older title") + recent := sessionPickerLabel("2h ago", "Recent title") + older := sessionPickerLabel("2006-01-02", "Older title") - todayColumn := strings.Index(today, "Today title") + recentColumn := strings.Index(recent, "Recent title") olderColumn := strings.Index(older, "Older title") - if todayColumn < 0 || olderColumn < 0 { - t.Fatalf("sessionPickerLabel omitted a title: today=%q older=%q", today, older) + if recentColumn < 0 || olderColumn < 0 { + t.Fatalf("sessionPickerLabel omitted a title: recent=%q older=%q", recent, older) } - if todayColumn != olderColumn { - t.Fatalf("title columns differ: today=%d (%q), older=%d (%q)", todayColumn, today, olderColumn, older) + if recentColumn != olderColumn { + t.Fatalf("title columns differ: recent=%d (%q), older=%d (%q)", recentColumn, recent, olderColumn, older) } } diff --git a/internal/tui/session.go b/internal/tui/session.go index 624dbab0e..a2524ff8d 100644 --- a/internal/tui/session.go +++ b/internal/tui/session.go @@ -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 @@ -415,7 +396,7 @@ func (m model) newSessionPicker() *commandPicker { // 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 != "" { + if when := relativeAge(meta.UpdatedAt, now); when != "" { label = sessionPickerLabel(when, label) } items = append(items, pickerItem{ @@ -436,7 +417,7 @@ 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) @@ -456,7 +437,9 @@ func (m model) sessionPickerDetail(meta sessions.Metadata) string { if modelID := strings.TrimSpace(meta.ModelID); modelID != "" { parts = append(parts, modelID) } - if meta.EventCount > 0 { + if meta.EventCount == 1 { + parts = append(parts, "1 event") + } else if meta.EventCount > 0 { parts = append(parts, fmt.Sprintf("%d events", meta.EventCount)) } if status := sessionPickerStatus(meta); status != "" { diff --git a/internal/tui/session_test.go b/internal/tui/session_test.go index 37477396d..1ff5d4008 100644 --- a/internal/tui/session_test.go +++ b/internal/tui/session_test.go @@ -1249,3 +1249,129 @@ func assertPayloadFieldContains(t *testing.T, event sessions.Event, key string, t.Fatalf("expected payload %s to contain %q, got %#v in %#v", key, want, payload[key], payload) } } + +func TestRelativeAgeFormatsLastActivity(t *testing.T) { + now := time.Date(2026, 9, 12, 15, 0, 0, 0, time.UTC) + cases := []struct { + name string + ts time.Time + want string + }{ + {"just now", now.Add(-10 * time.Second), "now"}, + {"slight clock skew", now.Add(30 * time.Second), "now"}, + {"minutes", now.Add(-42 * time.Minute), "42m ago"}, + {"hours", now.Add(-3 * time.Hour), "3h ago"}, + {"days", now.Add(-12 * 24 * time.Hour), "12d ago"}, + {"this year", time.Date(2026, 1, 5, 9, 0, 0, 0, time.UTC), "Jan 5"}, + {"last year", time.Date(2025, 6, 1, 0, 0, 0, 0, time.UTC), "2025-06-01"}, + } + for _, tc := range cases { + if got := relativeAge(tc.ts.Format(time.RFC3339), now); got != tc.want { + t.Errorf("%s: sessionAge = %q, want %q", tc.name, got, tc.want) + } + } + if got := relativeAge("not-a-time", now); got != "" { + t.Errorf("unparseable timestamp = %q, want empty", got) + } + if got := relativeAge("", now); got != "" { + t.Errorf("empty timestamp = %q, want empty", got) + } +} + +func TestSessionPickerDetailComposesProjectModelAndSize(t *testing.T) { + m := model{cwd: "/repo"} + detail := m.sessionPickerDetail(sessions.Metadata{ + Title: "work", + Cwd: "/repo", + ModelID: "gpt-5", + EventCount: 42, + }) + for _, want := range []string{"/repo", "gpt-5", "42 events"} { + if !strings.Contains(detail, want) { + t.Fatalf("detail %q missing %q", detail, want) + } + } + // Missing fields are omitted rather than rendered as empty segments. + sparse := m.sessionPickerDetail(sessions.Metadata{Title: "bare", EventCount: 1}) + if strings.Contains(sparse, " · ") { + t.Fatalf("sparse detail should have no separators, got %q", sparse) + } + if sparse != "1 event" { + t.Fatalf("singular event count = %q, want %q", sparse, "1 event") + } +} + +func TestSessionPickerDetailStatusChips(t *testing.T) { + m := model{} + if got := m.sessionPickerDetail(sessions.Metadata{SessionKind: sessions.SessionKindFork, EventCount: 2}); !strings.Contains(got, "fork") { + t.Fatalf("fork session detail %q should carry the fork chip", got) + } + if got := m.sessionPickerDetail(sessions.Metadata{Tag: "btw", EventCount: 2}); !strings.Contains(got, "btw") { + t.Fatalf("tagged session detail %q should carry the tag", got) + } + if got := m.sessionPickerDetail(sessions.Metadata{EventCount: 2}); strings.Contains(got, "fork") { + t.Fatalf("plain session detail %q should have no status chip", got) + } +} + +func TestResumePickerDetailRendersOnlyAtMediumWidth(t *testing.T) { + store := testSessionStore(t) + sess, err := store.Create(sessions.CreateInput{Title: "Detail row", ModelID: "gpt-5-pickertest", Provider: "openai"}) + if err != nil { + t.Fatalf("Create returned error: %v", err) + } + if _, err := store.AppendEvent(sess.SessionID, sessions.AppendEventInput{ + Type: sessions.EventMessage, + Payload: map[string]any{"role": "assistant", "content": "hi"}, + }); err != nil { + t.Fatalf("AppendEvent returned error: %v", err) + } + m := newModel(context.Background(), Options{SessionStore: store}) + m.input.SetValue("/resume") + updated, _ := m.Update(testKey(tea.KeyEnter)) + next := updated.(model) + if next.picker == nil || next.picker.kind != pickerSession { + t.Fatalf("expected /resume to open the session picker, got %#v", next.picker) + } + + next.width = 120 + if wide := viewString(next.View()); !strings.Contains(wide, "gpt-5-pickertest") { + t.Fatalf("wide picker should render the detail line with the model:\n%s", wide) + } + next.width = 60 + if narrow := viewString(next.View()); strings.Contains(narrow, "gpt-5-pickertest") { + t.Fatalf("narrow picker should keep the single-line fallback:\n%s", narrow) + } +} + +func TestResumePickerFilterMatchesDetailFields(t *testing.T) { + store := testSessionStore(t) + mk := func(title, modelID string) { + sess, err := store.Create(sessions.CreateInput{Title: title, ModelID: modelID, Provider: "openai"}) + if err != nil { + t.Fatalf("Create %q returned error: %v", title, err) + } + if _, err := store.AppendEvent(sess.SessionID, sessions.AppendEventInput{ + Type: sessions.EventMessage, + Payload: map[string]any{"role": "assistant", "content": "hi"}, + }); err != nil { + t.Fatalf("AppendEvent %q returned error: %v", title, err) + } + } + mk("alpha", "gpt-5") + mk("beta", "claude-sonnet-4.5") + + m := newModel(context.Background(), Options{SessionStore: store}) + m.input.SetValue("/resume") + updated, _ := m.Update(testKey(tea.KeyEnter)) + next := updated.(model) + if next.picker == nil { + t.Fatal("expected /resume to open the session picker") + } + // The model id lives only on the detail line; it must still filter rows. + next.picker.query = "claude-sonnet" + next.picker.applyQuery() + if len(next.picker.items) != 1 || !strings.Contains(next.picker.items[0].Label, "beta") { + t.Fatalf("filter by model should keep only the matching session, got %#v", next.picker.items) + } +} From ed4718572799f3519e5f151f1d235fff9b316200 Mon Sep 17 00:00:00 2001 From: anandh8x Date: Sat, 12 Sep 2026 19:04:48 +0530 Subject: [PATCH 04/10] refactor(tui): drop the event count from /resume detail rows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Event count is an internal metric — the detail line now carries just project, model, and the status chip, which are the fields that actually discriminate sessions. --- internal/tui/session.go | 17 ++++++----------- internal/tui/session_test.go | 25 ++++++++++--------------- 2 files changed, 16 insertions(+), 26 deletions(-) diff --git a/internal/tui/session.go b/internal/tui/session.go index a2524ff8d..1eda9d17a 100644 --- a/internal/tui/session.go +++ b/internal/tui/session.go @@ -424,24 +424,19 @@ func sessionPickerLabel(when, title string) string { } // sessionPickerDetail composes the faint second line under a /resume row: -// the session's project directory (~/-contracted), the model it ran on, its -// size in events, 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. +// 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, 4) + 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) } - if meta.EventCount == 1 { - parts = append(parts, "1 event") - } else if meta.EventCount > 0 { - parts = append(parts, fmt.Sprintf("%d events", meta.EventCount)) - } if status := sessionPickerStatus(meta); status != "" { parts = append(parts, status) } diff --git a/internal/tui/session_test.go b/internal/tui/session_test.go index 1ff5d4008..7b691dd75 100644 --- a/internal/tui/session_test.go +++ b/internal/tui/session_test.go @@ -1278,38 +1278,33 @@ func TestRelativeAgeFormatsLastActivity(t *testing.T) { } } -func TestSessionPickerDetailComposesProjectModelAndSize(t *testing.T) { +func TestSessionPickerDetailComposesProjectAndModel(t *testing.T) { m := model{cwd: "/repo"} detail := m.sessionPickerDetail(sessions.Metadata{ - Title: "work", - Cwd: "/repo", - ModelID: "gpt-5", - EventCount: 42, + Title: "work", + Cwd: "/repo", + ModelID: "gpt-5", }) - for _, want := range []string{"/repo", "gpt-5", "42 events"} { + for _, want := range []string{"/repo", "gpt-5"} { if !strings.Contains(detail, want) { t.Fatalf("detail %q missing %q", detail, want) } } // Missing fields are omitted rather than rendered as empty segments. - sparse := m.sessionPickerDetail(sessions.Metadata{Title: "bare", EventCount: 1}) - if strings.Contains(sparse, " · ") { - t.Fatalf("sparse detail should have no separators, got %q", sparse) - } - if sparse != "1 event" { - t.Fatalf("singular event count = %q, want %q", sparse, "1 event") + if sparse := m.sessionPickerDetail(sessions.Metadata{Title: "bare"}); sparse != "" { + t.Fatalf("empty metadata should produce no detail line, got %q", sparse) } } func TestSessionPickerDetailStatusChips(t *testing.T) { m := model{} - if got := m.sessionPickerDetail(sessions.Metadata{SessionKind: sessions.SessionKindFork, EventCount: 2}); !strings.Contains(got, "fork") { + if got := m.sessionPickerDetail(sessions.Metadata{SessionKind: sessions.SessionKindFork}); !strings.Contains(got, "fork") { t.Fatalf("fork session detail %q should carry the fork chip", got) } - if got := m.sessionPickerDetail(sessions.Metadata{Tag: "btw", EventCount: 2}); !strings.Contains(got, "btw") { + if got := m.sessionPickerDetail(sessions.Metadata{Tag: "btw"}); !strings.Contains(got, "btw") { t.Fatalf("tagged session detail %q should carry the tag", got) } - if got := m.sessionPickerDetail(sessions.Metadata{EventCount: 2}); strings.Contains(got, "fork") { + if got := m.sessionPickerDetail(sessions.Metadata{Title: "plain"}); got != "" { t.Fatalf("plain session detail %q should have no status chip", got) } } From c6bc43ab64770281dbe852d9e5cc1e641ec03c89 Mon Sep 17 00:00:00 2001 From: anandh8x Date: Sat, 12 Sep 2026 20:06:33 +0530 Subject: [PATCH 05/10] fix(tui): sanitize session metadata before it reaches picker rows Persisted titles (--session-title, /rename) and other metadata can legally contain newlines; a stray line would break the picker's fixed row geometry and misalign mouse hit-testing. Reuse sanitizeCardField for the label and detail line, and cover the detail-line click path. --- internal/tui/session.go | 7 +++-- internal/tui/session_test.go | 60 ++++++++++++++++++++++++++++++++++++ 2 files changed, 65 insertions(+), 2 deletions(-) diff --git a/internal/tui/session.go b/internal/tui/session.go index 1eda9d17a..a64e43c75 100644 --- a/internal/tui/session.go +++ b/internal/tui/session.go @@ -395,7 +395,10 @@ 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") + // 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") if when := relativeAge(meta.UpdatedAt, now); when != "" { label = sessionPickerLabel(when, label) } @@ -440,7 +443,7 @@ func (m model) sessionPickerDetail(meta sessions.Metadata) string { if status := sessionPickerStatus(meta); status != "" { parts = append(parts, status) } - return strings.Join(parts, " · ") + return sanitizeCardField(strings.Join(parts, " · ")) } // sessionPickerStatus is the concise status chip for a /resume row: an diff --git a/internal/tui/session_test.go b/internal/tui/session_test.go index 7b691dd75..675fe143c 100644 --- a/internal/tui/session_test.go +++ b/internal/tui/session_test.go @@ -1370,3 +1370,63 @@ func TestResumePickerFilterMatchesDetailFields(t *testing.T) { t.Fatalf("filter by model should keep only the matching session, got %#v", next.picker.items) } } + +func TestResumePickerSanitizesMultilineMetadata(t *testing.T) { + // Persisted session metadata is user-controlled: a title or model id can + // legally contain newlines, which must not break the picker's fixed + // one-or-two-line row geometry (a stray line shifts every row below it and + // misaligns mouse hit-testing). + store := testSessionStore(t) + sess, err := store.Create(sessions.CreateInput{ + Title: "alpha\nbravo", + ModelID: "gpt\n5", + Cwd: "/repo", + }) + if err != nil { + t.Fatalf("Create returned error: %v", err) + } + if _, err := store.AppendEvent(sess.SessionID, sessions.AppendEventInput{ + Type: sessions.EventMessage, + Payload: map[string]any{"role": "assistant", "content": "hi"}, + }); err != nil { + t.Fatalf("AppendEvent returned error: %v", err) + } + m := newModel(context.Background(), Options{SessionStore: store, Cwd: "/repo"}) + m.input.SetValue("/resume") + updated, _ := m.Update(testKey(tea.KeyEnter)) + next := updated.(model) + if next.picker == nil || len(next.picker.items) != 1 { + t.Fatalf("expected one picker item, got %#v", next.picker) + } + item := next.picker.items[0] + if strings.ContainsAny(item.Label+item.Detail, "\n\r") { + t.Fatalf("picker row must be single-line, got label=%q detail=%q", item.Label, item.Detail) + } +} + +func TestGenericPickerMouseSelectsItemFromDetailLine(t *testing.T) { + m := mouseTestModel() + m.width = 120 + m.picker = &commandPicker{ + kind: pickerSession, + items: []pickerItem{ + {Label: "one", Value: "s1", Detail: "meta one"}, + {Label: "two", Value: "s2", Detail: "meta two"}, + }, + } + m.picker.allItems = append([]pickerItem{}, m.picker.items...) + + width := chatWidth(m.width) + overlay := m.pickerOverlay(width) + rect := m.overlayMouseRect(len(viewLines(overlay)), width) + // Rows begin at y=3 inside the overlay; item 1's detail line is y=6. + // Click near the horizontal middle so the point lands inside the + // centered overlay block. + target, ok := m.selectGenericPickerAtMouse(testMouseClick(tea.MouseLeft, width/2, rect.y+6)) + if !ok { + t.Fatal("click on a detail line should select its item") + } + if target.Value != "s2" { + t.Fatalf("detail-line click selected %q, want s2", target.Value) + } +} From 322123111e5a5563172fdff1f696621cd51301ee Mon Sep 17 00:00:00 2001 From: anandh8x Date: Sat, 12 Sep 2026 20:17:54 +0530 Subject: [PATCH 06/10] fix(tui): strip carriage returns from rendered session metadata A \r in a persisted title or model id rewinds the cursor to column 0 and overwrites the row. Fold it into sanitizeCardField alongside the existing newline handling, and cover it in the picker test. --- internal/tui/command_center.go | 8 +++++--- internal/tui/session_test.go | 2 +- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/internal/tui/command_center.go b/internal/tui/command_center.go index e6c06248d..9dcc36499 100644 --- a/internal/tui/command_center.go +++ b/internal/tui/command_center.go @@ -365,13 +365,15 @@ func (defaultModelSwitchCompactionPolicy) BeforeModelSwitch(request modelSwitchC var modelSwitchCompactionGuard modelSwitchCompactionPolicy = defaultModelSwitchCompactionPolicy{} -// sanitizeCardField strips the card protocol's separator bytes from -// user-controlled values (titles can legally contain anything --session-title -// was given), so a hostile or accidental \x1f / newline cannot shift fields +// sanitizeCardField strips the card protocol's separator bytes and +// line-breaking controls from user-controlled values (titles can legally +// contain anything --session-title was given), so a hostile or accidental +// \x1f / newline / carriage return cannot shift fields, corrupt row geometry, // or leak control characters into the transcript. func sanitizeCardField(value string) string { value = strings.ReplaceAll(value, sessionsCardFieldSep, " ") value = strings.ReplaceAll(value, "\n", " ") + value = strings.ReplaceAll(value, "\r", " ") return strings.ReplaceAll(value, "\x00", "") } diff --git a/internal/tui/session_test.go b/internal/tui/session_test.go index 675fe143c..0fdbbe066 100644 --- a/internal/tui/session_test.go +++ b/internal/tui/session_test.go @@ -1379,7 +1379,7 @@ func TestResumePickerSanitizesMultilineMetadata(t *testing.T) { store := testSessionStore(t) sess, err := store.Create(sessions.CreateInput{ Title: "alpha\nbravo", - ModelID: "gpt\n5", + ModelID: "gpt\r5", Cwd: "/repo", }) if err != nil { From 7f09e639d66933110c0cdc86878eff496b2d84b2 Mon Sep 17 00:00:00 2001 From: anandh8x Date: Sat, 12 Sep 2026 20:27:25 +0530 Subject: [PATCH 07/10] fix(tui): pad picker detail lines on the selection band The detail line only painted its text, so the selection highlight stopped at the end of the text instead of spanning the row like the label line above it. Pad the remainder with the row surface. --- internal/tui/view.go | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/internal/tui/view.go b/internal/tui/view.go index 4ca5343b6..98a141366 100644 --- a/internal/tui/view.go +++ b/internal/tui/view.go @@ -825,7 +825,11 @@ func (m model) pickerOverlay(width int) string { if showDetails && item.Detail != "" { // The detail line sits under the row on the same selection band, // indented to the label column so it reads as part of the item. - detail := surface(zeroTheme.faint).Render(" " + item.Detail) + // Pad the remainder like the row above so the band is full-width. + detailText := " " + item.Detail + detailGap := innerWidth - lipgloss.Width(detailText) + detail := surface(zeroTheme.faint).Render(detailText) + + surface(zeroTheme.faint).Render(strings.Repeat(" ", maxInt(0, detailGap))) lines = append(lines, fitStyledLine(detail, innerWidth)) } } From 3b6e3034ee1a1af0df0ae2609f8034c5f2592f35 Mon Sep 17 00:00:00 2001 From: anandh8x Date: Sat, 12 Sep 2026 20:30:09 +0530 Subject: [PATCH 08/10] test(tui): cover carriage returns in sanitizeCardField --- internal/tui/rendering_lime_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/tui/rendering_lime_test.go b/internal/tui/rendering_lime_test.go index de95257fc..59fc944c5 100644 --- a/internal/tui/rendering_lime_test.go +++ b/internal/tui/rendering_lime_test.go @@ -1973,7 +1973,7 @@ func TestPermissionCollapseIsRunScoped(t *testing.T) { } func TestSessionsCardFieldsAreSanitized(t *testing.T) { - if got := sanitizeCardField("evil\x1ftitle\nwith\x00bytes"); strings.ContainsAny(got, "\x1f\n\x00") { + if got := sanitizeCardField("evil\x1ftitle\nwith\r\x00bytes"); strings.ContainsAny(got, "\x1f\n\r\x00") { t.Fatalf("sanitizeCardField left separator bytes: %q", got) } } From 402be168e0281475eca7fedf96e714b52945b403 Mon Sep 17 00:00:00 2001 From: anandh8x Date: Sat, 12 Sep 2026 20:43:27 +0530 Subject: [PATCH 09/10] fix(tui): drop all control runes from rendered session metadata MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The picker detail line newly renders cwd, model id, and tag, and any of them can legally carry ESC/CSI/OSC/BEL/BS/VT/NEL bytes — a project directory name containing \x1b[2J would repaint the terminal when the picker opens. sanitizeCardField now maps line separators and tabs to spaces and drops every remaining control rune, matching the package's other sanitizers; this also closes the same hole for titles, which predates the detail line. --- internal/tui/command_center.go | 28 +++++++++++++++++++--------- internal/tui/rendering_lime_test.go | 24 ++++++++++++++++++++++-- internal/tui/session_test.go | 10 +++++++--- 3 files changed, 48 insertions(+), 14 deletions(-) diff --git a/internal/tui/command_center.go b/internal/tui/command_center.go index 9dcc36499..4c09591c4 100644 --- a/internal/tui/command_center.go +++ b/internal/tui/command_center.go @@ -8,6 +8,7 @@ import ( "strconv" "strings" "time" + "unicode" tea "charm.land/bubbletea/v2" @@ -365,16 +366,25 @@ func (defaultModelSwitchCompactionPolicy) BeforeModelSwitch(request modelSwitchC var modelSwitchCompactionGuard modelSwitchCompactionPolicy = defaultModelSwitchCompactionPolicy{} -// sanitizeCardField strips the card protocol's separator bytes and -// line-breaking controls from user-controlled values (titles can legally -// contain anything --session-title was given), so a hostile or accidental -// \x1f / newline / carriage return cannot shift fields, corrupt row geometry, -// or leak control characters into the transcript. +// sanitizeCardField strips the card protocol's separator byte and every +// control rune from user-controlled values (titles can legally contain +// anything --session-title was given, and a directory name can legally +// contain an ESC byte on Unix). Line separators and tabs become spaces so +// words stay apart; the remaining controls — ESC/CSI/OSC initiators, BEL, BS, +// VT, NEL, NUL — are dropped outright, since any of them can repaint the +// terminal or shift cells when the value lands in a rendered row. func sanitizeCardField(value string) string { - value = strings.ReplaceAll(value, sessionsCardFieldSep, " ") - value = strings.ReplaceAll(value, "\n", " ") - value = strings.ReplaceAll(value, "\r", " ") - return strings.ReplaceAll(value, "\x00", "") + var out strings.Builder + for _, r := range value { + switch { + case r == '\x1f' || r == '\n' || r == '\r' || r == '\t': + out.WriteRune(' ') + case unicode.IsControl(r): + default: + out.WriteRune(r) + } + } + return out.String() } // relativeAge renders an RFC3339 timestamp as a short age ("2h ago"), falling diff --git a/internal/tui/rendering_lime_test.go b/internal/tui/rendering_lime_test.go index 59fc944c5..12b74d0f4 100644 --- a/internal/tui/rendering_lime_test.go +++ b/internal/tui/rendering_lime_test.go @@ -6,6 +6,7 @@ import ( "strings" "testing" "time" + "unicode" "charm.land/lipgloss/v2" "github.com/charmbracelet/colorprofile" @@ -1973,8 +1974,27 @@ func TestPermissionCollapseIsRunScoped(t *testing.T) { } func TestSessionsCardFieldsAreSanitized(t *testing.T) { - if got := sanitizeCardField("evil\x1ftitle\nwith\r\x00bytes"); strings.ContainsAny(got, "\x1f\n\r\x00") { - t.Fatalf("sanitizeCardField left separator bytes: %q", got) + inputs := []string{ + "evil\x1ftitle", + "line\nbreak", + "line\rbreak", + "nul\x00byte", + "esc\x1b[31mred", + "csi\x1b[2J\x1b[H", + "osc\x1b]0;pwned\a", + "bell\aring", + "back\bspace", + "tab\there", + "vert\vtab", + "nel\u0085here", + } + for _, input := range inputs { + got := sanitizeCardField(input) + for _, r := range got { + if unicode.IsControl(r) { + t.Fatalf("sanitizeCardField(%q) left control rune %q in %q", input, r, got) + } + } } } diff --git a/internal/tui/session_test.go b/internal/tui/session_test.go index 0fdbbe066..07001264e 100644 --- a/internal/tui/session_test.go +++ b/internal/tui/session_test.go @@ -9,6 +9,7 @@ import ( "sync/atomic" "testing" "time" + "unicode" tea "charm.land/bubbletea/v2" @@ -1379,8 +1380,9 @@ func TestResumePickerSanitizesMultilineMetadata(t *testing.T) { store := testSessionStore(t) sess, err := store.Create(sessions.CreateInput{ Title: "alpha\nbravo", - ModelID: "gpt\r5", + ModelID: "gpt\x1b[31m5", Cwd: "/repo", + Tag: "side\x1b]0;x\achat", }) if err != nil { t.Fatalf("Create returned error: %v", err) @@ -1399,8 +1401,10 @@ func TestResumePickerSanitizesMultilineMetadata(t *testing.T) { t.Fatalf("expected one picker item, got %#v", next.picker) } item := next.picker.items[0] - if strings.ContainsAny(item.Label+item.Detail, "\n\r") { - t.Fatalf("picker row must be single-line, got label=%q detail=%q", item.Label, item.Detail) + for _, r := range item.Label + item.Detail { + if unicode.IsControl(r) { + t.Fatalf("picker row leaked control rune %q: label=%q detail=%q", r, item.Label, item.Detail) + } } } From 48e347f3a10e43fcdca9b73ed2ec25e572edcb68 Mon Sep 17 00:00:00 2001 From: anandh8x Date: Sat, 12 Sep 2026 20:44:45 +0530 Subject: [PATCH 10/10] fix(tui): include exactly-30-day sessions in the relative-age branch --- internal/tui/command_center.go | 2 +- internal/tui/session_test.go | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/internal/tui/command_center.go b/internal/tui/command_center.go index 4c09591c4..475932c49 100644 --- a/internal/tui/command_center.go +++ b/internal/tui/command_center.go @@ -404,7 +404,7 @@ func relativeAge(timestamp string, now time.Time) string { return fmt.Sprintf("%dm ago", int(age.Minutes())) case age < 24*time.Hour: return fmt.Sprintf("%dh ago", int(age.Hours())) - case age < 30*24*time.Hour: + case age <= 30*24*time.Hour: return fmt.Sprintf("%dd ago", int(age.Hours()/24)) } parsed, now = parsed.Local(), now.Local() diff --git a/internal/tui/session_test.go b/internal/tui/session_test.go index 07001264e..0edfe02ce 100644 --- a/internal/tui/session_test.go +++ b/internal/tui/session_test.go @@ -1263,6 +1263,7 @@ func TestRelativeAgeFormatsLastActivity(t *testing.T) { {"minutes", now.Add(-42 * time.Minute), "42m ago"}, {"hours", now.Add(-3 * time.Hour), "3h ago"}, {"days", now.Add(-12 * 24 * time.Hour), "12d ago"}, + {"exactly one month", now.Add(-30 * 24 * time.Hour), "30d ago"}, {"this year", time.Date(2026, 1, 5, 9, 0, 0, 0, time.UTC), "Jan 5"}, {"last year", time.Date(2025, 6, 1, 0, 0, 0, 0, time.UTC), "2025-06-01"}, }