1package modes
2
3import (
4 "context"
5 "encoding/json"
6 "fmt"
7 "strings"
8 "sync"
9 "time"
10
11 "github.com/patriceckhart/zot/internal/agent/extensions"
12 "github.com/patriceckhart/zot/internal/agent/modes/telegram"
13 "github.com/patriceckhart/zot/internal/agent/tools"
14 "github.com/patriceckhart/zot/internal/auth"
15 "github.com/patriceckhart/zot/internal/core"
16 "github.com/patriceckhart/zot/internal/provider"
17 "github.com/patriceckhart/zot/internal/skills"
18 "github.com/patriceckhart/zot/internal/tui"
19)
20
21// InteractiveConfig configures the interactive loop.
22type InteractiveConfig struct {
23 Terminal tui.Terminal
24 Theme tui.Theme
25 Model string
26 Provider string
27 AuthMethod string // "apikey" | "oauth" — used to tag cost as (sub) in status bar
28 BaseURL string
29 Reasoning string
30 SystemPrompt string
31 Tools core.Registry
32 MaxSteps int
33 CWD string
34
35 // Agent is optional. If nil, zot opens without credentials; the
36 // user must /login before they can prompt.
37 Agent *core.Agent
38
39 InitialInput string
40
41 // Auth is required. When the user runs /login, Interactive talks to
42 // AuthManager to open a browser and wait for the callback.
43 AuthManager *auth.Manager
44 // BuildAgent is called after a successful login to (re)construct the
45 // agent with the fresh credential. It returns the new agent and
46 // the concrete provider/model in use.
47 BuildAgent func() (*core.Agent, string, string, error)
48
49 // BuildAgentFor rebuilds the agent with an explicit provider/model
50 // override (used by the /model picker when switching providers).
51 // If providerOverride is empty, the current provider is kept.
52 BuildAgentFor func(providerOverride, modelOverride string) (*core.Agent, string, string, error)
53
54 // ZotHome is the root directory for sessions/, used by /sessions
55 // and the update-check cache.
56 ZotHome string
57
58 // Version is the binary's current version (from main.version).
59 // Used only for display; the update check itself is done outside
60 // this package to avoid an import cycle.
61 Version string
62
63 // UpdateInfoChan is an optional channel that delivers the result
64 // of the github-release update check. Interactive reads at most
65 // one value, drops it if the check reported nothing, and otherwise
66 // surfaces a yellow "update available" banner at the top of the
67 // chat. Nil channel = no banner, no startup cost.
68 UpdateInfoChan <-chan UpdateInfo
69
70 // Sandbox is the shared sandbox pointer. Toggled by /jail and /unjail.
71 Sandbox *tools.Sandbox
72
73 // LoadSession swaps the current session for the one at path. The
74 // callback returns the new agent message slice so the TUI can invalidate.
75 LoadSession func(path string) error
76
77 // PersistModel is called whenever the user switches model or provider.
78 // It should update config.json and (if there's an active session)
79 // write a new meta row so resume picks up the same model.
80 PersistModel func(providerName, model string)
81
82 OnAssistant func(m provider.Message)
83 OnToolResult func(id string, r core.ToolResult)
84
85 // Extensions, if non-nil, lets users invoke extension-registered
86 // slash commands. Commands declared by extensions are looked up
87 // AFTER the built-in catalog so a built-in name always wins.
88 Extensions *extensions.Manager
89
90 // SkillSnapshot, if non-nil, returns the current list of
91 // discovered SKILL.md files. Re-invoked each time /skills opens
92 // so the picker reflects edits made during the session.
93 SkillSnapshot func() []*skills.Skill
94
95 // ChangelogChan, if non-nil, delivers release-notes for the
96 // current binary version once at startup. Interactive opens a
97 // dismissible overlay when the channel produces a non-empty
98 // body. Receiver fires at most once.
99 ChangelogChan <-chan ChangelogPayload
100
101 // OnChangelogDismiss, if non-nil, is called once the user
102 // closes the changelog overlay. The cli wires this to a
103 // MarkChangelogShown call so the same version doesn't show
104 // again on the next launch.
105 OnChangelogDismiss func()
106
107 // NoYolo is true when --no-yolo was passed. Interactive opens
108 // a confirmation dialog before every tool call and blocks the
109 // tool until the user picks yes / always-this-tool /
110 // always-all / no. When false (default), tools run freely.
111 NoYolo bool
112
113 // ConfirmGate is the session-scoped gate wrapping this
114 // interactive's Confirmer. When non-nil, /yolo can call
115 // AllowAll() on it to disable confirmation for the rest of the
116 // session. When nil (yolo mode), /yolo reports that there's
117 // nothing to disable.
118 ConfirmGate *core.ConfirmGate
119}
120
121// ChangelogPayload mirrors agent.ChangelogInfo without the import
122// cycle. The cli builds one from the http response, the tui opens
123// the overlay when one arrives.
124type ChangelogPayload struct {
125 Version string
126 Body string
127 URL string
128}
129
130// Interactive is the TUI chat loop.
131type Interactive struct {
132 cfg InteractiveConfig
133 view *tui.View
134 ed *tui.Editor
135 rend *tui.Renderer
136
137 mu sync.Mutex
138 agent *core.Agent
139 streaming strings.Builder
140 streamOn bool
141 toolCalls map[string]*tui.ToolCallView
142 toolOrder []string
143 statusErr string
144 statusOK string
145 helpBlock []string // rendered above the chat when /help was typed
146 cumUsage provider.Usage
147 lastCtxInput int // input_tokens of the most recent turn — approximates current context size
148 busy bool
149 dirty chan struct{}
150 cancelTurn context.CancelFunc
151 scrollOffset int // rows from the bottom; 0 = pinned to latest
152
153 // Messages typed while a turn is in flight. Each is delivered as
154 // its own follow-up turn once the current one finishes. Rendered
155 // above the status bar as "sliding in: ..." chips.
156 queued []string
157
158 // runCtx is the top-level context passed to Run(). Follow-up turns
159 // drained from `queued` are started against this context so they
160 // survive past the ctx of the key event that enqueued them.
161 runCtx context.Context
162
163 // autoCompacting is true while a model-triggered compaction is in
164 // flight. Surfaced in the status bar so the user can tell a
165 // condense pass from a regular assistant turn.
166 autoCompacting bool
167
168 // updateInfo is the result of the async update check. Zero value
169 // while the check hasn't completed or nothing is available.
170 updateInfo UpdateInfo
171
172 dialog *loginDialog
173 modelDialog *modelDialog
174 sessionDialog *sessionDialog
175 jumpDialog *jumpDialog
176 btwDialog *btwDialog
177 skillsDialog *skillsDialog
178 changelogDialog *changelogDialog
179 confirmDialog *confirmDialog
180 logoutDialog *logoutDialog
181 telegramDialog *telegramDialog
182 telegramBridge *telegram.Bridge
183 suggest *slashSuggester
184 spin *spinner
185
186 // parkedTurn is the 1-based turn number the viewport is currently
187 // scrolled to by /jump. 0 = not parked, showing the tail as usual.
188 // Rendered as a muted footer at the bottom of the chat so users
189 // don't forget they're looking at history.
190 parkedTurn int
191 parkedTotal int
192
193 // lastCtrlC is when the user last pressed ctrl+c. The first press
194 // clears the editor / cancels a turn / shows a hint; a second press
195 // within ctrlCExitWindow exits. Mirrors the python-repl convention.
196 lastCtrlC time.Time
197
198 // welcomeStart is when the interactive run began. The welcome
199 // banner shows the binary version for welcomeVersionDuration
200 // after this point and reverts to plain text after.
201 welcomeStart time.Time
202
203 // extNotes are one-shot styled lines pushed by extensions via
204 // Notify / Display. They live above the editor (just below the
205 // transcript) until cleared by /clear or another reset.
206 extNotes []string
207}
208
209// welcomeVersionDuration is how long the welcome banner shows the
210// version suffix before reverting to the plain headline. 1.5s is
211// enough to read at a glance and keeps the splash short.
212const welcomeVersionDuration = 1500 * time.Millisecond
213
214// NewInteractive constructs an Interactive from cfg.
215func NewInteractive(cfg InteractiveConfig) *Interactive {
216 i := &Interactive{
217 cfg: cfg,
218 view: &tui.View{
219 Theme: cfg.Theme,
220 ImageProto: tui.DetectImageProtocol(),
221 },
222 ed: tui.NewEditor(cfg.Theme.FG256(cfg.Theme.Accent, "▌ ")),
223 rend: tui.NewRenderer(cfg.Terminal),
224 toolCalls: map[string]*tui.ToolCallView{},
225 dirty: make(chan struct{}, 8),
226 dialog: newLoginDialog(),
227 modelDialog: newModelDialog(),
228 sessionDialog: newSessionDialog(),
229 jumpDialog: newJumpDialog(),
230 btwDialog: newBtwDialog(),
231 skillsDialog: newSkillsDialog(),
232 changelogDialog: newChangelogDialog(),
233 confirmDialog: newConfirmDialog(),
234 logoutDialog: newLogoutDialog(),
235 telegramDialog: newTelegramDialog(),
236 suggest: newSlashSuggester(),
237 spin: newSpinner(),
238 }
239 if cfg.Agent != nil {
240 i.agent = cfg.Agent
241 i.view.Messages = cfg.Agent.Messages()
242 }
243 return i
244}
245
246// Run blocks until the user quits.
247func (i *Interactive) Run(ctx context.Context) error {
248 i.runCtx = ctx
249 term := i.cfg.Terminal
250 restore, err := term.EnterRaw()
251 if err != nil {
252 return err
253 }
254 defer restore()
255 defer func() {
256 if i.telegramBridge != nil {
257 i.telegramBridge.Stop()
258 }
259 }()
260
261 _, _ = term.Write([]byte(tui.SeqBracketedPasteOn))
262 _, _ = term.Write([]byte(tui.SeqAltScreenOn))
263 defer term.Write([]byte(tui.SeqAltScreenOff + tui.SeqBracketedPasteOff + tui.SeqShowCursor))
264
265 cols, rows := term.Size()
266 i.rend.Resize(cols, rows)
267 term.OnResize(func() {
268 c, r := term.Size()
269 i.rend.Resize(c, r)
270 // Force an immediate redraw on resize. The throttled invalidate
271 // path is fine for animation, but a window resize is a discrete
272 // user action where any visible delay (or stale frame) reads as
273 // brokenness. redraw() is mutex-safe; the worst that happens is
274 // a duplicate paint if the throttler is mid-flight, which is
275 // invisible.
276 i.redraw()
277 })
278
279 if i.cfg.InitialInput != "" {
280 i.ed.SetValue(i.cfg.InitialInput)
281 }
282
283 // Stamp the welcome time and schedule a one-shot redraw at the
284 // expiry so the version suffix disappears on its own even if the
285 // user hasn't typed anything yet.
286 i.welcomeStart = time.Now()
287 time.AfterFunc(welcomeVersionDuration, i.invalidate)
288
289 // If the agent was constructed with a pre-loaded transcript
290 // (--continue, --resume, --session) park the viewport on the
291 // most recent turn so the user lands looking at where the
292 // previous session left off rather than at the bottom of an
293 // already-rendered final reply.
294 if i.agent != nil {
295 if msgs := i.agent.Messages(); len(msgs) > 0 {
296 i.scrollToLastTurn(msgs)
297 }
298 }
299
300 // No credential at startup? Auto-open the login dialog, and mark
301 // the status line. The user can Esc out of the dialog if they
302 // want to dismiss it (e.g. to check /help or /exit first).
303 if i.agent == nil {
304 i.statusErr = "not logged in. pick a login method below or press esc to dismiss."
305 i.dialog.Open()
306 }
307
308 // Input goroutine.
309 keys := make(chan tui.Key, 8)
310 go func() {
311 reader := tui.NewReaderWithPeek(term.ReadByte, term.PeekByteTimeout)
312 for {
313 k, err := reader.Read()
314 if err != nil {
315 return
316 }
317 keys <- k
318 }
319 }()
320
321 // Subscribe to auth events.
322 var authEvents <-chan auth.Event
323 if i.cfg.AuthManager != nil {
324 authEvents = i.cfg.AuthManager.Events()
325 }
326
327 // Animation ticker: drives spinner and dialog-related redraws when
328 // nothing else changed. 120ms is slow enough that highlighting a huge
329 // transcript doesn't spin the cpu.
330 tick := time.NewTicker(120 * time.Millisecond)
331 defer tick.Stop()
332
333 // Redraw throttle: coalesce bursts of invalidate() calls so we paint
334 // at most once every redrawMinInterval. Huge tool-result dumps can
335 // fire hundreds of invalidations while the user is typing; without
336 // this, the input goroutine never gets CPU and keystrokes lag.
337 const redrawMinInterval = 16 * time.Millisecond
338 var lastRedraw time.Time
339 var pendingRedraw bool
340 var pendingTimer *time.Timer
341
342 drainPending := func() {
343 if pendingTimer != nil {
344 pendingTimer.Stop()
345 pendingTimer = nil
346 }
347 if pendingRedraw {
348 pendingRedraw = false
349 lastRedraw = time.Now()
350 i.redraw()
351 }
352 }
353
354 requestRedraw := func() {
355 since := time.Since(lastRedraw)
356 if since >= redrawMinInterval {
357 // Redrawing right now subsumes any pending redraw, so clear
358 // the throttle state. Without this, a pending flag stays
359 // stuck at true and subsequent invalidate() calls within
360 // redrawMinInterval get dropped — which is exactly how the
361 // final "turn finished" frame went missing until the user
362 // nudged the ui by typing or scrolling.
363 if pendingTimer != nil {
364 pendingTimer.Stop()
365 }
366 pendingRedraw = false
367 lastRedraw = time.Now()
368 i.redraw()
369 return
370 }
371 if pendingRedraw {
372 return // already scheduled
373 }
374 pendingRedraw = true
375 wait := redrawMinInterval - since
376 if pendingTimer == nil {
377 pendingTimer = time.AfterFunc(wait, func() {
378 // Poke the dirty channel so the main loop wakes and
379 // drains the pending redraw on its own goroutine. We
380 // can't call drainPending here directly — it touches
381 // closure state shared with the main loop.
382 i.invalidate()
383 })
384 } else {
385 pendingTimer.Reset(wait)
386 }
387 }
388
389 i.invalidate()
390
391 updates := i.cfg.UpdateInfoChan // nil-safe; nil channel blocks forever in select
392 changelog := i.cfg.ChangelogChan // single-shot, see case below
393
394 for {
395 select {
396 case <-ctx.Done():
397 return ctx.Err()
398 case k := <-keys:
399 if done := i.handleKey(ctx, k); done {
400 return nil
401 }
402 i.invalidate()
403 case ev := <-authEvents:
404 i.handleAuthEvent(ev)
405 i.invalidate()
406 case info, ok := <-updates:
407 if ok && info.Available {
408 i.mu.Lock()
409 i.updateInfo = info
410 i.mu.Unlock()
411 i.invalidate()
412 }
413 updates = nil // single-shot; subsequent iterations skip this case
414 case cl, ok := <-changelog:
415 if ok && cl.Body != "" {
416 i.changelogDialog.Open(cl.Version, cl.URL, cl.Body)
417 i.invalidate()
418 }
419 changelog = nil // single-shot
420 case <-i.dirty:
421 requestRedraw()
422 case <-tick.C:
423 // Always drain a pending redraw on the tick. This is the
424 // safety net that catches the case where the dirty channel
425 // was saturated when the final "turn finished" invalidate
426 // fired, or where the throttle scheduled a deferred redraw
427 // and the AfterFunc-driven invalidate got dropped on a
428 // full channel.
429 drainPending()
430 if i.busy || i.dialog.Active() || i.modelDialog.Active() || i.sessionDialog.Active() || i.jumpDialog.Active() || i.btwDialog.Active() || i.skillsDialog.Active() || i.changelogDialog.Active() || i.confirmDialog.Active() || i.logoutDialog.Active() || i.telegramDialog.Active() {
431 requestRedraw() // keep the spinner / dialog animation moving
432 }
433 }
434 }
435}
436
437func (i *Interactive) invalidate() {
438 select {
439 case i.dirty <- struct{}{}:
440 default:
441 }
442}
443
444// lastCols returns the current terminal width in columns.
445func (i *Interactive) lastCols() int {
446 cols, _ := i.cfg.Terminal.Size()
447 return cols
448}
449
450// chatPage returns the number of chat rows currently visible, used
451// as the page size for PageUp/PageDown.
452func (i *Interactive) chatPage() int {
453 _, rows := i.cfg.Terminal.Size()
454 p := rows - 6 // rough reservation for status + editor + a dialog line
455 if p < 4 {
456 p = 4
457 }
458 return p
459}
460
461// scrollBy adjusts the scroll offset. Positive = up (into history).
462// Clearing the parked-turn label when we're back at the bottom means
463// the "viewing turn N" footer goes away automatically as soon as you
464// scroll back to the live tail.
465func (i *Interactive) scrollBy(delta int) {
466 i.mu.Lock()
467 i.scrollOffset += delta
468 if i.scrollOffset < 0 {
469 i.scrollOffset = 0
470 }
471 if i.scrollOffset == 0 {
472 i.parkedTurn = 0
473 i.parkedTotal = 0
474 }
475 i.mu.Unlock()
476 i.invalidate()
477}
478
479// scrollToBottom pins the view to the latest content.
480func (i *Interactive) scrollToBottom() {
481 i.mu.Lock()
482 i.scrollOffset = 0
483 i.parkedTurn = 0
484 i.parkedTotal = 0
485 i.mu.Unlock()
486 i.invalidate()
487}
488
489func (i *Interactive) redraw() {
490 i.mu.Lock()
491 defer i.mu.Unlock()
492
493 cols, _ := i.cfg.Terminal.Size()
494 if i.agent != nil {
495 i.view.Messages = i.agent.Messages()
496 } else {
497 i.view.Messages = nil
498 }
499 i.view.Streaming = i.streaming.String()
500 i.view.StreamingActive = i.streamOn
501 // Belt and suspenders: if the transcript's last message is already an
502 // assistant message, the streaming view is stale (EvAssistantMessage may
503 // not have fired yet) — suppress it to avoid double-rendering the reply.
504 if n := len(i.view.Messages); n > 0 && i.view.Messages[n-1].Role == provider.RoleAssistant {
505 i.view.StreamingActive = false
506 }
507 // Live tool-call view: only shown while a turn is in flight. Once
508 // the agent is idle, every tool call has already been folded into
509 // the transcript (as assistant.ToolCallBlock + a tool-role message),
510 // so showing v.ToolCalls a second time would duplicate them below
511 // the final assistant text — which looks like the summary came
512 // "before" the tools.
513 i.view.ToolCalls = i.view.ToolCalls[:0]
514 if i.busy {
515 for _, id := range i.toolOrder {
516 if tc, ok := i.toolCalls[id]; ok {
517 i.view.ToolCalls = append(i.view.ToolCalls, *tc)
518 }
519 }
520 }
521 i.view.Err = i.statusErr
522
523 chat := i.view.Build(cols)
524
525 // Welcome banner: shown at the top of the chat area when there is
526 // no transcript yet. Disappears after the first message is sent.
527 // The version suffix is shown for welcomeVersionDuration after
528 // startup, then drops off automatically.
529 if len(i.view.Messages) == 0 && !i.streamOn && len(i.toolOrder) == 0 {
530 showVer := !i.welcomeStart.IsZero() && time.Since(i.welcomeStart) < welcomeVersionDuration
531 chat = append(welcomeBanner(i.cfg.Theme, i.cfg.Version, showVer), chat...)
532 }
533
534 // Update-available banner: prepended above everything else so it's
535 // the first thing the user sees when opening a new zot session.
536 // Once rendered, it stays until the user updates to a newer
537 // version — we don't persist a "dismissed" flag because this is
538 // cheap and re-showing it is how most users remember to update.
539 if i.updateInfo.Available {
540 banner := renderUpdateBanner(i.cfg.Theme, i.updateInfo, cols)
541 chat = append(banner, chat...)
542 }
543
544 // /help block: appended to the transcript so it appears at the
545 // bottom of the chat area (right above the status bar / editor).
546 // Prepending it would push long conversations off the top of the
547 // viewport, which users would miss entirely.
548 if len(i.helpBlock) > 0 {
549 chat = append(chat, i.helpBlock...)
550 }
551
552 if i.statusOK != "" {
553 // Hard-truncate the OK line to the visible width so a long
554 // session path ("resumed session: /Users/.../sessions/...")
555 // doesn't overflow past the right edge and look broken on a
556 // narrow terminal.
557 line := "✓ " + i.statusOK
558 if cols > 4 && len(line) > cols {
559 line = line[:cols-1] + "…"
560 }
561 chat = append(chat, i.cfg.Theme.FG256(i.cfg.Theme.Tool, line), "")
562 }
563
564 // Extension notes (notify / display) live just under the
565 // transcript, above the dialog/editor band. Cleared by /clear.
566 if len(i.extNotes) > 0 {
567 chat = append(chat, i.extNotes...)
568 chat = append(chat, "")
569 }
570
571 // Dialogs (login or model picker) render between chat and the editor.
572 var dialog []string
573 switch {
574 case i.dialog.Active():
575 dialog = i.dialog.Render(i.cfg.Theme, cols)
576 case i.modelDialog.Active():
577 dialog = i.modelDialog.Render(i.cfg.Theme, cols)
578 case i.sessionDialog.Active():
579 dialog = i.sessionDialog.Render(i.cfg.Theme, cols)
580 case i.jumpDialog.Active():
581 dialog = i.jumpDialog.Render(i.cfg.Theme, cols)
582 case i.btwDialog.Active():
583 dialog = i.btwDialog.Render(i.cfg.Theme, cols)
584 case i.skillsDialog.Active():
585 dialog = i.skillsDialog.Render(i.cfg.Theme, cols)
586 case i.changelogDialog.Active():
587 dialog = i.changelogDialog.Render(i.cfg.Theme, cols)
588 case i.confirmDialog.Active():
589 dialog = i.confirmDialog.Render(i.cfg.Theme, cols)
590 case i.logoutDialog.Active():
591 dialog = i.logoutDialog.Render(i.cfg.Theme, cols)
592 case i.telegramDialog.Active():
593 dialog = i.telegramDialog.Render(i.cfg.Theme, cols)
594 }
595
596 // Slash-command autocomplete: popup above the status line, only
597 // when the editor starts with "/" and no dialog is already open.
598 // Feed extension-registered commands into the suggester first so
599 // they show up in tab-complete + the popup alongside the built-ins.
600 if i.cfg.Extensions != nil {
601 catalog := i.cfg.Extensions.Commands()
602 extra := make([]slashCommand, 0, len(catalog))
603 for _, c := range catalog {
604 // The popup renders extension commands under a dedicated
605 // "── extensions ───" divider, so the description doesn't
606 // need to repeat the source. If the description is empty,
607 // fall back to the extension name so the row isn't blank.
608 desc := c.Description
609 if strings.TrimSpace(desc) == "" {
610 desc = "(" + c.Extension + ")"
611 }
612 extra = append(extra, slashCommand{
613 Name: "/" + c.Name,
614 Desc: desc,
615 })
616 }
617 i.suggest.SetExtra(extra)
618 }
619 var suggest []string
620 currentInput := i.ed.Value()
621 if len(dialog) == 0 && i.suggest.Active(currentInput) && !i.busy {
622 suggest = i.suggest.Render(currentInput, i.cfg.Theme, cols)
623 }
624
625 // Busy prefix shown at the far left of the status bar. The
626 // spinner glyph and its funny-line message share the `zot`
627 // label colour (Theme.Assistant) so the whole "who's working"
628 // band reads at a glance. Elapsed time stays muted because it
629 // drifts every second and shouldn't grab focus.
630 var busyPrefix string
631 if i.busy {
632 busyPrefix = fmt.Sprintf("%s %s · %s",
633 i.cfg.Theme.FG256(i.cfg.Theme.Assistant, i.spin.Frame()),
634 i.cfg.Theme.FG256(i.cfg.Theme.Assistant, i.spin.Message()),
635 i.cfg.Theme.FG256(i.cfg.Theme.Muted, i.spin.Elapsed().String()),
636 )
637 }
638
639 ctxMax := 0
640 if m, err := provider.FindModel(i.cfg.Provider, i.cfg.Model); err == nil {
641 ctxMax = m.ContextWindow
642 }
643 statusLines := tui.StatusBar(tui.StatusBarParams{
644 Theme: i.cfg.Theme,
645 Provider: i.cfg.Provider,
646 Model: i.cfg.Model,
647 Busy: i.busy,
648 BusyPrefix: busyPrefix,
649 CWD: i.cfg.CWD,
650 Locked: i.cfg.Sandbox.Locked(),
651 Usage: i.cumUsage,
652 Subscription: i.cfg.AuthMethod == "oauth",
653 ContextUsed: i.lastCtxInput,
654 ContextMax: ctxMax,
655 AutoCompacting: i.autoCompacting,
656 Telegram: i.telegramBridge != nil && i.telegramBridge.Active(),
657 Cols: cols,
658 })
659 edLines, curR, curC := i.ed.Render(cols)
660
661 // "Sliding in" chips for messages the user typed while a turn is
662 // in flight. Shown directly above the status bar so they're close
663 // to the editor but don't push the chat around.
664 var queue []string
665 if len(i.queued) > 0 {
666 for _, q := range i.queued {
667 label := i.cfg.Theme.FG256(i.cfg.Theme.Accent, "▸ sliding in: ")
668 text := truncateLine(q, cols-15)
669 queue = append(queue, label+i.cfg.Theme.FG256(i.cfg.Theme.Muted, text))
670 }
671 }
672
673 // Bottom-sticky sections (always visible, never scroll).
674 bottom := make([]string, 0, len(dialog)+len(suggest)+len(queue)+len(edLines)+1)
675 bottom = append(bottom, dialog...)
676 bottom = append(bottom, suggest...)
677 bottom = append(bottom, queue...)
678 bottom = append(bottom, statusLines...)
679 bottom = append(bottom, edLines...)
680
681 _, rows := i.cfg.Terminal.Size()
682 chatRows := rows - len(bottom)
683 if chatRows < 1 {
684 chatRows = 1
685 }
686
687 // Apply scroll offset to the chat slice.
688 maxOffset := len(chat) - chatRows
689 if maxOffset < 0 {
690 maxOffset = 0
691 }
692 if i.scrollOffset > maxOffset {
693 i.scrollOffset = maxOffset
694 }
695 if i.scrollOffset < 0 {
696 i.scrollOffset = 0
697 }
698
699 var visibleChat []string
700 if len(chat) <= chatRows {
701 visibleChat = chat
702 } else {
703 end := len(chat) - i.scrollOffset
704 start := end - chatRows
705 if start < 0 {
706 start = 0
707 }
708 visibleChat = chat[start:end]
709 }
710
711 // A tiny "scrolled up" indicator in the top-right of the chat pane
712 // so you know you're not at the bottom. When the viewport was
713 // parked by /jump we include the turn number so the user remembers
714 // they're reading history rather than the live conversation.
715 if i.scrollOffset > 0 && len(visibleChat) > 0 {
716 var text string
717 if i.parkedTurn > 0 && i.parkedTotal > 0 {
718 text = fmt.Sprintf(" ↑ viewing turn %d of %d · %d lines more below (pgdn / end)",
719 i.parkedTurn, i.parkedTotal, i.scrollOffset)
720 } else {
721 text = fmt.Sprintf(" ↑ %d lines more below (end to jump)", i.scrollOffset)
722 }
723 note := i.cfg.Theme.FG256(i.cfg.Theme.Muted, text)
724 visibleChat = append([]string{note}, visibleChat...)
725 if len(visibleChat) > chatRows {
726 visibleChat = visibleChat[:chatRows]
727 }
728 }
729
730 frame := make([]string, 0, len(visibleChat)+len(bottom))
731 frame = append(frame, visibleChat...)
732 frame = append(frame, bottom...)
733
734 cursorRow := len(visibleChat) + len(dialog) + len(suggest) + len(queue) + len(statusLines) + curR
735 cursorCol := curC
736 i.rend.Draw(frame, cursorRow, cursorCol)
737}
738
739// truncateLine shortens s so it fits within n display cells, with an
740// ellipsis if trimmed. Used by the "sliding in" chips so a pasted
741// novel doesn't blow past the status line.
742func truncateLine(s string, n int) string {
743 if n <= 0 {
744 return ""
745 }
746 // Collapse newlines — chips are single line.
747 s = strings.ReplaceAll(s, "\n", " ↩ ")
748 runes := []rune(s)
749 if len(runes) <= n {
750 return s
751 }
752 if n <= 1 {
753 return "…"
754 }
755 return string(runes[:n-1]) + "…"
756}
757
758// ctrlCExitWindow is how long after a ctrl+c press a *second* press
759// will exit instead of just clearing input. Long enough to be
760// deliberate (rules out accidental key chord), short enough that the
761// hint stays meaningful.
762const ctrlCExitWindow = 2 * time.Second
763
764// armCtrlCExit records the timestamp of the current ctrl+c so the next
765// one within ctrlCExitWindow exits.
766func (i *Interactive) armCtrlCExit() {
767 i.mu.Lock()
768 i.lastCtrlC = time.Now()
769 i.mu.Unlock()
770}
771
772// ctrlCExitArmed reports whether a previous ctrl+c was recent enough
773// that another press should now exit.
774func (i *Interactive) ctrlCExitArmed() bool {
775 i.mu.Lock()
776 t := i.lastCtrlC
777 i.mu.Unlock()
778 return !t.IsZero() && time.Since(t) <= ctrlCExitWindow
779}
780
781func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) {
782 // Any key that isn't ctrl+c invalidates an armed ctrl+c-exit, so
783 // pressing ctrl+c then typing then ctrl+c much later doesn't quit
784 // unexpectedly. The hint message also goes stale; clear it.
785 if k.Kind != tui.KeyCtrlC {
786 i.mu.Lock()
787 if !i.lastCtrlC.IsZero() {
788 i.lastCtrlC = time.Time{}
789 if strings.HasPrefix(i.statusOK, "input cleared") || strings.HasPrefix(i.statusOK, "press ctrl+c") {
790 i.statusOK = ""
791 }
792 }
793 i.mu.Unlock()
794 }
795
796 // Dialogs consume keys while open (except ctrl+c, which always closes them).
797
798 // Confirm dialog has highest priority: the agent goroutine is
799 // blocked waiting for an answer, so we must not let keys leak
800 // anywhere else while it's up.
801 if i.confirmDialog.Active() {
802 i.confirmDialog.HandleKey(k)
803 i.invalidate()
804 return false
805 }
806 if i.dialog.Active() {
807 if k.Kind == tui.KeyCtrlC {
808 i.dialog.Close()
809 if i.cfg.AuthManager != nil {
810 i.cfg.AuthManager.CancelOAuth()
811 }
812 return false
813 }
814 act := i.dialog.HandleKey(k)
815 if act.StartAPIKey {
816 i.startAPIKeyFlow(act.Provider)
817 }
818 if act.StartOAuth {
819 i.startOAuthFlow(act.Provider)
820 }
821 return false
822 }
823 if i.modelDialog.Active() {
824 if k.Kind == tui.KeyCtrlC {
825 i.modelDialog.Close()
826 return false
827 }
828 act := i.modelDialog.HandleKey(k)
829 if act.Select {
830 i.applyModelSelection(act.Provider, act.Model)
831 }
832 return false
833 }
834 if i.sessionDialog.Active() {
835 if k.Kind == tui.KeyCtrlC {
836 i.sessionDialog.Close()
837 return false
838 }
839 act := i.sessionDialog.HandleKey(k)
840 if act.Select {
841 i.applySessionSelection(act.Path)
842 }
843 return false
844 }
845 if i.logoutDialog.Active() {
846 if k.Kind == tui.KeyCtrlC {
847 i.logoutDialog.Close()
848 i.invalidate()
849 return false
850 }
851 act := i.logoutDialog.HandleKey(k)
852 if act.Select {
853 i.doLogout(act.Target)
854 }
855 i.invalidate()
856 return false
857 }
858 if i.telegramDialog.Active() {
859 if k.Kind == tui.KeyCtrlC {
860 i.telegramDialog.Close()
861 i.invalidate()
862 return false
863 }
864 act := i.telegramDialog.HandleKey(k)
865 if act.Select {
866 i.doTelegram(act.Action)
867 }
868 i.invalidate()
869 return false
870 }
871 if i.jumpDialog.Active() {
872 if k.Kind == tui.KeyCtrlC {
873 i.jumpDialog.Close()
874 return false
875 }
876 act := i.jumpDialog.HandleKey(k)
877 if act.Select {
878 i.applyJumpSelection(act.MessageIdx, act.TurnNo)
879 }
880 return false
881 }
882 if i.btwDialog.Active() {
883 if k.Kind == tui.KeyCtrlC {
884 i.btwDialog.Close()
885 i.invalidate()
886 return false
887 }
888 i.btwDialog.HandleKey(k, i.invalidate)
889 return false
890 }
891 if i.skillsDialog.Active() {
892 if k.Kind == tui.KeyCtrlC {
893 i.skillsDialog.Close()
894 i.invalidate()
895 return false
896 }
897 i.skillsDialog.HandleKey(k)
898 i.invalidate()
899 return false
900 }
901 if i.changelogDialog.Active() {
902 if closed := i.changelogDialog.HandleKey(k); closed {
903 // User dismissed; let the parent persist the
904 // LastChangelogShown marker via the close callback.
905 if i.cfg.OnChangelogDismiss != nil {
906 i.cfg.OnChangelogDismiss()
907 }
908 }
909 i.invalidate()
910 return false
911 }
912
913 // Global keys.
914 switch k.Kind {
915 case tui.KeyCtrlC:
916 // While busy: cancel the active turn (same as esc). The exit
917 // hint stays armed so a quick second ctrl+c after the turn
918 // dies still exits, matching habits from other repls.
919 if i.busy && i.cancelTurn != nil {
920 i.cancelTurn()
921 i.armCtrlCExit()
922 return false
923 }
924 // Idle: first press clears the editor (and any queued
925 // follow-up messages); a second press within ctrlCExitWindow
926 // exits. With both an empty editor and no queue the first
927 // press still just arms — require a deliberate double-tap.
928 hadInput := !i.ed.IsEmpty() || len(i.queued) > 0
929 if hadInput {
930 i.ed.Clear()
931 i.suggest.Reset()
932 i.mu.Lock()
933 i.queued = nil
934 i.statusOK = "input cleared"
935 i.statusErr = ""
936 i.mu.Unlock()
937 i.armCtrlCExit()
938 return false
939 }
940 if i.ctrlCExitArmed() {
941 return true
942 }
943 i.mu.Lock()
944 i.statusOK = "press ctrl+c again to exit"
945 i.statusErr = ""
946 i.mu.Unlock()
947 i.armCtrlCExit()
948 return false
949 case tui.KeyEsc:
950 // Esc interrupts a running turn. When idle, fall through so the
951 // editor can clear itself.
952 if i.busy && i.cancelTurn != nil {
953 i.cancelTurn()
954 // If a confirm dialog is pending, refuse it so the agent
955 // goroutine unblocks and the context cancellation can
956 // actually take effect.
957 i.confirmDialog.CancelAll("turn cancelled")
958 return false
959 }
960 case tui.KeyCtrlD:
961 if i.ed.IsEmpty() && !i.busy {
962 return true
963 }
964 case tui.KeyCtrlL:
965 i.rend.Clear()
966 i.invalidate()
967 return false
968 case tui.KeyCtrlO:
969 // Toggle expansion of collapsed tool results. Affects every tool
970 // call in the transcript — press again to re-collapse.
971 i.mu.Lock()
972 i.view.ExpandAll = !i.view.ExpandAll
973 i.mu.Unlock()
974 i.invalidate()
975 return false
976 case tui.KeyPageUp:
977 i.scrollBy(+i.chatPage())
978 return false
979 case tui.KeyPageDown:
980 i.scrollBy(-i.chatPage())
981 return false
982 case tui.KeyUp:
983 // Wheel-up in alt screen sends Up arrows on most terminals.
984 // When the editor is empty we use up/down for chat scroll —
985 // independently of whether the agent is busy, so users can
986 // scroll back through long streaming replies while they run.
987 if i.ed.IsEmpty() {
988 i.scrollBy(+3)
989 return false
990 }
991 case tui.KeyDown:
992 if i.ed.IsEmpty() {
993 if i.scrollOffset > 0 {
994 i.scrollBy(-3)
995 }
996 return false
997 }
998 }
999
1000 // Note: we intentionally do NOT gate the editor on i.busy here.
1001 // Typing while the agent is working is supported — submitted
1002 // messages are queued and delivered as follow-up turns when the
1003 // current turn ends. See the submit handler below.
1004
1005 if k.Kind == tui.KeyEnter && k.Alt {
1006 i.ed.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: '\n', Alt: true})
1007 return false
1008 }
1009
1010 // Slash suggestions: intercept up/down/tab/enter when the popup is visible.
1011 if i.suggest.Active(i.ed.Value()) {
1012 switch k.Kind {
1013 case tui.KeyUp:
1014 i.suggest.Up()
1015 return false
1016 case tui.KeyDown:
1017 i.suggest.Down()
1018 return false
1019 case tui.KeyTab:
1020 if name := i.suggest.Selection(i.ed.Value()); name != "" {
1021 i.ed.SetValue(name)
1022 i.suggest.Reset()
1023 }
1024 return false
1025 case tui.KeyEnter:
1026 // Enter on an ambiguous or partial slash prefix: complete to the
1027 // currently highlighted command and run it. That way typing
1028 // "/lo" + enter picks whichever of /login or /logout is selected
1029 // in the popup instead of submitting "/lo" as unknown. Also
1030 // clear the editor so the command doesn't linger after the
1031 // dialog opens/closes.
1032 if name := i.suggest.Selection(i.ed.Value()); name != "" {
1033 i.ed.PushHistory(name)
1034 i.ed.Clear()
1035 i.suggest.Reset()
1036 return i.runSlash(ctx, name)
1037 }
1038 case tui.KeyEsc:
1039 i.ed.Clear()
1040 i.suggest.Reset()
1041 return false
1042 }
1043 }
1044
1045 if submit := i.ed.HandleKey(k); submit {
1046 text := strings.TrimRight(i.ed.Value(), "\n")
1047 if text == "" {
1048 return false
1049 }
1050 i.ed.PushHistory(text)
1051 i.ed.Clear()
1052 i.suggest.Reset()
1053
1054 if looksLikeSlashCommand(text) {
1055 head := text
1056 rest := ""
1057 if idx := strings.IndexAny(text, " \t"); idx >= 0 {
1058 head = text[:idx]
1059 rest = strings.TrimSpace(text[idx:])
1060 }
1061 if !isKnownSlashCommand(text) {
1062 // Try extensions before giving up. Extensions register
1063 // commands by bare name (no leading slash); strip it here.
1064 extName := strings.TrimPrefix(head, "/")
1065 if i.cfg.Extensions != nil && i.cfg.Extensions.HasCommand(extName) {
1066 go i.invokeExtensionCommand(ctx, extName, rest)
1067 return false
1068 }
1069 i.mu.Lock()
1070 i.statusErr = "unknown command " + head + " — type /help to see the list"
1071 i.statusOK = ""
1072 i.mu.Unlock()
1073 return false
1074 }
1075 // Slash commands run regardless of busy state. Commands that
1076 // would mutate the transcript or replace the agent (/clear,
1077 // /compact, /logout, /login, /model) cancel the active turn
1078 // first and wait for the goroutine to wind down so they don't
1079 // race with a streaming response. Safe commands (/help,
1080 // /jump, /sessions, /jail, /unjail, /exit) run immediately
1081 // without disturbing the active turn.
1082 if slashCancelsTurn(head) {
1083 i.cancelAndWaitForIdle()
1084 }
1085 return i.runSlash(ctx, text)
1086 }
1087
1088 if i.agent == nil {
1089 i.mu.Lock()
1090 i.statusErr = "not logged in. type /login first."
1091 i.mu.Unlock()
1092 return false
1093 }
1094 // If a turn is already in flight, queue this prompt instead of
1095 // starting a second one. The drain loop at the end of startTurn
1096 // will pick it up when the current turn finishes.
1097 i.mu.Lock()
1098 if i.busy {
1099 i.queued = append(i.queued, text)
1100 i.mu.Unlock()
1101 i.invalidate()
1102 return false
1103 }
1104 i.mu.Unlock()
1105 i.startTurn(ctx, text)
1106 }
1107 return false
1108}
1109
1110// invokeExtensionCommand fires an extension-registered slash command
1111// in a background goroutine, awaits the response, and applies the
1112// requested action (prompt / insert / display / noop). Errors and
1113// timeouts surface as a status_err line.
1114func (i *Interactive) invokeExtensionCommand(ctx context.Context, name, args string) {
1115 resp, err := i.cfg.Extensions.Invoke(ctx, name, args, 30*time.Second)
1116 if err != nil {
1117 i.mu.Lock()
1118 i.statusErr = "extension /" + name + ": " + err.Error()
1119 i.statusOK = ""
1120 i.mu.Unlock()
1121 i.invalidate()
1122 return
1123 }
1124 if resp.Error != "" {
1125 i.mu.Lock()
1126 i.statusErr = "extension /" + name + ": " + resp.Error
1127 i.statusOK = ""
1128 i.mu.Unlock()
1129 i.invalidate()
1130 return
1131 }
1132 switch resp.Action {
1133 case "prompt":
1134 if strings.TrimSpace(resp.Prompt) == "" {
1135 return
1136 }
1137 i.startTurn(i.runCtx, resp.Prompt)
1138 case "insert":
1139 i.ed.Insert(resp.Insert)
1140 i.invalidate()
1141 case "display":
1142 i.appendExtensionNote(name, resp.Display, "info")
1143 case "noop", "":
1144 // nothing
1145 default:
1146 i.mu.Lock()
1147 i.statusErr = "extension /" + name + ": unknown action " + resp.Action
1148 i.mu.Unlock()
1149 i.invalidate()
1150 }
1151}
1152
1153// appendExtensionNote renders an extension-originated note in the
1154// chat. Levels: "info" (muted), "warn" (warning), "error" (error),
1155// "success" (tool/ok green).
1156func (i *Interactive) appendExtensionNote(extName, msg, level string) {
1157 if msg == "" {
1158 return
1159 }
1160 i.mu.Lock()
1161 defer i.mu.Unlock()
1162 color := i.cfg.Theme.Muted
1163 switch level {
1164 case "warn":
1165 color = i.cfg.Theme.Warning
1166 case "error":
1167 color = i.cfg.Theme.Error
1168 case "success":
1169 color = i.cfg.Theme.Tool
1170 }
1171 prefix := i.cfg.Theme.FG256(i.cfg.Theme.Accent, "["+extName+"] ")
1172 for _, line := range strings.Split(msg, "\n") {
1173 i.statusOK = "" // clear any stale ok
1174 i.statusErr = ""
1175 i.extNotes = append(i.extNotes, prefix+i.cfg.Theme.FG256(color, line))
1176 }
1177}
1178
1179// HostHooks implementation for the extension manager. The manager
1180// holds an interface, not a concrete *Interactive, so these methods
1181// are the only thing the manager sees.
1182
1183// Notify is the manager's NotifyFromExt entry point.
1184func (i *Interactive) Notify(extName, level, message string) {
1185 i.appendExtensionNote(extName, message, level)
1186 i.invalidate()
1187}
1188
1189// Submit feeds text through the agent loop as if the user had typed it.
1190func (i *Interactive) Submit(text string) {
1191 i.startTurn(i.runCtx, text)
1192}
1193
1194// SubmitOrQueue runs text immediately if the agent is idle, or
1195// appends it to the pending queue if a turn is already in flight.
1196// Used by the telegram bridge (and by the editor submit path) so
1197// both input sources share the same "queue behind an active turn"
1198// semantics. Images are ignored for now — only the text prompt is
1199// forwarded — because the queued-prompt path is text-only; a
1200// follow-up can expand the queue entry to carry images.
1201func (i *Interactive) SubmitOrQueue(text string, images []provider.ImageBlock) {
1202 _ = images // reserved for future queued-with-images support
1203 i.mu.Lock()
1204 if i.agent == nil {
1205 i.statusErr = "not logged in. type /login first."
1206 i.mu.Unlock()
1207 i.invalidate()
1208 return
1209 }
1210 if i.busy {
1211 i.queued = append(i.queued, text)
1212 i.mu.Unlock()
1213 i.invalidate()
1214 return
1215 }
1216 i.mu.Unlock()
1217 i.startTurn(i.runCtx, text)
1218}
1219
1220// CancelTurn aborts the active turn if one is running. Used by the
1221// telegram bridge when the paired user sends /stop.
1222func (i *Interactive) CancelTurn() {
1223 i.mu.Lock()
1224 cancel := i.cancelTurn
1225 i.mu.Unlock()
1226 if cancel != nil {
1227 cancel()
1228 }
1229}
1230
1231// Insert places text at the cursor in the editor.
1232func (i *Interactive) Insert(text string) {
1233 i.ed.Insert(text)
1234 i.invalidate()
1235}
1236
1237// Display appends a styled note from extName to the chat without a
1238// model call.
1239func (i *Interactive) Display(extName, text string) {
1240 i.appendExtensionNote(extName, text, "info")
1241 i.invalidate()
1242}
1243
1244func (i *Interactive) runSlash(ctx context.Context, cmd string) (done bool) {
1245 parts := strings.Fields(cmd)
1246 switch parts[0] {
1247 case "/exit":
1248 return true
1249 case "/clear":
1250 if i.agent != nil {
1251 i.agent.SetMessages(nil)
1252 }
1253 i.mu.Lock()
1254 i.toolCalls = map[string]*tui.ToolCallView{}
1255 i.toolOrder = nil
1256 i.statusErr = ""
1257 i.statusOK = ""
1258 i.helpBlock = nil
1259 i.parkedTurn = 0
1260 i.parkedTotal = 0
1261 i.scrollOffset = 0
1262 i.extNotes = nil
1263 i.view.InvalidateRenderCache()
1264 i.mu.Unlock()
1265 case "/help":
1266 i.mu.Lock()
1267 i.helpBlock = renderHelpBlock(i.cfg.Theme, i.lastCols())
1268 i.statusErr = ""
1269 i.statusOK = ""
1270 // Pin the viewport to the newest content so the help block,
1271 // which we just appended to the end of the transcript, is
1272 // what the user actually sees.
1273 i.scrollOffset = 0
1274 i.mu.Unlock()
1275 case "/login":
1276 i.dialog.Open()
1277 case "/logout":
1278 if len(parts) >= 2 {
1279 // Explicit target: /logout anthropic | openai | all
1280 i.doLogout(parts[1])
1281 break
1282 }
1283 // No arg: open the picker over whichever providers are
1284 // currently logged in. If nothing's logged in, bail with a
1285 // status line.
1286 i.openLogoutDialog()
1287 case "/model":
1288 if len(parts) >= 2 {
1289 i.applyModelSelection("", parts[1])
1290 } else {
1291 i.modelDialog.Open(i.cfg.Model)
1292 }
1293 case "/sessions":
1294 i.sessionDialog.Open(i.cfg.ZotHome, i.cfg.CWD)
1295 case "/jump":
1296 i.openJumpDialog(parts[1:])
1297 case "/btw":
1298 i.openBtwDialog(parts[1:])
1299 case "/skills":
1300 i.openSkillsDialog()
1301 case "/compact":
1302 i.runCompact(ctx, false)
1303 case "/jail":
1304 if i.cfg.Sandbox == nil {
1305 i.mu.Lock()
1306 i.statusErr = "sandbox not available in this build"
1307 i.mu.Unlock()
1308 break
1309 }
1310 i.cfg.Sandbox.Lock()
1311 i.mu.Lock()
1312 i.statusOK = "jailed to " + i.cfg.CWD + " (tools cannot touch paths outside this directory)"
1313 i.statusErr = ""
1314 i.mu.Unlock()
1315 case "/unjail":
1316 if i.cfg.Sandbox == nil {
1317 i.mu.Lock()
1318 i.statusErr = "sandbox not available in this build"
1319 i.mu.Unlock()
1320 break
1321 }
1322 i.cfg.Sandbox.Unlock()
1323 i.mu.Lock()
1324 i.statusOK = "unjailed"
1325 i.statusErr = ""
1326 i.mu.Unlock()
1327 case "/reload-ext":
1328 i.runReloadExt(ctx)
1329 case "/yolo":
1330 i.runYoloOn()
1331 case "/telegram", "/tg":
1332 if len(parts) >= 2 {
1333 i.doTelegram(parts[1])
1334 break
1335 }
1336 i.openTelegramDialog()
1337 default:
1338 // Last-resort fallback: try the extension manager. Built-in
1339 // cases above always win; this branch only fires for slash
1340 // commands the extension manager registered. Same routing as
1341 // the editor's submit-handler dispatch path so the autocomplete
1342 // "enter on highlighted suggestion" flow also works.
1343 extName := strings.TrimPrefix(parts[0], "/")
1344 if i.cfg.Extensions != nil && i.cfg.Extensions.HasCommand(extName) {
1345 rest := ""
1346 if len(parts) > 1 {
1347 rest = strings.Join(parts[1:], " ")
1348 }
1349 go i.invokeExtensionCommand(ctx, extName, rest)
1350 return false
1351 }
1352 i.mu.Lock()
1353 i.statusErr = "unknown command: " + parts[0]
1354 i.mu.Unlock()
1355 }
1356 return false
1357}
1358
1359// openLogoutDialog shows the provider picker for `/logout` with no
1360// argument. Only providers the user is currently logged into are
1361// listed, plus an "all" entry when more than one is present. If
1362// nothing's logged in, writes a status line instead of opening an
1363// empty dialog.
1364func (i *Interactive) openLogoutDialog() {
1365 if i.cfg.AuthManager == nil {
1366 i.mu.Lock()
1367 i.statusErr = "no auth manager configured"
1368 i.mu.Unlock()
1369 i.invalidate()
1370 return
1371 }
1372 store := i.cfg.AuthManager.Store()
1373 if store == nil {
1374 i.mu.Lock()
1375 i.statusErr = "auth store is not available"
1376 i.mu.Unlock()
1377 i.invalidate()
1378 return
1379 }
1380 creds, err := store.Load()
1381 if err != nil {
1382 i.mu.Lock()
1383 i.statusErr = "read auth store: " + err.Error()
1384 i.mu.Unlock()
1385 i.invalidate()
1386 return
1387 }
1388
1389 var items []logoutItem
1390 for _, p := range []string{"anthropic", "openai"} {
1391 if creds.Has(p) {
1392 items = append(items, logoutItem{
1393 label: p,
1394 target: p,
1395 method: creds.Method(p),
1396 })
1397 }
1398 }
1399 if len(items) == 0 {
1400 i.mu.Lock()
1401 i.statusOK = "no credentials stored; already logged out"
1402 i.statusErr = ""
1403 i.mu.Unlock()
1404 i.invalidate()
1405 return
1406 }
1407 if len(items) > 1 {
1408 items = append(items, logoutItem{label: "all", target: "all"})
1409 }
1410
1411 i.logoutDialog.Open(items)
1412 i.invalidate()
1413}
1414
1415// doLogout clears credentials for the given provider (or all providers)
1416// from auth.json. If the active agent was using those credentials, it
1417// is torn down so the user is forced through /login before their next
1418// prompt.
1419//
1420// target: "anthropic" | "openai" | "all"
1421func (i *Interactive) doLogout(target string) {
1422 if i.cfg.AuthManager == nil {
1423 i.mu.Lock()
1424 i.statusErr = "no auth manager configured"
1425 i.mu.Unlock()
1426 return
1427 }
1428 store := i.cfg.AuthManager.Store()
1429 if store == nil {
1430 i.mu.Lock()
1431 i.statusErr = "auth store is not available"
1432 i.mu.Unlock()
1433 return
1434 }
1435
1436 var providers []string
1437 switch target {
1438 case "", "all":
1439 providers = []string{"anthropic", "openai"}
1440 case "anthropic", "openai":
1441 providers = []string{target}
1442 default:
1443 i.mu.Lock()
1444 i.statusErr = "unknown provider: " + target + " (use anthropic, openai, or all)"
1445 i.mu.Unlock()
1446 return
1447 }
1448
1449 var errs []string
1450 clearedCurrent := false
1451 for _, p := range providers {
1452 if err := store.Clear(p); err != nil {
1453 errs = append(errs, p+": "+err.Error())
1454 continue
1455 }
1456 if p == i.cfg.Provider {
1457 clearedCurrent = true
1458 }
1459 }
1460
1461 i.mu.Lock()
1462 defer i.mu.Unlock()
1463 if len(errs) > 0 {
1464 i.statusErr = "logout errors: " + strings.Join(errs, "; ")
1465 return
1466 }
1467 i.statusErr = ""
1468 if clearedCurrent {
1469 // The running agent was using a credential we just wiped. Drop
1470 // it so prompts can't go out with the stale client, and hint at
1471 // /login.
1472 i.agent = nil
1473 i.statusOK = "logged out of " + strings.Join(providers, ", ") + ". type /login to sign back in."
1474 } else {
1475 i.statusOK = "logged out of " + strings.Join(providers, ", ")
1476 }
1477}
1478
1479func (i *Interactive) startAPIKeyFlow(provider string) {
1480 url, err := i.cfg.AuthManager.StartAPIKey(provider)
1481 if err != nil {
1482 i.dialog.ShowResult(false, err.Error())
1483 return
1484 }
1485 i.dialog.ShowWaiting(url)
1486}
1487
1488func (i *Interactive) startOAuthFlow(provider string) {
1489 url, err := i.cfg.AuthManager.StartOAuth(provider)
1490 if err != nil {
1491 i.dialog.ShowResult(false, err.Error())
1492 return
1493 }
1494 i.dialog.ShowWaiting(url)
1495}
1496
1497// applyModelSelection switches the active model (and provider, if the
1498// new model belongs to a different one). It rebuilds the underlying
1499// client when needed so the provider wire-protocol matches.
1500// cancelAndWaitForIdle cancels the active turn (if any) and blocks
1501// briefly until the turn goroutine has updated i.busy = false. Used
1502// before destructive slash commands so transcript-mutating work
1503// (/clear, /compact, /logout, /login completion, cross-provider
1504// /model swap) doesn't race with the still-running stream.
1505//
1506// The wait is bounded; if the turn doesn't release within the timeout
1507// we proceed anyway. Worst case is a brief overlap that the agent's
1508// own mutex protects against.
1509func (i *Interactive) cancelAndWaitForIdle() {
1510 i.mu.Lock()
1511 busy := i.busy
1512 cancel := i.cancelTurn
1513 i.mu.Unlock()
1514 if !busy {
1515 return
1516 }
1517 if cancel != nil {
1518 cancel()
1519 }
1520 deadline := time.Now().Add(2 * time.Second)
1521 for time.Now().Before(deadline) {
1522 i.mu.Lock()
1523 done := !i.busy
1524 i.mu.Unlock()
1525 if done {
1526 return
1527 }
1528 time.Sleep(10 * time.Millisecond)
1529 }
1530}
1531
1532// openBtwDialog opens the side-chat overlay with a frozen snapshot
1533// of the current main session. The optional argument is auto-
1534// submitted as the first question, so '/btw does X work?' fires the
1535// model call immediately instead of just opening an empty dialog.
1536func (i *Interactive) openBtwDialog(args []string) {
1537 if i.agent == nil {
1538 i.mu.Lock()
1539 i.statusErr = "not logged in. type /login first."
1540 i.mu.Unlock()
1541 return
1542 }
1543 seed := strings.TrimSpace(strings.Join(args, " "))
1544 i.btwDialog.Open(i.cfg.Theme, i.agent, i.agent.System, i.cfg.Model, seed)
1545 i.invalidate()
1546}
1547
1548// openSkillsDialog opens the skill inspector. The picker reflects
1549// whatever SkillSnapshot returns at call time, so edits to a
1550// SKILL.md made during a session show up on the next /skills.
1551func (i *Interactive) openSkillsDialog() {
1552 var list []*skills.Skill
1553 if i.cfg.SkillSnapshot != nil {
1554 list = i.cfg.SkillSnapshot()
1555 }
1556 i.skillsDialog.Open(list)
1557 i.invalidate()
1558}
1559
1560// openJumpDialog builds a /jump picker from the current transcript.
1561// If the user typed "/jump foo" with a filter and it matches exactly
1562// one turn, jump there directly without showing the dialog.
1563func (i *Interactive) openJumpDialog(args []string) {
1564 if i.view == nil || len(i.view.Messages) == 0 {
1565 i.mu.Lock()
1566 i.statusErr = "nothing to jump to \u2014 the session is empty"
1567 i.mu.Unlock()
1568 return
1569 }
1570 filter := strings.TrimSpace(strings.Join(args, " "))
1571 i.jumpDialog.Open(i.view.Messages, filter)
1572 // Shortcut: with a filter argument that matches exactly one turn,
1573 // jump immediately and skip the picker.
1574 if filter != "" {
1575 if tgts := i.jumpDialog.Targets(); len(tgts) == 1 {
1576 t := tgts[0]
1577 i.jumpDialog.Close()
1578 i.applyJumpSelection(t.MessageIdx, t.TurnNo)
1579 }
1580 }
1581}
1582
1583// applyJumpSelection scrolls the chat viewport so the user message at
1584// msgIdx is visible at (or near) the top of the chat area. Uses the
1585// anchor slice returned by view.BuildWithAnchors so the mapping from
1586// message index to row is exact, regardless of variable-height tool
1587// blocks above the target.
1588func (i *Interactive) applyJumpSelection(msgIdx, turnNo int) {
1589 cols := i.lastCols()
1590 chat, anchors := i.view.BuildWithAnchors(cols)
1591 var row int
1592 found := false
1593 for _, a := range anchors {
1594 if a.MessageIdx == msgIdx {
1595 row = a.Row
1596 found = true
1597 break
1598 }
1599 }
1600 if !found {
1601 i.mu.Lock()
1602 i.statusErr = "could not resolve jump target"
1603 i.mu.Unlock()
1604 return
1605 }
1606
1607 chatLen := len(chat)
1608 page := i.chatPage()
1609 if page < 1 {
1610 page = 1
1611 }
1612 // scrollOffset is measured from the bottom of the chat slice, so
1613 // to place `row` at the top of the viewport we want:
1614 // chatLen - scrollOffset - page == row
1615 // Solve for scrollOffset and clamp to [0, chatLen-page].
1616 offset := chatLen - (row + page)
1617 if offset < 0 {
1618 offset = 0
1619 }
1620 maxOffset := chatLen - page
1621 if maxOffset < 0 {
1622 maxOffset = 0
1623 }
1624 if offset > maxOffset {
1625 offset = maxOffset
1626 }
1627
1628 i.mu.Lock()
1629 i.scrollOffset = offset
1630 i.parkedTurn = turnNo
1631 i.parkedTotal = totalTurnsLocked(i.view.Messages)
1632 i.statusOK = fmt.Sprintf("jumped to turn %d", turnNo)
1633 i.statusErr = ""
1634 i.mu.Unlock()
1635}
1636
1637// totalTurnsLocked counts user messages in the transcript. Caller is
1638// assumed to hold i.mu (the name is a mild reminder; this function
1639// itself doesn't touch shared state beyond the slice it's handed).
1640func totalTurnsLocked(msgs []provider.Message) int {
1641 n := 0
1642 for _, m := range msgs {
1643 if m.Role == provider.RoleUser {
1644 n++
1645 }
1646 }
1647 return n
1648}
1649
1650// applySessionSelection loads the given session via the cli-provided
1651// callback and parks the viewport on the last turn so the user lands
1652// looking at where the conversation left off (their last prompt at the
1653// top of the chat, the assistant's last reply right below). Older
1654// history is one scroll up; pgdn or end snaps to the current tail.
1655//
1656// Without this, scrollOffset stayed at 0 (pinned to the live tail),
1657// which on a long resumed session showed only the last few rows of
1658// the final assistant message — the user read that as "only one liner
1659// happened, the resume didn't work".
1660func (i *Interactive) applySessionSelection(path string) {
1661 if i.cfg.LoadSession == nil {
1662 i.mu.Lock()
1663 i.statusErr = "session loading is not wired in this build"
1664 i.mu.Unlock()
1665 return
1666 }
1667 if err := i.cfg.LoadSession(path); err != nil {
1668 i.mu.Lock()
1669 i.statusErr = err.Error()
1670 i.mu.Unlock()
1671 return
1672 }
1673
1674 i.mu.Lock()
1675 i.statusOK = "resumed session: " + path
1676 i.statusErr = ""
1677 i.parkedTurn = 0
1678 i.parkedTotal = 0
1679 i.view.InvalidateRenderCache()
1680 // Pull the freshly-loaded transcript into the view so the anchor
1681 // math below sees the post-resume messages, not the empty pre-load
1682 // state. redraw() does the same on its next pass; we just front-run
1683 // it here to compute the scroll target.
1684 if i.agent != nil {
1685 i.view.Messages = i.agent.Messages()
1686 }
1687 msgs := i.view.Messages
1688 i.mu.Unlock()
1689
1690 i.scrollToLastTurn(msgs)
1691}
1692
1693// scrollToLastTurn parks the viewport at the most recent user turn,
1694// or at the top if the transcript has no user messages. Used after
1695// resume so the user lands looking at where they left off.
1696func (i *Interactive) scrollToLastTurn(msgs []provider.Message) {
1697 if len(msgs) == 0 {
1698 i.mu.Lock()
1699 i.scrollOffset = 0
1700 i.mu.Unlock()
1701 return
1702 }
1703 // Find the last user message index.
1704 lastUser := -1
1705 turnNo, totalTurns := 0, 0
1706 for idx, m := range msgs {
1707 if m.Role == provider.RoleUser {
1708 totalTurns++
1709 lastUser = idx
1710 }
1711 }
1712 if lastUser < 0 {
1713 i.mu.Lock()
1714 i.scrollOffset = 0
1715 i.mu.Unlock()
1716 return
1717 }
1718 turnNo = totalTurns
1719
1720 cols := i.lastCols()
1721 chat, anchors := i.view.BuildWithAnchors(cols)
1722 var row int
1723 found := false
1724 for _, a := range anchors {
1725 if a.MessageIdx == lastUser {
1726 row = a.Row
1727 found = true
1728 break
1729 }
1730 }
1731 if !found {
1732 i.mu.Lock()
1733 i.scrollOffset = 0
1734 i.mu.Unlock()
1735 return
1736 }
1737
1738 chatLen := len(chat)
1739 page := i.chatPage()
1740 if page < 1 {
1741 page = 1
1742 }
1743 offset := chatLen - (row + page)
1744 if offset < 0 {
1745 offset = 0
1746 }
1747 maxOffset := chatLen - page
1748 if maxOffset < 0 {
1749 maxOffset = 0
1750 }
1751 if offset > maxOffset {
1752 offset = maxOffset
1753 }
1754
1755 i.mu.Lock()
1756 i.scrollOffset = offset
1757 // Mark the parked-turn footer so the user sees "viewing turn N of
1758 // M · pgdn to catch up" — same affordance as /jump. Tells them at
1759 // a glance that they're looking at history, not the live tail.
1760 if offset > 0 {
1761 i.parkedTurn = turnNo
1762 i.parkedTotal = totalTurns
1763 }
1764 i.mu.Unlock()
1765 i.invalidate()
1766}
1767
1768func (i *Interactive) applyModelSelection(prov, model string) {
1769 if model == "" {
1770 return
1771 }
1772 m, err := provider.FindModel(prov, model)
1773 if err != nil {
1774 i.mu.Lock()
1775 i.statusErr = err.Error()
1776 i.mu.Unlock()
1777 return
1778 }
1779 // Same provider? Just swap the model on the existing agent.
1780 if i.agent != nil && m.Provider == i.cfg.Provider {
1781 i.mu.Lock()
1782 i.cfg.Model = m.ID
1783 i.agent.Model = m.ID
1784 i.statusOK = "model: " + m.ID
1785 i.statusErr = ""
1786 i.mu.Unlock()
1787 if i.cfg.PersistModel != nil {
1788 i.cfg.PersistModel(i.cfg.Provider, m.ID)
1789 }
1790 return
1791 }
1792 // Different provider: rebuild agent (needs credentials for target provider).
1793 if i.cfg.BuildAgentFor == nil {
1794 i.mu.Lock()
1795 i.statusErr = "cannot switch provider: no builder configured"
1796 i.mu.Unlock()
1797 return
1798 }
1799 // Snapshot the current transcript and cumulative usage BEFORE we
1800 // build the replacement agent so we can hand them off. Without
1801 // this the user perceives the entire session as wiped on a
1802 // cross-provider /model swap.
1803 var carryMsgs []provider.Message
1804 var carryCost provider.Usage
1805 if i.agent != nil {
1806 carryMsgs = i.agent.Messages()
1807 carryCost = i.agent.Cost()
1808 }
1809
1810 ag, p, md, err := i.cfg.BuildAgentFor(m.Provider, m.ID)
1811 if err != nil {
1812 i.mu.Lock()
1813 i.statusErr = err.Error()
1814 i.mu.Unlock()
1815 return
1816 }
1817
1818 // Replay the transcript and seed the cost on the freshly-built
1819 // agent. Messages travel cleanly between providers because they
1820 // use the same provider.Message shape; tool-call ids are local
1821 // to a turn so cross-provider continuation never confuses the
1822 // new model (it just sees the assistant's reply, no orphan
1823 // tool_use blocks because /model swaps are gated to idle state).
1824 if len(carryMsgs) > 0 {
1825 ag.SetMessages(carryMsgs)
1826 }
1827 ag.SeedCost(carryCost)
1828
1829 i.mu.Lock()
1830 i.agent = ag
1831 i.cfg.Provider = p
1832 i.cfg.Model = md
1833 i.statusOK = "switched to " + p + " / " + md
1834 i.statusErr = ""
1835 // Render cache keys are width+content based, so the new agent's
1836 // identical messages will reuse the existing entries. Nothing
1837 // to invalidate.
1838 i.mu.Unlock()
1839 if i.cfg.PersistModel != nil {
1840 i.cfg.PersistModel(p, md)
1841 }
1842}
1843
1844func (i *Interactive) handleAuthEvent(ev auth.Event) {
1845 switch ev.Kind {
1846 case "started":
1847 i.dialog.ShowWaiting(ev.URL)
1848 case "browser_open":
1849 // no-op
1850 case "error":
1851 i.dialog.ShowResult(false, ev.Message)
1852 case "success":
1853 // Rebuild the agent with the fresh credential.
1854 ag, prov, model, err := i.cfg.BuildAgent()
1855 if err != nil {
1856 i.dialog.ShowResult(false, err.Error())
1857 return
1858 }
1859 i.mu.Lock()
1860 i.agent = ag
1861 i.cfg.Provider = prov
1862 i.cfg.Model = model
1863 i.statusErr = ""
1864 i.statusOK = "logged in to " + ev.Provider + " via " + ev.Method
1865 i.mu.Unlock()
1866 i.dialog.ShowResult(true, "")
1867 }
1868}
1869
1870// runCompact invokes core.Agent.Compact and reflects the progress in
1871// the tui. It runs in a goroutine so the ui stays responsive; esc/ctrl+c
1872// cancel via the same cancelTurn channel used for normal turns.
1873//
1874// When auto is true the spinner message is pinned to "condensing
1875// history" and the status bar surfaces "(auto)" next to the context
1876// percentage so it's obvious the system triggered this, not the user.
1877func (i *Interactive) runCompact(parent context.Context, auto bool) {
1878 if i.agent == nil {
1879 i.mu.Lock()
1880 i.statusErr = "not logged in. type /login first."
1881 i.mu.Unlock()
1882 return
1883 }
1884 ctx, cancel := context.WithCancel(parent)
1885 i.mu.Lock()
1886 i.busy = true
1887 if auto {
1888 i.spin.StartFixed("condensing history")
1889 i.autoCompacting = true
1890 i.statusOK = "condensing history… (esc to cancel)"
1891 } else {
1892 i.spin.Start()
1893 i.statusOK = "compacting..."
1894 }
1895 i.cancelTurn = cancel
1896 i.statusErr = ""
1897 i.streaming.Reset()
1898 i.streamOn = true
1899 i.scrollOffset = 0
1900 i.helpBlock = nil
1901 i.mu.Unlock()
1902 i.invalidate()
1903
1904 go func() {
1905 sink := func(delta string) {
1906 i.mu.Lock()
1907 i.streaming.WriteString(delta)
1908 i.mu.Unlock()
1909 i.invalidate()
1910 }
1911 summary, err := i.agent.Compact(ctx, 4, sink)
1912 i.mu.Lock()
1913 i.busy = false
1914 i.streamOn = false
1915 i.streaming.Reset()
1916 i.cancelTurn = nil
1917 i.autoCompacting = false
1918 switch {
1919 case err != nil && ctx.Err() != nil:
1920 i.statusErr = ""
1921 if auto {
1922 i.statusOK = "auto-condense cancelled"
1923 } else {
1924 i.statusOK = "compaction cancelled"
1925 }
1926 case err != nil:
1927 i.statusErr = "compaction failed: " + err.Error()
1928 i.statusOK = ""
1929 default:
1930 i.statusErr = ""
1931 i.statusOK = fmt.Sprintf("compacted transcript (%d chars of summary)", len(summary))
1932 i.lastCtxInput = 0 // reset; next turn will get a fresh measurement
1933 i.toolCalls = map[string]*tui.ToolCallView{}
1934 i.toolOrder = nil
1935 // Transcript was rewritten in place — purge the per-message
1936 // render cache so stale entries keyed on the old messages
1937 // don't linger.
1938 i.view.InvalidateRenderCache()
1939 }
1940 i.mu.Unlock()
1941 i.invalidate()
1942 }()
1943}
1944
1945func (i *Interactive) startTurn(parent context.Context, prompt string) {
1946 if i.agent == nil {
1947 return
1948 }
1949 ctx, cancel := context.WithCancel(parent)
1950 i.mu.Lock()
1951 i.busy = true
1952 i.spin.Start()
1953 i.cancelTurn = cancel
1954 i.statusErr = ""
1955 i.statusOK = ""
1956 i.streaming.Reset()
1957 i.streamOn = true
1958 i.toolCalls = map[string]*tui.ToolCallView{}
1959 i.toolOrder = nil
1960 i.scrollOffset = 0 // jump back to the bottom on new turn
1961 i.parkedTurn = 0 // starting a turn clears the /jump parked state
1962 i.parkedTotal = 0
1963 i.helpBlock = nil // hide the help block once the user asks something
1964 i.mu.Unlock()
1965 i.invalidate()
1966
1967 sink := func(ev core.AgentEvent) {
1968 i.handleEvent(ev)
1969 i.invalidate()
1970 }
1971
1972 go func() {
1973 err := i.agent.Prompt(ctx, prompt, nil, sink)
1974 i.mu.Lock()
1975 i.busy = false
1976 i.streamOn = false
1977 i.cancelTurn = nil
1978 if err != nil && ctx.Err() == nil {
1979 i.statusErr = err.Error()
1980 }
1981 // Pop the next queued message, if any, and relaunch.
1982 var next string
1983 var hasNext bool
1984 if len(i.queued) > 0 && ctx.Err() == nil && err == nil {
1985 next, i.queued = i.queued[0], i.queued[1:]
1986 hasNext = true
1987 }
1988 // If the turn was cancelled or errored, drop the queue so the
1989 // user isn't bombarded with stale messages after an interrupt.
1990 if ctx.Err() != nil || err != nil {
1991 i.queued = nil
1992 }
1993 // Decide whether the next thing to do is an auto-compaction.
1994 // Only fires when the turn completed cleanly AND the queue is
1995 // empty (otherwise a queued message would race the condense).
1996 shouldAutoCompact := !hasNext && err == nil && ctx.Err() == nil && i.shouldAutoCompactLocked()
1997 i.mu.Unlock()
1998 i.invalidate()
1999 parent := i.runCtx
2000 if parent == nil {
2001 parent = context.Background()
2002 }
2003 switch {
2004 case hasNext:
2005 i.startTurn(parent, next)
2006 case shouldAutoCompact:
2007 i.runCompact(parent, true)
2008 }
2009 }()
2010}
2011
2012// autoCompactThreshold is the context-window fraction at which the
2013// agent will auto-compact after a turn ends. 0.85 leaves enough
2014// headroom for one more user prompt + response before we bump the
2015// hard limit.
2016const autoCompactThreshold = 0.85
2017
2018// shouldAutoCompactLocked reports whether the last turn pushed context
2019// usage past the auto-compact threshold. Must be called with i.mu
2020// held; it reads lastCtxInput and the current model's context window.
2021func (i *Interactive) shouldAutoCompactLocked() bool {
2022 if i.agent == nil {
2023 return false
2024 }
2025 if i.autoCompacting {
2026 return false
2027 }
2028 m, err := provider.FindModel(i.cfg.Provider, i.cfg.Model)
2029 if err != nil || m.ContextWindow <= 0 {
2030 return false
2031 }
2032 if i.lastCtxInput <= 0 {
2033 return false
2034 }
2035 return float64(i.lastCtxInput)/float64(m.ContextWindow) >= autoCompactThreshold
2036}
2037
2038func (i *Interactive) handleEvent(ev core.AgentEvent) {
2039 i.mu.Lock()
2040 defer i.mu.Unlock()
2041 switch e := ev.(type) {
2042 case core.EvTextDelta:
2043 i.streaming.WriteString(e.Delta)
2044 case core.EvAssistantMessage:
2045 i.streaming.Reset()
2046 i.streamOn = false
2047 if i.cfg.OnAssistant != nil {
2048 i.cfg.OnAssistant(e.Message)
2049 }
2050 // Mirror the assistant's final visible text to the telegram
2051 // bridge when active. Only TextBlock content is forwarded;
2052 // tool_use blocks are internal. Send on a goroutine so the
2053 // network call doesn't hold the event loop lock.
2054 if i.telegramBridge != nil && i.telegramBridge.Active() {
2055 var sb strings.Builder
2056 for _, c := range e.Message.Content {
2057 if tb, ok := c.(provider.TextBlock); ok {
2058 if sb.Len() > 0 {
2059 sb.WriteString("\n")
2060 }
2061 sb.WriteString(tb.Text)
2062 }
2063 }
2064 if text := sb.String(); strings.TrimSpace(text) != "" {
2065 go i.telegramBridge.OnAssistantText(text)
2066 }
2067 }
2068 case core.EvToolUseStart:
2069 // Live streaming: pre-create the view so the user sees the
2070 // tool call being composed in real time. Any subsequent
2071 // EvToolCall for the same ID updates the same struct (the
2072 // final parsed args + name are already known here).
2073 if _, exists := i.toolCalls[e.ID]; !exists {
2074 i.toolCalls[e.ID] = &tui.ToolCallView{
2075 ID: e.ID,
2076 Name: e.Name,
2077 Streaming: true,
2078 }
2079 i.toolOrder = append(i.toolOrder, e.ID)
2080 }
2081 case core.EvToolUseArgs:
2082 if tc, ok := i.toolCalls[e.ID]; ok {
2083 tc.RawJSONBuf += e.Delta
2084 // Refresh the live path as soon as it parses; used in
2085 // the header (▸ write /Users/pat/Desktop/demo.ts)
2086 // while the content is still streaming.
2087 if p, pok, _ := tui.ExtractPartialStringField(tc.RawJSONBuf, "path"); pok {
2088 tc.LivePath = p
2089 } else if p, pok, _ := tui.ExtractPartialStringField(tc.RawJSONBuf, "file_path"); pok {
2090 tc.LivePath = p
2091 }
2092 }
2093 case core.EvToolUseEnd:
2094 if tc, ok := i.toolCalls[e.ID]; ok {
2095 tc.Streaming = false
2096 }
2097 case core.EvToolCall:
2098 // If we already pre-created the view during streaming, just
2099 // refresh the final Args summary. Otherwise create a new one
2100 // (non-streaming providers or legacy paths).
2101 if tc, ok := i.toolCalls[e.ID]; ok {
2102 tc.Args = shortArgs(e.Args)
2103 tc.Streaming = false
2104 } else {
2105 i.toolCalls[e.ID] = &tui.ToolCallView{
2106 ID: e.ID,
2107 Name: e.Name,
2108 Args: shortArgs(e.Args),
2109 }
2110 i.toolOrder = append(i.toolOrder, e.ID)
2111 }
2112 case core.EvToolResult:
2113 if tc, ok := i.toolCalls[e.ID]; ok {
2114 tc.Done = true
2115 tc.Error = e.Result.IsError
2116 var text strings.Builder
2117 for _, c := range e.Result.Content {
2118 if tb, ok := c.(provider.TextBlock); ok {
2119 if text.Len() > 0 {
2120 text.WriteString("\n")
2121 }
2122 text.WriteString(tb.Text)
2123 }
2124 }
2125 tc.Result = text.String()
2126 }
2127 if i.cfg.OnToolResult != nil {
2128 i.cfg.OnToolResult(e.ID, e.Result)
2129 }
2130 case core.EvUsage:
2131 i.cumUsage = e.Cumulative
2132 if e.Usage.InputTokens > 0 {
2133 i.lastCtxInput = e.Usage.InputTokens + e.Usage.CacheReadTokens + e.Usage.CacheWriteTokens
2134 }
2135 case core.EvTurnEnd:
2136 if e.Stop == provider.StopAborted {
2137 // Aborted turn: discard the partial streaming text (it is not
2138 // persisted in the transcript) and clear any transient error.
2139 i.streaming.Reset()
2140 i.streamOn = false
2141 i.statusErr = ""
2142 i.statusOK = "cancelled"
2143 return
2144 }
2145 if e.Err != nil && !strings.Contains(e.Err.Error(), "context canceled") {
2146 i.statusErr = e.Err.Error()
2147 }
2148 }
2149}
2150
2151// Agent returns the current agent, if any. Used by cli.go to flush the
2152// final transcript to the session file.
2153func (i *Interactive) Agent() *core.Agent {
2154 i.mu.Lock()
2155 defer i.mu.Unlock()
2156 return i.agent
2157}
2158
2159func shortArgs(raw json.RawMessage) string {
2160 var v any
2161 if err := json.Unmarshal(raw, &v); err != nil {
2162 return ""
2163 }
2164 if m, ok := v.(map[string]any); ok {
2165 for _, k := range []string{"path", "file_path", "command"} {
2166 if s, ok := m[k].(string); ok {
2167 if len(s) > 60 {
2168 s = s[:57] + "..."
2169 }
2170 return s
2171 }
2172 }
2173 }
2174 b, _ := json.Marshal(v)
2175 s := string(b)
2176 if len(s) > 60 {
2177 s = s[:57] + "..."
2178 }
2179 return s
2180}
2181
2182// silence unused import in some build configs
2183var _ = fmt.Sprintf
2184
2185// runReloadExt triggers a live reload of every extension (discovered
2186// + explicit). Runs on a goroutine so the TUI stays responsive; the
2187// Manager.Reload takes a couple of hundred ms to shut down subprocs
2188// and respawn them. Shows a status line throughout.
2189func (i *Interactive) runReloadExt(ctx context.Context) {
2190 if i.cfg.Extensions == nil {
2191 i.mu.Lock()
2192 i.statusErr = "no extension manager in this build"
2193 i.mu.Unlock()
2194 i.invalidate()
2195 return
2196 }
2197 i.mu.Lock()
2198 i.statusOK = "reloading extensions…"
2199 i.statusErr = ""
2200 i.mu.Unlock()
2201 i.invalidate()
2202
2203 go func() {
2204 stats := i.cfg.Extensions.Reload(ctx, 2*time.Second)
2205 msg := fmt.Sprintf("reloaded: %d stopped, %d loaded (%d ready)", stats.Stopped, stats.Loaded, stats.Ready)
2206 if len(stats.Errors) > 0 {
2207 msg += fmt.Sprintf(", %d error(s)", len(stats.Errors))
2208 }
2209 i.mu.Lock()
2210 i.statusOK = msg
2211 i.statusErr = ""
2212 i.mu.Unlock()
2213 i.invalidate()
2214 }()
2215}
2216
2217// Confirm implements core.Confirmer. The agent goroutine calls
2218// this synchronously before every tool invocation when --no-yolo is
2219// active. We push the request onto the confirmDialog queue, trigger
2220// a redraw, and block the caller until the user answers.
2221//
2222// If the session is cancelled or the TUI exits mid-prompt, any
2223// pending request is refused via CancelAll so the agent doesn't
2224// deadlock.
2225func (i *Interactive) Confirm(toolName string, preview string) core.ConfirmDecision {
2226 resp := make(chan core.ConfirmDecision, 1)
2227 i.confirmDialog.Enqueue(&confirmRequest{
2228 toolName: toolName,
2229 preview: preview,
2230 resp: resp,
2231 })
2232 i.invalidate()
2233 return <-resp
2234}
2235
2236// runYoloOn disables --no-yolo for the rest of the session. Tool
2237// calls run without prompting after this; there's intentionally no
2238// way to re-enable gating mid-session, if the user wants that back
2239// they can exit and restart with --no-yolo.
2240func (i *Interactive) runYoloOn() {
2241 if i.cfg.ConfirmGate == nil {
2242 i.mu.Lock()
2243 i.statusOK = "yolo mode is already on (no --no-yolo in this session)"
2244 i.statusErr = ""
2245 i.mu.Unlock()
2246 i.invalidate()
2247 return
2248 }
2249 i.cfg.ConfirmGate.AllowAll()
2250 // Also auto-allow any currently pending confirmation so the
2251 // agent doesn't deadlock if /yolo is typed while a prompt is
2252 // open.
2253 i.confirmDialog.AllowAllPending()
2254 i.mu.Lock()
2255 i.statusOK = "yolo engaged: no more tool-call confirmations this session"
2256 i.statusErr = ""
2257 i.mu.Unlock()
2258 i.invalidate()
2259}
2260
2261// openTelegramDialog shows the picker for `/telegram` with no arg.
2262// Items depend on current state: disconnect + status when running,
2263// connect + status when stopped.
2264func (i *Interactive) openTelegramDialog() {
2265 items := i.telegramMenuItems()
2266 if len(items) == 0 {
2267 i.mu.Lock()
2268 i.statusErr = "telegram not configured. run `zot telegram-bot setup` first."
2269 i.mu.Unlock()
2270 i.invalidate()
2271 return
2272 }
2273 i.telegramDialog.Open(items)
2274 i.invalidate()
2275}
2276
2277// telegramMenuItems builds the dialog entries for the current
2278// bridge state. Returns empty when no bot.json exists so the
2279// caller can show a helpful status line instead of an empty menu.
2280func (i *Interactive) telegramMenuItems() []telegramItem {
2281 cfg, err := telegram.LoadConfig(i.cfg.ZotHome)
2282 if err != nil || cfg.BotToken == "" {
2283 return nil
2284 }
2285 var items []telegramItem
2286 if i.telegramBridge != nil && i.telegramBridge.Active() {
2287 items = append(items, telegramItem{label: "disconnect", action: "disconnect", hint: "stop mirroring"})
2288 st := i.telegramBridge.State()
2289 hint := "active"
2290 if st.Username != "" {
2291 hint += " as @" + st.Username
2292 }
2293 items = append(items, telegramItem{label: "status", action: "status", hint: hint})
2294 } else {
2295 label := "connect"
2296 hint := "start mirroring dms into this session"
2297 if cfg.AllowedUserID == 0 {
2298 hint = "awaiting pairing (send /start to the bot once connected)"
2299 }
2300 items = append(items, telegramItem{label: label, action: "connect", hint: hint})
2301 items = append(items, telegramItem{label: "status", action: "status", hint: "disconnected"})
2302 }
2303 return items
2304}
2305
2306// doTelegram dispatches one of the three explicit actions. Called
2307// from /telegram <action> or after the picker selects a row.
2308func (i *Interactive) doTelegram(action string) {
2309 switch action {
2310 case "connect":
2311 i.telegramConnect()
2312 case "disconnect":
2313 i.telegramDisconnect()
2314 case "status":
2315 i.telegramStatus()
2316 default:
2317 i.mu.Lock()
2318 i.statusErr = "unknown telegram action: " + action + " (use connect, disconnect, or status)"
2319 i.mu.Unlock()
2320 i.invalidate()
2321 }
2322}
2323
2324// telegramConnect starts the bridge. Refuses if it's already
2325// running or if the on-disk bot.json is missing a token.
2326func (i *Interactive) telegramConnect() {
2327 if i.telegramBridge != nil && i.telegramBridge.Active() {
2328 i.mu.Lock()
2329 i.statusOK = "telegram already connected"
2330 i.statusErr = ""
2331 i.mu.Unlock()
2332 i.invalidate()
2333 return
2334 }
2335 cfg, err := telegram.LoadConfig(i.cfg.ZotHome)
2336 if err != nil {
2337 i.mu.Lock()
2338 i.statusErr = "telegram: " + err.Error()
2339 i.mu.Unlock()
2340 i.invalidate()
2341 return
2342 }
2343 if cfg.BotToken == "" {
2344 i.mu.Lock()
2345 i.statusErr = "telegram: no bot token configured. run `zot telegram-bot setup` first."
2346 i.mu.Unlock()
2347 i.invalidate()
2348 return
2349 }
2350 // Refuse to start when a background daemon is already polling
2351 // the same bot. Two concurrent long-poll consumers race each
2352 // update and one always loses, so DMs get half-delivered. The
2353 // user can `zot telegram-bot stop` first, then /telegram connect.
2354 if pid, alive, _ := telegram.IsRunning(i.cfg.ZotHome); alive && pid > 0 {
2355 i.mu.Lock()
2356 i.statusErr = fmt.Sprintf("telegram: bot daemon already running (pid %d). stop it with `zot telegram-bot stop` first.", pid)
2357 i.mu.Unlock()
2358 i.invalidate()
2359 return
2360 }
2361 i.telegramBridge = &telegram.Bridge{
2362 Client: telegram.NewClient(cfg.BotToken),
2363 Config: cfg,
2364 Save: func(next telegram.Config) error {
2365 return telegram.SaveConfig(i.cfg.ZotHome, next)
2366 },
2367 Host: &telegramHost{iv: i},
2368 }
2369 if err := i.telegramBridge.Start(i.runCtx); err != nil {
2370 i.telegramBridge = nil
2371 i.mu.Lock()
2372 i.statusErr = "telegram connect failed: " + err.Error()
2373 i.mu.Unlock()
2374 i.invalidate()
2375 return
2376 }
2377 state := i.telegramBridge.State()
2378 label := "telegram connected"
2379 if state.Username != "" {
2380 label += " as @" + state.Username
2381 }
2382 if state.PairedID == 0 {
2383 label += " — send /start to the bot from your phone to claim it"
2384 }
2385 i.mu.Lock()
2386 i.statusOK = label
2387 i.statusErr = ""
2388 i.mu.Unlock()
2389 i.invalidate()
2390}
2391
2392// telegramDisconnect stops the bridge. No-op when already stopped.
2393func (i *Interactive) telegramDisconnect() {
2394 if i.telegramBridge == nil || !i.telegramBridge.Active() {
2395 i.mu.Lock()
2396 i.statusOK = "telegram already disconnected"
2397 i.statusErr = ""
2398 i.mu.Unlock()
2399 i.invalidate()
2400 return
2401 }
2402 i.telegramBridge.Stop()
2403 i.mu.Lock()
2404 i.statusOK = "telegram disconnected"
2405 i.statusErr = ""
2406 i.mu.Unlock()
2407 i.invalidate()
2408}
2409
2410// telegramStatus writes a one-liner describing the bridge state.
2411// Reports on both the in-tui bridge and the background daemon so
2412// the user isn't confused when the daemon owns the poll loop.
2413func (i *Interactive) telegramStatus() {
2414 var msg string
2415 if i.telegramBridge != nil && i.telegramBridge.Active() {
2416 s := i.telegramBridge.State()
2417 msg = "telegram: connected (tui bridge)"
2418 if s.Username != "" {
2419 msg += " as @" + s.Username
2420 }
2421 if s.PairedID != 0 {
2422 msg += fmt.Sprintf(" · paired with user %d", s.PairedID)
2423 } else {
2424 msg += " · awaiting pairing"
2425 }
2426 } else if pid, alive, _ := telegram.IsRunning(i.cfg.ZotHome); alive && pid > 0 {
2427 msg = fmt.Sprintf("telegram: background daemon running (pid %d) · /telegram connect won't work until you stop it", pid)
2428 } else {
2429 cfg, _ := telegram.LoadConfig(i.cfg.ZotHome)
2430 if cfg.BotToken == "" {
2431 msg = "telegram: not configured. run `zot telegram-bot setup` first."
2432 } else {
2433 msg = "telegram: disconnected"
2434 if cfg.BotUsername != "" {
2435 msg += " (@" + cfg.BotUsername + " ready to connect)"
2436 }
2437 }
2438 }
2439 i.mu.Lock()
2440 i.statusOK = msg
2441 i.statusErr = ""
2442 i.mu.Unlock()
2443 i.invalidate()
2444}
2445
2446// telegramHost adapts *Interactive to telegram.Host so the bridge
2447// can call back into the TUI without importing modes directly.
2448type telegramHost struct{ iv *Interactive }
2449
2450func (h *telegramHost) SubmitOrQueue(prompt string, images []provider.ImageBlock) {
2451 h.iv.SubmitOrQueue(prompt, images)
2452}
2453
2454func (h *telegramHost) CancelTurn() { h.iv.CancelTurn() }
2455
2456func (h *telegramHost) Notify(level, message string) {
2457 h.iv.mu.Lock()
2458 switch level {
2459 case "error", "warn":
2460 h.iv.statusErr = message
2461 h.iv.statusOK = ""
2462 default:
2463 h.iv.statusOK = message
2464 h.iv.statusErr = ""
2465 }
2466 h.iv.mu.Unlock()
2467 h.iv.invalidate()
2468}