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