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