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