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