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 // Mirror the user's typed prompt into the paired Telegram
1095 // chat (when the bridge is active) so the Telegram thread
1096 // stays a complete record of the session, not just the half
1097 // that originated on the phone. On a goroutine so the
1098 // network write doesn't delay the local turn.
1099 if i.telegramBridge != nil && i.telegramBridge.Active() {
1100 go i.telegramBridge.OnUserTyped(text)
1101 }
1102 // If a turn is already in flight, queue this prompt instead of
1103 // starting a second one. The drain loop at the end of startTurn
1104 // will pick it up when the current turn finishes.
1105 i.mu.Lock()
1106 if i.busy {
1107 i.queued = append(i.queued, text)
1108 i.mu.Unlock()
1109 i.invalidate()
1110 return false
1111 }
1112 i.mu.Unlock()
1113 i.startTurn(ctx, text)
1114 }
1115 return false
1116}
1117
1118// invokeExtensionCommand fires an extension-registered slash command
1119// in a background goroutine, awaits the response, and applies the
1120// requested action (prompt / insert / display / noop). Errors and
1121// timeouts surface as a status_err line.
1122func (i *Interactive) invokeExtensionCommand(ctx context.Context, name, args string) {
1123 resp, err := i.cfg.Extensions.Invoke(ctx, name, args, 30*time.Second)
1124 if err != nil {
1125 i.mu.Lock()
1126 i.statusErr = "extension /" + name + ": " + err.Error()
1127 i.statusOK = ""
1128 i.mu.Unlock()
1129 i.invalidate()
1130 return
1131 }
1132 if resp.Error != "" {
1133 i.mu.Lock()
1134 i.statusErr = "extension /" + name + ": " + resp.Error
1135 i.statusOK = ""
1136 i.mu.Unlock()
1137 i.invalidate()
1138 return
1139 }
1140 switch resp.Action {
1141 case "prompt":
1142 if strings.TrimSpace(resp.Prompt) == "" {
1143 return
1144 }
1145 i.startTurn(i.runCtx, resp.Prompt)
1146 case "insert":
1147 i.ed.Insert(resp.Insert)
1148 i.invalidate()
1149 case "display":
1150 i.appendExtensionNote(name, resp.Display, "info")
1151 case "noop", "":
1152 // nothing
1153 default:
1154 i.mu.Lock()
1155 i.statusErr = "extension /" + name + ": unknown action " + resp.Action
1156 i.mu.Unlock()
1157 i.invalidate()
1158 }
1159}
1160
1161// appendExtensionNote renders an extension-originated note in the
1162// chat. Levels: "info" (muted), "warn" (warning), "error" (error),
1163// "success" (tool/ok green).
1164func (i *Interactive) appendExtensionNote(extName, msg, level string) {
1165 if msg == "" {
1166 return
1167 }
1168 i.mu.Lock()
1169 defer i.mu.Unlock()
1170 color := i.cfg.Theme.Muted
1171 switch level {
1172 case "warn":
1173 color = i.cfg.Theme.Warning
1174 case "error":
1175 color = i.cfg.Theme.Error
1176 case "success":
1177 color = i.cfg.Theme.Tool
1178 }
1179 prefix := i.cfg.Theme.FG256(i.cfg.Theme.Accent, "["+extName+"] ")
1180 for _, line := range strings.Split(msg, "\n") {
1181 i.statusOK = "" // clear any stale ok
1182 i.statusErr = ""
1183 i.extNotes = append(i.extNotes, prefix+i.cfg.Theme.FG256(color, line))
1184 }
1185}
1186
1187// HostHooks implementation for the extension manager. The manager
1188// holds an interface, not a concrete *Interactive, so these methods
1189// are the only thing the manager sees.
1190
1191// Notify is the manager's NotifyFromExt entry point.
1192func (i *Interactive) Notify(extName, level, message string) {
1193 i.appendExtensionNote(extName, message, level)
1194 i.invalidate()
1195}
1196
1197// Submit feeds text through the agent loop as if the user had typed it.
1198func (i *Interactive) Submit(text string) {
1199 i.startTurn(i.runCtx, text)
1200}
1201
1202// SubmitOrQueue runs text immediately if the agent is idle, or
1203// appends it to the pending queue if a turn is already in flight.
1204// Used by the telegram bridge (and by the editor submit path) so
1205// both input sources share the same "queue behind an active turn"
1206// semantics. Images are ignored for now — only the text prompt is
1207// forwarded — because the queued-prompt path is text-only; a
1208// follow-up can expand the queue entry to carry images.
1209func (i *Interactive) SubmitOrQueue(text string, images []provider.ImageBlock) {
1210 _ = images // reserved for future queued-with-images support
1211 i.mu.Lock()
1212 if i.agent == nil {
1213 i.statusErr = "not logged in. type /login first."
1214 i.mu.Unlock()
1215 i.invalidate()
1216 return
1217 }
1218 if i.busy {
1219 i.queued = append(i.queued, text)
1220 i.mu.Unlock()
1221 i.invalidate()
1222 return
1223 }
1224 i.mu.Unlock()
1225 i.startTurn(i.runCtx, text)
1226}
1227
1228// CancelTurn aborts the active turn if one is running. Used by the
1229// telegram bridge when the paired user sends /stop.
1230func (i *Interactive) CancelTurn() {
1231 i.mu.Lock()
1232 cancel := i.cancelTurn
1233 i.mu.Unlock()
1234 if cancel != nil {
1235 cancel()
1236 }
1237}
1238
1239// Insert places text at the cursor in the editor.
1240func (i *Interactive) Insert(text string) {
1241 i.ed.Insert(text)
1242 i.invalidate()
1243}
1244
1245// Display appends a styled note from extName to the chat without a
1246// model call.
1247func (i *Interactive) Display(extName, text string) {
1248 i.appendExtensionNote(extName, text, "info")
1249 i.invalidate()
1250}
1251
1252func (i *Interactive) runSlash(ctx context.Context, cmd string) (done bool) {
1253 parts := strings.Fields(cmd)
1254 switch parts[0] {
1255 case "/exit":
1256 return true
1257 case "/clear":
1258 if i.agent != nil {
1259 i.agent.SetMessages(nil)
1260 }
1261 i.mu.Lock()
1262 i.toolCalls = map[string]*tui.ToolCallView{}
1263 i.toolOrder = nil
1264 i.statusErr = ""
1265 i.statusOK = ""
1266 i.helpBlock = nil
1267 i.parkedTurn = 0
1268 i.parkedTotal = 0
1269 i.scrollOffset = 0
1270 i.extNotes = nil
1271 i.view.InvalidateRenderCache()
1272 i.mu.Unlock()
1273 case "/help":
1274 i.mu.Lock()
1275 i.helpBlock = renderHelpBlock(i.cfg.Theme, i.lastCols())
1276 i.statusErr = ""
1277 i.statusOK = ""
1278 // Pin the viewport to the newest content so the help block,
1279 // which we just appended to the end of the transcript, is
1280 // what the user actually sees.
1281 i.scrollOffset = 0
1282 i.mu.Unlock()
1283 case "/login":
1284 i.dialog.Open()
1285 case "/logout":
1286 if len(parts) >= 2 {
1287 // Explicit target: /logout anthropic | openai | all
1288 i.doLogout(parts[1])
1289 break
1290 }
1291 // No arg: open the picker over whichever providers are
1292 // currently logged in. If nothing's logged in, bail with a
1293 // status line.
1294 i.openLogoutDialog()
1295 case "/model":
1296 if len(parts) >= 2 {
1297 i.applyModelSelection("", parts[1])
1298 } else {
1299 i.modelDialog.Open(i.cfg.Model)
1300 }
1301 case "/sessions":
1302 i.sessionDialog.Open(i.cfg.ZotHome, i.cfg.CWD)
1303 case "/jump":
1304 i.openJumpDialog(parts[1:])
1305 case "/btw":
1306 i.openBtwDialog(parts[1:])
1307 case "/skills":
1308 i.openSkillsDialog()
1309 case "/compact":
1310 i.runCompact(ctx, false)
1311 case "/jail":
1312 if i.cfg.Sandbox == nil {
1313 i.mu.Lock()
1314 i.statusErr = "sandbox not available in this build"
1315 i.mu.Unlock()
1316 break
1317 }
1318 i.cfg.Sandbox.Lock()
1319 i.mu.Lock()
1320 i.statusOK = "jailed to " + i.cfg.CWD + " (tools cannot touch paths outside this directory)"
1321 i.statusErr = ""
1322 i.mu.Unlock()
1323 case "/unjail":
1324 if i.cfg.Sandbox == nil {
1325 i.mu.Lock()
1326 i.statusErr = "sandbox not available in this build"
1327 i.mu.Unlock()
1328 break
1329 }
1330 i.cfg.Sandbox.Unlock()
1331 i.mu.Lock()
1332 i.statusOK = "unjailed"
1333 i.statusErr = ""
1334 i.mu.Unlock()
1335 case "/reload-ext":
1336 i.runReloadExt(ctx)
1337 case "/yolo":
1338 i.runYoloOn()
1339 case "/telegram", "/tg":
1340 if len(parts) >= 2 {
1341 i.doTelegram(parts[1])
1342 break
1343 }
1344 i.openTelegramDialog()
1345 default:
1346 // Last-resort fallback: try the extension manager. Built-in
1347 // cases above always win; this branch only fires for slash
1348 // commands the extension manager registered. Same routing as
1349 // the editor's submit-handler dispatch path so the autocomplete
1350 // "enter on highlighted suggestion" flow also works.
1351 extName := strings.TrimPrefix(parts[0], "/")
1352 if i.cfg.Extensions != nil && i.cfg.Extensions.HasCommand(extName) {
1353 rest := ""
1354 if len(parts) > 1 {
1355 rest = strings.Join(parts[1:], " ")
1356 }
1357 go i.invokeExtensionCommand(ctx, extName, rest)
1358 return false
1359 }
1360 i.mu.Lock()
1361 i.statusErr = "unknown command: " + parts[0]
1362 i.mu.Unlock()
1363 }
1364 return false
1365}
1366
1367// openLogoutDialog shows the provider picker for `/logout` with no
1368// argument. Only providers the user is currently logged into are
1369// listed, plus an "all" entry when more than one is present. If
1370// nothing's logged in, writes a status line instead of opening an
1371// empty dialog.
1372func (i *Interactive) openLogoutDialog() {
1373 if i.cfg.AuthManager == nil {
1374 i.mu.Lock()
1375 i.statusErr = "no auth manager configured"
1376 i.mu.Unlock()
1377 i.invalidate()
1378 return
1379 }
1380 store := i.cfg.AuthManager.Store()
1381 if store == nil {
1382 i.mu.Lock()
1383 i.statusErr = "auth store is not available"
1384 i.mu.Unlock()
1385 i.invalidate()
1386 return
1387 }
1388 creds, err := store.Load()
1389 if err != nil {
1390 i.mu.Lock()
1391 i.statusErr = "read auth store: " + err.Error()
1392 i.mu.Unlock()
1393 i.invalidate()
1394 return
1395 }
1396
1397 var items []logoutItem
1398 for _, p := range []string{"anthropic", "openai"} {
1399 if creds.Has(p) {
1400 items = append(items, logoutItem{
1401 label: p,
1402 target: p,
1403 method: creds.Method(p),
1404 })
1405 }
1406 }
1407 if len(items) == 0 {
1408 i.mu.Lock()
1409 i.statusOK = "no credentials stored; already logged out"
1410 i.statusErr = ""
1411 i.mu.Unlock()
1412 i.invalidate()
1413 return
1414 }
1415 if len(items) > 1 {
1416 items = append(items, logoutItem{label: "all", target: "all"})
1417 }
1418
1419 i.logoutDialog.Open(items)
1420 i.invalidate()
1421}
1422
1423// doLogout clears credentials for the given provider (or all providers)
1424// from auth.json. If the active agent was using those credentials, it
1425// is torn down so the user is forced through /login before their next
1426// prompt.
1427//
1428// target: "anthropic" | "openai" | "all"
1429func (i *Interactive) doLogout(target string) {
1430 if i.cfg.AuthManager == nil {
1431 i.mu.Lock()
1432 i.statusErr = "no auth manager configured"
1433 i.mu.Unlock()
1434 return
1435 }
1436 store := i.cfg.AuthManager.Store()
1437 if store == nil {
1438 i.mu.Lock()
1439 i.statusErr = "auth store is not available"
1440 i.mu.Unlock()
1441 return
1442 }
1443
1444 var providers []string
1445 switch target {
1446 case "", "all":
1447 providers = []string{"anthropic", "openai"}
1448 case "anthropic", "openai":
1449 providers = []string{target}
1450 default:
1451 i.mu.Lock()
1452 i.statusErr = "unknown provider: " + target + " (use anthropic, openai, or all)"
1453 i.mu.Unlock()
1454 return
1455 }
1456
1457 var errs []string
1458 clearedCurrent := false
1459 for _, p := range providers {
1460 if err := store.Clear(p); err != nil {
1461 errs = append(errs, p+": "+err.Error())
1462 continue
1463 }
1464 if p == i.cfg.Provider {
1465 clearedCurrent = true
1466 }
1467 }
1468
1469 i.mu.Lock()
1470 defer i.mu.Unlock()
1471 if len(errs) > 0 {
1472 i.statusErr = "logout errors: " + strings.Join(errs, "; ")
1473 return
1474 }
1475 i.statusErr = ""
1476 if clearedCurrent {
1477 // The running agent was using a credential we just wiped. Drop
1478 // it so prompts can't go out with the stale client, and hint at
1479 // /login.
1480 i.agent = nil
1481 i.statusOK = "logged out of " + strings.Join(providers, ", ") + ". type /login to sign back in."
1482 } else {
1483 i.statusOK = "logged out of " + strings.Join(providers, ", ")
1484 }
1485}
1486
1487func (i *Interactive) startAPIKeyFlow(provider string) {
1488 url, err := i.cfg.AuthManager.StartAPIKey(provider)
1489 if err != nil {
1490 i.dialog.ShowResult(false, err.Error())
1491 return
1492 }
1493 i.dialog.ShowWaiting(url)
1494}
1495
1496func (i *Interactive) startOAuthFlow(provider string) {
1497 url, err := i.cfg.AuthManager.StartOAuth(provider)
1498 if err != nil {
1499 i.dialog.ShowResult(false, err.Error())
1500 return
1501 }
1502 i.dialog.ShowWaiting(url)
1503}
1504
1505// applyModelSelection switches the active model (and provider, if the
1506// new model belongs to a different one). It rebuilds the underlying
1507// client when needed so the provider wire-protocol matches.
1508// cancelAndWaitForIdle cancels the active turn (if any) and blocks
1509// briefly until the turn goroutine has updated i.busy = false. Used
1510// before destructive slash commands so transcript-mutating work
1511// (/clear, /compact, /logout, /login completion, cross-provider
1512// /model swap) doesn't race with the still-running stream.
1513//
1514// The wait is bounded; if the turn doesn't release within the timeout
1515// we proceed anyway. Worst case is a brief overlap that the agent's
1516// own mutex protects against.
1517func (i *Interactive) cancelAndWaitForIdle() {
1518 i.mu.Lock()
1519 busy := i.busy
1520 cancel := i.cancelTurn
1521 i.mu.Unlock()
1522 if !busy {
1523 return
1524 }
1525 if cancel != nil {
1526 cancel()
1527 }
1528 deadline := time.Now().Add(2 * time.Second)
1529 for time.Now().Before(deadline) {
1530 i.mu.Lock()
1531 done := !i.busy
1532 i.mu.Unlock()
1533 if done {
1534 return
1535 }
1536 time.Sleep(10 * time.Millisecond)
1537 }
1538}
1539
1540// openBtwDialog opens the side-chat overlay with a frozen snapshot
1541// of the current main session. The optional argument is auto-
1542// submitted as the first question, so '/btw does X work?' fires the
1543// model call immediately instead of just opening an empty dialog.
1544func (i *Interactive) openBtwDialog(args []string) {
1545 if i.agent == nil {
1546 i.mu.Lock()
1547 i.statusErr = "not logged in. type /login first."
1548 i.mu.Unlock()
1549 return
1550 }
1551 seed := strings.TrimSpace(strings.Join(args, " "))
1552 i.btwDialog.Open(i.cfg.Theme, i.agent, i.agent.System, i.cfg.Model, seed)
1553 i.invalidate()
1554}
1555
1556// openSkillsDialog opens the skill inspector. The picker reflects
1557// whatever SkillSnapshot returns at call time, so edits to a
1558// SKILL.md made during a session show up on the next /skills.
1559func (i *Interactive) openSkillsDialog() {
1560 var list []*skills.Skill
1561 if i.cfg.SkillSnapshot != nil {
1562 list = i.cfg.SkillSnapshot()
1563 }
1564 i.skillsDialog.Open(list)
1565 i.invalidate()
1566}
1567
1568// openJumpDialog builds a /jump picker from the current transcript.
1569// If the user typed "/jump foo" with a filter and it matches exactly
1570// one turn, jump there directly without showing the dialog.
1571func (i *Interactive) openJumpDialog(args []string) {
1572 if i.view == nil || len(i.view.Messages) == 0 {
1573 i.mu.Lock()
1574 i.statusErr = "nothing to jump to \u2014 the session is empty"
1575 i.mu.Unlock()
1576 return
1577 }
1578 filter := strings.TrimSpace(strings.Join(args, " "))
1579 i.jumpDialog.Open(i.view.Messages, filter)
1580 // Shortcut: with a filter argument that matches exactly one turn,
1581 // jump immediately and skip the picker.
1582 if filter != "" {
1583 if tgts := i.jumpDialog.Targets(); len(tgts) == 1 {
1584 t := tgts[0]
1585 i.jumpDialog.Close()
1586 i.applyJumpSelection(t.MessageIdx, t.TurnNo)
1587 }
1588 }
1589}
1590
1591// applyJumpSelection scrolls the chat viewport so the user message at
1592// msgIdx is visible at (or near) the top of the chat area. Uses the
1593// anchor slice returned by view.BuildWithAnchors so the mapping from
1594// message index to row is exact, regardless of variable-height tool
1595// blocks above the target.
1596func (i *Interactive) applyJumpSelection(msgIdx, turnNo int) {
1597 cols := i.lastCols()
1598 chat, anchors := i.view.BuildWithAnchors(cols)
1599 var row int
1600 found := false
1601 for _, a := range anchors {
1602 if a.MessageIdx == msgIdx {
1603 row = a.Row
1604 found = true
1605 break
1606 }
1607 }
1608 if !found {
1609 i.mu.Lock()
1610 i.statusErr = "could not resolve jump target"
1611 i.mu.Unlock()
1612 return
1613 }
1614
1615 chatLen := len(chat)
1616 page := i.chatPage()
1617 if page < 1 {
1618 page = 1
1619 }
1620 // scrollOffset is measured from the bottom of the chat slice, so
1621 // to place `row` at the top of the viewport we want:
1622 // chatLen - scrollOffset - page == row
1623 // Solve for scrollOffset and clamp to [0, chatLen-page].
1624 offset := chatLen - (row + page)
1625 if offset < 0 {
1626 offset = 0
1627 }
1628 maxOffset := chatLen - page
1629 if maxOffset < 0 {
1630 maxOffset = 0
1631 }
1632 if offset > maxOffset {
1633 offset = maxOffset
1634 }
1635
1636 i.mu.Lock()
1637 i.scrollOffset = offset
1638 i.parkedTurn = turnNo
1639 i.parkedTotal = totalTurnsLocked(i.view.Messages)
1640 i.statusOK = fmt.Sprintf("jumped to turn %d", turnNo)
1641 i.statusErr = ""
1642 i.mu.Unlock()
1643}
1644
1645// totalTurnsLocked counts user messages in the transcript. Caller is
1646// assumed to hold i.mu (the name is a mild reminder; this function
1647// itself doesn't touch shared state beyond the slice it's handed).
1648func totalTurnsLocked(msgs []provider.Message) int {
1649 n := 0
1650 for _, m := range msgs {
1651 if m.Role == provider.RoleUser {
1652 n++
1653 }
1654 }
1655 return n
1656}
1657
1658// applySessionSelection loads the given session via the cli-provided
1659// callback and parks the viewport on the last turn so the user lands
1660// looking at where the conversation left off (their last prompt at the
1661// top of the chat, the assistant's last reply right below). Older
1662// history is one scroll up; pgdn or end snaps to the current tail.
1663//
1664// Without this, scrollOffset stayed at 0 (pinned to the live tail),
1665// which on a long resumed session showed only the last few rows of
1666// the final assistant message — the user read that as "only one liner
1667// happened, the resume didn't work".
1668func (i *Interactive) applySessionSelection(path string) {
1669 if i.cfg.LoadSession == nil {
1670 i.mu.Lock()
1671 i.statusErr = "session loading is not wired in this build"
1672 i.mu.Unlock()
1673 return
1674 }
1675 if err := i.cfg.LoadSession(path); err != nil {
1676 i.mu.Lock()
1677 i.statusErr = err.Error()
1678 i.mu.Unlock()
1679 return
1680 }
1681
1682 i.mu.Lock()
1683 i.statusOK = "resumed session: " + path
1684 i.statusErr = ""
1685 i.parkedTurn = 0
1686 i.parkedTotal = 0
1687 i.view.InvalidateRenderCache()
1688 // Pull the freshly-loaded transcript into the view so the anchor
1689 // math below sees the post-resume messages, not the empty pre-load
1690 // state. redraw() does the same on its next pass; we just front-run
1691 // it here to compute the scroll target.
1692 if i.agent != nil {
1693 i.view.Messages = i.agent.Messages()
1694 }
1695 msgs := i.view.Messages
1696 i.mu.Unlock()
1697
1698 i.scrollToLastTurn(msgs)
1699}
1700
1701// scrollToLastTurn parks the viewport at the most recent user turn,
1702// or at the top if the transcript has no user messages. Used after
1703// resume so the user lands looking at where they left off.
1704func (i *Interactive) scrollToLastTurn(msgs []provider.Message) {
1705 if len(msgs) == 0 {
1706 i.mu.Lock()
1707 i.scrollOffset = 0
1708 i.mu.Unlock()
1709 return
1710 }
1711 // Find the last user message index.
1712 lastUser := -1
1713 turnNo, totalTurns := 0, 0
1714 for idx, m := range msgs {
1715 if m.Role == provider.RoleUser {
1716 totalTurns++
1717 lastUser = idx
1718 }
1719 }
1720 if lastUser < 0 {
1721 i.mu.Lock()
1722 i.scrollOffset = 0
1723 i.mu.Unlock()
1724 return
1725 }
1726 turnNo = totalTurns
1727
1728 cols := i.lastCols()
1729 chat, anchors := i.view.BuildWithAnchors(cols)
1730 var row int
1731 found := false
1732 for _, a := range anchors {
1733 if a.MessageIdx == lastUser {
1734 row = a.Row
1735 found = true
1736 break
1737 }
1738 }
1739 if !found {
1740 i.mu.Lock()
1741 i.scrollOffset = 0
1742 i.mu.Unlock()
1743 return
1744 }
1745
1746 chatLen := len(chat)
1747 page := i.chatPage()
1748 if page < 1 {
1749 page = 1
1750 }
1751 offset := chatLen - (row + page)
1752 if offset < 0 {
1753 offset = 0
1754 }
1755 maxOffset := chatLen - page
1756 if maxOffset < 0 {
1757 maxOffset = 0
1758 }
1759 if offset > maxOffset {
1760 offset = maxOffset
1761 }
1762
1763 i.mu.Lock()
1764 i.scrollOffset = offset
1765 // Mark the parked-turn footer so the user sees "viewing turn N of
1766 // M · pgdn to catch up" — same affordance as /jump. Tells them at
1767 // a glance that they're looking at history, not the live tail.
1768 if offset > 0 {
1769 i.parkedTurn = turnNo
1770 i.parkedTotal = totalTurns
1771 }
1772 i.mu.Unlock()
1773 i.invalidate()
1774}
1775
1776func (i *Interactive) applyModelSelection(prov, model string) {
1777 if model == "" {
1778 return
1779 }
1780 m, err := provider.FindModel(prov, model)
1781 if err != nil {
1782 i.mu.Lock()
1783 i.statusErr = err.Error()
1784 i.mu.Unlock()
1785 return
1786 }
1787 // Same provider? Just swap the model on the existing agent.
1788 if i.agent != nil && m.Provider == i.cfg.Provider {
1789 i.mu.Lock()
1790 i.cfg.Model = m.ID
1791 i.agent.Model = m.ID
1792 i.statusOK = "model: " + m.ID
1793 i.statusErr = ""
1794 i.mu.Unlock()
1795 if i.cfg.PersistModel != nil {
1796 i.cfg.PersistModel(i.cfg.Provider, m.ID)
1797 }
1798 return
1799 }
1800 // Different provider: rebuild agent (needs credentials for target provider).
1801 if i.cfg.BuildAgentFor == nil {
1802 i.mu.Lock()
1803 i.statusErr = "cannot switch provider: no builder configured"
1804 i.mu.Unlock()
1805 return
1806 }
1807 // Snapshot the current transcript and cumulative usage BEFORE we
1808 // build the replacement agent so we can hand them off. Without
1809 // this the user perceives the entire session as wiped on a
1810 // cross-provider /model swap.
1811 var carryMsgs []provider.Message
1812 var carryCost provider.Usage
1813 if i.agent != nil {
1814 carryMsgs = i.agent.Messages()
1815 carryCost = i.agent.Cost()
1816 }
1817
1818 ag, p, md, err := i.cfg.BuildAgentFor(m.Provider, m.ID)
1819 if err != nil {
1820 i.mu.Lock()
1821 i.statusErr = err.Error()
1822 i.mu.Unlock()
1823 return
1824 }
1825
1826 // Replay the transcript and seed the cost on the freshly-built
1827 // agent. Messages travel cleanly between providers because they
1828 // use the same provider.Message shape; tool-call ids are local
1829 // to a turn so cross-provider continuation never confuses the
1830 // new model (it just sees the assistant's reply, no orphan
1831 // tool_use blocks because /model swaps are gated to idle state).
1832 if len(carryMsgs) > 0 {
1833 ag.SetMessages(carryMsgs)
1834 }
1835 ag.SeedCost(carryCost)
1836
1837 i.mu.Lock()
1838 i.agent = ag
1839 i.cfg.Provider = p
1840 i.cfg.Model = md
1841 i.statusOK = "switched to " + p + " / " + md
1842 i.statusErr = ""
1843 // Render cache keys are width+content based, so the new agent's
1844 // identical messages will reuse the existing entries. Nothing
1845 // to invalidate.
1846 i.mu.Unlock()
1847 if i.cfg.PersistModel != nil {
1848 i.cfg.PersistModel(p, md)
1849 }
1850}
1851
1852func (i *Interactive) handleAuthEvent(ev auth.Event) {
1853 switch ev.Kind {
1854 case "started":
1855 i.dialog.ShowWaiting(ev.URL)
1856 case "browser_open":
1857 // no-op
1858 case "error":
1859 i.dialog.ShowResult(false, ev.Message)
1860 case "success":
1861 // Rebuild the agent with the fresh credential.
1862 ag, prov, model, err := i.cfg.BuildAgent()
1863 if err != nil {
1864 i.dialog.ShowResult(false, err.Error())
1865 return
1866 }
1867 i.mu.Lock()
1868 i.agent = ag
1869 i.cfg.Provider = prov
1870 i.cfg.Model = model
1871 i.statusErr = ""
1872 i.statusOK = "logged in to " + ev.Provider + " via " + ev.Method
1873 i.mu.Unlock()
1874 i.dialog.ShowResult(true, "")
1875 }
1876}
1877
1878// runCompact invokes core.Agent.Compact and reflects the progress in
1879// the tui. It runs in a goroutine so the ui stays responsive; esc/ctrl+c
1880// cancel via the same cancelTurn channel used for normal turns.
1881//
1882// When auto is true the spinner message is pinned to "condensing
1883// history" and the status bar surfaces "(auto)" next to the context
1884// percentage so it's obvious the system triggered this, not the user.
1885func (i *Interactive) runCompact(parent context.Context, auto bool) {
1886 if i.agent == nil {
1887 i.mu.Lock()
1888 i.statusErr = "not logged in. type /login first."
1889 i.mu.Unlock()
1890 return
1891 }
1892 ctx, cancel := context.WithCancel(parent)
1893 i.mu.Lock()
1894 i.busy = true
1895 if auto {
1896 i.spin.StartFixed("condensing history")
1897 i.autoCompacting = true
1898 i.statusOK = "condensing history… (esc to cancel)"
1899 } else {
1900 i.spin.Start()
1901 i.statusOK = "compacting..."
1902 }
1903 i.cancelTurn = cancel
1904 i.statusErr = ""
1905 i.streaming.Reset()
1906 i.streamOn = true
1907 i.scrollOffset = 0
1908 i.helpBlock = nil
1909 i.mu.Unlock()
1910 i.invalidate()
1911
1912 go func() {
1913 sink := func(delta string) {
1914 i.mu.Lock()
1915 i.streaming.WriteString(delta)
1916 i.mu.Unlock()
1917 i.invalidate()
1918 }
1919 summary, err := i.agent.Compact(ctx, 4, sink)
1920 i.mu.Lock()
1921 i.busy = false
1922 i.streamOn = false
1923 i.streaming.Reset()
1924 i.cancelTurn = nil
1925 i.autoCompacting = false
1926 switch {
1927 case err != nil && ctx.Err() != nil:
1928 i.statusErr = ""
1929 if auto {
1930 i.statusOK = "auto-condense cancelled"
1931 } else {
1932 i.statusOK = "compaction cancelled"
1933 }
1934 case err != nil:
1935 i.statusErr = "compaction failed: " + err.Error()
1936 i.statusOK = ""
1937 default:
1938 i.statusErr = ""
1939 i.statusOK = fmt.Sprintf("compacted transcript (%d chars of summary)", len(summary))
1940 i.lastCtxInput = 0 // reset; next turn will get a fresh measurement
1941 i.toolCalls = map[string]*tui.ToolCallView{}
1942 i.toolOrder = nil
1943 // Transcript was rewritten in place — purge the per-message
1944 // render cache so stale entries keyed on the old messages
1945 // don't linger.
1946 i.view.InvalidateRenderCache()
1947 }
1948 i.mu.Unlock()
1949 i.invalidate()
1950 }()
1951}
1952
1953func (i *Interactive) startTurn(parent context.Context, prompt string) {
1954 if i.agent == nil {
1955 return
1956 }
1957 ctx, cancel := context.WithCancel(parent)
1958 i.mu.Lock()
1959 i.busy = true
1960 i.spin.Start()
1961 i.cancelTurn = cancel
1962 i.statusErr = ""
1963 i.statusOK = ""
1964 i.streaming.Reset()
1965 i.streamOn = true
1966 i.toolCalls = map[string]*tui.ToolCallView{}
1967 i.toolOrder = nil
1968 i.scrollOffset = 0 // jump back to the bottom on new turn
1969 i.parkedTurn = 0 // starting a turn clears the /jump parked state
1970 i.parkedTotal = 0
1971 i.helpBlock = nil // hide the help block once the user asks something
1972 i.mu.Unlock()
1973 i.invalidate()
1974
1975 sink := func(ev core.AgentEvent) {
1976 i.handleEvent(ev)
1977 i.invalidate()
1978 }
1979
1980 go func() {
1981 err := i.agent.Prompt(ctx, prompt, nil, sink)
1982 i.mu.Lock()
1983 i.busy = false
1984 i.streamOn = false
1985 i.cancelTurn = nil
1986 if err != nil && ctx.Err() == nil {
1987 i.statusErr = err.Error()
1988 }
1989 // Pop the next queued message, if any, and relaunch.
1990 var next string
1991 var hasNext bool
1992 if len(i.queued) > 0 && ctx.Err() == nil && err == nil {
1993 next, i.queued = i.queued[0], i.queued[1:]
1994 hasNext = true
1995 }
1996 // If the turn was cancelled or errored, drop the queue so the
1997 // user isn't bombarded with stale messages after an interrupt.
1998 if ctx.Err() != nil || err != nil {
1999 i.queued = nil
2000 }
2001 // Decide whether the next thing to do is an auto-compaction.
2002 // Only fires when the turn completed cleanly AND the queue is
2003 // empty (otherwise a queued message would race the condense).
2004 shouldAutoCompact := !hasNext && err == nil && ctx.Err() == nil && i.shouldAutoCompactLocked()
2005 i.mu.Unlock()
2006 i.invalidate()
2007 parent := i.runCtx
2008 if parent == nil {
2009 parent = context.Background()
2010 }
2011 switch {
2012 case hasNext:
2013 i.startTurn(parent, next)
2014 case shouldAutoCompact:
2015 i.runCompact(parent, true)
2016 }
2017 }()
2018}
2019
2020// autoCompactThreshold is the context-window fraction at which the
2021// agent will auto-compact after a turn ends. 0.85 leaves enough
2022// headroom for one more user prompt + response before we bump the
2023// hard limit.
2024const autoCompactThreshold = 0.85
2025
2026// shouldAutoCompactLocked reports whether the last turn pushed context
2027// usage past the auto-compact threshold. Must be called with i.mu
2028// held; it reads lastCtxInput and the current model's context window.
2029func (i *Interactive) shouldAutoCompactLocked() bool {
2030 if i.agent == nil {
2031 return false
2032 }
2033 if i.autoCompacting {
2034 return false
2035 }
2036 m, err := provider.FindModel(i.cfg.Provider, i.cfg.Model)
2037 if err != nil || m.ContextWindow <= 0 {
2038 return false
2039 }
2040 if i.lastCtxInput <= 0 {
2041 return false
2042 }
2043 return float64(i.lastCtxInput)/float64(m.ContextWindow) >= autoCompactThreshold
2044}
2045
2046func (i *Interactive) handleEvent(ev core.AgentEvent) {
2047 i.mu.Lock()
2048 defer i.mu.Unlock()
2049 switch e := ev.(type) {
2050 case core.EvTextDelta:
2051 i.streaming.WriteString(e.Delta)
2052 case core.EvAssistantMessage:
2053 i.streaming.Reset()
2054 i.streamOn = false
2055 if i.cfg.OnAssistant != nil {
2056 i.cfg.OnAssistant(e.Message)
2057 }
2058 // Mirror the assistant's final visible text to the telegram
2059 // bridge when active. Only TextBlock content is forwarded;
2060 // tool_use blocks are internal. Send on a goroutine so the
2061 // network call doesn't hold the event loop lock.
2062 if i.telegramBridge != nil && i.telegramBridge.Active() {
2063 var sb strings.Builder
2064 for _, c := range e.Message.Content {
2065 if tb, ok := c.(provider.TextBlock); ok {
2066 if sb.Len() > 0 {
2067 sb.WriteString("\n")
2068 }
2069 sb.WriteString(tb.Text)
2070 }
2071 }
2072 if text := sb.String(); strings.TrimSpace(text) != "" {
2073 go i.telegramBridge.OnAssistantText(text)
2074 }
2075 }
2076 case core.EvToolUseStart:
2077 // Live streaming: pre-create the view so the user sees the
2078 // tool call being composed in real time. Any subsequent
2079 // EvToolCall for the same ID updates the same struct (the
2080 // final parsed args + name are already known here).
2081 if _, exists := i.toolCalls[e.ID]; !exists {
2082 i.toolCalls[e.ID] = &tui.ToolCallView{
2083 ID: e.ID,
2084 Name: e.Name,
2085 Streaming: true,
2086 }
2087 i.toolOrder = append(i.toolOrder, e.ID)
2088 }
2089 case core.EvToolUseArgs:
2090 if tc, ok := i.toolCalls[e.ID]; ok {
2091 tc.RawJSONBuf += e.Delta
2092 // Refresh the live path as soon as it parses; used in
2093 // the header (▸ write /Users/pat/Desktop/demo.ts)
2094 // while the content is still streaming.
2095 if p, pok, _ := tui.ExtractPartialStringField(tc.RawJSONBuf, "path"); pok {
2096 tc.LivePath = p
2097 } else if p, pok, _ := tui.ExtractPartialStringField(tc.RawJSONBuf, "file_path"); pok {
2098 tc.LivePath = p
2099 }
2100 }
2101 case core.EvToolUseEnd:
2102 if tc, ok := i.toolCalls[e.ID]; ok {
2103 tc.Streaming = false
2104 }
2105 case core.EvToolCall:
2106 // If we already pre-created the view during streaming, just
2107 // refresh the final Args summary. Otherwise create a new one
2108 // (non-streaming providers or legacy paths).
2109 if tc, ok := i.toolCalls[e.ID]; ok {
2110 tc.Args = shortArgs(e.Args)
2111 tc.Streaming = false
2112 } else {
2113 i.toolCalls[e.ID] = &tui.ToolCallView{
2114 ID: e.ID,
2115 Name: e.Name,
2116 Args: shortArgs(e.Args),
2117 }
2118 i.toolOrder = append(i.toolOrder, e.ID)
2119 }
2120 case core.EvToolResult:
2121 if tc, ok := i.toolCalls[e.ID]; ok {
2122 tc.Done = true
2123 tc.Error = e.Result.IsError
2124 var text strings.Builder
2125 for _, c := range e.Result.Content {
2126 if tb, ok := c.(provider.TextBlock); ok {
2127 if text.Len() > 0 {
2128 text.WriteString("\n")
2129 }
2130 text.WriteString(tb.Text)
2131 }
2132 }
2133 tc.Result = text.String()
2134 }
2135 if i.cfg.OnToolResult != nil {
2136 i.cfg.OnToolResult(e.ID, e.Result)
2137 }
2138 case core.EvUsage:
2139 i.cumUsage = e.Cumulative
2140 if e.Usage.InputTokens > 0 {
2141 i.lastCtxInput = e.Usage.InputTokens + e.Usage.CacheReadTokens + e.Usage.CacheWriteTokens
2142 }
2143 case core.EvTurnEnd:
2144 if e.Stop == provider.StopAborted {
2145 // Aborted turn: discard the partial streaming text (it is not
2146 // persisted in the transcript) and clear any transient error.
2147 i.streaming.Reset()
2148 i.streamOn = false
2149 i.statusErr = ""
2150 i.statusOK = "cancelled"
2151 return
2152 }
2153 if e.Err != nil && !strings.Contains(e.Err.Error(), "context canceled") {
2154 i.statusErr = e.Err.Error()
2155 }
2156 }
2157}
2158
2159// Agent returns the current agent, if any. Used by cli.go to flush the
2160// final transcript to the session file.
2161func (i *Interactive) Agent() *core.Agent {
2162 i.mu.Lock()
2163 defer i.mu.Unlock()
2164 return i.agent
2165}
2166
2167func shortArgs(raw json.RawMessage) string {
2168 var v any
2169 if err := json.Unmarshal(raw, &v); err != nil {
2170 return ""
2171 }
2172 if m, ok := v.(map[string]any); ok {
2173 for _, k := range []string{"path", "file_path", "command"} {
2174 if s, ok := m[k].(string); ok {
2175 if len(s) > 60 {
2176 s = s[:57] + "..."
2177 }
2178 return s
2179 }
2180 }
2181 }
2182 b, _ := json.Marshal(v)
2183 s := string(b)
2184 if len(s) > 60 {
2185 s = s[:57] + "..."
2186 }
2187 return s
2188}
2189
2190// silence unused import in some build configs
2191var _ = fmt.Sprintf
2192
2193// runReloadExt triggers a live reload of every extension (discovered
2194// + explicit). Runs on a goroutine so the TUI stays responsive; the
2195// Manager.Reload takes a couple of hundred ms to shut down subprocs
2196// and respawn them. Shows a status line throughout.
2197func (i *Interactive) runReloadExt(ctx context.Context) {
2198 if i.cfg.Extensions == nil {
2199 i.mu.Lock()
2200 i.statusErr = "no extension manager in this build"
2201 i.mu.Unlock()
2202 i.invalidate()
2203 return
2204 }
2205 i.mu.Lock()
2206 i.statusOK = "reloading extensions…"
2207 i.statusErr = ""
2208 i.mu.Unlock()
2209 i.invalidate()
2210
2211 go func() {
2212 stats := i.cfg.Extensions.Reload(ctx, 2*time.Second)
2213 msg := fmt.Sprintf("reloaded: %d stopped, %d loaded (%d ready)", stats.Stopped, stats.Loaded, stats.Ready)
2214 if len(stats.Errors) > 0 {
2215 msg += fmt.Sprintf(", %d error(s)", len(stats.Errors))
2216 }
2217 i.mu.Lock()
2218 i.statusOK = msg
2219 i.statusErr = ""
2220 i.mu.Unlock()
2221 i.invalidate()
2222 }()
2223}
2224
2225// Confirm implements core.Confirmer. The agent goroutine calls
2226// this synchronously before every tool invocation when --no-yolo is
2227// active. We push the request onto the confirmDialog queue, trigger
2228// a redraw, and block the caller until the user answers.
2229//
2230// If the session is cancelled or the TUI exits mid-prompt, any
2231// pending request is refused via CancelAll so the agent doesn't
2232// deadlock.
2233func (i *Interactive) Confirm(toolName string, preview string) core.ConfirmDecision {
2234 resp := make(chan core.ConfirmDecision, 1)
2235 i.confirmDialog.Enqueue(&confirmRequest{
2236 toolName: toolName,
2237 preview: preview,
2238 resp: resp,
2239 })
2240 i.invalidate()
2241 return <-resp
2242}
2243
2244// runYoloOn disables --no-yolo for the rest of the session. Tool
2245// calls run without prompting after this; there's intentionally no
2246// way to re-enable gating mid-session, if the user wants that back
2247// they can exit and restart with --no-yolo.
2248func (i *Interactive) runYoloOn() {
2249 if i.cfg.ConfirmGate == nil {
2250 i.mu.Lock()
2251 i.statusOK = "yolo mode is already on (no --no-yolo in this session)"
2252 i.statusErr = ""
2253 i.mu.Unlock()
2254 i.invalidate()
2255 return
2256 }
2257 i.cfg.ConfirmGate.AllowAll()
2258 // Also auto-allow any currently pending confirmation so the
2259 // agent doesn't deadlock if /yolo is typed while a prompt is
2260 // open.
2261 i.confirmDialog.AllowAllPending()
2262 i.mu.Lock()
2263 i.statusOK = "yolo engaged: no more tool-call confirmations this session"
2264 i.statusErr = ""
2265 i.mu.Unlock()
2266 i.invalidate()
2267}
2268
2269// openTelegramDialog shows the picker for `/telegram` with no arg.
2270// Items depend on current state: disconnect + status when running,
2271// connect + status when stopped.
2272func (i *Interactive) openTelegramDialog() {
2273 items := i.telegramMenuItems()
2274 if len(items) == 0 {
2275 i.mu.Lock()
2276 i.statusErr = "telegram not configured. run `zot telegram-bot setup` first."
2277 i.mu.Unlock()
2278 i.invalidate()
2279 return
2280 }
2281 i.telegramDialog.Open(items)
2282 i.invalidate()
2283}
2284
2285// telegramMenuItems builds the dialog entries for the current
2286// bridge state. Returns empty when no bot.json exists so the
2287// caller can show a helpful status line instead of an empty menu.
2288func (i *Interactive) telegramMenuItems() []telegramItem {
2289 cfg, err := telegram.LoadConfig(i.cfg.ZotHome)
2290 if err != nil || cfg.BotToken == "" {
2291 return nil
2292 }
2293 var items []telegramItem
2294 if i.telegramBridge != nil && i.telegramBridge.Active() {
2295 items = append(items, telegramItem{label: "disconnect", action: "disconnect", hint: "stop mirroring"})
2296 st := i.telegramBridge.State()
2297 hint := "active"
2298 if st.Username != "" {
2299 hint += " as @" + st.Username
2300 }
2301 items = append(items, telegramItem{label: "status", action: "status", hint: hint})
2302 } else {
2303 label := "connect"
2304 hint := "start mirroring dms into this session"
2305 if cfg.AllowedUserID == 0 {
2306 hint = "awaiting pairing (send /start to the bot once connected)"
2307 }
2308 items = append(items, telegramItem{label: label, action: "connect", hint: hint})
2309 items = append(items, telegramItem{label: "status", action: "status", hint: "disconnected"})
2310 }
2311 return items
2312}
2313
2314// doTelegram dispatches one of the three explicit actions. Called
2315// from /telegram <action> or after the picker selects a row.
2316func (i *Interactive) doTelegram(action string) {
2317 switch action {
2318 case "connect":
2319 i.telegramConnect()
2320 case "disconnect":
2321 i.telegramDisconnect()
2322 case "status":
2323 i.telegramStatus()
2324 default:
2325 i.mu.Lock()
2326 i.statusErr = "unknown telegram action: " + action + " (use connect, disconnect, or status)"
2327 i.mu.Unlock()
2328 i.invalidate()
2329 }
2330}
2331
2332// telegramConnect starts the bridge. Refuses if it's already
2333// running or if the on-disk bot.json is missing a token.
2334func (i *Interactive) telegramConnect() {
2335 if i.telegramBridge != nil && i.telegramBridge.Active() {
2336 i.mu.Lock()
2337 i.statusOK = "telegram already connected"
2338 i.statusErr = ""
2339 i.mu.Unlock()
2340 i.invalidate()
2341 return
2342 }
2343 cfg, err := telegram.LoadConfig(i.cfg.ZotHome)
2344 if err != nil {
2345 i.mu.Lock()
2346 i.statusErr = "telegram: " + err.Error()
2347 i.mu.Unlock()
2348 i.invalidate()
2349 return
2350 }
2351 if cfg.BotToken == "" {
2352 i.mu.Lock()
2353 i.statusErr = "telegram: no bot token configured. run `zot telegram-bot setup` first."
2354 i.mu.Unlock()
2355 i.invalidate()
2356 return
2357 }
2358 // Refuse to start when a background daemon is already polling
2359 // the same bot. Two concurrent long-poll consumers race each
2360 // update and one always loses, so DMs get half-delivered. The
2361 // user can `zot telegram-bot stop` first, then /telegram connect.
2362 if pid, alive, _ := telegram.IsRunning(i.cfg.ZotHome); alive && pid > 0 {
2363 i.mu.Lock()
2364 i.statusErr = fmt.Sprintf("telegram: bot daemon already running (pid %d). stop it with `zot telegram-bot stop` first.", pid)
2365 i.mu.Unlock()
2366 i.invalidate()
2367 return
2368 }
2369 i.telegramBridge = &telegram.Bridge{
2370 Client: telegram.NewClient(cfg.BotToken),
2371 Config: cfg,
2372 Save: func(next telegram.Config) error {
2373 return telegram.SaveConfig(i.cfg.ZotHome, next)
2374 },
2375 Host: &telegramHost{iv: i},
2376 }
2377 if err := i.telegramBridge.Start(i.runCtx); err != nil {
2378 i.telegramBridge = nil
2379 i.mu.Lock()
2380 i.statusErr = "telegram connect failed: " + err.Error()
2381 i.mu.Unlock()
2382 i.invalidate()
2383 return
2384 }
2385 state := i.telegramBridge.State()
2386 label := "telegram connected"
2387 if state.Username != "" {
2388 label += " as @" + state.Username
2389 }
2390 if state.PairedID == 0 {
2391 label += " — send /start to the bot from your phone to claim it"
2392 }
2393 i.mu.Lock()
2394 i.statusOK = label
2395 i.statusErr = ""
2396 i.mu.Unlock()
2397 i.invalidate()
2398}
2399
2400// telegramDisconnect stops the bridge. No-op when already stopped.
2401func (i *Interactive) telegramDisconnect() {
2402 if i.telegramBridge == nil || !i.telegramBridge.Active() {
2403 i.mu.Lock()
2404 i.statusOK = "telegram already disconnected"
2405 i.statusErr = ""
2406 i.mu.Unlock()
2407 i.invalidate()
2408 return
2409 }
2410 i.telegramBridge.Stop()
2411 i.mu.Lock()
2412 i.statusOK = "telegram disconnected"
2413 i.statusErr = ""
2414 i.mu.Unlock()
2415 i.invalidate()
2416}
2417
2418// telegramStatus writes a one-liner describing the bridge state.
2419// Reports on both the in-tui bridge and the background daemon so
2420// the user isn't confused when the daemon owns the poll loop.
2421func (i *Interactive) telegramStatus() {
2422 var msg string
2423 if i.telegramBridge != nil && i.telegramBridge.Active() {
2424 s := i.telegramBridge.State()
2425 msg = "telegram: connected (tui bridge)"
2426 if s.Username != "" {
2427 msg += " as @" + s.Username
2428 }
2429 if s.PairedID != 0 {
2430 msg += fmt.Sprintf(" · paired with user %d", s.PairedID)
2431 } else {
2432 msg += " · awaiting pairing"
2433 }
2434 } else if pid, alive, _ := telegram.IsRunning(i.cfg.ZotHome); alive && pid > 0 {
2435 msg = fmt.Sprintf("telegram: background daemon running (pid %d) · /telegram connect won't work until you stop it", pid)
2436 } else {
2437 cfg, _ := telegram.LoadConfig(i.cfg.ZotHome)
2438 if cfg.BotToken == "" {
2439 msg = "telegram: not configured. run `zot telegram-bot setup` first."
2440 } else {
2441 msg = "telegram: disconnected"
2442 if cfg.BotUsername != "" {
2443 msg += " (@" + cfg.BotUsername + " ready to connect)"
2444 }
2445 }
2446 }
2447 i.mu.Lock()
2448 i.statusOK = msg
2449 i.statusErr = ""
2450 i.mu.Unlock()
2451 i.invalidate()
2452}
2453
2454// telegramHost adapts *Interactive to telegram.Host so the bridge
2455// can call back into the TUI without importing modes directly.
2456type telegramHost struct{ iv *Interactive }
2457
2458func (h *telegramHost) SubmitOrQueue(prompt string, images []provider.ImageBlock) {
2459 h.iv.SubmitOrQueue(prompt, images)
2460}
2461
2462func (h *telegramHost) CancelTurn() { h.iv.CancelTurn() }
2463
2464func (h *telegramHost) Notify(level, message string) {
2465 h.iv.mu.Lock()
2466 switch level {
2467 case "error", "warn":
2468 h.iv.statusErr = message
2469 h.iv.statusOK = ""
2470 default:
2471 h.iv.statusOK = message
2472 h.iv.statusErr = ""
2473 }
2474 h.iv.mu.Unlock()
2475 h.iv.invalidate()
2476}