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 ZotHome string
53
54 // Sandbox is the shared sandbox pointer. Toggled by /lock and /unlock.
55 Sandbox *tools.Sandbox
56
57 // LoadSession swaps the current session for the one at path. The
58 // callback returns the new agent message slice so the TUI can invalidate.
59 LoadSession func(path string) error
60
61 // PersistModel is called whenever the user switches model or provider.
62 // It should update config.json and (if there's an active session)
63 // write a new meta row so resume picks up the same model.
64 PersistModel func(providerName, model string)
65
66 OnAssistant func(m provider.Message)
67 OnToolResult func(id string, r core.ToolResult)
68}
69
70// Interactive is the TUI chat loop.
71type Interactive struct {
72 cfg InteractiveConfig
73 view *tui.View
74 ed *tui.Editor
75 rend *tui.Renderer
76
77 mu sync.Mutex
78 agent *core.Agent
79 streaming strings.Builder
80 streamOn bool
81 toolCalls map[string]*tui.ToolCallView
82 toolOrder []string
83 statusErr string
84 statusOK string
85 helpBlock []string // rendered above the chat when /help was typed
86 cumUsage provider.Usage
87 lastCtxInput int // input_tokens of the most recent turn — approximates current context size
88 busy bool
89 dirty chan struct{}
90 cancelTurn context.CancelFunc
91 scrollOffset int // rows from the bottom; 0 = pinned to latest
92
93 // Messages typed while a turn is in flight. Each is delivered as
94 // its own follow-up turn once the current one finishes. Rendered
95 // above the status bar as "sliding in: ..." chips.
96 queued []string
97
98 // runCtx is the top-level context passed to Run(). Follow-up turns
99 // drained from `queued` are started against this context so they
100 // survive past the ctx of the key event that enqueued them.
101 runCtx context.Context
102
103 // autoCompacting is true while a model-triggered compaction is in
104 // flight. Surfaced in the status bar so the user can tell a
105 // condense pass from a regular assistant turn.
106 autoCompacting bool
107
108 dialog *loginDialog
109 modelDialog *modelDialog
110 sessionDialog *sessionDialog
111 suggest *slashSuggester
112 spin *spinner
113}
114
115// NewInteractive constructs an Interactive from cfg.
116func NewInteractive(cfg InteractiveConfig) *Interactive {
117 i := &Interactive{
118 cfg: cfg,
119 view: &tui.View{
120 Theme: cfg.Theme,
121 ImageProto: tui.DetectImageProtocol(),
122 },
123 ed: tui.NewEditor(cfg.Theme.FG256(cfg.Theme.Accent, "▌ ")),
124 rend: tui.NewRenderer(cfg.Terminal),
125 toolCalls: map[string]*tui.ToolCallView{},
126 dirty: make(chan struct{}, 8),
127 dialog: newLoginDialog(),
128 modelDialog: newModelDialog(),
129 sessionDialog: newSessionDialog(),
130 suggest: newSlashSuggester(),
131 spin: newSpinner(),
132 }
133 if cfg.Agent != nil {
134 i.agent = cfg.Agent
135 i.view.Messages = cfg.Agent.Messages()
136 }
137 return i
138}
139
140// Run blocks until the user quits.
141func (i *Interactive) Run(ctx context.Context) error {
142 i.runCtx = ctx
143 term := i.cfg.Terminal
144 restore, err := term.EnterRaw()
145 if err != nil {
146 return err
147 }
148 defer restore()
149
150 _, _ = term.Write([]byte(tui.SeqBracketedPasteOn))
151 _, _ = term.Write([]byte(tui.SeqAltScreenOn))
152 defer term.Write([]byte(tui.SeqAltScreenOff + tui.SeqBracketedPasteOff + tui.SeqShowCursor))
153
154 cols, rows := term.Size()
155 i.rend.Resize(cols, rows)
156 term.OnResize(func() {
157 c, r := term.Size()
158 i.rend.Resize(c, r)
159 i.invalidate()
160 })
161
162 if i.cfg.InitialInput != "" {
163 i.ed.SetValue(i.cfg.InitialInput)
164 }
165
166 // No credential at startup? Auto-open the login dialog, and mark
167 // the status line. The user can Esc out of the dialog if they
168 // want to dismiss it (e.g. to check /help or /exit first).
169 if i.agent == nil {
170 i.statusErr = "not logged in. pick a login method below or press esc to dismiss."
171 i.dialog.Open()
172 }
173
174 // Input goroutine.
175 keys := make(chan tui.Key, 8)
176 go func() {
177 reader := tui.NewReaderWithPeek(term.ReadByte, term.PeekByteTimeout)
178 for {
179 k, err := reader.Read()
180 if err != nil {
181 return
182 }
183 keys <- k
184 }
185 }()
186
187 // Subscribe to auth events.
188 var authEvents <-chan auth.Event
189 if i.cfg.AuthManager != nil {
190 authEvents = i.cfg.AuthManager.Events()
191 }
192
193 // Animation ticker: drives spinner and dialog-related redraws when
194 // nothing else changed. 120ms is slow enough that highlighting a huge
195 // transcript doesn't spin the cpu.
196 tick := time.NewTicker(120 * time.Millisecond)
197 defer tick.Stop()
198
199 // Redraw throttle: coalesce bursts of invalidate() calls so we paint
200 // at most once every redrawMinInterval. Huge tool-result dumps can
201 // fire hundreds of invalidations while the user is typing; without
202 // this, the input goroutine never gets CPU and keystrokes lag.
203 const redrawMinInterval = 16 * time.Millisecond
204 var lastRedraw time.Time
205 var pendingRedraw bool
206 var pendingTimer *time.Timer
207
208 drainPending := func() {
209 if pendingTimer != nil {
210 pendingTimer.Stop()
211 pendingTimer = nil
212 }
213 if pendingRedraw {
214 pendingRedraw = false
215 lastRedraw = time.Now()
216 i.redraw()
217 }
218 }
219
220 requestRedraw := func() {
221 since := time.Since(lastRedraw)
222 if since >= redrawMinInterval {
223 lastRedraw = time.Now()
224 i.redraw()
225 return
226 }
227 if pendingRedraw {
228 return // already scheduled
229 }
230 pendingRedraw = true
231 wait := redrawMinInterval - since
232 if pendingTimer == nil {
233 pendingTimer = time.AfterFunc(wait, func() {
234 // Poke the dirty channel; the main loop will call drainPending().
235 i.invalidate()
236 })
237 } else {
238 pendingTimer.Reset(wait)
239 }
240 }
241
242 i.invalidate()
243
244 for {
245 select {
246 case <-ctx.Done():
247 return ctx.Err()
248 case k := <-keys:
249 if done := i.handleKey(ctx, k); done {
250 return nil
251 }
252 i.invalidate()
253 case ev := <-authEvents:
254 i.handleAuthEvent(ev)
255 i.invalidate()
256 case <-i.dirty:
257 requestRedraw()
258 case <-tick.C:
259 if i.busy || i.dialog.Active() || i.modelDialog.Active() || i.sessionDialog.Active() {
260 drainPending()
261 requestRedraw()
262 }
263 }
264 }
265}
266
267func (i *Interactive) invalidate() {
268 select {
269 case i.dirty <- struct{}{}:
270 default:
271 }
272}
273
274// lastCols returns the current terminal width in columns.
275func (i *Interactive) lastCols() int {
276 cols, _ := i.cfg.Terminal.Size()
277 return cols
278}
279
280// chatPage returns the number of chat rows currently visible, used
281// as the page size for PageUp/PageDown.
282func (i *Interactive) chatPage() int {
283 _, rows := i.cfg.Terminal.Size()
284 p := rows - 6 // rough reservation for status + editor + a dialog line
285 if p < 4 {
286 p = 4
287 }
288 return p
289}
290
291// scrollBy adjusts the scroll offset. Positive = up (into history).
292func (i *Interactive) scrollBy(delta int) {
293 i.mu.Lock()
294 i.scrollOffset += delta
295 if i.scrollOffset < 0 {
296 i.scrollOffset = 0
297 }
298 i.mu.Unlock()
299 i.invalidate()
300}
301
302// scrollToBottom pins the view to the latest content.
303func (i *Interactive) scrollToBottom() {
304 i.mu.Lock()
305 i.scrollOffset = 0
306 i.mu.Unlock()
307 i.invalidate()
308}
309
310func (i *Interactive) redraw() {
311 i.mu.Lock()
312 defer i.mu.Unlock()
313
314 cols, _ := i.cfg.Terminal.Size()
315 if i.agent != nil {
316 i.view.Messages = i.agent.Messages()
317 } else {
318 i.view.Messages = nil
319 }
320 i.view.Streaming = i.streaming.String()
321 i.view.StreamingActive = i.streamOn
322 // Belt and suspenders: if the transcript's last message is already an
323 // assistant message, the streaming view is stale (EvAssistantMessage may
324 // not have fired yet) — suppress it to avoid double-rendering the reply.
325 if n := len(i.view.Messages); n > 0 && i.view.Messages[n-1].Role == provider.RoleAssistant {
326 i.view.StreamingActive = false
327 }
328 // Live tool-call view: only shown while a turn is in flight. Once
329 // the agent is idle, every tool call has already been folded into
330 // the transcript (as assistant.ToolCallBlock + a tool-role message),
331 // so showing v.ToolCalls a second time would duplicate them below
332 // the final assistant text — which looks like the summary came
333 // "before" the tools.
334 i.view.ToolCalls = i.view.ToolCalls[:0]
335 if i.busy {
336 for _, id := range i.toolOrder {
337 if tc, ok := i.toolCalls[id]; ok {
338 i.view.ToolCalls = append(i.view.ToolCalls, *tc)
339 }
340 }
341 }
342 i.view.Err = i.statusErr
343
344 chat := i.view.Build(cols)
345
346 // Welcome banner: shown at the top of the chat area when there is
347 // no transcript yet. Disappears after the first message is sent.
348 if len(i.view.Messages) == 0 && !i.streamOn && len(i.toolOrder) == 0 {
349 chat = append(welcomeBanner(i.cfg.Theme), chat...)
350 }
351
352 // /help block: appended to the transcript so it appears at the
353 // bottom of the chat area (right above the status bar / editor).
354 // Prepending it would push long conversations off the top of the
355 // viewport, which users would miss entirely.
356 if len(i.helpBlock) > 0 {
357 chat = append(chat, i.helpBlock...)
358 }
359
360 if i.statusOK != "" {
361 chat = append(chat, i.cfg.Theme.FG256(i.cfg.Theme.Tool, "✓ "+i.statusOK), "")
362 }
363
364 // Dialogs (login or model picker) render between chat and the editor.
365 var dialog []string
366 switch {
367 case i.dialog.Active():
368 dialog = i.dialog.Render(i.cfg.Theme, cols)
369 case i.modelDialog.Active():
370 dialog = i.modelDialog.Render(i.cfg.Theme, cols)
371 case i.sessionDialog.Active():
372 dialog = i.sessionDialog.Render(i.cfg.Theme, cols)
373 }
374
375 // Slash-command autocomplete: popup above the status line, only
376 // when the editor starts with "/" and no dialog is already open.
377 var suggest []string
378 currentInput := i.ed.Value()
379 if len(dialog) == 0 && i.suggest.Active(currentInput) && !i.busy {
380 suggest = i.suggest.Render(currentInput, i.cfg.Theme, cols)
381 }
382
383 // Busy prefix shown at the far left of the status bar.
384 var busyPrefix string
385 if i.busy {
386 busyPrefix = fmt.Sprintf("%s %s · %s",
387 i.cfg.Theme.FG256(i.cfg.Theme.Spinner, i.spin.Frame()),
388 i.cfg.Theme.FG256(i.cfg.Theme.Spinner, i.spin.Message()),
389 i.cfg.Theme.FG256(i.cfg.Theme.Muted, i.spin.Elapsed().String()),
390 )
391 }
392
393 ctxMax := 0
394 if m, err := provider.FindModel(i.cfg.Provider, i.cfg.Model); err == nil {
395 ctxMax = m.ContextWindow
396 }
397 status := tui.StatusBar(tui.StatusBarParams{
398 Theme: i.cfg.Theme,
399 Provider: i.cfg.Provider,
400 Model: i.cfg.Model,
401 Busy: i.busy,
402 BusyPrefix: busyPrefix,
403 CWD: i.cfg.CWD,
404 Locked: i.cfg.Sandbox.Locked(),
405 Usage: i.cumUsage,
406 Subscription: i.cfg.AuthMethod == "oauth",
407 ContextUsed: i.lastCtxInput,
408 ContextMax: ctxMax,
409 AutoCompacting: i.autoCompacting,
410 Cols: cols,
411 })
412 edLines, curR, curC := i.ed.Render(cols)
413
414 // "Sliding in" chips for messages the user typed while a turn is
415 // in flight. Shown directly above the status bar so they're close
416 // to the editor but don't push the chat around.
417 var queue []string
418 if len(i.queued) > 0 {
419 for _, q := range i.queued {
420 label := i.cfg.Theme.FG256(i.cfg.Theme.Accent, "▸ sliding in: ")
421 text := truncateLine(q, cols-15)
422 queue = append(queue, label+i.cfg.Theme.FG256(i.cfg.Theme.Muted, text))
423 }
424 }
425
426 // Bottom-sticky sections (always visible, never scroll).
427 bottom := make([]string, 0, len(dialog)+len(suggest)+len(queue)+len(edLines)+1)
428 bottom = append(bottom, dialog...)
429 bottom = append(bottom, suggest...)
430 bottom = append(bottom, queue...)
431 bottom = append(bottom, status)
432 bottom = append(bottom, edLines...)
433
434 _, rows := i.cfg.Terminal.Size()
435 chatRows := rows - len(bottom)
436 if chatRows < 1 {
437 chatRows = 1
438 }
439
440 // Apply scroll offset to the chat slice.
441 maxOffset := len(chat) - chatRows
442 if maxOffset < 0 {
443 maxOffset = 0
444 }
445 if i.scrollOffset > maxOffset {
446 i.scrollOffset = maxOffset
447 }
448 if i.scrollOffset < 0 {
449 i.scrollOffset = 0
450 }
451
452 var visibleChat []string
453 if len(chat) <= chatRows {
454 visibleChat = chat
455 } else {
456 end := len(chat) - i.scrollOffset
457 start := end - chatRows
458 if start < 0 {
459 start = 0
460 }
461 visibleChat = chat[start:end]
462 }
463
464 // A tiny "scrolled up" indicator in the top-right of the chat pane
465 // so you know you're not at the bottom.
466 if i.scrollOffset > 0 && len(visibleChat) > 0 {
467 note := i.cfg.Theme.FG256(i.cfg.Theme.Muted,
468 fmt.Sprintf(" ↑ %d lines more below (end to jump)", i.scrollOffset))
469 visibleChat = append([]string{note}, visibleChat...)
470 if len(visibleChat) > chatRows {
471 visibleChat = visibleChat[:chatRows]
472 }
473 }
474
475 frame := make([]string, 0, len(visibleChat)+len(bottom))
476 frame = append(frame, visibleChat...)
477 frame = append(frame, bottom...)
478
479 cursorRow := len(visibleChat) + len(dialog) + len(suggest) + len(queue) + 1 + curR
480 cursorCol := curC
481 i.rend.Draw(frame, cursorRow, cursorCol)
482}
483
484// truncateLine shortens s so it fits within n display cells, with an
485// ellipsis if trimmed. Used by the "sliding in" chips so a pasted
486// novel doesn't blow past the status line.
487func truncateLine(s string, n int) string {
488 if n <= 0 {
489 return ""
490 }
491 // Collapse newlines — chips are single line.
492 s = strings.ReplaceAll(s, "\n", " ↩ ")
493 runes := []rune(s)
494 if len(runes) <= n {
495 return s
496 }
497 if n <= 1 {
498 return "…"
499 }
500 return string(runes[:n-1]) + "…"
501}
502
503func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) {
504 // Dialogs consume keys while open (except ctrl+c, which always closes them).
505 if i.dialog.Active() {
506 if k.Kind == tui.KeyCtrlC {
507 i.dialog.Close()
508 if i.cfg.AuthManager != nil {
509 i.cfg.AuthManager.CancelOAuth()
510 }
511 return false
512 }
513 act := i.dialog.HandleKey(k)
514 if act.StartAPIKey {
515 i.startAPIKeyFlow(act.Provider)
516 }
517 if act.StartOAuth {
518 i.startOAuthFlow(act.Provider)
519 }
520 return false
521 }
522 if i.modelDialog.Active() {
523 if k.Kind == tui.KeyCtrlC {
524 i.modelDialog.Close()
525 return false
526 }
527 act := i.modelDialog.HandleKey(k)
528 if act.Select {
529 i.applyModelSelection(act.Provider, act.Model)
530 }
531 return false
532 }
533 if i.sessionDialog.Active() {
534 if k.Kind == tui.KeyCtrlC {
535 i.sessionDialog.Close()
536 return false
537 }
538 act := i.sessionDialog.HandleKey(k)
539 if act.Select {
540 i.applySessionSelection(act.Path)
541 }
542 return false
543 }
544
545 // Global keys.
546 switch k.Kind {
547 case tui.KeyCtrlC:
548 if i.busy && i.cancelTurn != nil {
549 i.cancelTurn()
550 return false
551 }
552 return true
553 case tui.KeyEsc:
554 // Esc interrupts a running turn. When idle, fall through so the
555 // editor can clear itself.
556 if i.busy && i.cancelTurn != nil {
557 i.cancelTurn()
558 return false
559 }
560 case tui.KeyCtrlD:
561 if i.ed.IsEmpty() && !i.busy {
562 return true
563 }
564 case tui.KeyCtrlL:
565 i.rend.Clear()
566 i.invalidate()
567 return false
568 case tui.KeyCtrlO:
569 // Toggle expansion of collapsed tool results. Affects every tool
570 // call in the transcript — press again to re-collapse.
571 i.mu.Lock()
572 i.view.ExpandAll = !i.view.ExpandAll
573 i.mu.Unlock()
574 i.invalidate()
575 return false
576 case tui.KeyPageUp:
577 i.scrollBy(+i.chatPage())
578 return false
579 case tui.KeyPageDown:
580 i.scrollBy(-i.chatPage())
581 return false
582 case tui.KeyUp:
583 // Wheel-up in alt screen sends Up arrows on most terminals.
584 // When the editor is empty we use up/down for chat scroll —
585 // independently of whether the agent is busy, so users can
586 // scroll back through long streaming replies while they run.
587 if i.ed.IsEmpty() {
588 i.scrollBy(+3)
589 return false
590 }
591 case tui.KeyDown:
592 if i.ed.IsEmpty() {
593 if i.scrollOffset > 0 {
594 i.scrollBy(-3)
595 }
596 return false
597 }
598 }
599
600 // Note: we intentionally do NOT gate the editor on i.busy here.
601 // Typing while the agent is working is supported — submitted
602 // messages are queued and delivered as follow-up turns when the
603 // current turn ends. See the submit handler below.
604
605 if k.Kind == tui.KeyEnter && k.Alt {
606 i.ed.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: '\n', Alt: true})
607 return false
608 }
609
610 // Slash suggestions: intercept up/down/tab/enter when the popup is visible.
611 if i.suggest.Active(i.ed.Value()) {
612 switch k.Kind {
613 case tui.KeyUp:
614 i.suggest.Up()
615 return false
616 case tui.KeyDown:
617 i.suggest.Down()
618 return false
619 case tui.KeyTab:
620 if name := i.suggest.Selection(i.ed.Value()); name != "" {
621 i.ed.SetValue(name)
622 i.suggest.Reset()
623 }
624 return false
625 case tui.KeyEnter:
626 // Enter on an ambiguous or partial slash prefix: complete to the
627 // currently highlighted command and run it. That way typing
628 // "/lo" + enter picks whichever of /login or /logout is selected
629 // in the popup instead of submitting "/lo" as unknown. Also
630 // clear the editor so the command doesn't linger after the
631 // dialog opens/closes.
632 if name := i.suggest.Selection(i.ed.Value()); name != "" {
633 i.ed.PushHistory(name)
634 i.ed.Clear()
635 i.suggest.Reset()
636 return i.runSlash(ctx, name)
637 }
638 case tui.KeyEsc:
639 i.ed.Clear()
640 i.suggest.Reset()
641 return false
642 }
643 }
644
645 if submit := i.ed.HandleKey(k); submit {
646 text := strings.TrimRight(i.ed.Value(), "\n")
647 if text == "" {
648 return false
649 }
650 i.ed.PushHistory(text)
651 i.ed.Clear()
652 i.suggest.Reset()
653
654 if looksLikeSlashCommand(text) {
655 if !isKnownSlashCommand(text) {
656 head := text
657 if idx := strings.IndexAny(text, " \t"); idx >= 0 {
658 head = text[:idx]
659 }
660 i.mu.Lock()
661 i.statusErr = "unknown command " + head + " — type /help to see the list"
662 i.statusOK = ""
663 i.mu.Unlock()
664 return false
665 }
666 // Slash commands need a quiet state (they may swap models,
667 // compact the transcript, open dialogs, etc). Refuse while
668 // a turn is in flight — esc / ctrl+c cancels first.
669 i.mu.Lock()
670 busy := i.busy
671 i.mu.Unlock()
672 if busy {
673 i.mu.Lock()
674 i.statusErr = "cancel the current turn (esc) before running a slash command"
675 i.mu.Unlock()
676 return false
677 }
678 return i.runSlash(ctx, text)
679 }
680
681 if i.agent == nil {
682 i.mu.Lock()
683 i.statusErr = "not logged in. type /login first."
684 i.mu.Unlock()
685 return false
686 }
687 // If a turn is already in flight, queue this prompt instead of
688 // starting a second one. The drain loop at the end of startTurn
689 // will pick it up when the current turn finishes.
690 i.mu.Lock()
691 if i.busy {
692 i.queued = append(i.queued, text)
693 i.mu.Unlock()
694 i.invalidate()
695 return false
696 }
697 i.mu.Unlock()
698 i.startTurn(ctx, text)
699 }
700 return false
701}
702
703func (i *Interactive) runSlash(ctx context.Context, cmd string) (done bool) {
704 parts := strings.Fields(cmd)
705 switch parts[0] {
706 case "/exit":
707 return true
708 case "/clear":
709 if i.agent != nil {
710 i.agent.SetMessages(nil)
711 }
712 i.mu.Lock()
713 i.toolCalls = map[string]*tui.ToolCallView{}
714 i.toolOrder = nil
715 i.statusErr = ""
716 i.statusOK = ""
717 i.helpBlock = nil
718 i.mu.Unlock()
719 case "/help":
720 i.mu.Lock()
721 i.helpBlock = renderHelpBlock(i.cfg.Theme, i.lastCols())
722 i.statusErr = ""
723 i.statusOK = ""
724 // Pin the viewport to the newest content so the help block,
725 // which we just appended to the end of the transcript, is
726 // what the user actually sees.
727 i.scrollOffset = 0
728 i.mu.Unlock()
729 case "/login":
730 i.dialog.Open()
731 case "/logout":
732 target := "all"
733 if len(parts) >= 2 {
734 target = parts[1]
735 }
736 i.doLogout(target)
737 case "/model":
738 if len(parts) >= 2 {
739 i.applyModelSelection("", parts[1])
740 } else {
741 i.modelDialog.Open(i.cfg.Model)
742 }
743 case "/sessions":
744 i.sessionDialog.Open(i.cfg.ZotHome, i.cfg.CWD)
745 case "/compact":
746 i.runCompact(ctx, false)
747 case "/lock":
748 if i.cfg.Sandbox == nil {
749 i.mu.Lock()
750 i.statusErr = "sandbox not available in this build"
751 i.mu.Unlock()
752 break
753 }
754 i.cfg.Sandbox.Lock()
755 i.mu.Lock()
756 i.statusOK = "locked to " + i.cfg.CWD + " (tools cannot touch paths outside this directory)"
757 i.statusErr = ""
758 i.mu.Unlock()
759 case "/unlock":
760 if i.cfg.Sandbox == nil {
761 i.mu.Lock()
762 i.statusErr = "sandbox not available in this build"
763 i.mu.Unlock()
764 break
765 }
766 i.cfg.Sandbox.Unlock()
767 i.mu.Lock()
768 i.statusOK = "unlocked"
769 i.statusErr = ""
770 i.mu.Unlock()
771 default:
772 i.mu.Lock()
773 i.statusErr = "unknown command: " + parts[0]
774 i.mu.Unlock()
775 }
776 return false
777}
778
779// doLogout clears credentials for the given provider (or all providers)
780// from auth.json. If the active agent was using those credentials, it
781// is torn down so the user is forced through /login before their next
782// prompt.
783//
784// target: "anthropic" | "openai" | "all"
785func (i *Interactive) doLogout(target string) {
786 if i.cfg.AuthManager == nil {
787 i.mu.Lock()
788 i.statusErr = "no auth manager configured"
789 i.mu.Unlock()
790 return
791 }
792 store := i.cfg.AuthManager.Store()
793 if store == nil {
794 i.mu.Lock()
795 i.statusErr = "auth store is not available"
796 i.mu.Unlock()
797 return
798 }
799
800 var providers []string
801 switch target {
802 case "", "all":
803 providers = []string{"anthropic", "openai"}
804 case "anthropic", "openai":
805 providers = []string{target}
806 default:
807 i.mu.Lock()
808 i.statusErr = "unknown provider: " + target + " (use anthropic, openai, or all)"
809 i.mu.Unlock()
810 return
811 }
812
813 var errs []string
814 clearedCurrent := false
815 for _, p := range providers {
816 if err := store.Clear(p); err != nil {
817 errs = append(errs, p+": "+err.Error())
818 continue
819 }
820 if p == i.cfg.Provider {
821 clearedCurrent = true
822 }
823 }
824
825 i.mu.Lock()
826 defer i.mu.Unlock()
827 if len(errs) > 0 {
828 i.statusErr = "logout errors: " + strings.Join(errs, "; ")
829 return
830 }
831 i.statusErr = ""
832 if clearedCurrent {
833 // The running agent was using a credential we just wiped. Drop
834 // it so prompts can't go out with the stale client, and hint at
835 // /login.
836 i.agent = nil
837 i.statusOK = "logged out of " + strings.Join(providers, ", ") + ". type /login to sign back in."
838 } else {
839 i.statusOK = "logged out of " + strings.Join(providers, ", ")
840 }
841}
842
843func (i *Interactive) startAPIKeyFlow(provider string) {
844 url, err := i.cfg.AuthManager.StartAPIKey(provider)
845 if err != nil {
846 i.dialog.ShowResult(false, err.Error())
847 return
848 }
849 i.dialog.ShowWaiting(url)
850}
851
852func (i *Interactive) startOAuthFlow(provider string) {
853 url, err := i.cfg.AuthManager.StartOAuth(provider)
854 if err != nil {
855 i.dialog.ShowResult(false, err.Error())
856 return
857 }
858 i.dialog.ShowWaiting(url)
859}
860
861// applyModelSelection switches the active model (and provider, if the
862// new model belongs to a different one). It rebuilds the underlying
863// client when needed so the provider wire-protocol matches.
864// applySessionSelection loads the given session via the cli-provided callback.
865func (i *Interactive) applySessionSelection(path string) {
866 if i.cfg.LoadSession == nil {
867 i.mu.Lock()
868 i.statusErr = "session loading is not wired in this build"
869 i.mu.Unlock()
870 return
871 }
872 if err := i.cfg.LoadSession(path); err != nil {
873 i.mu.Lock()
874 i.statusErr = err.Error()
875 i.mu.Unlock()
876 return
877 }
878 i.mu.Lock()
879 i.statusOK = "resumed session: " + path
880 i.statusErr = ""
881 i.scrollOffset = 0
882 i.mu.Unlock()
883}
884
885func (i *Interactive) applyModelSelection(prov, model string) {
886 if model == "" {
887 return
888 }
889 m, err := provider.FindModel(prov, model)
890 if err != nil {
891 i.mu.Lock()
892 i.statusErr = err.Error()
893 i.mu.Unlock()
894 return
895 }
896 // Same provider? Just swap the model on the existing agent.
897 if i.agent != nil && m.Provider == i.cfg.Provider {
898 i.mu.Lock()
899 i.cfg.Model = m.ID
900 i.agent.Model = m.ID
901 i.statusOK = "model: " + m.ID
902 i.statusErr = ""
903 i.mu.Unlock()
904 if i.cfg.PersistModel != nil {
905 i.cfg.PersistModel(i.cfg.Provider, m.ID)
906 }
907 return
908 }
909 // Different provider: rebuild agent (needs credentials for target provider).
910 if i.cfg.BuildAgentFor == nil {
911 i.mu.Lock()
912 i.statusErr = "cannot switch provider: no builder configured"
913 i.mu.Unlock()
914 return
915 }
916 ag, p, md, err := i.cfg.BuildAgentFor(m.Provider, m.ID)
917 if err != nil {
918 i.mu.Lock()
919 i.statusErr = err.Error()
920 i.mu.Unlock()
921 return
922 }
923 i.mu.Lock()
924 i.agent = ag
925 i.cfg.Provider = p
926 i.cfg.Model = md
927 i.statusOK = "switched to " + p + " / " + md
928 i.statusErr = ""
929 i.mu.Unlock()
930 if i.cfg.PersistModel != nil {
931 i.cfg.PersistModel(p, md)
932 }
933}
934
935func (i *Interactive) handleAuthEvent(ev auth.Event) {
936 switch ev.Kind {
937 case "started":
938 i.dialog.ShowWaiting(ev.URL)
939 case "browser_open":
940 // no-op
941 case "error":
942 i.dialog.ShowResult(false, ev.Message)
943 case "success":
944 // Rebuild the agent with the fresh credential.
945 ag, prov, model, err := i.cfg.BuildAgent()
946 if err != nil {
947 i.dialog.ShowResult(false, err.Error())
948 return
949 }
950 i.mu.Lock()
951 i.agent = ag
952 i.cfg.Provider = prov
953 i.cfg.Model = model
954 i.statusErr = ""
955 i.statusOK = "logged in to " + ev.Provider + " via " + ev.Method
956 i.mu.Unlock()
957 i.dialog.ShowResult(true, "")
958 }
959}
960
961// runCompact invokes core.Agent.Compact and reflects the progress in
962// the tui. It runs in a goroutine so the ui stays responsive; esc/ctrl+c
963// cancel via the same cancelTurn channel used for normal turns.
964//
965// When auto is true the spinner message is pinned to "condensing
966// history" and the status bar surfaces "(auto)" next to the context
967// percentage so it's obvious the system triggered this, not the user.
968func (i *Interactive) runCompact(parent context.Context, auto bool) {
969 if i.agent == nil {
970 i.mu.Lock()
971 i.statusErr = "not logged in. type /login first."
972 i.mu.Unlock()
973 return
974 }
975 ctx, cancel := context.WithCancel(parent)
976 i.mu.Lock()
977 i.busy = true
978 if auto {
979 i.spin.StartFixed("condensing history")
980 i.autoCompacting = true
981 i.statusOK = "condensing history… (esc to cancel)"
982 } else {
983 i.spin.Start()
984 i.statusOK = "compacting..."
985 }
986 i.cancelTurn = cancel
987 i.statusErr = ""
988 i.streaming.Reset()
989 i.streamOn = true
990 i.scrollOffset = 0
991 i.helpBlock = nil
992 i.mu.Unlock()
993 i.invalidate()
994
995 go func() {
996 sink := func(delta string) {
997 i.mu.Lock()
998 i.streaming.WriteString(delta)
999 i.mu.Unlock()
1000 i.invalidate()
1001 }
1002 summary, err := i.agent.Compact(ctx, 4, sink)
1003 i.mu.Lock()
1004 i.busy = false
1005 i.streamOn = false
1006 i.streaming.Reset()
1007 i.cancelTurn = nil
1008 i.autoCompacting = false
1009 switch {
1010 case err != nil && ctx.Err() != nil:
1011 i.statusErr = ""
1012 if auto {
1013 i.statusOK = "auto-condense cancelled"
1014 } else {
1015 i.statusOK = "compaction cancelled"
1016 }
1017 case err != nil:
1018 i.statusErr = "compaction failed: " + err.Error()
1019 i.statusOK = ""
1020 default:
1021 i.statusErr = ""
1022 i.statusOK = fmt.Sprintf("compacted transcript (%d chars of summary)", len(summary))
1023 i.lastCtxInput = 0 // reset; next turn will get a fresh measurement
1024 i.toolCalls = map[string]*tui.ToolCallView{}
1025 i.toolOrder = nil
1026 }
1027 i.mu.Unlock()
1028 i.invalidate()
1029 }()
1030}
1031
1032func (i *Interactive) startTurn(parent context.Context, prompt string) {
1033 if i.agent == nil {
1034 return
1035 }
1036 ctx, cancel := context.WithCancel(parent)
1037 i.mu.Lock()
1038 i.busy = true
1039 i.spin.Start()
1040 i.cancelTurn = cancel
1041 i.statusErr = ""
1042 i.statusOK = ""
1043 i.streaming.Reset()
1044 i.streamOn = true
1045 i.toolCalls = map[string]*tui.ToolCallView{}
1046 i.toolOrder = nil
1047 i.scrollOffset = 0 // jump back to the bottom on new turn
1048 i.helpBlock = nil // hide the help block once the user asks something
1049 i.mu.Unlock()
1050 i.invalidate()
1051
1052 sink := func(ev core.AgentEvent) {
1053 i.handleEvent(ev)
1054 i.invalidate()
1055 }
1056
1057 go func() {
1058 err := i.agent.Prompt(ctx, prompt, nil, sink)
1059 i.mu.Lock()
1060 i.busy = false
1061 i.streamOn = false
1062 i.cancelTurn = nil
1063 if err != nil && ctx.Err() == nil {
1064 i.statusErr = err.Error()
1065 }
1066 // Pop the next queued message, if any, and relaunch.
1067 var next string
1068 var hasNext bool
1069 if len(i.queued) > 0 && ctx.Err() == nil && err == nil {
1070 next, i.queued = i.queued[0], i.queued[1:]
1071 hasNext = true
1072 }
1073 // If the turn was cancelled or errored, drop the queue so the
1074 // user isn't bombarded with stale messages after an interrupt.
1075 if ctx.Err() != nil || err != nil {
1076 i.queued = nil
1077 }
1078 // Decide whether the next thing to do is an auto-compaction.
1079 // Only fires when the turn completed cleanly AND the queue is
1080 // empty (otherwise a queued message would race the condense).
1081 shouldAutoCompact := !hasNext && err == nil && ctx.Err() == nil && i.shouldAutoCompactLocked()
1082 i.mu.Unlock()
1083 i.invalidate()
1084 parent := i.runCtx
1085 if parent == nil {
1086 parent = context.Background()
1087 }
1088 switch {
1089 case hasNext:
1090 i.startTurn(parent, next)
1091 case shouldAutoCompact:
1092 i.runCompact(parent, true)
1093 }
1094 }()
1095}
1096
1097// autoCompactThreshold is the context-window fraction at which the
1098// agent will auto-compact after a turn ends. 0.85 leaves enough
1099// headroom for one more user prompt + response before we bump the
1100// hard limit.
1101const autoCompactThreshold = 0.85
1102
1103// shouldAutoCompactLocked reports whether the last turn pushed context
1104// usage past the auto-compact threshold. Must be called with i.mu
1105// held; it reads lastCtxInput and the current model's context window.
1106func (i *Interactive) shouldAutoCompactLocked() bool {
1107 if i.agent == nil {
1108 return false
1109 }
1110 if i.autoCompacting {
1111 return false
1112 }
1113 m, err := provider.FindModel(i.cfg.Provider, i.cfg.Model)
1114 if err != nil || m.ContextWindow <= 0 {
1115 return false
1116 }
1117 if i.lastCtxInput <= 0 {
1118 return false
1119 }
1120 return float64(i.lastCtxInput)/float64(m.ContextWindow) >= autoCompactThreshold
1121}
1122
1123func (i *Interactive) handleEvent(ev core.AgentEvent) {
1124 i.mu.Lock()
1125 defer i.mu.Unlock()
1126 switch e := ev.(type) {
1127 case core.EvTextDelta:
1128 i.streaming.WriteString(e.Delta)
1129 case core.EvAssistantMessage:
1130 i.streaming.Reset()
1131 i.streamOn = false
1132 if i.cfg.OnAssistant != nil {
1133 i.cfg.OnAssistant(e.Message)
1134 }
1135 case core.EvToolCall:
1136 tcv := &tui.ToolCallView{
1137 ID: e.ID,
1138 Name: e.Name,
1139 Args: shortArgs(e.Args),
1140 }
1141 i.toolCalls[e.ID] = tcv
1142 i.toolOrder = append(i.toolOrder, e.ID)
1143 case core.EvToolResult:
1144 if tc, ok := i.toolCalls[e.ID]; ok {
1145 tc.Done = true
1146 tc.Error = e.Result.IsError
1147 var text strings.Builder
1148 for _, c := range e.Result.Content {
1149 if tb, ok := c.(provider.TextBlock); ok {
1150 if text.Len() > 0 {
1151 text.WriteString("\n")
1152 }
1153 text.WriteString(tb.Text)
1154 }
1155 }
1156 tc.Result = text.String()
1157 }
1158 if i.cfg.OnToolResult != nil {
1159 i.cfg.OnToolResult(e.ID, e.Result)
1160 }
1161 case core.EvUsage:
1162 i.cumUsage = e.Cumulative
1163 if e.Usage.InputTokens > 0 {
1164 i.lastCtxInput = e.Usage.InputTokens + e.Usage.CacheReadTokens + e.Usage.CacheWriteTokens
1165 }
1166 case core.EvTurnEnd:
1167 if e.Stop == provider.StopAborted {
1168 // Aborted turn: discard the partial streaming text (it is not
1169 // persisted in the transcript) and clear any transient error.
1170 i.streaming.Reset()
1171 i.streamOn = false
1172 i.statusErr = ""
1173 i.statusOK = "cancelled"
1174 return
1175 }
1176 if e.Err != nil && !strings.Contains(e.Err.Error(), "context canceled") {
1177 i.statusErr = e.Err.Error()
1178 }
1179 }
1180}
1181
1182// Agent returns the current agent, if any. Used by cli.go to flush the
1183// final transcript to the session file.
1184func (i *Interactive) Agent() *core.Agent {
1185 i.mu.Lock()
1186 defer i.mu.Unlock()
1187 return i.agent
1188}
1189
1190func shortArgs(raw json.RawMessage) string {
1191 var v any
1192 if err := json.Unmarshal(raw, &v); err != nil {
1193 return ""
1194 }
1195 if m, ok := v.(map[string]any); ok {
1196 for _, k := range []string{"path", "file_path", "command"} {
1197 if s, ok := m[k].(string); ok {
1198 if len(s) > 60 {
1199 s = s[:57] + "..."
1200 }
1201 return s
1202 }
1203 }
1204 }
1205 b, _ := json.Marshal(v)
1206 s := string(b)
1207 if len(s) > 60 {
1208 s = s[:57] + "..."
1209 }
1210 return s
1211}
1212
1213// silence unused import in some build configs
1214var _ = fmt.Sprintf