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