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