Compare commits

...
Author SHA1 Message Date
anandh8x 48e347f3a1 fix(tui): include exactly-30-day sessions in the relative-age branch 2026-09-12 20:44:45 +05:30
anandh8x 402be168e0 fix(tui): drop all control runes from rendered session metadata
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.
2026-09-12 20:43:27 +05:30
anandh8x 3b6e3034ee test(tui): cover carriage returns in sanitizeCardField 2026-09-12 20:30:09 +05:30
anandh8x 7f09e639d6 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.
2026-09-12 20:27:25 +05:30
anandh8x 322123111e 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.
2026-09-12 20:17:54 +05:30
anandh8x c6bc43ab64 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.
2026-09-12 20:06:33 +05:30
anandh8x ed47185727 refactor(tui): drop the event count from /resume detail rows
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.
2026-09-12 19:04:48 +05:30
anandh8x 5c9dde8061 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.
2026-09-12 18:31:39 +05:30
anandh8x f848ead1f3 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.
2026-09-12 18:22:42 +05:30
anandh8x dbbc0bbd0f 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.
2026-09-12 18:21:31 +05:30
8 changed files with 346 additions and 52 deletions

No files matched your search

+29 -10
View File
@@ -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
+7 -7
View File
@@ -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)
}
}
+10 -3
View File
@@ -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
}
+5 -2
View File
@@ -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
+22 -2
View File
@@ -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)
}
}
}
}
+45 -27
View File
@@ -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
+186
View File
@@ -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)
}
}
+42 -1
View File
@@ -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.