diff --git a/apps/tui-go/go.mod b/apps/tui-go/go.mod index 84392ea9..0a9c3c3e 100644 --- a/apps/tui-go/go.mod +++ b/apps/tui-go/go.mod @@ -3,6 +3,7 @@ module github.com/correx/tui-go go 1.24.2 require ( + github.com/aymanbagabas/go-osc52/v2 v2.0.1 github.com/charmbracelet/bubbletea v1.3.10 github.com/charmbracelet/glamour v1.0.0 github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 @@ -13,7 +14,6 @@ require ( require ( github.com/alecthomas/chroma/v2 v2.20.0 // indirect - github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/aymerick/douceur v0.2.0 // indirect github.com/charmbracelet/colorprofile v0.4.1 // indirect github.com/charmbracelet/x/cellbuf v0.0.15 // indirect diff --git a/apps/tui-go/internal/app/model.go b/apps/tui-go/internal/app/model.go index edb7bd73..ec2d715e 100644 --- a/apps/tui-go/internal/app/model.go +++ b/apps/tui-go/internal/app/model.go @@ -241,6 +241,11 @@ type Model struct { routerMessages map[string][]RouterEntry routerConnected bool chatMode string + // transcriptSel is the index (into the selected session's transcript) of the message + // the operator has navigated to with ctrl+↑/↓; -1 = no selection (tail-follow). A + // selected message is highlighted, scrolled into view, and is what `y` copies. + transcriptSel int + copiedFlash int // frame at which a copy happened, for a brief "copied" footer note // provider currentModel string @@ -353,6 +358,7 @@ func NewModel(client *ws.Client) Model { providerType: "LOCAL", snapshotPhase: true, eventStripShown: true, + transcriptSel: -1, } } diff --git a/apps/tui-go/internal/app/transcript_test.go b/apps/tui-go/internal/app/transcript_test.go new file mode 100644 index 00000000..073a99b7 --- /dev/null +++ b/apps/tui-go/internal/app/transcript_test.go @@ -0,0 +1,67 @@ +package app + +import "testing" + +func transcriptModel(roles ...string) Model { + m := NewModel(nil) + m.selectedID = "s1" + msgs := make([]RouterEntry, len(roles)) + for i, r := range roles { + msgs[i] = RouterEntry{Role: r, Content: r + itoa(i)} + } + m.routerMessages["s1"] = msgs + return m +} + +// ctrl+↑/↓ jumps between USER messages: from no selection ↑ grabs the most recent user +// message, keeps stepping to older ones (clamping), and ↓ steps back down, dropping to +// tail-follow (-1) once past the last user message. +func TestTranscriptNavUser(t *testing.T) { + // indices: 0 1 2 3 4 + m := transcriptModel("user", "router", "user", "router", "user") + + if m.transcriptSel != -1 { + t.Fatalf("initial sel = %d, want -1", m.transcriptSel) + } + m.transcriptNavUser(-1) // up from bottom → newest user (idx 4) + if m.transcriptSel != 4 { + t.Fatalf("up#1 = %d, want 4", m.transcriptSel) + } + m.transcriptNavUser(-1) // → user idx 2 + if m.transcriptSel != 2 { + t.Fatalf("up#2 = %d, want 2", m.transcriptSel) + } + m.transcriptNavUser(-1) // → user idx 0 + if m.transcriptSel != 0 { + t.Fatalf("up#3 = %d, want 0", m.transcriptSel) + } + m.transcriptNavUser(-1) // clamp at oldest + if m.transcriptSel != 0 { + t.Fatalf("up#4 (clamp) = %d, want 0", m.transcriptSel) + } + m.transcriptNavUser(1) // → user idx 2 + if m.transcriptSel != 2 { + t.Fatalf("down#1 = %d, want 2", m.transcriptSel) + } + m.transcriptNavUser(1) // → user idx 4 + m.transcriptNavUser(1) // past last user → tail-follow + if m.transcriptSel != -1 { + t.Fatalf("down past last = %d, want -1 (tail-follow)", m.transcriptSel) + } +} + +// `y` copies the selected message, or the latest user/router turn when nothing is selected. +func TestCopyText(t *testing.T) { + m := transcriptModel("user", "router", "tool") + + // no selection → most recent user/router turn (the "router" at idx 1, since "tool" + // is skipped). + if got := m.copyText(); got != "router1" { + t.Fatalf("copyText (no sel) = %q, want %q", got, "router1") + } + + m.transcriptSel = 0 + if got := m.copyText(); got != "user0" { + t.Fatalf("copyText (sel idx 0) = %q, want %q", got, "user0") + } +} diff --git a/apps/tui-go/internal/app/update.go b/apps/tui-go/internal/app/update.go index d2c4c415..1c16739f 100644 --- a/apps/tui-go/internal/app/update.go +++ b/apps/tui-go/internal/app/update.go @@ -1,9 +1,12 @@ package app import ( + "fmt" + "os" "strings" "time" + osc52 "github.com/aymanbagabas/go-osc52/v2" tea "github.com/charmbracelet/bubbletea" "github.com/correx/tui-go/internal/protocol" "github.com/correx/tui-go/internal/ws" @@ -15,6 +18,9 @@ type serverMsg struct{ m protocol.ServerMessage } type connMsg struct{ s ws.Status } type tickMsg struct{} +// copiedFlashFrames is how many ticks the "✓ copied" footer note lingers after a yank. +const copiedFlashFrames = 12 + func readServer(c *ws.Client) tea.Cmd { return func() tea.Msg { return serverMsg{<-c.Incoming()} } } @@ -48,6 +54,9 @@ func (m Model) animating() bool { if m.overlay == OverlayPalette { // the palette shows a blinking filter caret return true } + if m.copiedFlash != 0 && m.frame-m.copiedFlash < copiedFlashFrames { + return true // keep ticking briefly so the "copied" note animates out + } for i := range m.sessions { if m.sessions[i].Active { return true @@ -182,6 +191,18 @@ func (m Model) handleKey(k tea.KeyMsg) (tea.Model, tea.Cmd) { if m.displayState() == StateWorkflowPropose { return m.handleProposeKey(k) } + // Transcript message-jump works in any edit mode while in-session, so you can scroll + // back through your sent messages without leaving the input. ctrl+↑ older, ctrl+↓ newer. + if m.displayState() == StateInSession { + switch k.Type { + case tea.KeyCtrlUp: + m.transcriptNavUser(-1) + return m, nil + case tea.KeyCtrlDown: + m.transcriptNavUser(1) + return m, nil + } + } if m.editMode == ModeInsert { return m.handleInsertKey(k) } @@ -207,6 +228,12 @@ func (m Model) handleNormalKey(k tea.KeyMsg) (tea.Model, tea.Cmd) { case tea.KeyEnter: return m.normalEnter() case tea.KeyEsc: + // In-session, esc first drops a transcript selection (back to tail-follow); + // otherwise it clears the session-list filter. + if m.transcriptSel >= 0 { + m.transcriptSel = -1 + return m, nil + } m.filter = "" return m, nil case tea.KeyCtrlX: @@ -265,6 +292,13 @@ func (m Model) handleNormalKey(k tea.KeyMsg) (tea.Model, tea.Cmd) { } case "s": m.cycleChatMode() + case "y": + // Copy the selected message (or the latest assistant turn if none is selected) + // to the system clipboard over OSC52 — SSH-safe, unlike a mouse drag. + if cmd := m.copyMessage(); cmd != nil { + m.copiedFlash = m.frame + return m, cmd + } case "c": if m.selectedID != "" { m.client.Send(protocol.CancelSession(m.selectedID)) @@ -959,6 +993,7 @@ func (m *Model) listNav(dir int) { } if m.selectedID != list[n].ID { m.bgUpdates = 0 + m.transcriptSel = -1 // a different session's transcript: drop the stale selection } m.selectedID = list[n].ID m.wfIndex = -1 @@ -1019,6 +1054,83 @@ func (m *Model) appendRouter(sid string, e RouterEntry) { m.routerMessages[sid] = append(m.routerMessages[sid], e) } +// transcriptNavUser moves the transcript selection to the previous (dir<0) or next +// (dir>0) USER message — "go back to my previous message". From no selection, ctrl+↑ +// grabs the most recent user message; ctrl+↓ past the last one drops back to tail-follow. +// Messages only ever append, so a stored index stays pointing at the same message. +func (m *Model) transcriptNavUser(dir int) { + msgs := m.routerMessages[m.selectedID] + n := len(msgs) + if n == 0 { + return + } + if dir < 0 { + start := m.transcriptSel + if start < 0 || start > n { + start = n // searching upward from the bottom + } + for i := start - 1; i >= 0; i-- { + if msgs[i].Role == "user" { + m.transcriptSel = i + return + } + } + return // already at the oldest user message + } + if m.transcriptSel < 0 { + return // already tailing the bottom + } + for i := m.transcriptSel + 1; i < n; i++ { + if msgs[i].Role == "user" { + m.transcriptSel = i + return + } + } + m.transcriptSel = -1 // past the last user message → resume tail-follow +} + +// copyMessage returns a command that copies the selected message — or, with no +// selection, the latest assistant/router turn — to the system clipboard via OSC52. +// Returns nil when there is nothing to copy. +func (m Model) copyMessage() tea.Cmd { + if text := m.copyText(); strings.TrimSpace(text) != "" { + return osc52Copy(text) + } + return nil +} + +// copyText is the content `y` will yank: the selected message, or — with no selection — +// the most recent user/router turn. Empty when there is nothing to copy. +func (m Model) copyText() string { + msgs := m.routerMessages[m.selectedID] + if len(msgs) == 0 { + return "" + } + if m.transcriptSel >= 0 && m.transcriptSel < len(msgs) { + return msgs[m.transcriptSel].Content + } + for i := len(msgs) - 1; i >= 0; i-- { + if r := msgs[i].Role; r == "router" || r == "user" { + return msgs[i].Content + } + } + return "" +} + +// osc52Copy writes the clipboard sequence to stderr (separate from the alt-screen on +// stdout). Wrapped for tmux passthrough when running inside tmux so the copy still +// reaches the outer terminal (e.g. over SSH / Termius). +func osc52Copy(text string) tea.Cmd { + return func() tea.Msg { + seq := osc52.New(text) + if os.Getenv("TMUX") != "" { + seq = seq.Tmux() + } + fmt.Fprint(os.Stderr, seq) + return nil + } +} + // currentDiff returns the most recent tool diff in the selected transcript. func (m Model) currentDiff() string { if s := m.session(m.selectedID); s != nil && s.Pending != nil && s.Pending.Preview != "" { diff --git a/apps/tui-go/internal/app/view.go b/apps/tui-go/internal/app/view.go index acbd5b9d..75e3d568 100644 --- a/apps/tui-go/internal/app/view.go +++ b/apps/tui-go/internal/app/view.go @@ -244,9 +244,12 @@ func (m Model) renderFooter() string { } default: // StateInSession if nrw { - hints = []string{hint("i", "msg"), hint("e", "events"), hint("S", "stats"), hint("l", "back"), hint("p", "cmds"), hint("q", "quit")} + hints = []string{hint("i", "msg"), hint("^↑↓", "msgs"), hint("y", "copy"), hint("e", "events"), hint("l", "back"), hint("p", "cmds"), hint("q", "quit")} } else { - hints = []string{hint("i", "message"), hint("e", "events"), hint("t", "tools"), hint("S", "stats"), hint("m", "model"), hint("s", "mode"), hint("l", "back"), hint("p", "cmds"), hint("q", "quit")} + hints = []string{hint("i", "message"), hint("^↑↓", "msgs"), hint("y", "copy"), hint("e", "events"), hint("t", "tools"), hint("S", "stats"), hint("m", "model"), hint("l", "back"), hint("p", "cmds"), hint("q", "quit")} + } + if m.copiedFlash != 0 && m.frame-m.copiedFlash < copiedFlashFrames { + hints = append(hints, lipgloss.NewStyle().Foreground(t.P.OK).Background(bg).Bold(true).Render("✓ copied")) } if s := m.session(m.selectedID); s != nil && s.Pending != nil { hints = append(hints, hint("a", "approval (pending)")) @@ -488,9 +491,18 @@ func (m Model) routerRows(w, h int) []string { return []string{t.span("awaiting router response…", t.P.Faint)} } var rows []string - for _, e := range msgs { + msgStart := make([]int, len(msgs)) // first row index of each message, for scroll-to-selection + for mi, e := range msgs { + msgStart[mi] = len(rows) switch e.Role { case "user": + if mi == m.transcriptSel { + // Selected message: an inverted tag + a highlighted background bar so it's + // unmistakable which one ctrl+↑/↓ landed on (and what `y` will copy). + tag := lipgloss.NewStyle().Foreground(t.P.BgDeep).Background(t.P.Accent).Bold(true).Render(" › ") + rows = append(rows, tag+" "+lipgloss.NewStyle().Foreground(t.P.FgStrong).Background(t.P.Sel).Render(e.Content)) + break + } tag := lipgloss.NewStyle().Foreground(t.P.Accent).Background(t.P.Bg).Bold(true).Render("›") rows = append(rows, tag+" "+t.span(e.Content, t.P.FgStrong)) case "router": @@ -525,7 +537,18 @@ func (m Model) routerRows(w, h int) []string { rows = append(rows, t.span(e.Content, t.P.Dim)) } } - // keep last h rows + // With a selection, scroll so the selected message is visible (top-aligned, clamped + // to the end); otherwise tail-follow the newest output. + if m.transcriptSel >= 0 && m.transcriptSel < len(msgStart) && len(rows) > h { + start := msgStart[m.transcriptSel] + if start > len(rows)-h { + start = len(rows) - h + } + if start < 0 { + start = 0 + } + return rows[start : start+h] + } if len(rows) > h { rows = rows[len(rows)-h:] }