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