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