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