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
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 (`<task-notification>`, `<system-reminder>`, 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 (`<teammate-message teammate_id=… summary=…>`, several per message, optionally after "Another Claude session sent a message:") show one `From <id> · <summary or status>` 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
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
68 changes: 48 additions & 20 deletions preview.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@ import (
"runtime"
"slices"
"sort"
"strconv"
"strings"
"time"
"unicode"
Expand Down Expand Up @@ -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
Expand All @@ -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")).
Expand Down
79 changes: 37 additions & 42 deletions preview_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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() != "" {
Expand Down Expand Up @@ -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)
}
}
57 changes: 23 additions & 34 deletions ui.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@ import (
"regexp"
"slices"
"sort"
"strconv"
"strings"
"time"

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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
}
Loading