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 if act.Delete {
2228 i.applySessionDeletion(act.Path)
2229 }
2230 // Always request a redraw after handling a key here: when esc
2231 // closes the picker, the overlay-close detection in the render
2232 // pass needs to run so the tall dialog rows get repainted from
2233 // the chat (otherwise VS Code's retained scrollback leaves a
2234 // duplicate frame on screen).
2235 i.invalidate()
2236 return false
2237 }
2238 if i.swarmDialog.Active() {
2239 if k.Kind == tui.KeyCtrlC {
2240 i.swarmDialog.Close()
2241 i.invalidate()
2242 return false
2243 }
2244 _, msg, errMsg := i.swarmDialog.HandleKey(k)
2245 if msg != "" || errMsg != "" {
2246 i.mu.Lock()
2247 i.statusOK = msg
2248 i.statusErr = errMsg
2249 i.mu.Unlock()
2250 }
2251 i.invalidate()
2252 return false
2253 }
2254 if i.logoutDialog.Active() {
2255 if k.Kind == tui.KeyCtrlC {
2256 i.logoutDialog.Close()
2257 i.invalidate()
2258 return false
2259 }
2260 act := i.logoutDialog.HandleKey(k)
2261 if act.Select {
2262 i.doLogout(act.Target)
2263 }
2264 i.invalidate()
2265 return false
2266 }
2267 if i.telegramDialog.Active() {
2268 if k.Kind == tui.KeyCtrlC {
2269 i.telegramDialog.Close()
2270 i.invalidate()
2271 return false
2272 }
2273 act := i.telegramDialog.HandleKey(k)
2274 if act.Select {
2275 i.doTelegram(act.Action)
2276 }
2277 i.invalidate()
2278 return false
2279 }
2280 if i.settingsDialog.Active() {
2281 if k.Kind == tui.KeyCtrlC {
2282 i.settingsDialog.Close()
2283 i.invalidate()
2284 return false
2285 }
2286 act := i.settingsDialog.HandleKey(k)
2287 if act.ModelShortcutSlot > 0 {
2288 i.openQuickModelPicker(act.ModelShortcutSlot)
2289 }
2290 if act.Toggle {
2291 i.applySettingChange(act)
2292 }
2293 i.invalidate()
2294 return false
2295 }
2296 if i.sessionOpsDialog.Active() {
2297 if k.Kind == tui.KeyCtrlC {
2298 i.sessionOpsDialog.Close()
2299 i.invalidate()
2300 return false
2301 }
2302 act := i.sessionOpsDialog.HandleKey(k)
2303 if act.Select {
2304 i.doSessionOp(act.Action, "")
2305 }
2306 i.invalidate()
2307 return false
2308 }
2309 if i.sessionTreeDialog.Active() {
2310 if k.Kind == tui.KeyCtrlC {
2311 i.sessionTreeDialog.Close()
2312 i.invalidate()
2313 return false
2314 }
2315 act := i.sessionTreeDialog.HandleKey(k)
2316 if act.Select {
2317 i.applySessionTreeMessageSelection(act.Path, act.MessageIdx, act.TurnNo, act.Role, act.Prompt)
2318 }
2319 i.invalidate()
2320 return false
2321 }
2322 if i.extPanel.Active() {
2323 if k.Kind == tui.KeyCtrlC || k.Kind == tui.KeyEsc {
2324 if i.cfg.Extensions != nil {
2325 _ = i.cfg.Extensions.SendPanelClose(i.extPanel.ext, i.extPanel.id)
2326 }
2327 i.extPanel.Close()
2328 i.invalidate()
2329 return false
2330 }
2331 if i.cfg.Extensions != nil {
2332 _ = i.cfg.Extensions.SendPanelKey(i.extPanel.ext, i.extPanel.id, panelKeyName(k), panelKeyText(k))
2333 }
2334 return false
2335 }
2336 if i.timeline.Active() {
2337 data := i.timelineData()
2338 act := i.timeline.HandleKey(k, data)
2339 if act.Export {
2340 path, err := i.timeline.Export("", data)
2341 if err != nil {
2342 i.timeline.SetNotice("export failed: " + err.Error())
2343 } else {
2344 i.timeline.SetNotice("exported to " + friendlyPath(path))
2345 }
2346 }
2347 i.mu.Lock()
2348 i.chatCacheValid = false
2349 i.scrollOffset = 0
2350 i.mu.Unlock()
2351 i.invalidate()
2352 return false
2353 }
2354 if i.jumpDialog.Active() {
2355 if k.Kind == tui.KeyCtrlC {
2356 i.jumpDialog.Close()
2357 i.pendingFork = false
2358 return false
2359 }
2360 act := i.jumpDialog.HandleKey(k)
2361 if act.Select {
2362 if i.pendingFork {
2363 i.applyForkSelection(act.MessageIdx)
2364 } else {
2365 i.applyJumpSelection(act.MessageIdx, act.TurnNo)
2366 }
2367 }
2368 // If the user dismissed the dialog without selecting, also
2369 // clear the pending-fork flag so a later plain /jump isn't
2370 // hijacked.
2371 if act.Close {
2372 i.pendingFork = false
2373 }
2374 return false
2375 }
2376 if i.btwDialog.Active() {
2377 if k.Kind == tui.KeyCtrlC {
2378 i.btwDialog.Close()
2379 i.invalidate()
2380 return false
2381 }
2382 if k.Kind == tui.KeyCtrlO {
2383 i.toggleBtwToolExpansion()
2384 return false
2385 }
2386 i.btwDialog.HandleKey(k, i.invalidate)
2387 return false
2388 }
2389 if i.skillsDialog.Active() {
2390 if k.Kind == tui.KeyCtrlC {
2391 i.skillsDialog.Close()
2392 i.invalidate()
2393 return false
2394 }
2395 i.skillsDialog.HandleKey(k)
2396 i.invalidate()
2397 return false
2398 }
2399 if i.changelogDialog.Active() {
2400 if closed := i.changelogDialog.HandleKey(k); closed {
2401 // User dismissed; let the parent persist the
2402 // LastChangelogShown marker via the close callback.
2403 if i.cfg.OnChangelogDismiss != nil {
2404 i.cfg.OnChangelogDismiss()
2405 }
2406 }
2407 i.invalidate()
2408 return false
2409 }
2410
2411 if slot := quickModelShortcutSlot(k); slot > 0 {
2412 i.applyQuickModelShortcut(slot)
2413 return false
2414 }
2415
2416 // Global keys.
2417 switch k.Kind {
2418 case tui.KeyCtrlC:
2419 i.mu.Lock()
2420 loadingSession := i.sessionLoading
2421 i.mu.Unlock()
2422 if loadingSession {
2423 return true
2424 }
2425 // While busy: do NOT cancel the turn. ctrl+c during a
2426 // running turn is almost always reflex muscle memory
2427 // ("be quiet" in a shell) rather than a deliberate
2428 // decision to kill a multi-minute model call that's
2429 // already cost tokens. Use esc to interrupt a turn; use
2430 // a deliberate double-ctrl+c to exit zot entirely. First
2431 // press arms the exit hint, second press within
2432 // ctrlCExitWindow quits.
2433 if i.busy {
2434 if i.ctrlCExitArmed() {
2435 return true
2436 }
2437 i.mu.Lock()
2438 i.statusOK = "press ctrl+c again to exit, esc to cancel the turn"
2439 i.statusErr = ""
2440 i.mu.Unlock()
2441 i.armCtrlCExit()
2442 return false
2443 }
2444 // Idle: first press clears the editor (and any queued
2445 // follow-up messages); a second press within ctrlCExitWindow
2446 // exits. With both an empty editor and no queue the first
2447 // press still just arms — require a deliberate double-tap.
2448 ag := i.agent
2449 pending := 0
2450 if ag != nil {
2451 pending = ag.QueuedMessageCount()
2452 }
2453 hadInput := !i.ed.IsEmpty() || len(i.queued) > 0 || pending > 0
2454 if hadInput {
2455 i.ed.Clear()
2456 i.clipboardImages = nil
2457 i.suggest.Reset()
2458 if ag != nil {
2459 ag.DrainQueuedMessages()
2460 }
2461 i.mu.Lock()
2462 i.queued = nil
2463 i.statusOK = "input cleared"
2464 i.statusErr = ""
2465 i.mu.Unlock()
2466 i.armCtrlCExit()
2467 return false
2468 }
2469 if i.ctrlCExitArmed() {
2470 return true
2471 }
2472 i.mu.Lock()
2473 i.statusOK = "press ctrl+c again to exit"
2474 i.statusErr = ""
2475 i.mu.Unlock()
2476 i.armCtrlCExit()
2477 return false
2478 case tui.KeyEsc:
2479 // Esc interrupts a running turn — but only when nothing
2480 // else on screen wants to consume the key first. The slash
2481 // popup has its own Esc behaviour (close + clear editor),
2482 // and transient overlays like the /help block and extension
2483 // notes should dismiss on Esc before we even consider the
2484 // turn. Without these guards, a casual Esc press after
2485 // running /help on a busy turn rips the turn away.
2486 if i.suggest.Active(i.ed.Value()) || i.fileSuggest.Active(i.ed.Value()) {
2487 break
2488 }
2489 i.mu.Lock()
2490 hadHelp := len(i.helpBlock) > 0
2491 hadNotes := len(i.extNotes) > 0
2492 if hadHelp {
2493 i.helpBlock = nil
2494 }
2495 if hadNotes {
2496 i.extNotes = nil
2497 }
2498 i.mu.Unlock()
2499 if hadHelp || hadNotes {
2500 i.invalidate()
2501 return false
2502 }
2503 if i.busy && i.cancelTurn != nil {
2504 i.cancelTurn()
2505 // If a confirm dialog is pending, refuse it so the agent
2506 // goroutine unblocks and the context cancellation can
2507 // actually take effect.
2508 i.confirmDialog.CancelAll("turn cancelled")
2509 return false
2510 }
2511 case tui.KeyCtrlD:
2512 if i.ed.IsEmpty() && !i.busy {
2513 return true
2514 }
2515 case tui.KeyCtrlL:
2516 i.rend.Clear()
2517 i.invalidate()
2518 return false
2519 case tui.KeyPasteClipboard:
2520 i.pasteClipboard()
2521 return false
2522 case tui.KeyCtrlO:
2523 i.toggleToolExpansion()
2524 return false
2525 case tui.KeyPageUp:
2526 i.scrollBy(+i.chatPage())
2527 return false
2528 case tui.KeyPageDown:
2529 i.scrollBy(-i.chatPage())
2530 return false
2531 case tui.KeyUp:
2532 // Alt/Option+Up: pop the most recently queued ("sliding in")
2533 // message back into the editor so the user can edit and
2534 // resend it. Repeated presses keep peeling messages off the
2535 // tail of the queue; each press *replaces* the editor
2536 // contents (we don't append/push). When the queue is empty
2537 // the keypress falls through to the normal scroll behavior.
2538 if k.Alt {
2539 i.mu.Lock()
2540 var text string
2541 if i.agent != nil {
2542 text, _ = i.agent.PopQueuedMessage()
2543 }
2544 if text == "" {
2545 if n := len(i.queued); n > 0 {
2546 text = i.queued[n-1]
2547 i.queued = i.queued[:n-1]
2548 }
2549 }
2550 i.mu.Unlock()
2551 if text != "" {
2552 i.ed.SetValue(text)
2553 i.inputHistoryIndex = -1
2554 i.invalidate()
2555 return false
2556 }
2557 }
2558 // In multi-line / wrapped input, Up first moves inside the editor.
2559 // At the editor's top edge, plain Up can browse input history when
2560 // history browsing is safe/active; otherwise it falls back to chat
2561 // scrolling, preserving the old single-line scroll behavior.
2562 if !i.suggest.Active(i.ed.Value()) && !i.fileSuggest.Active(i.ed.Value()) {
2563 if i.ed.MoveVertical(-1) {
2564 i.invalidate()
2565 return false
2566 }
2567 if !k.Alt && i.handleInputHistoryKey(k) {
2568 return false
2569 }
2570 i.scrollBy(+3)
2571 return false
2572 }
2573 case tui.KeyDown:
2574 if !i.suggest.Active(i.ed.Value()) && !i.fileSuggest.Active(i.ed.Value()) {
2575 if i.ed.MoveVertical(+1) {
2576 i.invalidate()
2577 return false
2578 }
2579 if !k.Alt && i.handleInputHistoryKey(k) {
2580 return false
2581 }
2582 if i.scrollOffset > 0 {
2583 i.scrollBy(-3)
2584 }
2585 return false
2586 }
2587 }
2588
2589 // Note: we intentionally do NOT gate the editor on i.busy here.
2590 // Typing while the agent is working is supported — submitted
2591 // messages are queued and delivered as follow-up turns when the
2592 // current turn ends. See the submit handler below.
2593
2594 if k.Kind == tui.KeyEnter && (k.Alt || k.Shift) {
2595 i.ed.HandleKey(k)
2596 return false
2597 }
2598
2599 // Slash suggestions: intercept up/down/tab/enter when the popup is visible.
2600 if i.suggest.Active(i.ed.Value()) {
2601 switch k.Kind {
2602 case tui.KeyUp:
2603 i.suggest.Up()
2604 return false
2605 case tui.KeyDown:
2606 i.suggest.Down()
2607 return false
2608 case tui.KeyPageUp:
2609 i.suggest.PageUp()
2610 return false
2611 case tui.KeyPageDown:
2612 i.suggest.PageDown()
2613 return false
2614 case tui.KeyTab:
2615 if name := i.suggest.Selection(i.ed.Value()); name != "" {
2616 i.ed.SetValue(name)
2617 i.suggest.Reset()
2618 }
2619 return false
2620 case tui.KeyEnter:
2621 // Enter on an ambiguous or partial slash prefix: complete to the
2622 // currently highlighted command and run it. That way typing
2623 // "/lo" + enter picks whichever of /login or /logout is selected
2624 // in the popup instead of submitting "/lo" as unknown. Also
2625 // clear the editor so the command doesn't linger after the
2626 // dialog opens/closes.
2627 if name := i.suggest.Selection(i.ed.Value()); name != "" {
2628 i.ed.Clear()
2629 i.suggest.Reset()
2630 return i.runSlash(ctx, name)
2631 }
2632 case tui.KeyEsc:
2633 i.ed.Clear()
2634 i.suggest.Reset()
2635 return false
2636 }
2637 }
2638
2639 // File suggestions: intercept up/down/tab/enter when the @-popup is visible.
2640 if i.fileSuggest.Active(i.ed.Value()) {
2641 switch k.Kind {
2642 case tui.KeyUp:
2643 i.fileSuggest.Up()
2644 return false
2645 case tui.KeyDown:
2646 i.fileSuggest.Down()
2647 return false
2648 case tui.KeyRight:
2649 // Open selected directory. The filter the user typed picked
2650 // that directory at the current level; once we descend it no
2651 // longer applies to the directory's contents, so clear it.
2652 // Otherwise typing "@eda" then right would re-filter inside
2653 // eda/ by "eda" and show nothing.
2654 if i.fileSuggest.Right() {
2655 i.clearFileSuggestQuery()
2656 }
2657 return false
2658 case tui.KeyLeft:
2659 // Go back to parent directory. Clear the filter for the same
2660 // reason as Right: it was scoped to the level we just left.
2661 if i.fileSuggest.Left() {
2662 i.clearFileSuggestQuery()
2663 }
2664 return false
2665 case tui.KeyEnter:
2666 if entry, ok := i.fileSuggest.SelectedEntry(i.ed.Value()); ok {
2667 var chip string
2668 if entry.isDir {
2669 chip = "[dir:" + entry.rel + "/]"
2670 } else {
2671 chip = "[file:" + entry.rel + "]"
2672 }
2673 val := i.ed.Value()
2674 if idx := strings.LastIndex(val, "@"); idx >= 0 {
2675 val = val[:idx]
2676 }
2677 i.ed.SetValue(val + chip + " ")
2678 i.fileSuggest.Reset()
2679 }
2680 return false
2681 case tui.KeyEsc:
2682 val := i.ed.Value()
2683 if idx := strings.LastIndex(val, "@"); idx >= 0 {
2684 i.ed.SetValue(val[:idx])
2685 }
2686 i.fileSuggest.Reset()
2687 return false
2688 }
2689 }
2690
2691 // Tab-complete a path token in the editor when no popup is open.
2692 // Recognises tokens that look like paths (start with ~, /, ./, ../
2693 // or contain a slash); shell-style completion expands ~, lists the
2694 // parent dir, and completes the basename to the longest common
2695 // prefix. Single match: full replace and trailing / for dirs.
2696 // No match: no-op. Plain bare words (no slash, no tilde) fall
2697 // through so Tab keeps its current no-op behaviour outside paths.
2698 if k.Kind == tui.KeyTab && !i.suggest.Active(i.ed.Value()) && !i.fileSuggest.Active(i.ed.Value()) {
2699 if i.tryPathTabComplete() {
2700 return false
2701 }
2702 }
2703
2704 if i.inputHistoryIndex >= 0 && k.Kind != tui.KeyUp && k.Kind != tui.KeyDown {
2705 i.inputHistoryIndex = -1
2706 }
2707
2708 if k.Kind == tui.KeyEsc {
2709 i.clipboardImages = nil
2710 }
2711 if submit := i.ed.HandleKey(k); submit {
2712 // SubmitValue() expands any [pasted text #N +L lines]
2713 // placeholders back into their bodies; the raw Value()
2714 // is only what the user sees on screen.
2715 text := strings.TrimRight(i.ed.SubmitValue(), "\n")
2716 // Expand [file:name] and [dir:name/] chips to full paths.
2717 text = expandFileChips(text, i.cfg.CWD)
2718 text, images := preparePromptWithClipboardImages(text, i.clipboardImages)
2719 if text == "" && len(images) == 0 {
2720 return false
2721 }
2722 i.clipboardImages = nil
2723 i.ed.Clear()
2724 i.inputHistoryIndex = -1
2725 i.suggest.Reset()
2726 i.fileSuggest.Reset()
2727
2728 if cmd, ok := shellEscapeCommand(text); ok {
2729 i.startShellEscape(ctx, cmd)
2730 return false
2731 }
2732
2733 if looksLikeSlashCommand(text) {
2734 head := text
2735 rest := ""
2736 if idx := strings.IndexAny(text, " \t"); idx >= 0 {
2737 head = text[:idx]
2738 rest = strings.TrimSpace(text[idx:])
2739 }
2740 if !isKnownSlashCommand(text) {
2741 // Try extensions before giving up. Extensions register
2742 // commands by bare name (no leading slash); strip it here.
2743 extName := strings.TrimPrefix(head, "/")
2744 if i.cfg.Extensions != nil && i.cfg.Extensions.HasCommand(extName) {
2745 go i.invokeExtensionCommand(ctx, extName, rest)
2746 return false
2747 }
2748 i.mu.Lock()
2749 i.statusErr = "unknown command " + head + " — type /help to see the list"
2750 i.statusOK = ""
2751 i.mu.Unlock()
2752 return false
2753 }
2754 // Slash commands run regardless of busy state. Commands that
2755 // would mutate the transcript or replace the agent (/clear,
2756 // /compact, /logout, /login, /model) cancel the active turn
2757 // first and wait for the goroutine to wind down so they don't
2758 // race with a streaming response. Safe commands (/help,
2759 // /jump, /sessions, /jail, /unjail, /exit) run immediately
2760 // without disturbing the active turn.
2761 if slashCancelsTurn(head) {
2762 i.cancelAndWaitForIdle()
2763 }
2764 return i.runSlash(ctx, text)
2765 }
2766
2767 if i.agent == nil {
2768 i.mu.Lock()
2769 i.statusErr = "not logged in. type /login first."
2770 i.mu.Unlock()
2771 return false
2772 }
2773 // Mirror the user's typed prompt into the paired Telegram
2774 // chat (when the bridge is active) so the Telegram thread
2775 // stays a complete record of the session, not just the half
2776 // that originated on the phone. On a goroutine so the
2777 // network write doesn't delay the local turn.
2778 if i.telegramBridge != nil && i.telegramBridge.Active() {
2779 go i.telegramBridge.OnUserTyped(text)
2780 }
2781 // If a turn is already in flight, queue this prompt inside the
2782 // agent loop so it is delivered at the next safe model-call
2783 // boundary instead of waiting for the whole run to finish.
2784 i.mu.Lock()
2785 busy := i.busy
2786 compacting := i.compacting
2787 ag := i.agent
2788 i.mu.Unlock()
2789 if busy {
2790 if len(images) > 0 {
2791 i.mu.Lock()
2792 i.statusErr = "can't queue clipboard images while a turn is running; wait for the current turn to finish"
2793 i.mu.Unlock()
2794 i.invalidate()
2795 return false
2796 }
2797 if ag != nil && !compacting {
2798 ag.QueueMessage(text)
2799 } else {
2800 i.mu.Lock()
2801 i.queued = append(i.queued, text)
2802 i.mu.Unlock()
2803 }
2804 i.invalidate()
2805 return false
2806 }
2807 i.startTurnWithImages(ctx, text, images)
2808 }
2809 return false
2810}
2811
2812func (i *Interactive) pasteClipboard() {
2813 _, data, ok, err := tui.ReadClipboardImagePNG()
2814 if err != nil {
2815 i.mu.Lock()
2816 i.statusErr = "clipboard paste failed: " + err.Error()
2817 i.statusOK = ""
2818 i.mu.Unlock()
2819 return
2820 }
2821 if ok {
2822 i.mu.Lock()
2823 marker := fmt.Sprintf("[clipboard image #%d]", len(i.clipboardImages)+1)
2824 i.clipboardImages = append(i.clipboardImages, clipboardImageAttachment{
2825 Marker: marker,
2826 Image: provider.ImageBlock{MimeType: "image/png", Data: data},
2827 })
2828 i.ed.Insert(marker + " ")
2829 i.statusOK = ""
2830 i.statusErr = ""
2831 i.mu.Unlock()
2832 return
2833 }
2834 key, pastedText, err := resolveClipboardText(tui.Key{Kind: tui.KeyPasteClipboard}, tui.ReadClipboardText)
2835 i.mu.Lock()
2836 defer i.mu.Unlock()
2837 if err != nil {
2838 i.statusErr = "clipboard paste failed: " + err.Error()
2839 i.statusOK = ""
2840 return
2841 }
2842 if pastedText {
2843 i.ed.HandleKey(key)
2844 i.statusErr = ""
2845 i.statusOK = ""
2846 return
2847 }
2848 i.statusErr = "clipboard does not contain text or an image"
2849 i.statusOK = ""
2850}
2851
2852func (i *Interactive) handleInputHistoryKey(k tui.Key) bool {
2853 if k.Kind != tui.KeyUp && k.Kind != tui.KeyDown {
2854 return false
2855 }
2856 // Do not steal normal vertical cursor movement. History browsing can only
2857 // start from an empty editor; once active, Up/Down keep walking
2858 // the ring so repeated presses work even though the editor now
2859 // contains the selected historical prompt.
2860 if i.inputHistoryIndex < 0 && !i.ed.IsEmpty() {
2861 return false
2862 }
2863 hist := i.inputHistory()
2864 if len(hist) == 0 {
2865 return false
2866 }
2867
2868 if i.inputHistoryIndex < 0 {
2869 // Start just after the newest item so Up lands on the most
2870 // recent user prompt. A lone Down from an empty editor is not
2871 // history navigation; let the caller fall through to normal UI
2872 // behavior instead.
2873 if k.Kind != tui.KeyUp {
2874 return false
2875 }
2876 i.inputHistoryIndex = len(hist)
2877 }
2878
2879 switch k.Kind {
2880 case tui.KeyUp:
2881 if i.inputHistoryIndex > 0 {
2882 i.inputHistoryIndex--
2883 }
2884 case tui.KeyDown:
2885 if i.inputHistoryIndex < len(hist) {
2886 i.inputHistoryIndex++
2887 }
2888 }
2889
2890 if i.inputHistoryIndex >= len(hist) {
2891 i.ed.Clear()
2892 } else {
2893 i.ed.SetValue(hist[i.inputHistoryIndex])
2894 }
2895 return true
2896}
2897
2898func (i *Interactive) inputHistory() []string {
2899 if i.agent == nil {
2900 return nil
2901 }
2902 msgs := i.agent.Messages()
2903 hist := make([]string, 0, len(msgs))
2904 for _, m := range msgs {
2905 if m.Role != provider.RoleUser || isHiddenTranscriptMessage(m) || m.Meta[shellEscapeMetaKey] == "true" {
2906 continue
2907 }
2908 text := userMessageText(m)
2909 if strings.TrimSpace(text) == "" {
2910 continue
2911 }
2912 hist = append(hist, text)
2913 }
2914 return hist
2915}
2916
2917func userMessageText(m provider.Message) string {
2918 var sb strings.Builder
2919 for _, c := range m.Content {
2920 if tb, ok := c.(provider.TextBlock); ok {
2921 if sb.Len() > 0 {
2922 sb.WriteByte('\n')
2923 }
2924 sb.WriteString(tb.Text)
2925 }
2926 }
2927 return sb.String()
2928}
2929
2930// invokeExtensionCommand fires an extension-registered slash command
2931// in a background goroutine, awaits the response, and applies the
2932// requested action (prompt / insert / display / noop). Errors and
2933// timeouts surface as a status_err line.
2934
2935func (i *Interactive) invokeExtensionCommand(ctx context.Context, name, args string) {
2936 resp, err := i.cfg.Extensions.Invoke(ctx, name, args, 30*time.Second)
2937 if err != nil {
2938 i.mu.Lock()
2939 i.statusErr = "extension /" + name + ": " + err.Error()
2940 i.statusOK = ""
2941 i.mu.Unlock()
2942 i.invalidate()
2943 return
2944 }
2945 if resp.Error != "" {
2946 i.mu.Lock()
2947 i.statusErr = "extension /" + name + ": " + resp.Error
2948 i.statusOK = ""
2949 i.mu.Unlock()
2950 i.invalidate()
2951 return
2952 }
2953 switch resp.Action {
2954 case "open_panel":
2955 if resp.OpenPanel != nil {
2956 extName := name
2957 if i.cfg.Extensions != nil {
2958 if owner := i.cfg.Extensions.CommandOwner(name); owner != "" {
2959 extName = owner
2960 }
2961 }
2962 i.OpenPanel(extName, *resp.OpenPanel)
2963 }
2964 case "prompt":
2965 if strings.TrimSpace(resp.Prompt) == "" {
2966 return
2967 }
2968 i.startTurn(i.runCtx, resp.Prompt)
2969 case "insert":
2970 i.ed.Insert(resp.Insert)
2971 i.invalidate()
2972 case "display":
2973 i.appendExtensionNote(name, resp.Display, "info")
2974 case "noop", "":
2975 // nothing
2976 default:
2977 i.mu.Lock()
2978 i.statusErr = "extension /" + name + ": unknown action " + resp.Action
2979 i.mu.Unlock()
2980 i.invalidate()
2981 }
2982}
2983
2984// appendExtensionNote renders an extension-originated note in the
2985// chat. Levels: "info" (muted), "warn" (warning), "error" (error),
2986// "success" (tool/ok green).
2987func (i *Interactive) appendExtensionNote(extName, msg, level string) {
2988 if msg == "" {
2989 return
2990 }
2991 i.mu.Lock()
2992 defer i.mu.Unlock()
2993 color := i.cfg.Theme.Muted
2994 switch level {
2995 case "warn":
2996 color = i.cfg.Theme.Warning
2997 case "error":
2998 color = i.cfg.Theme.Error
2999 case "success":
3000 color = i.cfg.Theme.Tool
3001 }
3002 prefix := i.cfg.Theme.FG256(i.cfg.Theme.Accent, "["+extName+"] ")
3003 for _, line := range strings.Split(msg, "\n") {
3004 i.statusOK = "" // clear any stale ok
3005 i.statusErr = ""
3006 i.extNotes = append(i.extNotes, prefix+i.cfg.Theme.FG256(color, line))
3007 }
3008 // Extension slash commands complete asynchronously. If their result is
3009 // displayed while confirmation is waiting, keep Esc assigned to the note
3010 // until it is dismissed rather than letting it answer the tool call.
3011 i.confirmDialog.Blur()
3012}
3013
3014// HostHooks implementation for the extension manager. The manager
3015// holds an interface, not a concrete *Interactive, so these methods
3016// are the only thing the manager sees.
3017
3018// Notify is the manager's NotifyFromExt entry point.
3019func (i *Interactive) Notify(extName, level, message string) {
3020 i.appendExtensionNote(extName, message, level)
3021 i.invalidate()
3022}
3023
3024// ClearNotes removes every note line owned by extName from the
3025// bottom-sticky ext-notes block. Extensions use this to retract a
3026// transient status line (e.g. an approval prompt) once it no longer
3027// applies, instead of leaving it stacked forever. Notes from other
3028// extensions and internal notes (auto-compact) are left untouched.
3029func (i *Interactive) ClearNotes(extName string) {
3030 marker := "[" + extName + "] "
3031 i.mu.Lock()
3032 if len(i.extNotes) == 0 {
3033 i.mu.Unlock()
3034 return
3035 }
3036 kept := i.extNotes[:0:0]
3037 changed := false
3038 for _, line := range i.extNotes {
3039 if strings.Contains(line, marker) {
3040 changed = true
3041 continue
3042 }
3043 kept = append(kept, line)
3044 }
3045 if changed {
3046 i.extNotes = kept
3047 }
3048 i.mu.Unlock()
3049 if changed {
3050 i.invalidate()
3051 }
3052}
3053
3054// Submit feeds text through the agent loop as if the user had typed it.
3055func (i *Interactive) Submit(text string) {
3056 if cmd, ok := shellEscapeCommand(text); ok {
3057 i.startShellEscape(i.runCtx, cmd)
3058 return
3059 }
3060 i.startTurn(i.runCtx, text)
3061}
3062
3063// completeStartupPre applies deferred InitialInput after entry.pre finishes.
3064// When AutoSubmitInitial was set, the deferred prompt is submitted; otherwise
3065// it only pre-fills the editor (CLI-supplied prompts).
3066func (i *Interactive) completeStartupPre() {
3067 i.mu.Lock()
3068 if !i.awaitingStartupPre {
3069 i.mu.Unlock()
3070 return
3071 }
3072 i.awaitingStartupPre = false
3073 deferred := i.deferredInitialInput
3074 auto := i.autoSubmitDeferred
3075 i.deferredInitialInput = ""
3076 i.autoSubmitDeferred = false
3077 onDone := i.cfg.OnStartupPreDone
3078 i.mu.Unlock()
3079 if onDone != nil {
3080 onDone()
3081 }
3082 i.startupPreDone <- startupPreResult{deferred: deferred, autoSubmit: auto}
3083 i.invalidate()
3084}
3085
3086// applyStartupPreResult runs on the TUI event loop so the editor remains
3087// single-threaded. Input entered while resources were reloading wins over the
3088// deferred prefill rather than being overwritten.
3089func (i *Interactive) applyStartupPreResult(result startupPreResult) {
3090 if result.deferred == "" {
3091 return
3092 }
3093 if result.autoSubmit {
3094 i.Submit(result.deferred)
3095 return
3096 }
3097 if i.ed.IsEmpty() {
3098 i.ed.SetValue(result.deferred)
3099 }
3100}
3101
3102// ApplyChangedCWD is called by hosts after a successful /cd hook that do
3103// not provide startup context metadata.
3104func (i *Interactive) ApplyChangedCWD(ag *core.Agent, provider, model, cwd string) {
3105 i.applyChangedCWD(ag, provider, model, cwd, nil)
3106}
3107
3108// ApplyChangedCWDWithStartupContext swaps a rebuilt agent and its display-only
3109// startup context into the running TUI after a successful /cd hook.
3110func (i *Interactive) ApplyChangedCWDWithStartupContext(ag *core.Agent, provider, model, cwd string, startupContextPaths []string) {
3111 i.applyChangedCWD(ag, provider, model, cwd, startupContextPaths)
3112}
3113
3114func (i *Interactive) applyChangedCWD(ag *core.Agent, provider, model, cwd string, startupContextPaths []string) {
3115 i.mu.Lock()
3116 i.agent = ag
3117 i.cfg.CWD = cwd
3118 i.cfg.StartupContextPaths = append([]string(nil), startupContextPaths...)
3119 i.view.StartupContextPaths = nil
3120 if i.cfg.ShowInstructionsAtStartup != nil && *i.cfg.ShowInstructionsAtStartup {
3121 i.view.StartupContextPaths = append(i.view.StartupContextPaths, startupContextPaths...)
3122 }
3123 // Re-report the working directory to the terminal so "new tab here"
3124 // tracks the /cd change (OSC 7, see issue #38).
3125 if i.cfg.Terminal != nil {
3126 if seq := tui.ReportCWD(cwd); seq != "" {
3127 _, _ = i.cfg.Terminal.Write([]byte(seq))
3128 }
3129 }
3130 i.cfg.Provider = provider
3131 i.cfg.Model = model
3132 i.toolCalls = map[string]*tui.ToolCallView{}
3133 i.toolOrder = nil
3134 i.toolGate = map[string]int{}
3135 i.helpBlock = nil
3136 i.parkedTurn = 0
3137 i.statusErr = ""
3138 i.mu.Unlock()
3139 i.fileSuggest.Reset()
3140 i.fileSuggest.SetCWD(cwd)
3141 i.invalidate()
3142}
3143
3144// SubmitSlash runs text as a slash command in the TUI as if the user
3145// had typed it. text must start with '/' — callers that hand it
3146// plain prose silently get a no-op so a misbehaving extension can't
3147// run a stray prompt through this path. Read-only commands run in
3148// place; commands that would mutate the transcript or replace the
3149// agent cancel the active turn first via the same path the editor
3150// uses for typed slash commands.
3151func (i *Interactive) SubmitSlash(text string) {
3152 text = strings.TrimSpace(text)
3153 if !strings.HasPrefix(text, "/") {
3154 return
3155 }
3156 head := text
3157 if idx := strings.IndexAny(text, " \t"); idx >= 0 {
3158 head = text[:idx]
3159 }
3160 if slashCancelsTurn(head) {
3161 i.cancelAndWaitForIdle()
3162 }
3163 i.runSlash(i.runCtx, text)
3164 i.invalidate()
3165}
3166
3167// SubmitOrQueue runs text immediately if the agent is idle, or
3168// appends it to the pending queue if a turn is already in flight.
3169// Used by the telegram bridge (and by the editor submit path) so
3170// both input sources share the same "queue behind an active turn"
3171// semantics. Images are ignored for now — only the text prompt is
3172// forwarded — because the queued-prompt path is text-only; a
3173// follow-up can expand the queue entry to carry images.
3174func (i *Interactive) SubmitOrQueue(text string, images []provider.ImageBlock) {
3175 if cmd, ok := shellEscapeCommand(text); ok {
3176 i.startShellEscape(i.runCtx, cmd)
3177 return
3178 }
3179 i.mu.Lock()
3180 if i.agent == nil {
3181 i.statusErr = "not logged in. type /login first."
3182 i.mu.Unlock()
3183 i.invalidate()
3184 return
3185 }
3186 if i.busy {
3187 // Queue text only; images are dropped for queued prompts. Compaction
3188 // uses the host queue because it has no active agent loop to drain
3189 // Agent.QueueMessage entries.
3190 ag := i.agent
3191 compacting := i.compacting
3192 i.mu.Unlock()
3193 if ag != nil && !compacting {
3194 ag.QueueMessage(text)
3195 } else {
3196 i.mu.Lock()
3197 i.queued = append(i.queued, text)
3198 i.mu.Unlock()
3199 }
3200 i.invalidate()
3201 return
3202 }
3203 i.mu.Unlock()
3204 i.startTurnWithImages(i.runCtx, text, images)
3205}
3206
3207// CancelTurn aborts the active turn if one is running. Used by the
3208// telegram bridge when the paired user sends /stop.
3209// ChangelogVersion returns the version string of the changelog
3210// currently shown (or last shown). Used by the dismiss callback
3211// to store the correct version for dev builds.
3212func (i *Interactive) ChangelogVersion() string {
3213 if i.changelogDialog != nil {
3214 return i.changelogDialog.version
3215 }
3216 return ""
3217}
3218
3219func (i *Interactive) CancelTurn() {
3220 i.mu.Lock()
3221 cancel := i.cancelTurn
3222 i.mu.Unlock()
3223 if cancel != nil {
3224 cancel()
3225 i.confirmDialog.CancelAll("turn cancelled")
3226 }
3227}
3228
3229// Insert places text at the cursor in the editor.
3230func (i *Interactive) Insert(text string) {
3231 i.ed.Insert(text)
3232 i.invalidate()
3233}
3234
3235// Display appends a styled note from extName to the chat without a
3236// model call.
3237func (i *Interactive) Display(extName, text string) {
3238 i.appendExtensionNote(extName, text, "info")
3239 i.invalidate()
3240}
3241
3242func (i *Interactive) OpenPanel(extName string, spec extproto.PanelSpec) {
3243 i.mu.Lock()
3244 defer i.mu.Unlock()
3245 i.extPanel.Open(extName, spec.ID, spec.Title, spec.Lines, spec.Footer)
3246 i.confirmDialog.Blur()
3247 if i.cfg.Extensions != nil {
3248 cols, rows := i.cfg.Terminal.Size()
3249 _ = cols
3250 _ = rows
3251 }
3252 i.invalidate()
3253}
3254
3255func (i *Interactive) UpdatePanel(extName, panelID, title string, lines []string, footer string) {
3256 i.mu.Lock()
3257 defer i.mu.Unlock()
3258 if i.extPanel.Active() && i.extPanel.ext == extName && i.extPanel.id == panelID {
3259 i.extPanel.Update(title, lines, footer)
3260 i.invalidate()
3261 }
3262}
3263
3264func (i *Interactive) ClosePanel(extName, panelID string) {
3265 i.mu.Lock()
3266 defer i.mu.Unlock()
3267 if i.extPanel.Active() && i.extPanel.ext == extName && i.extPanel.id == panelID {
3268 i.extPanel.Close()
3269 i.invalidate()
3270 }
3271}
3272
3273func effectiveImageProtocol(override *bool) tui.ImageProtocol {
3274 detected := tui.DetectImageProtocol()
3275 if override == nil {
3276 return detected
3277 }
3278 if !*override {
3279 return tui.ImageProtocolNone
3280 }
3281 return detected
3282}
3283
3284func imageProtocolName(p tui.ImageProtocol) string {
3285 switch p {
3286 case tui.ImageProtocolKitty:
3287 return "kitty/ghostty"
3288 case tui.ImageProtocolITerm2:
3289 return "iTerm2"
3290 default:
3291 return "none"
3292 }
3293}
3294
3295func onOff(v bool) string {
3296 if v {
3297 return "enabled"
3298 }
3299 return "disabled"
3300}
3301
3302func (i *Interactive) applyInputCursorColor() {
3303 if i == nil || i.cfg.Terminal == nil {
3304 return
3305 }
3306 _, _ = i.cfg.Terminal.Write([]byte(tui.CursorColor256(15) + tui.CursorShapeBlock()))
3307}
3308
3309func reasoningSettingOptions(levels []string) []settingsOption {
3310 descriptions := map[string]string{
3311 "": "no reasoning",
3312 "minimum": "very brief (~1k tokens)",
3313 "low": "light (~2k tokens)",
3314 "medium": "moderate (~8k tokens)",
3315 "high": "deep (~16k tokens)",
3316 "xhigh": "extra-high effort",
3317 "max": "unconstrained effort",
3318 }
3319 options := make([]settingsOption, 0, len(levels))
3320 for _, level := range levels {
3321 label := level
3322 if label == "" {
3323 label = "off"
3324 }
3325 options = append(options, settingsOption{value: level, label: label, desc: descriptions[level]})
3326 }
3327 return options
3328}
3329
3330func (i *Interactive) reasoningSettingItem() settingsItem {
3331 levels := []string{"", "minimum", "low", "medium", "high", "xhigh", "max"}
3332 reasoning := provider.NormalizeReasoning(i.cfg.Reasoning)
3333 desc := "reasoning depth for reasoning-capable models"
3334 hint := ""
3335 if m, err := provider.FindModel(i.cfg.Provider, i.cfg.Model); err == nil {
3336 levels = provider.AvailableReasoningLevels(m)
3337 reasoning = provider.ClampReasoningForModel(m, reasoning)
3338 if !m.Reasoning {
3339 hint = "current model does not support reasoning"
3340 desc += "; current model does not support reasoning"
3341 } else if len(levels) == 1 {
3342 hint = "current model has no adjustable reasoning levels"
3343 desc += "; current model has no adjustable reasoning levels"
3344 } else {
3345 desc += "; only levels supported by the current model are shown"
3346 }
3347 }
3348 options := reasoningSettingOptions(levels)
3349 choice := 0
3350 for idx, opt := range options {
3351 if opt.value == reasoning {
3352 choice = idx
3353 break
3354 }
3355 }
3356
3357 return settingsItem{
3358 key: "reasoning",
3359 label: "reasoning level",
3360 desc: desc,
3361 options: options,
3362 choice: choice,
3363 hint: hint,
3364 }
3365}
3366
3367func (i *Interactive) openReasoningDialog() {
3368 i.settingsDialog.OpenDirectOption(i.reasoningSettingItem())
3369}
3370
3371func (i *Interactive) openSettingsDialog() {
3372 detected := tui.DetectImageProtocol()
3373 imgEnabled := detected != tui.ImageProtocolNone
3374 if i.cfg.InlineImagesEnabled != nil {
3375 imgEnabled = *i.cfg.InlineImagesEnabled
3376 }
3377 imgDisabled := detected == tui.ImageProtocolNone
3378 imgHint := ""
3379 if imgDisabled {
3380 imgEnabled = false
3381 imgHint = "this terminal does not support inline images"
3382 } else {
3383 imgHint = "terminal supports " + imageProtocolName(detected)
3384 }
3385
3386 autoSwarm := false
3387 if i.cfg.AutoSwarmEnabled != nil {
3388 autoSwarm = *i.cfg.AutoSwarmEnabled
3389 }
3390 autoSwarmDisabled := i.cfg.Swarm == nil
3391 autoSwarmHint := ""
3392 if autoSwarmDisabled {
3393 autoSwarm = false
3394 autoSwarmHint = "swarm supervisor not available in this mode"
3395 }
3396
3397 jailByDefault := i.cfg.JailByDefault != nil && *i.cfg.JailByDefault
3398 openRouterServerTools := i.openRouterServerToolsEnabled()
3399 openRouterServerToolsDisabled := true
3400 openRouterServerToolsHint := ""
3401 switch {
3402 case i.cfg.Provider != "openrouter":
3403 openRouterServerToolsHint = "only available when the current provider is OpenRouter"
3404 case i.cfg.NoTools:
3405 openRouterServerToolsHint = "disabled for this run by --no-tools"
3406 case strings.Contains(strings.ToLower(i.cfg.Model), "@preset/"):
3407 openRouterServerToolsHint = "this preset owns its server-tool configuration"
3408 default:
3409 openRouterServerToolsDisabled = false
3410 }
3411 recursiveFiles := i.cfg.RecursiveFileSuggest != nil && *i.cfg.RecursiveFileSuggest
3412 respectGitignore := i.cfg.RespectGitignore == nil || *i.cfg.RespectGitignore
3413 compactMode := i.compactModeEnabled()
3414 showInstructions := i.cfg.ShowInstructionsAtStartup != nil && *i.cfg.ShowInstructionsAtStartup
3415 inputStyle := tui.NormalizeInputStyle(i.cfg.TUIInputStyle)
3416 statusPosition := tui.NormalizeStatusPosition(i.cfg.TUIStatusPosition)
3417 workingPosition := tui.NormalizeWorkingPosition(i.cfg.TUIWorkingPosition)
3418 quickItems := i.quickModelSettingItems()
3419
3420 autoCompactOptions := []settingsOption{
3421 {value: "0", label: "off", desc: "disable context-percentage triggers; payload-too-large recovery stays enabled"},
3422 {value: "70", label: "70%", desc: "compact earlier to keep more context headroom"},
3423 {value: "80", label: "80%", desc: "compact with moderate context headroom"},
3424 {value: "85", label: "85%", desc: "default balance between history and headroom"},
3425 {value: "90", label: "90%", desc: "retain more history before compacting"},
3426 }
3427 autoCompactThreshold := normalizeAutoCompactThreshold(i.cfg.AutoCompactThreshold)
3428 autoCompactChoice := 0
3429 for idx, opt := range autoCompactOptions {
3430 if opt.value == strconv.Itoa(autoCompactThreshold) {
3431 autoCompactChoice = idx
3432 break
3433 }
3434 }
3435
3436 reasoningItem := i.reasoningSettingItem()
3437
3438 themeName := i.cfg.ThemeName
3439 if themeName == "" {
3440 themeName = "auto"
3441 }
3442 if themeName != "auto" && !tui.ThemeExists(i.cfg.ZotHome, themeName) {
3443 themeName = "auto"
3444 i.cfg.ThemeName = ""
3445 if i.cfg.SettingsStore != nil {
3446 _ = i.cfg.SettingsStore.SetTheme("auto")
3447 }
3448 i.applyThemeNow("auto")
3449 }
3450 themeOptions := []settingsOption{}
3451 themeChoice := 0
3452 availableThemes := tui.AvailableThemes(i.cfg.ZotHome)
3453 if i.cfg.ExtensionThemes != nil {
3454 availableThemes = append(availableThemes, i.cfg.ExtensionThemes()...)
3455 }
3456 for idx, opt := range availableThemes {
3457 themeOptions = append(themeOptions, settingsOption{value: opt.Value, label: opt.Label, desc: opt.Description})
3458 if opt.Value == themeName {
3459 themeChoice = idx
3460 }
3461 }
3462
3463 inputStyleOptions := []settingsOption{
3464 {value: tui.InputStylePlain, label: "plain", desc: "render the input as the normal prompt line"},
3465 {value: tui.InputStyleLines, label: "lines", desc: "draw separator lines above and below the input"},
3466 {value: tui.InputStyleBlock, label: "block", desc: "render the input as a user-bubble-style block"},
3467 }
3468 inputStyleChoice := 0
3469 for idx, opt := range inputStyleOptions {
3470 if opt.value == inputStyle {
3471 inputStyleChoice = idx
3472 break
3473 }
3474 }
3475 statusPositionOptions := []settingsOption{
3476 {value: tui.StatusPositionAboveInput, label: "above input", desc: "show model, usage, and working directory above the input"},
3477 {value: tui.StatusPositionBelowInput, label: "below input", desc: "show model, usage, and working directory below the input"},
3478 }
3479 statusPositionChoice := 0
3480 for idx, opt := range statusPositionOptions {
3481 if opt.value == statusPosition {
3482 statusPositionChoice = idx
3483 break
3484 }
3485 }
3486
3487 workingPositionOptions := []settingsOption{
3488 {value: tui.WorkingPositionAboveInput, label: "above input", desc: "show the working spinner above the input"},
3489 {value: tui.WorkingPositionBelowInput, label: "below input", desc: "show the working spinner below the input"},
3490 }
3491 workingPositionChoice := 0
3492 for idx, opt := range workingPositionOptions {
3493 if opt.value == workingPosition {
3494 workingPositionChoice = idx
3495 break
3496 }
3497 }
3498
3499 items := []settingsItem{
3500 {
3501 key: "inline_images_enabled",
3502 label: "render images when supported",
3503 desc: "draw screenshots inline instead of showing a text placeholder",
3504 value: imgEnabled,
3505 disabled: imgDisabled,
3506 hint: imgHint,
3507 },
3508 {
3509 key: "auto_swarm_enabled",
3510 label: "auto-swarm",
3511 desc: "let the agent spawn background sub-agents in parallel via the swarm_spawn tool",
3512 value: autoSwarm,
3513 disabled: autoSwarmDisabled,
3514 hint: autoSwarmHint,
3515 },
3516 {
3517 key: "auto_compact_threshold",
3518 label: "auto-compact threshold",
3519 desc: "choose how full the model context can get before zot condenses conversation history",
3520 options: autoCompactOptions,
3521 choice: autoCompactChoice,
3522 },
3523 {
3524 key: "jail_by_default",
3525 label: "jail new sessions by default",
3526 desc: "confine tools to the session working directory unless /unjail is used",
3527 value: jailByDefault,
3528 },
3529 {
3530 key: "openrouter_server_tools_enabled",
3531 label: "OpenRouter Server Tools",
3532 desc: "advertise beta remote tools such as web search, datetime, and advisor; additional charges may apply",
3533 value: openRouterServerTools && !openRouterServerToolsDisabled,
3534 disabled: openRouterServerToolsDisabled,
3535 hint: openRouterServerToolsHint,
3536 },
3537 {
3538 key: "recursive_file_suggest",
3539 label: "recursive @-file search",
3540 desc: "fuzzy-search the whole project tree when picking files with @ instead of browsing one directory at a time",
3541 value: recursiveFiles,
3542 },
3543 {
3544 key: "respect_gitignore",
3545 label: "hide gitignored files in @-picker",
3546 desc: "skip files and directories matched by the project's root .gitignore (and .git) when picking files with @",
3547 value: respectGitignore,
3548 },
3549 {
3550 key: "compact_mode",
3551 label: "compact transcript rendering",
3552 desc: "reduce visual chrome by rendering tool calls without boxes and sent messages without padded bubbles",
3553 value: compactMode,
3554 },
3555 {
3556 key: "show_instructions_at_startup",
3557 label: "show loaded resources at startup",
3558 desc: "list loaded context files, extensions, and user-installed skills above the transcript",
3559 value: showInstructions,
3560 },
3561 {
3562 key: "tui_settings",
3563 label: "tui settings",
3564 desc: "choose input chrome and where status information appears",
3565 children: []settingsItem{
3566 {
3567 key: "tui_input_style",
3568 label: "input style",
3569 desc: "choose between the plain prompt, lines, and a block input area",
3570 options: inputStyleOptions,
3571 choice: inputStyleChoice,
3572 },
3573 {
3574 key: "tui_status_position",
3575 label: "status position",
3576 desc: "place model, usage, and working directory above or below the input",
3577 options: statusPositionOptions,
3578 choice: statusPositionChoice,
3579 },
3580 {
3581 key: "tui_working_position",
3582 label: "working spinner position",
3583 desc: "place the working spinner above or below the input",
3584 options: workingPositionOptions,
3585 choice: workingPositionChoice,
3586 },
3587 },
3588 },
3589 reasoningItem,
3590 {
3591 key: "theme",
3592 label: "color theme",
3593 desc: "choose a theme from $ZOT_HOME/themes or a loaded extension",
3594 options: themeOptions,
3595 choice: themeChoice,
3596 },
3597 }
3598 if len(quickItems) > 0 {
3599 items = append(items, settingsItem{
3600 key: "quick_models",
3601 label: "model shortcuts",
3602 desc: "configure " + quickModelShortcutPrefix() + "+1 through " + quickModelShortcutPrefix() + "+9 quick model switches",
3603 children: quickItems,
3604 })
3605 }
3606 i.settingsDialog.Open(items)
3607}
3608
3609func (i *Interactive) applySettingChange(act settingsAction) {
3610 switch {
3611 case strings.HasPrefix(act.Key, "quick_model_"):
3612 i.applyQuickModelSetting(act.Key, act.StringValue)
3613 case act.Key == "reasoning":
3614 i.applyReasoningSetting(act.StringValue)
3615 case act.Key == "theme":
3616 i.applyThemeSetting(act.StringValue)
3617 case act.Key == "auto_compact_threshold":
3618 i.applyAutoCompactThresholdSetting(act.StringValue)
3619 case act.Key == "tui_input_style":
3620 i.applyTUIInputStyleSetting(act.StringValue)
3621 case act.Key == "tui_status_position":
3622 i.applyTUIStatusPositionSetting(act.StringValue)
3623 case act.Key == "tui_working_position":
3624 i.applyTUIWorkingPositionSetting(act.StringValue)
3625 default:
3626 i.applySettingToggle(act.Key, act.Value)
3627 }
3628}
3629
3630func (i *Interactive) applyAutoCompactThresholdSetting(value string) {
3631 threshold, err := strconv.Atoi(value)
3632 if err != nil {
3633 return
3634 }
3635 threshold = normalizeAutoCompactThreshold(&threshold)
3636 if store, ok := i.cfg.SettingsStore.(autoCompactThresholdSettingsStore); ok {
3637 if err := store.SetAutoCompactThreshold(threshold); err != nil {
3638 i.mu.Lock()
3639 i.statusErr = "settings: " + err.Error()
3640 i.mu.Unlock()
3641 return
3642 }
3643 }
3644 i.mu.Lock()
3645 i.cfg.AutoCompactThreshold = &threshold
3646 if threshold == 0 {
3647 i.statusOK = "auto-compact threshold off"
3648 } else {
3649 i.statusOK = fmt.Sprintf("auto-compact threshold %d%%", threshold)
3650 }
3651 i.statusErr = ""
3652 i.mu.Unlock()
3653 i.invalidate()
3654}
3655
3656func (i *Interactive) quickModelSettingItems() []settingsItem {
3657 if len(i.cfg.QuickModelShortcuts) < 9 {
3658 next := make([]QuickModelShortcut, 9)
3659 copy(next, i.cfg.QuickModelShortcuts)
3660 i.cfg.QuickModelShortcuts = next
3661 }
3662 items := make([]settingsItem, 0, 9)
3663 for slot := 1; slot <= 9; slot++ {
3664 items = append(items, i.quickModelSettingItem(slot))
3665 }
3666 return items
3667}
3668
3669func (i *Interactive) quickModelSettingItem(slot int) settingsItem {
3670 current := QuickModelShortcut{}
3671 if slot >= 1 && len(i.cfg.QuickModelShortcuts) >= slot {
3672 current = i.cfg.QuickModelShortcuts[slot-1]
3673 }
3674 hint := "not assigned"
3675 if current.Provider != "" && current.Model != "" {
3676 hint = current.Provider + " / " + current.Model
3677 }
3678 return settingsItem{
3679 key: "quick_model_" + strconv.Itoa(slot),
3680 label: "model " + strconv.Itoa(slot),
3681 desc: quickModelShortcutLabel(slot) + " switches to this model. Enter opens the /model selector, Backspace clears.",
3682 picker: true,
3683 hint: hint,
3684 }
3685}
3686
3687func (i *Interactive) compactModeEnabled() bool {
3688 return i.cfg.CompactMode != nil && *i.cfg.CompactMode
3689}
3690
3691func quickModelShortcutSlot(k tui.Key) int {
3692 if k.Kind != tui.KeyRune || k.Rune < '1' || k.Rune > '9' {
3693 return 0
3694 }
3695 if runtime.GOOS == "darwin" {
3696 if !k.Super && !k.Ctrl {
3697 return 0
3698 }
3699 } else if !k.Ctrl {
3700 return 0
3701 }
3702 return int(k.Rune - '0')
3703}
3704
3705func quickModelShortcutPrefix() string {
3706 return "Ctrl"
3707}
3708
3709func quickModelShortcutLabel(slot int) string {
3710 return quickModelShortcutPrefix() + "+" + strconv.Itoa(slot)
3711}
3712
3713func (i *Interactive) openQuickModelPicker(slot int) {
3714 if slot < 1 || slot > 9 {
3715 return
3716 }
3717 i.quickModelAssign = slot
3718 current := i.cfg.Model
3719 if len(i.cfg.QuickModelShortcuts) >= slot && i.cfg.QuickModelShortcuts[slot-1].Model != "" {
3720 current = i.cfg.QuickModelShortcuts[slot-1].Model
3721 }
3722 var loggedIn []string
3723 if i.cfg.LoggedInProviders != nil {
3724 loggedIn = i.cfg.LoggedInProviders()
3725 }
3726 i.modelDialog.Open(current, loggedIn, i.cfg.Reasoning)
3727}
3728
3729func (i *Interactive) applyQuickModelSelection(slot int, providerName, model string) {
3730 i.setQuickModelShortcut(slot, providerName, model)
3731}
3732
3733func (i *Interactive) applyQuickModelShortcut(slot int) {
3734 if slot < 1 || slot > 9 {
3735 return
3736 }
3737 if i.busy {
3738 i.mu.Lock()
3739 i.statusErr = "cannot switch model while a turn is running"
3740 i.statusOK = ""
3741 i.mu.Unlock()
3742 i.invalidate()
3743 return
3744 }
3745 if len(i.cfg.QuickModelShortcuts) < slot {
3746 i.mu.Lock()
3747 i.statusErr = quickModelShortcutLabel(slot) + " is not assigned"
3748 i.statusOK = ""
3749 i.mu.Unlock()
3750 i.invalidate()
3751 return
3752 }
3753 shortcut := i.cfg.QuickModelShortcuts[slot-1]
3754 if shortcut.Provider == "" || shortcut.Model == "" {
3755 i.mu.Lock()
3756 i.statusErr = quickModelShortcutLabel(slot) + " is not assigned"
3757 i.statusOK = ""
3758 i.mu.Unlock()
3759 i.invalidate()
3760 return
3761 }
3762 i.swapModel(shortcut.Provider, shortcut.Model, i.cfg.BuildAgentFor, false)
3763 i.invalidate()
3764}
3765
3766func (i *Interactive) applyQuickModelSetting(key, value string) {
3767 slotText := strings.TrimPrefix(key, "quick_model_")
3768 slot, err := strconv.Atoi(slotText)
3769 if err != nil || slot < 1 || slot > 9 {
3770 return
3771 }
3772 providerName, model := "", ""
3773 if value != "" {
3774 parts := strings.SplitN(value, "\t", 2)
3775 if len(parts) == 2 {
3776 providerName, model = parts[0], parts[1]
3777 }
3778 }
3779 i.setQuickModelShortcut(slot, providerName, model)
3780}
3781
3782func (i *Interactive) setQuickModelShortcut(slot int, providerName, model string) {
3783 if len(i.cfg.QuickModelShortcuts) < slot {
3784 next := make([]QuickModelShortcut, slot)
3785 copy(next, i.cfg.QuickModelShortcuts)
3786 i.cfg.QuickModelShortcuts = next
3787 }
3788 i.cfg.QuickModelShortcuts[slot-1] = QuickModelShortcut{Provider: providerName, Model: model}
3789 if i.cfg.SettingsStore != nil {
3790 if err := i.cfg.SettingsStore.SetQuickModelShortcut(slot, providerName, model); err != nil {
3791 i.mu.Lock()
3792 i.statusErr = "settings: " + err.Error()
3793 i.mu.Unlock()
3794 return
3795 }
3796 }
3797 i.mu.Lock()
3798 if model == "" {
3799 i.statusOK = quickModelShortcutLabel(slot) + " cleared"
3800 } else {
3801 i.statusOK = quickModelShortcutLabel(slot) + " set to " + providerName + " / " + model
3802 }
3803 i.statusErr = ""
3804 i.mu.Unlock()
3805 i.refreshQuickModelSettingsItem(slot)
3806 i.invalidate()
3807}
3808
3809func (i *Interactive) refreshQuickModelSettingsItem(slot int) {
3810 if i.settingsDialog == nil || !i.settingsDialog.Active() || len(i.settingsDialog.items) == 0 {
3811 return
3812 }
3813 key := "quick_model_" + strconv.Itoa(slot)
3814 for idx, it := range i.settingsDialog.items {
3815 if it.key == key {
3816 i.settingsDialog.items[idx] = i.quickModelSettingItem(slot)
3817 return
3818 }
3819 }
3820}
3821
3822func (i *Interactive) applySettingToggle(key string, value bool) {
3823 // Every setting toggle forces a full repaint at the end — same
3824 // effect as the user pressing Ctrl+L — so any per-setting visual
3825 // change (image rendering, status copy, future toggles) lands
3826 // immediately instead of waiting for the next diff frame.
3827 defer func() {
3828 if i.rend != nil {
3829 i.rend.Clear()
3830 }
3831 i.invalidate()
3832 }()
3833 switch key {
3834 case "inline_images_enabled":
3835 val := value
3836 i.cfg.InlineImagesEnabled = &val
3837 if i.cfg.SettingsStore != nil {
3838 if err := i.cfg.SettingsStore.SetInlineImages(value); err != nil {
3839 i.mu.Lock()
3840 i.statusErr = "settings: " + err.Error()
3841 i.mu.Unlock()
3842 return
3843 }
3844 }
3845 i.mu.Lock()
3846 i.view.ImageProto = effectiveImageProtocol(i.cfg.InlineImagesEnabled)
3847 i.view.InvalidateRenderCache()
3848 i.statusOK = "inline image rendering " + onOff(value)
3849 i.statusErr = ""
3850 i.mu.Unlock()
3851 case "auto_swarm_enabled":
3852 val := value
3853 i.cfg.AutoSwarmEnabled = &val
3854 if i.cfg.SettingsStore != nil {
3855 if err := i.cfg.SettingsStore.SetAutoSwarm(value); err != nil {
3856 i.mu.Lock()
3857 i.statusErr = "settings: " + err.Error()
3858 i.mu.Unlock()
3859 return
3860 }
3861 }
3862 // Add/remove the swarm_spawn tool on the live agent so the
3863 // model's tools[] list reflects the toggle on the next turn.
3864 // Without this the tool stays advertised after a disable and
3865 // the model keeps trying to call it.
3866 i.applyAutoSwarmTool(value)
3867 // Also swap the system-prompt addendum in/out so the model
3868 // knows to use the tool proactively (or stops referencing it
3869 // after a disable).
3870 i.applyAutoSwarmSystemPrompt(value)
3871 i.mu.Lock()
3872 i.statusOK = "auto-swarm " + onOff(value)
3873 i.statusErr = ""
3874 i.mu.Unlock()
3875 case "jail_by_default":
3876 val := value
3877 i.cfg.JailByDefault = &val
3878 if i.cfg.SettingsStore != nil {
3879 if err := i.cfg.SettingsStore.SetJailByDefault(value); err != nil {
3880 i.mu.Lock()
3881 i.statusErr = "settings: " + err.Error()
3882 i.mu.Unlock()
3883 return
3884 }
3885 }
3886 if i.cfg.Sandbox != nil {
3887 if value {
3888 i.cfg.Sandbox.Lock()
3889 } else {
3890 i.cfg.Sandbox.Unlock()
3891 }
3892 }
3893 i.mu.Lock()
3894 i.statusOK = "jail by default " + onOff(value)
3895 i.statusErr = ""
3896 i.mu.Unlock()
3897 case "openrouter_server_tools_enabled":
3898 val := value
3899 i.cfg.OpenRouterServerToolsEnabled = &val
3900 if i.cfg.SettingsStore != nil {
3901 if err := i.cfg.SettingsStore.SetOpenRouterServerTools(value); err != nil {
3902 i.mu.Lock()
3903 i.statusErr = "settings: " + err.Error()
3904 i.mu.Unlock()
3905 return
3906 }
3907 }
3908 i.applyOpenRouterServerTools(value)
3909 i.mu.Lock()
3910 i.statusOK = "OpenRouter server tools " + onOff(value)
3911 i.statusErr = ""
3912 i.mu.Unlock()
3913 case "recursive_file_suggest":
3914 val := value
3915 i.cfg.RecursiveFileSuggest = &val
3916 if i.cfg.SettingsStore != nil {
3917 if err := i.cfg.SettingsStore.SetRecursiveFileSuggest(value); err != nil {
3918 i.mu.Lock()
3919 i.statusErr = "settings: " + err.Error()
3920 i.mu.Unlock()
3921 return
3922 }
3923 }
3924 // Flip the live picker so the next @ reflects the new mode
3925 // without restarting zot. SetRecursive drops its cache.
3926 i.fileSuggest.SetRecursive(value)
3927 i.mu.Lock()
3928 i.statusOK = "recursive @-file search " + onOff(value)
3929 i.statusErr = ""
3930 i.mu.Unlock()
3931 case "respect_gitignore":
3932 val := value
3933 i.cfg.RespectGitignore = &val
3934 if i.cfg.SettingsStore != nil {
3935 if err := i.cfg.SettingsStore.SetRespectGitignore(value); err != nil {
3936 i.mu.Lock()
3937 i.statusErr = "settings: " + err.Error()
3938 i.mu.Unlock()
3939 return
3940 }
3941 }
3942 i.fileSuggest.SetRespectGitignore(value)
3943 i.mu.Lock()
3944 i.statusOK = "hide gitignored files in @-picker " + onOff(value)
3945 i.statusErr = ""
3946 i.mu.Unlock()
3947 case "compact_mode":
3948 val := value
3949 i.cfg.CompactMode = &val
3950 if i.cfg.SettingsStore != nil {
3951 if err := i.cfg.SettingsStore.SetCompactMode(value); err != nil {
3952 i.mu.Lock()
3953 i.statusErr = "settings: " + err.Error()
3954 i.mu.Unlock()
3955 return
3956 }
3957 }
3958 i.mu.Lock()
3959 i.view.CompactMode = value
3960 i.view.InvalidateRenderCache()
3961 i.statusOK = "compact transcript rendering " + onOff(value)
3962 i.statusErr = ""
3963 i.mu.Unlock()
3964 case "show_instructions_at_startup":
3965 val := value
3966 i.cfg.ShowInstructionsAtStartup = &val
3967 if store, ok := i.cfg.SettingsStore.(showInstructionsSettingsStore); ok {
3968 if err := store.SetShowInstructionsAtStartup(value); err != nil {
3969 i.mu.Lock()
3970 i.statusErr = "settings: " + err.Error()
3971 i.mu.Unlock()
3972 return
3973 }
3974 }
3975 i.mu.Lock()
3976 i.view.StartupAgentName = ""
3977 i.view.StartupContextPaths = nil
3978 i.view.StartupExtensionNames = nil
3979 i.view.StartupSkillNames = nil
3980 if value {
3981 i.view.StartupAgentName = i.cfg.StartupAgentName
3982 i.view.StartupContextPaths = append(i.view.StartupContextPaths, i.cfg.StartupContextPaths...)
3983 i.view.StartupExtensionNames = append(i.view.StartupExtensionNames, i.cfg.StartupExtensionNames...)
3984 i.view.StartupSkillNames = append(i.view.StartupSkillNames, i.cfg.StartupSkillNames...)
3985 }
3986 i.statusOK = "show loaded resources at startup " + onOff(value)
3987 i.statusErr = ""
3988 i.mu.Unlock()
3989 }
3990}
3991
3992func (i *Interactive) applyTUIInputStyleSetting(style string) {
3993 defer func() {
3994 if i.rend != nil {
3995 i.rend.Clear()
3996 }
3997 i.invalidate()
3998 }()
3999 style = tui.NormalizeInputStyle(style)
4000 i.cfg.TUIInputStyle = style
4001 i.applyInputCursorColor()
4002 if i.cfg.SettingsStore != nil {
4003 if err := i.cfg.SettingsStore.SetTUIInputStyle(style); err != nil {
4004 i.mu.Lock()
4005 i.statusErr = "settings: " + err.Error()
4006 i.mu.Unlock()
4007 return
4008 }
4009 }
4010 i.mu.Lock()
4011 i.statusOK = "input style " + style
4012 i.statusErr = ""
4013 i.mu.Unlock()
4014}
4015
4016func (i *Interactive) applyTUIStatusPositionSetting(position string) {
4017 defer func() {
4018 if i.rend != nil {
4019 i.rend.Clear()
4020 }
4021 i.invalidate()
4022 }()
4023 position = tui.NormalizeStatusPosition(position)
4024 i.cfg.TUIStatusPosition = position
4025 if i.cfg.SettingsStore != nil {
4026 if err := i.cfg.SettingsStore.SetTUIStatusPosition(position); err != nil {
4027 i.mu.Lock()
4028 i.statusErr = "settings: " + err.Error()
4029 i.mu.Unlock()
4030 return
4031 }
4032 }
4033 i.mu.Lock()
4034 label := "above input"
4035 if position == tui.StatusPositionBelowInput {
4036 label = "below input"
4037 }
4038 i.statusOK = "status position " + label
4039 i.statusErr = ""
4040 i.mu.Unlock()
4041}
4042
4043func (i *Interactive) applyTUIWorkingPositionSetting(position string) {
4044 defer func() {
4045 if i.rend != nil {
4046 i.rend.Clear()
4047 }
4048 i.invalidate()
4049 }()
4050 position = tui.NormalizeWorkingPosition(position)
4051 i.cfg.TUIWorkingPosition = position
4052 if i.cfg.SettingsStore != nil {
4053 if err := i.cfg.SettingsStore.SetTUIWorkingPosition(position); err != nil {
4054 i.mu.Lock()
4055 i.statusErr = "settings: " + err.Error()
4056 i.mu.Unlock()
4057 return
4058 }
4059 }
4060 i.mu.Lock()
4061 label := "above input"
4062 if position == tui.WorkingPositionBelowInput {
4063 label = "below input"
4064 }
4065 i.statusOK = "working spinner position " + label
4066 i.statusErr = ""
4067 i.mu.Unlock()
4068}
4069
4070func (i *Interactive) applyThemeSetting(name string) {
4071 if i.cfg.SettingsStore != nil {
4072 if err := i.cfg.SettingsStore.SetTheme(name); err != nil {
4073 i.mu.Lock()
4074 i.statusErr = "settings: " + err.Error()
4075 i.mu.Unlock()
4076 return
4077 }
4078 }
4079 i.cfg.ThemeName = name
4080 if name == "auto" {
4081 i.cfg.ThemeName = ""
4082 }
4083 i.applyThemeNow(name)
4084}
4085
4086func (i *Interactive) applyThemeNow(name string) {
4087 if name == "" {
4088 name = "auto"
4089 }
4090 detected := tui.Dark
4091 if tui.IsLightTheme(i.cfg.Theme) {
4092 detected = tui.Light
4093 }
4094 th, applied, err := tui.LoadThemeFromHome(i.cfg.ZotHome, name, detected)
4095 if err != nil {
4096 if i.cfg.SettingsStore != nil {
4097 _ = i.cfg.SettingsStore.SetTheme("auto")
4098 }
4099 i.cfg.ThemeName = ""
4100 th, applied, _ = tui.LoadThemeFromHome(i.cfg.ZotHome, "auto", detected)
4101 i.mu.Lock()
4102 i.statusErr = "theme missing; reset to default"
4103 i.mu.Unlock()
4104 } else {
4105 i.mu.Lock()
4106 label := applied
4107 if label == "" {
4108 label = "auto"
4109 }
4110 i.statusOK = "theme " + label
4111 i.statusErr = ""
4112 i.mu.Unlock()
4113 }
4114 i.cfg.Theme = th
4115 i.view.Theme = th
4116 i.view.InvalidateRenderCache()
4117 i.ed.Prompt = th.AccentBar(th.Accent)
4118 i.applyInputCursorColor()
4119 i.spin.Configure(th)
4120 if i.rend != nil {
4121 i.rend.SetTheme(th)
4122 i.rend.Clear()
4123 }
4124 i.invalidate()
4125}
4126
4127func (i *Interactive) applyReasoningSetting(level string) {
4128 defer func() {
4129 if i.rend != nil {
4130 i.rend.Clear()
4131 }
4132 i.invalidate()
4133 }()
4134 level = provider.NormalizeReasoning(level)
4135 i.cfg.Reasoning = level
4136 if i.cfg.SettingsStore != nil {
4137 if err := i.cfg.SettingsStore.SetReasoning(level); err != nil {
4138 i.mu.Lock()
4139 i.statusErr = "settings: " + err.Error()
4140 i.mu.Unlock()
4141 return
4142 }
4143 }
4144 i.mu.Lock()
4145 if i.agent != nil {
4146 i.agent.Reasoning = level
4147 }
4148 label := level
4149 if label == "" {
4150 label = "off"
4151 }
4152 i.statusOK = "reasoning level " + label
4153 i.statusErr = ""
4154 i.mu.Unlock()
4155}
4156
4157// buildStudyPrompt returns the canned prompt the /study command
4158// submits to the agent.
4159//
4160// With no argument, /study targets the current directory — the
4161// historical behaviour. With an argument, /study targets that path
4162// instead; either a directory ("read every file in here") or a
4163// single file ("read this file"). The argument can be:
4164//
4165// - a relative path (resolved against cwd)
4166// - an absolute path
4167// - an @-picker chip, which has already been expanded to an
4168// absolute path by expandFileChips before runSlash sees it
4169//
4170// The path is stat'd to pick the right wording ("directory" vs
4171// "file"). If the path doesn't exist, we still build a sensible
4172// prompt rather than erroring — the agent will surface the
4173// missing-file failure itself when it tries to read it, which is
4174// more useful than a refusal here.
4175func buildStudyPrompt(arg, cwd string) string {
4176 arg = strings.TrimSpace(arg)
4177 if arg == "" {
4178 return "Read and understand everything in the current directory."
4179 }
4180 abs := arg
4181 if !filepath.IsAbs(abs) {
4182 abs = filepath.Join(cwd, abs)
4183 }
4184 display := arg
4185 if rel, err := filepath.Rel(cwd, abs); err == nil && !strings.HasPrefix(rel, "..") {
4186 display = rel
4187 }
4188 if info, err := os.Stat(abs); err == nil && !info.IsDir() {
4189 return "Read and understand the file " + display + "."
4190 }
4191 return "Read and understand everything in the directory " + display + "."
4192}
4193
4194// tryPathTabCompleteEditor looks at ed's current value, finds the
4195// path-like token immediately before the cursor (the cursor is always
4196// at the end of the buffer after a keystroke, so "before the cursor"
4197// is the trailing non-whitespace run), and rewrites it to its shell-
4198// style completion against the filesystem.
4199//
4200// Returns true when it consumed the Tab keystroke (token recognised,
4201// completion attempted — even if no candidates matched, the keystroke
4202// is still consumed so it doesn't insert a literal tab character).
4203// Returns false when the token doesn't look like a path; callers then
4204// let Tab fall through to its normal no-op.
4205//
4206// Recognised path shapes:
4207// - ~ or ~/foo expanded via os.UserHomeDir()
4208// - /abs/path or /abs/path/foo absolute
4209// - ./foo, ../foo, foo/bar relative to cwd
4210//
4211// A bare word like "hello" is not treated as a path so plain text
4212// keeps Tab as a literal no-op.
4213//
4214// Free function (not a method) so the same logic runs against the
4215// editor instances owned by btwDialog and swarmDialog without each
4216// dialog needing its own copy.
4217func tryPathTabCompleteEditor(ed *tui.Editor, cwd string) bool {
4218 if ed == nil {
4219 return false
4220 }
4221 val := ed.Value()
4222 // Find the trailing run of non-whitespace.
4223 start := len(val)
4224 for start > 0 {
4225 r := val[start-1]
4226 if r == ' ' || r == '\t' || r == '\n' {
4227 break
4228 }
4229 start--
4230 }
4231 token := val[start:]
4232 if token == "" {
4233 return false
4234 }
4235 if !looksLikePathToken(token) {
4236 return false
4237 }
4238
4239 // Resolve the absolute parent directory + base prefix to match.
4240 parentAbs, basePrefix, displayParent, ok := resolvePathTabToken(token, cwd)
4241 if !ok {
4242 return true
4243 }
4244 entries, err := os.ReadDir(parentAbs)
4245 if err != nil {
4246 return true
4247 }
4248 var names []string
4249 var isDir []bool
4250 for _, e := range entries {
4251 name := e.Name()
4252 if !strings.HasPrefix(name, basePrefix) {
4253 continue
4254 }
4255 // Hide dotfiles unless the user explicitly typed a leading dot,
4256 // mirroring bash's default behaviour.
4257 if strings.HasPrefix(name, ".") && !strings.HasPrefix(basePrefix, ".") {
4258 continue
4259 }
4260 names = append(names, name)
4261 isDir = append(isDir, e.IsDir())
4262 }
4263 if len(names) == 0 {
4264 return true
4265 }
4266
4267 var completed string
4268 var completedIsDir bool
4269 if len(names) == 1 {
4270 completed = names[0]
4271 completedIsDir = isDir[0]
4272 } else {
4273 completed = longestCommonPrefix(names)
4274 if completed == basePrefix {
4275 // Already at the deepest unambiguous prefix; nothing to add.
4276 return true
4277 }
4278 }
4279
4280 // Build the replacement token in the same display form the user
4281 // typed (preserve ~ vs absolute vs relative).
4282 newToken := displayParent + completed
4283 if len(names) == 1 && completedIsDir {
4284 newToken += "/"
4285 }
4286
4287 ed.SetValue(val[:start] + newToken)
4288 return true
4289}
4290
4291// tryPathTabComplete is the Interactive-bound convenience wrapper.
4292// It calls the free helper against the main editor and invalidates
4293// the frame on a successful rewrite.
4294func (i *Interactive) tryPathTabComplete() bool {
4295 if tryPathTabCompleteEditor(i.ed, i.cfg.CWD) {
4296 i.invalidate()
4297 return true
4298 }
4299 return false
4300}
4301
4302// looksLikePathToken reports whether tok is shaped like a filesystem
4303// path. Paths must either start with ~, /, ./, ../ or contain a /.
4304// Plain words are excluded so Tab on "hello" stays a no-op.
4305func looksLikePathToken(tok string) bool {
4306 if tok == "" {
4307 return false
4308 }
4309 if tok[0] == '~' || tok[0] == '/' {
4310 return true
4311 }
4312 if strings.HasPrefix(tok, "./") || strings.HasPrefix(tok, "../") {
4313 return true
4314 }
4315 return strings.Contains(tok, "/")
4316}
4317
4318// resolvePathTabToken splits tok into (absolute parent dir, basename
4319// prefix to match, display-form parent the user typed). ok is false
4320// when the parent dir can't be resolved (e.g. ~ with no $HOME).
4321func resolvePathTabToken(tok, cwd string) (parentAbs, basePrefix, displayParent string, ok bool) {
4322 // Detect ~ expansion.
4323 expanded := tok
4324 homePrefix := ""
4325 if tok == "~" {
4326 home, err := os.UserHomeDir()
4327 if err != nil || home == "" {
4328 return "", "", "", false
4329 }
4330 // "~" alone: complete in $HOME. parent = home, base = "".
4331 return home, "", "~/", true
4332 }
4333 if strings.HasPrefix(tok, "~/") {
4334 home, err := os.UserHomeDir()
4335 if err != nil || home == "" {
4336 return "", "", "", false
4337 }
4338 expanded = home + tok[1:]
4339 homePrefix = "~"
4340 }
4341
4342 dir, base := splitDirBase(expanded)
4343 if !filepath.IsAbs(dir) {
4344 dir = filepath.Join(cwd, dir)
4345 }
4346
4347 // Reconstruct the display form the user typed for the parent,
4348 // keeping ~ when they used it. The base is dropped — the caller
4349 // substitutes the completed name.
4350 display := tok[:len(tok)-len(base)]
4351 if homePrefix != "" && !strings.HasPrefix(display, "~") {
4352 display = homePrefix + display[len(homePrefix):]
4353 }
4354 return dir, base, display, true
4355}
4356
4357// splitDirBase is like filepath.Split but preserves the trailing
4358// slash convention: "foo" => (".", "foo"); "foo/" => ("foo", "");
4359// "a/b" => ("a/", "b"); "/" => ("/", ""). Returned dir always has
4360// the trailing separator when non-empty so callers can rebuild paths
4361// by concatenation.
4362func splitDirBase(p string) (dir, base string) {
4363 if p == "" {
4364 return ".", ""
4365 }
4366 i := strings.LastIndex(p, "/")
4367 if i < 0 {
4368 return ".", p
4369 }
4370 return p[:i+1], p[i+1:]
4371}
4372
4373func longestCommonPrefix(ss []string) string {
4374 if len(ss) == 0 {
4375 return ""
4376 }
4377 prefix := ss[0]
4378 for _, s := range ss[1:] {
4379 n := 0
4380 for n < len(prefix) && n < len(s) && prefix[n] == s[n] {
4381 n++
4382 }
4383 prefix = prefix[:n]
4384 if prefix == "" {
4385 return ""
4386 }
4387 }
4388 return prefix
4389}
4390
4391func (i *Interactive) runSlash(ctx context.Context, cmd string) (done bool) {
4392 parts := strings.Fields(cmd)
4393 if len(parts) == 0 {
4394 return false
4395 }
4396
4397 if isSkillCommand(cmd) {
4398 var available []*skills.Skill
4399 if i.cfg.SkillSnapshot != nil {
4400 available = i.cfg.SkillSnapshot()
4401 }
4402 prompt, _, err := expandSkillCommand(cmd, available)
4403 if err != nil {
4404 i.mu.Lock()
4405 i.statusErr = err.Error()
4406 i.statusOK = ""
4407 i.mu.Unlock()
4408 return false
4409 }
4410 i.submitOrQueuePrompt(ctx, prompt)
4411 return false
4412 }
4413
4414 switch strings.ToLower(parts[0]) {
4415 case "/exit":
4416 return true
4417 case "/clear":
4418 if i.agent != nil {
4419 i.agent.SetMessages(nil)
4420 }
4421 i.mu.Lock()
4422 i.toolCalls = map[string]*tui.ToolCallView{}
4423 i.toolOrder = nil
4424 i.toolGate = map[string]int{}
4425 i.statusErr = ""
4426 i.statusOK = ""
4427 i.helpBlock = nil
4428 i.parkedTurn = 0
4429 i.parkedTotal = 0
4430 i.scrollOffset = 0
4431 i.extNotes = nil
4432 i.reloadErrors = nil
4433 i.view.InvalidateRenderCache()
4434 i.mu.Unlock()
4435 case "/help":
4436 i.mu.Lock()
4437 i.helpBlock = renderHelpBlock(i.cfg.Theme, i.lastCols(), i.llamaConfigured)
4438 i.statusErr = ""
4439 i.statusOK = ""
4440 // Pin the viewport to the newest content so the help block,
4441 // which we just appended to the end of the transcript, is
4442 // what the user actually sees.
4443 i.scrollOffset = 0
4444 i.mu.Unlock()
4445 case "/login":
4446 i.dialog.Open(i.cfg.ZotHome)
4447 case "/logout":
4448 if len(parts) >= 2 {
4449 // Explicit target: /logout anthropic | openai | all
4450 i.doLogout(parts[1])
4451 break
4452 }
4453 // No arg: open the picker over whichever providers are
4454 // currently logged in. If nothing's logged in, bail with a
4455 // status line.
4456 i.openLogoutDialog()
4457 case "/model":
4458 if len(parts) >= 2 {
4459 i.applyModelSelection("", parts[1])
4460 } else if i.llamaConfigured && i.cfg.RefreshLlamaCPPModels != nil {
4461 i.mu.Lock()
4462 i.statusOK = "refreshing models"
4463 i.statusErr = ""
4464 i.mu.Unlock()
4465 go func() {
4466 refreshCtx, cancel := context.WithTimeout(ctx, 3*time.Second)
4467 defer cancel()
4468 i.modelRefresh <- modelRefreshResult{err: i.cfg.RefreshLlamaCPPModels(refreshCtx)}
4469 }()
4470 } else {
4471 i.openModelPickerAfterRefresh(nil)
4472 }
4473 case "/llama":
4474 if i.cfg.LlamaCPPConfig == nil {
4475 i.mu.Lock()
4476 i.statusErr = "llama.cpp configuration is unavailable"
4477 i.statusOK = ""
4478 i.mu.Unlock()
4479 break
4480 }
4481 baseURL, apiKey, configErr := i.cfg.LlamaCPPConfig()
4482 if configErr == nil {
4483 configErr = i.llamaDialog.Open(baseURL, apiKey, i.invalidate)
4484 }
4485 if configErr != nil {
4486 i.mu.Lock()
4487 i.statusErr = configErr.Error()
4488 i.statusOK = ""
4489 i.mu.Unlock()
4490 }
4491 case "/reasoning":
4492 i.openReasoningDialog()
4493 case "/settings":
4494 i.openSettingsDialog()
4495 case "/sessions":
4496 i.sessionDialog.Open(i.sessionsRoot(), i.cfg.CWD)
4497 case "/jump":
4498 i.openJumpDialog(parts[1:])
4499 case "/btw":
4500 i.openBtwDialog(parts[1:])
4501 case "/skills":
4502 i.openSkillsDialog()
4503 case "/compact":
4504 i.runCompact(ctx, false)
4505 case "/study":
4506 // Canned prompt that tells the agent to read every file
4507 // in some target so its later turns have the whole thing
4508 // in context. With no argument, the target is the current
4509 // directory. With an argument, the target is whatever the
4510 // user passed — typed by hand, drag-dropped, or selected
4511 // via the @ file picker (which is why we accept both files
4512 // and directories; the @-picker chips for both have already
4513 // been expanded to absolute paths by expandFileChips above).
4514 // Dispatched through the normal queue-or-start path so it
4515 // behaves identically to typing the prompt by hand.
4516 studyPrompt := buildStudyPrompt(strings.TrimSpace(strings.TrimPrefix(cmd, parts[0])), i.cfg.CWD)
4517 i.mu.Lock()
4518 busy := i.busy
4519 compacting := i.compacting
4520 ag := i.agent
4521 i.mu.Unlock()
4522 if busy {
4523 if ag != nil && !compacting {
4524 ag.QueueMessage(studyPrompt)
4525 } else {
4526 i.mu.Lock()
4527 i.queued = append(i.queued, studyPrompt)
4528 i.mu.Unlock()
4529 }
4530 i.invalidate()
4531 break
4532 }
4533 i.startTurn(ctx, studyPrompt)
4534 case "/cd":
4535 // Hidden command: switch the running session's cwd. Not in
4536 // slash_suggest, not in /help. Used by the workspaces
4537 // extension's panel-key Enter handler so picking a row
4538 // jumps zot into that directory without relaunching.
4539 //
4540 // Recovers the raw argument (path) from the original cmd
4541 // string rather than parts, so paths with spaces survive.
4542 // The host's ChangeCWD hook handles validation, session
4543 // close + reopen, agent rebuild, sandbox re-rooting, and
4544 // re-jail-if-jailed semantics.
4545 if i.cfg.ChangeCWD == nil {
4546 i.mu.Lock()
4547 i.statusErr = "/cd unavailable: host did not wire ChangeCWD"
4548 i.mu.Unlock()
4549 break
4550 }
4551 path := strings.TrimSpace(strings.TrimPrefix(cmd, parts[0]))
4552 if path == "" {
4553 i.mu.Lock()
4554 i.statusErr = "/cd: missing path"
4555 i.mu.Unlock()
4556 break
4557 }
4558 if err := i.cfg.ChangeCWD(path); err != nil {
4559 i.mu.Lock()
4560 i.statusErr = "/cd: " + err.Error()
4561 i.statusOK = ""
4562 i.mu.Unlock()
4563 break
4564 }
4565 // ChangeCWD has already updated i.cfg.CWD and swapped the
4566 // agent + session. Reset transient TUI state so the new
4567 // session opens clean.
4568 i.mu.Lock()
4569 i.toolCalls = map[string]*tui.ToolCallView{}
4570 i.toolOrder = nil
4571 i.toolGate = map[string]int{}
4572 i.helpBlock = nil
4573 i.parkedTurn = 0
4574 i.statusOK = "cwd " + i.cfg.CWD
4575 i.statusErr = ""
4576 i.mu.Unlock()
4577 i.fileSuggest.Reset()
4578 i.fileSuggest.SetCWD(i.cfg.CWD)
4579 i.invalidate()
4580 case "/jail":
4581 if i.cfg.Sandbox == nil {
4582 i.mu.Lock()
4583 i.statusErr = "sandbox not available in this build"
4584 i.mu.Unlock()
4585 break
4586 }
4587 i.cfg.Sandbox.Lock()
4588 i.mu.Lock()
4589 i.statusOK = "jailed to " + i.cfg.CWD + " (tools cannot touch paths outside this directory)"
4590 i.statusErr = ""
4591 i.mu.Unlock()
4592 case "/unjail":
4593 if i.cfg.Sandbox == nil {
4594 i.mu.Lock()
4595 i.statusErr = "sandbox not available in this build"
4596 i.mu.Unlock()
4597 break
4598 }
4599 i.cfg.Sandbox.Unlock()
4600 i.mu.Lock()
4601 i.statusOK = "unjailed"
4602 i.statusErr = ""
4603 i.mu.Unlock()
4604 case "/reload-ext":
4605 i.runReloadExt(ctx)
4606 case "/telegram", "/tg":
4607 if len(parts) >= 2 {
4608 i.doTelegram(parts[1])
4609 break
4610 }
4611 i.openTelegramDialog()
4612 case "/session":
4613 if len(parts) >= 2 {
4614 action := parts[1]
4615 arg := ""
4616 if len(parts) >= 3 {
4617 arg = strings.Join(parts[2:], " ")
4618 }
4619 i.doSessionOp(action, arg)
4620 break
4621 }
4622 i.openSessionOpsDialog()
4623 case "/swarm":
4624 i.runSwarm(ctx, parts[1:])
4625 default:
4626 // Last-resort fallback: try the extension manager. Built-in
4627 // cases above always win; this branch only fires for slash
4628 // commands the extension manager registered. Same routing as
4629 // the editor's submit-handler dispatch path so the autocomplete
4630 // "enter on highlighted suggestion" flow also works.
4631 extName := strings.TrimPrefix(parts[0], "/")
4632 if i.cfg.Extensions != nil && i.cfg.Extensions.HasCommand(extName) {
4633 rest := ""
4634 if len(parts) > 1 {
4635 rest = strings.Join(parts[1:], " ")
4636 }
4637 go i.invokeExtensionCommand(ctx, extName, rest)
4638 return false
4639 }
4640 i.mu.Lock()
4641 i.statusErr = "unknown command: " + parts[0]
4642 i.mu.Unlock()
4643 }
4644 return false
4645}
4646
4647func (i *Interactive) openModelPickerAfterRefresh(refreshErr error) {
4648 var loggedIn []string
4649 if i.cfg.LoggedInProviders != nil {
4650 loggedIn = i.cfg.LoggedInProviders()
4651 }
4652 i.modelDialog.Open(i.cfg.Model, loggedIn, i.cfg.Reasoning)
4653 i.mu.Lock()
4654 if refreshErr != nil {
4655 i.statusErr = "llama.cpp model refresh: " + refreshErr.Error()
4656 i.statusOK = ""
4657 } else if i.statusOK == "refreshing models" {
4658 i.statusOK = ""
4659 }
4660 i.mu.Unlock()
4661}
4662
4663// openLogoutDialog shows the provider picker for `/logout` with no
4664// argument. Only providers the user is currently logged into are
4665// listed, plus an "all" entry when more than one is present. If
4666// nothing's logged in, writes a status line instead of opening an
4667// empty dialog.
4668func (i *Interactive) openLogoutDialog() {
4669 if i.cfg.AuthManager == nil {
4670 i.mu.Lock()
4671 i.statusErr = "no auth manager configured"
4672 i.mu.Unlock()
4673 i.invalidate()
4674 return
4675 }
4676 store := i.cfg.AuthManager.Store()
4677 if store == nil {
4678 i.mu.Lock()
4679 i.statusErr = "auth store is not available"
4680 i.mu.Unlock()
4681 i.invalidate()
4682 return
4683 }
4684 creds, err := store.Load()
4685 if err != nil {
4686 i.mu.Lock()
4687 i.statusErr = "read auth store: " + err.Error()
4688 i.mu.Unlock()
4689 i.invalidate()
4690 return
4691 }
4692
4693 var items []logoutItem
4694 for _, p := range []string{"anthropic", "kimi", "google", "github-copilot"} {
4695 if creds.Has(p) {
4696 method := creds.Method(p)
4697 if method == "oauth" {
4698 method = "subscription"
4699 }
4700 items = append(items, logoutItem{
4701 label: providerLabel(p),
4702 target: p,
4703 method: method,
4704 })
4705 }
4706 }
4707 if creds.OpenAI.APIKey != "" || creds.OpenAI.APIKeyCommand != nil {
4708 items = append(items, logoutItem{label: providerLabel("openai"), target: "openai", method: "api key"})
4709 }
4710 if creds.OpenAI.OAuth != nil {
4711 items = append(items, logoutItem{label: providerLabel("openai-codex"), target: "openai-codex", method: "subscription"})
4712 }
4713 for p, c := range creds.AdditionalAPIKeyCreds {
4714 if c.APIKey != "" || c.APIKeyCommand != nil || c.BaseURL != "" {
4715 items = append(items, logoutItem{label: providerLabel(p), target: p, method: "api key"})
4716 }
4717 }
4718 if len(items) == 0 {
4719 i.mu.Lock()
4720 i.statusOK = "no credentials stored; already logged out"
4721 i.statusErr = ""
4722 i.mu.Unlock()
4723 i.invalidate()
4724 return
4725 }
4726 if len(items) > 1 {
4727 items = append(items, logoutItem{label: "all", target: "all"})
4728 }
4729
4730 i.logoutDialog.Open(items)
4731 i.invalidate()
4732}
4733
4734// doLogout clears credentials for the given provider (or all providers)
4735// from auth.json. If the active agent was using those credentials, it
4736// is torn down so the user is forced through /login before their next
4737// prompt.
4738//
4739// target: "anthropic" | "openai" | "kimi" | "github-copilot" | "all"
4740func (i *Interactive) doLogout(target string) {
4741 if i.cfg.AuthManager == nil {
4742 i.mu.Lock()
4743 i.statusErr = "no auth manager configured"
4744 i.mu.Unlock()
4745 return
4746 }
4747 store := i.cfg.AuthManager.Store()
4748 if store == nil {
4749 i.mu.Lock()
4750 i.statusErr = "auth store is not available"
4751 i.mu.Unlock()
4752 return
4753 }
4754
4755 var providers []string
4756 switch target {
4757 case "", "all":
4758 providers = append([]string{"anthropic", "openai", "openai-codex", "kimi", "google", "github-copilot"}, auth.APIKeyProviders()...)
4759 case "anthropic", "openai", "openai-codex", "kimi", "google", "github-copilot":
4760 providers = []string{target}
4761 default:
4762 known := false
4763 for _, p := range auth.APIKeyProviders() {
4764 if target == p {
4765 known = true
4766 break
4767 }
4768 }
4769 if !known {
4770 i.mu.Lock()
4771 i.statusErr = "unknown provider: " + target
4772 i.mu.Unlock()
4773 return
4774 }
4775 providers = []string{target}
4776 }
4777
4778 var errs []string
4779 clearedCurrent := false
4780 for _, p := range providers {
4781 var err error
4782 switch p {
4783 case "openai":
4784 err = store.ClearAPIKey("openai")
4785 case "openai-codex":
4786 err = store.ClearOAuth("openai")
4787 default:
4788 err = store.Clear(p)
4789 }
4790 if err != nil {
4791 errs = append(errs, p+": "+err.Error())
4792 continue
4793 }
4794 if p == "kimi" && i.cfg.SetKimiCLIFallbackDisabled != nil {
4795 if err := i.cfg.SetKimiCLIFallbackDisabled(true); err != nil {
4796 errs = append(errs, p+": "+err.Error())
4797 continue
4798 }
4799 }
4800 if p == i.cfg.Provider {
4801 clearedCurrent = true
4802 }
4803 }
4804
4805 llamaConfigured := i.llamaConfigured
4806 if i.cfg.LlamaCPPConfig != nil {
4807 baseURL, _, err := i.cfg.LlamaCPPConfig()
4808 llamaConfigured = err == nil && baseURL != ""
4809 }
4810
4811 i.mu.Lock()
4812 defer i.mu.Unlock()
4813 i.llamaConfigured = llamaConfigured
4814 if len(errs) > 0 {
4815 i.statusErr = "logout errors: " + strings.Join(errs, "; ")
4816 return
4817 }
4818 i.statusErr = ""
4819 if clearedCurrent {
4820 // The running agent was using a credential we just wiped. Drop
4821 // it so prompts can't go out with the stale client, and hint at
4822 // /login.
4823 i.agent = nil
4824 i.statusOK = "logged out of " + strings.Join(providers, ", ") + ". type /login to sign back in."
4825 } else {
4826 i.statusOK = "logged out of " + strings.Join(providers, ", ")
4827 }
4828}
4829
4830func providerSetupInfo(provider string) (string, []string, bool) {
4831 const docsURL = "https://raw.githubusercontent.com/patriceckhart/zot/main/docs/providers.md"
4832 switch provider {
4833 case "amazon-bedrock":
4834 return "Amazon Bedrock setup", []string{
4835 "Amazon Bedrock uses AWS credentials instead of a generic zot API-key entry.",
4836 "Configure an AWS profile, IAM keys, bearer token, or role-based credentials.",
4837 "",
4838 "For Bedrock API keys, set:",
4839 " AWS_BEARER_TOKEN_BEDROCK=...",
4840 " AWS_REGION=us-east-1",
4841 "",
4842 "Docs:",
4843 " " + docsURL,
4844 }, true
4845 case "google-vertex":
4846 return "Google Vertex AI setup", []string{
4847 "Google Vertex AI usually uses Google Cloud credentials and project settings.",
4848 "Set a Google API key, application-default credentials, or a service account.",
4849 "",
4850 "Common environment:",
4851 " GOOGLE_CLOUD_API_KEY=...",
4852 " GOOGLE_CLOUD_PROJECT=...",
4853 " GOOGLE_CLOUD_LOCATION=us-central1",
4854 "",
4855 "Docs:",
4856 " " + docsURL,
4857 }, true
4858 case "cloudflare-workers-ai":
4859 return "Cloudflare Workers AI setup", []string{
4860 "Cloudflare Workers AI needs both an API token and an account ID.",
4861 "",
4862 "Set:",
4863 " CLOUDFLARE_API_KEY=...",
4864 " CLOUDFLARE_ACCOUNT_ID=...",
4865 "",
4866 "Docs:",
4867 " " + docsURL,
4868 }, true
4869 case "cloudflare-ai-gateway":
4870 return "Cloudflare AI Gateway setup", []string{
4871 "Cloudflare AI Gateway needs an API token, account ID, and gateway ID.",
4872 "",
4873 "Set:",
4874 " CLOUDFLARE_API_KEY=...",
4875 " CLOUDFLARE_ACCOUNT_ID=...",
4876 " CLOUDFLARE_GATEWAY_ID=...",
4877 "",
4878 "Docs:",
4879 " " + docsURL,
4880 }, true
4881 case "azure-openai-responses":
4882 return "Azure OpenAI Responses setup", []string{
4883 "Azure OpenAI needs an API key plus your Azure endpoint or deployment setup.",
4884 "",
4885 "Set:",
4886 " AZURE_OPENAI_API_KEY=...",
4887 " AZURE_OPENAI_BASE_URL=https://your-resource.openai.azure.com",
4888 " AZURE_OPENAI_API_VERSION=2024-02-01",
4889 "",
4890 "Docs:",
4891 " " + docsURL,
4892 }, true
4893 default:
4894 return "", nil, false
4895 }
4896}
4897
4898func (i *Interactive) saveLlamaCPPLogin(baseURL, apiKey string) {
4899 if i.cfg.AuthManager == nil || i.cfg.AuthManager.Store() == nil {
4900 i.dialog.ShowResult(false, "auth store is unavailable")
4901 return
4902 }
4903 if err := i.cfg.AuthManager.Store().SetEndpointCredential(provider.LlamaCPPProviderID, baseURL, apiKey); err != nil {
4904 i.dialog.ShowResult(false, err.Error())
4905 return
4906 }
4907 i.mu.Lock()
4908 i.llamaConfigured = true
4909 i.statusErr = ""
4910 i.statusOK = "configured llama.cpp router"
4911 i.mu.Unlock()
4912 i.dialog.ShowResult(true, "")
4913}
4914
4915func (i *Interactive) startAPIKeyFlow(provider string) {
4916 if title, lines, ok := providerSetupInfo(provider); ok {
4917 i.dialog.ShowInfo(title, lines)
4918 return
4919 }
4920 if provider == "kimi" && i.cfg.SetKimiCLIFallbackDisabled != nil {
4921 _ = i.cfg.SetKimiCLIFallbackDisabled(false)
4922 }
4923 url, err := i.cfg.AuthManager.StartAPIKey(provider)
4924 if err != nil {
4925 i.dialog.ShowResult(false, err.Error())
4926 return
4927 }
4928 i.dialog.ShowWaiting(url)
4929}
4930
4931func (i *Interactive) startOAuthFlow(provider string) {
4932 if provider == "kimi" && i.cfg.SetKimiCLIFallbackDisabled != nil {
4933 _ = i.cfg.SetKimiCLIFallbackDisabled(false)
4934 }
4935 // Device-code providers already support headless login and must only
4936 // start one polling flow.
4937 if provider == "kimi" || provider == "xai" || provider == "github-copilot" {
4938 loginURL, err := i.cfg.AuthManager.StartOAuth(provider)
4939 if err != nil {
4940 i.dialog.ShowResult(false, err.Error())
4941 return
4942 }
4943 i.dialog.ShowWaiting(loginURL)
4944 return
4945 }
4946 // Always run the manual/copy-code flow in parallel with the local
4947 // callback server so headless environments (docker, SSH) can paste
4948 // the authorization code directly without first pressing 'p'.
4949 _, err := i.cfg.AuthManager.StartOAuth(provider)
4950 if err != nil {
4951 i.dialog.ShowResult(false, err.Error())
4952 return
4953 }
4954 manualURL, mErr := i.cfg.AuthManager.StartManualOAuth(provider)
4955 if mErr == nil {
4956 i.dialog.ShowWaiting(manualURL)
4957 } else {
4958 i.dialog.ShowResult(false, mErr.Error())
4959 }
4960}
4961
4962func (i *Interactive) startManualOAuthFlow(provider string) {
4963 if i.cfg.AuthManager == nil {
4964 return
4965 }
4966 i.cfg.AuthManager.CancelOAuth()
4967 url, err := i.cfg.AuthManager.StartManualOAuth(provider)
4968 if err != nil {
4969 i.dialog.ShowResult(false, err.Error())
4970 return
4971 }
4972 i.dialog.url = url
4973 i.invalidate()
4974}
4975
4976func (i *Interactive) submitManualOAuthCode(code string) {
4977 if i.cfg.AuthManager == nil {
4978 return
4979 }
4980 go func() {
4981 if err := i.cfg.AuthManager.CompleteManualOAuth(i.runCtx, code); err != nil {
4982 i.dialog.ShowResult(false, err.Error())
4983 i.invalidate()
4984 }
4985 }()
4986}
4987
4988// applyModelSelection switches the active model (and provider, if the
4989// new model belongs to a different one). It rebuilds the underlying
4990// client when needed so the provider wire-protocol matches.
4991// cancelAndWaitForIdle cancels the active turn (if any) and blocks
4992// briefly until the turn goroutine has updated i.busy = false. Used
4993// before destructive slash commands so transcript-mutating work
4994// (/clear, /compact, /logout, /login completion, cross-provider
4995// /model swap) doesn't race with the still-running stream.
4996//
4997// The wait is bounded; if the turn doesn't release within the timeout
4998// we proceed anyway. Worst case is a brief overlap that the agent's
4999// own mutex protects against.
5000func (i *Interactive) cancelAndWaitForIdle() {
5001 i.mu.Lock()
5002 busy := i.busy
5003 cancel := i.cancelTurn
5004 i.mu.Unlock()
5005 if !busy {
5006 return
5007 }
5008 if cancel != nil {
5009 cancel()
5010 }
5011 // ConfirmToolCall waits on its response channel rather than the turn
5012 // context, so cancellation must also resolve every pending prompt.
5013 i.confirmDialog.CancelAll("turn cancelled")
5014 deadline := time.Now().Add(2 * time.Second)
5015 for time.Now().Before(deadline) {
5016 i.mu.Lock()
5017 done := !i.busy
5018 i.mu.Unlock()
5019 if done {
5020 return
5021 }
5022 time.Sleep(10 * time.Millisecond)
5023 }
5024}
5025
5026// openBtwDialog opens the side-chat overlay with a frozen snapshot
5027// of the current main session. The optional argument is auto-
5028// submitted as the first question, so '/btw does X work?' fires the
5029// model call immediately instead of just opening an empty dialog.
5030func (i *Interactive) openBtwDialog(args []string) {
5031 if i.agent == nil {
5032 i.mu.Lock()
5033 i.statusErr = "not logged in. type /login first."
5034 i.mu.Unlock()
5035 return
5036 }
5037 seed := strings.TrimSpace(strings.Join(args, " "))
5038 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)
5039 i.invalidate()
5040}
5041
5042// submitOrQueuePrompt submits a slash command's expanded prompt immediately,
5043// or queues it behind the active turn using the normal text-only queue.
5044func (i *Interactive) submitOrQueuePrompt(ctx context.Context, prompt string) {
5045 i.mu.Lock()
5046 if i.agent == nil {
5047 i.statusErr = "not logged in. type /login first."
5048 i.statusOK = ""
5049 i.mu.Unlock()
5050 i.invalidate()
5051 return
5052 }
5053 busy := i.busy
5054 compacting := i.compacting
5055 ag := i.agent
5056 i.mu.Unlock()
5057
5058 if busy {
5059 if !compacting {
5060 ag.QueueMessage(prompt)
5061 } else {
5062 i.mu.Lock()
5063 i.queued = append(i.queued, prompt)
5064 i.mu.Unlock()
5065 }
5066 i.invalidate()
5067 return
5068 }
5069 i.startTurn(ctx, prompt)
5070}
5071
5072// openSkillsDialog opens the skill inspector. The picker reflects
5073// whatever SkillSnapshot returns at call time, so edits to a
5074// SKILL.md made during a session show up on the next /skills.
5075func (i *Interactive) openSkillsDialog() {
5076 var list []*skills.Skill
5077 if i.cfg.SkillSnapshot != nil {
5078 list = i.cfg.SkillSnapshot()
5079 }
5080 i.skillsDialog.Open(list)
5081 i.invalidate()
5082}
5083
5084// openJumpDialog builds a /jump picker from the current transcript.
5085// If the user typed "/jump foo" with a filter and it matches exactly
5086// one turn, jump there directly without showing the dialog.
5087func (i *Interactive) openJumpDialog(args []string) {
5088 if i.view == nil || len(i.view.Messages) == 0 {
5089 i.mu.Lock()
5090 i.statusErr = "nothing to jump to \u2014 the session is empty"
5091 i.mu.Unlock()
5092 return
5093 }
5094 filter := strings.TrimSpace(strings.Join(args, " "))
5095 i.jumpDialog.Open(i.view.Messages, filter)
5096 // Shortcut: with a filter argument that matches exactly one turn,
5097 // jump immediately and skip the picker.
5098 if filter != "" {
5099 if tgts := i.jumpDialog.Targets(); len(tgts) == 1 {
5100 t := tgts[0]
5101 i.jumpDialog.Close()
5102 i.applyJumpSelection(t.MessageIdx, t.TurnNo)
5103 }
5104 }
5105}
5106
5107// applyJumpSelection scrolls the chat viewport so the user message at
5108// msgIdx is visible at (or near) the top of the chat area. Uses the
5109// anchor slice returned by view.BuildWithAnchors so the mapping from
5110// message index to row is exact, regardless of variable-height tool
5111// blocks above the target.
5112func (i *Interactive) applyJumpSelection(msgIdx, turnNo int) {
5113 cols := i.lastCols()
5114 chat, anchors := i.view.BuildWithAnchors(cols)
5115 var row int
5116 found := false
5117 for _, a := range anchors {
5118 if a.MessageIdx == msgIdx {
5119 row = a.Row
5120 found = true
5121 break
5122 }
5123 }
5124 if !found {
5125 i.mu.Lock()
5126 i.statusErr = "could not resolve jump target"
5127 i.mu.Unlock()
5128 return
5129 }
5130
5131 chatLen := len(chat)
5132 page := i.chatPage()
5133 if page < 1 {
5134 page = 1
5135 }
5136 // scrollOffset is measured from the bottom of the chat slice, so
5137 // to place `row` at the top of the viewport we want:
5138 // chatLen - scrollOffset - page == row
5139 // Solve for scrollOffset and clamp to [0, chatLen-page].
5140 offset := chatLen - (row + page)
5141 if offset < 0 {
5142 offset = 0
5143 }
5144 maxOffset := chatLen - page
5145 if maxOffset < 0 {
5146 maxOffset = 0
5147 }
5148 if offset > maxOffset {
5149 offset = maxOffset
5150 }
5151
5152 i.mu.Lock()
5153 i.scrollOffset = offset
5154 i.parkedTurn = turnNo
5155 i.parkedTotal = totalTurnsLocked(i.view.Messages)
5156 i.statusOK = fmt.Sprintf("jumped to turn %d", turnNo)
5157 i.statusErr = ""
5158 i.mu.Unlock()
5159}
5160
5161// totalTurnsLocked counts user messages in the transcript. Caller is
5162// assumed to hold i.mu (the name is a mild reminder; this function
5163// itself doesn't touch shared state beyond the slice it's handed).
5164func totalTurnsLocked(msgs []provider.Message) int {
5165 n := 0
5166 for _, m := range msgs {
5167 if m.Role == provider.RoleUser {
5168 n++
5169 }
5170 }
5171 return n
5172}
5173
5174// applySessionDeletion permanently removes a session selected in /sessions.
5175// The active session is protected because unlinking its open file would lose
5176// subsequent transcript writes on Unix and behaves differently on Windows.
5177func (i *Interactive) applySessionDeletion(path string) {
5178 if i.cfg.CurrentSessionPath == nil {
5179 i.mu.Lock()
5180 i.statusErr = "session deletion is not wired in this build"
5181 i.statusOK = ""
5182 i.mu.Unlock()
5183 return
5184 }
5185 current := i.cfg.CurrentSessionPath()
5186 if current != "" && filepath.Clean(current) == filepath.Clean(path) {
5187 i.mu.Lock()
5188 i.statusErr = "cannot delete the active session; resume another session first"
5189 i.statusOK = ""
5190 i.mu.Unlock()
5191 return
5192 }
5193 if err := core.DeleteSession(path); err != nil {
5194 i.mu.Lock()
5195 i.statusErr = "delete session: " + err.Error()
5196 i.statusOK = ""
5197 i.mu.Unlock()
5198 return
5199 }
5200 i.sessionDialog.Remove(path)
5201 i.mu.Lock()
5202 i.statusOK = "deleted session: " + friendlyPath(path)
5203 i.statusErr = ""
5204 i.mu.Unlock()
5205}
5206
5207// applySessionSelection loads the given session via the cli-provided
5208// callback and snaps the viewport to the bottom (the latest message)
5209// so the user lands at the live tail of the resumed conversation.
5210func (i *Interactive) applySessionSelection(path string) {
5211 if i.cfg.LoadSession == nil {
5212 i.mu.Lock()
5213 i.statusErr = "session loading is not wired in this build"
5214 i.mu.Unlock()
5215 return
5216 }
5217 i.mu.Lock()
5218 if i.sessionLoading {
5219 i.statusErr = "already resuming a session"
5220 i.mu.Unlock()
5221 i.invalidate()
5222 return
5223 }
5224 i.sessionLoading = true
5225 i.statusOK = "resuming session: " + path
5226 i.statusErr = ""
5227 i.mu.Unlock()
5228 i.invalidate()
5229
5230 go func() {
5231 err := i.cfg.LoadSession(path)
5232 i.mu.Lock()
5233 defer i.mu.Unlock()
5234 i.sessionLoading = false
5235 if err != nil {
5236 i.statusErr = err.Error()
5237 i.statusOK = ""
5238 i.invalidate()
5239 return
5240 }
5241 i.statusOK = "resumed session: " + path
5242 i.statusErr = ""
5243 i.parkedTurn = 0
5244 i.parkedTotal = 0
5245 i.scrollOffset = 0
5246 // Fresh transcript swapped in: drop the auto-follow baseline so
5247 // the next render's follow guard doesn't see the wholesale
5248 // length change as a delta and jump the viewport.
5249 i.prevChatLen = 0
5250 i.prevChatCols = 0
5251 i.extNotes = nil
5252 i.view.InvalidateRenderCache()
5253 if i.agent != nil {
5254 i.view.Messages = i.agent.Messages()
5255 i.cumUsage = i.agent.Cost()
5256 if last := i.agent.LastTurnUsage(); last.InputTokens > 0 || last.CacheReadTokens > 0 || last.CacheWriteTokens > 0 {
5257 i.lastCtxInput = last.InputTokens + last.CacheReadTokens + last.CacheWriteTokens
5258 } else {
5259 i.lastCtxInput = 0
5260 }
5261 // Snap to the tail again — the swap brought in a fresh
5262 // transcript whose markdown / chroma cost we don't want
5263 // blocking the redraw.
5264 if len(i.view.Messages) > initialResumeTailLimit {
5265 i.view.TailLimit = initialResumeTailLimit
5266 } else {
5267 i.view.TailLimit = 0
5268 }
5269 }
5270 i.invalidate()
5271 }()
5272}
5273
5274// scrollToLastTurn parks the viewport at the most recent user turn,
5275// or at the top if the transcript has no user messages. Used after
5276// resume so the user lands looking at where they left off.
5277func (i *Interactive) scrollToLastTurn(msgs []provider.Message) {
5278 if len(msgs) == 0 {
5279 i.mu.Lock()
5280 i.scrollOffset = 0
5281 i.mu.Unlock()
5282 return
5283 }
5284 // Find the last user message index.
5285 lastUser := -1
5286 turnNo, totalTurns := 0, 0
5287 for idx, m := range msgs {
5288 if m.Role == provider.RoleUser {
5289 totalTurns++
5290 lastUser = idx
5291 }
5292 }
5293 if lastUser < 0 {
5294 i.mu.Lock()
5295 i.scrollOffset = 0
5296 i.mu.Unlock()
5297 return
5298 }
5299 turnNo = totalTurns
5300
5301 cols := i.lastCols()
5302 chat, anchors := i.view.BuildWithAnchors(cols)
5303 var row int
5304 found := false
5305 for _, a := range anchors {
5306 if a.MessageIdx == lastUser {
5307 row = a.Row
5308 found = true
5309 break
5310 }
5311 }
5312 if !found {
5313 i.mu.Lock()
5314 i.scrollOffset = 0
5315 i.mu.Unlock()
5316 return
5317 }
5318
5319 chatLen := len(chat)
5320 page := i.chatPage()
5321 if page < 1 {
5322 page = 1
5323 }
5324 offset := chatLen - (row + page)
5325 if offset < 0 {
5326 offset = 0
5327 }
5328 maxOffset := chatLen - page
5329 if maxOffset < 0 {
5330 maxOffset = 0
5331 }
5332 if offset > maxOffset {
5333 offset = maxOffset
5334 }
5335
5336 i.mu.Lock()
5337 i.scrollOffset = offset
5338 // Mark the parked-turn footer so the user sees "viewing turn N of
5339 // M - pgdn to catch up" — same affordance as /jump. Tells them at
5340 // a glance that they're looking at history, not the live tail.
5341 if offset > 0 {
5342 i.parkedTurn = turnNo
5343 i.parkedTotal = totalTurns
5344 }
5345 i.mu.Unlock()
5346 i.invalidate()
5347}
5348
5349func (i *Interactive) applyModelSelection(prov, model string) {
5350 i.swapModel(prov, model, i.cfg.BuildAgentFor, false)
5351}
5352
5353// applyRescueModelSelection is like applyModelSelection but routes
5354// through BuildAgentForRescue so launch-time --api-key / --base-url
5355// overrides are dropped before the new agent is built. Falls back to
5356// the regular builder when the host doesn't wire a rescue builder.
5357func (i *Interactive) applyRescueModelSelection(prov, model string) {
5358 builder := i.cfg.BuildAgentForRescue
5359 if builder == nil {
5360 builder = i.cfg.BuildAgentFor
5361 }
5362 i.swapModel(prov, model, builder, true)
5363}
5364
5365// swapModel applies a /model selection (or a rescue selection) using
5366// the supplied builder. rescue=true tags the success message so the
5367// user can see that launch-time overrides were ignored.
5368func (i *Interactive) swapModel(prov, model string, builder func(string, string) (*core.Agent, string, string, error), rescue bool) {
5369 if model == "" {
5370 return
5371 }
5372 m, err := provider.FindModel(prov, model)
5373 if err != nil {
5374 i.mu.Lock()
5375 i.statusErr = err.Error()
5376 i.mu.Unlock()
5377 return
5378 }
5379 // Same provider AND not a rescue retry: just swap the model on
5380 // the existing agent. Mixed-API providers dispatch from model metadata,
5381 // so the client remains reusable. Rescue retries always rebuild so a stale
5382 // auth header / base URL can't carry over.
5383 if !rescue && i.agent != nil && m.Provider == i.cfg.Provider {
5384 i.mu.Lock()
5385 i.cfg.Model = m.ID
5386 i.agent.Model = m.ID
5387 i.statusOK = "model: " + m.ID
5388 i.statusErr = ""
5389 i.mu.Unlock()
5390 if i.cfg.PersistModel != nil {
5391 i.cfg.PersistModel(i.cfg.Provider, m.ID)
5392 }
5393 return
5394 }
5395 if builder == nil {
5396 i.mu.Lock()
5397 i.statusErr = "cannot switch provider: no builder configured"
5398 i.mu.Unlock()
5399 return
5400 }
5401 // Snapshot the current transcript and cumulative usage BEFORE we
5402 // build the replacement agent so we can hand them off. Without
5403 // this the user perceives the entire session as wiped on a
5404 // cross-provider /model swap.
5405 var carryMsgs []provider.Message
5406 var carryCost provider.Usage
5407 if i.agent != nil {
5408 carryMsgs = i.agent.Messages()
5409 carryCost = i.agent.Cost()
5410 }
5411
5412 ag, p, md, err := builder(m.Provider, m.ID)
5413 if err != nil {
5414 i.mu.Lock()
5415 i.statusErr = err.Error()
5416 i.mu.Unlock()
5417 return
5418 }
5419
5420 // Replay the transcript and seed the cost on the freshly-built
5421 // agent. Messages travel cleanly between providers because they
5422 // use the same provider.Message shape; tool-call ids are local
5423 // to a turn so cross-provider continuation never confuses the
5424 // new model (it just sees the assistant's reply, no orphan
5425 // tool_use blocks because /model swaps are gated to idle state).
5426 if len(carryMsgs) > 0 {
5427 ag.SetMessages(carryMsgs)
5428 }
5429 ag.SeedCost(carryCost)
5430
5431 i.mu.Lock()
5432 i.agent = ag
5433 i.cfg.Provider = p
5434 i.cfg.Model = md
5435 if rescue {
5436 i.statusOK = "rescue retry: switched to " + p + " / " + md + " (ignored --api-key / --base-url)"
5437 } else {
5438 i.statusOK = "switched to " + p + " / " + md
5439 }
5440 i.statusErr = ""
5441 // Render cache keys are width+content based, so the new agent's
5442 // identical messages will reuse the existing entries. Nothing
5443 // to invalidate.
5444 i.mu.Unlock()
5445 // The new agent was built off the base tool registry, so any
5446 // dynamically-registered tools need to be reattached. The apply
5447 // helpers are no-ops when their feature is inactive, so the
5448 // cross-provider path still works on a vanilla setup.
5449 i.applyAutoSwarmTool(i.autoSwarmEnabled())
5450 i.applyOpenRouterServerTools(i.openRouterServerToolsEnabled())
5451 i.applyTelegramTools(i.telegramBridge != nil && i.telegramBridge.Active())
5452 if i.cfg.PersistModel != nil {
5453 i.cfg.PersistModel(p, md)
5454 }
5455}
5456
5457func (i *Interactive) handleAuthEvent(ev auth.Event) {
5458 switch ev.Kind {
5459 case "started":
5460 i.dialog.ShowWaiting(ev.URL)
5461 case "browser_open":
5462 // no-op
5463 case "error":
5464 i.dialog.ShowResult(false, ev.Message)
5465 case "success":
5466 // Rebuild the agent with the fresh credential.
5467 ag, prov, model, err := i.cfg.BuildAgent()
5468 if err != nil {
5469 i.dialog.ShowResult(false, err.Error())
5470 return
5471 }
5472 i.mu.Lock()
5473 i.agent = ag
5474 i.cfg.Provider = prov
5475 i.cfg.Model = model
5476 i.statusErr = ""
5477 i.statusOK = "logged in to " + ev.Provider + " via " + ev.Method
5478 i.mu.Unlock()
5479 i.applyAutoSwarmTool(i.autoSwarmEnabled())
5480 i.applyOpenRouterServerTools(i.openRouterServerToolsEnabled())
5481 i.applyTelegramTools(i.telegramBridge != nil && i.telegramBridge.Active())
5482 i.dialog.ShowResult(true, "")
5483 }
5484}
5485
5486// clearPendingCompactTurnLocked drops work that only makes sense after the
5487// current compaction. The caller must hold i.mu.
5488func (i *Interactive) clearPendingCompactTurnLocked() {
5489 i.pendingCompactPrompt = ""
5490 i.pendingCompactImages = nil
5491 i.hasPendingCompactPrompt = false
5492 i.continueAfterCompact = false
5493 i.pendingPostCompactNote = ""
5494}
5495
5496// runCompact invokes core.Agent.Compact and reflects the progress in
5497// the tui. It runs in a goroutine so the ui stays responsive; esc/ctrl+c
5498// cancel via the same cancelTurn channel used for normal turns.
5499//
5500// When auto is true the spinner message is pinned to "condensing
5501// history" and the status bar surfaces "(auto)" next to the context
5502// percentage so it's obvious the system triggered this, not the user.
5503func (i *Interactive) runCompact(parent context.Context, auto bool) {
5504 if i.agent == nil {
5505 i.mu.Lock()
5506 i.statusErr = "not logged in. type /login first."
5507 i.mu.Unlock()
5508 return
5509 }
5510 ctx, cancel := context.WithCancel(parent)
5511 i.mu.Lock()
5512 i.busy = true
5513 i.compacting = true
5514 if auto {
5515 i.spin.StartFixed("condensing history")
5516 i.autoCompacting = true
5517 } else {
5518 i.spin.StartFixed("compacting")
5519 }
5520 i.cancelTurn = cancel
5521 i.statusErr = ""
5522 i.statusOK = ""
5523 // Do NOT set streamOn: the summary text should not be visible
5524 // in the chat while compacting. The user just sees the spinner
5525 // and can keep typing / queue prompts.
5526 i.scrollOffset = 0
5527 i.helpBlock = nil
5528 i.mu.Unlock()
5529 i.invalidate()
5530
5531 go func() {
5532 // Sink discards deltas — we don't stream the summary to the UI.
5533 sink := func(delta string) {}
5534 summary, err := i.agent.Compact(ctx, 4, sink)
5535 _ = summary
5536 i.mu.Lock()
5537 i.busy = false
5538 i.compacting = false
5539 i.resetStreamingStateLocked()
5540 i.cancelTurn = nil
5541 i.autoCompacting = false
5542
5543 // Drain pending work after the transcript is clean. Overflow recovery
5544 // continues the user message already in the transcript. Pre-turn
5545 // compaction preserves its complete text-and-images request. Regular
5546 // prompts typed during compaction remain in the host queue.
5547 var next string
5548 var nextImages []provider.ImageBlock
5549 var hasNext bool
5550 var continueExisting bool
5551
5552 switch {
5553 case err != nil && ctx.Err() != nil:
5554 i.statusErr = ""
5555 if auto {
5556 i.statusOK = "auto-condense cancelled"
5557 } else {
5558 i.statusOK = "compaction cancelled"
5559 }
5560 i.queued = nil // drop queue on cancel
5561 i.clearPendingCompactTurnLocked()
5562 if i.agent != nil {
5563 i.agent.DrainQueuedMessages()
5564 }
5565 case err != nil:
5566 i.statusErr = "compaction failed: " + err.Error()
5567 i.statusOK = ""
5568 i.queued = nil // drop queue on error
5569 i.clearPendingCompactTurnLocked()
5570 if i.agent != nil {
5571 i.agent.DrainQueuedMessages()
5572 }
5573 default:
5574 i.statusErr = ""
5575 // Read token count from the compaction message meta.
5576 tokens := ""
5577 msgs := i.agent.Messages()
5578 if len(msgs) > 0 && msgs[0].Meta["compaction"] == "true" {
5579 tokens = msgs[0].Meta["tokens_before"]
5580 }
5581 switch {
5582 case i.pendingPostCompactNote != "":
5583 i.statusOK = i.pendingPostCompactNote
5584 case tokens != "":
5585 i.statusOK = fmt.Sprintf("compacted from ~%s tokens (ctrl+o to expand)", tokens)
5586 default:
5587 i.statusOK = "compacted (ctrl+o to expand)"
5588 }
5589 i.pendingPostCompactNote = ""
5590 i.extNotes = stripAutoCompactNotes(i.extNotes)
5591 i.lastCtxInput = estimateTimelineMessageTokens(msgs)
5592 i.toolCalls = map[string]*tui.ToolCallView{}
5593 i.toolOrder = nil
5594 i.toolGate = map[string]int{}
5595 i.view.InvalidateRenderCache()
5596 switch {
5597 case i.continueAfterCompact:
5598 continueExisting = true
5599 i.continueAfterCompact = false
5600 case i.hasPendingCompactPrompt:
5601 next = i.pendingCompactPrompt
5602 nextImages = i.pendingCompactImages
5603 i.pendingCompactPrompt = ""
5604 i.pendingCompactImages = nil
5605 i.hasPendingCompactPrompt = false
5606 hasNext = true
5607 case len(i.queued) > 0:
5608 next, i.queued = i.queued[0], i.queued[1:]
5609 hasNext = true
5610 }
5611 // A prompt resumed immediately after compaction must not trigger
5612 // the pre-turn threshold guard again before fresh provider usage
5613 // replaces this estimate.
5614 if hasNext {
5615 i.lastCtxInput = 0
5616 }
5617 }
5618 i.mu.Unlock()
5619 i.invalidate()
5620
5621 if hasNext || continueExisting {
5622 p := i.runCtx
5623 if p == nil {
5624 p = context.Background()
5625 }
5626 if continueExisting {
5627 i.startTurnRequest(p, "", nil, true)
5628 } else {
5629 i.startTurnWithImages(p, next, nextImages)
5630 }
5631 }
5632 }()
5633}
5634
5635// shellEscapeCommand reports whether text is a "!command" shell
5636// escape and, if so, returns the command with the leading '!' (and
5637// surrounding whitespace) stripped. A bare "!" with no command is
5638// treated as not an escape so it falls through to the normal prompt
5639// path rather than running an empty shell.
5640func shellEscapeCommand(text string) (string, bool) {
5641 return ShellEscapeCommand(text)
5642}
5643
5644// ShellEscapeCommand reports whether text is a "!command" shell
5645// escape and, if so, returns the command with the leading '!' (and
5646// surrounding whitespace) stripped. A bare "!" with no command is
5647// treated as not an escape so it falls through to the normal prompt
5648// path rather than running an empty shell.
5649func ShellEscapeCommand(text string) (string, bool) {
5650 trimmed := strings.TrimLeft(text, " \t")
5651 if !strings.HasPrefix(trimmed, "!") {
5652 return "", false
5653 }
5654 cmd := strings.TrimSpace(strings.TrimPrefix(trimmed, "!"))
5655 if cmd == "" {
5656 return "", false
5657 }
5658 return cmd, true
5659}
5660
5661// startShellEscape runs a "!command" in the same shell the bash tool
5662// uses, in the session working directory, honoring the /jail sandbox.
5663// It shares the busy/cancel state with the agent: esc cancels it, and
5664// it refuses to start while a turn or another shell escape is already
5665// in flight. Its terminal-log output is appended to the transcript as
5666// user context without automatically starting a model turn.
5667func (i *Interactive) startShellEscape(parent context.Context, cmd string) {
5668 i.mu.Lock()
5669 if i.busy || i.shellRunning {
5670 i.statusErr = "busy — wait for the current turn to finish before running a shell command"
5671 i.statusOK = ""
5672 i.mu.Unlock()
5673 i.invalidate()
5674 return
5675 }
5676 if parent == nil {
5677 parent = i.runCtx
5678 }
5679 if parent == nil {
5680 parent = context.Background()
5681 }
5682 ctx, cancel := context.WithCancel(parent)
5683 i.busy = true
5684 i.shellRunning = true
5685 i.shellLive = "$ " + cmd + "\n\n"
5686 i.cancelTurn = cancel
5687 i.statusErr = ""
5688 i.statusOK = ""
5689 i.spin.StartFixed("running shell command")
5690 // Clear stale extension notes the same way a new turn would so the
5691 // screen doesn't accumulate transient state.
5692 i.extNotes = nil
5693 i.scrollOffset = 0
5694 i.parkedTurn = 0
5695 i.parkedTotal = 0
5696 i.helpBlock = nil
5697 sandbox := i.cfg.Sandbox
5698 cwd := i.cfg.CWD
5699 i.mu.Unlock()
5700 i.invalidate()
5701
5702 go func() {
5703 defer cancel()
5704 raw, _ := json.Marshal(map[string]any{"command": cmd})
5705 bash := &tools.BashTool{CWD: cwd, Sandbox: sandbox}
5706 progress := func(chunk string) {
5707 i.mu.Lock()
5708 i.shellLive += chunk
5709 i.mu.Unlock()
5710 i.invalidate()
5711 }
5712 res, err := bash.Execute(ctx, raw, progress)
5713
5714 var out string
5715 if err != nil {
5716 out = "$ " + cmd + "\n\n" + err.Error() + "\n\n[error]"
5717 } else {
5718 for _, c := range res.Content {
5719 if tb, ok := c.(provider.TextBlock); ok {
5720 out += tb.Text
5721 }
5722 }
5723 }
5724 cancelled := ctx.Err() != nil
5725 failed := err != nil || res.IsError || cancelled
5726 if cancelled {
5727 out += "\n\n[cancelled]"
5728 }
5729
5730 if i.agent != nil {
5731 i.agent.AppendUserContext(out, map[string]string{shellEscapeMetaKey: "true"})
5732 }
5733
5734 i.mu.Lock()
5735 i.shellRunning = false
5736 i.shellLive = ""
5737 i.busy = false
5738 i.cancelTurn = nil
5739 awaitingPre := i.awaitingStartupPre
5740 if failed {
5741 if cancelled {
5742 i.statusErr = "shell command cancelled"
5743 } else {
5744 i.statusErr = "shell command failed"
5745 }
5746 i.statusOK = ""
5747 } else {
5748 i.statusOK = "shell command finished"
5749 i.statusErr = ""
5750 }
5751 i.mu.Unlock()
5752 i.invalidate()
5753 if awaitingPre {
5754 i.completeStartupPre()
5755 }
5756 }()
5757}
5758
5759func (i *Interactive) startTurn(parent context.Context, prompt string) {
5760 i.startTurnWithImages(parent, prompt, nil)
5761}
5762
5763func (i *Interactive) startTurnWithImages(parent context.Context, prompt string, images []provider.ImageBlock) {
5764 i.startTurnRequest(parent, prompt, images, false)
5765}
5766
5767func (i *Interactive) startTurnRequest(parent context.Context, prompt string, images []provider.ImageBlock, overflowRecoveryAttempted bool) {
5768 if i.agent == nil {
5769 // Text startup pre cannot run without credentials; continue so
5770 // deferred InitialInput (pre-fill or auto-submit) still applies.
5771 i.mu.Lock()
5772 awaitingPre := i.awaitingStartupPre
5773 i.mu.Unlock()
5774 if awaitingPre {
5775 i.completeStartupPre()
5776 }
5777 return
5778 }
5779 // Pre-turn safety: if the most recent context measurement is
5780 // already past the auto-compact threshold, condense before
5781 // sending so the next outbound request stays under the limit.
5782 // The condense flow re-fires the user's queued prompt for us, so
5783 // we just hand it off and exit.
5784 i.mu.Lock()
5785 needsPreCompact := !overflowRecoveryAttempted && !i.autoCompacting && i.shouldAutoCompactLocked()
5786 if needsPreCompact {
5787 i.pendingCompactPrompt = prompt
5788 i.pendingCompactImages = append([]provider.ImageBlock(nil), images...)
5789 i.hasPendingCompactPrompt = true
5790 i.statusErr = ""
5791 i.extNotes = append(i.extNotes, autoCompactNoteLine(i.cfg.Theme, "context near limit — condensing history before sending..."))
5792 i.pendingPostCompactNote = "context auto-compacted; sending your last message"
5793 i.mu.Unlock()
5794 i.invalidate()
5795 i.runCompact(parent, true)
5796 return
5797 }
5798 i.mu.Unlock()
5799
5800 ctx, cancel := context.WithCancel(parent)
5801 i.mu.Lock()
5802 i.busy = true
5803 i.spin.Start()
5804 i.cancelTurn = cancel
5805 i.statusErr = ""
5806 i.statusOK = ""
5807 i.streaming.Reset()
5808 i.streamOn = true
5809 i.toolCalls = map[string]*tui.ToolCallView{}
5810 i.toolOrder = nil
5811 i.toolGate = map[string]int{}
5812 i.extNotes = nil // ext notes are one-shot; a new prompt clears them
5813 i.scrollOffset = 0 // jump back to the bottom on new turn
5814 // Lift the resume tail cap once the user starts interacting. The
5815 // cap is purely a first-paint optimization (don't markdown the
5816 // whole history before showing anything). Keeping it active during
5817 // a turn makes the rendered chat a sliding window: appended
5818 // messages push older ones off the TOP of the buffer, which the
5819 // renderer must treat as a change above the viewport and repaint
5820 // fully, snapping the terminal's native scrollback to the bottom on
5821 // every streamed chunk. A fresh session has no cap (append-only),
5822 // which is why the jump only shows up in resumed sessions. Dropping
5823 // the cap here makes resumed turns append-only too.
5824 i.view.TailLimit = 0
5825 // Reset the auto-follow baseline so the very next render at
5826 // interactive.go:1053 doesn't see a synthetic shrink between
5827 // "last frame had the previous turn's tool overlay" and
5828 // "this frame had it cleared above". Without this, the guard
5829 // reads delta = -(rows in cleared overlay) and decrements
5830 // scrollOffset, which on terminals that mirror zot's pane
5831 // scroll into the host scrollbar visibly yanks the viewport.
5832 // See autofollow_shrink_test.go for the exact arithmetic.
5833 i.prevChatLen = 0
5834 i.prevChatCols = 0
5835 i.parkedTurn = 0 // starting a turn clears the /jump parked state
5836 i.parkedTotal = 0
5837 i.helpBlock = nil // hide the help block once the user asks something
5838 i.mu.Unlock()
5839 i.invalidate()
5840
5841 sink := func(ev core.AgentEvent) {
5842 i.handleEvent(ev)
5843 i.invalidate()
5844 }
5845
5846 go func() {
5847 var err error
5848 if overflowRecoveryAttempted {
5849 err = i.agent.Continue(ctx, sink)
5850 } else {
5851 err = i.agent.Prompt(ctx, prompt, images, sink)
5852 }
5853 i.mu.Lock()
5854 i.busy = false
5855 // Don't touch streamPending / streamFlushPending here — the
5856 // pacer may still be draining the final deltas and needs to
5857 // paint them even though Prompt has returned. It will reset
5858 // streamOn on its own once the buffer empties.
5859 if len(i.streamPending) == 0 {
5860 i.streamOn = false
5861 }
5862 i.cancelTurn = nil
5863 if err != nil && ctx.Err() == nil {
5864 i.statusErr = err.Error()
5865 }
5866 // Decide whether to offer a model rescue picker for recoverable
5867 // provider failures (auth/rate/temporary). The picker opens after
5868 // the mutex is released so it can take its own locks freely.
5869 var (
5870 offer bool
5871 rescueWhy string
5872 rescueImgs []provider.ImageBlock
5873 rescueModel string
5874 rescueProv string
5875 rescueFprov string
5876 )
5877 if err != nil && ctx.Err() == nil {
5878 if ok, reason := classifyRescueError(err); ok {
5879 offer = true
5880 rescueWhy = reason
5881 rescueImgs = images
5882 rescueModel = i.cfg.Model
5883 rescueProv = i.cfg.Provider
5884 rescueFprov = extractFailedProvider(err)
5885 if rescueFprov == "" {
5886 rescueFprov = i.cfg.Provider
5887 }
5888 // Suppress the red banner — the rescue dialog already
5889 // surfaces the failure.
5890 i.statusErr = ""
5891 }
5892 }
5893 // Detect responses that reject the current context, either as an
5894 // HTTP 413 payload limit or a model context-window error. Token-
5895 // based auto-compact can miss both when metadata is stale or the
5896 // limit is measured in raw bytes. Compact once, then continue the
5897 // user message already present in the transcript.
5898 contextOverflow := err != nil && ctx.Err() == nil && isContextOverflowError(err)
5899 recoverContextOverflow := contextOverflow && !overflowRecoveryAttempted
5900 if recoverContextOverflow {
5901 i.statusErr = ""
5902 i.continueAfterCompact = true
5903 i.extNotes = append(i.extNotes, autoCompactNoteLine(i.cfg.Theme, "request was too large. condensing history before retrying ..."))
5904 i.pendingPostCompactNote = "context auto-compacted; retrying your last message"
5905 }
5906 // Persist the assistant's reply (and every tool row before
5907 // it) to the session file while the turn memory is hot.
5908 // Without this, WriteNewTranscript only fires at zot exit,
5909 // meaning a crash or ungraceful kill drops the whole
5910 // conversation. FlushSession is idempotent (it advances the
5911 // baseline so subsequent flushes only write new rows).
5912 flush := i.cfg.FlushSession
5913 i.mu.Unlock()
5914 if flush != nil {
5915 flush()
5916 }
5917 i.mu.Lock()
5918 awaitingPre := i.awaitingStartupPre
5919 // Pop the next queued message, if any, and relaunch.
5920 var next string
5921 var hasNext bool
5922 if !awaitingPre && len(i.queued) > 0 && ctx.Err() == nil && err == nil {
5923 next, i.queued = i.queued[0], i.queued[1:]
5924 hasNext = true
5925 }
5926 // If the turn was cancelled or errored, drop the queue so the
5927 // user isn't bombarded with stale messages after an interrupt.
5928 if ctx.Err() != nil || (err != nil && !recoverContextOverflow) {
5929 i.queued = nil
5930 if i.agent != nil {
5931 i.agent.DrainQueuedMessages()
5932 }
5933 }
5934 // Decide whether the next thing to do is an auto-compaction.
5935 // Only fires when the turn completed cleanly AND no host-side
5936 // or agent-side queued messages are waiting (otherwise a queued
5937 // message would race the condense).
5938 agentQueued := 0
5939 if i.agent != nil {
5940 agentQueued = i.agent.QueuedMessageCount()
5941 }
5942 shouldAutoCompact := !awaitingPre && !hasNext && agentQueued == 0 && err == nil && ctx.Err() == nil && i.shouldAutoCompactLocked()
5943 i.mu.Unlock()
5944 i.invalidate()
5945 parent := i.runCtx
5946 if parent == nil {
5947 parent = context.Background()
5948 }
5949 if awaitingPre {
5950 i.completeStartupPre()
5951 return
5952 }
5953 switch {
5954 case hasNext:
5955 i.startTurn(parent, next)
5956 case offer:
5957 i.openRescueDialog(rescueProv, rescueFprov, rescueModel, rescueWhy, prompt, rescueImgs)
5958 case recoverContextOverflow:
5959 i.runCompact(parent, true)
5960 case shouldAutoCompact:
5961 i.runCompact(parent, true)
5962 }
5963 }()
5964}
5965
5966// openRescueDialog surfaces the rescue model picker after a recoverable
5967// provider failure. The pending prompt + images are stashed on the
5968// Interactive so a later applyRescueSelection can re-run the same turn
5969// against the freshly-picked model. activeProvider/failedProvider are
5970// usually the same, but some clients embed different prefixes in their
5971// errors than the configured provider id, so we accept both.
5972func (i *Interactive) openRescueDialog(activeProvider, failedProvider, failedModel, reason, prompt string, images []provider.ImageBlock) {
5973 if i.rescueDialog == nil {
5974 return
5975 }
5976 loggedIn := []string{}
5977 if i.cfg.LoggedInProviders != nil {
5978 loggedIn = i.cfg.LoggedInProviders()
5979 }
5980 fprov := failedProvider
5981 if fprov == "" {
5982 fprov = activeProvider
5983 }
5984 i.mu.Lock()
5985 i.pendingRescuePrompt = prompt
5986 i.pendingRescueImages = images
5987 i.mu.Unlock()
5988 i.rescueDialog.Open(failedModel, loggedIn, fprov, failedModel, reason, prompt)
5989 i.invalidate()
5990}
5991
5992// applyRescueSelection switches model (cross-provider if needed) and
5993// re-runs the same prompt+images that just failed. Mirrors
5994// applyModelSelection's transcript-carry logic so the user keeps full
5995// session continuity across the swap.
5996func (i *Interactive) applyRescueSelection(prov, model, prompt string) {
5997 if model == "" {
5998 return
5999 }
6000 i.applyRescueModelSelection(prov, model)
6001 i.mu.Lock()
6002 images := i.pendingRescueImages
6003 if prompt == "" {
6004 prompt = i.pendingRescuePrompt
6005 }
6006 i.pendingRescuePrompt = ""
6007 i.pendingRescueImages = nil
6008 i.mu.Unlock()
6009 parent := i.runCtx
6010 if parent == nil {
6011 parent = context.Background()
6012 }
6013 i.startTurnWithImages(parent, prompt, images)
6014}
6015
6016func stripAutoCompactNotes(notes []string) []string {
6017 if len(notes) == 0 {
6018 return notes
6019 }
6020 out := notes[:0]
6021 for _, n := range notes {
6022 if strings.Contains(n, "condensing history") {
6023 continue
6024 }
6025 out = append(out, n)
6026 }
6027 return out
6028}
6029
6030// autoCompactNoteLine returns a styled chat-area note for the
6031// inline auto-compact heads-up. Lives in extNotes so it survives
6032// the busy-spinner overwrite of the status row.
6033func autoCompactNoteLine(th tui.Theme, msg string) string {
6034 return " " + th.FG256(th.Warning, "⚠ "+msg)
6035}
6036
6037// isContextOverflowError matches provider responses that reject the
6038// current request because its raw payload or tokenized input is too
6039// large. Providers surface this as either HTTP 413 or semantic context
6040// errors with differing codes and messages.
6041func isContextOverflowError(err error) bool {
6042 if err == nil {
6043 return false
6044 }
6045 msg := strings.ToLower(err.Error())
6046 return strings.Contains(msg, "http 413") ||
6047 strings.Contains(msg, " 413") ||
6048 strings.HasPrefix(msg, "413 ") ||
6049 strings.Contains(msg, "payload too large") ||
6050 strings.Contains(msg, "request entity too large") ||
6051 strings.Contains(msg, "input exceeds the context window") ||
6052 strings.Contains(msg, "context window exceeded") ||
6053 strings.Contains(msg, "maximum context length") ||
6054 strings.Contains(msg, "context_length_exceeded") ||
6055 (strings.Contains(msg, "input") && strings.Contains(msg, "exceeds the maximum number of tokens"))
6056}
6057
6058const defaultAutoCompactThreshold = 85
6059
6060func normalizeAutoCompactThreshold(threshold *int) int {
6061 if threshold == nil {
6062 return defaultAutoCompactThreshold
6063 }
6064 switch *threshold {
6065 case 0, 70, 80, 85, 90:
6066 return *threshold
6067 default:
6068 return defaultAutoCompactThreshold
6069 }
6070}
6071
6072func shouldAutoCompact(inputTokens, contextWindow, thresholdPercent int) bool {
6073 if inputTokens <= 0 || contextWindow <= 0 || thresholdPercent <= 0 {
6074 return false
6075 }
6076 return float64(inputTokens)/float64(contextWindow) >= float64(thresholdPercent)/100
6077}
6078
6079// shouldAutoCompactLocked reports whether the last turn pushed context
6080// usage past the auto-compact threshold. Must be called with i.mu
6081// held; it reads lastCtxInput and the current model's context window.
6082func (i *Interactive) shouldAutoCompactLocked() bool {
6083 if i.agent == nil {
6084 return false
6085 }
6086 if i.autoCompacting {
6087 return false
6088 }
6089 m, err := provider.FindModel(i.cfg.Provider, i.cfg.Model)
6090 if err != nil || m.ContextWindow <= 0 {
6091 return false
6092 }
6093 threshold := normalizeAutoCompactThreshold(i.cfg.AutoCompactThreshold)
6094 return shouldAutoCompact(i.lastCtxInput, m.ContextWindow, threshold)
6095}
6096
6097func (i *Interactive) handleEvent(ev core.AgentEvent) {
6098 i.mu.Lock()
6099 defer i.mu.Unlock()
6100 switch e := ev.(type) {
6101 case core.EvAssistantStart:
6102 // Fires at the top of every oneTurn, including follow-up
6103 // turns after tool use. Without this, the streaming buffer
6104 // is still marked off from the previous assistant message
6105 // and the final summary text pops in all at once instead
6106 // of typewriter-streaming delta by delta.
6107 i.streaming.Reset()
6108 i.streamPending = i.streamPending[:0]
6109 i.streamFlushPending = false
6110 i.streamOn = true
6111 // Clear the live tool-call overlay. Any tools from the
6112 // previous round are now fully folded into the transcript
6113 // (assistant tool_use block + tool role message with the
6114 // result), so keeping them in the overlay would duplicate
6115 // them in the view — once inside the finalised transcript
6116 // and once below the streaming block, with the streaming
6117 // summary sandwiched in between. The next EvToolUseStart
6118 // will populate fresh entries for this turn's tools.
6119 i.toolCalls = map[string]*tui.ToolCallView{}
6120 i.toolOrder = nil
6121 i.toolGate = map[string]int{}
6122 case core.EvTextDelta:
6123 // Buffer into streamPending; the paintPace ticker drains
6124 // it into i.streaming a few runes at a time for a smooth
6125 // typewriter effect independent of upstream chunk size.
6126 i.streamPending = append(i.streamPending, []rune(e.Delta)...)
6127 i.streamOn = true
6128 case core.EvAssistantMessage:
6129 // OnAssistant + telegram mirroring always fire on message
6130 // arrival — they read the FINAL message content, which is
6131 // complete regardless of what's still in the pacer.
6132 i.assistantMessageSideEffects(e.Message)
6133 // If the pacer still has characters to drain, keep streamOn
6134 // true and mark flush pending; the paintPace ticker will
6135 // drain the remainder and reset streaming state when done.
6136 // Otherwise (rare: full-replay sessions, abort paths) clear
6137 // synchronously so a later render doesn't show stale text.
6138 if len(i.streamPending) > 0 {
6139 i.streamFlushPending = true
6140 return
6141 }
6142 i.resetStreamingStateLocked()
6143 case core.EvToolUseStart:
6144 // Live streaming: pre-create the view so the user sees the
6145 // tool call being composed in real time. Any subsequent
6146 // EvToolCall for the same ID updates the same struct (the
6147 // final parsed args + name are already known here).
6148 if _, exists := i.toolCalls[e.ID]; !exists {
6149 i.toolCalls[e.ID] = &tui.ToolCallView{
6150 ID: e.ID,
6151 Name: e.Name,
6152 Streaming: true,
6153 }
6154 i.toolOrder = append(i.toolOrder, e.ID)
6155 i.gateToolLocked(e.ID)
6156 }
6157 case core.EvToolUseArgs:
6158 if tc, ok := i.toolCalls[e.ID]; ok {
6159 tc.RawJSONBuf += e.Delta
6160 // Refresh the live path as soon as it parses; used in
6161 // the header (write /Users/pat/Desktop/demo.ts)
6162 // while the content is still streaming.
6163 if p, pok, _ := tui.ExtractPartialStringField(tc.RawJSONBuf, "path"); pok {
6164 tc.LivePath = p
6165 } else if p, pok, _ := tui.ExtractPartialStringField(tc.RawJSONBuf, "file_path"); pok {
6166 tc.LivePath = p
6167 }
6168 }
6169 case core.EvToolUseEnd:
6170 if tc, ok := i.toolCalls[e.ID]; ok {
6171 tc.Streaming = false
6172 }
6173 case core.EvToolCall:
6174 // If we already pre-created the view during streaming, just
6175 // refresh the final Args summary. Otherwise create a new one
6176 // (non-streaming providers or legacy paths).
6177 if tc, ok := i.toolCalls[e.ID]; ok {
6178 tc.Args = tui.ShortArgs(e.Name, e.Args)
6179 if tc.RawJSONBuf == "" {
6180 tc.RawJSONBuf = string(e.Args)
6181 }
6182 tc.Streaming = false
6183 } else {
6184 i.toolCalls[e.ID] = &tui.ToolCallView{
6185 ID: e.ID,
6186 Name: e.Name,
6187 Args: tui.ShortArgs(e.Name, e.Args),
6188 RawJSONBuf: string(e.Args),
6189 }
6190 i.toolOrder = append(i.toolOrder, e.ID)
6191 i.gateToolLocked(e.ID)
6192 }
6193 case core.EvToolResult:
6194 if tc, ok := i.toolCalls[e.ID]; ok {
6195 tc.Done = true
6196 tc.Error = e.Result.IsError
6197 tc.Preview = ""
6198 var text strings.Builder
6199 for _, c := range e.Result.Content {
6200 if tb, ok := c.(provider.TextBlock); ok {
6201 if text.Len() > 0 {
6202 text.WriteString("\n")
6203 }
6204 text.WriteString(tb.Text)
6205 }
6206 }
6207 tc.Result = text.String()
6208 }
6209 if i.cfg.OnToolResult != nil {
6210 i.cfg.OnToolResult(e.ID, e.Result)
6211 }
6212 case core.EvUsage:
6213 i.cumUsage = e.Cumulative
6214 if e.Usage.InputTokens > 0 {
6215 i.lastCtxInput = e.Usage.InputTokens + e.Usage.CacheReadTokens + e.Usage.CacheWriteTokens
6216 }
6217 case core.EvTurnEnd:
6218 if e.Stop == provider.StopAborted {
6219 i.resetStreamingStateLocked()
6220 i.statusErr = ""
6221 i.statusOK = "cancelled"
6222 return
6223 }
6224 if e.Stop == provider.StopLength {
6225 // The model hit its output-token cap mid-response, so the
6226 // reply (often a long write/edit) is truncated. Surface it
6227 // explicitly, otherwise the turn just ends and reads like
6228 // the UI gave up. The agent already requests the model's
6229 // full MaxOutput budget, so this means the response genuinely
6230 // exceeded that ceiling; ask the user to continue.
6231 i.statusErr = "response hit the model's output-token limit and was cut off, ask it to continue"
6232 i.statusOK = ""
6233 return
6234 }
6235 // Don't surface mid-loop stream errors as a red banner here.
6236 // EvTurnEnd fires after every step in a multi-step tool loop,
6237 // so a transient 503 / network blip would briefly paint a red
6238 // banner over the still-streaming chat before the agent loop
6239 // either retries or exits. The final error (if any) is set by
6240 // startTurnWithImages once Prompt() returns, and recoverable
6241 // failures are routed to the rescue picker instead — which
6242 // keeps the chat clean while the agent is working.
6243 _ = e.Err
6244 }
6245}
6246
6247// Agent returns the current agent, if any. Used by cli.go to flush the
6248// final transcript to the session file.
6249func (i *Interactive) Agent() *core.Agent {
6250 i.mu.Lock()
6251 defer i.mu.Unlock()
6252 return i.agent
6253}
6254
6255// silence unused import in some build configs
6256var _ = fmt.Sprintf
6257
6258// runReloadExt triggers a live reload of every extension (discovered
6259// + explicit). Runs on a goroutine so the TUI stays responsive; the
6260// Manager.Reload takes a couple of hundred ms to shut down subprocs
6261// and respawn them. Shows a temporary status line throughout.
6262func (i *Interactive) runReloadExt(ctx context.Context) {
6263 if i.cfg.Extensions == nil {
6264 i.mu.Lock()
6265 i.statusErr = "no extension manager in this build"
6266 i.mu.Unlock()
6267 i.invalidate()
6268 return
6269 }
6270 i.mu.Lock()
6271 i.statusOK = "reloading extensions..."
6272 i.statusErr = ""
6273 i.mu.Unlock()
6274 i.invalidate()
6275
6276 go func() {
6277 stats := i.cfg.Extensions.Reload(ctx, 2*time.Second)
6278 msg, failed := formatReloadStatus(stats)
6279 seq := i.setReloadStatus(msg, failed)
6280 i.invalidate()
6281 go i.dismissReloadStatus(ctx, seq, msg, failed)
6282 }()
6283}
6284
6285// Confirm implements core.Confirmer. The agent goroutine calls
6286// this synchronously before every tool invocation when --no-yolo is
6287// active. We push the request onto the confirmDialog queue, trigger
6288// a redraw, and block the caller until the user answers.
6289//
6290// If the session is cancelled or the TUI exits mid-prompt, any
6291// pending request is refused via CancelAll so the agent doesn't
6292// deadlock.
6293func (i *Interactive) Confirm(toolName string, preview string) core.ConfirmDecision {
6294 return i.ConfirmToolCall(core.ToolCallConfirmation{Name: toolName, Summary: preview})
6295}
6296
6297// ConfirmToolCall attaches a side-effect-free preview to the matching live
6298// tool panel, then blocks until the user approves or refuses the call.
6299func (i *Interactive) ConfirmToolCall(call core.ToolCallConfirmation) core.ConfirmDecision {
6300 resp := make(chan core.ConfirmDecision, 1)
6301 returnToChild := false
6302 if call.ID != "" {
6303 i.mu.Lock()
6304 if tc, ok := i.toolCalls[call.ID]; ok {
6305 tc.Args = call.Summary
6306 tc.Preview = call.Content
6307 }
6308 i.mu.Unlock()
6309 if i.btwDialog != nil && i.btwDialog.Active() {
6310 returnToChild = i.btwDialog.SetToolPreview(call.ID, call.Summary, call.Content)
6311 }
6312 }
6313 i.confirmDialog.Enqueue(&confirmRequest{
6314 toolName: call.Name,
6315 preview: call.Summary,
6316 resp: resp,
6317 returnToChild: returnToChild,
6318 })
6319 i.invalidate()
6320 return <-resp
6321}
6322
6323// openTelegramDialog shows the picker for `/telegram` with no arg.
6324// Items depend on current state: disconnect + status when running,
6325// connect + status when stopped.
6326func (i *Interactive) openTelegramDialog() {
6327 items := i.telegramMenuItems()
6328 if len(items) == 0 {
6329 i.mu.Lock()
6330 i.statusErr = "telegram not configured. run `zot telegram-bot setup` first."
6331 i.mu.Unlock()
6332 i.invalidate()
6333 return
6334 }
6335 i.telegramDialog.Open(items)
6336 i.invalidate()
6337}
6338
6339// telegramMenuItems builds the dialog entries for the current
6340// bridge state. Returns empty when no bot.json exists so the
6341// caller can show a helpful status line instead of an empty menu.
6342func (i *Interactive) telegramMenuItems() []telegramItem {
6343 cfg, err := telegram.LoadConfig(i.cfg.ZotHome)
6344 if err != nil || cfg.BotToken == "" {
6345 return nil
6346 }
6347 var items []telegramItem
6348 if i.telegramBridge != nil && i.telegramBridge.Active() {
6349 items = append(items, telegramItem{label: "disconnect", action: "disconnect", hint: "stop mirroring"})
6350 st := i.telegramBridge.State()
6351 hint := "active"
6352 if st.Username != "" {
6353 hint += " as @" + st.Username
6354 }
6355 items = append(items, telegramItem{label: "status", action: "status", hint: hint})
6356 } else {
6357 label := "connect"
6358 hint := "start mirroring dms into this session"
6359 if cfg.AllowedUserID == 0 {
6360 hint = "awaiting pairing (send /start to the bot once connected)"
6361 }
6362 items = append(items, telegramItem{label: label, action: "connect", hint: hint})
6363 items = append(items, telegramItem{label: "status", action: "status", hint: "disconnected"})
6364 }
6365 return items
6366}
6367
6368// doTelegram dispatches one of the three explicit actions. Called
6369// from /telegram <action> or after the picker selects a row.
6370func (i *Interactive) doTelegram(action string) {
6371 switch action {
6372 case "connect":
6373 i.telegramConnect()
6374 case "disconnect":
6375 i.telegramDisconnect()
6376 case "status":
6377 i.telegramStatus()
6378 default:
6379 i.mu.Lock()
6380 i.statusErr = "unknown telegram action: " + action + " (use connect, disconnect, or status)"
6381 i.mu.Unlock()
6382 i.invalidate()
6383 }
6384}
6385
6386// telegramConnect starts the bridge. Refuses if it's already
6387// running or if the on-disk bot.json is missing a token.
6388func (i *Interactive) telegramConnect() {
6389 if i.telegramBridge != nil && i.telegramBridge.Active() {
6390 i.mu.Lock()
6391 i.statusOK = "telegram already connected"
6392 i.statusErr = ""
6393 i.mu.Unlock()
6394 i.invalidate()
6395 return
6396 }
6397 cfg, err := telegram.LoadConfig(i.cfg.ZotHome)
6398 if err != nil {
6399 i.mu.Lock()
6400 i.statusErr = "telegram: " + err.Error()
6401 i.mu.Unlock()
6402 i.invalidate()
6403 return
6404 }
6405 if cfg.BotToken == "" {
6406 i.mu.Lock()
6407 i.statusErr = "telegram: no bot token configured. run `zot telegram-bot setup` first."
6408 i.mu.Unlock()
6409 i.invalidate()
6410 return
6411 }
6412 // Refuse to start when a background daemon is already polling
6413 // the same bot. Two concurrent long-poll consumers race each
6414 // update and one always loses, so DMs get half-delivered. The
6415 // user can `zot telegram-bot stop` first, then /telegram connect.
6416 if pid, alive, _ := telegram.IsRunning(i.cfg.ZotHome); alive && pid > 0 {
6417 i.mu.Lock()
6418 i.statusErr = fmt.Sprintf("telegram: bot daemon already running (pid %d). stop it with `zot telegram-bot stop` first.", pid)
6419 i.mu.Unlock()
6420 i.invalidate()
6421 return
6422 }
6423 i.telegramBridge = &telegram.Bridge{
6424 Client: telegram.NewClient(cfg.BotToken),
6425 Config: cfg,
6426 Save: func(next telegram.Config) error {
6427 return telegram.SaveConfig(i.cfg.ZotHome, next)
6428 },
6429 Host: &telegramHost{iv: i},
6430 }
6431 if err := i.telegramBridge.Start(i.runCtx); err != nil {
6432 i.telegramBridge = nil
6433 i.mu.Lock()
6434 i.statusErr = "telegram connect failed: " + err.Error()
6435 i.mu.Unlock()
6436 i.invalidate()
6437 return
6438 }
6439 i.applyTelegramTools(true)
6440 state := i.telegramBridge.State()
6441 label := "telegram connected"
6442 if state.Username != "" {
6443 label += " as @" + state.Username
6444 }
6445 if state.PairedID == 0 {
6446 label += " — send /start to the bot from your phone to claim it"
6447 }
6448 i.mu.Lock()
6449 i.statusOK = label
6450 i.statusErr = ""
6451 i.mu.Unlock()
6452 i.invalidate()
6453}
6454
6455// telegramDisconnect stops the bridge. No-op when already stopped.
6456func (i *Interactive) telegramDisconnect() {
6457 if i.telegramBridge == nil || !i.telegramBridge.Active() {
6458 i.mu.Lock()
6459 i.statusOK = "telegram already disconnected"
6460 i.statusErr = ""
6461 i.mu.Unlock()
6462 i.invalidate()
6463 return
6464 }
6465 i.telegramBridge.Stop()
6466 i.applyTelegramTools(false)
6467 i.mu.Lock()
6468 i.statusOK = "telegram disconnected"
6469 i.statusErr = ""
6470 i.mu.Unlock()
6471 i.invalidate()
6472}
6473
6474// telegramSenderAdapter wraps the bridge so the tools package can
6475// drive it without importing telegram directly. The Active() check
6476// is forwarded to the bridge so the tool can fail clearly with a
6477// model-readable error when the user disconnected mid-turn.
6478type telegramSenderAdapter struct {
6479 bridge *telegram.Bridge
6480}
6481
6482func (a telegramSenderAdapter) SendImage(ctx context.Context, path, caption string) error {
6483 if a.bridge == nil {
6484 return fmt.Errorf("telegram bridge is not connected")
6485 }
6486 return a.bridge.SendImage(ctx, path, caption)
6487}
6488
6489func (a telegramSenderAdapter) SendDocument(ctx context.Context, path, caption string) error {
6490 if a.bridge == nil {
6491 return fmt.Errorf("telegram bridge is not connected")
6492 }
6493 return a.bridge.SendDocument(ctx, path, caption)
6494}
6495
6496func (a telegramSenderAdapter) Active() bool {
6497 return a.bridge != nil && a.bridge.Active()
6498}
6499
6500// swarmWatchEntry is one tracked auto-swarm sub-agent. It records the
6501// delegated turn's outcome separately from the long-lived daemon status.
6502type swarmWatchEntry struct {
6503 agent *swarm.Agent
6504 task string
6505 done bool
6506 outcome string
6507 err string
6508}
6509
6510// TrackSwarmAgent is the exported entry point used by the cli to
6511// hand a freshly-spawned auto-swarm agent off to the watcher.
6512func (i *Interactive) TrackSwarmAgent(a *swarm.Agent, task string) {
6513 i.trackSwarmAgent(a, task)
6514}
6515
6516// trackSwarmAgent records a freshly-spawned auto-swarm agent and
6517// subscribes to both ways its delegated task can finish. Successful
6518// long-lived agents report a prompt-level turn_end and keep listening;
6519// startup failures and unexpected daemon exits only unblock Agent.Wait.
6520// Whichever signal arrives first completes the entry exactly once.
6521//
6522// Wired in from cli.go via SwarmSpawnTool.OnSpawned only when auto-
6523// swarm is enabled, so this is a no-op when the feature is off.
6524func (i *Interactive) trackSwarmAgent(a *swarm.Agent, task string) {
6525 if i == nil || a == nil {
6526 return
6527 }
6528 entry := &swarmWatchEntry{agent: a, task: task}
6529 i.swarmWatchMu.Lock()
6530 i.swarmWatch = append(i.swarmWatch, entry)
6531 i.swarmWatchMu.Unlock()
6532
6533 a.SetOnTurnEnd(func(step int, errMsg string) {
6534 outcome := "completed"
6535 if errMsg != "" {
6536 outcome = "failed"
6537 }
6538 i.completeSwarmWatchEntry(entry, outcome, errMsg)
6539 })
6540 go func() {
6541 a.Wait()
6542 snap := a.Snapshot()
6543 outcome := string(snap.Status)
6544 if snap.Status == swarm.StatusDone {
6545 outcome = "completed"
6546 }
6547 i.completeSwarmWatchEntry(entry, outcome, snap.Err)
6548 }()
6549}
6550
6551func (i *Interactive) completeSwarmWatchEntry(entry *swarmWatchEntry, outcome, errMsg string) {
6552 i.swarmWatchMu.Lock()
6553 if entry.done {
6554 i.swarmWatchMu.Unlock()
6555 return
6556 }
6557 entry.done = true
6558 entry.outcome = outcome
6559 entry.err = errMsg
6560 allDone := true
6561 for _, e := range i.swarmWatch {
6562 if !e.done {
6563 allDone = false
6564 break
6565 }
6566 }
6567 var batch []*swarmWatchEntry
6568 if allDone {
6569 batch = i.swarmWatch
6570 i.swarmWatch = nil
6571 }
6572 i.swarmWatchMu.Unlock()
6573 if len(batch) != 0 {
6574 i.flushSwarmSummary(batch)
6575 }
6576}
6577
6578// flushSwarmSummary composes a synthetic user turn describing every
6579// sub-agent's outcome and injects it via SubmitOrQueue so the main
6580// agent picks it up at the next safe boundary. The summary is
6581// phrased as a system update ("Auto-swarm finished: ...") so the
6582// model treats it as observed state, not as a fresh user request.
6583func (i *Interactive) flushSwarmSummary(batch []*swarmWatchEntry) {
6584 if len(batch) == 0 {
6585 return
6586 }
6587 var sb strings.Builder
6588 fmt.Fprintf(&sb, "[auto-swarm update] %d sub-agent(s) finished:\n\n", len(batch))
6589 for idx, e := range batch {
6590 snap := e.agent.Snapshot()
6591 status := e.outcome
6592 if status == "" {
6593 status = string(snap.Status)
6594 }
6595 task := snap.Task
6596 if task == "" {
6597 task = e.task
6598 }
6599 fmt.Fprintf(&sb, "%d. agent %s - status: %s\n", idx+1, snap.ID, status)
6600 fmt.Fprintf(&sb, " task: %s\n", truncateForSummary(task, 240))
6601 if snap.Err != "" {
6602 fmt.Fprintf(&sb, " error: %s\n", truncateForSummary(snap.Err, 240))
6603 } else if e.err != "" {
6604 fmt.Fprintf(&sb, " turn error: %s\n", truncateForSummary(e.err, 240))
6605 }
6606 if snap.LastAssistant != "" {
6607 sb.WriteString(" final response:\n")
6608 sb.WriteString(snap.LastAssistant)
6609 sb.WriteString("\n")
6610 } else if tail := strings.TrimSpace(snap.Tail); tail != "" {
6611 fmt.Fprintf(&sb, " tail: %s\n", truncateForSummary(tail, 600))
6612 }
6613 sb.WriteString("\n")
6614 }
6615 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.")
6616 i.SubmitOrQueue(sb.String(), nil)
6617}
6618
6619func truncateForSummary(s string, n int) string {
6620 s = strings.TrimSpace(s)
6621 if len(s) <= n {
6622 return s
6623 }
6624 return s[:n-3] + "..."
6625}
6626
6627// applyAutoSwarmSystemPrompt appends (active=true) or strips
6628// (active=false) the auto-swarm system-prompt block on the running
6629// agent so the model proactively considers swarm_spawn when the user
6630// flips the toggle. The block lives at the tail of agent.System so
6631// stripping is a plain suffix-trim; idempotent in both directions.
6632func (i *Interactive) applyAutoSwarmSystemPrompt(active bool) {
6633 if i.agent == nil {
6634 return
6635 }
6636 addendum := i.cfg.AutoSwarmSystemAddendum
6637 if addendum == "" {
6638 return
6639 }
6640 sys := i.agent.System
6641 has := strings.Contains(sys, addendum)
6642 switch {
6643 case active && !has:
6644 if sys != "" && !strings.HasSuffix(sys, "\n\n") {
6645 sys += "\n\n"
6646 }
6647 i.agent.System = sys + addendum
6648 case !active && has:
6649 i.agent.System = strings.TrimRight(strings.ReplaceAll(sys, addendum, ""), "\n") + "\n"
6650 }
6651}
6652
6653// applyAutoSwarmTool registers (active=true) or removes (active=false)
6654// the swarm_spawn tool on the running agent so the model only sees it
6655// when /settings -> auto-swarm is enabled. Mirrors applyTelegramTools'
6656// snapshot+mutate pattern so extension tools and /reload-ext additions
6657// survive a toggle.
6658func (i *Interactive) autoSwarmEnabled() bool {
6659 return i.cfg.AutoSwarmEnabled != nil && *i.cfg.AutoSwarmEnabled
6660}
6661
6662func (i *Interactive) openRouterServerToolsEnabled() bool {
6663 return i.cfg.OpenRouterServerToolsEnabled != nil && *i.cfg.OpenRouterServerToolsEnabled
6664}
6665
6666func (i *Interactive) applyOpenRouterServerTools(active bool) {
6667 if i.agent == nil {
6668 return
6669 }
6670 current := i.agent.Tools
6671 next := core.Registry{}
6672 for name, t := range current {
6673 if tools.IsOpenRouterServerTool(name) {
6674 continue
6675 }
6676 next[name] = t
6677 }
6678 i.agent.MaxToolCalls = 0
6679 if active && !i.cfg.NoTools && i.cfg.Provider == "openrouter" && !strings.Contains(strings.ToLower(i.cfg.Model), "@preset/") {
6680 for _, t := range tools.OpenRouterServerTools() {
6681 next[t.Name()] = t
6682 }
6683 i.agent.MaxToolCalls = tools.OpenRouterServerToolCallLimit
6684 }
6685 i.agent.SetTools(next)
6686}
6687
6688func (i *Interactive) applyAutoSwarmTool(active bool) {
6689 if i.agent == nil {
6690 return
6691 }
6692 current := i.agent.Tools
6693 next := core.Registry{}
6694 for name, t := range current {
6695 if name == "swarm_spawn" {
6696 continue
6697 }
6698 next[name] = t
6699 }
6700 if active && i.cfg.Swarm != nil {
6701 next["swarm_spawn"] = &tools.SwarmSpawnTool{
6702 Swarm: i.cfg.Swarm,
6703 Enabled: func() bool { return true },
6704 DefaultModel: func() string { return i.cfg.Model },
6705 DefaultProvider: func() string { return i.cfg.Provider },
6706 OnSpawned: i.trackSwarmAgent,
6707 }
6708 }
6709 i.agent.SetTools(next)
6710}
6711
6712// applyTelegramTools registers (active=true) or removes (active=false)
6713// the telegram_send_image and telegram_send_file tools on the running
6714// agent so the model only sees them while the bridge is connected.
6715// Snapshots and mutates the live tool registry so any extension or
6716// /reload-ext additions made while Telegram is connected survive a
6717// later /telegram disconnect (we only add or strip the two telegram
6718// entries, never the rest).
6719func (i *Interactive) applyTelegramTools(active bool) {
6720 if i.agent == nil {
6721 return
6722 }
6723 current := i.agent.Tools
6724 next := core.Registry{}
6725 for name, t := range current {
6726 if name == "telegram_send_image" || name == "telegram_send_file" {
6727 continue
6728 }
6729 next[name] = t
6730 }
6731 if active {
6732 sender := telegramSenderAdapter{bridge: i.telegramBridge}
6733 next["telegram_send_image"] = &tools.TelegramSendImageTool{
6734 CWD: i.cfg.CWD, Sandbox: i.cfg.Sandbox, Sender: sender,
6735 }
6736 next["telegram_send_file"] = &tools.TelegramSendFileTool{
6737 CWD: i.cfg.CWD, Sandbox: i.cfg.Sandbox, Sender: sender,
6738 }
6739 }
6740 i.agent.SetTools(next)
6741}
6742
6743// telegramStatus writes a one-liner describing the bridge state.
6744// Reports on both the in-tui bridge and the background daemon so
6745// the user isn't confused when the daemon owns the poll loop.
6746func (i *Interactive) telegramStatus() {
6747 var msg string
6748 if i.telegramBridge != nil && i.telegramBridge.Active() {
6749 s := i.telegramBridge.State()
6750 msg = "telegram: connected (tui bridge)"
6751 if s.Username != "" {
6752 msg += " as @" + s.Username
6753 }
6754 if s.PairedID != 0 {
6755 msg += fmt.Sprintf(" - paired with user %d", s.PairedID)
6756 } else {
6757 msg += " - awaiting pairing"
6758 }
6759 } else if pid, alive, _ := telegram.IsRunning(i.cfg.ZotHome); alive && pid > 0 {
6760 msg = fmt.Sprintf("telegram: background daemon running (pid %d) - /telegram connect won't work until you stop it", pid)
6761 } else {
6762 cfg, _ := telegram.LoadConfig(i.cfg.ZotHome)
6763 if cfg.BotToken == "" {
6764 msg = "telegram: not configured. run `zot telegram-bot setup` first."
6765 } else {
6766 msg = "telegram: disconnected"
6767 if cfg.BotUsername != "" {
6768 msg += " (@" + cfg.BotUsername + " ready to connect)"
6769 }
6770 }
6771 }
6772 i.mu.Lock()
6773 i.statusOK = msg
6774 i.statusErr = ""
6775 i.mu.Unlock()
6776 i.invalidate()
6777}
6778
6779// telegramHost adapts *Interactive to telegram.Host so the bridge
6780// can call back into the TUI without importing modes directly.
6781type telegramHost struct{ iv *Interactive }
6782
6783func (h *telegramHost) SubmitOrQueue(prompt string, images []provider.ImageBlock) {
6784 h.iv.SubmitOrQueue(prompt, images)
6785}
6786
6787func (h *telegramHost) CancelTurn() { h.iv.CancelTurn() }
6788
6789func (h *telegramHost) Status() string {
6790 h.iv.mu.Lock()
6791 providerName := h.iv.cfg.Provider
6792 model := h.iv.cfg.Model
6793 cwd := h.iv.cfg.CWD
6794 usage := h.iv.cumUsage
6795 subscription := h.iv.cfg.AuthMethod == "oauth"
6796 ctxUsed := h.iv.lastCtxInput
6797 busy := h.iv.busy
6798 queued := len(h.iv.queued)
6799 h.iv.mu.Unlock()
6800
6801 ctxMax := 0
6802 if m, err := provider.FindModel(providerName, model); err == nil {
6803 ctxMax = m.ContextWindow
6804 }
6805 return telegram.FormatStatus(telegram.StatusSnapshot{
6806 Provider: providerName,
6807 Model: model,
6808 CWD: cwd,
6809 Usage: usage,
6810 Subscription: subscription,
6811 ContextUsed: ctxUsed,
6812 ContextMax: ctxMax,
6813 Busy: busy,
6814 Queued: queued,
6815 })
6816}
6817
6818func (h *telegramHost) Notify(level, message string) {
6819 h.iv.mu.Lock()
6820 switch level {
6821 case "error", "warn":
6822 h.iv.statusErr = message
6823 h.iv.statusOK = ""
6824 default:
6825 h.iv.statusOK = message
6826 h.iv.statusErr = ""
6827 }
6828 h.iv.mu.Unlock()
6829 h.iv.invalidate()
6830}
6831
6832// openSessionOpsDialog shows the picker for `/session` with no arg.
6833// Always offers timeline, export, import, fork, and tree. The handlers bail
6834// with a clear status message when a precondition is not met (empty transcript
6835// on fork; no parent/siblings on tree).
6836func (i *Interactive) openSessionOpsDialog() {
6837 items := []sessionOpsItem{
6838 {label: "timeline", action: "timeline", hint: "inspect context, messages, and tool calls"},
6839 {label: "export", action: "export", hint: "write the current session to a .zotsession file"},
6840 {label: "import", action: "import", hint: "load a .zotsession file into this directory"},
6841 {label: "fork", action: "fork", hint: "branch from a past user message into a new session"},
6842 {label: "tree", action: "tree", hint: "switch between branches in this directory"},
6843 }
6844 i.sessionOpsDialog.Open(items)
6845 i.invalidate()
6846}
6847
6848// doSessionOp dispatches timeline, export, import, fork, or tree. arg is the
6849// optional positional argument from e.g. /session export <path>
6850// or /session import <path>; timeline, fork, and tree ignore it.
6851func (i *Interactive) doSessionOp(action, arg string) {
6852 switch action {
6853 case "timeline":
6854 i.openTimeline()
6855 case "export":
6856 i.doSessionExport(arg)
6857 case "import":
6858 i.doSessionImport(arg)
6859 case "fork":
6860 i.doSessionFork()
6861 case "tree":
6862 i.doSessionTree()
6863 default:
6864 i.mu.Lock()
6865 i.statusErr = "unknown /session action: " + action + " (use timeline, export, import, fork, or tree)"
6866 i.mu.Unlock()
6867 i.invalidate()
6868 }
6869}
6870
6871func (i *Interactive) openTimeline() {
6872 i.mu.Lock()
6873 if i.timeline == nil {
6874 i.timeline = newTimelineView()
6875 }
6876 i.timeline.Open(i.timelineDataLocked())
6877 i.chatCacheValid = false
6878 i.scrollOffset = 0
6879 i.statusErr = ""
6880 i.statusOK = ""
6881 i.mu.Unlock()
6882 i.invalidate()
6883}
6884
6885// doSessionExport writes the live session file to destination path
6886// dst. When dst is empty we default to ~/Downloads (falling back to
6887// the user's home directory if it doesn't exist). The helper
6888// expands a leading `~` and creates any missing parent directories.
6889func (i *Interactive) doSessionExport(dst string) {
6890 if i.cfg.CurrentSessionPath == nil {
6891 i.mu.Lock()
6892 i.statusErr = "export: no session is active (running with --no-session?)"
6893 i.mu.Unlock()
6894 i.invalidate()
6895 return
6896 }
6897 src := i.cfg.CurrentSessionPath()
6898 if src == "" {
6899 i.mu.Lock()
6900 i.statusErr = "export: no session is active (running with --no-session?)"
6901 i.mu.Unlock()
6902 i.invalidate()
6903 return
6904 }
6905 // Persist any in-memory agent messages to the session file so
6906 // the export carries the full conversation. Without this, the
6907 // default lazy-flush-at-exit strategy leaves most of a running
6908 // session unwritten and the export ends up with only the meta.
6909 if i.cfg.FlushSession != nil {
6910 i.cfg.FlushSession()
6911 }
6912 dst = unquotePath(dst)
6913 if dst == "" {
6914 dst = defaultExportDir()
6915 } else {
6916 dst = expandTilde(dst)
6917 }
6918 out, err := core.ExportSession(src, dst)
6919 if err != nil {
6920 i.mu.Lock()
6921 i.statusErr = "export: " + err.Error()
6922 i.mu.Unlock()
6923 i.invalidate()
6924 return
6925 }
6926 i.mu.Lock()
6927 i.statusOK = "exported session to " + friendlyPath(out)
6928 i.statusErr = ""
6929 i.mu.Unlock()
6930 i.invalidate()
6931}
6932
6933// doSessionImport copies the .zotsession file at src into the
6934// running cwd's sessions directory and loads it as the active
6935// session, same as `/sessions` -> pick. When src is empty we ask
6936// the user to pass a path (no usable default here).
6937func (i *Interactive) doSessionImport(src string) {
6938 src = unquotePath(src)
6939 if src == "" {
6940 i.mu.Lock()
6941 i.statusErr = "import: pass a path — e.g. /session import ~/Downloads/work.zotsession"
6942 i.mu.Unlock()
6943 i.invalidate()
6944 return
6945 }
6946 src = expandTilde(src)
6947 if _, err := os.Stat(src); err != nil {
6948 i.mu.Lock()
6949 i.statusErr = "import: " + err.Error()
6950 i.mu.Unlock()
6951 i.invalidate()
6952 return
6953 }
6954 newPath, err := core.ImportSession(src, i.sessionsRoot(), i.cfg.CWD, i.cfg.Version)
6955 if err != nil {
6956 i.mu.Lock()
6957 i.statusErr = "import: " + err.Error()
6958 i.mu.Unlock()
6959 i.invalidate()
6960 return
6961 }
6962 if i.cfg.LoadSession == nil {
6963 i.mu.Lock()
6964 i.statusOK = "imported session at " + friendlyPath(newPath) + " (run /sessions to resume it)"
6965 i.statusErr = ""
6966 i.mu.Unlock()
6967 i.invalidate()
6968 return
6969 }
6970 if err := i.cfg.LoadSession(newPath); err != nil {
6971 i.mu.Lock()
6972 i.statusErr = "import: load failed: " + err.Error()
6973 i.mu.Unlock()
6974 i.invalidate()
6975 return
6976 }
6977 i.mu.Lock()
6978 i.statusOK = "imported and switched to session " + friendlyPath(newPath)
6979 i.statusErr = ""
6980 i.mu.Unlock()
6981 i.invalidate()
6982}
6983
6984// defaultExportDir returns ~/Downloads when it exists, or ~ as a
6985// fallback, or /tmp on exotic machines with no home dir.
6986func defaultExportDir() string {
6987 home, err := os.UserHomeDir()
6988 if err != nil || home == "" {
6989 return os.TempDir()
6990 }
6991 downloads := filepath.Join(home, "Downloads")
6992 if fi, err := os.Stat(downloads); err == nil && fi.IsDir() {
6993 return downloads
6994 }
6995 return home
6996}
6997
6998// expandTilde turns a leading ~ into the user's home directory.
6999// Returns the input unchanged when there's no tilde or no home.
7000func expandTilde(p string) string {
7001 if p == "" || p[0] != '~' {
7002 return p
7003 }
7004 home, err := os.UserHomeDir()
7005 if err != nil || home == "" {
7006 return p
7007 }
7008 if len(p) == 1 {
7009 return home
7010 }
7011 if p[1] == '/' || p[1] == filepath.Separator {
7012 return filepath.Join(home, p[2:])
7013 }
7014 return p
7015}
7016
7017// unquotePath strips a matching pair of surrounding single or
7018// double quotes. Drag-drop paste in the tui auto-quotes dropped
7019// file paths so the shell-like `/session import 'foo bar.zs'`
7020// stays well-formed; when the TUI's own slash handler consumes
7021// the arg, we want the raw path back.
7022func unquotePath(p string) string {
7023 p = strings.TrimSpace(p)
7024 if len(p) >= 2 {
7025 first := p[0]
7026 last := p[len(p)-1]
7027 if (first == '\'' && last == '\'') || (first == '"' && last == '"') {
7028 p = p[1 : len(p)-1]
7029 }
7030 }
7031 return p
7032}
7033
7034// friendlyPath collapses the user's home directory to a leading ~
7035// so status messages read cleanly. Falls back to the raw path when
7036// the home dir is unknown.
7037func friendlyPath(p string) string {
7038 home, err := os.UserHomeDir()
7039 if err != nil || home == "" {
7040 return p
7041 }
7042 if strings.HasPrefix(p, home+string(filepath.Separator)) {
7043 return "~" + p[len(home):]
7044 }
7045 return p
7046}
7047
7048// doSessionFork opens the /jump turn picker in "fork mode". The
7049// next selection branches the current session at that user turn
7050// instead of scrolling the viewport to it.
7051func (i *Interactive) doSessionFork() {
7052 if i.cfg.CurrentSessionPath == nil || i.cfg.CurrentSessionPath() == "" {
7053 i.mu.Lock()
7054 i.statusErr = "fork: no session is active (running with --no-session?)"
7055 i.mu.Unlock()
7056 i.invalidate()
7057 return
7058 }
7059 msgs := []provider.Message{}
7060 if i.agent != nil {
7061 msgs = i.agent.Messages()
7062 }
7063 if len(msgs) == 0 {
7064 i.mu.Lock()
7065 i.statusErr = "fork: transcript is empty; nothing to fork from"
7066 i.mu.Unlock()
7067 i.invalidate()
7068 return
7069 }
7070 i.pendingFork = true
7071 i.jumpDialog.Open(msgs, "")
7072 i.invalidate()
7073}
7074
7075// doSessionTree shows the current session family as an inline branch tree.
7076// Pick an entry to check out that point into a new branch. Subsequent
7077// prompts use only context up to the selected message.
7078func (i *Interactive) doSessionTree() {
7079 current := ""
7080 if i.cfg.CurrentSessionPath != nil {
7081 current = i.cfg.CurrentSessionPath()
7082 }
7083 if current != "" && i.sessionTreeDialog.OpenSessionFamily(i.sessionsRoot(), i.cfg.CWD, current) {
7084 i.invalidate()
7085 return
7086 }
7087 msgs := []provider.Message{}
7088 if i.agent != nil {
7089 msgs = i.agent.Messages()
7090 }
7091 if len(msgs) == 0 {
7092 i.mu.Lock()
7093 i.statusErr = "tree: no messages in this session yet"
7094 i.mu.Unlock()
7095 i.invalidate()
7096 return
7097 }
7098 if !i.sessionTreeDialog.OpenMessages(msgs) {
7099 i.mu.Lock()
7100 i.statusErr = "tree: no messages to show"
7101 i.mu.Unlock()
7102 }
7103 i.invalidate()
7104}
7105
7106// applySessionTreeMessageSelection checks out the selected message into a
7107// new branch, then switches the running agent to that branch. Unlike /jump,
7108// this changes future model context: only messages up to the selected row are
7109// sent on subsequent turns.
7110func (i *Interactive) applySessionTreeMessageSelection(src string, msgIdx, turnNo int, role provider.Role, prompt string) {
7111 if i.cfg.CurrentSessionPath == nil || i.cfg.LoadSession == nil {
7112 i.mu.Lock()
7113 i.statusErr = "tree: session branching is not available in this build"
7114 i.mu.Unlock()
7115 i.invalidate()
7116 return
7117 }
7118 if src == "" {
7119 src = i.cfg.CurrentSessionPath()
7120 }
7121 if src == "" {
7122 i.mu.Lock()
7123 i.statusErr = "tree: no session is active"
7124 i.mu.Unlock()
7125 i.invalidate()
7126 return
7127 }
7128 if i.cfg.FlushSession != nil {
7129 i.cfg.FlushSession()
7130 }
7131 upTo := msgIdx + 1
7132 if role == provider.RoleUser {
7133 upTo = msgIdx
7134 }
7135 newPath, err := core.BranchSessionHidden(src, i.sessionsRoot(), i.cfg.CWD, i.cfg.Version, upTo)
7136 if err != nil {
7137 i.mu.Lock()
7138 i.statusErr = "tree: " + err.Error()
7139 i.mu.Unlock()
7140 i.invalidate()
7141 return
7142 }
7143 if err := i.cfg.LoadSession(newPath); err != nil {
7144 i.mu.Lock()
7145 i.statusErr = "tree: checkout failed: " + err.Error()
7146 i.mu.Unlock()
7147 i.invalidate()
7148 return
7149 }
7150 i.scrollToBottom()
7151 i.mu.Lock()
7152 if role == provider.RoleUser {
7153 i.ed.SetValue(prompt)
7154 i.statusOK = fmt.Sprintf("checked out before turn %d; edit and send to branch", turnNo)
7155 } else {
7156 i.statusOK = fmt.Sprintf("checked out turn %d into a new branch", turnNo)
7157 }
7158 i.statusErr = ""
7159 i.mu.Unlock()
7160 i.invalidate()
7161}
7162
7163// applyForkSelection branches the current session at msgIdx+1 (so
7164// the selected user message and everything before it is included
7165// in the new branch), then switches the running agent to the new
7166// file. Called from the jump-dialog handler when pendingFork=true.
7167func (i *Interactive) applyForkSelection(msgIdx int) {
7168 i.pendingFork = false
7169 if i.cfg.CurrentSessionPath == nil {
7170 i.mu.Lock()
7171 i.statusErr = "fork: no session is active"
7172 i.mu.Unlock()
7173 i.invalidate()
7174 return
7175 }
7176 src := i.cfg.CurrentSessionPath()
7177 if src == "" {
7178 i.mu.Lock()
7179 i.statusErr = "fork: no session is active"
7180 i.mu.Unlock()
7181 i.invalidate()
7182 return
7183 }
7184 if i.cfg.FlushSession != nil {
7185 i.cfg.FlushSession()
7186 }
7187 // msgIdx is 0-indexed message position; copy msgIdx+1 rows so
7188 // the selected user message is included.
7189 upTo := msgIdx + 1
7190 newPath, err := core.BranchSession(src, i.sessionsRoot(), i.cfg.CWD, i.cfg.Version, upTo)
7191 if err != nil {
7192 i.mu.Lock()
7193 i.statusErr = "fork: " + err.Error()
7194 i.mu.Unlock()
7195 i.invalidate()
7196 return
7197 }
7198 if i.cfg.LoadSession == nil {
7199 i.mu.Lock()
7200 i.statusOK = "forked at message " + formatInt(upTo) + " (run /sessions to resume)"
7201 i.statusErr = ""
7202 i.mu.Unlock()
7203 i.invalidate()
7204 return
7205 }
7206 if err := i.cfg.LoadSession(newPath); err != nil {
7207 i.mu.Lock()
7208 i.statusErr = "fork: switch failed: " + err.Error()
7209 i.mu.Unlock()
7210 i.invalidate()
7211 return
7212 }
7213 i.mu.Lock()
7214 i.statusOK = "forked and switched to new branch at " + friendlyPath(newPath)
7215 i.statusErr = ""
7216 i.mu.Unlock()
7217 i.invalidate()
7218}
7219
7220// formatInt is a tiny strconv.Itoa shim; keeps the handler above
7221// from needing a strconv import just for one call.
7222func formatInt(n int) string {
7223 return fmt.Sprintf("%d", n)
7224}
7225
7226// assistantText returns the concatenated text of every TextBlock in
7227// m. Used by the streaming-view dedupe guard to tell when a live
7228// streamed reply has already been promoted into the transcript.
7229func assistantText(m provider.Message) string {
7230 var sb strings.Builder
7231 for _, c := range m.Content {
7232 if tb, ok := c.(provider.TextBlock); ok {
7233 if sb.Len() > 0 {
7234 sb.WriteByte('\n')
7235 }
7236 sb.WriteString(tb.Text)
7237 }
7238 }
7239 return sb.String()
7240}
7241
7242// resetStreamingStateLocked clears every piece of streaming state
7243// in one shot. Used by abort paths (turn cancel, compact finish,
7244// queue drain) so the pacer doesn't keep draining stale runes from
7245// a prior turn. Must be called with i.mu held.
7246func (i *Interactive) resetStreamingStateLocked() {
7247 i.streaming.Reset()
7248 i.streamPending = i.streamPending[:0]
7249 i.streamFlushPending = false
7250 i.streamOn = false
7251 i.openAllToolGatesLocked()
7252}
7253
7254// openAllToolGatesLocked drops every pending tool gate so that any
7255// tool registered during this turn renders unconditionally from now
7256// on. Called when streaming finalizes (the paced text has fully
7257// drained and `streaming` is about to reset to length 0): without
7258// this, the gate comparison against a freshly-reset streaming buffer
7259// would wrongly re-hide tools that had already cleared their gate.
7260// Must be called with i.mu held.
7261func (i *Interactive) openAllToolGatesLocked() {
7262 for id := range i.toolGate {
7263 i.toolGate[id] = 0
7264 }
7265}
7266
7267// gateToolLocked records the stream position at which a tool call may
7268// become visible. The gate is the total length the streaming buffer
7269// will reach once the pacer has drained everything currently queued
7270// (already painted + still pending). Holding the tool block back
7271// until the pacer crosses that mark guarantees the prose emitted
7272// before the tool call finishes typing out above it, instead of the
7273// tool block snapping in while the paragraph is still filling in.
7274//
7275// We only gate while text is actively streaming. If no stream is in
7276// flight (gate 0), the tool shows immediately, which is the correct
7277// behaviour for tool-only turns and replayed sessions. First
7278// registration wins so a later EvToolCall can't move an existing
7279// gate. Must be called with i.mu held.
7280func (i *Interactive) gateToolLocked(id string) {
7281 if _, ok := i.toolGate[id]; ok {
7282 return
7283 }
7284 if !i.streamOn {
7285 i.toolGate[id] = 0
7286 return
7287 }
7288 i.toolGate[id] = i.streaming.Len() + len(i.streamPending)
7289}
7290
7291// toolGateOpenLocked reports whether the gated tool block may render
7292// yet, i.e. the pacer has drained enough text to reach the position
7293// recorded when the tool call arrived. Must be called with i.mu held.
7294func (i *Interactive) toolGateOpenLocked(id string) bool {
7295 gate, ok := i.toolGate[id]
7296 if !ok || gate == 0 {
7297 return true
7298 }
7299 return i.streaming.Len() >= gate
7300}
7301
7302// assistantMessageSideEffects runs the non-visual hooks attached to
7303// EvAssistantMessage: the host-provided OnAssistant callback and the
7304// telegram-bridge mirror. Called with i.mu held.
7305//
7306// Factored out of handleEvent because the streaming pacer may defer
7307// visual reset until after the last buffered rune has painted, but
7308// the callbacks themselves must fire on message arrival so
7309// downstream observers (session persistence, telegram, cost panels)
7310// don't wait on a UI animation to catch up.
7311func (i *Interactive) assistantMessageSideEffects(m provider.Message) {
7312 if i.cfg.OnAssistant != nil {
7313 i.cfg.OnAssistant(m)
7314 }
7315 if i.telegramBridge != nil && i.telegramBridge.Active() {
7316 var sb strings.Builder
7317 for _, c := range m.Content {
7318 if tb, ok := c.(provider.TextBlock); ok {
7319 if sb.Len() > 0 {
7320 sb.WriteString("\n")
7321 }
7322 sb.WriteString(tb.Text)
7323 }
7324 }
7325 if text := sb.String(); strings.TrimSpace(text) != "" {
7326 go i.telegramBridge.OnAssistantText(text)
7327 }
7328 }
7329}
7330
7331// paintPaceRate is how many runes the streaming pacer releases per
7332// tick. With a 16ms tick, 6 runes/tick is ~375 runes/s — fast enough
7333// that a 500-rune summary finishes in ~1.3s, slow enough to look
7334// like a human typing. Empirically matches the feel of provider
7335// paths that already drip-stream natively.
7336const paintPaceRate = 6
7337
7338// paintPaceInterval is the tick interval for the streaming pacer.
7339// 16ms lines up with the redraw throttle so we never paint faster
7340// than the terminal can keep up.
7341const paintPaceInterval = 16 * time.Millisecond
7342
7343// runStreamPacer drains buffered deltas from streamPending into
7344// streaming a small batch per tick, invalidating after each move.
7345// It stops when the context cancels (tui shutdown).
7346//
7347// Why a pacer: providers differ wildly in how they chunk their
7348// text_delta events. The API-key path on Anthropic emits ~30 drips
7349// for a 400-token summary; the OAuth path can coalesce the same
7350// summary into 3 fat chunks, visually indistinguishable from "the
7351// whole reply just appeared". The pacer normalizes that so every
7352// path looks the same on screen.
7353func (i *Interactive) runStreamPacer(ctx context.Context) {
7354 t := time.NewTicker(paintPaceInterval)
7355 defer t.Stop()
7356 for {
7357 select {
7358 case <-ctx.Done():
7359 return
7360 case <-t.C:
7361 i.mu.Lock()
7362 if len(i.streamPending) == 0 {
7363 // EvAssistantMessage already fired but the pacer
7364 // was still draining a tick ago. Everything is now
7365 // painted; clear the streaming flags so the next
7366 // redraw shows the finalised transcript message
7367 // and hides the streaming overlay.
7368 if i.streamFlushPending {
7369 i.streamFlushPending = false
7370 i.streaming.Reset()
7371 i.streamOn = false
7372 i.openAllToolGatesLocked()
7373 i.mu.Unlock()
7374 i.invalidate()
7375 continue
7376 }
7377 i.mu.Unlock()
7378 continue
7379 }
7380 n := paintPaceRate
7381 if n > len(i.streamPending) {
7382 n = len(i.streamPending)
7383 }
7384 i.streaming.WriteString(string(i.streamPending[:n]))
7385 i.streamPending = i.streamPending[n:]
7386 i.mu.Unlock()
7387 i.invalidate()
7388 }
7389 }
7390}