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:
@@ -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 != "" {
|
||||
|
||||
Reference in New Issue
Block a user