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)