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