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