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