1package modes
2
3import (
4 "context"
5 "fmt"
6 "os"
7 "path/filepath"
8 "strings"
9 "sync"
10 "time"
11
12 "github.com/patriceckhart/zot/internal/agent/extensions"
13 "github.com/patriceckhart/zot/internal/agent/modes/telegram"
14 "github.com/patriceckhart/zot/internal/agent/tools"
15 "github.com/patriceckhart/zot/internal/auth"
16 "github.com/patriceckhart/zot/internal/core"
17 "github.com/patriceckhart/zot/internal/extproto"
18 "github.com/patriceckhart/zot/internal/provider"
19 "github.com/patriceckhart/zot/internal/skills"
20 "github.com/patriceckhart/zot/internal/swarm"
21 "github.com/patriceckhart/zot/internal/tui"
22)
23
24// InteractiveConfig configures the interactive loop.
25type InteractiveConfig struct {
26 Terminal tui.Terminal
27 Theme tui.Theme
28 Model string
29 Provider string
30 AuthMethod string // "apikey" | "oauth" — used to tag cost as (sub) in status bar
31 BaseURL string
32 Reasoning string
33 SystemPrompt string
34 Tools core.Registry
35 MaxSteps int
36 CWD string
37
38 // InlineImagesEnabled overrides terminal image rendering. nil means
39 // auto-detect and render when supported; false disables; true uses
40 // the detected protocol when available.
41 InlineImagesEnabled *bool
42 SettingsStore SettingsStore
43
44 // Agent is optional. If nil, zot opens without credentials; the
45 // user must /login before they can prompt.
46 Agent *core.Agent
47
48 InitialInput string
49
50 // Auth is required. When the user runs /login, Interactive talks to
51 // AuthManager to open a browser and wait for the callback.
52 AuthManager *auth.Manager
53 // BuildAgent is called after a successful login to (re)construct the
54 // agent with the fresh credential. It returns the new agent and
55 // the concrete provider/model in use.
56 BuildAgent func() (*core.Agent, string, string, error)
57
58 // SetKimiCLIFallbackDisabled controls whether zot may fall back to
59 // the official Kimi Code CLI token when zot has no stored Kimi token.
60 SetKimiCLIFallbackDisabled func(disabled bool) error
61
62 // BuildAgentFor rebuilds the agent with an explicit provider/model
63 // override (used by the /model picker when switching providers).
64 // If providerOverride is empty, the current provider is kept.
65 BuildAgentFor func(providerOverride, modelOverride string) (*core.Agent, string, string, error)
66
67 // BuildAgentForRescue rebuilds the agent for the rescue picker that
68 // opens after a recoverable provider failure. Unlike BuildAgentFor,
69 // this builder drops launch-time --api-key and --base-url overrides
70 // because those are usually the reason rescue triggered. Re-resolves
71 // credentials from env vars / auth.json / provider defaults so the
72 // retry has a real chance of succeeding. Falls back to BuildAgentFor
73 // when nil so embedders that don't wire it keep working.
74 BuildAgentForRescue func(providerOverride, modelOverride string) (*core.Agent, string, string, error)
75
76 // LoggedInProviders returns the list of provider names that
77 // currently have credentials. Used by /model to filter the
78 // picker to only show reachable models.
79 LoggedInProviders func() []string
80
81 // ZotHome is the root directory for sessions/, used by /sessions
82 // and the update-check cache.
83 ZotHome string
84
85 // Version is the binary's current version (from main.version).
86 // Used only for display; the update check itself is done outside
87 // this package to avoid an import cycle.
88 Version string
89
90 // UpdateInfoChan is an optional channel that delivers the result
91 // of the github-release update check. Interactive reads at most
92 // one value, drops it if the check reported nothing, and otherwise
93 // surfaces a yellow "update available" banner at the top of the
94 // chat. Nil channel = no banner, no startup cost.
95 UpdateInfoChan <-chan UpdateInfo
96
97 // Sandbox is the shared sandbox pointer. Toggled by /jail and /unjail.
98 Sandbox *tools.Sandbox
99
100 // LoadSession swaps the current session for the one at path. The
101 // callback returns the new agent message slice so the TUI can invalidate.
102 LoadSession func(path string) error
103
104 // CurrentSessionPath returns the path of the live session file
105 // on disk (the one every AppendMessage writes to). Used by
106 // /session export so the exporter ships the exact bytes on
107 // disk. Returns an empty string when --no-session is set or
108 // no session is open.
109 CurrentSessionPath func() string
110
111 // FlushSession writes any in-memory agent messages to the
112 // session file that haven't been persisted yet. Called by
113 // /session export right before reading the file so the
114 // exported bytes reflect the full current conversation, not
115 // just the rows the agent happened to write synchronously.
116 // The default WriteNewTranscript-at-exit strategy means most
117 // of a running session lives only in memory until the tui
118 // closes; without a flush hook, /session export writes a
119 // file that only has the meta row.
120 FlushSession func()
121
122 // PersistModel is called whenever the user switches model or provider.
123 // It should update config.json and (if there's an active session)
124 // write a new meta row so resume picks up the same model.
125 PersistModel func(providerName, model string)
126
127 OnAssistant func(m provider.Message)
128 OnToolResult func(id string, r core.ToolResult)
129
130 // Extensions, if non-nil, lets users invoke extension-registered
131 // slash commands. Commands declared by extensions are looked up
132 // AFTER the built-in catalog so a built-in name always wins.
133 Extensions *extensions.Manager
134
135 // Swarm, if non-nil, enables the /swarm slash command and the
136 // dashboard dialog. The cli constructs the Swarm once per
137 // interactive run and tears it down on exit. nil disables the
138 // feature entirely (used by embedders / tests that don't want
139 // subprocesses).
140 Swarm *swarm.Swarm
141
142 // SkillSnapshot, if non-nil, returns the current list of
143 // discovered SKILL.md files. Re-invoked each time /skills opens
144 // so the picker reflects edits made during the session.
145 SkillSnapshot func() []*skills.Skill
146
147 // ChangelogChan, if non-nil, delivers release-notes for the
148 // current binary version once at startup. Interactive opens a
149 // dismissible overlay when the channel produces a non-empty
150 // body. Receiver fires at most once.
151 ChangelogChan <-chan ChangelogPayload
152
153 // OnChangelogDismiss, if non-nil, is called once the user
154 // closes the changelog overlay. The cli wires this to a
155 // MarkChangelogShown call so the same version doesn't show
156 // again on the next launch.
157 OnChangelogDismiss func()
158
159 // NoYolo is true when --no-yolo was passed. Interactive opens
160 // a confirmation dialog before every tool call and blocks the
161 // tool until the user picks yes / always-this-tool /
162 // always-all / no. When false (default), tools run freely.
163 NoYolo bool
164
165 // ConfirmGate is the session-scoped gate wrapping this
166 // interactive's Confirmer. When non-nil, /yolo can call
167 // AllowAll() on it to disable confirmation for the rest of the
168 // session. When nil (yolo mode), /yolo reports that there's
169 // nothing to disable.
170 ConfirmGate *core.ConfirmGate
171}
172
173// ChangelogPayload mirrors agent.ChangelogInfo without the import
174// cycle. The cli builds one from the http response, the tui opens
175// the overlay when one arrives.
176type ChangelogPayload struct {
177 Version string
178 Body string
179 URL string
180}
181
182// Interactive is the TUI chat loop.
183type chatCacheKey struct {
184 cols int
185 agentRev uint64
186 statusOK string
187 statusErr string
188 help string
189 extNotes string
190 updateAvailable bool
191 updateCurrent string
192 updateLatest string
193 updateURL string
194 welcomeShowVer bool
195 expandAll bool
196}
197
198// SettingsStore persists user-toggleable settings surfaced by /settings.
199type SettingsStore interface {
200 SetInlineImages(enabled bool) error
201}
202
203type Interactive struct {
204 cfg InteractiveConfig
205 view *tui.View
206 ed *tui.Editor
207 rend *tui.Renderer
208
209 mu sync.Mutex
210 agent *core.Agent
211 streaming strings.Builder // what's currently painted on screen
212 streamOn bool
213
214 // streamPending is the runes buffered after each EvTextDelta that
215 // haven't yet been promoted into `streaming` for rendering. It
216 // exists because some provider paths (notably Anthropic via the
217 // oauth/subscription channel) coalesce the model's output into a
218 // few fat chunks instead of drip-streaming. Painting those fat
219 // chunks verbatim looks like the summary "just appears". The
220 // paintPace goroutine drains a handful of runes per tick from
221 // this buffer into `streaming`, giving every path the same
222 // typewriter feel regardless of upstream chunk size.
223 streamPending []rune
224 // streamFlushPending is set when EvAssistantMessage fires while
225 // streamPending still has unrendered runes. The ticker flushes
226 // them, then closes out the stream (clearing flags, resetting
227 // buffers) so the final paint matches the on-disk message.
228 streamFlushPending bool
229 toolCalls map[string]*tui.ToolCallView
230 toolOrder []string
231 statusErr string
232 statusOK string
233 liveBlock []string // live streaming/tool progress rendered outside scrollback
234 helpBlock []string // rendered above the chat when /help was typed
235 cumUsage provider.Usage
236 lastCtxInput int // input_tokens of the most recent turn — approximates current context size
237 busy bool
238 dirty chan struct{}
239 cancelTurn context.CancelFunc
240 scrollOffset int // rows from the bottom; 0 = pinned to latest
241 prevScrollOffset int // last value redraw snapped against; tracks intent
242
243 // prevChatLen and prevChatCols track the chat buffer's size at the
244 // last redraw so that when content grows below the user's viewport
245 // while they're scrolled up reading history, we can bump
246 // scrollOffset by exactly the growth and keep the visible content
247 // pinned. Without this, every streamed line shifts the visible
248 // window down through the buffer (because scrollOffset is measured
249 // from the bottom) and the user's reading position drifts upward
250 // and off the top.
251 prevChatLen int
252 prevChatCols int
253 prevOverlayOpen bool
254
255 // chatCache stores the built transcript/status-note rows for idle
256 // frames. Editor typing changes only the bottom input region, so
257 // reusing this cache avoids copying/walking/reassembling a long
258 // session on every keypress.
259 chatCache []string
260 chatCacheKey chatCacheKey
261 chatCacheValid bool
262
263 // Messages typed while a turn is in flight. Each is delivered as
264 // its own follow-up turn once the current one finishes. Rendered
265 // above the status bar as "sliding in: ..." chips.
266 queued []string
267
268 // runCtx is the top-level context passed to Run(). Follow-up turns
269 // drained from `queued` are started against this context so they
270 // survive past the ctx of the key event that enqueued them.
271 runCtx context.Context
272
273 // pendingPostCompactNote is a status_ok message to surface after
274 // a successful auto-compact pass triggered by a 413 or by the
275 // pre-turn fraction guard. Cleared by runCompact once shown.
276 pendingPostCompactNote string
277
278 // autoCompacting is true while a model-triggered compaction is in
279 // flight. Surfaced in the status bar so the user can tell a
280 // condense pass from a regular assistant turn.
281 autoCompacting bool
282
283 // updateInfo is the result of the async update check. Zero value
284 // while the check hasn't completed or nothing is available.
285 updateInfo UpdateInfo
286
287 dialog *loginDialog
288 modelDialog *modelDialog
289 rescueDialog *rescueDialog
290 sessionDialog *sessionDialog
291 swarmDialog *swarmDialog
292 jumpDialog *jumpDialog
293 btwDialog *btwDialog
294 skillsDialog *skillsDialog
295 changelogDialog *changelogDialog
296 confirmDialog *confirmDialog
297 logoutDialog *logoutDialog
298 telegramDialog *telegramDialog
299 settingsDialog *settingsDialog
300 telegramBridge *telegram.Bridge
301 sessionOpsDialog *sessionOpsDialog
302 sessionTreeDialog *sessionTreeDialog
303 extPanel *extPanelDialog
304
305 // pendingFork is true when the user ran /session fork: the next
306 // jump-picker selection should branch off that message instead
307 // of scrolling. Flag resets after the action fires or the dialog
308 // is dismissed, so repeated /jump calls don't turn into forks.
309 pendingFork bool
310 suggest *slashSuggester
311 fileSuggest *fileSuggester
312 spin *spinner
313
314 // parkedTurn is the 1-based turn number the viewport is currently
315 // scrolled to by /jump. 0 = not parked, showing the tail as usual.
316 // Rendered as a muted footer at the bottom of the chat so users
317 // don't forget they're looking at history.
318 parkedTurn int
319 parkedTotal int
320
321 // inputHistoryIndex is -1 when not browsing history. When the
322 // editor is empty, Left/Right can walk previous user prompts for
323 // quick manual testing without stealing normal cursor movement in
324 // non-empty input.
325 inputHistoryIndex int
326
327 // lastCtrlC is when the user last pressed ctrl+c. The first press
328 // clears the editor / cancels a turn / shows a hint; a second press
329 // within ctrlCExitWindow exits. Mirrors the python-repl convention.
330 lastCtrlC time.Time
331
332 // welcomeStart is when the interactive run began. The welcome
333 // banner shows the binary version for welcomeVersionDuration
334 // after this point and reverts to plain text after.
335 welcomeStart time.Time
336
337 // extNotes are one-shot styled lines pushed by extensions via
338 // Notify / Display. They live above the editor (just below the
339 // transcript) until cleared by /clear or another reset.
340 extNotes []string
341
342 // sessionLoading is true while a /sessions selection is being read
343 // on a background goroutine. Keeping this off the input goroutine
344 // lets ctrl+c/exit remain responsive for very large JSONL sessions.
345 sessionLoading bool
346
347 // pendingRescuePrompt / pendingRescueImages stash the prompt and
348 // images that should be re-run after the user picks a model in
349 // the rescue dialog. Cleared once applyRescueSelection consumes
350 // them (or when the dialog is dismissed via esc).
351 pendingRescuePrompt string
352 pendingRescueImages []provider.ImageBlock
353}
354
355// welcomeVersionDuration is how long the welcome banner shows the
356// version suffix before reverting to the plain headline. 1.5s is
357// enough to read at a glance and keeps the splash short.
358const welcomeVersionDuration = 1500 * time.Millisecond
359
360// NewInteractive constructs an Interactive from cfg.
361func NewInteractive(cfg InteractiveConfig) *Interactive {
362 i := &Interactive{
363 cfg: cfg,
364 view: &tui.View{
365 Theme: cfg.Theme,
366 ImageProto: effectiveImageProtocol(cfg.InlineImagesEnabled),
367 },
368 // Prompt is the standard half-block accent bar used by chat
369 // speaker labels too, so the input gutter matches the rest
370 // of the UI.
371 ed: tui.NewEditor(cfg.Theme.AccentBar(cfg.Theme.Accent)),
372 rend: tui.NewRenderer(cfg.Terminal),
373 toolCalls: map[string]*tui.ToolCallView{},
374 dirty: make(chan struct{}, 8),
375 dialog: newLoginDialog(),
376 modelDialog: newModelDialog(),
377 rescueDialog: newRescueDialog(),
378 sessionDialog: newSessionDialog(),
379 swarmDialog: newSwarmDialog(),
380 jumpDialog: newJumpDialog(),
381 btwDialog: newBtwDialog(),
382 skillsDialog: newSkillsDialog(),
383 changelogDialog: newChangelogDialog(),
384 confirmDialog: newConfirmDialog(),
385 logoutDialog: newLogoutDialog(),
386 telegramDialog: newTelegramDialog(),
387 settingsDialog: newSettingsDialog(),
388 sessionOpsDialog: newSessionOpsDialog(),
389 sessionTreeDialog: newSessionTreeDialog(),
390 extPanel: newExtPanelDialog(),
391 suggest: newSlashSuggester(),
392 fileSuggest: newFileSuggester(),
393 spin: newSpinner(),
394 inputHistoryIndex: -1,
395 }
396 if cfg.Agent != nil {
397 i.agent = cfg.Agent
398 i.view.Messages = cfg.Agent.Messages()
399 i.cumUsage = cfg.Agent.Cost()
400 }
401 return i
402}
403
404// Run blocks until the user quits.
405func (i *Interactive) Run(ctx context.Context) error {
406 i.runCtx = ctx
407 term := i.cfg.Terminal
408 restore, err := term.EnterRaw()
409 if err != nil {
410 return err
411 }
412 defer restore()
413 defer func() {
414 if i.telegramBridge != nil {
415 i.telegramBridge.Stop()
416 }
417 }()
418
419 // Enabling mouse reporting steals click-drag selection from the
420 // host terminal (VS Code, Ghostty, iTerm). The user prefers native
421 // selection over the wheel-speed boost, so we no longer turn it
422 // on automatically. Wheel events fall through to the terminal's
423 // own scrollback handler.
424 // Keep zot on the terminal's main screen. We intentionally do not
425 // enter the alternate-screen buffer (CSI ?1049h). The renderer emits
426 // chat as normal terminal flow/scrollback and redraws only the live
427 // input/status block on normal typing.
428 _, _ = term.Write([]byte(tui.SeqBracketedPasteOn + tui.SeqResetScrollRegion + tui.SeqDeleteKittyImages + tui.SeqClearScreenNoHome + tui.SeqClearScrollback + tui.MoveTo(1, 1)))
429 defer term.Write([]byte(tui.SeqResetScrollRegion + tui.SeqDeleteKittyImages + tui.SeqBracketedPasteOff + tui.SeqShowCursor))
430
431 // Streaming pacer: drains buffered text deltas at a steady rate
432 // so typewriter feel is identical across providers regardless of
433 // upstream chunk size. Starts here so it lives for the whole
434 // session and exits with ctx.
435 go i.runStreamPacer(ctx)
436
437 cols, rows := term.Size()
438 i.rend.Resize(cols, rows)
439 term.OnResize(func() {
440 c, r := term.Size()
441 i.rend.Resize(c, r)
442 // Force an immediate redraw on resize. The throttled invalidate
443 // path is fine for animation, but a window resize is a discrete
444 // user action where any visible delay (or stale frame) reads as
445 // brokenness. redraw() is mutex-safe; the worst that happens is
446 // a duplicate paint if the throttler is mid-flight, which is
447 // invisible.
448 i.redraw()
449 })
450
451 if i.cfg.InitialInput != "" {
452 i.ed.SetValue(i.cfg.InitialInput)
453 }
454
455 // Stamp the welcome time and schedule a one-shot redraw at the
456 // expiry so the version suffix disappears on its own even if the
457 // user hasn't typed anything yet.
458 i.welcomeStart = time.Now()
459 time.AfterFunc(welcomeVersionDuration, i.invalidate)
460
461 // If the agent was constructed with a pre-loaded transcript
462 // (--continue, --resume, --session) pin the viewport at the
463 // bottom so the most recent reply (and any prompt the user just
464 // typed) is fully visible. Earlier behaviour parked the view at
465 // the last user turn, which could leave the latest message clipped
466 // off the bottom of the page on long sessions.
467 if i.agent != nil {
468 if msgs := i.agent.Messages(); len(msgs) > 0 {
469 i.scrollToBottom()
470 }
471 }
472
473 // No credential at startup? Auto-open the login dialog, and mark
474 // the status line. The user can Esc out of the dialog if they
475 // want to dismiss it (e.g. to check /help or /exit first).
476 if i.agent == nil {
477 i.statusErr = "not logged in. pick a login method below or press esc to dismiss."
478 i.dialog.Open(i.cfg.ZotHome)
479 }
480
481 // Input goroutine. Buffered generously so a drag-drop that the
482 // terminal delivers as a burst of single-character key events
483 // (no bracketed paste) can be drained in one main-loop pass
484 // instead of triggering a redraw per character.
485 keys := make(chan tui.Key, 256)
486 go func() {
487 reader := tui.NewReaderWithPeek(term.ReadByte, term.PeekByteTimeout)
488 for {
489 k, err := reader.Read()
490 if err != nil {
491 return
492 }
493 keys <- k
494 }
495 }()
496
497 // Subscribe to auth events.
498 var authEvents <-chan auth.Event
499 if i.cfg.AuthManager != nil {
500 authEvents = i.cfg.AuthManager.Events()
501 }
502
503 // Animation ticker: drives spinner and dialog-related redraws when
504 // nothing else changed. 120ms is slow enough that highlighting a huge
505 // transcript doesn't spin the cpu.
506 tick := time.NewTicker(120 * time.Millisecond)
507 defer tick.Stop()
508
509 // Redraw throttle: coalesce bursts of invalidate() calls so we paint
510 // at most once every redrawMinInterval. Huge tool-result dumps can
511 // fire hundreds of invalidations while the user is typing; without
512 // this, the input goroutine never gets CPU and keystrokes lag.
513 const redrawMinInterval = 16 * time.Millisecond
514 var lastRedraw time.Time
515 var pendingRedraw bool
516 var pendingTimer *time.Timer
517
518 drainPending := func() {
519 if pendingTimer != nil {
520 pendingTimer.Stop()
521 pendingTimer = nil
522 }
523 if pendingRedraw {
524 pendingRedraw = false
525 lastRedraw = time.Now()
526 i.redraw()
527 }
528 }
529
530 requestRedraw := func() {
531 since := time.Since(lastRedraw)
532 if since >= redrawMinInterval {
533 // Redrawing right now subsumes any pending redraw, so clear
534 // the throttle state. Without this, a pending flag stays
535 // stuck at true and subsequent invalidate() calls within
536 // redrawMinInterval get dropped — which is exactly how the
537 // final "turn finished" frame went missing until the user
538 // nudged the ui by typing or scrolling.
539 if pendingTimer != nil {
540 pendingTimer.Stop()
541 }
542 pendingRedraw = false
543 lastRedraw = time.Now()
544 i.redraw()
545 return
546 }
547 if pendingRedraw {
548 return // already scheduled
549 }
550 pendingRedraw = true
551 wait := redrawMinInterval - since
552 if pendingTimer == nil {
553 pendingTimer = time.AfterFunc(wait, func() {
554 // Poke the dirty channel so the main loop wakes and
555 // drains the pending redraw on its own goroutine. We
556 // can't call drainPending here directly — it touches
557 // closure state shared with the main loop.
558 i.invalidate()
559 })
560 } else {
561 pendingTimer.Reset(wait)
562 }
563 }
564
565 i.invalidate()
566
567 updates := i.cfg.UpdateInfoChan // nil-safe; nil channel blocks forever in select
568 changelog := i.cfg.ChangelogChan // single-shot, see case below
569
570 for {
571 select {
572 case <-ctx.Done():
573 return ctx.Err()
574 case k := <-keys:
575 if done := i.handleKey(ctx, k); done {
576 return nil
577 }
578 // Drain any keystrokes that arrived during this iteration.
579 // VS Code (and other terminals that don't bracket drops as
580 // paste) deliver a path one rune at a time — without this
581 // loop the editor would render between every rune and a
582 // long path on a heavy transcript would visibly type in.
583 drain:
584 for {
585 select {
586 case k2 := <-keys:
587 if done := i.handleKey(ctx, k2); done {
588 return nil
589 }
590 default:
591 break drain
592 }
593 }
594 i.invalidate()
595 case ev := <-authEvents:
596 i.handleAuthEvent(ev)
597 i.invalidate()
598 case info, ok := <-updates:
599 if ok && info.Available {
600 i.mu.Lock()
601 i.updateInfo = info
602 i.mu.Unlock()
603 i.invalidate()
604 }
605 updates = nil // single-shot; subsequent iterations skip this case
606 case cl, ok := <-changelog:
607 if ok && cl.Body != "" {
608 i.changelogDialog.Open(cl.Version, cl.URL, cl.Body)
609 i.invalidate()
610 }
611 changelog = nil // single-shot
612 case <-i.dirty:
613 requestRedraw()
614 case <-tick.C:
615 // Always drain a pending redraw on the tick. This is the
616 // safety net that catches the case where the dirty channel
617 // was saturated when the final "turn finished" invalidate
618 // fired, or where the throttle scheduled a deferred redraw
619 // and the AfterFunc-driven invalidate got dropped on a
620 // full channel.
621 drainPending()
622 // Only force a periodic redraw when something is actually
623 // animating (the main spinner during a busy turn, or the
624 // btw side-chat spinner while it's awaiting a response).
625 // Static pickers (model, session, jump, etc.) don't need
626 // the tick and firing it cancels the terminal's cursor
627 // blink inside dialogs that host their own editor (btw),
628 // because each frame re-emits hide-cursor + show-cursor.
629 //
630 // The swarm dashboard is also animated: its rows reflect
631 // background subprocesses whose activity / age change
632 // without any user input. Without the tick redraw the
633 // dashboard freezes on the snapshot taken when the user
634 // last pressed a key. We exclude the dashboard when one
635 // of its inline editors (spawn task or prompt composer)
636 // is active so the cursor blink in those editors works
637 // the same way it does inside btw.
638 if i.busy || i.btwDialog.Loading() || i.swarmDialog.NeedsTickRefresh() {
639 requestRedraw()
640 }
641 }
642 }
643}
644
645func (i *Interactive) invalidate() {
646 select {
647 case i.dirty <- struct{}{}:
648 default:
649 }
650}
651
652func (i *Interactive) cachedChatLocked(cols int) []string {
653 key, cacheable := i.chatCacheKeyLocked(cols)
654 if cacheable && i.chatCacheValid && i.chatCacheKey == key {
655 return append([]string(nil), i.chatCache...)
656 }
657 chat := i.buildChatLocked(cols)
658 if cacheable {
659 i.chatCache = append(i.chatCache[:0], chat...)
660 i.chatCacheKey = key
661 i.chatCacheValid = true
662 } else {
663 i.chatCacheValid = false
664 }
665 return chat
666}
667
668func (i *Interactive) chatCacheKeyLocked(cols int) (chatCacheKey, bool) {
669 // Live turns mutate streaming/tool-call state at high frequency;
670 // keep those on the old rebuild path. The cache targets the common
671 // idle case where only the editor contents changed between redraws.
672 if i.busy || i.streamOn || i.streamFlushPending {
673 return chatCacheKey{}, false
674 }
675 var rev uint64
676 if i.agent != nil {
677 rev = i.agent.Revision()
678 }
679 showVer := len(i.view.Messages) == 0 && !i.streamOn && len(i.toolOrder) == 0 && !i.welcomeStart.IsZero() && time.Since(i.welcomeStart) < welcomeVersionDuration
680 return chatCacheKey{
681 cols: cols,
682 agentRev: rev,
683 statusOK: i.statusOK,
684 statusErr: i.statusErr,
685 help: strings.Join(i.helpBlock, "\n"),
686 extNotes: strings.Join(i.extNotes, "\n"),
687 updateAvailable: i.updateInfo.Available,
688 updateCurrent: i.updateInfo.Current,
689 updateLatest: i.updateInfo.Latest,
690 updateURL: i.updateInfo.URL,
691 welcomeShowVer: showVer,
692 expandAll: i.view.ExpandAll,
693 }, true
694}
695
696func (i *Interactive) buildChatLocked(cols int) []string {
697 if i.agent != nil {
698 i.view.Messages = filterHiddenTranscriptMessages(i.agent.Messages())
699 } else {
700 i.view.Messages = nil
701 }
702 // Pacer flush: while the streaming pacer is still draining the
703 // buffer (i.e. EvAssistantMessage already fired but more runes
704 // are queued), the final assistant message is already in
705 // i.agent.Messages() in full. Painting it in the transcript
706 // AND the streaming block at the same time shows the user the
707 // complete text immediately — which defeats the whole pacer.
708 // Hide the last message until the pacer catches up; once the
709 // flush-pending latch clears, the message is revealed (the
710 // streaming block disappears the same frame because streamOn
711 // flips off, so the transition is seamless).
712 if i.streamFlushPending && len(i.view.Messages) > 0 {
713 i.view.Messages = i.view.Messages[:len(i.view.Messages)-1]
714 }
715 i.view.Streaming = i.streaming.String()
716 i.view.StreamingActive = i.streamOn
717 // Guard against the narrow race where EvAssistantMessage has
718 // just promoted a streaming reply into the transcript but a
719 // render tick hasn't flipped streamOn off yet. Without the
720 // guard, the same text would appear twice (once as the
721 // in-flight streaming block, once as the last transcript
722 // message). We detect the duplicate strictly: the last
723 // assistant message's visible text must equal the streaming
724 // buffer. Just matching on role is too broad — it also hides
725 // the next round's typewriter streaming after a tool turn,
726 // because the last transcript message is always assistant
727 // (the tool-use block) until the follow-up summary lands.
728 if i.streamOn && i.streaming.Len() > 0 {
729 if n := len(i.view.Messages); n > 0 && i.view.Messages[n-1].Role == provider.RoleAssistant {
730 if assistantText(i.view.Messages[n-1]) == i.streaming.String() {
731 i.view.StreamingActive = false
732 }
733 }
734 }
735 // Live tool-call view: only shown while a turn is in flight. Once
736 // the agent is idle, every tool call has already been folded into
737 // the transcript (as assistant.ToolCallBlock + a tool-role message),
738 // so showing v.ToolCalls a second time would duplicate them below
739 // the final assistant text — which looks like the summary came
740 // "before" the tools.
741 i.view.ToolCalls = i.view.ToolCalls[:0]
742 if i.busy {
743 for _, id := range i.toolOrder {
744 if tc, ok := i.toolCalls[id]; ok {
745 i.view.ToolCalls = append(i.view.ToolCalls, *tc)
746 }
747 }
748 }
749 i.view.Err = i.statusErr
750 // Live streaming/tool rows are appended to the chat buffer (not
751 // hoisted into a separate live block above the editor). That keeps
752 // the renderer's diff view append-only: when a tool finishes the
753 // rows update in place at the end of the buffer, instead of the
754 // whole bottom band shrinking and shifting chat lines around.
755 i.liveBlock = nil
756 chat := i.view.Build(cols)
757
758 // Welcome banner: shown at the top of the chat area when there is
759 // no transcript yet. Disappears after the first message is sent.
760 // The version suffix is shown for welcomeVersionDuration after
761 // startup, then drops off automatically.
762 if len(i.view.Messages) == 0 && !i.streamOn && len(i.toolOrder) == 0 {
763 showVer := !i.welcomeStart.IsZero() && time.Since(i.welcomeStart) < welcomeVersionDuration
764 chat = append(welcomeBanner(i.cfg.Theme, i.cfg.Version, showVer), chat...)
765 }
766
767 // Update-available banner: prepended above everything else so it's
768 // the first thing the user sees when opening a new zot session.
769 // Once rendered, it stays until the user updates to a newer
770 // version — we don't persist a "dismissed" flag because this is
771 // cheap and re-showing it is how most users remember to update.
772 if i.updateInfo.Available {
773 banner := renderUpdateBanner(i.cfg.Theme, i.updateInfo, cols)
774 chat = append(banner, chat...)
775 }
776
777 // /help block: appended to the transcript so it appears at the
778 // bottom of the chat area (right above the status bar / editor).
779 // Prepending it would push long conversations off the top of the
780 // viewport, which users would miss entirely.
781 if len(i.helpBlock) > 0 {
782 chat = append(chat, i.helpBlock...)
783 }
784
785 if i.statusOK != "" {
786 // Hard-truncate the OK line to the visible width so a long
787 // session path ("resumed session: /Users/.../sessions/...")
788 // doesn't overflow past the right edge and look broken on a
789 // narrow terminal.
790 line := "✓ " + i.statusOK
791 if cols > 4 && len(line) > cols {
792 line = line[:cols-1] + "…"
793 }
794 chat = append(chat, i.cfg.Theme.FG256(i.cfg.Theme.Tool, line), "")
795 }
796
797 // Extension notes (notify / display) live just under the
798 // transcript, above the dialog/editor band. Cleared by /clear.
799 if len(i.extNotes) > 0 {
800 chat = append(chat, i.extNotes...)
801 chat = append(chat, "")
802 }
803
804 // Strip trailing blank rows so the chat content sits flush
805 // against the new "blank above status bar" row added by the
806 // bottom-region assembly. Build() ends every message with a
807 // blank separator; without this trim, the final message in
808 // the transcript would have its own trailing blank plus the
809 // status block's leading blank, doubling the gap.
810 for len(chat) > 0 && strings.TrimSpace(chat[len(chat)-1]) == "" {
811 chat = chat[:len(chat)-1]
812 }
813 return chat
814}
815
816// lastCols returns the current terminal width in columns.
817func (i *Interactive) lastCols() int {
818 cols, _ := i.cfg.Terminal.Size()
819 return cols
820}
821
822// chatPage returns the number of chat rows currently visible, used
823// as the page size for PageUp/PageDown.
824func (i *Interactive) chatPage() int {
825 _, rows := i.cfg.Terminal.Size()
826 p := rows - 6 // rough reservation for status + editor + a dialog line
827 if p < 4 {
828 p = 4
829 }
830 return p
831}
832
833// scrollBy adjusts the scroll offset. Positive = up (into history).
834// Clearing the parked-turn label when we're back at the bottom means
835// the "viewing turn N" footer goes away automatically as soon as you
836// scroll back to the live tail.
837func (i *Interactive) scrollBy(delta int) {
838 i.mu.Lock()
839 i.scrollOffset += delta
840 if i.scrollOffset < 0 {
841 i.scrollOffset = 0
842 }
843 if i.scrollOffset == 0 {
844 i.parkedTurn = 0
845 i.parkedTotal = 0
846 }
847 if i.rend != nil {
848 // VS Code's terminal is especially prone to leaving stray
849 // wrapped-character fragments behind during scroll-driven
850 // viewport changes. Force a full repaint on scroll, but
851 // avoid a whole-screen clear because that visibly flickers.
852 i.rend.Invalidate()
853 }
854 i.mu.Unlock()
855 i.invalidate()
856}
857
858// scrollToBottom pins the view to the latest content.
859func (i *Interactive) scrollToBottom() {
860 i.mu.Lock()
861 i.scrollOffset = 0
862 i.parkedTurn = 0
863 i.parkedTotal = 0
864 if i.rend != nil {
865 i.rend.Invalidate()
866 }
867 i.mu.Unlock()
868 i.invalidate()
869}
870
871func (i *Interactive) redraw() {
872 i.mu.Lock()
873 defer i.mu.Unlock()
874
875 cols, _ := i.cfg.Terminal.Size()
876 chat := i.cachedChatLocked(cols)
877
878 // Dialogs (login or model picker) render between chat and the editor.
879 var dialog []string
880 switch {
881 case i.dialog.Active():
882 dialog = i.dialog.Render(i.cfg.Theme, cols)
883 case i.modelDialog.Active():
884 dialog = i.modelDialog.Render(i.cfg.Theme, cols)
885 case i.rescueDialog.Active():
886 dialog = i.rescueDialog.Render(i.cfg.Theme, cols)
887 case i.sessionDialog.Active():
888 // Reserve rows for the editor (~3), status line (1-2),
889 // dialog chrome (header + hint + rule + indicators, ~5),
890 // and leave the remainder for session rows. Minimum of 3
891 // rows so even a very small terminal shows something.
892 _, rows := i.cfg.Terminal.Size()
893 avail := rows - 12
894 if avail < 3 {
895 avail = 3
896 }
897 i.sessionDialog.MaxRows = avail
898 dialog = i.sessionDialog.Render(i.cfg.Theme, cols)
899 case i.swarmDialog.Active():
900 dialog = i.swarmDialog.Render(i.cfg.Theme, cols)
901 case i.jumpDialog.Active():
902 dialog = i.jumpDialog.Render(i.cfg.Theme, cols)
903 case i.btwDialog.Active():
904 dialog = i.btwDialog.Render(i.cfg.Theme, cols)
905 case i.skillsDialog.Active():
906 dialog = i.skillsDialog.Render(i.cfg.Theme, cols)
907 case i.changelogDialog.Active():
908 dialog = i.changelogDialog.Render(i.cfg.Theme, cols)
909 case i.confirmDialog.Active():
910 dialog = i.confirmDialog.Render(i.cfg.Theme, cols)
911 case i.logoutDialog.Active():
912 dialog = i.logoutDialog.Render(i.cfg.Theme, cols)
913 case i.telegramDialog.Active():
914 dialog = i.telegramDialog.Render(i.cfg.Theme, cols)
915 case i.settingsDialog.Active():
916 dialog = i.settingsDialog.Render(i.cfg.Theme, cols)
917 case i.sessionOpsDialog.Active():
918 dialog = i.sessionOpsDialog.Render(i.cfg.Theme, cols)
919 case i.sessionTreeDialog.Active():
920 dialog = i.sessionTreeDialog.Render(i.cfg.Theme, cols)
921 case i.extPanel.Active():
922 dialog = i.extPanel.Render(i.cfg.Theme, cols)
923 }
924
925 // Slash-command autocomplete: popup above the status line, only
926 // when the editor starts with "/" and no dialog is already open.
927 // Feed extension-registered commands into the suggester first so
928 // they show up in tab-complete + the popup alongside the built-ins.
929 i.suggest.SetJailed(i.cfg.Sandbox.Locked())
930 if i.cfg.Extensions != nil {
931 catalog := i.cfg.Extensions.Commands()
932 extra := make([]slashCommand, 0, len(catalog))
933 for _, c := range catalog {
934 // The popup renders extension commands under a dedicated
935 // "── extensions ───" divider, so the description doesn't
936 // need to repeat the source. If the description is empty,
937 // fall back to the extension name so the row isn't blank.
938 desc := c.Description
939 if strings.TrimSpace(desc) == "" {
940 desc = "(" + c.Extension + ")"
941 }
942 extra = append(extra, slashCommand{
943 Name: "/" + c.Name,
944 Desc: desc,
945 })
946 }
947 i.suggest.SetExtra(extra)
948 }
949 var suggest []string
950 currentInput := i.ed.Value()
951 // Slash popup renders even while the agent is busy so the user
952 // can queue a destructive command (/clear, /compact, /logout,
953 // /model) or a read-only one (/help, /jump, /sessions, etc.)
954 // without waiting for the current turn to finish. The dispatcher
955 // in runSlash already handles the busy case per-command: safe
956 // ones run immediately, destructive ones cancel the turn first.
957 i.fileSuggest.SetCWD(i.cfg.CWD)
958 if len(dialog) == 0 && i.suggest.Active(currentInput) {
959 suggest = i.suggest.Render(currentInput, i.cfg.Theme, cols)
960 } else if len(dialog) == 0 && i.fileSuggest.Active(currentInput) {
961 suggest = i.fileSuggest.Render(currentInput, i.cfg.Theme, cols)
962 }
963
964 // Detect overlay close (any dialog or slash/file suggestion popup
965 // just transitioned from open to closed). Force a full redraw so
966 // the rows the overlay occupied are guaranteed to be repainted
967 // from the chat below, instead of the diff path leaving stale
968 // dialog content behind. Equivalent to the user pressing ctrl+l.
969 overlayOpen := len(dialog) > 0 || len(suggest) > 0
970 if i.prevOverlayOpen && !overlayOpen && i.rend != nil {
971 i.rend.Clear()
972 }
973 i.prevOverlayOpen = overlayOpen
974 if len(suggest) > 0 {
975 // One blank row above the popup so it doesn't sit flush
976 // against the chat / welcome content above.
977 suggest = append([]string{""}, suggest...)
978 }
979
980 // Busy prefix shown at the far left of the status bar. The
981 // spinner glyph and its funny-line message share the `zot`
982 // label colour (Theme.Assistant) so the whole "who's working"
983 // band reads at a glance. Elapsed time stays muted because it
984 // drifts every second and shouldn't grab focus.
985 var busyPrefix string
986 if i.busy {
987 busyPrefix = fmt.Sprintf("%s %s %s %s",
988 i.cfg.Theme.FG256(i.cfg.Theme.Assistant, i.spin.Frame()),
989 i.cfg.Theme.FG256(i.cfg.Theme.Assistant, i.spin.Message()),
990 i.cfg.Theme.FG256(i.cfg.Theme.Muted, "-"),
991 i.cfg.Theme.FG256(i.cfg.Theme.Muted, i.spin.Elapsed().String()),
992 )
993 }
994
995 ctxMax := 0
996 if m, err := provider.FindModel(i.cfg.Provider, i.cfg.Model); err == nil {
997 ctxMax = m.ContextWindow
998 }
999 statusLines := tui.StatusBar(tui.StatusBarParams{
1000 Theme: i.cfg.Theme,
1001 Provider: i.cfg.Provider,
1002 Model: i.cfg.Model,
1003 Busy: i.busy,
1004 BusyPrefix: busyPrefix,
1005 CWD: i.cfg.CWD,
1006 Locked: i.cfg.Sandbox.Locked(),
1007 NoYolo: i.cfg.NoYolo,
1008 Usage: i.cumUsage,
1009 Subscription: i.cfg.AuthMethod == "oauth",
1010 ContextUsed: i.lastCtxInput,
1011 ContextMax: ctxMax,
1012 AutoCompacting: i.autoCompacting,
1013 Telegram: i.telegramBridge != nil && i.telegramBridge.Active(),
1014 Cols: cols,
1015 })
1016 edLines, curR, curC := i.ed.Render(cols)
1017
1018 // "Sliding in" chips for messages the user typed while a turn is
1019 // in flight. Shown directly above the status bar so they're close
1020 // to the editor but don't push the chat around.
1021 var queue []string
1022 queued := append([]string(nil), i.queued...)
1023 if i.agent != nil {
1024 queued = append(queued, i.agent.PendingQueuedMessages()...)
1025 }
1026 if len(queued) > 0 {
1027 queue = append(queue, "")
1028 for _, q := range queued {
1029 label := i.cfg.Theme.FG256(i.cfg.Theme.Accent, " sliding in: ")
1030 text := truncateLine(q, cols-17)
1031 queue = append(queue, label+i.cfg.Theme.FG256(i.cfg.Theme.Muted, text))
1032 }
1033 // Hint row, rendered in the same muted tone as the model
1034 // info on the status bar so it reads as ambient metadata
1035 // rather than a chip. Tells the user how to recover the
1036 // most recent queued message back into the editor.
1037 hint := " Press " + slideBackChordHint() + " to slide back into input"
1038 queue = append(queue, i.cfg.Theme.FG256(i.cfg.Theme.Muted, hint))
1039 }
1040
1041 // Bottom-sticky sections (always visible, never scroll). Each
1042 // non-empty subsection (dialog, suggest popup, sliding-in queue)
1043 // is preceded by one blank row so it has air above the chat
1044 // content. The status block and editor get their own dedicated
1045 // blanks so spacing stays consistent whether or not a dialog or
1046 // popup is showing.
1047 bottom := make([]string, 0, len(dialog)+len(suggest)+len(queue)+len(edLines)+9)
1048 if len(dialog) > 0 {
1049 bottom = append(bottom, "")
1050 }
1051 bottom = append(bottom, dialog...)
1052 // The swarm dashboard owns the bottom of the screen while it's
1053 // active: it has its own inline editors for spawn (`n`) and
1054 // prompt (`p`), so the main input would be a confusing second
1055 // caret. The suggest popup, sliding-in queue, status block, and
1056 // main editor are all hidden underneath it. Keystrokes still
1057 // reach handleKey — it routes them to swarmDialog.HandleKey
1058 // before the editor ever sees them — so the only effect of this
1059 // branch is visual.
1060 if !i.swarmDialog.Active() {
1061 bottom = append(bottom, suggest...)
1062 bottom = append(bottom, queue...)
1063 bottom = append(bottom, "")
1064 bottom = append(bottom, statusLines...)
1065 bottom = append(bottom, "")
1066 bottom = append(bottom, edLines...)
1067 }
1068
1069 _, rows := i.cfg.Terminal.Size()
1070 chatRows := rows - len(bottom)
1071 if chatRows < 1 {
1072 chatRows = 1
1073 }
1074
1075 // Auto-follow guard: when the user has scrolled up (scrollOffset
1076 // > 0) and the agent appends new content below the viewport while
1077 // they're reading, compensate so the visible content stays
1078 // anchored. scrollOffset is measured from the bottom of `chat`,
1079 // so without compensation a growing buffer pushes the window
1080 // downward through the content and the lines the user was
1081 // reading scroll off the top.
1082 //
1083 // Skip compensation when the terminal width changed (a resize
1084 // reflows the whole buffer and the line-count delta no longer
1085 // corresponds to appended content) and when scrollOffset is 0
1086 // (the user is following the tail and wants new content to push
1087 // the view down as usual).
1088 if i.scrollOffset > 0 && i.prevChatCols == cols && i.prevChatLen > 0 {
1089 if delta := len(chat) - i.prevChatLen; delta != 0 {
1090 i.scrollOffset += delta
1091 if i.scrollOffset < 0 {
1092 i.scrollOffset = 0
1093 }
1094 }
1095 }
1096 i.prevChatLen = len(chat)
1097 i.prevChatCols = cols
1098
1099 // Apply scroll offset to the chat slice.
1100 maxOffset := len(chat) - chatRows
1101 if maxOffset < 0 {
1102 maxOffset = 0
1103 }
1104 if i.scrollOffset > maxOffset {
1105 i.scrollOffset = maxOffset
1106 }
1107 if i.scrollOffset < 0 {
1108 i.scrollOffset = 0
1109 }
1110
1111 var visibleChat []string
1112 if len(chat) <= chatRows {
1113 visibleChat = chat
1114 } else {
1115 end := len(chat) - i.scrollOffset
1116 rawStart := end - chatRows
1117 if rawStart < 0 {
1118 rawStart = 0
1119 }
1120 start := snapViewportStartToImageBlock(chat, rawStart)
1121 // If the snap pulled start upward (an image-block was atomic) while
1122 // the user is scrolling downward, the viewport would sit on the same
1123 // image until the user mashes down past every reserved row. Bump
1124 // scrollOffset past the image so one keypress always clears it.
1125 if start < rawStart && i.scrollOffset < i.prevScrollOffset {
1126 jump := rawStart - start
1127 i.scrollOffset -= jump
1128 if i.scrollOffset < 0 {
1129 i.scrollOffset = 0
1130 }
1131 end = len(chat) - i.scrollOffset
1132 rawStart = end - chatRows
1133 if rawStart < 0 {
1134 rawStart = 0
1135 }
1136 start = snapViewportStartToImageBlock(chat, rawStart)
1137 }
1138 end = start + chatRows
1139 if end > len(chat) {
1140 end = len(chat)
1141 start = end - chatRows
1142 if start < 0 {
1143 start = 0
1144 }
1145 }
1146 visibleChat = chat[start:end]
1147 }
1148 i.prevScrollOffset = i.scrollOffset
1149 visibleChat = clipBottomClippedImages(visibleChat)
1150
1151 // A tiny "scrolled up" indicator in the top-right of the chat pane
1152 // so you know you're not at the bottom. When the viewport was
1153 // parked by /jump we include the turn number so the user remembers
1154 // they're reading history rather than the live conversation.
1155 if i.scrollOffset > 0 && len(visibleChat) > 0 {
1156 var text string
1157 if i.parkedTurn > 0 && i.parkedTotal > 0 {
1158 text = fmt.Sprintf(" ↑ viewing turn %d of %d - %d lines more below (pgdn / end)",
1159 i.parkedTurn, i.parkedTotal, i.scrollOffset)
1160 } else {
1161 text = fmt.Sprintf(" ↑ %d lines more below (end to jump)", i.scrollOffset)
1162 }
1163 note := i.cfg.Theme.FG256(i.cfg.Theme.Muted, text)
1164 visibleChat = append([]string{note}, visibleChat...)
1165 if len(visibleChat) > chatRows {
1166 visibleChat = visibleChat[:chatRows]
1167 }
1168 }
1169
1170 // Default: the real terminal cursor sits on the main editor's
1171 // input position. In main-screen log mode cursor rows are relative
1172 // to the fixed bottom band, not the chat transcript.
1173 // dialogLead is 1 when the bottom region prepends a blank above
1174 // the dialog block (whenever a dialog is showing) so popup-side
1175 // cursor positions still land in the right cell.
1176 dialogLead := 0
1177 if len(dialog) > 0 {
1178 dialogLead = 1
1179 }
1180 // +2 accounts for the blank row above statusLines (so the
1181 // status block has air above it) and the blank row between
1182 // statusLines and edLines (input breathing room). Without
1183 // these the rendered cursor would land on a blank instead of
1184 // inside the editor row.
1185 cursorRow := dialogLead + len(dialog) + len(suggest) + len(queue) + 1 + len(statusLines) + 1 + curR
1186 cursorCol := curC
1187 if i.btwDialog.Active() {
1188 if r, c := i.btwDialog.CursorPos(cols); r >= 0 {
1189 cursorRow = dialogLead + r
1190 cursorCol = c
1191 }
1192 }
1193 if i.dialog.Active() {
1194 if r, c := i.dialog.CursorPos(cols); r >= 0 {
1195 cursorRow = dialogLead + r
1196 cursorCol = c
1197 }
1198 }
1199 if i.sessionDialog.Active() {
1200 if r, c := i.sessionDialog.CursorPos(); r >= 0 {
1201 cursorRow = dialogLead + r
1202 cursorCol = c
1203 }
1204 }
1205 if i.swarmDialog.Active() {
1206 if r, c := i.swarmDialog.CursorPos(cols); r >= 0 {
1207 cursorRow = dialogLead + r
1208 cursorCol = c
1209 } else {
1210 // Dashboard list / transcript view has no caret. Without
1211 // this branch the default cursorRow points at the
1212 // (hidden) main editor row, so the terminal would draw
1213 // a stray block somewhere in the chat region.
1214 cursorRow = -1
1215 cursorCol = 0
1216 }
1217 }
1218 if i.extPanel.Active() {
1219 cursorRow = -1
1220 cursorCol = 0
1221 }
1222 _ = visibleChat // maintained for legacy scroll state/indicators; DrawLog owns chat viewport.
1223 i.rend.DrawLog(chat, bottom, cursorRow, cursorCol)
1224}
1225
1226func hasImageEscape(line string) bool {
1227 return strings.Contains(line, "\x1b]1337;File=") || strings.Contains(line, "\x1b_G")
1228}
1229
1230// snapViewportStartToImageBlock treats inline images as atomic blocks for
1231// scrolling. Terminal image protocols draw from a single escape row into a
1232// separate graphics layer; the following blank rows are only zot's reserved
1233// footprint. If the viewport starts on one of those blank rows, there is no
1234// correct partial-image state to render. Snap back to the escape row instead
1235// so the image is either shown from its beginning or skipped entirely.
1236func snapViewportStartToImageBlock(chat []string, start int) int {
1237 if start <= 0 || start >= len(chat) {
1238 return start
1239 }
1240 if hasImageEscape(chat[start]) || !isBoxBlankLine(chat[start]) {
1241 return start
1242 }
1243 for k := start - 1; k >= 0; k-- {
1244 line := chat[k]
1245 if hasImageEscape(line) {
1246 return k
1247 }
1248 if !isBoxBlankLine(line) {
1249 break
1250 }
1251 }
1252 return start
1253}
1254
1255const hiddenOpenAIImageMirrorPrefix = "Tool output included the following image content:"
1256
1257func filterHiddenTranscriptMessages(msgs []provider.Message) []provider.Message {
1258 if len(msgs) == 0 {
1259 return nil
1260 }
1261 out := make([]provider.Message, 0, len(msgs))
1262 for _, m := range msgs {
1263 if isHiddenTranscriptMessage(m) {
1264 continue
1265 }
1266 out = append(out, m)
1267 }
1268 return out
1269}
1270
1271func isHiddenTranscriptMessage(m provider.Message) bool {
1272 if m.Role != provider.RoleUser || len(m.Content) == 0 {
1273 return false
1274 }
1275 tb, ok := m.Content[0].(provider.TextBlock)
1276 if !ok {
1277 return false
1278 }
1279 return strings.TrimSpace(tb.Text) == hiddenOpenAIImageMirrorPrefix
1280}
1281
1282func clipBottomClippedImages(lines []string) []string {
1283 if len(lines) == 0 {
1284 return lines
1285 }
1286 out := append([]string(nil), lines...)
1287 for i, line := range out {
1288 if !hasImageEscape(line) {
1289 continue
1290 }
1291 // Image blocks render as: image escape, zero or more blank
1292 // reservation rows, then the muted "image - ..." info line,
1293 // then one trailing blank. If the info line isn't visible in
1294 // the current chat slice, the image would paint down into the
1295 // fixed status bar area. Suppress that image for this frame.
1296 //
1297 // When the image lives inside a tool box, the reservation rows
1298 // are wrapped in vertical box edges ("│ … │"); those rows
1299 // look non-blank under a naive whitespace check but are still
1300 // reservation rows for this scan, so treat them as blank.
1301 foundInfo := false
1302 for j := i + 1; j < len(out); j++ {
1303 if strings.Contains(out[j], "image - ") {
1304 foundInfo = true
1305 break
1306 }
1307 if !isBoxBlankLine(out[j]) {
1308 break
1309 }
1310 }
1311 if !foundInfo {
1312 out[i] = ""
1313 }
1314 }
1315 return out
1316}
1317
1318// isBoxBlankLine reports whether line is visually empty after
1319// stripping ANSI escape sequences, surrounding whitespace, and the
1320// vertical box edges drawn by the tool-box renderer. Used by
1321// clipBottomClippedImages so an image's reservation rows still count
1322// as blank when those rows are wrapped in "│ … │" inside a tool box.
1323func isBoxBlankLine(line string) bool {
1324 stripped := stripANSIBytes(line)
1325 stripped = strings.TrimSpace(stripped)
1326 stripped = strings.Trim(stripped, "│")
1327 stripped = strings.TrimSpace(stripped)
1328 return stripped == ""
1329}
1330
1331// stripANSIBytes removes ANSI CSI escape sequences (ESC '[' … final
1332// byte) from s without pulling in the regexp package. Mirrors the
1333// internal helper in package tui; the duplicated copy avoids exporting
1334// it just for one caller.
1335func stripANSIBytes(s string) string {
1336 if !strings.Contains(s, "\x1b") {
1337 return s
1338 }
1339 var b strings.Builder
1340 b.Grow(len(s))
1341 i := 0
1342 for i < len(s) {
1343 if s[i] == 0x1b && i+1 < len(s) && s[i+1] == '[' {
1344 end := i + 2
1345 for end < len(s) {
1346 c := s[end]
1347 end++
1348 if c >= 0x40 && c <= 0x7e {
1349 break
1350 }
1351 }
1352 i = end
1353 continue
1354 }
1355 b.WriteByte(s[i])
1356 i++
1357 }
1358 return b.String()
1359}
1360
1361// truncateLine shortens s so it fits within n display cells, with an
1362// ellipsis if trimmed. Used by the "sliding in" chips so a pasted
1363// novel doesn't blow past the status line.
1364func panelKeyName(k tui.Key) string {
1365 switch k.Kind {
1366 case tui.KeyUp:
1367 return "up"
1368 case tui.KeyDown:
1369 return "down"
1370 case tui.KeyLeft:
1371 return "left"
1372 case tui.KeyRight:
1373 return "right"
1374 case tui.KeyEnter:
1375 return "enter"
1376 case tui.KeyEsc:
1377 return "esc"
1378 case tui.KeyTab:
1379 return "tab"
1380 case tui.KeyBackspace:
1381 return "backspace"
1382 case tui.KeyDelete:
1383 return "delete"
1384 case tui.KeyHome:
1385 return "home"
1386 case tui.KeyEnd:
1387 return "end"
1388 case tui.KeyPageUp:
1389 return "pageup"
1390 case tui.KeyPageDown:
1391 return "pagedown"
1392 case tui.KeyRune:
1393 return "rune"
1394 default:
1395 return "unknown"
1396 }
1397}
1398
1399func panelKeyText(k tui.Key) string {
1400 if k.Kind == tui.KeyRune {
1401 return string(k.Rune)
1402 }
1403 return ""
1404}
1405
1406func truncateLine(s string, n int) string {
1407 if n <= 0 {
1408 return ""
1409 }
1410 // Collapse newlines — chips are single line.
1411 s = strings.ReplaceAll(s, "\n", " ↩ ")
1412 runes := []rune(s)
1413 if len(runes) <= n {
1414 return s
1415 }
1416 if n <= 1 {
1417 return "…"
1418 }
1419 return string(runes[:n-1]) + "…"
1420}
1421
1422// ctrlCExitWindow is how long after a ctrl+c press a *second* press
1423// will exit instead of just clearing input. Long enough to be
1424// deliberate (rules out accidental key chord), short enough that the
1425// hint stays meaningful.
1426const ctrlCExitWindow = 2 * time.Second
1427
1428// armCtrlCExit records the timestamp of the current ctrl+c so the next
1429// one within ctrlCExitWindow exits.
1430func (i *Interactive) armCtrlCExit() {
1431 i.mu.Lock()
1432 i.lastCtrlC = time.Now()
1433 i.mu.Unlock()
1434}
1435
1436// ctrlCExitArmed reports whether a previous ctrl+c was recent enough
1437// that another press should now exit.
1438func (i *Interactive) ctrlCExitArmed() bool {
1439 i.mu.Lock()
1440 t := i.lastCtrlC
1441 i.mu.Unlock()
1442 return !t.IsZero() && time.Since(t) <= ctrlCExitWindow
1443}
1444
1445func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) {
1446 // Any key that isn't ctrl+c invalidates an armed ctrl+c-exit, so
1447 // pressing ctrl+c then typing then ctrl+c much later doesn't quit
1448 // unexpectedly. The hint message also goes stale; clear it.
1449 if k.Kind != tui.KeyCtrlC {
1450 i.mu.Lock()
1451 if !i.lastCtrlC.IsZero() {
1452 i.lastCtrlC = time.Time{}
1453 if strings.HasPrefix(i.statusOK, "input cleared") || strings.HasPrefix(i.statusOK, "press ctrl+c") {
1454 i.statusOK = ""
1455 }
1456 }
1457 i.mu.Unlock()
1458 }
1459
1460 // Dialogs consume keys while open (except ctrl+c, which always closes them).
1461
1462 // Confirm dialog has highest priority: the agent goroutine is
1463 // blocked waiting for an answer, so we must not let keys leak
1464 // anywhere else while it's up.
1465 if i.confirmDialog.Active() {
1466 i.confirmDialog.HandleKey(k)
1467 i.invalidate()
1468 return false
1469 }
1470 if i.dialog.Active() {
1471 if k.Kind == tui.KeyCtrlC {
1472 i.dialog.Close()
1473 if i.cfg.AuthManager != nil {
1474 i.cfg.AuthManager.CancelOAuth()
1475 }
1476 return false
1477 }
1478 act := i.dialog.HandleKey(k)
1479 if act.StartAPIKey {
1480 i.startAPIKeyFlow(act.Provider)
1481 }
1482 if act.StartOAuth {
1483 i.startOAuthFlow(act.Provider)
1484 }
1485 if act.StartManual {
1486 i.startManualOAuthFlow(act.Provider)
1487 }
1488 if act.SubmitCode != "" {
1489 i.submitManualOAuthCode(act.SubmitCode)
1490 }
1491 return false
1492 }
1493 if i.modelDialog.Active() {
1494 if k.Kind == tui.KeyCtrlC {
1495 i.modelDialog.Close()
1496 return false
1497 }
1498 act := i.modelDialog.HandleKey(k)
1499 if act.Select {
1500 i.applyModelSelection(act.Provider, act.Model)
1501 }
1502 return false
1503 }
1504 if i.rescueDialog.Active() {
1505 if k.Kind == tui.KeyCtrlC {
1506 i.rescueDialog.Close()
1507 i.invalidate()
1508 return false
1509 }
1510 act := i.rescueDialog.HandleKey(k)
1511 if act.Select {
1512 i.applyRescueSelection(act.Provider, act.Model, act.Prompt)
1513 }
1514 i.invalidate()
1515 return false
1516 }
1517 if i.sessionDialog.Active() {
1518 if k.Kind == tui.KeyCtrlC {
1519 i.sessionDialog.Close()
1520 return false
1521 }
1522 act := i.sessionDialog.HandleKey(k)
1523 if act.Select {
1524 i.applySessionSelection(act.Path)
1525 }
1526 return false
1527 }
1528 if i.swarmDialog.Active() {
1529 if k.Kind == tui.KeyCtrlC {
1530 i.swarmDialog.Close()
1531 i.invalidate()
1532 return false
1533 }
1534 _, msg, errMsg := i.swarmDialog.HandleKey(k)
1535 if msg != "" || errMsg != "" {
1536 i.mu.Lock()
1537 i.statusOK = msg
1538 i.statusErr = errMsg
1539 i.mu.Unlock()
1540 }
1541 i.invalidate()
1542 return false
1543 }
1544 if i.logoutDialog.Active() {
1545 if k.Kind == tui.KeyCtrlC {
1546 i.logoutDialog.Close()
1547 i.invalidate()
1548 return false
1549 }
1550 act := i.logoutDialog.HandleKey(k)
1551 if act.Select {
1552 i.doLogout(act.Target)
1553 }
1554 i.invalidate()
1555 return false
1556 }
1557 if i.telegramDialog.Active() {
1558 if k.Kind == tui.KeyCtrlC {
1559 i.telegramDialog.Close()
1560 i.invalidate()
1561 return false
1562 }
1563 act := i.telegramDialog.HandleKey(k)
1564 if act.Select {
1565 i.doTelegram(act.Action)
1566 }
1567 i.invalidate()
1568 return false
1569 }
1570 if i.settingsDialog.Active() {
1571 if k.Kind == tui.KeyCtrlC {
1572 i.settingsDialog.Close()
1573 i.invalidate()
1574 return false
1575 }
1576 act := i.settingsDialog.HandleKey(k)
1577 if act.Toggle {
1578 i.applySettingToggle(act.Key, act.Value)
1579 }
1580 i.invalidate()
1581 return false
1582 }
1583 if i.sessionOpsDialog.Active() {
1584 if k.Kind == tui.KeyCtrlC {
1585 i.sessionOpsDialog.Close()
1586 i.invalidate()
1587 return false
1588 }
1589 act := i.sessionOpsDialog.HandleKey(k)
1590 if act.Select {
1591 i.doSessionOp(act.Action, "")
1592 }
1593 i.invalidate()
1594 return false
1595 }
1596 if i.sessionTreeDialog.Active() {
1597 if k.Kind == tui.KeyCtrlC {
1598 i.sessionTreeDialog.Close()
1599 i.invalidate()
1600 return false
1601 }
1602 act := i.sessionTreeDialog.HandleKey(k)
1603 if act.Select {
1604 i.applySessionTreeSelection(act.Path)
1605 }
1606 i.invalidate()
1607 return false
1608 }
1609 if i.extPanel.Active() {
1610 if k.Kind == tui.KeyCtrlC || k.Kind == tui.KeyEsc {
1611 if i.cfg.Extensions != nil {
1612 _ = i.cfg.Extensions.SendPanelClose(i.extPanel.ext, i.extPanel.id)
1613 }
1614 i.extPanel.Close()
1615 i.invalidate()
1616 return false
1617 }
1618 if i.cfg.Extensions != nil {
1619 _ = i.cfg.Extensions.SendPanelKey(i.extPanel.ext, i.extPanel.id, panelKeyName(k), panelKeyText(k))
1620 }
1621 return false
1622 }
1623 if i.jumpDialog.Active() {
1624 if k.Kind == tui.KeyCtrlC {
1625 i.jumpDialog.Close()
1626 i.pendingFork = false
1627 return false
1628 }
1629 act := i.jumpDialog.HandleKey(k)
1630 if act.Select {
1631 if i.pendingFork {
1632 i.applyForkSelection(act.MessageIdx)
1633 } else {
1634 i.applyJumpSelection(act.MessageIdx, act.TurnNo)
1635 }
1636 }
1637 // If the user dismissed the dialog without selecting, also
1638 // clear the pending-fork flag so a later plain /jump isn't
1639 // hijacked.
1640 if act.Close {
1641 i.pendingFork = false
1642 }
1643 return false
1644 }
1645 if i.btwDialog.Active() {
1646 if k.Kind == tui.KeyCtrlC {
1647 i.btwDialog.Close()
1648 i.invalidate()
1649 return false
1650 }
1651 i.btwDialog.HandleKey(k, i.invalidate)
1652 return false
1653 }
1654 if i.skillsDialog.Active() {
1655 if k.Kind == tui.KeyCtrlC {
1656 i.skillsDialog.Close()
1657 i.invalidate()
1658 return false
1659 }
1660 i.skillsDialog.HandleKey(k)
1661 i.invalidate()
1662 return false
1663 }
1664 if i.changelogDialog.Active() {
1665 if closed := i.changelogDialog.HandleKey(k); closed {
1666 // User dismissed; let the parent persist the
1667 // LastChangelogShown marker via the close callback.
1668 if i.cfg.OnChangelogDismiss != nil {
1669 i.cfg.OnChangelogDismiss()
1670 }
1671 }
1672 i.invalidate()
1673 return false
1674 }
1675
1676 // Global keys.
1677 switch k.Kind {
1678 case tui.KeyCtrlC:
1679 i.mu.Lock()
1680 loadingSession := i.sessionLoading
1681 i.mu.Unlock()
1682 if loadingSession {
1683 return true
1684 }
1685 // While busy: do NOT cancel the turn. ctrl+c during a
1686 // running turn is almost always reflex muscle memory
1687 // ("be quiet" in a shell) rather than a deliberate
1688 // decision to kill a multi-minute model call that's
1689 // already cost tokens. Use esc to interrupt a turn; use
1690 // a deliberate double-ctrl+c to exit zot entirely. First
1691 // press arms the exit hint, second press within
1692 // ctrlCExitWindow quits.
1693 if i.busy {
1694 if i.ctrlCExitArmed() {
1695 return true
1696 }
1697 i.mu.Lock()
1698 i.statusOK = "press ctrl+c again to exit, esc to cancel the turn"
1699 i.statusErr = ""
1700 i.mu.Unlock()
1701 i.armCtrlCExit()
1702 return false
1703 }
1704 // Idle: first press clears the editor (and any queued
1705 // follow-up messages); a second press within ctrlCExitWindow
1706 // exits. With both an empty editor and no queue the first
1707 // press still just arms — require a deliberate double-tap.
1708 ag := i.agent
1709 pending := 0
1710 if ag != nil {
1711 pending = ag.QueuedMessageCount()
1712 }
1713 hadInput := !i.ed.IsEmpty() || len(i.queued) > 0 || pending > 0
1714 if hadInput {
1715 i.ed.Clear()
1716 i.suggest.Reset()
1717 if ag != nil {
1718 ag.DrainQueuedMessages()
1719 }
1720 i.mu.Lock()
1721 i.queued = nil
1722 i.statusOK = "input cleared"
1723 i.statusErr = ""
1724 i.mu.Unlock()
1725 i.armCtrlCExit()
1726 return false
1727 }
1728 if i.ctrlCExitArmed() {
1729 return true
1730 }
1731 i.mu.Lock()
1732 i.statusOK = "press ctrl+c again to exit"
1733 i.statusErr = ""
1734 i.mu.Unlock()
1735 i.armCtrlCExit()
1736 return false
1737 case tui.KeyEsc:
1738 // Esc interrupts a running turn — but only when nothing
1739 // else on screen wants to consume the key first. The slash
1740 // popup has its own Esc behaviour (close + clear editor),
1741 // and transient overlays like the /help block and extension
1742 // notes should dismiss on Esc before we even consider the
1743 // turn. Without these guards, a casual Esc press after
1744 // running /help on a busy turn rips the turn away.
1745 if i.suggest.Active(i.ed.Value()) || i.fileSuggest.Active(i.ed.Value()) {
1746 break
1747 }
1748 i.mu.Lock()
1749 hadHelp := len(i.helpBlock) > 0
1750 hadNotes := len(i.extNotes) > 0
1751 if hadHelp {
1752 i.helpBlock = nil
1753 }
1754 if hadNotes {
1755 i.extNotes = nil
1756 }
1757 i.mu.Unlock()
1758 if hadHelp || hadNotes {
1759 i.invalidate()
1760 return false
1761 }
1762 if i.busy && i.cancelTurn != nil {
1763 i.cancelTurn()
1764 // If a confirm dialog is pending, refuse it so the agent
1765 // goroutine unblocks and the context cancellation can
1766 // actually take effect.
1767 i.confirmDialog.CancelAll("turn cancelled")
1768 return false
1769 }
1770 case tui.KeyCtrlD:
1771 if i.ed.IsEmpty() && !i.busy {
1772 return true
1773 }
1774 case tui.KeyCtrlL:
1775 i.rend.Clear()
1776 i.invalidate()
1777 return false
1778 case tui.KeyCtrlO:
1779 // Toggle expansion of collapsed tool results. Affects every tool
1780 // call in the transcript — press again to re-collapse.
1781 // In main-screen scrollback mode this changes already-emitted
1782 // transcript rows, so do a full clear+replay instead of trying
1783 // to edit old scrollback in place.
1784 i.mu.Lock()
1785 i.view.ExpandAll = !i.view.ExpandAll
1786 if i.rend != nil {
1787 i.rend.Clear()
1788 }
1789 i.mu.Unlock()
1790 i.invalidate()
1791 return false
1792 case tui.KeyPageUp:
1793 i.scrollBy(+i.chatPage())
1794 return false
1795 case tui.KeyPageDown:
1796 i.scrollBy(-i.chatPage())
1797 return false
1798 case tui.KeyUp:
1799 // Alt/Option+Up: pop the most recently queued ("sliding in")
1800 // message back into the editor so the user can edit and
1801 // resend it. Repeated presses keep peeling messages off the
1802 // tail of the queue; each press *replaces* the editor
1803 // contents (we don't append/push). When the queue is empty
1804 // the keypress falls through to the normal scroll behavior.
1805 if k.Alt {
1806 i.mu.Lock()
1807 var text string
1808 if i.agent != nil {
1809 text, _ = i.agent.PopQueuedMessage()
1810 }
1811 if text == "" {
1812 if n := len(i.queued); n > 0 {
1813 text = i.queued[n-1]
1814 i.queued = i.queued[:n-1]
1815 }
1816 }
1817 i.mu.Unlock()
1818 if text != "" {
1819 i.ed.SetValue(text)
1820 i.inputHistoryIndex = -1
1821 i.invalidate()
1822 return false
1823 }
1824 i.mu.Unlock()
1825 }
1826 // Always use up/down for chat scrolling, even when the editor
1827 // contains text. This makes keyboard scrolling consistent with
1828 // a draft present at the cost of disabling vertical cursor
1829 // motion inside the multi-line editor. Keep slash-popup
1830 // navigation working by letting it intercept later when active.
1831 if !i.suggest.Active(i.ed.Value()) && !i.fileSuggest.Active(i.ed.Value()) {
1832 i.scrollBy(+3)
1833 return false
1834 }
1835 case tui.KeyDown:
1836 if !i.suggest.Active(i.ed.Value()) && !i.fileSuggest.Active(i.ed.Value()) {
1837 if i.scrollOffset > 0 {
1838 i.scrollBy(-3)
1839 }
1840 return false
1841 }
1842 }
1843
1844 // Note: we intentionally do NOT gate the editor on i.busy here.
1845 // Typing while the agent is working is supported — submitted
1846 // messages are queued and delivered as follow-up turns when the
1847 // current turn ends. See the submit handler below.
1848
1849 if k.Kind == tui.KeyEnter && k.Alt {
1850 i.ed.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: '\n', Alt: true})
1851 return false
1852 }
1853
1854 // Slash suggestions: intercept up/down/tab/enter when the popup is visible.
1855 if i.suggest.Active(i.ed.Value()) {
1856 switch k.Kind {
1857 case tui.KeyUp:
1858 i.suggest.Up()
1859 return false
1860 case tui.KeyDown:
1861 i.suggest.Down()
1862 return false
1863 case tui.KeyTab:
1864 if name := i.suggest.Selection(i.ed.Value()); name != "" {
1865 i.ed.SetValue(name)
1866 i.suggest.Reset()
1867 }
1868 return false
1869 case tui.KeyEnter:
1870 // Enter on an ambiguous or partial slash prefix: complete to the
1871 // currently highlighted command and run it. That way typing
1872 // "/lo" + enter picks whichever of /login or /logout is selected
1873 // in the popup instead of submitting "/lo" as unknown. Also
1874 // clear the editor so the command doesn't linger after the
1875 // dialog opens/closes.
1876 if name := i.suggest.Selection(i.ed.Value()); name != "" {
1877 i.ed.Clear()
1878 i.suggest.Reset()
1879 return i.runSlash(ctx, name)
1880 }
1881 case tui.KeyEsc:
1882 i.ed.Clear()
1883 i.suggest.Reset()
1884 return false
1885 }
1886 }
1887
1888 // File suggestions: intercept up/down/tab/enter when the @-popup is visible.
1889 if i.fileSuggest.Active(i.ed.Value()) {
1890 switch k.Kind {
1891 case tui.KeyUp:
1892 i.fileSuggest.Up()
1893 return false
1894 case tui.KeyDown:
1895 i.fileSuggest.Down()
1896 return false
1897 case tui.KeyRight:
1898 // Open selected directory.
1899 i.fileSuggest.Right()
1900 return false
1901 case tui.KeyLeft:
1902 // Go back to parent directory.
1903 i.fileSuggest.Left()
1904 return false
1905 case tui.KeyEnter:
1906 if entry, ok := i.fileSuggest.SelectedEntry(i.ed.Value()); ok {
1907 var chip string
1908 if entry.isDir {
1909 chip = "[dir:" + entry.rel + "/]"
1910 } else {
1911 chip = "[file:" + entry.rel + "]"
1912 }
1913 val := i.ed.Value()
1914 if idx := strings.LastIndex(val, "@"); idx >= 0 {
1915 val = val[:idx]
1916 }
1917 i.ed.SetValue(val + chip + " ")
1918 i.fileSuggest.Reset()
1919 }
1920 return false
1921 case tui.KeyEsc:
1922 val := i.ed.Value()
1923 if idx := strings.LastIndex(val, "@"); idx >= 0 {
1924 i.ed.SetValue(val[:idx])
1925 }
1926 i.fileSuggest.Reset()
1927 return false
1928 }
1929 }
1930
1931 // Tab-complete a path token in the editor when no popup is open.
1932 // Recognises tokens that look like paths (start with ~, /, ./, ../
1933 // or contain a slash); shell-style completion expands ~, lists the
1934 // parent dir, and completes the basename to the longest common
1935 // prefix. Single match: full replace and trailing / for dirs.
1936 // No match: no-op. Plain bare words (no slash, no tilde) fall
1937 // through so Tab keeps its current no-op behaviour outside paths.
1938 if k.Kind == tui.KeyTab && !i.suggest.Active(i.ed.Value()) && !i.fileSuggest.Active(i.ed.Value()) {
1939 if i.tryPathTabComplete() {
1940 return false
1941 }
1942 }
1943
1944 if i.handleInputHistoryKey(k) {
1945 return false
1946 }
1947 if i.inputHistoryIndex >= 0 && k.Kind != tui.KeyLeft && k.Kind != tui.KeyRight {
1948 i.inputHistoryIndex = -1
1949 }
1950
1951 if submit := i.ed.HandleKey(k); submit {
1952 // SubmitValue() expands any [pasted text #N +L lines]
1953 // placeholders back into their bodies; the raw Value()
1954 // is only what the user sees on screen.
1955 text := strings.TrimRight(i.ed.SubmitValue(), "\n")
1956 // Expand [file:name] and [dir:name/] chips to full paths.
1957 text = expandFileChips(text, i.cfg.CWD)
1958 if text == "" {
1959 return false
1960 }
1961 i.ed.Clear()
1962 i.inputHistoryIndex = -1
1963 i.suggest.Reset()
1964 i.fileSuggest.Reset()
1965
1966 if looksLikeSlashCommand(text) {
1967 head := text
1968 rest := ""
1969 if idx := strings.IndexAny(text, " \t"); idx >= 0 {
1970 head = text[:idx]
1971 rest = strings.TrimSpace(text[idx:])
1972 }
1973 if !isKnownSlashCommand(text) {
1974 // Try extensions before giving up. Extensions register
1975 // commands by bare name (no leading slash); strip it here.
1976 extName := strings.TrimPrefix(head, "/")
1977 if i.cfg.Extensions != nil && i.cfg.Extensions.HasCommand(extName) {
1978 go i.invokeExtensionCommand(ctx, extName, rest)
1979 return false
1980 }
1981 i.mu.Lock()
1982 i.statusErr = "unknown command " + head + " — type /help to see the list"
1983 i.statusOK = ""
1984 i.mu.Unlock()
1985 return false
1986 }
1987 // Slash commands run regardless of busy state. Commands that
1988 // would mutate the transcript or replace the agent (/clear,
1989 // /compact, /logout, /login, /model) cancel the active turn
1990 // first and wait for the goroutine to wind down so they don't
1991 // race with a streaming response. Safe commands (/help,
1992 // /jump, /sessions, /jail, /unjail, /exit) run immediately
1993 // without disturbing the active turn.
1994 if slashCancelsTurn(head) {
1995 i.cancelAndWaitForIdle()
1996 }
1997 return i.runSlash(ctx, text)
1998 }
1999
2000 if i.agent == nil {
2001 i.mu.Lock()
2002 i.statusErr = "not logged in. type /login first."
2003 i.mu.Unlock()
2004 return false
2005 }
2006 // Mirror the user's typed prompt into the paired Telegram
2007 // chat (when the bridge is active) so the Telegram thread
2008 // stays a complete record of the session, not just the half
2009 // that originated on the phone. On a goroutine so the
2010 // network write doesn't delay the local turn.
2011 if i.telegramBridge != nil && i.telegramBridge.Active() {
2012 go i.telegramBridge.OnUserTyped(text)
2013 }
2014 // If a turn is already in flight, queue this prompt inside the
2015 // agent loop so it is delivered at the next safe model-call
2016 // boundary instead of waiting for the whole run to finish.
2017 i.mu.Lock()
2018 busy := i.busy
2019 ag := i.agent
2020 i.mu.Unlock()
2021 if busy {
2022 if ag != nil {
2023 ag.QueueMessage(text)
2024 } else {
2025 i.mu.Lock()
2026 i.queued = append(i.queued, text)
2027 i.mu.Unlock()
2028 }
2029 i.invalidate()
2030 return false
2031 }
2032 i.startTurn(ctx, text)
2033 }
2034 return false
2035}
2036
2037func (i *Interactive) handleInputHistoryKey(k tui.Key) bool {
2038 if k.Kind != tui.KeyLeft && k.Kind != tui.KeyRight {
2039 return false
2040 }
2041 // Do not steal normal cursor movement. History browsing can only
2042 // start from an empty editor; once active, Left/Right keep walking
2043 // the ring so repeated presses work even though the editor now
2044 // contains the selected historical prompt.
2045 if i.inputHistoryIndex < 0 && !i.ed.IsEmpty() {
2046 return false
2047 }
2048 hist := i.inputHistory()
2049 if len(hist) == 0 {
2050 return false
2051 }
2052
2053 if i.inputHistoryIndex < 0 {
2054 // Start just after the newest item so Left lands on the most
2055 // recent user prompt and Right keeps the editor empty.
2056 i.inputHistoryIndex = len(hist)
2057 }
2058
2059 switch k.Kind {
2060 case tui.KeyLeft:
2061 if i.inputHistoryIndex > 0 {
2062 i.inputHistoryIndex--
2063 }
2064 case tui.KeyRight:
2065 if i.inputHistoryIndex < len(hist) {
2066 i.inputHistoryIndex++
2067 }
2068 }
2069
2070 if i.inputHistoryIndex >= len(hist) {
2071 i.ed.Clear()
2072 } else {
2073 i.ed.SetValue(hist[i.inputHistoryIndex])
2074 }
2075 return true
2076}
2077
2078func (i *Interactive) inputHistory() []string {
2079 if i.agent == nil {
2080 return nil
2081 }
2082 msgs := i.agent.Messages()
2083 hist := make([]string, 0, len(msgs))
2084 for _, m := range msgs {
2085 if m.Role != provider.RoleUser || isHiddenTranscriptMessage(m) {
2086 continue
2087 }
2088 text := userMessageText(m)
2089 if strings.TrimSpace(text) == "" {
2090 continue
2091 }
2092 hist = append(hist, text)
2093 }
2094 return hist
2095}
2096
2097func userMessageText(m provider.Message) string {
2098 var sb strings.Builder
2099 for _, c := range m.Content {
2100 if tb, ok := c.(provider.TextBlock); ok {
2101 if sb.Len() > 0 {
2102 sb.WriteByte('\n')
2103 }
2104 sb.WriteString(tb.Text)
2105 }
2106 }
2107 return sb.String()
2108}
2109
2110// invokeExtensionCommand fires an extension-registered slash command
2111// in a background goroutine, awaits the response, and applies the
2112// requested action (prompt / insert / display / noop). Errors and
2113// timeouts surface as a status_err line.
2114
2115func (i *Interactive) invokeExtensionCommand(ctx context.Context, name, args string) {
2116 resp, err := i.cfg.Extensions.Invoke(ctx, name, args, 30*time.Second)
2117 if err != nil {
2118 i.mu.Lock()
2119 i.statusErr = "extension /" + name + ": " + err.Error()
2120 i.statusOK = ""
2121 i.mu.Unlock()
2122 i.invalidate()
2123 return
2124 }
2125 if resp.Error != "" {
2126 i.mu.Lock()
2127 i.statusErr = "extension /" + name + ": " + resp.Error
2128 i.statusOK = ""
2129 i.mu.Unlock()
2130 i.invalidate()
2131 return
2132 }
2133 switch resp.Action {
2134 case "open_panel":
2135 if resp.OpenPanel != nil {
2136 extName := name
2137 if i.cfg.Extensions != nil {
2138 if owner := i.cfg.Extensions.CommandOwner(name); owner != "" {
2139 extName = owner
2140 }
2141 }
2142 i.OpenPanel(extName, *resp.OpenPanel)
2143 }
2144 case "prompt":
2145 if strings.TrimSpace(resp.Prompt) == "" {
2146 return
2147 }
2148 i.startTurn(i.runCtx, resp.Prompt)
2149 case "insert":
2150 i.ed.Insert(resp.Insert)
2151 i.invalidate()
2152 case "display":
2153 i.appendExtensionNote(name, resp.Display, "info")
2154 case "noop", "":
2155 // nothing
2156 default:
2157 i.mu.Lock()
2158 i.statusErr = "extension /" + name + ": unknown action " + resp.Action
2159 i.mu.Unlock()
2160 i.invalidate()
2161 }
2162}
2163
2164// appendExtensionNote renders an extension-originated note in the
2165// chat. Levels: "info" (muted), "warn" (warning), "error" (error),
2166// "success" (tool/ok green).
2167func (i *Interactive) appendExtensionNote(extName, msg, level string) {
2168 if msg == "" {
2169 return
2170 }
2171 i.mu.Lock()
2172 defer i.mu.Unlock()
2173 color := i.cfg.Theme.Muted
2174 switch level {
2175 case "warn":
2176 color = i.cfg.Theme.Warning
2177 case "error":
2178 color = i.cfg.Theme.Error
2179 case "success":
2180 color = i.cfg.Theme.Tool
2181 }
2182 prefix := i.cfg.Theme.FG256(i.cfg.Theme.Accent, "["+extName+"] ")
2183 for _, line := range strings.Split(msg, "\n") {
2184 i.statusOK = "" // clear any stale ok
2185 i.statusErr = ""
2186 i.extNotes = append(i.extNotes, prefix+i.cfg.Theme.FG256(color, line))
2187 }
2188}
2189
2190// HostHooks implementation for the extension manager. The manager
2191// holds an interface, not a concrete *Interactive, so these methods
2192// are the only thing the manager sees.
2193
2194// Notify is the manager's NotifyFromExt entry point.
2195func (i *Interactive) Notify(extName, level, message string) {
2196 i.appendExtensionNote(extName, message, level)
2197 i.invalidate()
2198}
2199
2200// Submit feeds text through the agent loop as if the user had typed it.
2201func (i *Interactive) Submit(text string) {
2202 i.startTurn(i.runCtx, text)
2203}
2204
2205// SubmitOrQueue runs text immediately if the agent is idle, or
2206// appends it to the pending queue if a turn is already in flight.
2207// Used by the telegram bridge (and by the editor submit path) so
2208// both input sources share the same "queue behind an active turn"
2209// semantics. Images are ignored for now — only the text prompt is
2210// forwarded — because the queued-prompt path is text-only; a
2211// follow-up can expand the queue entry to carry images.
2212func (i *Interactive) SubmitOrQueue(text string, images []provider.ImageBlock) {
2213 i.mu.Lock()
2214 if i.agent == nil {
2215 i.statusErr = "not logged in. type /login first."
2216 i.mu.Unlock()
2217 i.invalidate()
2218 return
2219 }
2220 if i.busy {
2221 // Queue text only; images are dropped for queued prompts.
2222 ag := i.agent
2223 i.mu.Unlock()
2224 if ag != nil {
2225 ag.QueueMessage(text)
2226 } else {
2227 i.mu.Lock()
2228 i.queued = append(i.queued, text)
2229 i.mu.Unlock()
2230 }
2231 i.invalidate()
2232 return
2233 }
2234 i.mu.Unlock()
2235 i.startTurnWithImages(i.runCtx, text, images)
2236}
2237
2238// CancelTurn aborts the active turn if one is running. Used by the
2239// telegram bridge when the paired user sends /stop.
2240// ChangelogVersion returns the version string of the changelog
2241// currently shown (or last shown). Used by the dismiss callback
2242// to store the correct version for dev builds.
2243func (i *Interactive) ChangelogVersion() string {
2244 if i.changelogDialog != nil {
2245 return i.changelogDialog.version
2246 }
2247 return ""
2248}
2249
2250func (i *Interactive) CancelTurn() {
2251 i.mu.Lock()
2252 cancel := i.cancelTurn
2253 i.mu.Unlock()
2254 if cancel != nil {
2255 cancel()
2256 i.confirmDialog.CancelAll("turn cancelled")
2257 }
2258}
2259
2260// Insert places text at the cursor in the editor.
2261func (i *Interactive) Insert(text string) {
2262 i.ed.Insert(text)
2263 i.invalidate()
2264}
2265
2266// Display appends a styled note from extName to the chat without a
2267// model call.
2268func (i *Interactive) Display(extName, text string) {
2269 i.appendExtensionNote(extName, text, "info")
2270 i.invalidate()
2271}
2272
2273func (i *Interactive) OpenPanel(extName string, spec extproto.PanelSpec) {
2274 i.mu.Lock()
2275 defer i.mu.Unlock()
2276 i.extPanel.Open(extName, spec.ID, spec.Title, spec.Lines, spec.Footer)
2277 if i.cfg.Extensions != nil {
2278 cols, rows := i.cfg.Terminal.Size()
2279 _ = cols
2280 _ = rows
2281 }
2282 i.invalidate()
2283}
2284
2285func (i *Interactive) UpdatePanel(extName, panelID, title string, lines []string, footer string) {
2286 i.mu.Lock()
2287 defer i.mu.Unlock()
2288 if i.extPanel.Active() && i.extPanel.ext == extName && i.extPanel.id == panelID {
2289 i.extPanel.Update(title, lines, footer)
2290 i.invalidate()
2291 }
2292}
2293
2294func (i *Interactive) ClosePanel(extName, panelID string) {
2295 i.mu.Lock()
2296 defer i.mu.Unlock()
2297 if i.extPanel.Active() && i.extPanel.ext == extName && i.extPanel.id == panelID {
2298 i.extPanel.Close()
2299 i.invalidate()
2300 }
2301}
2302
2303func effectiveImageProtocol(override *bool) tui.ImageProtocol {
2304 detected := tui.DetectImageProtocol()
2305 if override == nil {
2306 return detected
2307 }
2308 if !*override {
2309 return tui.ImageProtocolNone
2310 }
2311 return detected
2312}
2313
2314func imageProtocolName(p tui.ImageProtocol) string {
2315 switch p {
2316 case tui.ImageProtocolKitty:
2317 return "kitty/ghostty"
2318 case tui.ImageProtocolITerm2:
2319 return "iTerm2"
2320 default:
2321 return "none"
2322 }
2323}
2324
2325func onOff(v bool) string {
2326 if v {
2327 return "enabled"
2328 }
2329 return "disabled"
2330}
2331
2332func (i *Interactive) openSettingsDialog() {
2333 detected := tui.DetectImageProtocol()
2334 imgEnabled := detected != tui.ImageProtocolNone
2335 if i.cfg.InlineImagesEnabled != nil {
2336 imgEnabled = *i.cfg.InlineImagesEnabled
2337 }
2338 imgDisabled := detected == tui.ImageProtocolNone
2339 imgHint := ""
2340 if imgDisabled {
2341 imgEnabled = false
2342 imgHint = "this terminal does not support inline images"
2343 } else {
2344 imgHint = "terminal supports " + imageProtocolName(detected)
2345 }
2346 i.settingsDialog.Open([]settingsItem{{
2347 key: "inline_images_enabled",
2348 label: "render images when supported",
2349 desc: "draw screenshots inline instead of showing a text placeholder",
2350 value: imgEnabled,
2351 disabled: imgDisabled,
2352 hint: imgHint,
2353 }})
2354}
2355
2356func (i *Interactive) applySettingToggle(key string, value bool) {
2357 switch key {
2358 case "inline_images_enabled":
2359 val := value
2360 i.cfg.InlineImagesEnabled = &val
2361 if i.cfg.SettingsStore != nil {
2362 if err := i.cfg.SettingsStore.SetInlineImages(value); err != nil {
2363 i.mu.Lock()
2364 i.statusErr = "settings: " + err.Error()
2365 i.mu.Unlock()
2366 return
2367 }
2368 }
2369 i.mu.Lock()
2370 i.view.ImageProto = effectiveImageProtocol(i.cfg.InlineImagesEnabled)
2371 i.view.InvalidateRenderCache()
2372 i.statusOK = "inline image rendering " + onOff(value)
2373 i.statusErr = ""
2374 i.mu.Unlock()
2375 }
2376}
2377
2378// buildStudyPrompt returns the canned prompt the /study command
2379// submits to the agent.
2380//
2381// With no argument, /study targets the current directory — the
2382// historical behaviour. With an argument, /study targets that path
2383// instead; either a directory ("read every file in here") or a
2384// single file ("read this file"). The argument can be:
2385//
2386// - a relative path (resolved against cwd)
2387// - an absolute path
2388// - an @-picker chip, which has already been expanded to an
2389// absolute path by expandFileChips before runSlash sees it
2390//
2391// The path is stat'd to pick the right wording ("directory" vs
2392// "file"). If the path doesn't exist, we still build a sensible
2393// prompt rather than erroring — the agent will surface the
2394// missing-file failure itself when it tries to read it, which is
2395// more useful than a refusal here.
2396func buildStudyPrompt(arg, cwd string) string {
2397 arg = strings.TrimSpace(arg)
2398 if arg == "" {
2399 return "Read and understand everything in the current directory."
2400 }
2401 abs := arg
2402 if !filepath.IsAbs(abs) {
2403 abs = filepath.Join(cwd, abs)
2404 }
2405 display := arg
2406 if rel, err := filepath.Rel(cwd, abs); err == nil && !strings.HasPrefix(rel, "..") {
2407 display = rel
2408 }
2409 if info, err := os.Stat(abs); err == nil && !info.IsDir() {
2410 return "Read and understand the file " + display + "."
2411 }
2412 return "Read and understand everything in the directory " + display + "."
2413}
2414
2415// tryPathTabCompleteEditor looks at ed's current value, finds the
2416// path-like token immediately before the cursor (the cursor is always
2417// at the end of the buffer after a keystroke, so "before the cursor"
2418// is the trailing non-whitespace run), and rewrites it to its shell-
2419// style completion against the filesystem.
2420//
2421// Returns true when it consumed the Tab keystroke (token recognised,
2422// completion attempted — even if no candidates matched, the keystroke
2423// is still consumed so it doesn't insert a literal tab character).
2424// Returns false when the token doesn't look like a path; callers then
2425// let Tab fall through to its normal no-op.
2426//
2427// Recognised path shapes:
2428// - ~ or ~/foo expanded via os.UserHomeDir()
2429// - /abs/path or /abs/path/foo absolute
2430// - ./foo, ../foo, foo/bar relative to cwd
2431//
2432// A bare word like "hello" is not treated as a path so plain text
2433// keeps Tab as a literal no-op.
2434//
2435// Free function (not a method) so the same logic runs against the
2436// editor instances owned by btwDialog and swarmDialog without each
2437// dialog needing its own copy.
2438func tryPathTabCompleteEditor(ed *tui.Editor, cwd string) bool {
2439 if ed == nil {
2440 return false
2441 }
2442 val := ed.Value()
2443 // Find the trailing run of non-whitespace.
2444 start := len(val)
2445 for start > 0 {
2446 r := val[start-1]
2447 if r == ' ' || r == '\t' || r == '\n' {
2448 break
2449 }
2450 start--
2451 }
2452 token := val[start:]
2453 if token == "" {
2454 return false
2455 }
2456 if !looksLikePathToken(token) {
2457 return false
2458 }
2459
2460 // Resolve the absolute parent directory + base prefix to match.
2461 parentAbs, basePrefix, displayParent, ok := resolvePathTabToken(token, cwd)
2462 if !ok {
2463 return true
2464 }
2465 entries, err := os.ReadDir(parentAbs)
2466 if err != nil {
2467 return true
2468 }
2469 var names []string
2470 var isDir []bool
2471 for _, e := range entries {
2472 name := e.Name()
2473 if !strings.HasPrefix(name, basePrefix) {
2474 continue
2475 }
2476 // Hide dotfiles unless the user explicitly typed a leading dot,
2477 // mirroring bash's default behaviour.
2478 if strings.HasPrefix(name, ".") && !strings.HasPrefix(basePrefix, ".") {
2479 continue
2480 }
2481 names = append(names, name)
2482 isDir = append(isDir, e.IsDir())
2483 }
2484 if len(names) == 0 {
2485 return true
2486 }
2487
2488 var completed string
2489 var completedIsDir bool
2490 if len(names) == 1 {
2491 completed = names[0]
2492 completedIsDir = isDir[0]
2493 } else {
2494 completed = longestCommonPrefix(names)
2495 if completed == basePrefix {
2496 // Already at the deepest unambiguous prefix; nothing to add.
2497 return true
2498 }
2499 }
2500
2501 // Build the replacement token in the same display form the user
2502 // typed (preserve ~ vs absolute vs relative).
2503 newToken := displayParent + completed
2504 if len(names) == 1 && completedIsDir {
2505 newToken += "/"
2506 }
2507
2508 ed.SetValue(val[:start] + newToken)
2509 return true
2510}
2511
2512// tryPathTabComplete is the Interactive-bound convenience wrapper.
2513// It calls the free helper against the main editor and invalidates
2514// the frame on a successful rewrite.
2515func (i *Interactive) tryPathTabComplete() bool {
2516 if tryPathTabCompleteEditor(i.ed, i.cfg.CWD) {
2517 i.invalidate()
2518 return true
2519 }
2520 return false
2521}
2522
2523// looksLikePathToken reports whether tok is shaped like a filesystem
2524// path. Paths must either start with ~, /, ./, ../ or contain a /.
2525// Plain words are excluded so Tab on "hello" stays a no-op.
2526func looksLikePathToken(tok string) bool {
2527 if tok == "" {
2528 return false
2529 }
2530 if tok[0] == '~' || tok[0] == '/' {
2531 return true
2532 }
2533 if strings.HasPrefix(tok, "./") || strings.HasPrefix(tok, "../") {
2534 return true
2535 }
2536 return strings.Contains(tok, "/")
2537}
2538
2539// resolvePathTabToken splits tok into (absolute parent dir, basename
2540// prefix to match, display-form parent the user typed). ok is false
2541// when the parent dir can't be resolved (e.g. ~ with no $HOME).
2542func resolvePathTabToken(tok, cwd string) (parentAbs, basePrefix, displayParent string, ok bool) {
2543 // Detect ~ expansion.
2544 expanded := tok
2545 homePrefix := ""
2546 if tok == "~" {
2547 home, err := os.UserHomeDir()
2548 if err != nil || home == "" {
2549 return "", "", "", false
2550 }
2551 // "~" alone: complete in $HOME. parent = home, base = "".
2552 return home, "", "~/", true
2553 }
2554 if strings.HasPrefix(tok, "~/") {
2555 home, err := os.UserHomeDir()
2556 if err != nil || home == "" {
2557 return "", "", "", false
2558 }
2559 expanded = home + tok[1:]
2560 homePrefix = "~"
2561 }
2562
2563 dir, base := splitDirBase(expanded)
2564 if !filepath.IsAbs(dir) {
2565 dir = filepath.Join(cwd, dir)
2566 }
2567
2568 // Reconstruct the display form the user typed for the parent,
2569 // keeping ~ when they used it. The base is dropped — the caller
2570 // substitutes the completed name.
2571 display := tok[:len(tok)-len(base)]
2572 if homePrefix != "" && !strings.HasPrefix(display, "~") {
2573 display = homePrefix + display[len(homePrefix):]
2574 }
2575 return dir, base, display, true
2576}
2577
2578// splitDirBase is like filepath.Split but preserves the trailing
2579// slash convention: "foo" => (".", "foo"); "foo/" => ("foo", "");
2580// "a/b" => ("a/", "b"); "/" => ("/", ""). Returned dir always has
2581// the trailing separator when non-empty so callers can rebuild paths
2582// by concatenation.
2583func splitDirBase(p string) (dir, base string) {
2584 if p == "" {
2585 return ".", ""
2586 }
2587 i := strings.LastIndex(p, "/")
2588 if i < 0 {
2589 return ".", p
2590 }
2591 return p[:i+1], p[i+1:]
2592}
2593
2594func longestCommonPrefix(ss []string) string {
2595 if len(ss) == 0 {
2596 return ""
2597 }
2598 prefix := ss[0]
2599 for _, s := range ss[1:] {
2600 n := 0
2601 for n < len(prefix) && n < len(s) && prefix[n] == s[n] {
2602 n++
2603 }
2604 prefix = prefix[:n]
2605 if prefix == "" {
2606 return ""
2607 }
2608 }
2609 return prefix
2610}
2611
2612func (i *Interactive) runSlash(ctx context.Context, cmd string) (done bool) {
2613 parts := strings.Fields(cmd)
2614 switch parts[0] {
2615 case "/exit":
2616 return true
2617 case "/clear":
2618 if i.agent != nil {
2619 i.agent.SetMessages(nil)
2620 }
2621 i.mu.Lock()
2622 i.toolCalls = map[string]*tui.ToolCallView{}
2623 i.toolOrder = nil
2624 i.statusErr = ""
2625 i.statusOK = ""
2626 i.helpBlock = nil
2627 i.parkedTurn = 0
2628 i.parkedTotal = 0
2629 i.scrollOffset = 0
2630 i.extNotes = nil
2631 i.view.InvalidateRenderCache()
2632 i.mu.Unlock()
2633 case "/help":
2634 i.mu.Lock()
2635 i.helpBlock = renderHelpBlock(i.cfg.Theme, i.lastCols())
2636 i.statusErr = ""
2637 i.statusOK = ""
2638 // Pin the viewport to the newest content so the help block,
2639 // which we just appended to the end of the transcript, is
2640 // what the user actually sees.
2641 i.scrollOffset = 0
2642 i.mu.Unlock()
2643 case "/login":
2644 i.dialog.Open(i.cfg.ZotHome)
2645 case "/logout":
2646 if len(parts) >= 2 {
2647 // Explicit target: /logout anthropic | openai | all
2648 i.doLogout(parts[1])
2649 break
2650 }
2651 // No arg: open the picker over whichever providers are
2652 // currently logged in. If nothing's logged in, bail with a
2653 // status line.
2654 i.openLogoutDialog()
2655 case "/model":
2656 if len(parts) >= 2 {
2657 i.applyModelSelection("", parts[1])
2658 } else {
2659 var loggedIn []string
2660 if i.cfg.LoggedInProviders != nil {
2661 loggedIn = i.cfg.LoggedInProviders()
2662 }
2663 i.modelDialog.Open(i.cfg.Model, loggedIn)
2664 }
2665 case "/settings":
2666 i.mu.Lock()
2667 i.statusErr = "/settings is temporarily disabled"
2668 i.statusOK = ""
2669 i.mu.Unlock()
2670 case "/sessions":
2671 i.sessionDialog.Open(i.cfg.ZotHome, i.cfg.CWD)
2672 case "/jump":
2673 i.openJumpDialog(parts[1:])
2674 case "/btw":
2675 i.openBtwDialog(parts[1:])
2676 case "/skills":
2677 i.openSkillsDialog()
2678 case "/compact":
2679 i.runCompact(ctx, false)
2680 case "/study":
2681 // Canned prompt that tells the agent to read every file
2682 // in some target so its later turns have the whole thing
2683 // in context. With no argument, the target is the current
2684 // directory. With an argument, the target is whatever the
2685 // user passed — typed by hand, drag-dropped, or selected
2686 // via the @ file picker (which is why we accept both files
2687 // and directories; the @-picker chips for both have already
2688 // been expanded to absolute paths by expandFileChips above).
2689 // Dispatched through the normal queue-or-start path so it
2690 // behaves identically to typing the prompt by hand.
2691 studyPrompt := buildStudyPrompt(strings.TrimSpace(strings.TrimPrefix(cmd, parts[0])), i.cfg.CWD)
2692 i.mu.Lock()
2693 busy := i.busy
2694 ag := i.agent
2695 i.mu.Unlock()
2696 if busy {
2697 if ag != nil {
2698 ag.QueueMessage(studyPrompt)
2699 } else {
2700 i.mu.Lock()
2701 i.queued = append(i.queued, studyPrompt)
2702 i.mu.Unlock()
2703 }
2704 i.invalidate()
2705 break
2706 }
2707 i.startTurn(ctx, studyPrompt)
2708 case "/jail":
2709 if i.cfg.Sandbox == nil {
2710 i.mu.Lock()
2711 i.statusErr = "sandbox not available in this build"
2712 i.mu.Unlock()
2713 break
2714 }
2715 i.cfg.Sandbox.Lock()
2716 i.mu.Lock()
2717 i.statusOK = "jailed to " + i.cfg.CWD + " (tools cannot touch paths outside this directory)"
2718 i.statusErr = ""
2719 i.mu.Unlock()
2720 case "/unjail":
2721 if i.cfg.Sandbox == nil {
2722 i.mu.Lock()
2723 i.statusErr = "sandbox not available in this build"
2724 i.mu.Unlock()
2725 break
2726 }
2727 i.cfg.Sandbox.Unlock()
2728 i.mu.Lock()
2729 i.statusOK = "unjailed"
2730 i.statusErr = ""
2731 i.mu.Unlock()
2732 case "/reload-ext":
2733 i.runReloadExt(ctx)
2734 case "/telegram", "/tg":
2735 if len(parts) >= 2 {
2736 i.doTelegram(parts[1])
2737 break
2738 }
2739 i.openTelegramDialog()
2740 case "/session":
2741 if len(parts) >= 2 {
2742 action := parts[1]
2743 arg := ""
2744 if len(parts) >= 3 {
2745 arg = strings.Join(parts[2:], " ")
2746 }
2747 i.doSessionOp(action, arg)
2748 break
2749 }
2750 i.openSessionOpsDialog()
2751 case "/swarm":
2752 i.runSwarm(ctx, parts[1:])
2753 default:
2754 // Last-resort fallback: try the extension manager. Built-in
2755 // cases above always win; this branch only fires for slash
2756 // commands the extension manager registered. Same routing as
2757 // the editor's submit-handler dispatch path so the autocomplete
2758 // "enter on highlighted suggestion" flow also works.
2759 extName := strings.TrimPrefix(parts[0], "/")
2760 if i.cfg.Extensions != nil && i.cfg.Extensions.HasCommand(extName) {
2761 rest := ""
2762 if len(parts) > 1 {
2763 rest = strings.Join(parts[1:], " ")
2764 }
2765 go i.invokeExtensionCommand(ctx, extName, rest)
2766 return false
2767 }
2768 i.mu.Lock()
2769 i.statusErr = "unknown command: " + parts[0]
2770 i.mu.Unlock()
2771 }
2772 return false
2773}
2774
2775// openLogoutDialog shows the provider picker for `/logout` with no
2776// argument. Only providers the user is currently logged into are
2777// listed, plus an "all" entry when more than one is present. If
2778// nothing's logged in, writes a status line instead of opening an
2779// empty dialog.
2780func (i *Interactive) openLogoutDialog() {
2781 if i.cfg.AuthManager == nil {
2782 i.mu.Lock()
2783 i.statusErr = "no auth manager configured"
2784 i.mu.Unlock()
2785 i.invalidate()
2786 return
2787 }
2788 store := i.cfg.AuthManager.Store()
2789 if store == nil {
2790 i.mu.Lock()
2791 i.statusErr = "auth store is not available"
2792 i.mu.Unlock()
2793 i.invalidate()
2794 return
2795 }
2796 creds, err := store.Load()
2797 if err != nil {
2798 i.mu.Lock()
2799 i.statusErr = "read auth store: " + err.Error()
2800 i.mu.Unlock()
2801 i.invalidate()
2802 return
2803 }
2804
2805 var items []logoutItem
2806 for _, p := range []string{"anthropic", "openai", "kimi", "google"} {
2807 if creds.Has(p) {
2808 method := creds.Method(p)
2809 if method == "oauth" {
2810 method = "subscription"
2811 }
2812 items = append(items, logoutItem{
2813 label: providerLabel(p),
2814 target: p,
2815 method: method,
2816 })
2817 }
2818 }
2819 if len(items) == 0 {
2820 i.mu.Lock()
2821 i.statusOK = "no credentials stored; already logged out"
2822 i.statusErr = ""
2823 i.mu.Unlock()
2824 i.invalidate()
2825 return
2826 }
2827 if len(items) > 1 {
2828 items = append(items, logoutItem{label: "all", target: "all"})
2829 }
2830
2831 i.logoutDialog.Open(items)
2832 i.invalidate()
2833}
2834
2835// doLogout clears credentials for the given provider (or all providers)
2836// from auth.json. If the active agent was using those credentials, it
2837// is torn down so the user is forced through /login before their next
2838// prompt.
2839//
2840// target: "anthropic" | "openai" | "kimi" | "all"
2841func (i *Interactive) doLogout(target string) {
2842 if i.cfg.AuthManager == nil {
2843 i.mu.Lock()
2844 i.statusErr = "no auth manager configured"
2845 i.mu.Unlock()
2846 return
2847 }
2848 store := i.cfg.AuthManager.Store()
2849 if store == nil {
2850 i.mu.Lock()
2851 i.statusErr = "auth store is not available"
2852 i.mu.Unlock()
2853 return
2854 }
2855
2856 var providers []string
2857 switch target {
2858 case "", "all":
2859 providers = []string{"anthropic", "openai", "kimi", "google"}
2860 case "anthropic", "openai", "kimi", "google":
2861 providers = []string{target}
2862 default:
2863 i.mu.Lock()
2864 i.statusErr = "unknown provider: " + target + " (use anthropic, openai, kimi, google, or all)"
2865 i.mu.Unlock()
2866 return
2867 }
2868
2869 var errs []string
2870 clearedCurrent := false
2871 for _, p := range providers {
2872 if err := store.Clear(p); err != nil {
2873 errs = append(errs, p+": "+err.Error())
2874 continue
2875 }
2876 if p == "kimi" && i.cfg.SetKimiCLIFallbackDisabled != nil {
2877 if err := i.cfg.SetKimiCLIFallbackDisabled(true); err != nil {
2878 errs = append(errs, p+": "+err.Error())
2879 continue
2880 }
2881 }
2882 if p == i.cfg.Provider {
2883 clearedCurrent = true
2884 }
2885 }
2886
2887 i.mu.Lock()
2888 defer i.mu.Unlock()
2889 if len(errs) > 0 {
2890 i.statusErr = "logout errors: " + strings.Join(errs, "; ")
2891 return
2892 }
2893 i.statusErr = ""
2894 if clearedCurrent {
2895 // The running agent was using a credential we just wiped. Drop
2896 // it so prompts can't go out with the stale client, and hint at
2897 // /login.
2898 i.agent = nil
2899 i.statusOK = "logged out of " + strings.Join(providers, ", ") + ". type /login to sign back in."
2900 } else {
2901 i.statusOK = "logged out of " + strings.Join(providers, ", ")
2902 }
2903}
2904
2905func (i *Interactive) startAPIKeyFlow(provider string) {
2906 if provider == "kimi" && i.cfg.SetKimiCLIFallbackDisabled != nil {
2907 _ = i.cfg.SetKimiCLIFallbackDisabled(false)
2908 }
2909 url, err := i.cfg.AuthManager.StartAPIKey(provider)
2910 if err != nil {
2911 i.dialog.ShowResult(false, err.Error())
2912 return
2913 }
2914 i.dialog.ShowWaiting(url)
2915}
2916
2917func (i *Interactive) startOAuthFlow(provider string) {
2918 if provider == "kimi" && i.cfg.SetKimiCLIFallbackDisabled != nil {
2919 _ = i.cfg.SetKimiCLIFallbackDisabled(false)
2920 }
2921 // Always run the manual/copy-code flow in parallel with the local
2922 // callback server so headless environments (docker, SSH) can paste
2923 // the authorization code directly without first pressing 'p'.
2924 _, err := i.cfg.AuthManager.StartOAuth(provider)
2925 if err != nil {
2926 i.dialog.ShowResult(false, err.Error())
2927 return
2928 }
2929 manualURL, mErr := i.cfg.AuthManager.StartManualOAuth(provider)
2930 if mErr == nil {
2931 i.dialog.ShowWaiting(manualURL)
2932 } else {
2933 i.dialog.ShowResult(false, mErr.Error())
2934 }
2935}
2936
2937func (i *Interactive) startManualOAuthFlow(provider string) {
2938 if i.cfg.AuthManager == nil {
2939 return
2940 }
2941 i.cfg.AuthManager.CancelOAuth()
2942 url, err := i.cfg.AuthManager.StartManualOAuth(provider)
2943 if err != nil {
2944 i.dialog.ShowResult(false, err.Error())
2945 return
2946 }
2947 i.dialog.url = url
2948 i.invalidate()
2949}
2950
2951func (i *Interactive) submitManualOAuthCode(code string) {
2952 if i.cfg.AuthManager == nil {
2953 return
2954 }
2955 go func() {
2956 if err := i.cfg.AuthManager.CompleteManualOAuth(i.runCtx, code); err != nil {
2957 i.dialog.ShowResult(false, err.Error())
2958 i.invalidate()
2959 }
2960 }()
2961}
2962
2963// applyModelSelection switches the active model (and provider, if the
2964// new model belongs to a different one). It rebuilds the underlying
2965// client when needed so the provider wire-protocol matches.
2966// cancelAndWaitForIdle cancels the active turn (if any) and blocks
2967// briefly until the turn goroutine has updated i.busy = false. Used
2968// before destructive slash commands so transcript-mutating work
2969// (/clear, /compact, /logout, /login completion, cross-provider
2970// /model swap) doesn't race with the still-running stream.
2971//
2972// The wait is bounded; if the turn doesn't release within the timeout
2973// we proceed anyway. Worst case is a brief overlap that the agent's
2974// own mutex protects against.
2975func (i *Interactive) cancelAndWaitForIdle() {
2976 i.mu.Lock()
2977 busy := i.busy
2978 cancel := i.cancelTurn
2979 i.mu.Unlock()
2980 if !busy {
2981 return
2982 }
2983 if cancel != nil {
2984 cancel()
2985 }
2986 deadline := time.Now().Add(2 * time.Second)
2987 for time.Now().Before(deadline) {
2988 i.mu.Lock()
2989 done := !i.busy
2990 i.mu.Unlock()
2991 if done {
2992 return
2993 }
2994 time.Sleep(10 * time.Millisecond)
2995 }
2996}
2997
2998// openBtwDialog opens the side-chat overlay with a frozen snapshot
2999// of the current main session. The optional argument is auto-
3000// submitted as the first question, so '/btw does X work?' fires the
3001// model call immediately instead of just opening an empty dialog.
3002func (i *Interactive) openBtwDialog(args []string) {
3003 if i.agent == nil {
3004 i.mu.Lock()
3005 i.statusErr = "not logged in. type /login first."
3006 i.mu.Unlock()
3007 return
3008 }
3009 seed := strings.TrimSpace(strings.Join(args, " "))
3010 i.btwDialog.Open(i.cfg.Theme, i.agent, i.agent.System, i.cfg.Model, i.cfg.CWD, seed, i.invalidate)
3011 i.invalidate()
3012}
3013
3014// openSkillsDialog opens the skill inspector. The picker reflects
3015// whatever SkillSnapshot returns at call time, so edits to a
3016// SKILL.md made during a session show up on the next /skills.
3017func (i *Interactive) openSkillsDialog() {
3018 var list []*skills.Skill
3019 if i.cfg.SkillSnapshot != nil {
3020 list = i.cfg.SkillSnapshot()
3021 }
3022 i.skillsDialog.Open(list)
3023 i.invalidate()
3024}
3025
3026// openJumpDialog builds a /jump picker from the current transcript.
3027// If the user typed "/jump foo" with a filter and it matches exactly
3028// one turn, jump there directly without showing the dialog.
3029func (i *Interactive) openJumpDialog(args []string) {
3030 if i.view == nil || len(i.view.Messages) == 0 {
3031 i.mu.Lock()
3032 i.statusErr = "nothing to jump to \u2014 the session is empty"
3033 i.mu.Unlock()
3034 return
3035 }
3036 filter := strings.TrimSpace(strings.Join(args, " "))
3037 i.jumpDialog.Open(i.view.Messages, filter)
3038 // Shortcut: with a filter argument that matches exactly one turn,
3039 // jump immediately and skip the picker.
3040 if filter != "" {
3041 if tgts := i.jumpDialog.Targets(); len(tgts) == 1 {
3042 t := tgts[0]
3043 i.jumpDialog.Close()
3044 i.applyJumpSelection(t.MessageIdx, t.TurnNo)
3045 }
3046 }
3047}
3048
3049// applyJumpSelection scrolls the chat viewport so the user message at
3050// msgIdx is visible at (or near) the top of the chat area. Uses the
3051// anchor slice returned by view.BuildWithAnchors so the mapping from
3052// message index to row is exact, regardless of variable-height tool
3053// blocks above the target.
3054func (i *Interactive) applyJumpSelection(msgIdx, turnNo int) {
3055 cols := i.lastCols()
3056 chat, anchors := i.view.BuildWithAnchors(cols)
3057 var row int
3058 found := false
3059 for _, a := range anchors {
3060 if a.MessageIdx == msgIdx {
3061 row = a.Row
3062 found = true
3063 break
3064 }
3065 }
3066 if !found {
3067 i.mu.Lock()
3068 i.statusErr = "could not resolve jump target"
3069 i.mu.Unlock()
3070 return
3071 }
3072
3073 chatLen := len(chat)
3074 page := i.chatPage()
3075 if page < 1 {
3076 page = 1
3077 }
3078 // scrollOffset is measured from the bottom of the chat slice, so
3079 // to place `row` at the top of the viewport we want:
3080 // chatLen - scrollOffset - page == row
3081 // Solve for scrollOffset and clamp to [0, chatLen-page].
3082 offset := chatLen - (row + page)
3083 if offset < 0 {
3084 offset = 0
3085 }
3086 maxOffset := chatLen - page
3087 if maxOffset < 0 {
3088 maxOffset = 0
3089 }
3090 if offset > maxOffset {
3091 offset = maxOffset
3092 }
3093
3094 i.mu.Lock()
3095 i.scrollOffset = offset
3096 i.parkedTurn = turnNo
3097 i.parkedTotal = totalTurnsLocked(i.view.Messages)
3098 i.statusOK = fmt.Sprintf("jumped to turn %d", turnNo)
3099 i.statusErr = ""
3100 i.mu.Unlock()
3101}
3102
3103// totalTurnsLocked counts user messages in the transcript. Caller is
3104// assumed to hold i.mu (the name is a mild reminder; this function
3105// itself doesn't touch shared state beyond the slice it's handed).
3106func totalTurnsLocked(msgs []provider.Message) int {
3107 n := 0
3108 for _, m := range msgs {
3109 if m.Role == provider.RoleUser {
3110 n++
3111 }
3112 }
3113 return n
3114}
3115
3116// applySessionSelection loads the given session via the cli-provided
3117// callback and snaps the viewport to the bottom (the latest message)
3118// so the user lands at the live tail of the resumed conversation.
3119func (i *Interactive) applySessionSelection(path string) {
3120 if i.cfg.LoadSession == nil {
3121 i.mu.Lock()
3122 i.statusErr = "session loading is not wired in this build"
3123 i.mu.Unlock()
3124 return
3125 }
3126 i.mu.Lock()
3127 if i.sessionLoading {
3128 i.statusErr = "already resuming a session"
3129 i.mu.Unlock()
3130 i.invalidate()
3131 return
3132 }
3133 i.sessionLoading = true
3134 i.statusOK = "resuming session: " + path
3135 i.statusErr = ""
3136 i.mu.Unlock()
3137 i.invalidate()
3138
3139 go func() {
3140 err := i.cfg.LoadSession(path)
3141 i.mu.Lock()
3142 defer i.mu.Unlock()
3143 i.sessionLoading = false
3144 if err != nil {
3145 i.statusErr = err.Error()
3146 i.statusOK = ""
3147 i.invalidate()
3148 return
3149 }
3150 i.statusOK = "resumed session: " + path
3151 i.statusErr = ""
3152 i.parkedTurn = 0
3153 i.parkedTotal = 0
3154 i.scrollOffset = 0
3155 i.extNotes = nil
3156 i.view.InvalidateRenderCache()
3157 if i.agent != nil {
3158 i.view.Messages = i.agent.Messages()
3159 i.cumUsage = i.agent.Cost()
3160 }
3161 i.invalidate()
3162 }()
3163}
3164
3165// scrollToLastTurn parks the viewport at the most recent user turn,
3166// or at the top if the transcript has no user messages. Used after
3167// resume so the user lands looking at where they left off.
3168func (i *Interactive) scrollToLastTurn(msgs []provider.Message) {
3169 if len(msgs) == 0 {
3170 i.mu.Lock()
3171 i.scrollOffset = 0
3172 i.mu.Unlock()
3173 return
3174 }
3175 // Find the last user message index.
3176 lastUser := -1
3177 turnNo, totalTurns := 0, 0
3178 for idx, m := range msgs {
3179 if m.Role == provider.RoleUser {
3180 totalTurns++
3181 lastUser = idx
3182 }
3183 }
3184 if lastUser < 0 {
3185 i.mu.Lock()
3186 i.scrollOffset = 0
3187 i.mu.Unlock()
3188 return
3189 }
3190 turnNo = totalTurns
3191
3192 cols := i.lastCols()
3193 chat, anchors := i.view.BuildWithAnchors(cols)
3194 var row int
3195 found := false
3196 for _, a := range anchors {
3197 if a.MessageIdx == lastUser {
3198 row = a.Row
3199 found = true
3200 break
3201 }
3202 }
3203 if !found {
3204 i.mu.Lock()
3205 i.scrollOffset = 0
3206 i.mu.Unlock()
3207 return
3208 }
3209
3210 chatLen := len(chat)
3211 page := i.chatPage()
3212 if page < 1 {
3213 page = 1
3214 }
3215 offset := chatLen - (row + page)
3216 if offset < 0 {
3217 offset = 0
3218 }
3219 maxOffset := chatLen - page
3220 if maxOffset < 0 {
3221 maxOffset = 0
3222 }
3223 if offset > maxOffset {
3224 offset = maxOffset
3225 }
3226
3227 i.mu.Lock()
3228 i.scrollOffset = offset
3229 // Mark the parked-turn footer so the user sees "viewing turn N of
3230 // M - pgdn to catch up" — same affordance as /jump. Tells them at
3231 // a glance that they're looking at history, not the live tail.
3232 if offset > 0 {
3233 i.parkedTurn = turnNo
3234 i.parkedTotal = totalTurns
3235 }
3236 i.mu.Unlock()
3237 i.invalidate()
3238}
3239
3240func (i *Interactive) applyModelSelection(prov, model string) {
3241 i.swapModel(prov, model, i.cfg.BuildAgentFor, false)
3242}
3243
3244// applyRescueModelSelection is like applyModelSelection but routes
3245// through BuildAgentForRescue so launch-time --api-key / --base-url
3246// overrides are dropped before the new agent is built. Falls back to
3247// the regular builder when the host doesn't wire a rescue builder.
3248func (i *Interactive) applyRescueModelSelection(prov, model string) {
3249 builder := i.cfg.BuildAgentForRescue
3250 if builder == nil {
3251 builder = i.cfg.BuildAgentFor
3252 }
3253 i.swapModel(prov, model, builder, true)
3254}
3255
3256// swapModel applies a /model selection (or a rescue selection) using
3257// the supplied builder. rescue=true tags the success message so the
3258// user can see that launch-time overrides were ignored.
3259func (i *Interactive) swapModel(prov, model string, builder func(string, string) (*core.Agent, string, string, error), rescue bool) {
3260 if model == "" {
3261 return
3262 }
3263 m, err := provider.FindModel(prov, model)
3264 if err != nil {
3265 i.mu.Lock()
3266 i.statusErr = err.Error()
3267 i.mu.Unlock()
3268 return
3269 }
3270 // Same provider AND not a rescue retry: just swap the model on
3271 // the existing agent — no rebuild needed because the underlying
3272 // client is reusable. Rescue retries always rebuild so a stale
3273 // auth header / base URL can't carry over.
3274 if !rescue && i.agent != nil && m.Provider == i.cfg.Provider {
3275 i.mu.Lock()
3276 i.cfg.Model = m.ID
3277 i.agent.Model = m.ID
3278 i.statusOK = "model: " + m.ID
3279 i.statusErr = ""
3280 i.mu.Unlock()
3281 if i.cfg.PersistModel != nil {
3282 i.cfg.PersistModel(i.cfg.Provider, m.ID)
3283 }
3284 return
3285 }
3286 if builder == nil {
3287 i.mu.Lock()
3288 i.statusErr = "cannot switch provider: no builder configured"
3289 i.mu.Unlock()
3290 return
3291 }
3292 // Snapshot the current transcript and cumulative usage BEFORE we
3293 // build the replacement agent so we can hand them off. Without
3294 // this the user perceives the entire session as wiped on a
3295 // cross-provider /model swap.
3296 var carryMsgs []provider.Message
3297 var carryCost provider.Usage
3298 if i.agent != nil {
3299 carryMsgs = i.agent.Messages()
3300 carryCost = i.agent.Cost()
3301 }
3302
3303 ag, p, md, err := builder(m.Provider, m.ID)
3304 if err != nil {
3305 i.mu.Lock()
3306 i.statusErr = err.Error()
3307 i.mu.Unlock()
3308 return
3309 }
3310
3311 // Replay the transcript and seed the cost on the freshly-built
3312 // agent. Messages travel cleanly between providers because they
3313 // use the same provider.Message shape; tool-call ids are local
3314 // to a turn so cross-provider continuation never confuses the
3315 // new model (it just sees the assistant's reply, no orphan
3316 // tool_use blocks because /model swaps are gated to idle state).
3317 if len(carryMsgs) > 0 {
3318 ag.SetMessages(carryMsgs)
3319 }
3320 ag.SeedCost(carryCost)
3321
3322 i.mu.Lock()
3323 i.agent = ag
3324 i.cfg.Provider = p
3325 i.cfg.Model = md
3326 if rescue {
3327 i.statusOK = "rescue retry: switched to " + p + " / " + md + " (ignored --api-key / --base-url)"
3328 } else {
3329 i.statusOK = "switched to " + p + " / " + md
3330 }
3331 i.statusErr = ""
3332 // Render cache keys are width+content based, so the new agent's
3333 // identical messages will reuse the existing entries. Nothing
3334 // to invalidate.
3335 i.mu.Unlock()
3336 // The new agent was built off the base tool registry, so any
3337 // dynamically-registered tools (telegram_send_*) need to be
3338 // reattached. applyTelegramTools is a no-op when the bridge is
3339 // idle so the cross-provider path still works on a vanilla setup.
3340 i.applyTelegramTools(i.telegramBridge != nil && i.telegramBridge.Active())
3341 if i.cfg.PersistModel != nil {
3342 i.cfg.PersistModel(p, md)
3343 }
3344}
3345
3346func (i *Interactive) handleAuthEvent(ev auth.Event) {
3347 switch ev.Kind {
3348 case "started":
3349 i.dialog.ShowWaiting(ev.URL)
3350 case "browser_open":
3351 // no-op
3352 case "error":
3353 i.dialog.ShowResult(false, ev.Message)
3354 case "success":
3355 // Rebuild the agent with the fresh credential.
3356 ag, prov, model, err := i.cfg.BuildAgent()
3357 if err != nil {
3358 i.dialog.ShowResult(false, err.Error())
3359 return
3360 }
3361 i.mu.Lock()
3362 i.agent = ag
3363 i.cfg.Provider = prov
3364 i.cfg.Model = model
3365 i.statusErr = ""
3366 i.statusOK = "logged in to " + ev.Provider + " via " + ev.Method
3367 i.mu.Unlock()
3368 i.applyTelegramTools(i.telegramBridge != nil && i.telegramBridge.Active())
3369 i.dialog.ShowResult(true, "")
3370 }
3371}
3372
3373// runCompact invokes core.Agent.Compact and reflects the progress in
3374// the tui. It runs in a goroutine so the ui stays responsive; esc/ctrl+c
3375// cancel via the same cancelTurn channel used for normal turns.
3376//
3377// When auto is true the spinner message is pinned to "condensing
3378// history" and the status bar surfaces "(auto)" next to the context
3379// percentage so it's obvious the system triggered this, not the user.
3380func (i *Interactive) runCompact(parent context.Context, auto bool) {
3381 if i.agent == nil {
3382 i.mu.Lock()
3383 i.statusErr = "not logged in. type /login first."
3384 i.mu.Unlock()
3385 return
3386 }
3387 ctx, cancel := context.WithCancel(parent)
3388 i.mu.Lock()
3389 i.busy = true
3390 if auto {
3391 i.spin.StartFixed("condensing history")
3392 i.autoCompacting = true
3393 } else {
3394 i.spin.StartFixed("compacting")
3395 }
3396 i.cancelTurn = cancel
3397 i.statusErr = ""
3398 i.statusOK = ""
3399 // Do NOT set streamOn: the summary text should not be visible
3400 // in the chat while compacting. The user just sees the spinner
3401 // and can keep typing / queue prompts.
3402 i.scrollOffset = 0
3403 i.helpBlock = nil
3404 i.mu.Unlock()
3405 i.invalidate()
3406
3407 go func() {
3408 // Sink discards deltas — we don't stream the summary to the UI.
3409 sink := func(delta string) {}
3410 summary, err := i.agent.Compact(ctx, 4, sink)
3411 _ = summary
3412 i.mu.Lock()
3413 i.busy = false
3414 i.resetStreamingStateLocked()
3415 i.cancelTurn = nil
3416 i.autoCompacting = false
3417
3418 // Drain the queue: if the user typed a prompt while compacting,
3419 // fire it now that the transcript is clean.
3420 var next string
3421 var hasNext bool
3422
3423 switch {
3424 case err != nil && ctx.Err() != nil:
3425 i.statusErr = ""
3426 if auto {
3427 i.statusOK = "auto-condense cancelled"
3428 } else {
3429 i.statusOK = "compaction cancelled"
3430 }
3431 i.queued = nil // drop queue on cancel
3432 if i.agent != nil {
3433 i.agent.DrainQueuedMessages()
3434 }
3435 case err != nil:
3436 i.statusErr = "compaction failed: " + err.Error()
3437 i.statusOK = ""
3438 i.queued = nil // drop queue on error
3439 if i.agent != nil {
3440 i.agent.DrainQueuedMessages()
3441 }
3442 default:
3443 i.statusErr = ""
3444 // Read token count from the compaction message meta.
3445 tokens := ""
3446 msgs := i.agent.Messages()
3447 if len(msgs) > 0 && msgs[0].Meta["compaction"] == "true" {
3448 tokens = msgs[0].Meta["tokens_before"]
3449 }
3450 switch {
3451 case i.pendingPostCompactNote != "":
3452 i.statusOK = i.pendingPostCompactNote
3453 case tokens != "":
3454 i.statusOK = fmt.Sprintf("compacted from ~%s tokens (ctrl+o to expand)", tokens)
3455 default:
3456 i.statusOK = "compacted (ctrl+o to expand)"
3457 }
3458 i.pendingPostCompactNote = ""
3459 i.extNotes = stripAutoCompactNotes(i.extNotes)
3460 i.lastCtxInput = 0
3461 i.toolCalls = map[string]*tui.ToolCallView{}
3462 i.toolOrder = nil
3463 i.view.InvalidateRenderCache()
3464 // Pop queued prompt if any.
3465 if len(i.queued) > 0 {
3466 next, i.queued = i.queued[0], i.queued[1:]
3467 hasNext = true
3468 }
3469 }
3470 i.mu.Unlock()
3471 i.invalidate()
3472
3473 if hasNext {
3474 p := i.runCtx
3475 if p == nil {
3476 p = context.Background()
3477 }
3478 i.startTurn(p, next)
3479 }
3480 }()
3481}
3482
3483func (i *Interactive) startTurn(parent context.Context, prompt string) {
3484 i.startTurnWithImages(parent, prompt, nil)
3485}
3486
3487func (i *Interactive) startTurnWithImages(parent context.Context, prompt string, images []provider.ImageBlock) {
3488 if i.agent == nil {
3489 return
3490 }
3491 // Force a full repaint when a new turn begins so any stray dialog,
3492 // popup, or stale tool-progress rows don't leak into the visible
3493 // chat area before the assistant starts streaming. Equivalent to
3494 // the user pressing ctrl+l right before submit.
3495 if i.rend != nil {
3496 i.rend.Clear()
3497 }
3498 // Pre-turn safety: if the most recent context measurement is
3499 // already past the auto-compact threshold, condense before
3500 // sending so the next outbound request stays under the limit.
3501 // The condense flow re-fires the user's queued prompt for us, so
3502 // we just hand it off and exit.
3503 i.mu.Lock()
3504 needsPreCompact := !i.autoCompacting && i.shouldAutoCompactLocked()
3505 if needsPreCompact {
3506 if prompt != "" {
3507 i.queued = append([]string{prompt}, i.queued...)
3508 }
3509 i.statusErr = ""
3510 i.extNotes = append(i.extNotes, autoCompactNoteLine(i.cfg.Theme, "context near limit — condensing history before sending…"))
3511 i.pendingPostCompactNote = "context auto-compacted; sending your last message"
3512 i.mu.Unlock()
3513 i.invalidate()
3514 i.runCompact(parent, true)
3515 return
3516 }
3517 i.mu.Unlock()
3518
3519 ctx, cancel := context.WithCancel(parent)
3520 i.mu.Lock()
3521 i.busy = true
3522 i.spin.Start()
3523 i.cancelTurn = cancel
3524 i.statusErr = ""
3525 i.statusOK = ""
3526 i.streaming.Reset()
3527 i.streamOn = true
3528 i.toolCalls = map[string]*tui.ToolCallView{}
3529 i.toolOrder = nil
3530 i.scrollOffset = 0 // jump back to the bottom on new turn
3531 // Reset the auto-follow baseline so the very next render at
3532 // interactive.go:1053 doesn't see a synthetic shrink between
3533 // "last frame had the previous turn's tool overlay" and
3534 // "this frame had it cleared above". Without this, the guard
3535 // reads delta = -(rows in cleared overlay) and decrements
3536 // scrollOffset, which on terminals that mirror zot's pane
3537 // scroll into the host scrollbar visibly yanks the viewport.
3538 // See autofollow_shrink_test.go for the exact arithmetic.
3539 i.prevChatLen = 0
3540 i.prevChatCols = 0
3541 i.parkedTurn = 0 // starting a turn clears the /jump parked state
3542 i.parkedTotal = 0
3543 i.helpBlock = nil // hide the help block once the user asks something
3544 i.mu.Unlock()
3545 i.invalidate()
3546
3547 sink := func(ev core.AgentEvent) {
3548 i.handleEvent(ev)
3549 i.invalidate()
3550 }
3551
3552 go func() {
3553 err := i.agent.Prompt(ctx, prompt, images, sink)
3554 i.mu.Lock()
3555 i.busy = false
3556 // Don't touch streamPending / streamFlushPending here — the
3557 // pacer may still be draining the final deltas and needs to
3558 // paint them even though Prompt has returned. It will reset
3559 // streamOn on its own once the buffer empties.
3560 if len(i.streamPending) == 0 {
3561 i.streamOn = false
3562 }
3563 i.cancelTurn = nil
3564 if err != nil && ctx.Err() == nil {
3565 i.statusErr = err.Error()
3566 }
3567 // Decide whether to offer a model rescue picker for recoverable
3568 // provider failures (auth/rate/temporary). The picker opens after
3569 // the mutex is released so it can take its own locks freely.
3570 var (
3571 offer bool
3572 rescueWhy string
3573 rescueImgs []provider.ImageBlock
3574 rescueModel string
3575 rescueProv string
3576 rescueFprov string
3577 )
3578 if err != nil && ctx.Err() == nil {
3579 if ok, reason := classifyRescueError(err); ok {
3580 offer = true
3581 rescueWhy = reason
3582 rescueImgs = images
3583 rescueModel = i.cfg.Model
3584 rescueProv = i.cfg.Provider
3585 rescueFprov = extractFailedProvider(err)
3586 if rescueFprov == "" {
3587 rescueFprov = i.cfg.Provider
3588 }
3589 // Suppress the red banner — the rescue dialog already
3590 // surfaces the failure.
3591 i.statusErr = ""
3592 }
3593 }
3594 // Detect HTTP 413 "payload too large" responses. The provider
3595 // rejected the request because the request body exceeded its
3596 // per-request limit. Token-based auto-compact can miss this
3597 // because the limit is on raw bytes, not tokens. Re-queue the
3598 // prompt so it survives the condense pass and trigger one.
3599 payloadTooLarge := err != nil && ctx.Err() == nil && isPayloadTooLargeError(err)
3600 if payloadTooLarge {
3601 i.statusErr = ""
3602 i.queued = append([]string{prompt}, i.queued...)
3603 i.extNotes = append(i.extNotes, autoCompactNoteLine(i.cfg.Theme, "request was too large. condensing history before retrying ..."))
3604 i.pendingPostCompactNote = "context auto-compacted; retrying your last message"
3605 }
3606 // Persist the assistant's reply (and every tool row before
3607 // it) to the session file while the turn memory is hot.
3608 // Without this, WriteNewTranscript only fires at zot exit,
3609 // meaning a crash or ungraceful kill drops the whole
3610 // conversation. FlushSession is idempotent (it advances the
3611 // baseline so subsequent flushes only write new rows).
3612 flush := i.cfg.FlushSession
3613 i.mu.Unlock()
3614 if flush != nil {
3615 flush()
3616 }
3617 i.mu.Lock()
3618 // Pop the next queued message, if any, and relaunch.
3619 var next string
3620 var hasNext bool
3621 if len(i.queued) > 0 && ctx.Err() == nil && err == nil {
3622 next, i.queued = i.queued[0], i.queued[1:]
3623 hasNext = true
3624 }
3625 // If the turn was cancelled or errored, drop the queue so the
3626 // user isn't bombarded with stale messages after an interrupt.
3627 if ctx.Err() != nil || err != nil {
3628 i.queued = nil
3629 if i.agent != nil {
3630 i.agent.DrainQueuedMessages()
3631 }
3632 }
3633 // Decide whether the next thing to do is an auto-compaction.
3634 // Only fires when the turn completed cleanly AND no host-side
3635 // or agent-side queued messages are waiting (otherwise a queued
3636 // message would race the condense).
3637 agentQueued := 0
3638 if i.agent != nil {
3639 agentQueued = i.agent.QueuedMessageCount()
3640 }
3641 shouldAutoCompact := !hasNext && agentQueued == 0 && err == nil && ctx.Err() == nil && i.shouldAutoCompactLocked()
3642 i.mu.Unlock()
3643 i.invalidate()
3644 parent := i.runCtx
3645 if parent == nil {
3646 parent = context.Background()
3647 }
3648 switch {
3649 case hasNext:
3650 i.startTurn(parent, next)
3651 case offer:
3652 i.openRescueDialog(rescueProv, rescueFprov, rescueModel, rescueWhy, prompt, rescueImgs)
3653 case payloadTooLarge:
3654 i.runCompact(parent, true)
3655 case shouldAutoCompact:
3656 i.runCompact(parent, true)
3657 }
3658 }()
3659}
3660
3661// openRescueDialog surfaces the rescue model picker after a recoverable
3662// provider failure. The pending prompt + images are stashed on the
3663// Interactive so a later applyRescueSelection can re-run the same turn
3664// against the freshly-picked model. activeProvider/failedProvider are
3665// usually the same, but some clients embed different prefixes in their
3666// errors than the configured provider id, so we accept both.
3667func (i *Interactive) openRescueDialog(activeProvider, failedProvider, failedModel, reason, prompt string, images []provider.ImageBlock) {
3668 if i.rescueDialog == nil {
3669 return
3670 }
3671 loggedIn := []string{}
3672 if i.cfg.LoggedInProviders != nil {
3673 loggedIn = i.cfg.LoggedInProviders()
3674 }
3675 fprov := failedProvider
3676 if fprov == "" {
3677 fprov = activeProvider
3678 }
3679 i.mu.Lock()
3680 i.pendingRescuePrompt = prompt
3681 i.pendingRescueImages = images
3682 i.mu.Unlock()
3683 i.rescueDialog.Open(failedModel, loggedIn, fprov, failedModel, reason, prompt)
3684 i.invalidate()
3685}
3686
3687// applyRescueSelection switches model (cross-provider if needed) and
3688// re-runs the same prompt+images that just failed. Mirrors
3689// applyModelSelection's transcript-carry logic so the user keeps full
3690// session continuity across the swap.
3691func (i *Interactive) applyRescueSelection(prov, model, prompt string) {
3692 if model == "" {
3693 return
3694 }
3695 i.applyRescueModelSelection(prov, model)
3696 i.mu.Lock()
3697 images := i.pendingRescueImages
3698 if prompt == "" {
3699 prompt = i.pendingRescuePrompt
3700 }
3701 i.pendingRescuePrompt = ""
3702 i.pendingRescueImages = nil
3703 i.mu.Unlock()
3704 parent := i.runCtx
3705 if parent == nil {
3706 parent = context.Background()
3707 }
3708 i.startTurnWithImages(parent, prompt, images)
3709}
3710
3711func stripAutoCompactNotes(notes []string) []string {
3712 if len(notes) == 0 {
3713 return notes
3714 }
3715 out := notes[:0]
3716 for _, n := range notes {
3717 if strings.Contains(n, "condensing history") {
3718 continue
3719 }
3720 out = append(out, n)
3721 }
3722 return out
3723}
3724
3725// autoCompactNoteLine returns a styled chat-area note for the
3726// inline auto-compact heads-up. Lives in extNotes so it survives
3727// the busy-spinner overwrite of the status row.
3728func autoCompactNoteLine(th tui.Theme, msg string) string {
3729 return " " + th.FG256(th.Warning, "⚠ "+msg)
3730}
3731
3732// isPayloadTooLargeError matches HTTP 413 responses surfaced by the
3733// provider clients. The error formatting differs slightly between
3734// providers (anthropic and openai both prepend the status code), so
3735// we look for the canonical 413 marker as well as the conventional
3736// 'payload too large' phrase.
3737func isPayloadTooLargeError(err error) bool {
3738 if err == nil {
3739 return false
3740 }
3741 msg := strings.ToLower(err.Error())
3742 return strings.Contains(msg, "http 413") || strings.Contains(msg, " 413") || strings.HasPrefix(msg, "413 ") || strings.Contains(msg, "payload too large") || strings.Contains(msg, "request entity too large")
3743}
3744
3745// autoCompactThreshold is the context-window fraction at which the
3746// agent will auto-compact after a turn ends. 0.85 leaves enough
3747// headroom for one more user prompt + response before we bump the
3748// hard limit.
3749const autoCompactThreshold = 0.85
3750
3751// shouldAutoCompactLocked reports whether the last turn pushed context
3752// usage past the auto-compact threshold. Must be called with i.mu
3753// held; it reads lastCtxInput and the current model's context window.
3754func (i *Interactive) shouldAutoCompactLocked() bool {
3755 if i.agent == nil {
3756 return false
3757 }
3758 if i.autoCompacting {
3759 return false
3760 }
3761 m, err := provider.FindModel(i.cfg.Provider, i.cfg.Model)
3762 if err != nil || m.ContextWindow <= 0 {
3763 return false
3764 }
3765 if i.lastCtxInput <= 0 {
3766 return false
3767 }
3768 return float64(i.lastCtxInput)/float64(m.ContextWindow) >= autoCompactThreshold
3769}
3770
3771func (i *Interactive) handleEvent(ev core.AgentEvent) {
3772 i.mu.Lock()
3773 defer i.mu.Unlock()
3774 switch e := ev.(type) {
3775 case core.EvAssistantStart:
3776 // Fires at the top of every oneTurn, including follow-up
3777 // turns after tool use. Without this, the streaming buffer
3778 // is still marked off from the previous assistant message
3779 // and the final summary text pops in all at once instead
3780 // of typewriter-streaming delta by delta.
3781 i.streaming.Reset()
3782 i.streamPending = i.streamPending[:0]
3783 i.streamFlushPending = false
3784 i.streamOn = true
3785 // Clear the live tool-call overlay. Any tools from the
3786 // previous round are now fully folded into the transcript
3787 // (assistant tool_use block + tool role message with the
3788 // result), so keeping them in the overlay would duplicate
3789 // them in the view — once inside the finalised transcript
3790 // and once below the streaming block, with the streaming
3791 // summary sandwiched in between. The next EvToolUseStart
3792 // will populate fresh entries for this turn's tools.
3793 i.toolCalls = map[string]*tui.ToolCallView{}
3794 i.toolOrder = nil
3795 case core.EvTextDelta:
3796 // Buffer into streamPending; the paintPace ticker drains
3797 // it into i.streaming a few runes at a time for a smooth
3798 // typewriter effect independent of upstream chunk size.
3799 i.streamPending = append(i.streamPending, []rune(e.Delta)...)
3800 i.streamOn = true
3801 case core.EvAssistantMessage:
3802 // OnAssistant + telegram mirroring always fire on message
3803 // arrival — they read the FINAL message content, which is
3804 // complete regardless of what's still in the pacer.
3805 i.assistantMessageSideEffects(e.Message)
3806 // If the pacer still has characters to drain, keep streamOn
3807 // true and mark flush pending; the paintPace ticker will
3808 // drain the remainder and reset streaming state when done.
3809 // Otherwise (rare: full-replay sessions, abort paths) clear
3810 // synchronously so a later render doesn't show stale text.
3811 if len(i.streamPending) > 0 {
3812 i.streamFlushPending = true
3813 return
3814 }
3815 i.resetStreamingStateLocked()
3816 case core.EvToolUseStart:
3817 // Live streaming: pre-create the view so the user sees the
3818 // tool call being composed in real time. Any subsequent
3819 // EvToolCall for the same ID updates the same struct (the
3820 // final parsed args + name are already known here).
3821 if _, exists := i.toolCalls[e.ID]; !exists {
3822 i.toolCalls[e.ID] = &tui.ToolCallView{
3823 ID: e.ID,
3824 Name: e.Name,
3825 Streaming: true,
3826 }
3827 i.toolOrder = append(i.toolOrder, e.ID)
3828 }
3829 case core.EvToolUseArgs:
3830 if tc, ok := i.toolCalls[e.ID]; ok {
3831 tc.RawJSONBuf += e.Delta
3832 // Refresh the live path as soon as it parses; used in
3833 // the header (write /Users/pat/Desktop/demo.ts)
3834 // while the content is still streaming.
3835 if p, pok, _ := tui.ExtractPartialStringField(tc.RawJSONBuf, "path"); pok {
3836 tc.LivePath = p
3837 } else if p, pok, _ := tui.ExtractPartialStringField(tc.RawJSONBuf, "file_path"); pok {
3838 tc.LivePath = p
3839 }
3840 }
3841 case core.EvToolUseEnd:
3842 if tc, ok := i.toolCalls[e.ID]; ok {
3843 tc.Streaming = false
3844 }
3845 case core.EvToolCall:
3846 // If we already pre-created the view during streaming, just
3847 // refresh the final Args summary. Otherwise create a new one
3848 // (non-streaming providers or legacy paths).
3849 if tc, ok := i.toolCalls[e.ID]; ok {
3850 tc.Args = tui.ShortArgs(e.Name, e.Args)
3851 tc.Streaming = false
3852 } else {
3853 i.toolCalls[e.ID] = &tui.ToolCallView{
3854 ID: e.ID,
3855 Name: e.Name,
3856 Args: tui.ShortArgs(e.Name, e.Args),
3857 }
3858 i.toolOrder = append(i.toolOrder, e.ID)
3859 }
3860 case core.EvToolResult:
3861 if tc, ok := i.toolCalls[e.ID]; ok {
3862 tc.Done = true
3863 tc.Error = e.Result.IsError
3864 var text strings.Builder
3865 for _, c := range e.Result.Content {
3866 if tb, ok := c.(provider.TextBlock); ok {
3867 if text.Len() > 0 {
3868 text.WriteString("\n")
3869 }
3870 text.WriteString(tb.Text)
3871 }
3872 }
3873 tc.Result = text.String()
3874 }
3875 if i.cfg.OnToolResult != nil {
3876 i.cfg.OnToolResult(e.ID, e.Result)
3877 }
3878 case core.EvUsage:
3879 i.cumUsage = e.Cumulative
3880 if e.Usage.InputTokens > 0 {
3881 i.lastCtxInput = e.Usage.InputTokens + e.Usage.CacheReadTokens + e.Usage.CacheWriteTokens
3882 }
3883 case core.EvTurnEnd:
3884 if e.Stop == provider.StopAborted {
3885 i.resetStreamingStateLocked()
3886 i.statusErr = ""
3887 i.statusOK = "cancelled"
3888 return
3889 }
3890 // Don't surface mid-loop stream errors as a red banner here.
3891 // EvTurnEnd fires after every step in a multi-step tool loop,
3892 // so a transient 503 / network blip would briefly paint a red
3893 // banner over the still-streaming chat before the agent loop
3894 // either retries or exits. The final error (if any) is set by
3895 // startTurnWithImages once Prompt() returns, and recoverable
3896 // failures are routed to the rescue picker instead — which
3897 // keeps the chat clean while the agent is working.
3898 _ = e.Err
3899 }
3900}
3901
3902// Agent returns the current agent, if any. Used by cli.go to flush the
3903// final transcript to the session file.
3904func (i *Interactive) Agent() *core.Agent {
3905 i.mu.Lock()
3906 defer i.mu.Unlock()
3907 return i.agent
3908}
3909
3910// silence unused import in some build configs
3911var _ = fmt.Sprintf
3912
3913// runReloadExt triggers a live reload of every extension (discovered
3914// + explicit). Runs on a goroutine so the TUI stays responsive; the
3915// Manager.Reload takes a couple of hundred ms to shut down subprocs
3916// and respawn them. Shows a status line throughout.
3917func (i *Interactive) runReloadExt(ctx context.Context) {
3918 if i.cfg.Extensions == nil {
3919 i.mu.Lock()
3920 i.statusErr = "no extension manager in this build"
3921 i.mu.Unlock()
3922 i.invalidate()
3923 return
3924 }
3925 i.mu.Lock()
3926 i.statusOK = "reloading extensions…"
3927 i.statusErr = ""
3928 i.mu.Unlock()
3929 i.invalidate()
3930
3931 go func() {
3932 stats := i.cfg.Extensions.Reload(ctx, 2*time.Second)
3933 msg := fmt.Sprintf("reloaded: %d stopped, %d loaded (%d ready)", stats.Stopped, stats.Loaded, stats.Ready)
3934 if len(stats.Errors) > 0 {
3935 msg += fmt.Sprintf(", %d error(s)", len(stats.Errors))
3936 }
3937 i.mu.Lock()
3938 i.statusOK = msg
3939 i.statusErr = ""
3940 i.mu.Unlock()
3941 i.invalidate()
3942 }()
3943}
3944
3945// Confirm implements core.Confirmer. The agent goroutine calls
3946// this synchronously before every tool invocation when --no-yolo is
3947// active. We push the request onto the confirmDialog queue, trigger
3948// a redraw, and block the caller until the user answers.
3949//
3950// If the session is cancelled or the TUI exits mid-prompt, any
3951// pending request is refused via CancelAll so the agent doesn't
3952// deadlock.
3953func (i *Interactive) Confirm(toolName string, preview string) core.ConfirmDecision {
3954 resp := make(chan core.ConfirmDecision, 1)
3955 i.confirmDialog.Enqueue(&confirmRequest{
3956 toolName: toolName,
3957 preview: preview,
3958 resp: resp,
3959 })
3960 i.invalidate()
3961 return <-resp
3962}
3963
3964// openTelegramDialog shows the picker for `/telegram` with no arg.
3965// Items depend on current state: disconnect + status when running,
3966// connect + status when stopped.
3967func (i *Interactive) openTelegramDialog() {
3968 items := i.telegramMenuItems()
3969 if len(items) == 0 {
3970 i.mu.Lock()
3971 i.statusErr = "telegram not configured. run `zot telegram-bot setup` first."
3972 i.mu.Unlock()
3973 i.invalidate()
3974 return
3975 }
3976 i.telegramDialog.Open(items)
3977 i.invalidate()
3978}
3979
3980// telegramMenuItems builds the dialog entries for the current
3981// bridge state. Returns empty when no bot.json exists so the
3982// caller can show a helpful status line instead of an empty menu.
3983func (i *Interactive) telegramMenuItems() []telegramItem {
3984 cfg, err := telegram.LoadConfig(i.cfg.ZotHome)
3985 if err != nil || cfg.BotToken == "" {
3986 return nil
3987 }
3988 var items []telegramItem
3989 if i.telegramBridge != nil && i.telegramBridge.Active() {
3990 items = append(items, telegramItem{label: "disconnect", action: "disconnect", hint: "stop mirroring"})
3991 st := i.telegramBridge.State()
3992 hint := "active"
3993 if st.Username != "" {
3994 hint += " as @" + st.Username
3995 }
3996 items = append(items, telegramItem{label: "status", action: "status", hint: hint})
3997 } else {
3998 label := "connect"
3999 hint := "start mirroring dms into this session"
4000 if cfg.AllowedUserID == 0 {
4001 hint = "awaiting pairing (send /start to the bot once connected)"
4002 }
4003 items = append(items, telegramItem{label: label, action: "connect", hint: hint})
4004 items = append(items, telegramItem{label: "status", action: "status", hint: "disconnected"})
4005 }
4006 return items
4007}
4008
4009// doTelegram dispatches one of the three explicit actions. Called
4010// from /telegram <action> or after the picker selects a row.
4011func (i *Interactive) doTelegram(action string) {
4012 switch action {
4013 case "connect":
4014 i.telegramConnect()
4015 case "disconnect":
4016 i.telegramDisconnect()
4017 case "status":
4018 i.telegramStatus()
4019 default:
4020 i.mu.Lock()
4021 i.statusErr = "unknown telegram action: " + action + " (use connect, disconnect, or status)"
4022 i.mu.Unlock()
4023 i.invalidate()
4024 }
4025}
4026
4027// telegramConnect starts the bridge. Refuses if it's already
4028// running or if the on-disk bot.json is missing a token.
4029func (i *Interactive) telegramConnect() {
4030 if i.telegramBridge != nil && i.telegramBridge.Active() {
4031 i.mu.Lock()
4032 i.statusOK = "telegram already connected"
4033 i.statusErr = ""
4034 i.mu.Unlock()
4035 i.invalidate()
4036 return
4037 }
4038 cfg, err := telegram.LoadConfig(i.cfg.ZotHome)
4039 if err != nil {
4040 i.mu.Lock()
4041 i.statusErr = "telegram: " + err.Error()
4042 i.mu.Unlock()
4043 i.invalidate()
4044 return
4045 }
4046 if cfg.BotToken == "" {
4047 i.mu.Lock()
4048 i.statusErr = "telegram: no bot token configured. run `zot telegram-bot setup` first."
4049 i.mu.Unlock()
4050 i.invalidate()
4051 return
4052 }
4053 // Refuse to start when a background daemon is already polling
4054 // the same bot. Two concurrent long-poll consumers race each
4055 // update and one always loses, so DMs get half-delivered. The
4056 // user can `zot telegram-bot stop` first, then /telegram connect.
4057 if pid, alive, _ := telegram.IsRunning(i.cfg.ZotHome); alive && pid > 0 {
4058 i.mu.Lock()
4059 i.statusErr = fmt.Sprintf("telegram: bot daemon already running (pid %d). stop it with `zot telegram-bot stop` first.", pid)
4060 i.mu.Unlock()
4061 i.invalidate()
4062 return
4063 }
4064 i.telegramBridge = &telegram.Bridge{
4065 Client: telegram.NewClient(cfg.BotToken),
4066 Config: cfg,
4067 Save: func(next telegram.Config) error {
4068 return telegram.SaveConfig(i.cfg.ZotHome, next)
4069 },
4070 Host: &telegramHost{iv: i},
4071 }
4072 if err := i.telegramBridge.Start(i.runCtx); err != nil {
4073 i.telegramBridge = nil
4074 i.mu.Lock()
4075 i.statusErr = "telegram connect failed: " + err.Error()
4076 i.mu.Unlock()
4077 i.invalidate()
4078 return
4079 }
4080 i.applyTelegramTools(true)
4081 state := i.telegramBridge.State()
4082 label := "telegram connected"
4083 if state.Username != "" {
4084 label += " as @" + state.Username
4085 }
4086 if state.PairedID == 0 {
4087 label += " — send /start to the bot from your phone to claim it"
4088 }
4089 i.mu.Lock()
4090 i.statusOK = label
4091 i.statusErr = ""
4092 i.mu.Unlock()
4093 i.invalidate()
4094}
4095
4096// telegramDisconnect stops the bridge. No-op when already stopped.
4097func (i *Interactive) telegramDisconnect() {
4098 if i.telegramBridge == nil || !i.telegramBridge.Active() {
4099 i.mu.Lock()
4100 i.statusOK = "telegram already disconnected"
4101 i.statusErr = ""
4102 i.mu.Unlock()
4103 i.invalidate()
4104 return
4105 }
4106 i.telegramBridge.Stop()
4107 i.applyTelegramTools(false)
4108 i.mu.Lock()
4109 i.statusOK = "telegram disconnected"
4110 i.statusErr = ""
4111 i.mu.Unlock()
4112 i.invalidate()
4113}
4114
4115// telegramSenderAdapter wraps the bridge so the tools package can
4116// drive it without importing telegram directly. The Active() check
4117// is forwarded to the bridge so the tool can fail clearly with a
4118// model-readable error when the user disconnected mid-turn.
4119type telegramSenderAdapter struct {
4120 bridge *telegram.Bridge
4121}
4122
4123func (a telegramSenderAdapter) SendImage(ctx context.Context, path, caption string) error {
4124 if a.bridge == nil {
4125 return fmt.Errorf("telegram bridge is not connected")
4126 }
4127 return a.bridge.SendImage(ctx, path, caption)
4128}
4129
4130func (a telegramSenderAdapter) SendDocument(ctx context.Context, path, caption string) error {
4131 if a.bridge == nil {
4132 return fmt.Errorf("telegram bridge is not connected")
4133 }
4134 return a.bridge.SendDocument(ctx, path, caption)
4135}
4136
4137func (a telegramSenderAdapter) Active() bool {
4138 return a.bridge != nil && a.bridge.Active()
4139}
4140
4141// applyTelegramTools registers (active=true) or removes (active=false)
4142// the telegram_send_image and telegram_send_file tools on the running
4143// agent so the model only sees them while the bridge is connected.
4144// Snapshots and mutates the live tool registry so any extension or
4145// /reload-ext additions made while Telegram is connected survive a
4146// later /telegram disconnect (we only add or strip the two telegram
4147// entries, never the rest).
4148func (i *Interactive) applyTelegramTools(active bool) {
4149 if i.agent == nil {
4150 return
4151 }
4152 current := i.agent.Tools
4153 next := core.Registry{}
4154 for name, t := range current {
4155 if name == "telegram_send_image" || name == "telegram_send_file" {
4156 continue
4157 }
4158 next[name] = t
4159 }
4160 if active {
4161 sender := telegramSenderAdapter{bridge: i.telegramBridge}
4162 next["telegram_send_image"] = &tools.TelegramSendImageTool{
4163 CWD: i.cfg.CWD, Sandbox: i.cfg.Sandbox, Sender: sender,
4164 }
4165 next["telegram_send_file"] = &tools.TelegramSendFileTool{
4166 CWD: i.cfg.CWD, Sandbox: i.cfg.Sandbox, Sender: sender,
4167 }
4168 }
4169 i.agent.SetTools(next)
4170}
4171
4172// telegramStatus writes a one-liner describing the bridge state.
4173// Reports on both the in-tui bridge and the background daemon so
4174// the user isn't confused when the daemon owns the poll loop.
4175func (i *Interactive) telegramStatus() {
4176 var msg string
4177 if i.telegramBridge != nil && i.telegramBridge.Active() {
4178 s := i.telegramBridge.State()
4179 msg = "telegram: connected (tui bridge)"
4180 if s.Username != "" {
4181 msg += " as @" + s.Username
4182 }
4183 if s.PairedID != 0 {
4184 msg += fmt.Sprintf(" - paired with user %d", s.PairedID)
4185 } else {
4186 msg += " - awaiting pairing"
4187 }
4188 } else if pid, alive, _ := telegram.IsRunning(i.cfg.ZotHome); alive && pid > 0 {
4189 msg = fmt.Sprintf("telegram: background daemon running (pid %d) - /telegram connect won't work until you stop it", pid)
4190 } else {
4191 cfg, _ := telegram.LoadConfig(i.cfg.ZotHome)
4192 if cfg.BotToken == "" {
4193 msg = "telegram: not configured. run `zot telegram-bot setup` first."
4194 } else {
4195 msg = "telegram: disconnected"
4196 if cfg.BotUsername != "" {
4197 msg += " (@" + cfg.BotUsername + " ready to connect)"
4198 }
4199 }
4200 }
4201 i.mu.Lock()
4202 i.statusOK = msg
4203 i.statusErr = ""
4204 i.mu.Unlock()
4205 i.invalidate()
4206}
4207
4208// telegramHost adapts *Interactive to telegram.Host so the bridge
4209// can call back into the TUI without importing modes directly.
4210type telegramHost struct{ iv *Interactive }
4211
4212func (h *telegramHost) SubmitOrQueue(prompt string, images []provider.ImageBlock) {
4213 h.iv.SubmitOrQueue(prompt, images)
4214}
4215
4216func (h *telegramHost) CancelTurn() { h.iv.CancelTurn() }
4217
4218func (h *telegramHost) Status() string {
4219 h.iv.mu.Lock()
4220 providerName := h.iv.cfg.Provider
4221 model := h.iv.cfg.Model
4222 cwd := h.iv.cfg.CWD
4223 usage := h.iv.cumUsage
4224 subscription := h.iv.cfg.AuthMethod == "oauth"
4225 ctxUsed := h.iv.lastCtxInput
4226 busy := h.iv.busy
4227 queued := len(h.iv.queued)
4228 h.iv.mu.Unlock()
4229
4230 ctxMax := 0
4231 if m, err := provider.FindModel(providerName, model); err == nil {
4232 ctxMax = m.ContextWindow
4233 }
4234 return telegram.FormatStatus(telegram.StatusSnapshot{
4235 Provider: providerName,
4236 Model: model,
4237 CWD: cwd,
4238 Usage: usage,
4239 Subscription: subscription,
4240 ContextUsed: ctxUsed,
4241 ContextMax: ctxMax,
4242 Busy: busy,
4243 Queued: queued,
4244 })
4245}
4246
4247func (h *telegramHost) Notify(level, message string) {
4248 h.iv.mu.Lock()
4249 switch level {
4250 case "error", "warn":
4251 h.iv.statusErr = message
4252 h.iv.statusOK = ""
4253 default:
4254 h.iv.statusOK = message
4255 h.iv.statusErr = ""
4256 }
4257 h.iv.mu.Unlock()
4258 h.iv.invalidate()
4259}
4260
4261// openSessionOpsDialog shows the picker for `/session` with no arg.
4262// Always offers export, import, fork, tree; the handlers bail with
4263// a clear status message when the precondition isn't met (empty
4264// transcript on fork; no parent/siblings on tree).
4265func (i *Interactive) openSessionOpsDialog() {
4266 items := []sessionOpsItem{
4267 {label: "export", action: "export", hint: "write the current session to a .zotsession file"},
4268 {label: "import", action: "import", hint: "load a .zotsession file into this directory"},
4269 {label: "fork", action: "fork", hint: "branch from a past user message into a new session"},
4270 {label: "tree", action: "tree", hint: "switch between branches in this directory"},
4271 }
4272 i.sessionOpsDialog.Open(items)
4273 i.invalidate()
4274}
4275
4276// doSessionOp dispatches export, import, fork, or tree. arg is the
4277// optional positional argument from e.g. /session export <path>
4278// or /session import <path>; fork and tree ignore it.
4279func (i *Interactive) doSessionOp(action, arg string) {
4280 switch action {
4281 case "export":
4282 i.doSessionExport(arg)
4283 case "import":
4284 i.doSessionImport(arg)
4285 case "fork":
4286 i.doSessionFork()
4287 case "tree":
4288 i.doSessionTree()
4289 default:
4290 i.mu.Lock()
4291 i.statusErr = "unknown /session action: " + action + " (use export, import, fork, or tree)"
4292 i.mu.Unlock()
4293 i.invalidate()
4294 }
4295}
4296
4297// doSessionExport writes the live session file to destination path
4298// dst. When dst is empty we default to ~/Downloads (falling back to
4299// the user's home directory if it doesn't exist). The helper
4300// expands a leading `~` and creates any missing parent directories.
4301func (i *Interactive) doSessionExport(dst string) {
4302 if i.cfg.CurrentSessionPath == nil {
4303 i.mu.Lock()
4304 i.statusErr = "export: no session is active (running with --no-session?)"
4305 i.mu.Unlock()
4306 i.invalidate()
4307 return
4308 }
4309 src := i.cfg.CurrentSessionPath()
4310 if src == "" {
4311 i.mu.Lock()
4312 i.statusErr = "export: no session is active (running with --no-session?)"
4313 i.mu.Unlock()
4314 i.invalidate()
4315 return
4316 }
4317 // Persist any in-memory agent messages to the session file so
4318 // the export carries the full conversation. Without this, the
4319 // default lazy-flush-at-exit strategy leaves most of a running
4320 // session unwritten and the export ends up with only the meta.
4321 if i.cfg.FlushSession != nil {
4322 i.cfg.FlushSession()
4323 }
4324 dst = unquotePath(dst)
4325 if dst == "" {
4326 dst = defaultExportDir()
4327 } else {
4328 dst = expandTilde(dst)
4329 }
4330 out, err := core.ExportSession(src, dst)
4331 if err != nil {
4332 i.mu.Lock()
4333 i.statusErr = "export: " + err.Error()
4334 i.mu.Unlock()
4335 i.invalidate()
4336 return
4337 }
4338 i.mu.Lock()
4339 i.statusOK = "exported session to " + friendlyPath(out)
4340 i.statusErr = ""
4341 i.mu.Unlock()
4342 i.invalidate()
4343}
4344
4345// doSessionImport copies the .zotsession file at src into the
4346// running cwd's sessions directory and loads it as the active
4347// session, same as `/sessions` -> pick. When src is empty we ask
4348// the user to pass a path (no usable default here).
4349func (i *Interactive) doSessionImport(src string) {
4350 src = unquotePath(src)
4351 if src == "" {
4352 i.mu.Lock()
4353 i.statusErr = "import: pass a path — e.g. /session import ~/Downloads/work.zotsession"
4354 i.mu.Unlock()
4355 i.invalidate()
4356 return
4357 }
4358 src = expandTilde(src)
4359 if _, err := os.Stat(src); err != nil {
4360 i.mu.Lock()
4361 i.statusErr = "import: " + err.Error()
4362 i.mu.Unlock()
4363 i.invalidate()
4364 return
4365 }
4366 newPath, err := core.ImportSession(src, i.cfg.ZotHome, i.cfg.CWD, i.cfg.Version)
4367 if err != nil {
4368 i.mu.Lock()
4369 i.statusErr = "import: " + err.Error()
4370 i.mu.Unlock()
4371 i.invalidate()
4372 return
4373 }
4374 if i.cfg.LoadSession == nil {
4375 i.mu.Lock()
4376 i.statusOK = "imported session at " + friendlyPath(newPath) + " (run /sessions to resume it)"
4377 i.statusErr = ""
4378 i.mu.Unlock()
4379 i.invalidate()
4380 return
4381 }
4382 if err := i.cfg.LoadSession(newPath); err != nil {
4383 i.mu.Lock()
4384 i.statusErr = "import: load failed: " + err.Error()
4385 i.mu.Unlock()
4386 i.invalidate()
4387 return
4388 }
4389 i.mu.Lock()
4390 i.statusOK = "imported and switched to session " + friendlyPath(newPath)
4391 i.statusErr = ""
4392 i.mu.Unlock()
4393 i.invalidate()
4394}
4395
4396// defaultExportDir returns ~/Downloads when it exists, or ~ as a
4397// fallback, or /tmp on exotic machines with no home dir.
4398func defaultExportDir() string {
4399 home, err := os.UserHomeDir()
4400 if err != nil || home == "" {
4401 return os.TempDir()
4402 }
4403 downloads := filepath.Join(home, "Downloads")
4404 if fi, err := os.Stat(downloads); err == nil && fi.IsDir() {
4405 return downloads
4406 }
4407 return home
4408}
4409
4410// expandTilde turns a leading ~ into the user's home directory.
4411// Returns the input unchanged when there's no tilde or no home.
4412func expandTilde(p string) string {
4413 if p == "" || p[0] != '~' {
4414 return p
4415 }
4416 home, err := os.UserHomeDir()
4417 if err != nil || home == "" {
4418 return p
4419 }
4420 if len(p) == 1 {
4421 return home
4422 }
4423 if p[1] == '/' || p[1] == filepath.Separator {
4424 return filepath.Join(home, p[2:])
4425 }
4426 return p
4427}
4428
4429// unquotePath strips a matching pair of surrounding single or
4430// double quotes. Drag-drop paste in the tui auto-quotes dropped
4431// file paths so the shell-like `/session import 'foo bar.zs'`
4432// stays well-formed; when the TUI's own slash handler consumes
4433// the arg, we want the raw path back.
4434func unquotePath(p string) string {
4435 p = strings.TrimSpace(p)
4436 if len(p) >= 2 {
4437 first := p[0]
4438 last := p[len(p)-1]
4439 if (first == '\'' && last == '\'') || (first == '"' && last == '"') {
4440 p = p[1 : len(p)-1]
4441 }
4442 }
4443 return p
4444}
4445
4446// friendlyPath collapses the user's home directory to a leading ~
4447// so status messages read cleanly. Falls back to the raw path when
4448// the home dir is unknown.
4449func friendlyPath(p string) string {
4450 home, err := os.UserHomeDir()
4451 if err != nil || home == "" {
4452 return p
4453 }
4454 if strings.HasPrefix(p, home+string(filepath.Separator)) {
4455 return "~" + p[len(home):]
4456 }
4457 return p
4458}
4459
4460// doSessionFork opens the /jump turn picker in "fork mode". The
4461// next selection branches the current session at that user turn
4462// instead of scrolling the viewport to it.
4463func (i *Interactive) doSessionFork() {
4464 if i.cfg.CurrentSessionPath == nil || i.cfg.CurrentSessionPath() == "" {
4465 i.mu.Lock()
4466 i.statusErr = "fork: no session is active (running with --no-session?)"
4467 i.mu.Unlock()
4468 i.invalidate()
4469 return
4470 }
4471 msgs := []provider.Message{}
4472 if i.agent != nil {
4473 msgs = i.agent.Messages()
4474 }
4475 if len(msgs) == 0 {
4476 i.mu.Lock()
4477 i.statusErr = "fork: transcript is empty; nothing to fork from"
4478 i.mu.Unlock()
4479 i.invalidate()
4480 return
4481 }
4482 i.pendingFork = true
4483 i.jumpDialog.Open(msgs, "")
4484 i.invalidate()
4485}
4486
4487// doSessionTree shows the branch topology picker for this cwd.
4488// Pick an entry to switch into it (same semantics as /sessions
4489// picking a past session, but with the parent/child indentation).
4490func (i *Interactive) doSessionTree() {
4491 if i.cfg.ZotHome == "" || i.cfg.CWD == "" {
4492 i.mu.Lock()
4493 i.statusErr = "tree: session storage not configured"
4494 i.mu.Unlock()
4495 i.invalidate()
4496 return
4497 }
4498 // Flush the running agent's transcript first so its message
4499 // count + latest preview are accurate in the tree.
4500 if i.cfg.FlushSession != nil {
4501 i.cfg.FlushSession()
4502 }
4503 roots := core.BuildSessionTree(i.cfg.ZotHome, i.cfg.CWD)
4504 if len(roots) == 0 {
4505 i.mu.Lock()
4506 i.statusErr = "tree: no sessions in this directory yet"
4507 i.mu.Unlock()
4508 i.invalidate()
4509 return
4510 }
4511 current := ""
4512 if i.cfg.CurrentSessionPath != nil {
4513 current = i.cfg.CurrentSessionPath()
4514 }
4515 if !i.sessionTreeDialog.Open(roots, current) {
4516 i.mu.Lock()
4517 i.statusErr = "tree: no branches to show"
4518 i.mu.Unlock()
4519 }
4520 i.invalidate()
4521}
4522
4523// applySessionTreeSelection switches the running agent to the
4524// session file at path. Thin wrapper around LoadSession that also
4525// writes a status line.
4526func (i *Interactive) applySessionTreeSelection(path string) {
4527 if i.cfg.LoadSession == nil {
4528 i.mu.Lock()
4529 i.statusErr = "tree: session swap not available in this build"
4530 i.mu.Unlock()
4531 i.invalidate()
4532 return
4533 }
4534 if err := i.cfg.LoadSession(path); err != nil {
4535 i.mu.Lock()
4536 i.statusErr = "tree: load failed: " + err.Error()
4537 i.mu.Unlock()
4538 i.invalidate()
4539 return
4540 }
4541 i.mu.Lock()
4542 i.statusOK = "switched to branch " + friendlyPath(path)
4543 i.statusErr = ""
4544 i.mu.Unlock()
4545 i.invalidate()
4546}
4547
4548// applyForkSelection branches the current session at msgIdx+1 (so
4549// the selected user message and everything before it is included
4550// in the new branch), then switches the running agent to the new
4551// file. Called from the jump-dialog handler when pendingFork=true.
4552func (i *Interactive) applyForkSelection(msgIdx int) {
4553 i.pendingFork = false
4554 if i.cfg.CurrentSessionPath == nil {
4555 i.mu.Lock()
4556 i.statusErr = "fork: no session is active"
4557 i.mu.Unlock()
4558 i.invalidate()
4559 return
4560 }
4561 src := i.cfg.CurrentSessionPath()
4562 if src == "" {
4563 i.mu.Lock()
4564 i.statusErr = "fork: no session is active"
4565 i.mu.Unlock()
4566 i.invalidate()
4567 return
4568 }
4569 if i.cfg.FlushSession != nil {
4570 i.cfg.FlushSession()
4571 }
4572 // msgIdx is 0-indexed message position; copy msgIdx+1 rows so
4573 // the selected user message is included.
4574 upTo := msgIdx + 1
4575 newPath, err := core.BranchSession(src, i.cfg.ZotHome, i.cfg.CWD, i.cfg.Version, upTo)
4576 if err != nil {
4577 i.mu.Lock()
4578 i.statusErr = "fork: " + err.Error()
4579 i.mu.Unlock()
4580 i.invalidate()
4581 return
4582 }
4583 if i.cfg.LoadSession == nil {
4584 i.mu.Lock()
4585 i.statusOK = "forked at message " + formatInt(upTo) + " (run /sessions to resume)"
4586 i.statusErr = ""
4587 i.mu.Unlock()
4588 i.invalidate()
4589 return
4590 }
4591 if err := i.cfg.LoadSession(newPath); err != nil {
4592 i.mu.Lock()
4593 i.statusErr = "fork: switch failed: " + err.Error()
4594 i.mu.Unlock()
4595 i.invalidate()
4596 return
4597 }
4598 i.mu.Lock()
4599 i.statusOK = "forked and switched to new branch at " + friendlyPath(newPath)
4600 i.statusErr = ""
4601 i.mu.Unlock()
4602 i.invalidate()
4603}
4604
4605// formatInt is a tiny strconv.Itoa shim; keeps the handler above
4606// from needing a strconv import just for one call.
4607func formatInt(n int) string {
4608 return fmt.Sprintf("%d", n)
4609}
4610
4611// assistantText returns the concatenated text of every TextBlock in
4612// m. Used by the streaming-view dedupe guard to tell when a live
4613// streamed reply has already been promoted into the transcript.
4614func assistantText(m provider.Message) string {
4615 var sb strings.Builder
4616 for _, c := range m.Content {
4617 if tb, ok := c.(provider.TextBlock); ok {
4618 if sb.Len() > 0 {
4619 sb.WriteByte('\n')
4620 }
4621 sb.WriteString(tb.Text)
4622 }
4623 }
4624 return sb.String()
4625}
4626
4627// resetStreamingStateLocked clears every piece of streaming state
4628// in one shot. Used by abort paths (turn cancel, compact finish,
4629// queue drain) so the pacer doesn't keep draining stale runes from
4630// a prior turn. Must be called with i.mu held.
4631func (i *Interactive) resetStreamingStateLocked() {
4632 i.streaming.Reset()
4633 i.streamPending = i.streamPending[:0]
4634 i.streamFlushPending = false
4635 i.streamOn = false
4636}
4637
4638// assistantMessageSideEffects runs the non-visual hooks attached to
4639// EvAssistantMessage: the host-provided OnAssistant callback and the
4640// telegram-bridge mirror. Called with i.mu held.
4641//
4642// Factored out of handleEvent because the streaming pacer may defer
4643// visual reset until after the last buffered rune has painted, but
4644// the callbacks themselves must fire on message arrival so
4645// downstream observers (session persistence, telegram, cost panels)
4646// don't wait on a UI animation to catch up.
4647func (i *Interactive) assistantMessageSideEffects(m provider.Message) {
4648 if i.cfg.OnAssistant != nil {
4649 i.cfg.OnAssistant(m)
4650 }
4651 if i.telegramBridge != nil && i.telegramBridge.Active() {
4652 var sb strings.Builder
4653 for _, c := range m.Content {
4654 if tb, ok := c.(provider.TextBlock); ok {
4655 if sb.Len() > 0 {
4656 sb.WriteString("\n")
4657 }
4658 sb.WriteString(tb.Text)
4659 }
4660 }
4661 if text := sb.String(); strings.TrimSpace(text) != "" {
4662 go i.telegramBridge.OnAssistantText(text)
4663 }
4664 }
4665}
4666
4667// paintPaceRate is how many runes the streaming pacer releases per
4668// tick. With a 16ms tick, 6 runes/tick is ~375 runes/s — fast enough
4669// that a 500-rune summary finishes in ~1.3s, slow enough to look
4670// like a human typing. Empirically matches the feel of provider
4671// paths that already drip-stream natively.
4672const paintPaceRate = 6
4673
4674// paintPaceInterval is the tick interval for the streaming pacer.
4675// 16ms lines up with the redraw throttle so we never paint faster
4676// than the terminal can keep up.
4677const paintPaceInterval = 16 * time.Millisecond
4678
4679// runStreamPacer drains buffered deltas from streamPending into
4680// streaming a small batch per tick, invalidating after each move.
4681// It stops when the context cancels (tui shutdown).
4682//
4683// Why a pacer: providers differ wildly in how they chunk their
4684// text_delta events. The API-key path on Anthropic emits ~30 drips
4685// for a 400-token summary; the OAuth path can coalesce the same
4686// summary into 3 fat chunks, visually indistinguishable from "the
4687// whole reply just appeared". The pacer normalizes that so every
4688// path looks the same on screen.
4689func (i *Interactive) runStreamPacer(ctx context.Context) {
4690 t := time.NewTicker(paintPaceInterval)
4691 defer t.Stop()
4692 for {
4693 select {
4694 case <-ctx.Done():
4695 return
4696 case <-t.C:
4697 i.mu.Lock()
4698 if len(i.streamPending) == 0 {
4699 // EvAssistantMessage already fired but the pacer
4700 // was still draining a tick ago. Everything is now
4701 // painted; clear the streaming flags so the next
4702 // redraw shows the finalised transcript message
4703 // and hides the streaming overlay.
4704 if i.streamFlushPending {
4705 i.streamFlushPending = false
4706 i.streaming.Reset()
4707 i.streamOn = false
4708 i.mu.Unlock()
4709 i.invalidate()
4710 continue
4711 }
4712 i.mu.Unlock()
4713 continue
4714 }
4715 n := paintPaceRate
4716 if n > len(i.streamPending) {
4717 n = len(i.streamPending)
4718 }
4719 i.streaming.WriteString(string(i.streamPending[:n]))
4720 i.streamPending = i.streamPending[n:]
4721 i.mu.Unlock()
4722 i.invalidate()
4723 }
4724 }
4725}