mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-08 09:49:34 -10:00
Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
48e347f3a1 | ||
|
|
402be168e0 | ||
|
|
3b6e3034ee | ||
|
|
7f09e639d6 | ||
|
|
322123111e | ||
|
|
c6bc43ab64 | ||
|
|
ed47185727 | ||
|
|
5c9dde8061 | ||
|
|
f848ead1f3 | ||
|
|
dbbc0bbd0f |
No files matched your search
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
Reference in new issue
Block a user