feat(tui-go): global input history + stop idle redraws (copy-safe)

Input history is now a single global ring (recordInput) shared by every input surface —
free-chat, the workflow-intent line, and in-session chat — so ↑/↓ recalls anything you've
sent, including outside a session (previously per-session, so idle/intent had none).

Copy fix: the 120ms animation tick now only runs while something actually animates
(animating(): active session spinner, blinking caret, reconnect). Init no longer starts it
unconditionally; tickKick (re)starts it on demand and tickMsg stops it when idle. An idle
screen no longer auto-redraws, so a native terminal text selection survives instead of being
wiped every frame. Tests cover the ring (dedup/cap/cross-context walk) and the idle gate.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-21 09:33:16 +00:00
parent 7d4e203429
commit 9143d554f1
3 changed files with 173 additions and 19 deletions
@@ -0,0 +1,91 @@
package app
import "testing"
// History is a single global ring shared by every input surface (free-chat, the
// workflow-intent line, in-session chat), so ↑/↓ recalls anything you've sent — including
// outside a session, which the per-session map could not do.
func TestRecordInputGlobalRing(t *testing.T) {
m := NewModel(nil)
m.recordInput("first")
m.recordInput(" second ") // trimmed
m.recordInput("second") // immediate dup ignored
m.recordInput("") // blank ignored
m.recordInput(" ") // blank-after-trim ignored
want := []string{"first", "second"}
if len(m.inputHistory) != len(want) {
t.Fatalf("inputHistory = %q, want %q", m.inputHistory, want)
}
for i := range want {
if m.inputHistory[i] != want[i] {
t.Fatalf("inputHistory[%d] = %q, want %q", i, m.inputHistory[i], want[i])
}
}
}
func TestRecordInputCaps(t *testing.T) {
m := NewModel(nil)
for i := 0; i < 250; i++ {
m.recordInput(string(rune('a'+i%26)) + itoa(i))
}
if len(m.inputHistory) != 100 {
t.Fatalf("ring len = %d, want 100 (capped)", len(m.inputHistory))
}
}
// (itoa is defined in view.go and reused here for the cap test.)
// ↑/↓ walks the ring with no session selected (selectedID == ""), proving history
// recall works in free-chat / intent contexts, not just in-session.
func TestHistoryWalkWithoutSession(t *testing.T) {
m := NewModel(nil)
m.recordInput("one")
m.recordInput("two")
m.inputBuffer = "draft"
m.historyPrev() // newest first
if m.inputBuffer != "two" {
t.Fatalf("prev#1 = %q, want %q", m.inputBuffer, "two")
}
m.historyPrev()
if m.inputBuffer != "one" {
t.Fatalf("prev#2 = %q, want %q", m.inputBuffer, "one")
}
m.historyPrev() // clamp at oldest
if m.inputBuffer != "one" {
t.Fatalf("prev#3 (clamp) = %q, want %q", m.inputBuffer, "one")
}
m.historyNext()
if m.inputBuffer != "two" {
t.Fatalf("next#1 = %q, want %q", m.inputBuffer, "two")
}
m.historyNext() // back to the stashed live draft
if m.inputBuffer != "draft" {
t.Fatalf("next#2 = %q, want restored draft %q", m.inputBuffer, "draft")
}
}
// (animating + caps tests below.)
// animating() gates the redraw tick: it must be FALSE on an idle screen (normal mode,
// no active session) so terminal selection survives, and TRUE while typing or spinning.
func TestAnimatingGatesIdleRedraw(t *testing.T) {
m := NewModel(nil)
m.editMode = ModeNormal
if m.animating() {
t.Fatal("idle (normal mode, no active session) must NOT animate — else selection is wiped")
}
m.editMode = ModeInsert
if !m.animating() {
t.Fatal("insert mode must animate (blinking caret)")
}
m.editMode = ModeNormal
m.sessions = []Session{{ID: "s1", Active: true}}
if !m.animating() {
t.Fatal("an active session must animate (spinner)")
}
}
+6 -4
View File
@@ -228,9 +228,9 @@ type Model struct {
inputMode InputMode
inputBuffer string
inputCursor int
history map[string][]string
historyIndex int
savedBuffer string
inputHistory []string // submitted lines across all contexts (chat, intent, in-session), newest last
historyIndex int // -1 = editing the live buffer; else an index into inputHistory
savedBuffer string // live buffer stashed while walking history
// flow flags
sessionEntered bool
@@ -303,6 +303,9 @@ type Model struct {
// animation
frame int // tick counter; drives spinner + caret blink
ticking bool // true while a tick loop is scheduled. The loop is gated on animating()
// so an idle screen stops redrawing — which lets native terminal selection survive
// (the 120ms redraw used to wipe a mouse drag every frame).
// snapshot phase
snapshotPhase bool
@@ -338,7 +341,6 @@ func NewModel(client *ws.Client) Model {
theme: NewTheme(SoftBlue),
wfIndex: -1,
inputMode: ModeRouter,
history: map[string][]string{},
historyIndex: -1,
routerMessages: map[string][]RouterEntry{},
configStaged: map[string]string{},
+74 -13
View File
@@ -27,9 +27,45 @@ func tick() tea.Cmd {
return tea.Tick(120*time.Millisecond, func(time.Time) tea.Msg { return tickMsg{} })
}
// Init starts the channel readers and the cursor-blink tick.
// Init starts the channel readers. The animation tick is NOT started here — it is
// kicked lazily by tickKick once something actually needs to animate (an active
// session's spinner, a blinking caret), so an idle screen never auto-redraws.
func (m Model) Init() tea.Cmd {
return tea.Batch(readServer(m.client), readConn(m.client), tick())
return tea.Batch(readServer(m.client), readConn(m.client))
}
// animating reports whether anything on screen needs the frame counter to advance:
// a spinning session, a blinking caret (any typing surface), or the reconnect state.
// When false, the tick loop stops and the screen holds still — so a native terminal
// text selection survives instead of being wiped by the next redraw.
func (m Model) animating() bool {
if m.editMode == ModeInsert || m.steering || m.clarTyping || m.proposeTyping || m.configEditing {
return true
}
if m.reconnecting {
return true
}
if m.overlay == OverlayPalette { // the palette shows a blinking filter caret
return true
}
for i := range m.sessions {
if m.sessions[i].Active {
return true
}
}
return false
}
// tickKick starts the tick loop iff animation is needed and no loop is already
// running. Returns nil otherwise. The single-loop invariant: ticking is true exactly
// while a loop is scheduled (set here, cleared by tickMsg when it stops), so this
// never spawns a second concurrent loop.
func (m *Model) tickKick() tea.Cmd {
if m.animating() && !m.ticking {
m.ticking = true
return tick()
}
return nil
}
// Update is the single reducer. View renders purely from the returned model.
@@ -41,15 +77,19 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case tickMsg:
m.frame++
if m.animating() {
return m, tick()
}
m.ticking = false // loop stops; tickKick will restart it when animation resumes
return m, nil
case connMsg:
m.applyConn(msg.s)
return m, readConn(m.client)
return m, tea.Batch(readConn(m.client), m.tickKick())
case serverMsg:
m.applyServerPhased(msg.m)
return m, readServer(m.client)
return m, tea.Batch(readServer(m.client), m.tickKick())
case sessionsLoadedMsg:
m.applySessionsLoaded(msg)
@@ -62,7 +102,13 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, nil
case tea.KeyMsg:
return m.handleKey(msg)
next, cmd := m.handleKey(msg)
// A keypress may have entered a typing/active state (insert mode, steering, …);
// kick the tick so the caret/spinner animates again.
if nm, ok := next.(Model); ok {
return nm, tea.Batch(cmd, nm.tickKick())
}
return next, cmd
}
return m, nil
}
@@ -757,6 +803,7 @@ func (m Model) submit() (tea.Model, tea.Cmd) {
// Intent line for a workflow being started.
if m.wfPendingID != "" {
id := m.wfPendingID
m.recordInput(text)
m.client.Send(protocol.StartSession(id, text))
m.wfPendingID = ""
m.wfPendingName = ""
@@ -788,6 +835,7 @@ func (m Model) submit() (tea.Model, tea.Cmd) {
s.Name = "chat"
m.selectedID = id
m.sessionEntered = true
m.recordInput(text)
m.client.Send(protocol.StartChatSession(id, text))
m.clearInput()
return m, nil
@@ -797,12 +845,7 @@ func (m Model) submit() (tea.Model, tea.Cmd) {
return m, nil
}
sid := m.selectedID
hist := m.history[sid]
hist = append(hist, text)
if len(hist) > 50 {
hist = hist[len(hist)-50:]
}
m.history[sid] = hist
m.recordInput(text)
// No optimistic echo — the USER turn arrives as a chat.turn event.
m.client.Send(protocol.ChatInput(sid, text, m.chatMode))
m.clearInput()
@@ -907,8 +950,26 @@ func (m *Model) listNav(dir int) {
m.wfIndex = -1
}
// recordInput appends a submitted line to the global history ring (shared across
// free-chat, the workflow-intent line, and in-session chat), deduping the immediate
// repeat and capping the ring. Called from every submit path so ↑/↓ recalls anything
// you've sent, in any input context.
func (m *Model) recordInput(text string) {
text = strings.TrimSpace(text)
if text == "" {
return
}
if n := len(m.inputHistory); n > 0 && m.inputHistory[n-1] == text {
return
}
m.inputHistory = append(m.inputHistory, text)
if len(m.inputHistory) > 100 {
m.inputHistory = m.inputHistory[len(m.inputHistory)-100:]
}
}
func (m *Model) historyPrev() {
hist := m.history[m.selectedID]
hist := m.inputHistory
if len(hist) == 0 {
return
}
@@ -926,7 +987,7 @@ func (m *Model) historyPrev() {
}
func (m *Model) historyNext() {
hist := m.history[m.selectedID]
hist := m.inputHistory
switch {
case m.historyIndex == -1:
return