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