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