mirror of
https://github.com/patriceckhart/zot.git
synced 2026-06-26 21:36:31 +02:00
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 "<pad><pad>(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.
This commit is contained in:
parent
fc47398717
commit
6019404644
3 changed files with 63 additions and 15 deletions
|
|
@ -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 <text> behaves like the reference).
|
||||
func (d *btwDialog) Open(th tui.Theme, agent *core.Agent, system, model, seed string) {
|
||||
// auto-submitted (so /btw <text> 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."))
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue