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