feat(tui-go): transcript message-jump + OSC52 copy

ctrl+↑/↓ jumps between your sent (user) messages in the transcript: ctrl+↑ from the
bottom selects the most recent, steps to older ones (clamping); ctrl+↓ steps back and
drops to tail-follow past the last. The selected message is highlighted and scrolled
into view (routerRows tracks per-message row offsets and windows to the selection).

`y` yanks the selected message — or the latest turn when none is selected — to the
system clipboard over OSC52 (go-osc52, tmux-wrapped under $TMUX), which is SSH/Termius-
safe unlike a mouse drag. A brief "✓ copied" footer note animates out (and self-stops
the tick). esc drops the selection; switching sessions clears it. Tests cover the
user-message jump/clamp/tail and the copy-text selection.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-21 09:59:29 +00:00
parent 26621567ac
commit ba7ac06c16
5 changed files with 213 additions and 5 deletions
+1 -1
View File
@@ -3,6 +3,7 @@ module github.com/correx/tui-go
go 1.24.2 go 1.24.2
require ( require (
github.com/aymanbagabas/go-osc52/v2 v2.0.1
github.com/charmbracelet/bubbletea v1.3.10 github.com/charmbracelet/bubbletea v1.3.10
github.com/charmbracelet/glamour v1.0.0 github.com/charmbracelet/glamour v1.0.0
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834
@@ -13,7 +14,6 @@ require (
require ( require (
github.com/alecthomas/chroma/v2 v2.20.0 // indirect 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/aymerick/douceur v0.2.0 // indirect
github.com/charmbracelet/colorprofile v0.4.1 // indirect github.com/charmbracelet/colorprofile v0.4.1 // indirect
github.com/charmbracelet/x/cellbuf v0.0.15 // indirect github.com/charmbracelet/x/cellbuf v0.0.15 // indirect
+6
View File
@@ -241,6 +241,11 @@ type Model struct {
routerMessages map[string][]RouterEntry routerMessages map[string][]RouterEntry
routerConnected bool routerConnected bool
chatMode string 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 // provider
currentModel string currentModel string
@@ -353,6 +358,7 @@ func NewModel(client *ws.Client) Model {
providerType: "LOCAL", providerType: "LOCAL",
snapshotPhase: true, snapshotPhase: true,
eventStripShown: true, eventStripShown: true,
transcriptSel: -1,
} }
} }
@@ -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")
}
}
+112
View File
@@ -1,9 +1,12 @@
package app package app
import ( import (
"fmt"
"os"
"strings" "strings"
"time" "time"
osc52 "github.com/aymanbagabas/go-osc52/v2"
tea "github.com/charmbracelet/bubbletea" tea "github.com/charmbracelet/bubbletea"
"github.com/correx/tui-go/internal/protocol" "github.com/correx/tui-go/internal/protocol"
"github.com/correx/tui-go/internal/ws" "github.com/correx/tui-go/internal/ws"
@@ -15,6 +18,9 @@ type serverMsg struct{ m protocol.ServerMessage }
type connMsg struct{ s ws.Status } type connMsg struct{ s ws.Status }
type tickMsg struct{} 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 { func readServer(c *ws.Client) tea.Cmd {
return func() tea.Msg { return serverMsg{<-c.Incoming()} } 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 if m.overlay == OverlayPalette { // the palette shows a blinking filter caret
return true 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 { for i := range m.sessions {
if m.sessions[i].Active { if m.sessions[i].Active {
return true return true
@@ -182,6 +191,18 @@ func (m Model) handleKey(k tea.KeyMsg) (tea.Model, tea.Cmd) {
if m.displayState() == StateWorkflowPropose { if m.displayState() == StateWorkflowPropose {
return m.handleProposeKey(k) 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 { if m.editMode == ModeInsert {
return m.handleInsertKey(k) return m.handleInsertKey(k)
} }
@@ -207,6 +228,12 @@ func (m Model) handleNormalKey(k tea.KeyMsg) (tea.Model, tea.Cmd) {
case tea.KeyEnter: case tea.KeyEnter:
return m.normalEnter() return m.normalEnter()
case tea.KeyEsc: 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 = "" m.filter = ""
return m, nil return m, nil
case tea.KeyCtrlX: case tea.KeyCtrlX:
@@ -265,6 +292,13 @@ func (m Model) handleNormalKey(k tea.KeyMsg) (tea.Model, tea.Cmd) {
} }
case "s": case "s":
m.cycleChatMode() 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": case "c":
if m.selectedID != "" { if m.selectedID != "" {
m.client.Send(protocol.CancelSession(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 { if m.selectedID != list[n].ID {
m.bgUpdates = 0 m.bgUpdates = 0
m.transcriptSel = -1 // a different session's transcript: drop the stale selection
} }
m.selectedID = list[n].ID m.selectedID = list[n].ID
m.wfIndex = -1 m.wfIndex = -1
@@ -1019,6 +1054,83 @@ func (m *Model) appendRouter(sid string, e RouterEntry) {
m.routerMessages[sid] = append(m.routerMessages[sid], e) 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. // currentDiff returns the most recent tool diff in the selected transcript.
func (m Model) currentDiff() string { func (m Model) currentDiff() string {
if s := m.session(m.selectedID); s != nil && s.Pending != nil && s.Pending.Preview != "" { if s := m.session(m.selectedID); s != nil && s.Pending != nil && s.Pending.Preview != "" {
+27 -4
View File
@@ -244,9 +244,12 @@ func (m Model) renderFooter() string {
} }
default: // StateInSession default: // StateInSession
if nrw { 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 { } 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 { if s := m.session(m.selectedID); s != nil && s.Pending != nil {
hints = append(hints, hint("a", "approval (pending)")) 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)} return []string{t.span("awaiting router response…", t.P.Faint)}
} }
var rows []string 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 { switch e.Role {
case "user": 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("") 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)) rows = append(rows, tag+" "+t.span(e.Content, t.P.FgStrong))
case "router": case "router":
@@ -525,7 +537,18 @@ func (m Model) routerRows(w, h int) []string {
rows = append(rows, t.span(e.Content, t.P.Dim)) 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 { if len(rows) > h {
rows = rows[len(rows)-h:] rows = rows[len(rows)-h:]
} }