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