diff --git a/internal/tui/command_center.go b/internal/tui/command_center.go index 571626b8a..475932c49 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,17 +366,30 @@ 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 -// 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", " ") - 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"); "" +// 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 +399,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/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/rendering_lime_test.go b/internal/tui/rendering_lime_test.go index de95257fc..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\x00bytes"); strings.ContainsAny(got, "\x1f\n\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.go b/internal/tui/session.go index 4aec00e58..a64e43c75 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 @@ -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") + 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) + } + 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 diff --git a/internal/tui/session_test.go b/internal/tui/session_test.go index 37477396d..0edfe02ce 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" @@ -1249,3 +1250,188 @@ 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"}, + {"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"}, + } + 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 TestSessionPickerDetailComposesProjectAndModel(t *testing.T) { + m := model{cwd: "/repo"} + detail := m.sessionPickerDetail(sessions.Metadata{ + Title: "work", + Cwd: "/repo", + ModelID: "gpt-5", + }) + 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. + 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}); !strings.Contains(got, "fork") { + t.Fatalf("fork session detail %q should carry the fork chip", got) + } + 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{Title: "plain"}); got != "" { + 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) + } +} + +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\x1b[31m5", + Cwd: "/repo", + Tag: "side\x1b]0;x\achat", + }) + 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] + 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) + } + } +} + +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) + } +} diff --git a/internal/tui/view.go b/internal/tui/view.go index c32f3b93c..98a141366 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,16 @@ 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. + // 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)) + } } if len(visible) == 0 { if m.picker.loading { @@ -845,6 +856,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.