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