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.SeqClearScreen + 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 i.parkedTurn = 0 // starting a turn clears the /jump parked state
3171 i.parkedTotal = 0
3172 i.helpBlock = nil // hide the help block once the user asks something
3173 i.mu.Unlock()
3174 i.invalidate()
3175
3176 sink := func(ev core.AgentEvent) {
3177 i.handleEvent(ev)
3178 i.invalidate()
3179 }
3180
3181 go func() {
3182 err := i.agent.Prompt(ctx, prompt, images, sink)
3183 i.mu.Lock()
3184 i.busy = false
3185 // Don't touch streamPending / streamFlushPending here — the
3186 // pacer may still be draining the final deltas and needs to
3187 // paint them even though Prompt has returned. It will reset
3188 // streamOn on its own once the buffer empties.
3189 if len(i.streamPending) == 0 {
3190 i.streamOn = false
3191 }
3192 i.cancelTurn = nil
3193 if err != nil && ctx.Err() == nil {
3194 i.statusErr = err.Error()
3195 }
3196 // Decide whether to offer a model rescue picker for recoverable
3197 // provider failures (auth/rate/temporary). The picker opens after
3198 // the mutex is released so it can take its own locks freely.
3199 var (
3200 offer bool
3201 rescueWhy string
3202 rescueImgs []provider.ImageBlock
3203 rescueModel string
3204 rescueProv string
3205 rescueFprov string
3206 )
3207 if err != nil && ctx.Err() == nil {
3208 if ok, reason := classifyRescueError(err); ok {
3209 offer = true
3210 rescueWhy = reason
3211 rescueImgs = images
3212 rescueModel = i.cfg.Model
3213 rescueProv = i.cfg.Provider
3214 rescueFprov = extractFailedProvider(err)
3215 if rescueFprov == "" {
3216 rescueFprov = i.cfg.Provider
3217 }
3218 // Suppress the red banner — the rescue dialog already
3219 // surfaces the failure.
3220 i.statusErr = ""
3221 }
3222 }
3223 // Detect HTTP 413 "payload too large" responses. The provider
3224 // rejected the request because the request body exceeded its
3225 // per-request limit. Token-based auto-compact can miss this
3226 // because the limit is on raw bytes, not tokens. Re-queue the
3227 // prompt so it survives the condense pass and trigger one.
3228 payloadTooLarge := err != nil && ctx.Err() == nil && isPayloadTooLargeError(err)
3229 if payloadTooLarge {
3230 i.statusErr = ""
3231 i.queued = append([]string{prompt}, i.queued...)
3232 i.extNotes = append(i.extNotes, autoCompactNoteLine(i.cfg.Theme, "request was too large. condensing history before retrying ..."))
3233 i.pendingPostCompactNote = "context auto-compacted; retrying your last message"
3234 }
3235 // Persist the assistant's reply (and every tool row before
3236 // it) to the session file while the turn memory is hot.
3237 // Without this, WriteNewTranscript only fires at zot exit,
3238 // meaning a crash or ungraceful kill drops the whole
3239 // conversation. FlushSession is idempotent (it advances the
3240 // baseline so subsequent flushes only write new rows).
3241 flush := i.cfg.FlushSession
3242 i.mu.Unlock()
3243 if flush != nil {
3244 flush()
3245 }
3246 i.mu.Lock()
3247 // Pop the next queued message, if any, and relaunch.
3248 var next string
3249 var hasNext bool
3250 if len(i.queued) > 0 && ctx.Err() == nil && err == nil {
3251 next, i.queued = i.queued[0], i.queued[1:]
3252 hasNext = true
3253 }
3254 // If the turn was cancelled or errored, drop the queue so the
3255 // user isn't bombarded with stale messages after an interrupt.
3256 if ctx.Err() != nil || err != nil {
3257 i.queued = nil
3258 }
3259 // Decide whether the next thing to do is an auto-compaction.
3260 // Only fires when the turn completed cleanly AND the queue is
3261 // empty (otherwise a queued message would race the condense).
3262 shouldAutoCompact := !hasNext && err == nil && ctx.Err() == nil && i.shouldAutoCompactLocked()
3263 i.mu.Unlock()
3264 i.invalidate()
3265 parent := i.runCtx
3266 if parent == nil {
3267 parent = context.Background()
3268 }
3269 switch {
3270 case hasNext:
3271 i.startTurn(parent, next)
3272 case offer:
3273 i.openRescueDialog(rescueProv, rescueFprov, rescueModel, rescueWhy, prompt, rescueImgs)
3274 case payloadTooLarge:
3275 i.runCompact(parent, true)
3276 case shouldAutoCompact:
3277 i.runCompact(parent, true)
3278 }
3279 }()
3280}
3281
3282// openRescueDialog surfaces the rescue model picker after a recoverable
3283// provider failure. The pending prompt + images are stashed on the
3284// Interactive so a later applyRescueSelection can re-run the same turn
3285// against the freshly-picked model. activeProvider/failedProvider are
3286// usually the same, but some clients embed different prefixes in their
3287// errors than the configured provider id, so we accept both.
3288func (i *Interactive) openRescueDialog(activeProvider, failedProvider, failedModel, reason, prompt string, images []provider.ImageBlock) {
3289 if i.rescueDialog == nil {
3290 return
3291 }
3292 loggedIn := []string{}
3293 if i.cfg.LoggedInProviders != nil {
3294 loggedIn = i.cfg.LoggedInProviders()
3295 }
3296 fprov := failedProvider
3297 if fprov == "" {
3298 fprov = activeProvider
3299 }
3300 i.mu.Lock()
3301 i.pendingRescuePrompt = prompt
3302 i.pendingRescueImages = images
3303 i.mu.Unlock()
3304 i.rescueDialog.Open(failedModel, loggedIn, fprov, failedModel, reason, prompt)
3305 i.invalidate()
3306}
3307
3308// applyRescueSelection switches model (cross-provider if needed) and
3309// re-runs the same prompt+images that just failed. Mirrors
3310// applyModelSelection's transcript-carry logic so the user keeps full
3311// session continuity across the swap.
3312func (i *Interactive) applyRescueSelection(prov, model, prompt string) {
3313 if model == "" {
3314 return
3315 }
3316 i.applyRescueModelSelection(prov, model)
3317 i.mu.Lock()
3318 images := i.pendingRescueImages
3319 if prompt == "" {
3320 prompt = i.pendingRescuePrompt
3321 }
3322 i.pendingRescuePrompt = ""
3323 i.pendingRescueImages = nil
3324 i.mu.Unlock()
3325 parent := i.runCtx
3326 if parent == nil {
3327 parent = context.Background()
3328 }
3329 i.startTurnWithImages(parent, prompt, images)
3330}
3331
3332func stripAutoCompactNotes(notes []string) []string {
3333 if len(notes) == 0 {
3334 return notes
3335 }
3336 out := notes[:0]
3337 for _, n := range notes {
3338 if strings.Contains(n, "condensing history") {
3339 continue
3340 }
3341 out = append(out, n)
3342 }
3343 return out
3344}
3345
3346// autoCompactNoteLine returns a styled chat-area note for the
3347// inline auto-compact heads-up. Lives in extNotes so it survives
3348// the busy-spinner overwrite of the status row.
3349func autoCompactNoteLine(th tui.Theme, msg string) string {
3350 return " " + th.FG256(th.Warning, "⚠ "+msg)
3351}
3352
3353// isPayloadTooLargeError matches HTTP 413 responses surfaced by the
3354// provider clients. The error formatting differs slightly between
3355// providers (anthropic and openai both prepend the status code), so
3356// we look for the canonical 413 marker as well as the conventional
3357// 'payload too large' phrase.
3358func isPayloadTooLargeError(err error) bool {
3359 if err == nil {
3360 return false
3361 }
3362 msg := strings.ToLower(err.Error())
3363 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")
3364}
3365
3366// autoCompactThreshold is the context-window fraction at which the
3367// agent will auto-compact after a turn ends. 0.85 leaves enough
3368// headroom for one more user prompt + response before we bump the
3369// hard limit.
3370const autoCompactThreshold = 0.85
3371
3372// shouldAutoCompactLocked reports whether the last turn pushed context
3373// usage past the auto-compact threshold. Must be called with i.mu
3374// held; it reads lastCtxInput and the current model's context window.
3375func (i *Interactive) shouldAutoCompactLocked() bool {
3376 if i.agent == nil {
3377 return false
3378 }
3379 if i.autoCompacting {
3380 return false
3381 }
3382 m, err := provider.FindModel(i.cfg.Provider, i.cfg.Model)
3383 if err != nil || m.ContextWindow <= 0 {
3384 return false
3385 }
3386 if i.lastCtxInput <= 0 {
3387 return false
3388 }
3389 return float64(i.lastCtxInput)/float64(m.ContextWindow) >= autoCompactThreshold
3390}
3391
3392func (i *Interactive) handleEvent(ev core.AgentEvent) {
3393 i.mu.Lock()
3394 defer i.mu.Unlock()
3395 switch e := ev.(type) {
3396 case core.EvAssistantStart:
3397 // Fires at the top of every oneTurn, including follow-up
3398 // turns after tool use. Without this, the streaming buffer
3399 // is still marked off from the previous assistant message
3400 // and the final summary text pops in all at once instead
3401 // of typewriter-streaming delta by delta.
3402 i.streaming.Reset()
3403 i.streamPending = i.streamPending[:0]
3404 i.streamFlushPending = false
3405 i.streamOn = true
3406 // Clear the live tool-call overlay. Any tools from the
3407 // previous round are now fully folded into the transcript
3408 // (assistant tool_use block + tool role message with the
3409 // result), so keeping them in the overlay would duplicate
3410 // them in the view — once inside the finalised transcript
3411 // and once below the streaming block, with the streaming
3412 // summary sandwiched in between. The next EvToolUseStart
3413 // will populate fresh entries for this turn's tools.
3414 i.toolCalls = map[string]*tui.ToolCallView{}
3415 i.toolOrder = nil
3416 case core.EvTextDelta:
3417 // Buffer into streamPending; the paintPace ticker drains
3418 // it into i.streaming a few runes at a time for a smooth
3419 // typewriter effect independent of upstream chunk size.
3420 i.streamPending = append(i.streamPending, []rune(e.Delta)...)
3421 i.streamOn = true
3422 case core.EvAssistantMessage:
3423 // OnAssistant + telegram mirroring always fire on message
3424 // arrival — they read the FINAL message content, which is
3425 // complete regardless of what's still in the pacer.
3426 i.assistantMessageSideEffects(e.Message)
3427 // If the pacer still has characters to drain, keep streamOn
3428 // true and mark flush pending; the paintPace ticker will
3429 // drain the remainder and reset streaming state when done.
3430 // Otherwise (rare: full-replay sessions, abort paths) clear
3431 // synchronously so a later render doesn't show stale text.
3432 if len(i.streamPending) > 0 {
3433 i.streamFlushPending = true
3434 return
3435 }
3436 i.resetStreamingStateLocked()
3437 case core.EvToolUseStart:
3438 // Live streaming: pre-create the view so the user sees the
3439 // tool call being composed in real time. Any subsequent
3440 // EvToolCall for the same ID updates the same struct (the
3441 // final parsed args + name are already known here).
3442 if _, exists := i.toolCalls[e.ID]; !exists {
3443 i.toolCalls[e.ID] = &tui.ToolCallView{
3444 ID: e.ID,
3445 Name: e.Name,
3446 Streaming: true,
3447 }
3448 i.toolOrder = append(i.toolOrder, e.ID)
3449 }
3450 case core.EvToolUseArgs:
3451 if tc, ok := i.toolCalls[e.ID]; ok {
3452 tc.RawJSONBuf += e.Delta
3453 // Refresh the live path as soon as it parses; used in
3454 // the header (write /Users/pat/Desktop/demo.ts)
3455 // while the content is still streaming.
3456 if p, pok, _ := tui.ExtractPartialStringField(tc.RawJSONBuf, "path"); pok {
3457 tc.LivePath = p
3458 } else if p, pok, _ := tui.ExtractPartialStringField(tc.RawJSONBuf, "file_path"); pok {
3459 tc.LivePath = p
3460 }
3461 }
3462 case core.EvToolUseEnd:
3463 if tc, ok := i.toolCalls[e.ID]; ok {
3464 tc.Streaming = false
3465 }
3466 case core.EvToolCall:
3467 // If we already pre-created the view during streaming, just
3468 // refresh the final Args summary. Otherwise create a new one
3469 // (non-streaming providers or legacy paths).
3470 if tc, ok := i.toolCalls[e.ID]; ok {
3471 tc.Args = tui.ShortArgs(e.Name, e.Args)
3472 tc.Streaming = false
3473 } else {
3474 i.toolCalls[e.ID] = &tui.ToolCallView{
3475 ID: e.ID,
3476 Name: e.Name,
3477 Args: tui.ShortArgs(e.Name, e.Args),
3478 }
3479 i.toolOrder = append(i.toolOrder, e.ID)
3480 }
3481 case core.EvToolResult:
3482 if tc, ok := i.toolCalls[e.ID]; ok {
3483 tc.Done = true
3484 tc.Error = e.Result.IsError
3485 var text strings.Builder
3486 for _, c := range e.Result.Content {
3487 if tb, ok := c.(provider.TextBlock); ok {
3488 if text.Len() > 0 {
3489 text.WriteString("\n")
3490 }
3491 text.WriteString(tb.Text)
3492 }
3493 }
3494 tc.Result = text.String()
3495 }
3496 if i.cfg.OnToolResult != nil {
3497 i.cfg.OnToolResult(e.ID, e.Result)
3498 }
3499 case core.EvUsage:
3500 i.cumUsage = e.Cumulative
3501 if e.Usage.InputTokens > 0 {
3502 i.lastCtxInput = e.Usage.InputTokens + e.Usage.CacheReadTokens + e.Usage.CacheWriteTokens
3503 }
3504 case core.EvTurnEnd:
3505 if e.Stop == provider.StopAborted {
3506 i.resetStreamingStateLocked()
3507 i.statusErr = ""
3508 i.statusOK = "cancelled"
3509 return
3510 }
3511 // Don't surface mid-loop stream errors as a red banner here.
3512 // EvTurnEnd fires after every step in a multi-step tool loop,
3513 // so a transient 503 / network blip would briefly paint a red
3514 // banner over the still-streaming chat before the agent loop
3515 // either retries or exits. The final error (if any) is set by
3516 // startTurnWithImages once Prompt() returns, and recoverable
3517 // failures are routed to the rescue picker instead — which
3518 // keeps the chat clean while the agent is working.
3519 _ = e.Err
3520 }
3521}
3522
3523// Agent returns the current agent, if any. Used by cli.go to flush the
3524// final transcript to the session file.
3525func (i *Interactive) Agent() *core.Agent {
3526 i.mu.Lock()
3527 defer i.mu.Unlock()
3528 return i.agent
3529}
3530
3531// silence unused import in some build configs
3532var _ = fmt.Sprintf
3533
3534// runReloadExt triggers a live reload of every extension (discovered
3535// + explicit). Runs on a goroutine so the TUI stays responsive; the
3536// Manager.Reload takes a couple of hundred ms to shut down subprocs
3537// and respawn them. Shows a status line throughout.
3538func (i *Interactive) runReloadExt(ctx context.Context) {
3539 if i.cfg.Extensions == nil {
3540 i.mu.Lock()
3541 i.statusErr = "no extension manager in this build"
3542 i.mu.Unlock()
3543 i.invalidate()
3544 return
3545 }
3546 i.mu.Lock()
3547 i.statusOK = "reloading extensions…"
3548 i.statusErr = ""
3549 i.mu.Unlock()
3550 i.invalidate()
3551
3552 go func() {
3553 stats := i.cfg.Extensions.Reload(ctx, 2*time.Second)
3554 msg := fmt.Sprintf("reloaded: %d stopped, %d loaded (%d ready)", stats.Stopped, stats.Loaded, stats.Ready)
3555 if len(stats.Errors) > 0 {
3556 msg += fmt.Sprintf(", %d error(s)", len(stats.Errors))
3557 }
3558 i.mu.Lock()
3559 i.statusOK = msg
3560 i.statusErr = ""
3561 i.mu.Unlock()
3562 i.invalidate()
3563 }()
3564}
3565
3566// Confirm implements core.Confirmer. The agent goroutine calls
3567// this synchronously before every tool invocation when --no-yolo is
3568// active. We push the request onto the confirmDialog queue, trigger
3569// a redraw, and block the caller until the user answers.
3570//
3571// If the session is cancelled or the TUI exits mid-prompt, any
3572// pending request is refused via CancelAll so the agent doesn't
3573// deadlock.
3574func (i *Interactive) Confirm(toolName string, preview string) core.ConfirmDecision {
3575 resp := make(chan core.ConfirmDecision, 1)
3576 i.confirmDialog.Enqueue(&confirmRequest{
3577 toolName: toolName,
3578 preview: preview,
3579 resp: resp,
3580 })
3581 i.invalidate()
3582 return <-resp
3583}
3584
3585// openTelegramDialog shows the picker for `/telegram` with no arg.
3586// Items depend on current state: disconnect + status when running,
3587// connect + status when stopped.
3588func (i *Interactive) openTelegramDialog() {
3589 items := i.telegramMenuItems()
3590 if len(items) == 0 {
3591 i.mu.Lock()
3592 i.statusErr = "telegram not configured. run `zot telegram-bot setup` first."
3593 i.mu.Unlock()
3594 i.invalidate()
3595 return
3596 }
3597 i.telegramDialog.Open(items)
3598 i.invalidate()
3599}
3600
3601// telegramMenuItems builds the dialog entries for the current
3602// bridge state. Returns empty when no bot.json exists so the
3603// caller can show a helpful status line instead of an empty menu.
3604func (i *Interactive) telegramMenuItems() []telegramItem {
3605 cfg, err := telegram.LoadConfig(i.cfg.ZotHome)
3606 if err != nil || cfg.BotToken == "" {
3607 return nil
3608 }
3609 var items []telegramItem
3610 if i.telegramBridge != nil && i.telegramBridge.Active() {
3611 items = append(items, telegramItem{label: "disconnect", action: "disconnect", hint: "stop mirroring"})
3612 st := i.telegramBridge.State()
3613 hint := "active"
3614 if st.Username != "" {
3615 hint += " as @" + st.Username
3616 }
3617 items = append(items, telegramItem{label: "status", action: "status", hint: hint})
3618 } else {
3619 label := "connect"
3620 hint := "start mirroring dms into this session"
3621 if cfg.AllowedUserID == 0 {
3622 hint = "awaiting pairing (send /start to the bot once connected)"
3623 }
3624 items = append(items, telegramItem{label: label, action: "connect", hint: hint})
3625 items = append(items, telegramItem{label: "status", action: "status", hint: "disconnected"})
3626 }
3627 return items
3628}
3629
3630// doTelegram dispatches one of the three explicit actions. Called
3631// from /telegram <action> or after the picker selects a row.
3632func (i *Interactive) doTelegram(action string) {
3633 switch action {
3634 case "connect":
3635 i.telegramConnect()
3636 case "disconnect":
3637 i.telegramDisconnect()
3638 case "status":
3639 i.telegramStatus()
3640 default:
3641 i.mu.Lock()
3642 i.statusErr = "unknown telegram action: " + action + " (use connect, disconnect, or status)"
3643 i.mu.Unlock()
3644 i.invalidate()
3645 }
3646}
3647
3648// telegramConnect starts the bridge. Refuses if it's already
3649// running or if the on-disk bot.json is missing a token.
3650func (i *Interactive) telegramConnect() {
3651 if i.telegramBridge != nil && i.telegramBridge.Active() {
3652 i.mu.Lock()
3653 i.statusOK = "telegram already connected"
3654 i.statusErr = ""
3655 i.mu.Unlock()
3656 i.invalidate()
3657 return
3658 }
3659 cfg, err := telegram.LoadConfig(i.cfg.ZotHome)
3660 if err != nil {
3661 i.mu.Lock()
3662 i.statusErr = "telegram: " + err.Error()
3663 i.mu.Unlock()
3664 i.invalidate()
3665 return
3666 }
3667 if cfg.BotToken == "" {
3668 i.mu.Lock()
3669 i.statusErr = "telegram: no bot token configured. run `zot telegram-bot setup` first."
3670 i.mu.Unlock()
3671 i.invalidate()
3672 return
3673 }
3674 // Refuse to start when a background daemon is already polling
3675 // the same bot. Two concurrent long-poll consumers race each
3676 // update and one always loses, so DMs get half-delivered. The
3677 // user can `zot telegram-bot stop` first, then /telegram connect.
3678 if pid, alive, _ := telegram.IsRunning(i.cfg.ZotHome); alive && pid > 0 {
3679 i.mu.Lock()
3680 i.statusErr = fmt.Sprintf("telegram: bot daemon already running (pid %d). stop it with `zot telegram-bot stop` first.", pid)
3681 i.mu.Unlock()
3682 i.invalidate()
3683 return
3684 }
3685 i.telegramBridge = &telegram.Bridge{
3686 Client: telegram.NewClient(cfg.BotToken),
3687 Config: cfg,
3688 Save: func(next telegram.Config) error {
3689 return telegram.SaveConfig(i.cfg.ZotHome, next)
3690 },
3691 Host: &telegramHost{iv: i},
3692 }
3693 if err := i.telegramBridge.Start(i.runCtx); err != nil {
3694 i.telegramBridge = nil
3695 i.mu.Lock()
3696 i.statusErr = "telegram connect failed: " + err.Error()
3697 i.mu.Unlock()
3698 i.invalidate()
3699 return
3700 }
3701 i.applyTelegramTools(true)
3702 state := i.telegramBridge.State()
3703 label := "telegram connected"
3704 if state.Username != "" {
3705 label += " as @" + state.Username
3706 }
3707 if state.PairedID == 0 {
3708 label += " — send /start to the bot from your phone to claim it"
3709 }
3710 i.mu.Lock()
3711 i.statusOK = label
3712 i.statusErr = ""
3713 i.mu.Unlock()
3714 i.invalidate()
3715}
3716
3717// telegramDisconnect stops the bridge. No-op when already stopped.
3718func (i *Interactive) telegramDisconnect() {
3719 if i.telegramBridge == nil || !i.telegramBridge.Active() {
3720 i.mu.Lock()
3721 i.statusOK = "telegram already disconnected"
3722 i.statusErr = ""
3723 i.mu.Unlock()
3724 i.invalidate()
3725 return
3726 }
3727 i.telegramBridge.Stop()
3728 i.applyTelegramTools(false)
3729 i.mu.Lock()
3730 i.statusOK = "telegram disconnected"
3731 i.statusErr = ""
3732 i.mu.Unlock()
3733 i.invalidate()
3734}
3735
3736// telegramSenderAdapter wraps the bridge so the tools package can
3737// drive it without importing telegram directly. The Active() check
3738// is forwarded to the bridge so the tool can fail clearly with a
3739// model-readable error when the user disconnected mid-turn.
3740type telegramSenderAdapter struct {
3741 bridge *telegram.Bridge
3742}
3743
3744func (a telegramSenderAdapter) SendImage(ctx context.Context, path, caption string) error {
3745 if a.bridge == nil {
3746 return fmt.Errorf("telegram bridge is not connected")
3747 }
3748 return a.bridge.SendImage(ctx, path, caption)
3749}
3750
3751func (a telegramSenderAdapter) SendDocument(ctx context.Context, path, caption string) error {
3752 if a.bridge == nil {
3753 return fmt.Errorf("telegram bridge is not connected")
3754 }
3755 return a.bridge.SendDocument(ctx, path, caption)
3756}
3757
3758func (a telegramSenderAdapter) Active() bool {
3759 return a.bridge != nil && a.bridge.Active()
3760}
3761
3762// applyTelegramTools registers (active=true) or removes (active=false)
3763// the telegram_send_image and telegram_send_file tools on the running
3764// agent so the model only sees them while the bridge is connected.
3765// Snapshots and mutates the live tool registry so any extension or
3766// /reload-ext additions made while Telegram is connected survive a
3767// later /telegram disconnect (we only add or strip the two telegram
3768// entries, never the rest).
3769func (i *Interactive) applyTelegramTools(active bool) {
3770 if i.agent == nil {
3771 return
3772 }
3773 current := i.agent.Tools
3774 next := core.Registry{}
3775 for name, t := range current {
3776 if name == "telegram_send_image" || name == "telegram_send_file" {
3777 continue
3778 }
3779 next[name] = t
3780 }
3781 if active {
3782 sender := telegramSenderAdapter{bridge: i.telegramBridge}
3783 next["telegram_send_image"] = &tools.TelegramSendImageTool{
3784 CWD: i.cfg.CWD, Sandbox: i.cfg.Sandbox, Sender: sender,
3785 }
3786 next["telegram_send_file"] = &tools.TelegramSendFileTool{
3787 CWD: i.cfg.CWD, Sandbox: i.cfg.Sandbox, Sender: sender,
3788 }
3789 }
3790 i.agent.SetTools(next)
3791}
3792
3793// telegramStatus writes a one-liner describing the bridge state.
3794// Reports on both the in-tui bridge and the background daemon so
3795// the user isn't confused when the daemon owns the poll loop.
3796func (i *Interactive) telegramStatus() {
3797 var msg string
3798 if i.telegramBridge != nil && i.telegramBridge.Active() {
3799 s := i.telegramBridge.State()
3800 msg = "telegram: connected (tui bridge)"
3801 if s.Username != "" {
3802 msg += " as @" + s.Username
3803 }
3804 if s.PairedID != 0 {
3805 msg += fmt.Sprintf(" - paired with user %d", s.PairedID)
3806 } else {
3807 msg += " - awaiting pairing"
3808 }
3809 } else if pid, alive, _ := telegram.IsRunning(i.cfg.ZotHome); alive && pid > 0 {
3810 msg = fmt.Sprintf("telegram: background daemon running (pid %d) - /telegram connect won't work until you stop it", pid)
3811 } else {
3812 cfg, _ := telegram.LoadConfig(i.cfg.ZotHome)
3813 if cfg.BotToken == "" {
3814 msg = "telegram: not configured. run `zot telegram-bot setup` first."
3815 } else {
3816 msg = "telegram: disconnected"
3817 if cfg.BotUsername != "" {
3818 msg += " (@" + cfg.BotUsername + " ready to connect)"
3819 }
3820 }
3821 }
3822 i.mu.Lock()
3823 i.statusOK = msg
3824 i.statusErr = ""
3825 i.mu.Unlock()
3826 i.invalidate()
3827}
3828
3829// telegramHost adapts *Interactive to telegram.Host so the bridge
3830// can call back into the TUI without importing modes directly.
3831type telegramHost struct{ iv *Interactive }
3832
3833func (h *telegramHost) SubmitOrQueue(prompt string, images []provider.ImageBlock) {
3834 h.iv.SubmitOrQueue(prompt, images)
3835}
3836
3837func (h *telegramHost) CancelTurn() { h.iv.CancelTurn() }
3838
3839func (h *telegramHost) Status() string {
3840 h.iv.mu.Lock()
3841 providerName := h.iv.cfg.Provider
3842 model := h.iv.cfg.Model
3843 cwd := h.iv.cfg.CWD
3844 usage := h.iv.cumUsage
3845 subscription := h.iv.cfg.AuthMethod == "oauth"
3846 ctxUsed := h.iv.lastCtxInput
3847 busy := h.iv.busy
3848 queued := len(h.iv.queued)
3849 h.iv.mu.Unlock()
3850
3851 ctxMax := 0
3852 if m, err := provider.FindModel(providerName, model); err == nil {
3853 ctxMax = m.ContextWindow
3854 }
3855 return telegram.FormatStatus(telegram.StatusSnapshot{
3856 Provider: providerName,
3857 Model: model,
3858 CWD: cwd,
3859 Usage: usage,
3860 Subscription: subscription,
3861 ContextUsed: ctxUsed,
3862 ContextMax: ctxMax,
3863 Busy: busy,
3864 Queued: queued,
3865 })
3866}
3867
3868func (h *telegramHost) Notify(level, message string) {
3869 h.iv.mu.Lock()
3870 switch level {
3871 case "error", "warn":
3872 h.iv.statusErr = message
3873 h.iv.statusOK = ""
3874 default:
3875 h.iv.statusOK = message
3876 h.iv.statusErr = ""
3877 }
3878 h.iv.mu.Unlock()
3879 h.iv.invalidate()
3880}
3881
3882// openSessionOpsDialog shows the picker for `/session` with no arg.
3883// Always offers export, import, fork, tree; the handlers bail with
3884// a clear status message when the precondition isn't met (empty
3885// transcript on fork; no parent/siblings on tree).
3886func (i *Interactive) openSessionOpsDialog() {
3887 items := []sessionOpsItem{
3888 {label: "export", action: "export", hint: "write the current session to a .zotsession file"},
3889 {label: "import", action: "import", hint: "load a .zotsession file into this directory"},
3890 {label: "fork", action: "fork", hint: "branch from a past user message into a new session"},
3891 {label: "tree", action: "tree", hint: "switch between branches in this directory"},
3892 }
3893 i.sessionOpsDialog.Open(items)
3894 i.invalidate()
3895}
3896
3897// doSessionOp dispatches export, import, fork, or tree. arg is the
3898// optional positional argument from e.g. /session export <path>
3899// or /session import <path>; fork and tree ignore it.
3900func (i *Interactive) doSessionOp(action, arg string) {
3901 switch action {
3902 case "export":
3903 i.doSessionExport(arg)
3904 case "import":
3905 i.doSessionImport(arg)
3906 case "fork":
3907 i.doSessionFork()
3908 case "tree":
3909 i.doSessionTree()
3910 default:
3911 i.mu.Lock()
3912 i.statusErr = "unknown /session action: " + action + " (use export, import, fork, or tree)"
3913 i.mu.Unlock()
3914 i.invalidate()
3915 }
3916}
3917
3918// doSessionExport writes the live session file to destination path
3919// dst. When dst is empty we default to ~/Downloads (falling back to
3920// the user's home directory if it doesn't exist). The helper
3921// expands a leading `~` and creates any missing parent directories.
3922func (i *Interactive) doSessionExport(dst string) {
3923 if i.cfg.CurrentSessionPath == nil {
3924 i.mu.Lock()
3925 i.statusErr = "export: no session is active (running with --no-session?)"
3926 i.mu.Unlock()
3927 i.invalidate()
3928 return
3929 }
3930 src := i.cfg.CurrentSessionPath()
3931 if src == "" {
3932 i.mu.Lock()
3933 i.statusErr = "export: no session is active (running with --no-session?)"
3934 i.mu.Unlock()
3935 i.invalidate()
3936 return
3937 }
3938 // Persist any in-memory agent messages to the session file so
3939 // the export carries the full conversation. Without this, the
3940 // default lazy-flush-at-exit strategy leaves most of a running
3941 // session unwritten and the export ends up with only the meta.
3942 if i.cfg.FlushSession != nil {
3943 i.cfg.FlushSession()
3944 }
3945 dst = unquotePath(dst)
3946 if dst == "" {
3947 dst = defaultExportDir()
3948 } else {
3949 dst = expandTilde(dst)
3950 }
3951 out, err := core.ExportSession(src, dst)
3952 if err != nil {
3953 i.mu.Lock()
3954 i.statusErr = "export: " + err.Error()
3955 i.mu.Unlock()
3956 i.invalidate()
3957 return
3958 }
3959 i.mu.Lock()
3960 i.statusOK = "exported session to " + friendlyPath(out)
3961 i.statusErr = ""
3962 i.mu.Unlock()
3963 i.invalidate()
3964}
3965
3966// doSessionImport copies the .zotsession file at src into the
3967// running cwd's sessions directory and loads it as the active
3968// session, same as `/sessions` -> pick. When src is empty we ask
3969// the user to pass a path (no usable default here).
3970func (i *Interactive) doSessionImport(src string) {
3971 src = unquotePath(src)
3972 if src == "" {
3973 i.mu.Lock()
3974 i.statusErr = "import: pass a path — e.g. /session import ~/Downloads/work.zotsession"
3975 i.mu.Unlock()
3976 i.invalidate()
3977 return
3978 }
3979 src = expandTilde(src)
3980 if _, err := os.Stat(src); err != nil {
3981 i.mu.Lock()
3982 i.statusErr = "import: " + err.Error()
3983 i.mu.Unlock()
3984 i.invalidate()
3985 return
3986 }
3987 newPath, err := core.ImportSession(src, i.cfg.ZotHome, i.cfg.CWD, i.cfg.Version)
3988 if err != nil {
3989 i.mu.Lock()
3990 i.statusErr = "import: " + err.Error()
3991 i.mu.Unlock()
3992 i.invalidate()
3993 return
3994 }
3995 if i.cfg.LoadSession == nil {
3996 i.mu.Lock()
3997 i.statusOK = "imported session at " + friendlyPath(newPath) + " (run /sessions to resume it)"
3998 i.statusErr = ""
3999 i.mu.Unlock()
4000 i.invalidate()
4001 return
4002 }
4003 if err := i.cfg.LoadSession(newPath); err != nil {
4004 i.mu.Lock()
4005 i.statusErr = "import: load failed: " + err.Error()
4006 i.mu.Unlock()
4007 i.invalidate()
4008 return
4009 }
4010 i.mu.Lock()
4011 i.statusOK = "imported and switched to session " + friendlyPath(newPath)
4012 i.statusErr = ""
4013 i.mu.Unlock()
4014 i.invalidate()
4015}
4016
4017// defaultExportDir returns ~/Downloads when it exists, or ~ as a
4018// fallback, or /tmp on exotic machines with no home dir.
4019func defaultExportDir() string {
4020 home, err := os.UserHomeDir()
4021 if err != nil || home == "" {
4022 return os.TempDir()
4023 }
4024 downloads := filepath.Join(home, "Downloads")
4025 if fi, err := os.Stat(downloads); err == nil && fi.IsDir() {
4026 return downloads
4027 }
4028 return home
4029}
4030
4031// expandTilde turns a leading ~ into the user's home directory.
4032// Returns the input unchanged when there's no tilde or no home.
4033func expandTilde(p string) string {
4034 if p == "" || p[0] != '~' {
4035 return p
4036 }
4037 home, err := os.UserHomeDir()
4038 if err != nil || home == "" {
4039 return p
4040 }
4041 if len(p) == 1 {
4042 return home
4043 }
4044 if p[1] == '/' || p[1] == filepath.Separator {
4045 return filepath.Join(home, p[2:])
4046 }
4047 return p
4048}
4049
4050// unquotePath strips a matching pair of surrounding single or
4051// double quotes. Drag-drop paste in the tui auto-quotes dropped
4052// file paths so the shell-like `/session import 'foo bar.zs'`
4053// stays well-formed; when the TUI's own slash handler consumes
4054// the arg, we want the raw path back.
4055func unquotePath(p string) string {
4056 p = strings.TrimSpace(p)
4057 if len(p) >= 2 {
4058 first := p[0]
4059 last := p[len(p)-1]
4060 if (first == '\'' && last == '\'') || (first == '"' && last == '"') {
4061 p = p[1 : len(p)-1]
4062 }
4063 }
4064 return p
4065}
4066
4067// friendlyPath collapses the user's home directory to a leading ~
4068// so status messages read cleanly. Falls back to the raw path when
4069// the home dir is unknown.
4070func friendlyPath(p string) string {
4071 home, err := os.UserHomeDir()
4072 if err != nil || home == "" {
4073 return p
4074 }
4075 if strings.HasPrefix(p, home+string(filepath.Separator)) {
4076 return "~" + p[len(home):]
4077 }
4078 return p
4079}
4080
4081// doSessionFork opens the /jump turn picker in "fork mode". The
4082// next selection branches the current session at that user turn
4083// instead of scrolling the viewport to it.
4084func (i *Interactive) doSessionFork() {
4085 if i.cfg.CurrentSessionPath == nil || i.cfg.CurrentSessionPath() == "" {
4086 i.mu.Lock()
4087 i.statusErr = "fork: no session is active (running with --no-session?)"
4088 i.mu.Unlock()
4089 i.invalidate()
4090 return
4091 }
4092 msgs := []provider.Message{}
4093 if i.agent != nil {
4094 msgs = i.agent.Messages()
4095 }
4096 if len(msgs) == 0 {
4097 i.mu.Lock()
4098 i.statusErr = "fork: transcript is empty; nothing to fork from"
4099 i.mu.Unlock()
4100 i.invalidate()
4101 return
4102 }
4103 i.pendingFork = true
4104 i.jumpDialog.Open(msgs, "")
4105 i.invalidate()
4106}
4107
4108// doSessionTree shows the branch topology picker for this cwd.
4109// Pick an entry to switch into it (same semantics as /sessions
4110// picking a past session, but with the parent/child indentation).
4111func (i *Interactive) doSessionTree() {
4112 if i.cfg.ZotHome == "" || i.cfg.CWD == "" {
4113 i.mu.Lock()
4114 i.statusErr = "tree: session storage not configured"
4115 i.mu.Unlock()
4116 i.invalidate()
4117 return
4118 }
4119 // Flush the running agent's transcript first so its message
4120 // count + latest preview are accurate in the tree.
4121 if i.cfg.FlushSession != nil {
4122 i.cfg.FlushSession()
4123 }
4124 roots := core.BuildSessionTree(i.cfg.ZotHome, i.cfg.CWD)
4125 if len(roots) == 0 {
4126 i.mu.Lock()
4127 i.statusErr = "tree: no sessions in this directory yet"
4128 i.mu.Unlock()
4129 i.invalidate()
4130 return
4131 }
4132 current := ""
4133 if i.cfg.CurrentSessionPath != nil {
4134 current = i.cfg.CurrentSessionPath()
4135 }
4136 if !i.sessionTreeDialog.Open(roots, current) {
4137 i.mu.Lock()
4138 i.statusErr = "tree: no branches to show"
4139 i.mu.Unlock()
4140 }
4141 i.invalidate()
4142}
4143
4144// applySessionTreeSelection switches the running agent to the
4145// session file at path. Thin wrapper around LoadSession that also
4146// writes a status line.
4147func (i *Interactive) applySessionTreeSelection(path string) {
4148 if i.cfg.LoadSession == nil {
4149 i.mu.Lock()
4150 i.statusErr = "tree: session swap not available in this build"
4151 i.mu.Unlock()
4152 i.invalidate()
4153 return
4154 }
4155 if err := i.cfg.LoadSession(path); err != nil {
4156 i.mu.Lock()
4157 i.statusErr = "tree: load failed: " + err.Error()
4158 i.mu.Unlock()
4159 i.invalidate()
4160 return
4161 }
4162 i.mu.Lock()
4163 i.statusOK = "switched to branch " + friendlyPath(path)
4164 i.statusErr = ""
4165 i.mu.Unlock()
4166 i.invalidate()
4167}
4168
4169// applyForkSelection branches the current session at msgIdx+1 (so
4170// the selected user message and everything before it is included
4171// in the new branch), then switches the running agent to the new
4172// file. Called from the jump-dialog handler when pendingFork=true.
4173func (i *Interactive) applyForkSelection(msgIdx int) {
4174 i.pendingFork = false
4175 if i.cfg.CurrentSessionPath == nil {
4176 i.mu.Lock()
4177 i.statusErr = "fork: no session is active"
4178 i.mu.Unlock()
4179 i.invalidate()
4180 return
4181 }
4182 src := i.cfg.CurrentSessionPath()
4183 if src == "" {
4184 i.mu.Lock()
4185 i.statusErr = "fork: no session is active"
4186 i.mu.Unlock()
4187 i.invalidate()
4188 return
4189 }
4190 if i.cfg.FlushSession != nil {
4191 i.cfg.FlushSession()
4192 }
4193 // msgIdx is 0-indexed message position; copy msgIdx+1 rows so
4194 // the selected user message is included.
4195 upTo := msgIdx + 1
4196 newPath, err := core.BranchSession(src, i.cfg.ZotHome, i.cfg.CWD, i.cfg.Version, upTo)
4197 if err != nil {
4198 i.mu.Lock()
4199 i.statusErr = "fork: " + err.Error()
4200 i.mu.Unlock()
4201 i.invalidate()
4202 return
4203 }
4204 if i.cfg.LoadSession == nil {
4205 i.mu.Lock()
4206 i.statusOK = "forked at message " + formatInt(upTo) + " (run /sessions to resume)"
4207 i.statusErr = ""
4208 i.mu.Unlock()
4209 i.invalidate()
4210 return
4211 }
4212 if err := i.cfg.LoadSession(newPath); err != nil {
4213 i.mu.Lock()
4214 i.statusErr = "fork: switch failed: " + err.Error()
4215 i.mu.Unlock()
4216 i.invalidate()
4217 return
4218 }
4219 i.mu.Lock()
4220 i.statusOK = "forked and switched to new branch at " + friendlyPath(newPath)
4221 i.statusErr = ""
4222 i.mu.Unlock()
4223 i.invalidate()
4224}
4225
4226// formatInt is a tiny strconv.Itoa shim; keeps the handler above
4227// from needing a strconv import just for one call.
4228func formatInt(n int) string {
4229 return fmt.Sprintf("%d", n)
4230}
4231
4232// assistantText returns the concatenated text of every TextBlock in
4233// m. Used by the streaming-view dedupe guard to tell when a live
4234// streamed reply has already been promoted into the transcript.
4235func assistantText(m provider.Message) string {
4236 var sb strings.Builder
4237 for _, c := range m.Content {
4238 if tb, ok := c.(provider.TextBlock); ok {
4239 if sb.Len() > 0 {
4240 sb.WriteByte('\n')
4241 }
4242 sb.WriteString(tb.Text)
4243 }
4244 }
4245 return sb.String()
4246}
4247
4248// resetStreamingStateLocked clears every piece of streaming state
4249// in one shot. Used by abort paths (turn cancel, compact finish,
4250// queue drain) so the pacer doesn't keep draining stale runes from
4251// a prior turn. Must be called with i.mu held.
4252func (i *Interactive) resetStreamingStateLocked() {
4253 i.streaming.Reset()
4254 i.streamPending = i.streamPending[:0]
4255 i.streamFlushPending = false
4256 i.streamOn = false
4257}
4258
4259// assistantMessageSideEffects runs the non-visual hooks attached to
4260// EvAssistantMessage: the host-provided OnAssistant callback and the
4261// telegram-bridge mirror. Called with i.mu held.
4262//
4263// Factored out of handleEvent because the streaming pacer may defer
4264// visual reset until after the last buffered rune has painted, but
4265// the callbacks themselves must fire on message arrival so
4266// downstream observers (session persistence, telegram, cost panels)
4267// don't wait on a UI animation to catch up.
4268func (i *Interactive) assistantMessageSideEffects(m provider.Message) {
4269 if i.cfg.OnAssistant != nil {
4270 i.cfg.OnAssistant(m)
4271 }
4272 if i.telegramBridge != nil && i.telegramBridge.Active() {
4273 var sb strings.Builder
4274 for _, c := range m.Content {
4275 if tb, ok := c.(provider.TextBlock); ok {
4276 if sb.Len() > 0 {
4277 sb.WriteString("\n")
4278 }
4279 sb.WriteString(tb.Text)
4280 }
4281 }
4282 if text := sb.String(); strings.TrimSpace(text) != "" {
4283 go i.telegramBridge.OnAssistantText(text)
4284 }
4285 }
4286}
4287
4288// paintPaceRate is how many runes the streaming pacer releases per
4289// tick. With a 16ms tick, 6 runes/tick is ~375 runes/s — fast enough
4290// that a 500-rune summary finishes in ~1.3s, slow enough to look
4291// like a human typing. Empirically matches the feel of provider
4292// paths that already drip-stream natively.
4293const paintPaceRate = 6
4294
4295// paintPaceInterval is the tick interval for the streaming pacer.
4296// 16ms lines up with the redraw throttle so we never paint faster
4297// than the terminal can keep up.
4298const paintPaceInterval = 16 * time.Millisecond
4299
4300// runStreamPacer drains buffered deltas from streamPending into
4301// streaming a small batch per tick, invalidating after each move.
4302// It stops when the context cancels (tui shutdown).
4303//
4304// Why a pacer: providers differ wildly in how they chunk their
4305// text_delta events. The API-key path on Anthropic emits ~30 drips
4306// for a 400-token summary; the OAuth path can coalesce the same
4307// summary into 3 fat chunks, visually indistinguishable from "the
4308// whole reply just appeared". The pacer normalizes that so every
4309// path looks the same on screen.
4310func (i *Interactive) runStreamPacer(ctx context.Context) {
4311 t := time.NewTicker(paintPaceInterval)
4312 defer t.Stop()
4313 for {
4314 select {
4315 case <-ctx.Done():
4316 return
4317 case <-t.C:
4318 i.mu.Lock()
4319 if len(i.streamPending) == 0 {
4320 // EvAssistantMessage already fired but the pacer
4321 // was still draining a tick ago. Everything is now
4322 // painted; clear the streaming flags so the next
4323 // redraw shows the finalised transcript message
4324 // and hides the streaming overlay.
4325 if i.streamFlushPending {
4326 i.streamFlushPending = false
4327 i.streaming.Reset()
4328 i.streamOn = false
4329 i.mu.Unlock()
4330 i.invalidate()
4331 continue
4332 }
4333 i.mu.Unlock()
4334 continue
4335 }
4336 n := paintPaceRate
4337 if n > len(i.streamPending) {
4338 n = len(i.streamPending)
4339 }
4340 i.streaming.WriteString(string(i.streamPending[:n]))
4341 i.streamPending = i.streamPending[n:]
4342 i.mu.Unlock()
4343 i.invalidate()
4344 }
4345 }
4346}