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