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