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