Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 29 additions & 10 deletions internal/tui/command_center.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"strconv"
"strings"
"time"
"unicode"

tea "charm.land/bubbletea/v2"

Expand Down Expand Up @@ -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))
Expand All @@ -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
Expand Down
14 changes: 7 additions & 7 deletions internal/tui/model_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}

Expand Down
13 changes: 10 additions & 3 deletions internal/tui/mouse.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 := ""
Expand All @@ -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
Comment on lines +534 to 539

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Detail-line clicks lack coverage

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!

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
}
Expand Down
7 changes: 5 additions & 2 deletions internal/tui/picker.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
24 changes: 22 additions & 2 deletions internal/tui/rendering_lime_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"strings"
"testing"
"time"
"unicode"

"charm.land/lipgloss/v2"
"github.com/charmbracelet/colorprofile"
Expand Down Expand Up @@ -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)
}
}
}
}

Expand Down
72 changes: 45 additions & 27 deletions internal/tui/session.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@ import (
"path/filepath"
"runtime"
"strings"
"time"

"github.com/Gitlawb/zero/internal/agent"
"github.com/Gitlawb/zero/internal/execution"
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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")
Comment thread
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 {
Expand All @@ -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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Detail rows accept newlines

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 Detail.

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
Expand Down
Loading
Loading