From a84df5992b03521c7c7968f1d84c351abaebceb3 Mon Sep 17 00:00:00 2001 From: Rolando Santamaria Maso Date: Sat, 29 Aug 2026 01:20:23 +0200 Subject: [PATCH 1/5] feat(tui): two-step confirm for ^L and /clear MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The whole transcript — plus every turn stat, tool count, and context counter — was one accidental ^L away from gone, while deleting a single session or fact in the panels already required the d→y two-step gate. Arm the same confirmClear gate instead: y fires the wipe, any other key disarms, the composer footer shows the danger hint. /clear gains the same idle-only rule ^L always had, so a mid-turn wipe can no longer drop the view out from under a streaming turn. --- internal/tui/clear_confirm_test.go | 123 +++++++++++++++++++++++++++++ internal/tui/commands.go | 8 +- internal/tui/commands_e2e_test.go | 5 ++ internal/tui/commands_test.go | 10 ++- internal/tui/integration_test.go | 6 +- internal/tui/model.go | 4 +- internal/tui/panels.go | 13 ++- internal/tui/stats_test.go | 2 + internal/tui/view.go | 9 +++ 9 files changed, 173 insertions(+), 7 deletions(-) create mode 100644 internal/tui/clear_confirm_test.go diff --git a/internal/tui/clear_confirm_test.go b/internal/tui/clear_confirm_test.go new file mode 100644 index 0000000..5ef2d8f --- /dev/null +++ b/internal/tui/clear_confirm_test.go @@ -0,0 +1,123 @@ +package tui + +import ( + "strings" + "testing" +) + +// ^L and /clear are destructive at conversation scope — the whole transcript +// plus every session counter. They must arm the same two-step confirm the +// panel row deletes use (y fires, any other key disarms) instead of wiping +// on a single keypress, and they must stay idle-only like ^L always was. + +func seedConversation(m *Model) { + m.msgs = append(m.msgs, + message{role: roleUser, content: "hello"}, + message{role: roleAsst, content: "world"}, + ) + m.toolTotal = 3 + m.sessCtxTok = 100 +} + +func TestClearArmsConfirm(t *testing.T) { + m := newTestModel() + seedConversation(m) + + m.Update(key("ctrl+l")) + + if m.confirm != confirmClear { + t.Fatalf("ctrl+l did not arm confirmClear: %v", m.confirm) + } + if len(m.msgs) != 2 { + t.Fatalf("armed ^L already wiped the transcript: %d msgs", len(m.msgs)) + } + if got := plain(m.View()); !strings.Contains(got, "clear the conversation?") { + t.Errorf("footer does not show the clear confirm gate:\n%s", got) + } + + m.Update(key("y")) + if m.confirm != confirmNone { + t.Error("y did not disarm the confirm") + } + if len(m.msgs) != 0 || m.toolTotal != 0 || m.sessCtxTok != 0 { + t.Errorf("y did not clear: msgs=%d tools=%d ctx=%d", + len(m.msgs), m.toolTotal, m.sessCtxTok) + } +} + +func TestClearConfirmDisarmsOnOtherKey(t *testing.T) { + m := newTestModel() + seedConversation(m) + + m.Update(key("ctrl+l")) + if m.confirm != confirmClear { + t.Fatalf("ctrl+l did not arm confirmClear: %v", m.confirm) + } + + m.Update(key("esc")) + if m.confirm != confirmNone { + t.Error("esc did not disarm the confirm") + } + if len(m.msgs) != 2 { + t.Errorf("disarm lost the transcript: %d msgs", len(m.msgs)) + } + + // After disarming, printable keys type again — they never clear. + m.Update(key("x")) + if len(m.msgs) != 2 { + t.Errorf("rune keypress cleared the transcript: %d msgs", len(m.msgs)) + } +} + +func TestClearIgnoredWhileBusy(t *testing.T) { + m := newTestModel() + seedConversation(m) + m.busy = true + + m.Update(key("ctrl+l")) + + if m.confirm != confirmNone { + t.Error("^L armed a confirm mid-turn") + } + if len(m.msgs) != 2 { + t.Errorf("^L cleared mid-turn: %d msgs", len(m.msgs)) + } +} + +func TestSlashClearArmsConfirm(t *testing.T) { + m := newTestModel() + seedConversation(m) + + m.Update(key("/")) + m.ta.SetValue("/clear") + m.Update(key("enter")) + + if m.confirm != confirmClear { + t.Fatalf("/clear did not arm confirmClear: %v", m.confirm) + } + if len(m.msgs) != 2 { + t.Errorf("armed /clear already wiped the transcript: %d msgs", len(m.msgs)) + } + + m.Update(key("y")) + if len(m.msgs) != 0 { + t.Errorf("y did not clear after /clear arm: %d msgs", len(m.msgs)) + } +} + +func TestSlashClearRefusedWhileBusy(t *testing.T) { + m := newTestModel() + seedConversation(m) + m.busy = true + + m.Update(key("/")) + m.ta.SetValue("/clear") + m.Update(key("enter")) + + if m.confirm != confirmNone { + t.Error("/clear armed a confirm mid-turn") + } + if len(m.msgs) != 2 { + t.Errorf("/clear cleared mid-turn: %d msgs", len(m.msgs)) + } +} diff --git a/internal/tui/commands.go b/internal/tui/commands.go index 8eacba9..4951ef1 100644 --- a/internal/tui/commands.go +++ b/internal/tui/commands.go @@ -28,8 +28,12 @@ func slashCommands() []command { return nil }}, {"clear", "clear the conversation", func(m *Model, _ string) tea.Cmd { - m.clearConversation() - return nil + // Same gate as ^L, and idle-only: a mid-turn wipe would drop the + // view out from under the streaming turn. + if m.busy { + return m.transientNoteCmd("can't clear while a turn runs — esc cancels it first") + } + return m.armConfirm(confirmClear, "the conversation") }}, {"stats", "session metrics & context gauge", func(m *Model, _ string) tea.Cmd { m.showStats() diff --git a/internal/tui/commands_e2e_test.go b/internal/tui/commands_e2e_test.go index fa44738..4b8cbdc 100644 --- a/internal/tui/commands_e2e_test.go +++ b/internal/tui/commands_e2e_test.go @@ -119,6 +119,11 @@ func TestE2EAllCommands(t *testing.T) { } }, "/clear": func(t *testing.T, m *Model) { + // The command arms the two-step confirm; y fires the wipe. + if m.confirm != confirmClear { + t.Fatalf("/clear did not arm confirmClear: %v", m.confirm) + } + m.Update(key("y")) if len(m.msgs) != 0 { t.Fatalf("/clear left %d messages", len(m.msgs)) } diff --git a/internal/tui/commands_test.go b/internal/tui/commands_test.go index c03b381..9ada397 100644 --- a/internal/tui/commands_test.go +++ b/internal/tui/commands_test.go @@ -30,10 +30,14 @@ func TestCommandPrefix(t *testing.T) { func TestSlashCommandsViaSubmit(t *testing.T) { m := wired(t) - // /clear + // /clear arms the two-step confirm; y fires the wipe. m.msgs = append(m.msgs, message{role: roleUser, content: "x"}) m.ta.SetValue("/clear") exec(m.submit()) + if m.confirm != confirmClear { + t.Fatal("/clear did not arm confirmClear") + } + m.Update(key("y")) if len(m.msgs) != 0 { t.Errorf("/clear left %d messages", len(m.msgs)) } @@ -169,6 +173,10 @@ func TestCommandPopupEnterExecutes(t *testing.T) { t.Fatalf("cmd popup not ready: %+v", m.ac) } m.Update(key("enter")) // executes the highlighted command directly + if m.confirm != confirmClear { + t.Fatal("/clear via popup enter did not arm confirmClear") + } + m.Update(key("y")) if len(m.msgs) != 0 { t.Errorf("/clear via popup enter left %d messages", len(m.msgs)) } diff --git a/internal/tui/integration_test.go b/internal/tui/integration_test.go index 1be2e59..a01d672 100644 --- a/internal/tui/integration_test.go +++ b/internal/tui/integration_test.go @@ -371,9 +371,13 @@ func TestInitAndBasicKeys(t *testing.T) { t.Error("ctrl+t did not enable thinking") } m.Update(key("ctrl+t")) - // Clear (not busy). + // Clear (not busy): ^L arms the confirm, y fires the wipe. m.msgs = append(m.msgs, message{role: roleUser, content: "x"}) m.Update(key("ctrl+l")) + if m.confirm != confirmClear { + t.Fatal("ctrl+l did not arm confirmClear") + } + m.Update(key("y")) if len(m.msgs) != 0 { t.Error("ctrl+l did not clear") } diff --git a/internal/tui/model.go b/internal/tui/model.go index 339e47c..2755881 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -655,8 +655,10 @@ func (m *Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { m.refresh() return m, cmd case "ctrl+l": + // The whole transcript is conversation-scope destructive: arm the + // same two-step gate the panel row deletes use, idle-only like ^L. if !m.busy { - m.clearConversation() + return m, m.armConfirm(confirmClear, "the conversation") } return m, nil case "ctrl+e": diff --git a/internal/tui/panels.go b/internal/tui/panels.go index b35b880..7a0d439 100644 --- a/internal/tui/panels.go +++ b/internal/tui/panels.go @@ -48,6 +48,7 @@ const ( confirmNone confirmKind = iota confirmSessionDelete confirmFactDelete + confirmClear ) // handleConfirmKey resolves an armed delete: y fires it against the @@ -63,16 +64,24 @@ func (m *Model) handleConfirmKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return m, m.deleteSelected() case confirmFactDelete: return m, m.memDeleteSelected() + case confirmClear: + m.clearConversation() + return m, m.transientNoteCmd("conversation cleared") } } m.refresh() return m, nil } -// armConfirm arms a row-scoped delete and shows the gate in the panel. +// armConfirm arms a destructive action and shows the gate: row deletes in +// the panel, or the conversation clear in the composer footer. func (m *Model) armConfirm(kind confirmKind, what string) tea.Cmd { m.confirm = kind - m.panelMsg = "delete " + what + "? y confirm · any other key cancels" + verb := "delete " + if kind == confirmClear { + verb = "clear " + } + m.panelMsg = verb + what + "? y confirm · any other key cancels" m.refresh() return nil } diff --git a/internal/tui/stats_test.go b/internal/tui/stats_test.go index f7213c2..ebd846d 100644 --- a/internal/tui/stats_test.go +++ b/internal/tui/stats_test.go @@ -424,9 +424,11 @@ func TestClearResetsTelemetry(t *testing.T) { c.run(m, "") } } + m.Update(key("y")) // the two-step confirm fires the clear }, "ctrl+l": func(m *Model) { m.Update(tea.KeyMsg{Type: tea.KeyCtrlL}) + m.Update(key("y")) // the two-step confirm fires the clear }, } for name, clear := range clears { diff --git a/internal/tui/view.go b/internal/tui/view.go index 8f1c300..e219d6d 100644 --- a/internal/tui/view.go +++ b/internal/tui/view.go @@ -1138,6 +1138,15 @@ func (m *Model) footer() string { th.footer.Render("esc close"), ) } + // The conversation-clear gate rides the composer footer — it is armed + // outside any panel and must be visible where ^L was pressed. + if m.confirm == confirmClear { + return m.panelFooter( + th.footerDanger.Render("clear the conversation?"), + th.footerKey.Render("y")+th.footerDanger.Render(" clear"), + th.footer.Render("any other key cancels"), + ) + } // The status bar carries no static key cheatsheet (the welcome splash and // /help cover that) — only the live run state: a cancel hint while busy on // the left, and latency / scroll position on the right. From fa0ee146901ed81583b4ffa0bda8f4e3f826bfee Mon Sep 17 00:00:00 2001 From: Rolando Santamaria Maso Date: Sat, 29 Aug 2026 10:31:18 +0200 Subject: [PATCH 2/5] feat(tui): copy the last reply to the clipboard via OSC 52 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The #1 post-turn action — pasting the answer into a PR, issue, or editor — had no keyboard path: selecting long markdown inside a full-screen alt-buffer TUI is clunky, especially without --mouse. ^Y and /copy now copy the latest finalized assistant reply via OSC 52. The sequence is emitted through tea.Exec so it reaches the terminal verbatim (tea.Println output is dropped on the alt-screen), the payload is consumed by the local terminal emulator only, and oversized replies are refused rather than silently truncated. Terminals without OSC 52 support ignore the sequence; the note says so. Promotes charmbracelet/x/ansi from indirect to direct — no new dependency. --- go.mod | 2 +- internal/tui/clipboard.go | 63 +++++++++++++++++++++++++++++ internal/tui/clipboard_test.go | 67 +++++++++++++++++++++++++++++++ internal/tui/commands.go | 3 ++ internal/tui/commands_e2e_test.go | 8 ++++ internal/tui/model.go | 3 ++ 6 files changed, 145 insertions(+), 1 deletion(-) create mode 100644 internal/tui/clipboard.go create mode 100644 internal/tui/clipboard_test.go diff --git a/go.mod b/go.mod index 74504c3..da41faf 100644 --- a/go.mod +++ b/go.mod @@ -7,6 +7,7 @@ require ( github.com/charmbracelet/bubbletea v1.3.10 github.com/charmbracelet/glamour v1.0.0 github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 + github.com/charmbracelet/x/ansi v0.11.8 github.com/muesli/termenv v0.16.0 golang.org/x/net v0.58.0 ) @@ -17,7 +18,6 @@ require ( github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/aymerick/douceur v0.2.0 // indirect github.com/charmbracelet/colorprofile v0.4.3 // indirect - github.com/charmbracelet/x/ansi v0.11.8 // indirect github.com/charmbracelet/x/cellbuf v0.0.15 // indirect github.com/charmbracelet/x/exp/slice v0.0.0-20260816001655-68d539dca504 // indirect github.com/charmbracelet/x/term v0.2.2 // indirect diff --git a/internal/tui/clipboard.go b/internal/tui/clipboard.go new file mode 100644 index 0000000..bc10e35 --- /dev/null +++ b/internal/tui/clipboard.go @@ -0,0 +1,63 @@ +package tui + +import ( + "fmt" + "io" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/x/ansi" +) + +// osc52Cap is the payload size where terminal emulators start dropping +// OSC 52 writes (tmux buffers ~100KB, Windows Terminal ~150KB). Larger +// copies are refused with a note rather than silently truncated. +const osc52Cap = 100_000 + +// clipboardWrite is a tea.ExecCommand that writes a raw escape sequence to +// the terminal. tea.Println can't carry it: bodek runs on the alt-screen, +// where the renderer drops printed lines entirely. Exec briefly pauses the +// renderer and hands over the real terminal writer, so the sequence lands +// verbatim and frames can't interleave. +type clipboardWrite struct { + seq string + w io.Writer +} + +func (c *clipboardWrite) SetStdin(io.Reader) {} +func (c *clipboardWrite) SetStderr(io.Writer) {} +func (c *clipboardWrite) SetStdout(w io.Writer) { c.w = w } + +func (c *clipboardWrite) Run() error { + if c.w == nil { + return nil // no terminal writer (tests, headless contexts) + } + _, err := io.WriteString(c.w, c.seq) + return err +} + +// lastReply returns the text of the most recent finalized assistant reply, +// or "" while none has landed (streaming turns and empty messages skipped). +func (m *Model) lastReply() string { + for i := len(m.msgs) - 1; i >= 0; i-- { + if msg := m.msgs[i]; msg.role == roleAsst && !msg.streaming && msg.content != "" { + return msg.content + } + } + return "" +} + +// copyLastReply puts the latest assistant reply on the system clipboard via +// OSC 52: the sequence is consumed by the terminal emulator itself, so no +// external tool runs and the payload never leaves the machine. Terminals +// without OSC 52 support silently ignore the sequence — the note says so. +func (m *Model) copyLastReply() tea.Cmd { + text := m.lastReply() + if text == "" { + return m.transientNoteCmd("nothing to copy — no assistant reply yet") + } + if len(text) > osc52Cap { + return m.transientNoteCmd(fmt.Sprintf("reply too large for OSC 52 (%d bytes) — select it manually", len(text))) + } + note := m.transientNoteCmd(fmt.Sprintf("copied %d chars via OSC 52 — needs a supporting terminal", len(text))) + return tea.Batch(tea.Exec(&clipboardWrite{seq: ansi.SetSystemClipboard(text)}, nil), note) +} diff --git a/internal/tui/clipboard_test.go b/internal/tui/clipboard_test.go new file mode 100644 index 0000000..6c3c00e --- /dev/null +++ b/internal/tui/clipboard_test.go @@ -0,0 +1,67 @@ +package tui + +import ( + "bytes" + "io" + "strings" + "testing" + + "github.com/charmbracelet/x/ansi" +) + +// ^Y / /copy put the latest assistant reply on the system clipboard via +// OSC 52 — written straight to the terminal with tea.Exec, because bodek +// runs on the alt-screen where tea.Println output is dropped entirely. + +func TestOsc52Sequence(t *testing.T) { + got := ansi.SetSystemClipboard("hi") + want := "\x1b]52;c;aGk=\x07" + if got != want { + t.Errorf("osc52 sequence = %q, want %q", got, want) + } +} + +func TestClipboardWriteRun(t *testing.T) { + var buf bytes.Buffer + c := &clipboardWrite{seq: "SEQ"} + c.SetStdin(nil) + c.SetStdout(&buf) + c.SetStderr(io.Discard) + if err := c.Run(); err != nil { + t.Fatalf("Run: %v", err) + } + if buf.String() != "SEQ" { + t.Errorf("Run wrote %q, want %q", buf.String(), "SEQ") + } +} + +func TestLastReplyPicksLatestFinalized(t *testing.T) { + m := newTestModel() + m.msgs = append(m.msgs, + message{role: roleUser, content: "q"}, + message{role: roleAsst, streaming: true}, + ) + if got := m.lastReply(); got != "" { + t.Errorf("streaming turn not skipped: %q", got) + } + m.msgs[len(m.msgs)-1] = message{role: roleAsst, content: "first"} + m.msgs = append(m.msgs, message{role: roleAsst, content: "second"}) + if got := m.lastReply(); got != "second" { + t.Errorf("lastReply = %q, want %q", got, "second") + } +} + +func TestCopyLastReplyGuards(t *testing.T) { + m := newTestModel() + + // Nothing to copy: the guard note must still fire (non-nil cmd). + if cmd := m.copyLastReply(); cmd == nil { + t.Error("empty transcript returned nil cmd; want the nothing-to-copy notice") + } + + // Oversized reply: refuse instead of silently truncating. + m.msgs = append(m.msgs, message{role: roleAsst, content: strings.Repeat("x", osc52Cap+1)}) + if cmd := m.copyLastReply(); cmd == nil { + t.Error("oversized reply returned nil cmd; want the refusal notice") + } +} diff --git a/internal/tui/commands.go b/internal/tui/commands.go index 4951ef1..5565f1e 100644 --- a/internal/tui/commands.go +++ b/internal/tui/commands.go @@ -35,6 +35,9 @@ func slashCommands() []command { } return m.armConfirm(confirmClear, "the conversation") }}, + {"copy", "copy the last reply to the clipboard (OSC 52)", func(m *Model, _ string) tea.Cmd { + return m.copyLastReply() + }}, {"stats", "session metrics & context gauge", func(m *Model, _ string) tea.Cmd { m.showStats() return nil diff --git a/internal/tui/commands_e2e_test.go b/internal/tui/commands_e2e_test.go index 4b8cbdc..32daec3 100644 --- a/internal/tui/commands_e2e_test.go +++ b/internal/tui/commands_e2e_test.go @@ -128,6 +128,14 @@ func TestE2EAllCommands(t *testing.T) { t.Fatalf("/clear left %d messages", len(m.msgs)) } }, + "/copy": func(t *testing.T, m *Model) { + // With a finalized reply on record the copy path dispatches + // (guard branches are unit-tested in clipboard_test.go). + m.msgs = append(m.msgs, message{role: roleAsst, content: "the answer"}) + if cmd := m.copyLastReply(); cmd == nil { + t.Fatal("/copy returned nil cmd with a reply on record") + } + }, "/stats": func(t *testing.T, m *Model) { card := lastMsg(m) if card == nil || !card.raw || !strings.Contains(plain(card.content), "⬡ session") { diff --git a/internal/tui/model.go b/internal/tui/model.go index 2755881..8a3823f 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -699,6 +699,9 @@ func (m *Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { m.vp, cmd = m.vp.Update(msg) return m, cmd } + case "ctrl+y": + // Copy the latest reply — a chord, so typing a y is never hijacked. + return m, m.copyLastReply() case "ctrl+g": // Jump to the latest output. A ctrl binding, so typing a capital G // (even as the first character of a prompt) is never hijacked. From e8e54abc81dd4f9645e4e47a0916df36ef1912e2 Mon Sep 17 00:00:00 2001 From: Rolando Santamaria Maso Date: Sat, 29 Aug 2026 10:33:44 +0200 Subject: [PATCH 3/5] feat(tui): transcript find bar on alt+f MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Long agent sessions had no way to search content: turn jumps and folding navigate by position only. alt+f opens a one-row find strip above the composer — typed runes filter matches live across message text, reasoning/reply segments, and tool steps (names, args, results, sub-agent logs); enter jumps forward, N back, esc closes. The bar captures the keyboard while open, and the ^L clear-confirm chord stays live through it. Matches map to message blocks through a per-message line index built alongside the existing turn/step indexes, so jumps land with a line of context above the target. --- internal/tui/find.go | 165 +++++++++++++++++++++++++++++++ internal/tui/find_test.go | 138 ++++++++++++++++++++++++++ internal/tui/integration_test.go | 2 + internal/tui/model.go | 17 ++++ internal/tui/view.go | 8 ++ 5 files changed, 330 insertions(+) create mode 100644 internal/tui/find.go create mode 100644 internal/tui/find_test.go diff --git a/internal/tui/find.go b/internal/tui/find.go new file mode 100644 index 0000000..21a4af5 --- /dev/null +++ b/internal/tui/find.go @@ -0,0 +1,165 @@ +package tui + +import ( + "fmt" + "strings" + + tea "github.com/charmbracelet/bubbletea" +) + +// findState is the transcript search bar. alt+f opens it; typed runes filter +// matches live across message text, reasoning/reply segments, and tool steps; +// ⏎ jumps to the next match, N to the previous; esc closes. While open the +// bar captures the keyboard — printable keys filter, they never type into +// the composer. +type findState struct { + open bool + query []rune + matches []int // message indices holding a match, transcript order + sel int // cursor into matches; ⏎ jumps to matches[sel] +} + +// openFind shows the find bar and reserves its row. +func (m *Model) openFind() { + m.find = findState{open: true} + m.relayout() + m.refresh() +} + +// closeFind hides the find bar and drops the query. +func (m *Model) closeFind() { + if !m.find.open { + return + } + m.find = findState{} + m.relayout() + m.refresh() +} + +// handleFindKey routes the keyboard while the find bar is open. +func (m *Model) handleFindKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + switch msg.String() { + case "ctrl+c": + m.quitting = true + return m, tea.Quit + case "esc", "alt+f": + m.closeFind() + return m, nil + case "enter": + m.findGoto(1) + return m, nil + case "N": + m.findGoto(-1) + return m, nil + case "backspace": + if n := len(m.find.query); n > 0 { + m.find.query = m.find.query[:n-1] + m.findRescan() + m.refresh() + } + return m, nil + case "ctrl+l": + // The clear-confirm chord stays live while searching: arming hands + // the keyboard to the confirm gate — y fires (the wipe also resets + // and closes this bar), any other key disarms back into the query. + if !m.busy { + return m, m.armConfirm(confirmClear, "the conversation") + } + return m, nil + } + if msg.Type == tea.KeyRunes && len(msg.Runes) > 0 { + m.find.query = append(m.find.query, msg.Runes...) + m.findRescan() + m.refresh() + } + return m, nil // swallow everything else while the bar is open +} + +// findRescan recomputes matches for the query over the whole transcript. +func (m *Model) findRescan() { + q := strings.ToLower(string(m.find.query)) + m.find.matches = nil + m.find.sel = 0 + if q == "" { + return + } + for i := range m.msgs { + if findMsgMatch(m.msgs[i], q) { + m.find.matches = append(m.find.matches, i) + } + } +} + +// findMsgMatch reports whether a message's visible text contains q. Raw cards +// (help/stats — snapshot blobs with embedded ANSI) are skipped: their bytes +// are not user prose. +func findMsgMatch(msg message, q string) bool { + if msg.raw { + return false + } + if strings.Contains(strings.ToLower(msg.content), q) { + return true + } + for _, it := range msg.items { + if (it.thinking || it.reply) && strings.Contains(strings.ToLower(it.text), q) { + return true + } + } + for _, s := range msg.steps { + if strings.Contains(strings.ToLower(s.name+" "+s.arg+" "+s.result), q) { + return true + } + for _, l := range s.logs { + if strings.Contains(strings.ToLower(l), q) { + return true + } + } + } + return false +} + +// findGoto jumps the viewport to the match at the cursor and then advances +// the cursor by dir (+1 next, -1 previous, wrapping), parking one line of +// context above the target block. +func (m *Model) findGoto(dir int) { + n := len(m.find.matches) + if n == 0 { + return + } + line := m.msgLine(m.find.matches[m.find.sel]) + m.find.sel = ((m.find.sel+dir)%n + n) % n + if line > 0 { + line-- // land with one line of context above the block + } + m.vp.SetYOffset(line) + m.refresh() +} + +// msgLine maps a message index to its first content line, 0 when unknown. +func (m *Model) msgLine(idx int) int { + for _, r := range m.msgLineIndex { + if r.msgIdx == idx { + return r.line + } + } + return 0 +} + +// findBar renders the one-row search strip above the input box. +func (m *Model) findBar() string { + th := m.th + q := string(m.find.query) + if w := m.width - 52; w > 0 && len(q) > w { + q = truncate(q, w) + } + count := "type to search the transcript" + if len(m.find.query) > 0 { + if n := len(m.find.matches); n == 0 { + count = th.footerDanger.Render("no matches") + } else { + count = fmt.Sprintf("%d matches", n) + } + } + return " " + th.footerKey.Render("find") + + th.footer.Render(" '"+q+"' · "+count+" · ⏎ next · N prev · esc close") +} diff --git a/internal/tui/find_test.go b/internal/tui/find_test.go new file mode 100644 index 0000000..c9704ed --- /dev/null +++ b/internal/tui/find_test.go @@ -0,0 +1,138 @@ +package tui + +import ( + "strings" + "testing" +) + +// alt+f opens the transcript find bar: typing filters matches live, ⏎ jumps +// to the next match, N to the previous, esc closes. While the bar is open it +// captures printable keys — the composer must not type. + +func TestFindOpenClose(t *testing.T) { + m := newTestModel() + seedConversation(m) + h0 := m.inputAreaHeight() + + m.Update(key("alt+f")) + if !m.find.open { + t.Fatal("alt+f did not open the find bar") + } + if got := m.inputAreaHeight(); got != h0+1 { + t.Errorf("open find bar did not claim a row: %d -> %d", h0, got) + } + if got := plain(m.View()); !strings.Contains(got, "find") { + t.Error("find bar not rendered") + } + + m.Update(key("esc")) + if m.find.open { + t.Error("esc did not close the find bar") + } + if got := m.inputAreaHeight(); got != h0 { + t.Errorf("closed find bar still claims a row: %d", got) + } +} + +func TestFindMatchesAndJump(t *testing.T) { + m := newTestModel() + m.msgs = append(m.msgs, + message{role: roleUser, content: "needle one"}, + message{role: roleAsst, content: "about needles"}, + message{role: roleAsst, content: "unrelated"}, + message{role: roleAsst, content: "NEEDLE three"}, + ) + m.refresh() + + m.Update(key("alt+f")) + for _, r := range "needle" { + m.Update(key(string(r))) + } + if len(m.find.matches) != 3 { + t.Fatalf("matches = %v, want 3 messages", m.find.matches) + } + want := []int{0, 1, 3} + for i, w := range want { + if m.find.matches[i] != w { + t.Fatalf("matches = %v, want %v", m.find.matches, want) + } + } +} + +func TestFindEnterJumpsSequentially(t *testing.T) { + m := newTestModel() + filler := strings.Repeat("line\n", 45) // overflow the 30-row viewport + m.msgs = append(m.msgs, + message{role: roleUser, content: "alpha " + filler}, + message{role: roleAsst, content: "target here " + filler}, + message{role: roleAsst, content: "filler " + filler}, + message{role: roleAsst, content: "second target"}, + ) + m.refresh() + m.Update(key("alt+f")) + for _, r := range "target" { + m.Update(key(string(r))) + } + + m.Update(key("enter")) + if m.vp.AtBottom() { + t.Error("enter did not scroll to the first match") + } + first := m.vp.YOffset + m.Update(key("enter")) + second := m.vp.YOffset + if second <= first { + t.Errorf("second enter did not advance: %d then %d", first, second) + } + m.Update(key("N")) + if m.vp.YOffset != first { + t.Errorf("N did not go back: %d want %d", m.vp.YOffset, first) + } +} + +func TestFindCapturesKeys(t *testing.T) { + m := newTestModel() + seedConversation(m) + + m.Update(key("alt+f")) + m.Update(key("x")) + if m.ta.Value() != "" { + t.Errorf("find leaked %q into the composer", m.ta.Value()) + } + if m.find.query == nil || len(m.find.query) != 1 { + t.Errorf("rune did not reach the find query: %q", string(m.find.query)) + } +} + +func TestFindNoMatches(t *testing.T) { + m := newTestModel() + seedConversation(m) + m.Update(key("alt+f")) + m.Update(key("z")) + m.Update(key("z")) + m.Update(key("z")) + if len(m.find.matches) != 0 { + t.Fatalf("unexpected matches: %v", m.find.matches) + } + if got := plain(m.View()); !strings.Contains(got, "no matches") { + t.Errorf("no-match state not rendered:\n%s", got) + } + m.Update(key("enter")) // must not panic or move +} + +func TestClearClosesFind(t *testing.T) { + m := newTestModel() + seedConversation(m) + m.Update(key("alt+f")) + if !m.find.open { + t.Fatal("find bar did not open") + } + m.Update(key("ctrl+l")) // arms the confirm + m.Update(key("y")) // fires the clear + if m.find.open { + t.Error("clearing the conversation left the find bar open") + } + if len(m.find.matches) != 0 || len(m.find.query) != 0 { + t.Errorf("find state not reset: %q %v", string(m.find.query), m.find.matches) + } +} diff --git a/internal/tui/integration_test.go b/internal/tui/integration_test.go index a01d672..fc36afc 100644 --- a/internal/tui/integration_test.go +++ b/internal/tui/integration_test.go @@ -353,6 +353,8 @@ func key(s string) tea.KeyMsg { return tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("s"), Alt: true} case "alt+x": return tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("x"), Alt: true} + case "alt+f": + return tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("f"), Alt: true} default: return tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(s)} } diff --git a/internal/tui/model.go b/internal/tui/model.go index 8a3823f..37077cf 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -245,9 +245,12 @@ type Model struct { convPrefix string // cached rendering of the finalized transcript prefix convPrefixRefs []stepRef // step header line index for the cached prefix convPrefixTurn []stepRef // turn-head line index for the cached prefix (stepIdx -1) + convPrefixMsgs []stepRef // per-message first-line index for the cached prefix convCount int // messages the prefix covers (-1 = invalidated) stepLineIndex []stepRef // full transcript step index for mouse hit-testing turnLineIndex []stepRef // full transcript turn-head index (stepIdx -1) for jump/collapse + msgLineIndex []stepRef // full transcript per-message first-line index for find jumps + find findState // transcript search bar (alt+f) renderPending bool // a coalesced streaming render is scheduled renderSeq int // bumped per scheduled flush, to drop stale ticks @@ -606,6 +609,12 @@ func (m *Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return m.handlePopoverKey(msg) } + // The find bar captures keys while open — typed runes filter matches, + // they never reach the composer. + if m.find.open { + return m.handleFindKey(msg) + } + // The @-reference popup captures navigation keys while open. if m.ac.open { return m.handleACKey(msg) @@ -715,6 +724,10 @@ func (m *Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { case "alt+down": m.jumpTurn(true) return m, nil + case "alt+f": + // Transcript search — a chord, so a bare f always types. + m.openFind() + return m, nil case "ctrl+f": // Fold/unfold the most recent turn card — long sessions scan top-down // when the noisy turns collapse to their telemetry head. A chord: bare @@ -759,6 +772,7 @@ func (m *Model) clearConversation() { m.curIdx = -1 m.convCount = -1 // transcript replaced — drop the cached prefix m.convPrefixRefs = nil + m.find = findState{} // nothing left to search m.stepLineIndex = nil m.turnStats = nil m.toolTotal = 0 @@ -928,6 +942,9 @@ func (m *Model) inputAreaHeight() int { if m.ac.open { h += m.ac.height() } + if m.find.open { + h++ // the one-row search strip above the input box + } return h } diff --git a/internal/tui/view.go b/internal/tui/view.go index e219d6d..68ee7b9 100644 --- a/internal/tui/view.go +++ b/internal/tui/view.go @@ -349,10 +349,12 @@ func (m *Model) conversation() string { } var refs []stepRef var turns []stepRef + var msgsIdx []stepRef collectTurn := func(i, line int) { if m.msgs[i].role == roleAsst && !m.msgs[i].raw { turns = append(turns, stepRef{msgIdx: i, stepIdx: -1, line: line}) } + msgsIdx = append(msgsIdx, stepRef{msgIdx: i, stepIdx: -1, line: line}) } lineOffset := 0 if m.convCount != tail { @@ -367,10 +369,12 @@ func (m *Model) conversation() string { m.convPrefix = strings.Join(blocks, "\n\n") m.convPrefixRefs = refs m.convPrefixTurn = turns + m.convPrefixMsgs = msgsIdx m.convCount = tail } else { refs = append(refs, m.convPrefixRefs...) turns = append(turns, m.convPrefixTurn...) + msgsIdx = append(msgsIdx, m.convPrefixMsgs...) if m.convPrefix != "" { lineOffset = lineCount(m.convPrefix) + 1 } @@ -391,6 +395,7 @@ func (m *Model) conversation() string { refs = append(refs, r...) lineOffset += lineCount(s) + 1 } + m.msgLineIndex = msgsIdx if len(m.notices) > 0 { if notes := m.renderNotices(); notes != "" { blocks = append(blocks, notes) @@ -829,6 +834,9 @@ func (m *Model) inputArea() string { return m.approvalPanel() } box := m.th.inputBox.Width(m.width - 2).Render(m.ta.View()) + if m.find.open { + return m.findBar() + "\n" + box + } if m.pal.open { return m.palPopup() + "\n" + box } From 6060fc5956c66f5661bc1ecadfe437a6d556eb3b Mon Sep 17 00:00:00 2001 From: Rolando Santamaria Maso Date: Sat, 29 Aug 2026 10:35:49 +0200 Subject: [PATCH 4/5] docs(readme): sync key bindings and commands with the tui alt+f find bar, ^Y OSC 52 copy, and the two-step confirm on ^L and /clear. --- README.md | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index f583f51..32c780d 100644 --- a/README.md +++ b/README.md @@ -129,14 +129,16 @@ by `odek serve` from its usual chain — `~/.odek/config.json` → `./odek.json` | `/` | Open the command palette (see below) | | `@` | Attach a file (see below) | | `alt+↑` / `alt+↓` | Jump to the previous / next turn | +| `alt+f` | Search the transcript (`⏎` next match · `N` previous) | | `^F` | Fold/unfold the most recent turn card (click any turn head with `--mouse`) | | `tab` | Open/close the latest reasoning block (live turns auto-expand) | | `^R` | Browse & resume saved sessions | | `^O` | Switch the model | | `^T` | Toggle extended thinking for the next turn | | `^J` | Insert a newline in the input | -| `^L` | Clear the conversation | +| `^L` | Clear the conversation (two-step confirm: `y` clears, any other key cancels) | | `^E` | Toggle tool details — every step expands to its full output/logs | +| `^Y` | Copy the last reply to the clipboard (OSC 52 — needs a supporting terminal) | | `Esc` | Cancel the running turn (queued prompts return to the input) | | `↑` / `↓` / `PgUp` / `PgDn` / `^U` / `^D` | Scroll the transcript (arrows at the input's edge lines) | | `^P` / `^N` | Recall previous prompts (prompt history) | @@ -167,7 +169,8 @@ command and press `⏎`. | Command | Action | |---------|--------| | `/help` | Show available commands and key bindings | -| `/clear` | Clear the conversation | +| `/clear` | Clear the conversation (two-step confirm; idle only) | +| `/copy` | Copy the last reply to the clipboard (OSC 52) | | `/stats` | Session metrics card (cost, cache, context gauge) | | `/server` | Cockpit — server, link, budget & session in one card (or click the header) | | `/sessions` | Browse, search, pin, rename, export & resume sessions | From ffe1c11bca1e61511d4692aba3c865f459d3cf05 Mon Sep 17 00:00:00 2001 From: Rolando Santamaria Maso Date: Sat, 29 Aug 2026 10:52:39 +0200 Subject: [PATCH 5/5] test(tui): cover find-bar routing, match kinds, and osc52 runner Every uncovered block in the UX round's new code now has a test: find-bar key routing (ctrl+c quit, backspace incl. the empty-query no-op, busy ctrl+l guard), per-kind matching across reasoning/reply items and step name/arg/result/logs, stale-match jumps, close idempotence, find-bar render states (hint, count, truncation), and the clipboardWrite runner (nil-writer guard, verbatim write, error propagation). All functions in find.go and clipboard.go are at 100% statement coverage; SetStdin/SetStderr stay empty-bodied (zero instrumentable statements) and are exercised by TestClipboardWriteRunner. --- internal/tui/clipboard_test.go | 33 +++++++++ internal/tui/find_test.go | 127 +++++++++++++++++++++++++++++++++ 2 files changed, 160 insertions(+) diff --git a/internal/tui/clipboard_test.go b/internal/tui/clipboard_test.go index 6c3c00e..e23f3be 100644 --- a/internal/tui/clipboard_test.go +++ b/internal/tui/clipboard_test.go @@ -2,6 +2,7 @@ package tui import ( "bytes" + "errors" "io" "strings" "testing" @@ -65,3 +66,35 @@ func TestCopyLastReplyGuards(t *testing.T) { t.Error("oversized reply returned nil cmd; want the refusal notice") } } + +// errWriter always fails, to exercise Run's error propagation. +type errWriter struct{} + +func (errWriter) Write(p []byte) (int, error) { return 0, errors.New("boom") } + +func TestClipboardWriteRunner(t *testing.T) { + c := &clipboardWrite{seq: "\x1b]52;c;aGk=\x07"} + + // Headless: no writer wired — the setters are no-ops and Run is silent. + c.SetStdin(nil) + c.SetStderr(io.Discard) + if err := c.Run(); err != nil { + t.Errorf("Run with no writer = %v, want nil", err) + } + + // Wired: the sequence lands verbatim on the terminal writer. + var buf bytes.Buffer + c.SetStdout(&buf) + if err := c.Run(); err != nil { + t.Fatalf("Run = %v, want nil", err) + } + if buf.String() != c.seq { + t.Errorf("Run wrote %q, want %q", buf.String(), c.seq) + } + + // A failing writer must surface its error, not swallow it. + c.SetStdout(errWriter{}) + if err := c.Run(); err == nil { + t.Error("Run swallowed the writer error") + } +} diff --git a/internal/tui/find_test.go b/internal/tui/find_test.go index c9704ed..5c743f4 100644 --- a/internal/tui/find_test.go +++ b/internal/tui/find_test.go @@ -136,3 +136,130 @@ func TestClearClosesFind(t *testing.T) { t.Errorf("find state not reset: %q %v", string(m.find.query), m.find.matches) } } + +func TestFindKeyRouting(t *testing.T) { + m := newTestModel() + seedConversation(m) + m.Update(key("alt+f")) + + // ctrl+c quits from the find bar like from anywhere else. + m.Update(key("ctrl+c")) + if !m.quitting { + t.Error("ctrl+c in the find bar did not quit") + } + + // Backspace pops the query and rescans; past the last rune it is a + // no-op that keeps the bar open. + m = newTestModel() + seedConversation(m) + m.Update(key("alt+f")) + m.Update(key("a")) + m.Update(key("b")) + if string(m.find.query) != "ab" { + t.Fatalf("query = %q, want %q", string(m.find.query), "ab") + } + m.Update(key("backspace")) + if string(m.find.query) != "a" { + t.Errorf("backspace did not pop the query: %q", string(m.find.query)) + } + if !m.find.open { + t.Error("backspace closed the find bar") + } + m.Update(key("backspace")) + m.Update(key("backspace")) // empty-query backspace + if len(m.find.query) != 0 || len(m.find.matches) != 0 { + t.Errorf("stale find state after emptying the query: %q %v", string(m.find.query), m.find.matches) + } + if !m.find.open { + t.Error("find bar did not survive an empty-query backspace") + } +} + +func TestFindCtrlLBusyGuard(t *testing.T) { + m := newTestModel() + seedConversation(m) + m.busy = true + m.Update(key("alt+f")) + m.Update(key("ctrl+l")) + if m.confirm != confirmNone { + t.Error("ctrl+l armed the clear confirm mid-turn") + } + if !m.find.open { + t.Error("busy ctrl+l closed the find bar") + } +} + +func TestCloseFindIdempotent(t *testing.T) { + m := newTestModel() + m.closeFind() // already closed: guard branch, must not panic + if m.find.open { + t.Error("closeFind opened the bar") + } +} + +func TestFindMsgMatchItemKinds(t *testing.T) { + tests := []struct { + name string + msg message + want bool + }{ + {"raw cards never match", message{raw: true, content: "needle"}, false}, + {"content match", message{content: "the NEEDLE here"}, true}, + {"thinking item match", message{items: []turnItem{{thinking: true, text: "hidden needle"}}}, true}, + {"reply item match", message{items: []turnItem{{reply: true, text: "spoken needle"}}}, true}, + {"non-prose item text ignored", message{items: []turnItem{{text: "needle"}}}, false}, + {"step name match", message{steps: []step{{name: "needle_tool"}}}, true}, + {"step arg match", message{steps: []step{{name: "shell", arg: "grep needle file"}}}, true}, + {"step result match", message{steps: []step{{name: "shell", result: "found the needle"}}}, true}, + {"step log match", message{steps: []step{{name: "shell", logs: []string{"spinning needle"}}}}, true}, + {"no match anywhere", message{content: "haystack", steps: []step{{name: "shell", result: "nothing", logs: []string{"quiet"}}}}, false}, + } + for _, tt := range tests { + if got := findMsgMatch(tt.msg, "needle"); got != tt.want { + t.Errorf("%s: findMsgMatch = %v, want %v", tt.name, got, tt.want) + } + } +} + +func TestMsgLineUnknownIndex(t *testing.T) { + m := newTestModel() + if got := m.msgLine(42); got != 0 { + t.Errorf("msgLine(unknown) = %d, want 0", got) + } + + // A stale match (message gone from the line index) must not panic: + // the jump parks at the top. + seedConversation(m) + m.refresh() + m.find = findState{open: true, query: []rune("x"), matches: []int{999}} + m.findGoto(1) + if m.vp.YOffset != 0 { + t.Errorf("stale jump moved the viewport to %d, want 0", m.vp.YOffset) + } +} + +func TestFindBarRenderStates(t *testing.T) { + m := newTestModel() + + // Empty query: hint text. + m.find = findState{open: true} + if got := plain(m.findBar()); !strings.Contains(got, "type to search") { + t.Errorf("empty-query bar: %q", got) + } + + // Query with matches: count line. + m.find.query = []rune("needle") + m.find.matches = []int{0, 2} + if got := plain(m.findBar()); !strings.Contains(got, "2 matches") { + t.Errorf("counted bar: %q", got) + } + + // Over-long query on a wide screen: truncated, never rendered whole. + m.width = 100 + long := strings.Repeat("q", 80) + m.find.query = []rune(long) + m.find.matches = nil + if got := plain(m.findBar()); strings.Contains(got, long) { + t.Error("over-long query rendered untruncated") + } +}