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
|
inputMode InputMode
|
||||||
inputBuffer string
|
inputBuffer string
|
||||||
inputCursor int
|
inputCursor int
|
||||||
history map[string][]string
|
inputHistory []string // submitted lines across all contexts (chat, intent, in-session), newest last
|
||||||
historyIndex int
|
historyIndex int // -1 = editing the live buffer; else an index into inputHistory
|
||||||
savedBuffer string
|
savedBuffer string // live buffer stashed while walking history
|
||||||
|
|
||||||
// flow flags
|
// flow flags
|
||||||
sessionEntered bool
|
sessionEntered bool
|
||||||
@@ -303,6 +303,9 @@ type Model struct {
|
|||||||
|
|
||||||
// animation
|
// 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
|
// snapshot phase
|
||||||
snapshotPhase bool
|
snapshotPhase bool
|
||||||
@@ -338,7 +341,6 @@ func NewModel(client *ws.Client) Model {
|
|||||||
theme: NewTheme(SoftBlue),
|
theme: NewTheme(SoftBlue),
|
||||||
wfIndex: -1,
|
wfIndex: -1,
|
||||||
inputMode: ModeRouter,
|
inputMode: ModeRouter,
|
||||||
history: map[string][]string{},
|
|
||||||
historyIndex: -1,
|
historyIndex: -1,
|
||||||
routerMessages: map[string][]RouterEntry{},
|
routerMessages: map[string][]RouterEntry{},
|
||||||
configStaged: map[string]string{},
|
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{} })
|
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 {
|
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.
|
// 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:
|
case tickMsg:
|
||||||
m.frame++
|
m.frame++
|
||||||
|
if m.animating() {
|
||||||
return m, tick()
|
return m, tick()
|
||||||
|
}
|
||||||
|
m.ticking = false // loop stops; tickKick will restart it when animation resumes
|
||||||
|
return m, nil
|
||||||
|
|
||||||
case connMsg:
|
case connMsg:
|
||||||
m.applyConn(msg.s)
|
m.applyConn(msg.s)
|
||||||
return m, readConn(m.client)
|
return m, tea.Batch(readConn(m.client), m.tickKick())
|
||||||
|
|
||||||
case serverMsg:
|
case serverMsg:
|
||||||
m.applyServerPhased(msg.m)
|
m.applyServerPhased(msg.m)
|
||||||
return m, readServer(m.client)
|
return m, tea.Batch(readServer(m.client), m.tickKick())
|
||||||
|
|
||||||
case sessionsLoadedMsg:
|
case sessionsLoadedMsg:
|
||||||
m.applySessionsLoaded(msg)
|
m.applySessionsLoaded(msg)
|
||||||
@@ -62,7 +102,13 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
return m, nil
|
return m, nil
|
||||||
|
|
||||||
case tea.KeyMsg:
|
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
|
return m, nil
|
||||||
}
|
}
|
||||||
@@ -757,6 +803,7 @@ func (m Model) submit() (tea.Model, tea.Cmd) {
|
|||||||
// Intent line for a workflow being started.
|
// Intent line for a workflow being started.
|
||||||
if m.wfPendingID != "" {
|
if m.wfPendingID != "" {
|
||||||
id := m.wfPendingID
|
id := m.wfPendingID
|
||||||
|
m.recordInput(text)
|
||||||
m.client.Send(protocol.StartSession(id, text))
|
m.client.Send(protocol.StartSession(id, text))
|
||||||
m.wfPendingID = ""
|
m.wfPendingID = ""
|
||||||
m.wfPendingName = ""
|
m.wfPendingName = ""
|
||||||
@@ -788,6 +835,7 @@ func (m Model) submit() (tea.Model, tea.Cmd) {
|
|||||||
s.Name = "chat"
|
s.Name = "chat"
|
||||||
m.selectedID = id
|
m.selectedID = id
|
||||||
m.sessionEntered = true
|
m.sessionEntered = true
|
||||||
|
m.recordInput(text)
|
||||||
m.client.Send(protocol.StartChatSession(id, text))
|
m.client.Send(protocol.StartChatSession(id, text))
|
||||||
m.clearInput()
|
m.clearInput()
|
||||||
return m, nil
|
return m, nil
|
||||||
@@ -797,12 +845,7 @@ func (m Model) submit() (tea.Model, tea.Cmd) {
|
|||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
sid := m.selectedID
|
sid := m.selectedID
|
||||||
hist := m.history[sid]
|
m.recordInput(text)
|
||||||
hist = append(hist, text)
|
|
||||||
if len(hist) > 50 {
|
|
||||||
hist = hist[len(hist)-50:]
|
|
||||||
}
|
|
||||||
m.history[sid] = hist
|
|
||||||
// No optimistic echo — the USER turn arrives as a chat.turn event.
|
// No optimistic echo — the USER turn arrives as a chat.turn event.
|
||||||
m.client.Send(protocol.ChatInput(sid, text, m.chatMode))
|
m.client.Send(protocol.ChatInput(sid, text, m.chatMode))
|
||||||
m.clearInput()
|
m.clearInput()
|
||||||
@@ -907,8 +950,26 @@ func (m *Model) listNav(dir int) {
|
|||||||
m.wfIndex = -1
|
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() {
|
func (m *Model) historyPrev() {
|
||||||
hist := m.history[m.selectedID]
|
hist := m.inputHistory
|
||||||
if len(hist) == 0 {
|
if len(hist) == 0 {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -926,7 +987,7 @@ func (m *Model) historyPrev() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (m *Model) historyNext() {
|
func (m *Model) historyNext() {
|
||||||
hist := m.history[m.selectedID]
|
hist := m.inputHistory
|
||||||
switch {
|
switch {
|
||||||
case m.historyIndex == -1:
|
case m.historyIndex == -1:
|
||||||
return
|
return
|
||||||
|
|||||||
Reference in New Issue
Block a user