diff --git a/AGENTS.md b/AGENTS.md index 7df1bc4..5dc392e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -91,7 +91,7 @@ One package, split by area (all `package main`, same directory): - `renderBody()` / `harnessNote()` - preview readability: each message is wrapped to the preview width with a hanging indent (`ansi.Wrap`, ANSI-aware), markdown rendered (bold, inline code, headings, bullets, `[text](url)` and bare addresses as underlined text plus a short grey address, both clickable OSC 8 terminal links (`hyperlink`, one per word so a wrapped link never stays open), tables as aligned columns via `renderTable`, widest columns cut with … to fit), messages shown in full up to `maxMessageRunes`. Fenced code blocks over `maxCodeLines` collapse to `▸ lang · N lines` unless the search matches inside. Harness-injected user messages (``, ``, command output…) collapse to one dim `▸ tag · summary` line; messages from ccs/other sessions stay expanded - `timeGutter()` - consecutive messages from one speaker share a `User` / `Claude` header (a new one on a speaker change, after a gap or harness line, and for each search match, marked `▶`); the date is a dim `── 2026-09-29 ──` row (`dateRow`), once per day, and `renderPreview` pins the top visible message's date in the header's last line unless its own row is already at the top; harness notes and messages from other sessions use the same time gutter, and consecutive notes stack without blank lines; every message starts with its time (`21:45`) and the text lines up after it - `linkAt()` / `openURL` / `carryStyles()` - ccs holds the mouse, so the terminal never sees a click on a link: a left click in the preview finds the OSC 8 link under the pointer in the rendered line and opens it (`open`/`xdg-open`, http(s) only). Wrapped lines are self-contained (`carryStyles` closes open styles/links at each line end and reopens them on the next), so an underline never bleeds across the screen -- `visibleLinks()` / `linksPopup()` - Ctrl+O, the keyboard way to open links: collects the OSC 8 targets in the preview as drawn (top to bottom, each once; a link split word by word or across lines is joined back into its text), numbered in a popup that owns the keyboard (↑↓ ^P/N enter, ^O/esc close; mouse ignored; digits build a number, `pickLink`, moving the cursor to it and opening it once no longer number exists; the list scrolls with the cursor when taller than the screen, with a scrollbar as the list and preview have). PR rows show their mark and status before the title, so a long title is what's cut. No links in view says so in the header instead +- `visibleLinks()` / `linksPopup()` - Ctrl+O, the keyboard way to open links: collects the OSC 8 targets in the preview as drawn (top to bottom, each once; a link split word by word or across lines is joined back into its text), listed in a popup that owns the keyboard (↑↓ ^P/N, enter opens and leaves it open, ^O/esc close; mouse ignored; typing goes to its filter box, `linksInput`, an `editKey` box like the others, and `linksShown` keeps the links whose text as shown or address matches by the search's word rule, so a PR's status is searchable; the list scrolls with the cursor when taller than the screen, with a scrollbar as the list and preview have). PR rows show their mark and status before the title, so a long title is what's cut, drawn from the status at each frame (`linkText`). No links in view says so in the header instead - `teammateBlocks()` - messages from teammate agents (``, several per message, optionally after "Another Claude session sent a message:") show one `From · ` block each; a JSON status report is unpacked (its `result`/`message` rendered as markdown), and a status with nothing to read (idle, shutdown) is one dim `▸ id · status` line - `hitLinesIn()` / `jumpHit()` - with a search typed, Ctrl+] / Ctrl+\ put the next / previous hit at the top of the preview (wrapping; skips hits that share the final screen). Hits are the lines `buildPreviewLines` marks `▶` (never body text, which is indented), so nothing extra is cached; `renderPreview` adds `hit i/N` to the header's pinned-date line while searching - `previewLines()` - Memoised `buildPreviewLines` for the selected conversation (rebuilt only when selection, query, message count or width changes), avoids per-frame rescans of huge conversations. It waits `previewDebounce` (150ms) after the selection changes (`noteSelection`, `selectedAt`; a `previewSettledMsg` redraws), so scrolling through the list draws only each preview's header and builds nothing for sessions passed on the way (a big one takes 100-200ms); the selected session's own PR lookup waits too. The first selection isn't delayed; tests set it to 0 diff --git a/README.md b/README.md index dadb275..279bd9e 100644 --- a/README.md +++ b/README.md @@ -119,7 +119,7 @@ export CCS_CLAUDE_ARGS="--permission-mode auto" - `Ctrl+X U` - Switch Claude account (needs [cswap](https://github.com/realiti4/claude-swap), e.g. `uv tool install claude-swap`); clicking the account email does the same - `?` - Show the shortcuts that apply right now (the header lists only the common ones, in compact form: `^S` for Ctrl+S, and drops hints on narrow terminals rather than wrapping) - `Ctrl+L` - Changelog: what changed in every release, newest first, the installed and newer ones marked; scroll with ↑↓, PgUp/PgDn or the wheel -- `Ctrl+O` - Links: the selected session's PRs, then the links in the visible part of the conversation, all numbered, each PR with its status; type a link's number (it opens once no longer number fits) or use ↑↓ and Enter to open one in the browser (clicking a link works too) +- `Ctrl+O` - Links: the selected session's PRs, then the links in the visible part of the conversation, each PR with its status; type to filter them (text, address or status, e.g. `merged`), ↑↓ and Enter to open one in the browser, staying open for the next (clicking a link works too) - `Ctrl+]` / `Ctrl+\` - With a search typed, jump to the next / previous matching message in the conversation (wraps round); the preview shows `hit 2/5` - `Ctrl+C` - Quit diff --git a/preview.go b/preview.go index bb2bd3d..e3ead44 100644 --- a/preview.go +++ b/preview.go @@ -9,7 +9,6 @@ import ( "runtime" "slices" "sort" - "strconv" "strings" "time" "unicode" @@ -532,40 +531,66 @@ func (m model) linksWithPRs() []linkItem { return out } -// linksPopup lists linksWithPRs, numbered, with the cursor on one; a list -// taller than the screen scrolls to keep the cursor in view, with a scrollbar. +// linkText is how l reads in the ^O popup; a PR's is drawn from its status +// now, so a lookup that lands while the popup is open shows. +func (m model) linkText(l linkItem) string { + if l.text == "" { + return m.prLinkText(l.url) + } + return l.text +} + +// linksShown is the ^O popup's links matching what's typed in it, by the +// search's word rule over the text as shown and the address. +func (m model) linksShown() []linkItem { + terms := strings.Fields(strings.ToLower(m.linksInput.Value())) + if len(terms) == 0 { + return m.links + } + var out []linkItem + for _, l := range m.links { + if queryMatches(strings.ToLower(ansi.Strip(m.linkText(l))+" "+l.url), "", terms) { + out = append(out, l) + } + } + return out +} + +// linksPopup is the ^O popup: a filter box over linksShown, the cursor on +// one; a list taller than the screen scrolls with the cursor, with a scrollbar. func (m model) linksPopup() string { width := min(max(m.width-12, 30), 90) - rows := max(m.height-10, 3) // border, padding, title and hints + rows := max(m.height-12, 3) // border, padding, title, filter and hints + shown := m.linksShown() + cur := min(m.linksCursor, max(len(shown)-1, 0)) start := 0 - if m.linksCursor >= rows { - start = m.linksCursor - rows + 1 + if cur >= rows { + start = cur - rows + 1 } - end := min(start+rows, len(m.links)) + end := min(start+rows, len(shown)) var b strings.Builder b.WriteString("\033[1mlinks in view\033[0m") - if len(m.links) > rows { - fmt.Fprintf(&b, " \033[90m%d/%d\033[0m", m.linksCursor+1, len(m.links)) + if len(shown) > 0 { + fmt.Fprintf(&b, " \033[90m%d/%d\033[0m", cur+1, len(shown)) } - b.WriteString("\n\n") - numW := len(strconv.Itoa(len(m.links))) - top, size, bar := scrollThumb(len(m.links), start, end-start) + in := m.linksInput + in.Width = width - 4 + b.WriteString("\n\n" + in.View() + "\n\n") + top, size, bar := scrollThumb(len(shown), start, end-start) rowW := width if bar { rowW-- // the scrollbar's column } for i := start; i < end; i++ { - l := m.links[i] - if l.text == "" { // a PR: drawn from its latest status - l.text = m.prLinkText(l.url) - } + l := shown[i] + text := m.linkText(l) addr := strings.TrimPrefix(strings.TrimPrefix(l.url, "https://"), "http://") - row := fmt.Sprintf("%*d ", numW, i+1) + l.text - if ansi.Strip(l.text) != addr && !prURL.MatchString(l.url) { + row := text + if ansi.Strip(text) != addr && !prURL.MatchString(l.url) { row += " \033[90m" + addr + "\033[39m" } row = ansi.Truncate(row, rowW-2, "…") - if i == m.linksCursor { + if i == cur { row = "\033[1m› " + row + "\033[0m" } else { row = " " + row @@ -575,7 +600,10 @@ func (m model) linksPopup() string { } b.WriteString(row + "\n") } - b.WriteString("\n\033[90m" + hints("↑↓", "move", "0-9", "pick", keyEnter, "open", "^O/"+keyEsc, "close") + "\033[0m") + if len(shown) == 0 { + b.WriteString("\033[90mno links match\033[0m\n") + } + b.WriteString("\n\033[90m" + hints("↑↓", "move", keyEnter, "open", "^O/"+keyEsc, "close") + "\033[0m") return lipgloss.NewStyle(). Border(lipgloss.RoundedBorder()). BorderForeground(lipgloss.Color("39")). diff --git a/preview_test.go b/preview_test.go index 4f854b0..8306660 100644 --- a/preview_test.go +++ b/preview_test.go @@ -643,21 +643,38 @@ func TestLinksPopup(t *testing.T) { if !m.linksOpen { t.Fatal("^O should open the links popup") } - if v := strip2(m.View()); !strings.Contains(v, "1 the release notes github.com/a/b/releases") || !strings.Contains(v, "3 link text") { - t.Errorf("popup should number the links:\n%s", v) + if v := strip2(m.View()); !strings.Contains(v, "› the release notes github.com/a/b/releases") || !strings.Contains(v, "link text") { + t.Errorf("popup should list the links:\n%s", v) } m, _ = key(m, tea.KeyMsg{Type: tea.KeyDown}) m, cmd := key(m, tea.KeyMsg{Type: tea.KeyEnter}) - if m.linksOpen || cmd == nil { - t.Fatal("enter should open the selected link and close the popup") + if !m.linksOpen || cmd == nil { + t.Fatal("enter should open the selected link and leave the popup open") } cmd() - m, _ = key(m, tea.KeyMsg{Type: tea.KeyCtrlO}) - m, cmd = key(m, tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("3")}) + for _, r := range "wraps" { + m, _ = key(m, tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{r}}) + } + if v := strip2(m.View()); strings.Contains(v, "release notes") || !strings.Contains(v, "› link text") { + t.Errorf("typing filters the links, cursor on the first match:\n%s", v) + } + m, cmd = key(m, tea.KeyMsg{Type: tea.KeyEnter}) cmd() if want := []string{"https://example.com/x", "https://e.io/z"}; !reflect.DeepEqual(opened, want) { t.Errorf("opened %q, want %q", opened, want) } + m, _ = key(m, tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("zzz")}) + if v := strip2(m.View()); !strings.Contains(v, "no links match") { + t.Errorf("nothing matching says so:\n%s", v) + } + if _, cmd = key(m, tea.KeyMsg{Type: tea.KeyEnter}); cmd != nil { + t.Error("enter with nothing matching opens nothing") + } + m, _ = key(m, tea.KeyMsg{Type: tea.KeyEsc}) + m, _ = key(m, tea.KeyMsg{Type: tea.KeyCtrlO}) + if v := strip2(m.View()); !m.linksOpen || !strings.Contains(v, "release notes") { + t.Errorf("reopening starts with an empty filter:\n%s", v) + } m, _ = key(m, tea.KeyMsg{Type: tea.KeyCtrlO}) m, _ = key(m, tea.KeyMsg{Type: tea.KeyEsc}) if m.linksOpen || m.textInput.Value() != "" { @@ -721,58 +738,36 @@ func TestPreviewWaitsForTheSelectionToRest(t *testing.T) { } } -func TestLinksPopupNumbersAllAndShowsPRStatus(t *testing.T) { - var opened []string - defer func(f func(string) error) { openURL = f }(openURL) - openURL = func(u string) error { opened = append(opened, u); return nil } - var prs []string +func TestLinksPopupFiltersPRsByStatus(t *testing.T) { m := initialModel(nil, "", nil) m.prs = map[string]prInfo{} + var prs []string for i := 1; i <= 25; i++ { u := fmt.Sprintf("https://github.com/o/r/pull/%d", i) prs = append(prs, u) m.prs[u] = prInfo{State: "MERGED", Title: strings.Repeat("a very long title ", 10)} } + m.prs[prs[24]] = prInfo{State: "OPEN", MergeState: "CLEAN", Title: "go"} conv := Conversation{SessionID: "s", Cwd: "/p", PRs: prs, Messages: []Message{{Role: "assistant", Text: "hi", Ts: "2026-09-30T10:00:00Z"}}} m.items = []listItem{{conv: conv}} m.filtered = m.items m.width, m.height = 100, 20 m, _ = key(m, tea.KeyMsg{Type: tea.KeyCtrlO}) v := strip2(m.View()) - if !strings.Contains(v, " 1 ● r#1 merged · a very long") || !strings.Contains(v, "10 ● r#10 merged") { - t.Errorf("every link numbered, the status before the title:\n%s", v) - } - if strings.Contains(v, "r#25 ") || !strings.Contains(v, "1/25") { - t.Errorf("a list taller than the screen should scroll:\n%s", v) - } - if !strings.Contains(v, "┃") { - t.Errorf("and show a scrollbar:\n%s", v) - } - m, cmd := key(m, tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("2")}) - if cmd != nil || !m.linksOpen || m.linksCursor != 1 { - t.Fatalf("2 could start 20-25: select it and wait (cursor %d)", m.linksCursor) + if !strings.Contains(v, "› ✓ r#25 ready to merge · go") || !strings.Contains(v, "● r#1 merged · a very long") { + t.Errorf("each PR's status before its title, ready first:\n%s", v) } - m, cmd = key(m, tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("3")}) - if cmd == nil || m.linksOpen { - t.Fatal("23 is the only number starting so: open it") + if strings.Contains(v, "r#24 ") || !strings.Contains(v, "1/25") || !strings.Contains(v, "┃") { + t.Errorf("a list taller than the screen scrolls, with a scrollbar:\n%s", v) } - cmd() - if !reflect.DeepEqual(opened, []string{prs[22]}) { - t.Errorf("opened %q, want #23", opened) + for _, r := range "ready" { + m, _ = key(m, tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{r}}) } - m, _ = key(m, tea.KeyMsg{Type: tea.KeyCtrlO}) - m, _ = key(m, tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("2")}) - m, cmd = key(m, tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("6")}) - if cmd == nil || m.linksOpen { - t.Fatal("26 isn't a link: 6 starts over, and nothing longer starts with it, so it opens") + if v := strip2(m.View()); !strings.Contains(v, "1/1") || strings.Contains(v, "r#1 merged") { + t.Errorf("the status is searchable too:\n%s", v) } - cmd() - if !reflect.DeepEqual(opened, []string{prs[22], prs[5]}) { - t.Errorf("opened %q, want #23 then #6", opened) - } - m, _ = key(m, tea.KeyMsg{Type: tea.KeyCtrlO}) - m, _ = key(m, tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("1")}) - if v := strip2(m.View()); !m.linksOpen || !strings.Contains(v, "1/25") { - t.Errorf("1 waits for a second digit:\n%s", v) + m.prs[prs[24]] = prInfo{State: "MERGED", Title: "go"} + if v := strip2(m.View()); !strings.Contains(v, "no links match") { + t.Errorf("rows follow the latest status:\n%s", v) } } diff --git a/ui.go b/ui.go index c205705..1e77992 100644 --- a/ui.go +++ b/ui.go @@ -10,7 +10,6 @@ import ( "regexp" "slices" "sort" - "strconv" "strings" "time" @@ -171,9 +170,9 @@ type model struct { fetchPRs func([]string) (map[string]prInfo, error) // nil: never look PRs up // Ctrl+O: the links in the visible part of the preview, to open by key. linksOpen bool - linksNum string // digits typed in the ^O popup so far links []linkItem - linksCursor int + linksInput textinput.Model // filters the links (linksShown) + linksCursor int // into linksShown accts []cswapAccount acctCursor int acctBusy bool // a cswap command is running @@ -345,10 +344,16 @@ func initialModel(items []listItem, filterQuery string, claudeFlags []string) mo chat.Focus() // only fed keys while chatFocus; drawn without a cursor otherwise wait := chat + links := textinput.New() + links.Prompt = "> " + links.Placeholder = "type to filter..." + links.Cursor.SetMode(cursor.CursorStatic) + links.Focus() snooze, ok := snoozeLength(os.Getenv("CCS_SNOOZE")) m := model{ chatInput: chat, waitInput: wait, + linksInput: links, waitPreview: &previewCache{}, snooze: snooze, items: items, @@ -1077,17 +1082,22 @@ func (m model) update(msg tea.Msg) (tea.Model, tea.Cmd) { case "ctrl+c": m.quitting = true return m, tea.Quit - case "up", "ctrl+p", "ctrl+k": - m.linksCursor, m.linksNum = max(m.linksCursor-1, 0), "" - case "down", "ctrl+n", "ctrl+j": - m.linksCursor, m.linksNum = min(m.linksCursor+1, len(m.links)-1), "" + case "up", "ctrl+p": + m.linksCursor = max(m.linksCursor-1, 0) + case "down", "ctrl+n": + m.linksCursor = min(m.linksCursor+1, len(m.linksShown())-1) case "enter": - m.linksOpen = false - return m, openLinkCmd(m.links[m.linksCursor].url) + if shown := m.linksShown(); len(shown) > 0 { // stays open for the next + return m, openLinkCmd(shown[min(m.linksCursor, len(shown)-1)].url) + } default: - if len(k) == 1 && k[0] >= '0' && k[0] <= '9' { - return m.pickLink(k) + before := m.linksInput.Value() + var cmd tea.Cmd + m.linksInput, cmd = editKey(m.linksInput, msg) + if m.linksInput.Value() != before { + m.linksCursor = 0 } + return m, cmd } return m, nil } @@ -1115,7 +1125,8 @@ func (m model) update(msg tea.Msg) (tea.Model, tea.Cmd) { m.errorMsg = "No links in view" return m, nil } - m.linksOpen, m.linksCursor, m.linksNum = true, 0, "" + m.linksOpen, m.linksCursor = true, 0 + m.linksInput.SetValue("") return m, nil } if msg.String() == "ctrl+l" && !m.prompting() && !m.acctOpen && !m.waitOpen { @@ -2320,25 +2331,3 @@ func editKey(in textinput.Model, msg tea.Msg) (textinput.Model, tea.Cmd) { } return in, cmd } - -// pickLink takes a digit typed in the ^O popup: the digits so far are a link's -// number, which the cursor moves to; once no longer number exists, it opens. -// A digit that makes no link's number starts a new one. -func (m model) pickLink(d string) (tea.Model, tea.Cmd) { - num := m.linksNum + d - n, _ := strconv.Atoi(num) - if n < 1 || n > len(m.links) { - num = d - n, _ = strconv.Atoi(d) - } - if n < 1 || n > len(m.links) { - m.linksNum = "" - return m, nil - } - m.linksNum, m.linksCursor = num, n-1 - if n*10 > len(m.links) { - m.linksOpen, m.linksNum = false, "" - return m, openLinkCmd(m.links[n-1].url) - } - return m, nil -}