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