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