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