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