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