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