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