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