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