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