From 9143d554f14b559ec1e21db708597f6b9934df55 Mon Sep 17 00:00:00 2001 From: kami Date: Sun, 21 Jun 2026 09:33:16 +0000 Subject: [PATCH] feat(tui-go): global input history + stop idle redraws (copy-safe) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../tui-go/internal/app/input_history_test.go | 91 +++++++++++++++++++ apps/tui-go/internal/app/model.go | 12 ++- apps/tui-go/internal/app/update.go | 89 +++++++++++++++--- 3 files changed, 173 insertions(+), 19 deletions(-) create mode 100644 apps/tui-go/internal/app/input_history_test.go diff --git a/apps/tui-go/internal/app/input_history_test.go b/apps/tui-go/internal/app/input_history_test.go new file mode 100644 index 00000000..3b741567 --- /dev/null +++ b/apps/tui-go/internal/app/input_history_test.go @@ -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)") + } +} diff --git a/apps/tui-go/internal/app/model.go b/apps/tui-go/internal/app/model.go index 6ac6168f..870c72d8 100644 --- a/apps/tui-go/internal/app/model.go +++ b/apps/tui-go/internal/app/model.go @@ -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 @@ -302,7 +302,10 @@ type Model struct { paletteIndex int // animation - frame int // tick counter; drives spinner + caret blink + 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{}, diff --git a/apps/tui-go/internal/app/update.go b/apps/tui-go/internal/app/update.go index b26687a3..5dc6e10f 100644 --- a/apps/tui-go/internal/app/update.go +++ b/apps/tui-go/internal/app/update.go @@ -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++ - return m, tick() + 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