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