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