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