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