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
+112
View File
@@ -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 != "" {