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