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