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