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