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