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