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