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