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