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