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