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