From 6019404644dd1d4e6779fa2a61cc138195baf1aa Mon Sep 17 00:00:00 2001 From: patriceckhart Date: Tue, 21 Apr 2026 18:09:10 +0200 Subject: [PATCH] fix(tui): blinking cursor in /btw, proper idle redraws, tighter status bar Three related tweaks to how the interactive mode drives its redraw loop, the terminal cursor, and the status-bar layout. 1) Redraw-on-tick narrowed to things that actually animate. The render loop used to force a redraw every 120ms for every open dialog (model picker, jump, sessions, btw, etc). Most of those are static pickers \u2014 the repaint was wasted and had a visible side effect: the re-emitted hide-cursor / show-cursor pair at the start of each frame cancels the terminal's blink cycle for any dialog that hosts its own input field. Concretely the blinking cursor in /btw never blinked, it just sat as a steady reverse-video block. Tick-driven redraw is now only triggered when i.busy (main spinner animates) or btw.Loading() (side-chat spinner animates). Static dialogs rely on the dirty-channel invalidations that fire on key events, which is sufficient because nothing else on screen is moving. 2) btw side-chat redraws on completion. Consequence of (1): the btw goroutine that streams a model response used to depend on the 120ms tick to make the final answer visible. With the tick gone for idle dialogs, the answer landed in d.turns but the screen stayed blank until the user pressed a key. btwDialog.Open and submit now take an invalidate callback that the goroutine fires after completeTurn, including the early-error branch. While the request is in flight, btw.Loading() returns true so the tick keeps the spinner animating; once the answer lands, a single invalidate() redraws and the tick stops. 3) Cursor routed into btw while it's open. btwDialog already had a CursorPos(width) method that returns where the side-chat editor's caret sits within the dialog's rendered rows. The host never used it \u2014 the real terminal cursor stayed on the main editor below. Now when btw is active the host picks up CursorPos and points the terminal cursor there, so the blink shows in the correct input and the main editor has no cursor. 4) Status bar idle-path spacing. Idle row used to render as "(provider) model" = 4 spaces before the model, so the line started at column 4 while busy and idle didn't match. Dropped one pad in the idle branch; both paths now start at column 2, aligned with the conversation column (' you' / ' zot' message markers). No semantic change to transcripts or provider calls. All tests pass. --- internal/agent/modes/btw_dialog.go | 39 +++++++++++++++++++++++------ internal/agent/modes/interactive.go | 29 +++++++++++++++++---- internal/tui/view.go | 10 +++++--- 3 files changed, 63 insertions(+), 15 deletions(-) diff --git a/internal/agent/modes/btw_dialog.go b/internal/agent/modes/btw_dialog.go index dfa4a76..31ce73b 100644 --- a/internal/agent/modes/btw_dialog.go +++ b/internal/agent/modes/btw_dialog.go @@ -73,11 +73,27 @@ func (d *btwDialog) Active() bool { return d.active } +// Loading reports whether the dialog is currently awaiting a +// model response (and therefore rendering an animated spinner). +// Used by the host to decide whether a periodic redraw is worth +// triggering; when false and the user is just typing, we can +// skip the tick and let the terminal drive the cursor blink. +func (d *btwDialog) Loading() bool { + if d == nil { + return false + } + d.mu.Lock() + defer d.mu.Unlock() + return d.active && d.loading +} + // Open enters the side chat. agent supplies the live transcript and // system prompt, plus the underlying provider client to use for the // one-off completion. seed is an optional first question that gets -// auto-submitted (so /btw behaves like the reference). -func (d *btwDialog) Open(th tui.Theme, agent *core.Agent, system, model, seed string) { +// auto-submitted (so /btw starts a conversation right away). +// invalidate, if non-nil, is called after each state change so the +// host redraw loop can pick up the update without polling. +func (d *btwDialog) Open(th tui.Theme, agent *core.Agent, system, model, seed string, invalidate func()) { d.mu.Lock() d.active = true d.theme = th @@ -93,7 +109,7 @@ func (d *btwDialog) Open(th tui.Theme, agent *core.Agent, system, model, seed st if seed = strings.TrimSpace(seed); seed != "" { d.editor.SetValue(seed) - d.submit() + d.submit(invalidate) } } @@ -151,14 +167,17 @@ func (d *btwDialog) HandleKey(k tui.Key, invalidate func()) (closed bool) { submitted := editor.HandleKey(k) invalidate() if submitted && !loading { - d.submit() + d.submit(invalidate) } return false } // submit fires the LLM call for the current input and, on success, -// appends a new turn to d.turns. -func (d *btwDialog) submit() { +// appends a new turn to d.turns. invalidate is called every time +// the turn's visible state changes (text delta, error, complete) +// so the host redraw loop picks up the update without relying on +// a periodic tick. +func (d *btwDialog) submit(invalidate func()) { d.mu.Lock() if d.editor == nil || d.loading { d.mu.Unlock() @@ -219,6 +238,9 @@ func (d *btwDialog) submit() { stream, err := client.Stream(ctx, req) if err != nil { d.completeTurn(turnIdx, "", err.Error()) + if invalidate != nil { + invalidate() + } return } @@ -240,6 +262,9 @@ func (d *btwDialog) submit() { errMsg = finalErr.Error() } d.completeTurn(turnIdx, reply.String(), errMsg) + if invalidate != nil { + invalidate() + } }() } @@ -267,7 +292,7 @@ func (d *btwDialog) Render(th tui.Theme, width int) []string { } var out []string - out = append(out, frameHeaderColor(th, "btw — side chat (esc closes; nothing is added to the main thread)", width, th.Accent)) + out = append(out, frameHeaderColor(th, "btw - side chat (esc closes; nothing is added to the main thread)", width, th.Accent)) if len(d.turns) == 0 && !d.loading { out = append(out, " "+th.FG256(th.Muted, "ask anything; replies stay private to this side chat.")) diff --git a/internal/agent/modes/interactive.go b/internal/agent/modes/interactive.go index bd7367e..ab016a6 100644 --- a/internal/agent/modes/interactive.go +++ b/internal/agent/modes/interactive.go @@ -478,8 +478,15 @@ func (i *Interactive) Run(ctx context.Context) error { // and the AfterFunc-driven invalidate got dropped on a // full channel. drainPending() - if i.busy || i.dialog.Active() || i.modelDialog.Active() || i.sessionDialog.Active() || i.jumpDialog.Active() || i.btwDialog.Active() || i.skillsDialog.Active() || i.changelogDialog.Active() || i.confirmDialog.Active() || i.logoutDialog.Active() || i.telegramDialog.Active() || i.sessionOpsDialog.Active() || i.sessionTreeDialog.Active() { - requestRedraw() // keep the spinner / dialog animation moving + // Only force a periodic redraw when something is actually + // animating (the main spinner during a busy turn, or the + // btw side-chat spinner while it's awaiting a response). + // Static pickers (model, session, jump, etc.) don't need + // the tick and firing it cancels the terminal's cursor + // blink inside dialogs that host their own editor (btw), + // because each frame re-emits hide-cursor + show-cursor. + if i.busy || i.btwDialog.Loading() { + requestRedraw() } } } @@ -755,7 +762,7 @@ func (i *Interactive) redraw() { var queue []string if len(i.queued) > 0 { for _, q := range i.queued { - label := i.cfg.Theme.FG256(i.cfg.Theme.Accent, "▸ sliding in: ") + label := i.cfg.Theme.FG256(i.cfg.Theme.Accent, "sliding in: ") text := truncateLine(q, cols-15) queue = append(queue, label+i.cfg.Theme.FG256(i.cfg.Theme.Muted, text)) } @@ -822,8 +829,20 @@ func (i *Interactive) redraw() { frame = append(frame, visibleChat...) frame = append(frame, bottom...) + // Default: the real terminal cursor sits on the main editor's + // input position. When an overlay dialog has its own input + // field (today just /btw), route the cursor there instead so + // the blinking cursor shows where the user is actually typing. + // Dialogs without a cursor (model picker, /help, /login, etc.) + // return -1 and the main editor keeps the cursor. cursorRow := len(visibleChat) + len(dialog) + len(suggest) + len(queue) + len(statusLines) + curR cursorCol := curC + if i.btwDialog.Active() { + if r, c := i.btwDialog.CursorPos(cols); r >= 0 { + cursorRow = len(visibleChat) + r + cursorCol = c + } + } i.rend.Draw(frame, cursorRow, cursorCol) } @@ -1716,7 +1735,7 @@ func (i *Interactive) openBtwDialog(args []string) { return } seed := strings.TrimSpace(strings.Join(args, " ")) - i.btwDialog.Open(i.cfg.Theme, i.agent, i.agent.System, i.cfg.Model, seed) + i.btwDialog.Open(i.cfg.Theme, i.agent, i.agent.System, i.cfg.Model, seed, i.invalidate) i.invalidate() } @@ -2289,7 +2308,7 @@ func (i *Interactive) handleEvent(ev core.AgentEvent) { if tc, ok := i.toolCalls[e.ID]; ok { tc.RawJSONBuf += e.Delta // Refresh the live path as soon as it parses; used in - // the header (▸ write /Users/pat/Desktop/demo.ts) + // the header (write /Users/pat/Desktop/demo.ts) // while the content is still streaming. if p, pok, _ := tui.ExtractPartialStringField(tc.RawJSONBuf, "path"); pok { tc.LivePath = p diff --git a/internal/tui/view.go b/internal/tui/view.go index 9ae047d..be17a3e 100644 --- a/internal/tui/view.go +++ b/internal/tui/view.go @@ -445,7 +445,7 @@ func (v *View) renderMessage(m provider.Message, width int) []string { // assistant prose above. The matching closing rule // is emitted at the end of the tool-role message. lines = append(lines, toolBlockRule(v.Theme, width)) - lines = append(lines, indent+v.Theme.FG256(v.Theme.Tool, "▸ "+b.Name+" "+ShortArgs(b.Name, b.Arguments))) + lines = append(lines, indent+v.Theme.FG256(v.Theme.Tool, ""+b.Name+" "+ShortArgs(b.Name, b.Arguments))) } } case provider.RoleTool: @@ -493,7 +493,7 @@ func (v *View) renderToolCall(tc ToolCallView, width int) []string { if arg == "" && tc.LivePath != "" { arg = tc.LivePath } - head := v.Theme.FG256(v.Theme.Tool, "▸ "+tc.Name+" "+arg) + head := v.Theme.FG256(v.Theme.Tool, ""+tc.Name+" "+arg) // Live streaming body: pulled out of the partial JSON buffer for // tools whose interesting content is a string field (currently @@ -1377,10 +1377,14 @@ func StatusBar(p StatusBarParams) []string { // Exactly one pad (2 spaces) between the busy segment and // the provider/model block. The leading pad above covers // the left indent. + leftBuilder.WriteString(pad) } else { + // Idle path: a single pad of left inset so the line + // aligns with the conversation column on its left edge + // (" you" / " zot" message markers). Without the busy + // prefix there's no trailing separator to double-pad. leftBuilder.WriteString(pad) } - leftBuilder.WriteString(pad) leftBuilder.WriteString(th.FG256(th.Muted, left)) if middle != "" { leftBuilder.WriteString(pad)