1package modes
2
3import (
4 "context"
5 "encoding/json"
6 "fmt"
7 "os"
8 "path/filepath"
9 "strings"
10 "sync"
11 "time"
12
13 "github.com/patriceckhart/zot/internal/agent/extensions"
14 "github.com/patriceckhart/zot/internal/agent/modes/telegram"
15 "github.com/patriceckhart/zot/internal/agent/tools"
16 "github.com/patriceckhart/zot/internal/auth"
17 "github.com/patriceckhart/zot/internal/core"
18 "github.com/patriceckhart/zot/internal/provider"
19 "github.com/patriceckhart/zot/internal/skills"
20 "github.com/patriceckhart/zot/internal/tui"
21)
22
23// InteractiveConfig configures the interactive loop.
24type InteractiveConfig struct {
25 Terminal tui.Terminal
26 Theme tui.Theme
27 Model string
28 Provider string
29 AuthMethod string // "apikey" | "oauth" — used to tag cost as (sub) in status bar
30 BaseURL string
31 Reasoning string
32 SystemPrompt string
33 Tools core.Registry
34 MaxSteps int
35 CWD string
36
37 // Agent is optional. If nil, zot opens without credentials; the
38 // user must /login before they can prompt.
39 Agent *core.Agent
40
41 InitialInput string
42
43 // Auth is required. When the user runs /login, Interactive talks to
44 // AuthManager to open a browser and wait for the callback.
45 AuthManager *auth.Manager
46 // BuildAgent is called after a successful login to (re)construct the
47 // agent with the fresh credential. It returns the new agent and
48 // the concrete provider/model in use.
49 BuildAgent func() (*core.Agent, string, string, error)
50
51 // BuildAgentFor rebuilds the agent with an explicit provider/model
52 // override (used by the /model picker when switching providers).
53 // If providerOverride is empty, the current provider is kept.
54 BuildAgentFor func(providerOverride, modelOverride string) (*core.Agent, string, string, error)
55
56 // ZotHome is the root directory for sessions/, used by /sessions
57 // and the update-check cache.
58 ZotHome string
59
60 // Version is the binary's current version (from main.version).
61 // Used only for display; the update check itself is done outside
62 // this package to avoid an import cycle.
63 Version string
64
65 // UpdateInfoChan is an optional channel that delivers the result
66 // of the github-release update check. Interactive reads at most
67 // one value, drops it if the check reported nothing, and otherwise
68 // surfaces a yellow "update available" banner at the top of the
69 // chat. Nil channel = no banner, no startup cost.
70 UpdateInfoChan <-chan UpdateInfo
71
72 // Sandbox is the shared sandbox pointer. Toggled by /jail and /unjail.
73 Sandbox *tools.Sandbox
74
75 // LoadSession swaps the current session for the one at path. The
76 // callback returns the new agent message slice so the TUI can invalidate.
77 LoadSession func(path string) error
78
79 // CurrentSessionPath returns the path of the live session file
80 // on disk (the one every AppendMessage writes to). Used by
81 // /session export so the exporter ships the exact bytes on
82 // disk. Returns an empty string when --no-session is set or
83 // no session is open.
84 CurrentSessionPath func() string
85
86 // FlushSession writes any in-memory agent messages to the
87 // session file that haven't been persisted yet. Called by
88 // /session export right before reading the file so the
89 // exported bytes reflect the full current conversation, not
90 // just the rows the agent happened to write synchronously.
91 // The default WriteNewTranscript-at-exit strategy means most
92 // of a running session lives only in memory until the tui
93 // closes; without a flush hook, /session export writes a
94 // file that only has the meta row.
95 FlushSession func()
96
97 // PersistModel is called whenever the user switches model or provider.
98 // It should update config.json and (if there's an active session)
99 // write a new meta row so resume picks up the same model.
100 PersistModel func(providerName, model string)
101
102 OnAssistant func(m provider.Message)
103 OnToolResult func(id string, r core.ToolResult)
104
105 // Extensions, if non-nil, lets users invoke extension-registered
106 // slash commands. Commands declared by extensions are looked up
107 // AFTER the built-in catalog so a built-in name always wins.
108 Extensions *extensions.Manager
109
110 // SkillSnapshot, if non-nil, returns the current list of
111 // discovered SKILL.md files. Re-invoked each time /skills opens
112 // so the picker reflects edits made during the session.
113 SkillSnapshot func() []*skills.Skill
114
115 // ChangelogChan, if non-nil, delivers release-notes for the
116 // current binary version once at startup. Interactive opens a
117 // dismissible overlay when the channel produces a non-empty
118 // body. Receiver fires at most once.
119 ChangelogChan <-chan ChangelogPayload
120
121 // OnChangelogDismiss, if non-nil, is called once the user
122 // closes the changelog overlay. The cli wires this to a
123 // MarkChangelogShown call so the same version doesn't show
124 // again on the next launch.
125 OnChangelogDismiss func()
126
127 // NoYolo is true when --no-yolo was passed. Interactive opens
128 // a confirmation dialog before every tool call and blocks the
129 // tool until the user picks yes / always-this-tool /
130 // always-all / no. When false (default), tools run freely.
131 NoYolo bool
132
133 // ConfirmGate is the session-scoped gate wrapping this
134 // interactive's Confirmer. When non-nil, /yolo can call
135 // AllowAll() on it to disable confirmation for the rest of the
136 // session. When nil (yolo mode), /yolo reports that there's
137 // nothing to disable.
138 ConfirmGate *core.ConfirmGate
139}
140
141// ChangelogPayload mirrors agent.ChangelogInfo without the import
142// cycle. The cli builds one from the http response, the tui opens
143// the overlay when one arrives.
144type ChangelogPayload struct {
145 Version string
146 Body string
147 URL string
148}
149
150// Interactive is the TUI chat loop.
151type Interactive struct {
152 cfg InteractiveConfig
153 view *tui.View
154 ed *tui.Editor
155 rend *tui.Renderer
156
157 mu sync.Mutex
158 agent *core.Agent
159 streaming strings.Builder
160 streamOn bool
161 toolCalls map[string]*tui.ToolCallView
162 toolOrder []string
163 statusErr string
164 statusOK string
165 helpBlock []string // rendered above the chat when /help was typed
166 cumUsage provider.Usage
167 lastCtxInput int // input_tokens of the most recent turn — approximates current context size
168 busy bool
169 dirty chan struct{}
170 cancelTurn context.CancelFunc
171 scrollOffset int // rows from the bottom; 0 = pinned to latest
172
173 // Messages typed while a turn is in flight. Each is delivered as
174 // its own follow-up turn once the current one finishes. Rendered
175 // above the status bar as "sliding in: ..." chips.
176 queued []string
177
178 // runCtx is the top-level context passed to Run(). Follow-up turns
179 // drained from `queued` are started against this context so they
180 // survive past the ctx of the key event that enqueued them.
181 runCtx context.Context
182
183 // autoCompacting is true while a model-triggered compaction is in
184 // flight. Surfaced in the status bar so the user can tell a
185 // condense pass from a regular assistant turn.
186 autoCompacting bool
187
188 // updateInfo is the result of the async update check. Zero value
189 // while the check hasn't completed or nothing is available.
190 updateInfo UpdateInfo
191
192 dialog *loginDialog
193 modelDialog *modelDialog
194 sessionDialog *sessionDialog
195 jumpDialog *jumpDialog
196 btwDialog *btwDialog
197 skillsDialog *skillsDialog
198 changelogDialog *changelogDialog
199 confirmDialog *confirmDialog
200 logoutDialog *logoutDialog
201 telegramDialog *telegramDialog
202 telegramBridge *telegram.Bridge
203 sessionOpsDialog *sessionOpsDialog
204 sessionTreeDialog *sessionTreeDialog
205
206 // pendingFork is true when the user ran /session fork: the next
207 // jump-picker selection should branch off that message instead
208 // of scrolling. Flag resets after the action fires or the dialog
209 // is dismissed, so repeated /jump calls don't turn into forks.
210 pendingFork bool
211 suggest *slashSuggester
212 spin *spinner
213
214 // parkedTurn is the 1-based turn number the viewport is currently
215 // scrolled to by /jump. 0 = not parked, showing the tail as usual.
216 // Rendered as a muted footer at the bottom of the chat so users
217 // don't forget they're looking at history.
218 parkedTurn int
219 parkedTotal int
220
221 // lastCtrlC is when the user last pressed ctrl+c. The first press
222 // clears the editor / cancels a turn / shows a hint; a second press
223 // within ctrlCExitWindow exits. Mirrors the python-repl convention.
224 lastCtrlC time.Time
225
226 // welcomeStart is when the interactive run began. The welcome
227 // banner shows the binary version for welcomeVersionDuration
228 // after this point and reverts to plain text after.
229 welcomeStart time.Time
230
231 // extNotes are one-shot styled lines pushed by extensions via
232 // Notify / Display. They live above the editor (just below the
233 // transcript) until cleared by /clear or another reset.
234 extNotes []string
235}
236
237// welcomeVersionDuration is how long the welcome banner shows the
238// version suffix before reverting to the plain headline. 1.5s is
239// enough to read at a glance and keeps the splash short.
240const welcomeVersionDuration = 1500 * time.Millisecond
241
242// NewInteractive constructs an Interactive from cfg.
243func NewInteractive(cfg InteractiveConfig) *Interactive {
244 i := &Interactive{
245 cfg: cfg,
246 view: &tui.View{
247 Theme: cfg.Theme,
248 ImageProto: tui.DetectImageProtocol(),
249 },
250 ed: tui.NewEditor(cfg.Theme.FG256(cfg.Theme.Accent, "▌ ")),
251 rend: tui.NewRenderer(cfg.Terminal),
252 toolCalls: map[string]*tui.ToolCallView{},
253 dirty: make(chan struct{}, 8),
254 dialog: newLoginDialog(),
255 modelDialog: newModelDialog(),
256 sessionDialog: newSessionDialog(),
257 jumpDialog: newJumpDialog(),
258 btwDialog: newBtwDialog(),
259 skillsDialog: newSkillsDialog(),
260 changelogDialog: newChangelogDialog(),
261 confirmDialog: newConfirmDialog(),
262 logoutDialog: newLogoutDialog(),
263 telegramDialog: newTelegramDialog(),
264 sessionOpsDialog: newSessionOpsDialog(),
265 sessionTreeDialog: newSessionTreeDialog(),
266 suggest: newSlashSuggester(),
267 spin: newSpinner(),
268 }
269 if cfg.Agent != nil {
270 i.agent = cfg.Agent
271 i.view.Messages = cfg.Agent.Messages()
272 }
273 return i
274}
275
276// Run blocks until the user quits.
277func (i *Interactive) Run(ctx context.Context) error {
278 i.runCtx = ctx
279 term := i.cfg.Terminal
280 restore, err := term.EnterRaw()
281 if err != nil {
282 return err
283 }
284 defer restore()
285 defer func() {
286 if i.telegramBridge != nil {
287 i.telegramBridge.Stop()
288 }
289 }()
290
291 _, _ = term.Write([]byte(tui.SeqBracketedPasteOn))
292 _, _ = term.Write([]byte(tui.SeqAltScreenOn))
293 defer term.Write([]byte(tui.SeqAltScreenOff + tui.SeqBracketedPasteOff + tui.SeqShowCursor))
294
295 cols, rows := term.Size()
296 i.rend.Resize(cols, rows)
297 term.OnResize(func() {
298 c, r := term.Size()
299 i.rend.Resize(c, r)
300 // Force an immediate redraw on resize. The throttled invalidate
301 // path is fine for animation, but a window resize is a discrete
302 // user action where any visible delay (or stale frame) reads as
303 // brokenness. redraw() is mutex-safe; the worst that happens is
304 // a duplicate paint if the throttler is mid-flight, which is
305 // invisible.
306 i.redraw()
307 })
308
309 if i.cfg.InitialInput != "" {
310 i.ed.SetValue(i.cfg.InitialInput)
311 }
312
313 // Stamp the welcome time and schedule a one-shot redraw at the
314 // expiry so the version suffix disappears on its own even if the
315 // user hasn't typed anything yet.
316 i.welcomeStart = time.Now()
317 time.AfterFunc(welcomeVersionDuration, i.invalidate)
318
319 // If the agent was constructed with a pre-loaded transcript
320 // (--continue, --resume, --session) park the viewport on the
321 // most recent turn so the user lands looking at where the
322 // previous session left off rather than at the bottom of an
323 // already-rendered final reply.
324 if i.agent != nil {
325 if msgs := i.agent.Messages(); len(msgs) > 0 {
326 i.scrollToLastTurn(msgs)
327 }
328 }
329
330 // No credential at startup? Auto-open the login dialog, and mark
331 // the status line. The user can Esc out of the dialog if they
332 // want to dismiss it (e.g. to check /help or /exit first).
333 if i.agent == nil {
334 i.statusErr = "not logged in. pick a login method below or press esc to dismiss."
335 i.dialog.Open()
336 }
337
338 // Input goroutine.
339 keys := make(chan tui.Key, 8)
340 go func() {
341 reader := tui.NewReaderWithPeek(term.ReadByte, term.PeekByteTimeout)
342 for {
343 k, err := reader.Read()
344 if err != nil {
345 return
346 }
347 keys <- k
348 }
349 }()
350
351 // Subscribe to auth events.
352 var authEvents <-chan auth.Event
353 if i.cfg.AuthManager != nil {
354 authEvents = i.cfg.AuthManager.Events()
355 }
356
357 // Animation ticker: drives spinner and dialog-related redraws when
358 // nothing else changed. 120ms is slow enough that highlighting a huge
359 // transcript doesn't spin the cpu.
360 tick := time.NewTicker(120 * time.Millisecond)
361 defer tick.Stop()
362
363 // Redraw throttle: coalesce bursts of invalidate() calls so we paint
364 // at most once every redrawMinInterval. Huge tool-result dumps can
365 // fire hundreds of invalidations while the user is typing; without
366 // this, the input goroutine never gets CPU and keystrokes lag.
367 const redrawMinInterval = 16 * time.Millisecond
368 var lastRedraw time.Time
369 var pendingRedraw bool
370 var pendingTimer *time.Timer
371
372 drainPending := func() {
373 if pendingTimer != nil {
374 pendingTimer.Stop()
375 pendingTimer = nil
376 }
377 if pendingRedraw {
378 pendingRedraw = false
379 lastRedraw = time.Now()
380 i.redraw()
381 }
382 }
383
384 requestRedraw := func() {
385 since := time.Since(lastRedraw)
386 if since >= redrawMinInterval {
387 // Redrawing right now subsumes any pending redraw, so clear
388 // the throttle state. Without this, a pending flag stays
389 // stuck at true and subsequent invalidate() calls within
390 // redrawMinInterval get dropped — which is exactly how the
391 // final "turn finished" frame went missing until the user
392 // nudged the ui by typing or scrolling.
393 if pendingTimer != nil {
394 pendingTimer.Stop()
395 }
396 pendingRedraw = false
397 lastRedraw = time.Now()
398 i.redraw()
399 return
400 }
401 if pendingRedraw {
402 return // already scheduled
403 }
404 pendingRedraw = true
405 wait := redrawMinInterval - since
406 if pendingTimer == nil {
407 pendingTimer = time.AfterFunc(wait, func() {
408 // Poke the dirty channel so the main loop wakes and
409 // drains the pending redraw on its own goroutine. We
410 // can't call drainPending here directly — it touches
411 // closure state shared with the main loop.
412 i.invalidate()
413 })
414 } else {
415 pendingTimer.Reset(wait)
416 }
417 }
418
419 i.invalidate()
420
421 updates := i.cfg.UpdateInfoChan // nil-safe; nil channel blocks forever in select
422 changelog := i.cfg.ChangelogChan // single-shot, see case below
423
424 for {
425 select {
426 case <-ctx.Done():
427 return ctx.Err()
428 case k := <-keys:
429 if done := i.handleKey(ctx, k); done {
430 return nil
431 }
432 i.invalidate()
433 case ev := <-authEvents:
434 i.handleAuthEvent(ev)
435 i.invalidate()
436 case info, ok := <-updates:
437 if ok && info.Available {
438 i.mu.Lock()
439 i.updateInfo = info
440 i.mu.Unlock()
441 i.invalidate()
442 }
443 updates = nil // single-shot; subsequent iterations skip this case
444 case cl, ok := <-changelog:
445 if ok && cl.Body != "" {
446 i.changelogDialog.Open(cl.Version, cl.URL, cl.Body)
447 i.invalidate()
448 }
449 changelog = nil // single-shot
450 case <-i.dirty:
451 requestRedraw()
452 case <-tick.C:
453 // Always drain a pending redraw on the tick. This is the
454 // safety net that catches the case where the dirty channel
455 // was saturated when the final "turn finished" invalidate
456 // fired, or where the throttle scheduled a deferred redraw
457 // and the AfterFunc-driven invalidate got dropped on a
458 // full channel.
459 drainPending()
460 if i.busy || i.dialog.Active() || i.modelDialog.Active() || i.sessionDialog.Active() || i.jumpDialog.Active() || i.btwDialog.Active() || i.skillsDialog.Active() || i.changelogDialog.Active() || i.confirmDialog.Active() || i.logoutDialog.Active() || i.telegramDialog.Active() || i.sessionOpsDialog.Active() || i.sessionTreeDialog.Active() {
461 requestRedraw() // keep the spinner / dialog animation moving
462 }
463 }
464 }
465}
466
467func (i *Interactive) invalidate() {
468 select {
469 case i.dirty <- struct{}{}:
470 default:
471 }
472}
473
474// lastCols returns the current terminal width in columns.
475func (i *Interactive) lastCols() int {
476 cols, _ := i.cfg.Terminal.Size()
477 return cols
478}
479
480// chatPage returns the number of chat rows currently visible, used
481// as the page size for PageUp/PageDown.
482func (i *Interactive) chatPage() int {
483 _, rows := i.cfg.Terminal.Size()
484 p := rows - 6 // rough reservation for status + editor + a dialog line
485 if p < 4 {
486 p = 4
487 }
488 return p
489}
490
491// scrollBy adjusts the scroll offset. Positive = up (into history).
492// Clearing the parked-turn label when we're back at the bottom means
493// the "viewing turn N" footer goes away automatically as soon as you
494// scroll back to the live tail.
495func (i *Interactive) scrollBy(delta int) {
496 i.mu.Lock()
497 i.scrollOffset += delta
498 if i.scrollOffset < 0 {
499 i.scrollOffset = 0
500 }
501 if i.scrollOffset == 0 {
502 i.parkedTurn = 0
503 i.parkedTotal = 0
504 }
505 i.mu.Unlock()
506 i.invalidate()
507}
508
509// scrollToBottom pins the view to the latest content.
510func (i *Interactive) scrollToBottom() {
511 i.mu.Lock()
512 i.scrollOffset = 0
513 i.parkedTurn = 0
514 i.parkedTotal = 0
515 i.mu.Unlock()
516 i.invalidate()
517}
518
519func (i *Interactive) redraw() {
520 i.mu.Lock()
521 defer i.mu.Unlock()
522
523 cols, _ := i.cfg.Terminal.Size()
524 if i.agent != nil {
525 i.view.Messages = i.agent.Messages()
526 } else {
527 i.view.Messages = nil
528 }
529 i.view.Streaming = i.streaming.String()
530 i.view.StreamingActive = i.streamOn
531 // Belt and suspenders: if the transcript's last message is already an
532 // assistant message, the streaming view is stale (EvAssistantMessage may
533 // not have fired yet) — suppress it to avoid double-rendering the reply.
534 if n := len(i.view.Messages); n > 0 && i.view.Messages[n-1].Role == provider.RoleAssistant {
535 i.view.StreamingActive = false
536 }
537 // Live tool-call view: only shown while a turn is in flight. Once
538 // the agent is idle, every tool call has already been folded into
539 // the transcript (as assistant.ToolCallBlock + a tool-role message),
540 // so showing v.ToolCalls a second time would duplicate them below
541 // the final assistant text — which looks like the summary came
542 // "before" the tools.
543 i.view.ToolCalls = i.view.ToolCalls[:0]
544 if i.busy {
545 for _, id := range i.toolOrder {
546 if tc, ok := i.toolCalls[id]; ok {
547 i.view.ToolCalls = append(i.view.ToolCalls, *tc)
548 }
549 }
550 }
551 i.view.Err = i.statusErr
552
553 chat := i.view.Build(cols)
554
555 // Welcome banner: shown at the top of the chat area when there is
556 // no transcript yet. Disappears after the first message is sent.
557 // The version suffix is shown for welcomeVersionDuration after
558 // startup, then drops off automatically.
559 if len(i.view.Messages) == 0 && !i.streamOn && len(i.toolOrder) == 0 {
560 showVer := !i.welcomeStart.IsZero() && time.Since(i.welcomeStart) < welcomeVersionDuration
561 chat = append(welcomeBanner(i.cfg.Theme, i.cfg.Version, showVer), chat...)
562 }
563
564 // Update-available banner: prepended above everything else so it's
565 // the first thing the user sees when opening a new zot session.
566 // Once rendered, it stays until the user updates to a newer
567 // version — we don't persist a "dismissed" flag because this is
568 // cheap and re-showing it is how most users remember to update.
569 if i.updateInfo.Available {
570 banner := renderUpdateBanner(i.cfg.Theme, i.updateInfo, cols)
571 chat = append(banner, chat...)
572 }
573
574 // /help block: appended to the transcript so it appears at the
575 // bottom of the chat area (right above the status bar / editor).
576 // Prepending it would push long conversations off the top of the
577 // viewport, which users would miss entirely.
578 if len(i.helpBlock) > 0 {
579 chat = append(chat, i.helpBlock...)
580 }
581
582 if i.statusOK != "" {
583 // Hard-truncate the OK line to the visible width so a long
584 // session path ("resumed session: /Users/.../sessions/...")
585 // doesn't overflow past the right edge and look broken on a
586 // narrow terminal.
587 line := "✓ " + i.statusOK
588 if cols > 4 && len(line) > cols {
589 line = line[:cols-1] + "…"
590 }
591 chat = append(chat, i.cfg.Theme.FG256(i.cfg.Theme.Tool, line), "")
592 }
593
594 // Extension notes (notify / display) live just under the
595 // transcript, above the dialog/editor band. Cleared by /clear.
596 if len(i.extNotes) > 0 {
597 chat = append(chat, i.extNotes...)
598 chat = append(chat, "")
599 }
600
601 // Dialogs (login or model picker) render between chat and the editor.
602 var dialog []string
603 switch {
604 case i.dialog.Active():
605 dialog = i.dialog.Render(i.cfg.Theme, cols)
606 case i.modelDialog.Active():
607 dialog = i.modelDialog.Render(i.cfg.Theme, cols)
608 case i.sessionDialog.Active():
609 dialog = i.sessionDialog.Render(i.cfg.Theme, cols)
610 case i.jumpDialog.Active():
611 dialog = i.jumpDialog.Render(i.cfg.Theme, cols)
612 case i.btwDialog.Active():
613 dialog = i.btwDialog.Render(i.cfg.Theme, cols)
614 case i.skillsDialog.Active():
615 dialog = i.skillsDialog.Render(i.cfg.Theme, cols)
616 case i.changelogDialog.Active():
617 dialog = i.changelogDialog.Render(i.cfg.Theme, cols)
618 case i.confirmDialog.Active():
619 dialog = i.confirmDialog.Render(i.cfg.Theme, cols)
620 case i.logoutDialog.Active():
621 dialog = i.logoutDialog.Render(i.cfg.Theme, cols)
622 case i.telegramDialog.Active():
623 dialog = i.telegramDialog.Render(i.cfg.Theme, cols)
624 case i.sessionOpsDialog.Active():
625 dialog = i.sessionOpsDialog.Render(i.cfg.Theme, cols)
626 case i.sessionTreeDialog.Active():
627 dialog = i.sessionTreeDialog.Render(i.cfg.Theme, cols)
628 }
629
630 // Slash-command autocomplete: popup above the status line, only
631 // when the editor starts with "/" and no dialog is already open.
632 // Feed extension-registered commands into the suggester first so
633 // they show up in tab-complete + the popup alongside the built-ins.
634 if i.cfg.Extensions != nil {
635 catalog := i.cfg.Extensions.Commands()
636 extra := make([]slashCommand, 0, len(catalog))
637 for _, c := range catalog {
638 // The popup renders extension commands under a dedicated
639 // "── extensions ───" divider, so the description doesn't
640 // need to repeat the source. If the description is empty,
641 // fall back to the extension name so the row isn't blank.
642 desc := c.Description
643 if strings.TrimSpace(desc) == "" {
644 desc = "(" + c.Extension + ")"
645 }
646 extra = append(extra, slashCommand{
647 Name: "/" + c.Name,
648 Desc: desc,
649 })
650 }
651 i.suggest.SetExtra(extra)
652 }
653 var suggest []string
654 currentInput := i.ed.Value()
655 if len(dialog) == 0 && i.suggest.Active(currentInput) && !i.busy {
656 suggest = i.suggest.Render(currentInput, i.cfg.Theme, cols)
657 }
658
659 // Busy prefix shown at the far left of the status bar. The
660 // spinner glyph and its funny-line message share the `zot`
661 // label colour (Theme.Assistant) so the whole "who's working"
662 // band reads at a glance. Elapsed time stays muted because it
663 // drifts every second and shouldn't grab focus.
664 var busyPrefix string
665 if i.busy {
666 busyPrefix = fmt.Sprintf("%s %s · %s",
667 i.cfg.Theme.FG256(i.cfg.Theme.Assistant, i.spin.Frame()),
668 i.cfg.Theme.FG256(i.cfg.Theme.Assistant, i.spin.Message()),
669 i.cfg.Theme.FG256(i.cfg.Theme.Muted, i.spin.Elapsed().String()),
670 )
671 }
672
673 ctxMax := 0
674 if m, err := provider.FindModel(i.cfg.Provider, i.cfg.Model); err == nil {
675 ctxMax = m.ContextWindow
676 }
677 statusLines := tui.StatusBar(tui.StatusBarParams{
678 Theme: i.cfg.Theme,
679 Provider: i.cfg.Provider,
680 Model: i.cfg.Model,
681 Busy: i.busy,
682 BusyPrefix: busyPrefix,
683 CWD: i.cfg.CWD,
684 Locked: i.cfg.Sandbox.Locked(),
685 Usage: i.cumUsage,
686 Subscription: i.cfg.AuthMethod == "oauth",
687 ContextUsed: i.lastCtxInput,
688 ContextMax: ctxMax,
689 AutoCompacting: i.autoCompacting,
690 Telegram: i.telegramBridge != nil && i.telegramBridge.Active(),
691 Cols: cols,
692 })
693 edLines, curR, curC := i.ed.Render(cols)
694
695 // "Sliding in" chips for messages the user typed while a turn is
696 // in flight. Shown directly above the status bar so they're close
697 // to the editor but don't push the chat around.
698 var queue []string
699 if len(i.queued) > 0 {
700 for _, q := range i.queued {
701 label := i.cfg.Theme.FG256(i.cfg.Theme.Accent, "▸ sliding in: ")
702 text := truncateLine(q, cols-15)
703 queue = append(queue, label+i.cfg.Theme.FG256(i.cfg.Theme.Muted, text))
704 }
705 }
706
707 // Bottom-sticky sections (always visible, never scroll).
708 bottom := make([]string, 0, len(dialog)+len(suggest)+len(queue)+len(edLines)+1)
709 bottom = append(bottom, dialog...)
710 bottom = append(bottom, suggest...)
711 bottom = append(bottom, queue...)
712 bottom = append(bottom, statusLines...)
713 bottom = append(bottom, edLines...)
714
715 _, rows := i.cfg.Terminal.Size()
716 chatRows := rows - len(bottom)
717 if chatRows < 1 {
718 chatRows = 1
719 }
720
721 // Apply scroll offset to the chat slice.
722 maxOffset := len(chat) - chatRows
723 if maxOffset < 0 {
724 maxOffset = 0
725 }
726 if i.scrollOffset > maxOffset {
727 i.scrollOffset = maxOffset
728 }
729 if i.scrollOffset < 0 {
730 i.scrollOffset = 0
731 }
732
733 var visibleChat []string
734 if len(chat) <= chatRows {
735 visibleChat = chat
736 } else {
737 end := len(chat) - i.scrollOffset
738 start := end - chatRows
739 if start < 0 {
740 start = 0
741 }
742 visibleChat = chat[start:end]
743 }
744
745 // A tiny "scrolled up" indicator in the top-right of the chat pane
746 // so you know you're not at the bottom. When the viewport was
747 // parked by /jump we include the turn number so the user remembers
748 // they're reading history rather than the live conversation.
749 if i.scrollOffset > 0 && len(visibleChat) > 0 {
750 var text string
751 if i.parkedTurn > 0 && i.parkedTotal > 0 {
752 text = fmt.Sprintf(" ↑ viewing turn %d of %d · %d lines more below (pgdn / end)",
753 i.parkedTurn, i.parkedTotal, i.scrollOffset)
754 } else {
755 text = fmt.Sprintf(" ↑ %d lines more below (end to jump)", i.scrollOffset)
756 }
757 note := i.cfg.Theme.FG256(i.cfg.Theme.Muted, text)
758 visibleChat = append([]string{note}, visibleChat...)
759 if len(visibleChat) > chatRows {
760 visibleChat = visibleChat[:chatRows]
761 }
762 }
763
764 frame := make([]string, 0, len(visibleChat)+len(bottom))
765 frame = append(frame, visibleChat...)
766 frame = append(frame, bottom...)
767
768 cursorRow := len(visibleChat) + len(dialog) + len(suggest) + len(queue) + len(statusLines) + curR
769 cursorCol := curC
770 i.rend.Draw(frame, cursorRow, cursorCol)
771}
772
773// truncateLine shortens s so it fits within n display cells, with an
774// ellipsis if trimmed. Used by the "sliding in" chips so a pasted
775// novel doesn't blow past the status line.
776func truncateLine(s string, n int) string {
777 if n <= 0 {
778 return ""
779 }
780 // Collapse newlines — chips are single line.
781 s = strings.ReplaceAll(s, "\n", " ↩ ")
782 runes := []rune(s)
783 if len(runes) <= n {
784 return s
785 }
786 if n <= 1 {
787 return "…"
788 }
789 return string(runes[:n-1]) + "…"
790}
791
792// ctrlCExitWindow is how long after a ctrl+c press a *second* press
793// will exit instead of just clearing input. Long enough to be
794// deliberate (rules out accidental key chord), short enough that the
795// hint stays meaningful.
796const ctrlCExitWindow = 2 * time.Second
797
798// armCtrlCExit records the timestamp of the current ctrl+c so the next
799// one within ctrlCExitWindow exits.
800func (i *Interactive) armCtrlCExit() {
801 i.mu.Lock()
802 i.lastCtrlC = time.Now()
803 i.mu.Unlock()
804}
805
806// ctrlCExitArmed reports whether a previous ctrl+c was recent enough
807// that another press should now exit.
808func (i *Interactive) ctrlCExitArmed() bool {
809 i.mu.Lock()
810 t := i.lastCtrlC
811 i.mu.Unlock()
812 return !t.IsZero() && time.Since(t) <= ctrlCExitWindow
813}
814
815func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) {
816 // Any key that isn't ctrl+c invalidates an armed ctrl+c-exit, so
817 // pressing ctrl+c then typing then ctrl+c much later doesn't quit
818 // unexpectedly. The hint message also goes stale; clear it.
819 if k.Kind != tui.KeyCtrlC {
820 i.mu.Lock()
821 if !i.lastCtrlC.IsZero() {
822 i.lastCtrlC = time.Time{}
823 if strings.HasPrefix(i.statusOK, "input cleared") || strings.HasPrefix(i.statusOK, "press ctrl+c") {
824 i.statusOK = ""
825 }
826 }
827 i.mu.Unlock()
828 }
829
830 // Dialogs consume keys while open (except ctrl+c, which always closes them).
831
832 // Confirm dialog has highest priority: the agent goroutine is
833 // blocked waiting for an answer, so we must not let keys leak
834 // anywhere else while it's up.
835 if i.confirmDialog.Active() {
836 i.confirmDialog.HandleKey(k)
837 i.invalidate()
838 return false
839 }
840 if i.dialog.Active() {
841 if k.Kind == tui.KeyCtrlC {
842 i.dialog.Close()
843 if i.cfg.AuthManager != nil {
844 i.cfg.AuthManager.CancelOAuth()
845 }
846 return false
847 }
848 act := i.dialog.HandleKey(k)
849 if act.StartAPIKey {
850 i.startAPIKeyFlow(act.Provider)
851 }
852 if act.StartOAuth {
853 i.startOAuthFlow(act.Provider)
854 }
855 return false
856 }
857 if i.modelDialog.Active() {
858 if k.Kind == tui.KeyCtrlC {
859 i.modelDialog.Close()
860 return false
861 }
862 act := i.modelDialog.HandleKey(k)
863 if act.Select {
864 i.applyModelSelection(act.Provider, act.Model)
865 }
866 return false
867 }
868 if i.sessionDialog.Active() {
869 if k.Kind == tui.KeyCtrlC {
870 i.sessionDialog.Close()
871 return false
872 }
873 act := i.sessionDialog.HandleKey(k)
874 if act.Select {
875 i.applySessionSelection(act.Path)
876 }
877 return false
878 }
879 if i.logoutDialog.Active() {
880 if k.Kind == tui.KeyCtrlC {
881 i.logoutDialog.Close()
882 i.invalidate()
883 return false
884 }
885 act := i.logoutDialog.HandleKey(k)
886 if act.Select {
887 i.doLogout(act.Target)
888 }
889 i.invalidate()
890 return false
891 }
892 if i.telegramDialog.Active() {
893 if k.Kind == tui.KeyCtrlC {
894 i.telegramDialog.Close()
895 i.invalidate()
896 return false
897 }
898 act := i.telegramDialog.HandleKey(k)
899 if act.Select {
900 i.doTelegram(act.Action)
901 }
902 i.invalidate()
903 return false
904 }
905 if i.sessionOpsDialog.Active() {
906 if k.Kind == tui.KeyCtrlC {
907 i.sessionOpsDialog.Close()
908 i.invalidate()
909 return false
910 }
911 act := i.sessionOpsDialog.HandleKey(k)
912 if act.Select {
913 i.doSessionOp(act.Action, "")
914 }
915 i.invalidate()
916 return false
917 }
918 if i.sessionTreeDialog.Active() {
919 if k.Kind == tui.KeyCtrlC {
920 i.sessionTreeDialog.Close()
921 i.invalidate()
922 return false
923 }
924 act := i.sessionTreeDialog.HandleKey(k)
925 if act.Select {
926 i.applySessionTreeSelection(act.Path)
927 }
928 i.invalidate()
929 return false
930 }
931 if i.jumpDialog.Active() {
932 if k.Kind == tui.KeyCtrlC {
933 i.jumpDialog.Close()
934 i.pendingFork = false
935 return false
936 }
937 act := i.jumpDialog.HandleKey(k)
938 if act.Select {
939 if i.pendingFork {
940 i.applyForkSelection(act.MessageIdx)
941 } else {
942 i.applyJumpSelection(act.MessageIdx, act.TurnNo)
943 }
944 }
945 // If the user dismissed the dialog without selecting, also
946 // clear the pending-fork flag so a later plain /jump isn't
947 // hijacked.
948 if act.Close {
949 i.pendingFork = false
950 }
951 return false
952 }
953 if i.btwDialog.Active() {
954 if k.Kind == tui.KeyCtrlC {
955 i.btwDialog.Close()
956 i.invalidate()
957 return false
958 }
959 i.btwDialog.HandleKey(k, i.invalidate)
960 return false
961 }
962 if i.skillsDialog.Active() {
963 if k.Kind == tui.KeyCtrlC {
964 i.skillsDialog.Close()
965 i.invalidate()
966 return false
967 }
968 i.skillsDialog.HandleKey(k)
969 i.invalidate()
970 return false
971 }
972 if i.changelogDialog.Active() {
973 if closed := i.changelogDialog.HandleKey(k); closed {
974 // User dismissed; let the parent persist the
975 // LastChangelogShown marker via the close callback.
976 if i.cfg.OnChangelogDismiss != nil {
977 i.cfg.OnChangelogDismiss()
978 }
979 }
980 i.invalidate()
981 return false
982 }
983
984 // Global keys.
985 switch k.Kind {
986 case tui.KeyCtrlC:
987 // While busy: cancel the active turn (same as esc). The exit
988 // hint stays armed so a quick second ctrl+c after the turn
989 // dies still exits, matching habits from other repls.
990 if i.busy && i.cancelTurn != nil {
991 i.cancelTurn()
992 i.armCtrlCExit()
993 return false
994 }
995 // Idle: first press clears the editor (and any queued
996 // follow-up messages); a second press within ctrlCExitWindow
997 // exits. With both an empty editor and no queue the first
998 // press still just arms — require a deliberate double-tap.
999 hadInput := !i.ed.IsEmpty() || len(i.queued) > 0
1000 if hadInput {
1001 i.ed.Clear()
1002 i.suggest.Reset()
1003 i.mu.Lock()
1004 i.queued = nil
1005 i.statusOK = "input cleared"
1006 i.statusErr = ""
1007 i.mu.Unlock()
1008 i.armCtrlCExit()
1009 return false
1010 }
1011 if i.ctrlCExitArmed() {
1012 return true
1013 }
1014 i.mu.Lock()
1015 i.statusOK = "press ctrl+c again to exit"
1016 i.statusErr = ""
1017 i.mu.Unlock()
1018 i.armCtrlCExit()
1019 return false
1020 case tui.KeyEsc:
1021 // Esc interrupts a running turn. When idle, fall through so the
1022 // editor can clear itself.
1023 if i.busy && i.cancelTurn != nil {
1024 i.cancelTurn()
1025 // If a confirm dialog is pending, refuse it so the agent
1026 // goroutine unblocks and the context cancellation can
1027 // actually take effect.
1028 i.confirmDialog.CancelAll("turn cancelled")
1029 return false
1030 }
1031 case tui.KeyCtrlD:
1032 if i.ed.IsEmpty() && !i.busy {
1033 return true
1034 }
1035 case tui.KeyCtrlL:
1036 i.rend.Clear()
1037 i.invalidate()
1038 return false
1039 case tui.KeyCtrlO:
1040 // Toggle expansion of collapsed tool results. Affects every tool
1041 // call in the transcript — press again to re-collapse.
1042 i.mu.Lock()
1043 i.view.ExpandAll = !i.view.ExpandAll
1044 i.mu.Unlock()
1045 i.invalidate()
1046 return false
1047 case tui.KeyPageUp:
1048 i.scrollBy(+i.chatPage())
1049 return false
1050 case tui.KeyPageDown:
1051 i.scrollBy(-i.chatPage())
1052 return false
1053 case tui.KeyUp:
1054 // Wheel-up in alt screen sends Up arrows on most terminals.
1055 // When the editor is empty we use up/down for chat scroll —
1056 // independently of whether the agent is busy, so users can
1057 // scroll back through long streaming replies while they run.
1058 if i.ed.IsEmpty() {
1059 i.scrollBy(+3)
1060 return false
1061 }
1062 case tui.KeyDown:
1063 if i.ed.IsEmpty() {
1064 if i.scrollOffset > 0 {
1065 i.scrollBy(-3)
1066 }
1067 return false
1068 }
1069 }
1070
1071 // Note: we intentionally do NOT gate the editor on i.busy here.
1072 // Typing while the agent is working is supported — submitted
1073 // messages are queued and delivered as follow-up turns when the
1074 // current turn ends. See the submit handler below.
1075
1076 if k.Kind == tui.KeyEnter && k.Alt {
1077 i.ed.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: '\n', Alt: true})
1078 return false
1079 }
1080
1081 // Slash suggestions: intercept up/down/tab/enter when the popup is visible.
1082 if i.suggest.Active(i.ed.Value()) {
1083 switch k.Kind {
1084 case tui.KeyUp:
1085 i.suggest.Up()
1086 return false
1087 case tui.KeyDown:
1088 i.suggest.Down()
1089 return false
1090 case tui.KeyTab:
1091 if name := i.suggest.Selection(i.ed.Value()); name != "" {
1092 i.ed.SetValue(name)
1093 i.suggest.Reset()
1094 }
1095 return false
1096 case tui.KeyEnter:
1097 // Enter on an ambiguous or partial slash prefix: complete to the
1098 // currently highlighted command and run it. That way typing
1099 // "/lo" + enter picks whichever of /login or /logout is selected
1100 // in the popup instead of submitting "/lo" as unknown. Also
1101 // clear the editor so the command doesn't linger after the
1102 // dialog opens/closes.
1103 if name := i.suggest.Selection(i.ed.Value()); name != "" {
1104 i.ed.PushHistory(name)
1105 i.ed.Clear()
1106 i.suggest.Reset()
1107 return i.runSlash(ctx, name)
1108 }
1109 case tui.KeyEsc:
1110 i.ed.Clear()
1111 i.suggest.Reset()
1112 return false
1113 }
1114 }
1115
1116 if submit := i.ed.HandleKey(k); submit {
1117 text := strings.TrimRight(i.ed.Value(), "\n")
1118 if text == "" {
1119 return false
1120 }
1121 i.ed.PushHistory(text)
1122 i.ed.Clear()
1123 i.suggest.Reset()
1124
1125 if looksLikeSlashCommand(text) {
1126 head := text
1127 rest := ""
1128 if idx := strings.IndexAny(text, " \t"); idx >= 0 {
1129 head = text[:idx]
1130 rest = strings.TrimSpace(text[idx:])
1131 }
1132 if !isKnownSlashCommand(text) {
1133 // Try extensions before giving up. Extensions register
1134 // commands by bare name (no leading slash); strip it here.
1135 extName := strings.TrimPrefix(head, "/")
1136 if i.cfg.Extensions != nil && i.cfg.Extensions.HasCommand(extName) {
1137 go i.invokeExtensionCommand(ctx, extName, rest)
1138 return false
1139 }
1140 i.mu.Lock()
1141 i.statusErr = "unknown command " + head + " — type /help to see the list"
1142 i.statusOK = ""
1143 i.mu.Unlock()
1144 return false
1145 }
1146 // Slash commands run regardless of busy state. Commands that
1147 // would mutate the transcript or replace the agent (/clear,
1148 // /compact, /logout, /login, /model) cancel the active turn
1149 // first and wait for the goroutine to wind down so they don't
1150 // race with a streaming response. Safe commands (/help,
1151 // /jump, /sessions, /jail, /unjail, /exit) run immediately
1152 // without disturbing the active turn.
1153 if slashCancelsTurn(head) {
1154 i.cancelAndWaitForIdle()
1155 }
1156 return i.runSlash(ctx, text)
1157 }
1158
1159 if i.agent == nil {
1160 i.mu.Lock()
1161 i.statusErr = "not logged in. type /login first."
1162 i.mu.Unlock()
1163 return false
1164 }
1165 // Mirror the user's typed prompt into the paired Telegram
1166 // chat (when the bridge is active) so the Telegram thread
1167 // stays a complete record of the session, not just the half
1168 // that originated on the phone. On a goroutine so the
1169 // network write doesn't delay the local turn.
1170 if i.telegramBridge != nil && i.telegramBridge.Active() {
1171 go i.telegramBridge.OnUserTyped(text)
1172 }
1173 // If a turn is already in flight, queue this prompt instead of
1174 // starting a second one. The drain loop at the end of startTurn
1175 // will pick it up when the current turn finishes.
1176 i.mu.Lock()
1177 if i.busy {
1178 i.queued = append(i.queued, text)
1179 i.mu.Unlock()
1180 i.invalidate()
1181 return false
1182 }
1183 i.mu.Unlock()
1184 i.startTurn(ctx, text)
1185 }
1186 return false
1187}
1188
1189// invokeExtensionCommand fires an extension-registered slash command
1190// in a background goroutine, awaits the response, and applies the
1191// requested action (prompt / insert / display / noop). Errors and
1192// timeouts surface as a status_err line.
1193func (i *Interactive) invokeExtensionCommand(ctx context.Context, name, args string) {
1194 resp, err := i.cfg.Extensions.Invoke(ctx, name, args, 30*time.Second)
1195 if err != nil {
1196 i.mu.Lock()
1197 i.statusErr = "extension /" + name + ": " + err.Error()
1198 i.statusOK = ""
1199 i.mu.Unlock()
1200 i.invalidate()
1201 return
1202 }
1203 if resp.Error != "" {
1204 i.mu.Lock()
1205 i.statusErr = "extension /" + name + ": " + resp.Error
1206 i.statusOK = ""
1207 i.mu.Unlock()
1208 i.invalidate()
1209 return
1210 }
1211 switch resp.Action {
1212 case "prompt":
1213 if strings.TrimSpace(resp.Prompt) == "" {
1214 return
1215 }
1216 i.startTurn(i.runCtx, resp.Prompt)
1217 case "insert":
1218 i.ed.Insert(resp.Insert)
1219 i.invalidate()
1220 case "display":
1221 i.appendExtensionNote(name, resp.Display, "info")
1222 case "noop", "":
1223 // nothing
1224 default:
1225 i.mu.Lock()
1226 i.statusErr = "extension /" + name + ": unknown action " + resp.Action
1227 i.mu.Unlock()
1228 i.invalidate()
1229 }
1230}
1231
1232// appendExtensionNote renders an extension-originated note in the
1233// chat. Levels: "info" (muted), "warn" (warning), "error" (error),
1234// "success" (tool/ok green).
1235func (i *Interactive) appendExtensionNote(extName, msg, level string) {
1236 if msg == "" {
1237 return
1238 }
1239 i.mu.Lock()
1240 defer i.mu.Unlock()
1241 color := i.cfg.Theme.Muted
1242 switch level {
1243 case "warn":
1244 color = i.cfg.Theme.Warning
1245 case "error":
1246 color = i.cfg.Theme.Error
1247 case "success":
1248 color = i.cfg.Theme.Tool
1249 }
1250 prefix := i.cfg.Theme.FG256(i.cfg.Theme.Accent, "["+extName+"] ")
1251 for _, line := range strings.Split(msg, "\n") {
1252 i.statusOK = "" // clear any stale ok
1253 i.statusErr = ""
1254 i.extNotes = append(i.extNotes, prefix+i.cfg.Theme.FG256(color, line))
1255 }
1256}
1257
1258// HostHooks implementation for the extension manager. The manager
1259// holds an interface, not a concrete *Interactive, so these methods
1260// are the only thing the manager sees.
1261
1262// Notify is the manager's NotifyFromExt entry point.
1263func (i *Interactive) Notify(extName, level, message string) {
1264 i.appendExtensionNote(extName, message, level)
1265 i.invalidate()
1266}
1267
1268// Submit feeds text through the agent loop as if the user had typed it.
1269func (i *Interactive) Submit(text string) {
1270 i.startTurn(i.runCtx, text)
1271}
1272
1273// SubmitOrQueue runs text immediately if the agent is idle, or
1274// appends it to the pending queue if a turn is already in flight.
1275// Used by the telegram bridge (and by the editor submit path) so
1276// both input sources share the same "queue behind an active turn"
1277// semantics. Images are ignored for now — only the text prompt is
1278// forwarded — because the queued-prompt path is text-only; a
1279// follow-up can expand the queue entry to carry images.
1280func (i *Interactive) SubmitOrQueue(text string, images []provider.ImageBlock) {
1281 _ = images // reserved for future queued-with-images support
1282 i.mu.Lock()
1283 if i.agent == nil {
1284 i.statusErr = "not logged in. type /login first."
1285 i.mu.Unlock()
1286 i.invalidate()
1287 return
1288 }
1289 if i.busy {
1290 i.queued = append(i.queued, text)
1291 i.mu.Unlock()
1292 i.invalidate()
1293 return
1294 }
1295 i.mu.Unlock()
1296 i.startTurn(i.runCtx, text)
1297}
1298
1299// CancelTurn aborts the active turn if one is running. Used by the
1300// telegram bridge when the paired user sends /stop.
1301func (i *Interactive) CancelTurn() {
1302 i.mu.Lock()
1303 cancel := i.cancelTurn
1304 i.mu.Unlock()
1305 if cancel != nil {
1306 cancel()
1307 }
1308}
1309
1310// Insert places text at the cursor in the editor.
1311func (i *Interactive) Insert(text string) {
1312 i.ed.Insert(text)
1313 i.invalidate()
1314}
1315
1316// Display appends a styled note from extName to the chat without a
1317// model call.
1318func (i *Interactive) Display(extName, text string) {
1319 i.appendExtensionNote(extName, text, "info")
1320 i.invalidate()
1321}
1322
1323func (i *Interactive) runSlash(ctx context.Context, cmd string) (done bool) {
1324 parts := strings.Fields(cmd)
1325 switch parts[0] {
1326 case "/exit":
1327 return true
1328 case "/clear":
1329 if i.agent != nil {
1330 i.agent.SetMessages(nil)
1331 }
1332 i.mu.Lock()
1333 i.toolCalls = map[string]*tui.ToolCallView{}
1334 i.toolOrder = nil
1335 i.statusErr = ""
1336 i.statusOK = ""
1337 i.helpBlock = nil
1338 i.parkedTurn = 0
1339 i.parkedTotal = 0
1340 i.scrollOffset = 0
1341 i.extNotes = nil
1342 i.view.InvalidateRenderCache()
1343 i.mu.Unlock()
1344 case "/help":
1345 i.mu.Lock()
1346 i.helpBlock = renderHelpBlock(i.cfg.Theme, i.lastCols())
1347 i.statusErr = ""
1348 i.statusOK = ""
1349 // Pin the viewport to the newest content so the help block,
1350 // which we just appended to the end of the transcript, is
1351 // what the user actually sees.
1352 i.scrollOffset = 0
1353 i.mu.Unlock()
1354 case "/login":
1355 i.dialog.Open()
1356 case "/logout":
1357 if len(parts) >= 2 {
1358 // Explicit target: /logout anthropic | openai | all
1359 i.doLogout(parts[1])
1360 break
1361 }
1362 // No arg: open the picker over whichever providers are
1363 // currently logged in. If nothing's logged in, bail with a
1364 // status line.
1365 i.openLogoutDialog()
1366 case "/model":
1367 if len(parts) >= 2 {
1368 i.applyModelSelection("", parts[1])
1369 } else {
1370 i.modelDialog.Open(i.cfg.Model)
1371 }
1372 case "/sessions":
1373 i.sessionDialog.Open(i.cfg.ZotHome, i.cfg.CWD)
1374 case "/jump":
1375 i.openJumpDialog(parts[1:])
1376 case "/btw":
1377 i.openBtwDialog(parts[1:])
1378 case "/skills":
1379 i.openSkillsDialog()
1380 case "/compact":
1381 i.runCompact(ctx, false)
1382 case "/jail":
1383 if i.cfg.Sandbox == nil {
1384 i.mu.Lock()
1385 i.statusErr = "sandbox not available in this build"
1386 i.mu.Unlock()
1387 break
1388 }
1389 i.cfg.Sandbox.Lock()
1390 i.mu.Lock()
1391 i.statusOK = "jailed to " + i.cfg.CWD + " (tools cannot touch paths outside this directory)"
1392 i.statusErr = ""
1393 i.mu.Unlock()
1394 case "/unjail":
1395 if i.cfg.Sandbox == nil {
1396 i.mu.Lock()
1397 i.statusErr = "sandbox not available in this build"
1398 i.mu.Unlock()
1399 break
1400 }
1401 i.cfg.Sandbox.Unlock()
1402 i.mu.Lock()
1403 i.statusOK = "unjailed"
1404 i.statusErr = ""
1405 i.mu.Unlock()
1406 case "/reload-ext":
1407 i.runReloadExt(ctx)
1408 case "/yolo":
1409 i.runYoloOn()
1410 case "/telegram", "/tg":
1411 if len(parts) >= 2 {
1412 i.doTelegram(parts[1])
1413 break
1414 }
1415 i.openTelegramDialog()
1416 case "/session":
1417 if len(parts) >= 2 {
1418 action := parts[1]
1419 arg := ""
1420 if len(parts) >= 3 {
1421 arg = strings.Join(parts[2:], " ")
1422 }
1423 i.doSessionOp(action, arg)
1424 break
1425 }
1426 i.openSessionOpsDialog()
1427 default:
1428 // Last-resort fallback: try the extension manager. Built-in
1429 // cases above always win; this branch only fires for slash
1430 // commands the extension manager registered. Same routing as
1431 // the editor's submit-handler dispatch path so the autocomplete
1432 // "enter on highlighted suggestion" flow also works.
1433 extName := strings.TrimPrefix(parts[0], "/")
1434 if i.cfg.Extensions != nil && i.cfg.Extensions.HasCommand(extName) {
1435 rest := ""
1436 if len(parts) > 1 {
1437 rest = strings.Join(parts[1:], " ")
1438 }
1439 go i.invokeExtensionCommand(ctx, extName, rest)
1440 return false
1441 }
1442 i.mu.Lock()
1443 i.statusErr = "unknown command: " + parts[0]
1444 i.mu.Unlock()
1445 }
1446 return false
1447}
1448
1449// openLogoutDialog shows the provider picker for `/logout` with no
1450// argument. Only providers the user is currently logged into are
1451// listed, plus an "all" entry when more than one is present. If
1452// nothing's logged in, writes a status line instead of opening an
1453// empty dialog.
1454func (i *Interactive) openLogoutDialog() {
1455 if i.cfg.AuthManager == nil {
1456 i.mu.Lock()
1457 i.statusErr = "no auth manager configured"
1458 i.mu.Unlock()
1459 i.invalidate()
1460 return
1461 }
1462 store := i.cfg.AuthManager.Store()
1463 if store == nil {
1464 i.mu.Lock()
1465 i.statusErr = "auth store is not available"
1466 i.mu.Unlock()
1467 i.invalidate()
1468 return
1469 }
1470 creds, err := store.Load()
1471 if err != nil {
1472 i.mu.Lock()
1473 i.statusErr = "read auth store: " + err.Error()
1474 i.mu.Unlock()
1475 i.invalidate()
1476 return
1477 }
1478
1479 var items []logoutItem
1480 for _, p := range []string{"anthropic", "openai"} {
1481 if creds.Has(p) {
1482 items = append(items, logoutItem{
1483 label: p,
1484 target: p,
1485 method: creds.Method(p),
1486 })
1487 }
1488 }
1489 if len(items) == 0 {
1490 i.mu.Lock()
1491 i.statusOK = "no credentials stored; already logged out"
1492 i.statusErr = ""
1493 i.mu.Unlock()
1494 i.invalidate()
1495 return
1496 }
1497 if len(items) > 1 {
1498 items = append(items, logoutItem{label: "all", target: "all"})
1499 }
1500
1501 i.logoutDialog.Open(items)
1502 i.invalidate()
1503}
1504
1505// doLogout clears credentials for the given provider (or all providers)
1506// from auth.json. If the active agent was using those credentials, it
1507// is torn down so the user is forced through /login before their next
1508// prompt.
1509//
1510// target: "anthropic" | "openai" | "all"
1511func (i *Interactive) doLogout(target string) {
1512 if i.cfg.AuthManager == nil {
1513 i.mu.Lock()
1514 i.statusErr = "no auth manager configured"
1515 i.mu.Unlock()
1516 return
1517 }
1518 store := i.cfg.AuthManager.Store()
1519 if store == nil {
1520 i.mu.Lock()
1521 i.statusErr = "auth store is not available"
1522 i.mu.Unlock()
1523 return
1524 }
1525
1526 var providers []string
1527 switch target {
1528 case "", "all":
1529 providers = []string{"anthropic", "openai"}
1530 case "anthropic", "openai":
1531 providers = []string{target}
1532 default:
1533 i.mu.Lock()
1534 i.statusErr = "unknown provider: " + target + " (use anthropic, openai, or all)"
1535 i.mu.Unlock()
1536 return
1537 }
1538
1539 var errs []string
1540 clearedCurrent := false
1541 for _, p := range providers {
1542 if err := store.Clear(p); err != nil {
1543 errs = append(errs, p+": "+err.Error())
1544 continue
1545 }
1546 if p == i.cfg.Provider {
1547 clearedCurrent = true
1548 }
1549 }
1550
1551 i.mu.Lock()
1552 defer i.mu.Unlock()
1553 if len(errs) > 0 {
1554 i.statusErr = "logout errors: " + strings.Join(errs, "; ")
1555 return
1556 }
1557 i.statusErr = ""
1558 if clearedCurrent {
1559 // The running agent was using a credential we just wiped. Drop
1560 // it so prompts can't go out with the stale client, and hint at
1561 // /login.
1562 i.agent = nil
1563 i.statusOK = "logged out of " + strings.Join(providers, ", ") + ". type /login to sign back in."
1564 } else {
1565 i.statusOK = "logged out of " + strings.Join(providers, ", ")
1566 }
1567}
1568
1569func (i *Interactive) startAPIKeyFlow(provider string) {
1570 url, err := i.cfg.AuthManager.StartAPIKey(provider)
1571 if err != nil {
1572 i.dialog.ShowResult(false, err.Error())
1573 return
1574 }
1575 i.dialog.ShowWaiting(url)
1576}
1577
1578func (i *Interactive) startOAuthFlow(provider string) {
1579 url, err := i.cfg.AuthManager.StartOAuth(provider)
1580 if err != nil {
1581 i.dialog.ShowResult(false, err.Error())
1582 return
1583 }
1584 i.dialog.ShowWaiting(url)
1585}
1586
1587// applyModelSelection switches the active model (and provider, if the
1588// new model belongs to a different one). It rebuilds the underlying
1589// client when needed so the provider wire-protocol matches.
1590// cancelAndWaitForIdle cancels the active turn (if any) and blocks
1591// briefly until the turn goroutine has updated i.busy = false. Used
1592// before destructive slash commands so transcript-mutating work
1593// (/clear, /compact, /logout, /login completion, cross-provider
1594// /model swap) doesn't race with the still-running stream.
1595//
1596// The wait is bounded; if the turn doesn't release within the timeout
1597// we proceed anyway. Worst case is a brief overlap that the agent's
1598// own mutex protects against.
1599func (i *Interactive) cancelAndWaitForIdle() {
1600 i.mu.Lock()
1601 busy := i.busy
1602 cancel := i.cancelTurn
1603 i.mu.Unlock()
1604 if !busy {
1605 return
1606 }
1607 if cancel != nil {
1608 cancel()
1609 }
1610 deadline := time.Now().Add(2 * time.Second)
1611 for time.Now().Before(deadline) {
1612 i.mu.Lock()
1613 done := !i.busy
1614 i.mu.Unlock()
1615 if done {
1616 return
1617 }
1618 time.Sleep(10 * time.Millisecond)
1619 }
1620}
1621
1622// openBtwDialog opens the side-chat overlay with a frozen snapshot
1623// of the current main session. The optional argument is auto-
1624// submitted as the first question, so '/btw does X work?' fires the
1625// model call immediately instead of just opening an empty dialog.
1626func (i *Interactive) openBtwDialog(args []string) {
1627 if i.agent == nil {
1628 i.mu.Lock()
1629 i.statusErr = "not logged in. type /login first."
1630 i.mu.Unlock()
1631 return
1632 }
1633 seed := strings.TrimSpace(strings.Join(args, " "))
1634 i.btwDialog.Open(i.cfg.Theme, i.agent, i.agent.System, i.cfg.Model, seed)
1635 i.invalidate()
1636}
1637
1638// openSkillsDialog opens the skill inspector. The picker reflects
1639// whatever SkillSnapshot returns at call time, so edits to a
1640// SKILL.md made during a session show up on the next /skills.
1641func (i *Interactive) openSkillsDialog() {
1642 var list []*skills.Skill
1643 if i.cfg.SkillSnapshot != nil {
1644 list = i.cfg.SkillSnapshot()
1645 }
1646 i.skillsDialog.Open(list)
1647 i.invalidate()
1648}
1649
1650// openJumpDialog builds a /jump picker from the current transcript.
1651// If the user typed "/jump foo" with a filter and it matches exactly
1652// one turn, jump there directly without showing the dialog.
1653func (i *Interactive) openJumpDialog(args []string) {
1654 if i.view == nil || len(i.view.Messages) == 0 {
1655 i.mu.Lock()
1656 i.statusErr = "nothing to jump to \u2014 the session is empty"
1657 i.mu.Unlock()
1658 return
1659 }
1660 filter := strings.TrimSpace(strings.Join(args, " "))
1661 i.jumpDialog.Open(i.view.Messages, filter)
1662 // Shortcut: with a filter argument that matches exactly one turn,
1663 // jump immediately and skip the picker.
1664 if filter != "" {
1665 if tgts := i.jumpDialog.Targets(); len(tgts) == 1 {
1666 t := tgts[0]
1667 i.jumpDialog.Close()
1668 i.applyJumpSelection(t.MessageIdx, t.TurnNo)
1669 }
1670 }
1671}
1672
1673// applyJumpSelection scrolls the chat viewport so the user message at
1674// msgIdx is visible at (or near) the top of the chat area. Uses the
1675// anchor slice returned by view.BuildWithAnchors so the mapping from
1676// message index to row is exact, regardless of variable-height tool
1677// blocks above the target.
1678func (i *Interactive) applyJumpSelection(msgIdx, turnNo int) {
1679 cols := i.lastCols()
1680 chat, anchors := i.view.BuildWithAnchors(cols)
1681 var row int
1682 found := false
1683 for _, a := range anchors {
1684 if a.MessageIdx == msgIdx {
1685 row = a.Row
1686 found = true
1687 break
1688 }
1689 }
1690 if !found {
1691 i.mu.Lock()
1692 i.statusErr = "could not resolve jump target"
1693 i.mu.Unlock()
1694 return
1695 }
1696
1697 chatLen := len(chat)
1698 page := i.chatPage()
1699 if page < 1 {
1700 page = 1
1701 }
1702 // scrollOffset is measured from the bottom of the chat slice, so
1703 // to place `row` at the top of the viewport we want:
1704 // chatLen - scrollOffset - page == row
1705 // Solve for scrollOffset and clamp to [0, chatLen-page].
1706 offset := chatLen - (row + page)
1707 if offset < 0 {
1708 offset = 0
1709 }
1710 maxOffset := chatLen - page
1711 if maxOffset < 0 {
1712 maxOffset = 0
1713 }
1714 if offset > maxOffset {
1715 offset = maxOffset
1716 }
1717
1718 i.mu.Lock()
1719 i.scrollOffset = offset
1720 i.parkedTurn = turnNo
1721 i.parkedTotal = totalTurnsLocked(i.view.Messages)
1722 i.statusOK = fmt.Sprintf("jumped to turn %d", turnNo)
1723 i.statusErr = ""
1724 i.mu.Unlock()
1725}
1726
1727// totalTurnsLocked counts user messages in the transcript. Caller is
1728// assumed to hold i.mu (the name is a mild reminder; this function
1729// itself doesn't touch shared state beyond the slice it's handed).
1730func totalTurnsLocked(msgs []provider.Message) int {
1731 n := 0
1732 for _, m := range msgs {
1733 if m.Role == provider.RoleUser {
1734 n++
1735 }
1736 }
1737 return n
1738}
1739
1740// applySessionSelection loads the given session via the cli-provided
1741// callback and parks the viewport on the last turn so the user lands
1742// looking at where the conversation left off (their last prompt at the
1743// top of the chat, the assistant's last reply right below). Older
1744// history is one scroll up; pgdn or end snaps to the current tail.
1745//
1746// Without this, scrollOffset stayed at 0 (pinned to the live tail),
1747// which on a long resumed session showed only the last few rows of
1748// the final assistant message — the user read that as "only one liner
1749// happened, the resume didn't work".
1750func (i *Interactive) applySessionSelection(path string) {
1751 if i.cfg.LoadSession == nil {
1752 i.mu.Lock()
1753 i.statusErr = "session loading is not wired in this build"
1754 i.mu.Unlock()
1755 return
1756 }
1757 if err := i.cfg.LoadSession(path); err != nil {
1758 i.mu.Lock()
1759 i.statusErr = err.Error()
1760 i.mu.Unlock()
1761 return
1762 }
1763
1764 i.mu.Lock()
1765 i.statusOK = "resumed session: " + path
1766 i.statusErr = ""
1767 i.parkedTurn = 0
1768 i.parkedTotal = 0
1769 i.view.InvalidateRenderCache()
1770 // Pull the freshly-loaded transcript into the view so the anchor
1771 // math below sees the post-resume messages, not the empty pre-load
1772 // state. redraw() does the same on its next pass; we just front-run
1773 // it here to compute the scroll target.
1774 if i.agent != nil {
1775 i.view.Messages = i.agent.Messages()
1776 }
1777 msgs := i.view.Messages
1778 i.mu.Unlock()
1779
1780 i.scrollToLastTurn(msgs)
1781}
1782
1783// scrollToLastTurn parks the viewport at the most recent user turn,
1784// or at the top if the transcript has no user messages. Used after
1785// resume so the user lands looking at where they left off.
1786func (i *Interactive) scrollToLastTurn(msgs []provider.Message) {
1787 if len(msgs) == 0 {
1788 i.mu.Lock()
1789 i.scrollOffset = 0
1790 i.mu.Unlock()
1791 return
1792 }
1793 // Find the last user message index.
1794 lastUser := -1
1795 turnNo, totalTurns := 0, 0
1796 for idx, m := range msgs {
1797 if m.Role == provider.RoleUser {
1798 totalTurns++
1799 lastUser = idx
1800 }
1801 }
1802 if lastUser < 0 {
1803 i.mu.Lock()
1804 i.scrollOffset = 0
1805 i.mu.Unlock()
1806 return
1807 }
1808 turnNo = totalTurns
1809
1810 cols := i.lastCols()
1811 chat, anchors := i.view.BuildWithAnchors(cols)
1812 var row int
1813 found := false
1814 for _, a := range anchors {
1815 if a.MessageIdx == lastUser {
1816 row = a.Row
1817 found = true
1818 break
1819 }
1820 }
1821 if !found {
1822 i.mu.Lock()
1823 i.scrollOffset = 0
1824 i.mu.Unlock()
1825 return
1826 }
1827
1828 chatLen := len(chat)
1829 page := i.chatPage()
1830 if page < 1 {
1831 page = 1
1832 }
1833 offset := chatLen - (row + page)
1834 if offset < 0 {
1835 offset = 0
1836 }
1837 maxOffset := chatLen - page
1838 if maxOffset < 0 {
1839 maxOffset = 0
1840 }
1841 if offset > maxOffset {
1842 offset = maxOffset
1843 }
1844
1845 i.mu.Lock()
1846 i.scrollOffset = offset
1847 // Mark the parked-turn footer so the user sees "viewing turn N of
1848 // M · pgdn to catch up" — same affordance as /jump. Tells them at
1849 // a glance that they're looking at history, not the live tail.
1850 if offset > 0 {
1851 i.parkedTurn = turnNo
1852 i.parkedTotal = totalTurns
1853 }
1854 i.mu.Unlock()
1855 i.invalidate()
1856}
1857
1858func (i *Interactive) applyModelSelection(prov, model string) {
1859 if model == "" {
1860 return
1861 }
1862 m, err := provider.FindModel(prov, model)
1863 if err != nil {
1864 i.mu.Lock()
1865 i.statusErr = err.Error()
1866 i.mu.Unlock()
1867 return
1868 }
1869 // Same provider? Just swap the model on the existing agent.
1870 if i.agent != nil && m.Provider == i.cfg.Provider {
1871 i.mu.Lock()
1872 i.cfg.Model = m.ID
1873 i.agent.Model = m.ID
1874 i.statusOK = "model: " + m.ID
1875 i.statusErr = ""
1876 i.mu.Unlock()
1877 if i.cfg.PersistModel != nil {
1878 i.cfg.PersistModel(i.cfg.Provider, m.ID)
1879 }
1880 return
1881 }
1882 // Different provider: rebuild agent (needs credentials for target provider).
1883 if i.cfg.BuildAgentFor == nil {
1884 i.mu.Lock()
1885 i.statusErr = "cannot switch provider: no builder configured"
1886 i.mu.Unlock()
1887 return
1888 }
1889 // Snapshot the current transcript and cumulative usage BEFORE we
1890 // build the replacement agent so we can hand them off. Without
1891 // this the user perceives the entire session as wiped on a
1892 // cross-provider /model swap.
1893 var carryMsgs []provider.Message
1894 var carryCost provider.Usage
1895 if i.agent != nil {
1896 carryMsgs = i.agent.Messages()
1897 carryCost = i.agent.Cost()
1898 }
1899
1900 ag, p, md, err := i.cfg.BuildAgentFor(m.Provider, m.ID)
1901 if err != nil {
1902 i.mu.Lock()
1903 i.statusErr = err.Error()
1904 i.mu.Unlock()
1905 return
1906 }
1907
1908 // Replay the transcript and seed the cost on the freshly-built
1909 // agent. Messages travel cleanly between providers because they
1910 // use the same provider.Message shape; tool-call ids are local
1911 // to a turn so cross-provider continuation never confuses the
1912 // new model (it just sees the assistant's reply, no orphan
1913 // tool_use blocks because /model swaps are gated to idle state).
1914 if len(carryMsgs) > 0 {
1915 ag.SetMessages(carryMsgs)
1916 }
1917 ag.SeedCost(carryCost)
1918
1919 i.mu.Lock()
1920 i.agent = ag
1921 i.cfg.Provider = p
1922 i.cfg.Model = md
1923 i.statusOK = "switched to " + p + " / " + md
1924 i.statusErr = ""
1925 // Render cache keys are width+content based, so the new agent's
1926 // identical messages will reuse the existing entries. Nothing
1927 // to invalidate.
1928 i.mu.Unlock()
1929 if i.cfg.PersistModel != nil {
1930 i.cfg.PersistModel(p, md)
1931 }
1932}
1933
1934func (i *Interactive) handleAuthEvent(ev auth.Event) {
1935 switch ev.Kind {
1936 case "started":
1937 i.dialog.ShowWaiting(ev.URL)
1938 case "browser_open":
1939 // no-op
1940 case "error":
1941 i.dialog.ShowResult(false, ev.Message)
1942 case "success":
1943 // Rebuild the agent with the fresh credential.
1944 ag, prov, model, err := i.cfg.BuildAgent()
1945 if err != nil {
1946 i.dialog.ShowResult(false, err.Error())
1947 return
1948 }
1949 i.mu.Lock()
1950 i.agent = ag
1951 i.cfg.Provider = prov
1952 i.cfg.Model = model
1953 i.statusErr = ""
1954 i.statusOK = "logged in to " + ev.Provider + " via " + ev.Method
1955 i.mu.Unlock()
1956 i.dialog.ShowResult(true, "")
1957 }
1958}
1959
1960// runCompact invokes core.Agent.Compact and reflects the progress in
1961// the tui. It runs in a goroutine so the ui stays responsive; esc/ctrl+c
1962// cancel via the same cancelTurn channel used for normal turns.
1963//
1964// When auto is true the spinner message is pinned to "condensing
1965// history" and the status bar surfaces "(auto)" next to the context
1966// percentage so it's obvious the system triggered this, not the user.
1967func (i *Interactive) runCompact(parent context.Context, auto bool) {
1968 if i.agent == nil {
1969 i.mu.Lock()
1970 i.statusErr = "not logged in. type /login first."
1971 i.mu.Unlock()
1972 return
1973 }
1974 ctx, cancel := context.WithCancel(parent)
1975 i.mu.Lock()
1976 i.busy = true
1977 if auto {
1978 i.spin.StartFixed("condensing history")
1979 i.autoCompacting = true
1980 i.statusOK = "condensing history… (esc to cancel)"
1981 } else {
1982 i.spin.Start()
1983 i.statusOK = "compacting..."
1984 }
1985 i.cancelTurn = cancel
1986 i.statusErr = ""
1987 i.streaming.Reset()
1988 i.streamOn = true
1989 i.scrollOffset = 0
1990 i.helpBlock = nil
1991 i.mu.Unlock()
1992 i.invalidate()
1993
1994 go func() {
1995 sink := func(delta string) {
1996 i.mu.Lock()
1997 i.streaming.WriteString(delta)
1998 i.mu.Unlock()
1999 i.invalidate()
2000 }
2001 summary, err := i.agent.Compact(ctx, 4, sink)
2002 i.mu.Lock()
2003 i.busy = false
2004 i.streamOn = false
2005 i.streaming.Reset()
2006 i.cancelTurn = nil
2007 i.autoCompacting = false
2008 switch {
2009 case err != nil && ctx.Err() != nil:
2010 i.statusErr = ""
2011 if auto {
2012 i.statusOK = "auto-condense cancelled"
2013 } else {
2014 i.statusOK = "compaction cancelled"
2015 }
2016 case err != nil:
2017 i.statusErr = "compaction failed: " + err.Error()
2018 i.statusOK = ""
2019 default:
2020 i.statusErr = ""
2021 i.statusOK = fmt.Sprintf("compacted transcript (%d chars of summary)", len(summary))
2022 i.lastCtxInput = 0 // reset; next turn will get a fresh measurement
2023 i.toolCalls = map[string]*tui.ToolCallView{}
2024 i.toolOrder = nil
2025 // Transcript was rewritten in place — purge the per-message
2026 // render cache so stale entries keyed on the old messages
2027 // don't linger.
2028 i.view.InvalidateRenderCache()
2029 }
2030 i.mu.Unlock()
2031 i.invalidate()
2032 }()
2033}
2034
2035func (i *Interactive) startTurn(parent context.Context, prompt string) {
2036 if i.agent == nil {
2037 return
2038 }
2039 ctx, cancel := context.WithCancel(parent)
2040 i.mu.Lock()
2041 i.busy = true
2042 i.spin.Start()
2043 i.cancelTurn = cancel
2044 i.statusErr = ""
2045 i.statusOK = ""
2046 i.streaming.Reset()
2047 i.streamOn = true
2048 i.toolCalls = map[string]*tui.ToolCallView{}
2049 i.toolOrder = nil
2050 i.scrollOffset = 0 // jump back to the bottom on new turn
2051 i.parkedTurn = 0 // starting a turn clears the /jump parked state
2052 i.parkedTotal = 0
2053 i.helpBlock = nil // hide the help block once the user asks something
2054 i.mu.Unlock()
2055 i.invalidate()
2056
2057 sink := func(ev core.AgentEvent) {
2058 i.handleEvent(ev)
2059 i.invalidate()
2060 }
2061
2062 go func() {
2063 err := i.agent.Prompt(ctx, prompt, nil, sink)
2064 i.mu.Lock()
2065 i.busy = false
2066 i.streamOn = false
2067 i.cancelTurn = nil
2068 if err != nil && ctx.Err() == nil {
2069 i.statusErr = err.Error()
2070 }
2071 // Persist the assistant's reply (and every tool row before
2072 // it) to the session file while the turn memory is hot.
2073 // Without this, WriteNewTranscript only fires at zot exit,
2074 // meaning a crash or ungraceful kill drops the whole
2075 // conversation. FlushSession is idempotent (it advances the
2076 // baseline so subsequent flushes only write new rows).
2077 flush := i.cfg.FlushSession
2078 i.mu.Unlock()
2079 if flush != nil {
2080 flush()
2081 }
2082 i.mu.Lock()
2083 // Pop the next queued message, if any, and relaunch.
2084 var next string
2085 var hasNext bool
2086 if len(i.queued) > 0 && ctx.Err() == nil && err == nil {
2087 next, i.queued = i.queued[0], i.queued[1:]
2088 hasNext = true
2089 }
2090 // If the turn was cancelled or errored, drop the queue so the
2091 // user isn't bombarded with stale messages after an interrupt.
2092 if ctx.Err() != nil || err != nil {
2093 i.queued = nil
2094 }
2095 // Decide whether the next thing to do is an auto-compaction.
2096 // Only fires when the turn completed cleanly AND the queue is
2097 // empty (otherwise a queued message would race the condense).
2098 shouldAutoCompact := !hasNext && err == nil && ctx.Err() == nil && i.shouldAutoCompactLocked()
2099 i.mu.Unlock()
2100 i.invalidate()
2101 parent := i.runCtx
2102 if parent == nil {
2103 parent = context.Background()
2104 }
2105 switch {
2106 case hasNext:
2107 i.startTurn(parent, next)
2108 case shouldAutoCompact:
2109 i.runCompact(parent, true)
2110 }
2111 }()
2112}
2113
2114// autoCompactThreshold is the context-window fraction at which the
2115// agent will auto-compact after a turn ends. 0.85 leaves enough
2116// headroom for one more user prompt + response before we bump the
2117// hard limit.
2118const autoCompactThreshold = 0.85
2119
2120// shouldAutoCompactLocked reports whether the last turn pushed context
2121// usage past the auto-compact threshold. Must be called with i.mu
2122// held; it reads lastCtxInput and the current model's context window.
2123func (i *Interactive) shouldAutoCompactLocked() bool {
2124 if i.agent == nil {
2125 return false
2126 }
2127 if i.autoCompacting {
2128 return false
2129 }
2130 m, err := provider.FindModel(i.cfg.Provider, i.cfg.Model)
2131 if err != nil || m.ContextWindow <= 0 {
2132 return false
2133 }
2134 if i.lastCtxInput <= 0 {
2135 return false
2136 }
2137 return float64(i.lastCtxInput)/float64(m.ContextWindow) >= autoCompactThreshold
2138}
2139
2140func (i *Interactive) handleEvent(ev core.AgentEvent) {
2141 i.mu.Lock()
2142 defer i.mu.Unlock()
2143 switch e := ev.(type) {
2144 case core.EvTextDelta:
2145 i.streaming.WriteString(e.Delta)
2146 case core.EvAssistantMessage:
2147 i.streaming.Reset()
2148 i.streamOn = false
2149 if i.cfg.OnAssistant != nil {
2150 i.cfg.OnAssistant(e.Message)
2151 }
2152 // Mirror the assistant's final visible text to the telegram
2153 // bridge when active. Only TextBlock content is forwarded;
2154 // tool_use blocks are internal. Send on a goroutine so the
2155 // network call doesn't hold the event loop lock.
2156 if i.telegramBridge != nil && i.telegramBridge.Active() {
2157 var sb strings.Builder
2158 for _, c := range e.Message.Content {
2159 if tb, ok := c.(provider.TextBlock); ok {
2160 if sb.Len() > 0 {
2161 sb.WriteString("\n")
2162 }
2163 sb.WriteString(tb.Text)
2164 }
2165 }
2166 if text := sb.String(); strings.TrimSpace(text) != "" {
2167 go i.telegramBridge.OnAssistantText(text)
2168 }
2169 }
2170 case core.EvToolUseStart:
2171 // Live streaming: pre-create the view so the user sees the
2172 // tool call being composed in real time. Any subsequent
2173 // EvToolCall for the same ID updates the same struct (the
2174 // final parsed args + name are already known here).
2175 if _, exists := i.toolCalls[e.ID]; !exists {
2176 i.toolCalls[e.ID] = &tui.ToolCallView{
2177 ID: e.ID,
2178 Name: e.Name,
2179 Streaming: true,
2180 }
2181 i.toolOrder = append(i.toolOrder, e.ID)
2182 }
2183 case core.EvToolUseArgs:
2184 if tc, ok := i.toolCalls[e.ID]; ok {
2185 tc.RawJSONBuf += e.Delta
2186 // Refresh the live path as soon as it parses; used in
2187 // the header (▸ write /Users/pat/Desktop/demo.ts)
2188 // while the content is still streaming.
2189 if p, pok, _ := tui.ExtractPartialStringField(tc.RawJSONBuf, "path"); pok {
2190 tc.LivePath = p
2191 } else if p, pok, _ := tui.ExtractPartialStringField(tc.RawJSONBuf, "file_path"); pok {
2192 tc.LivePath = p
2193 }
2194 }
2195 case core.EvToolUseEnd:
2196 if tc, ok := i.toolCalls[e.ID]; ok {
2197 tc.Streaming = false
2198 }
2199 case core.EvToolCall:
2200 // If we already pre-created the view during streaming, just
2201 // refresh the final Args summary. Otherwise create a new one
2202 // (non-streaming providers or legacy paths).
2203 if tc, ok := i.toolCalls[e.ID]; ok {
2204 tc.Args = shortArgs(e.Args)
2205 tc.Streaming = false
2206 } else {
2207 i.toolCalls[e.ID] = &tui.ToolCallView{
2208 ID: e.ID,
2209 Name: e.Name,
2210 Args: shortArgs(e.Args),
2211 }
2212 i.toolOrder = append(i.toolOrder, e.ID)
2213 }
2214 case core.EvToolResult:
2215 if tc, ok := i.toolCalls[e.ID]; ok {
2216 tc.Done = true
2217 tc.Error = e.Result.IsError
2218 var text strings.Builder
2219 for _, c := range e.Result.Content {
2220 if tb, ok := c.(provider.TextBlock); ok {
2221 if text.Len() > 0 {
2222 text.WriteString("\n")
2223 }
2224 text.WriteString(tb.Text)
2225 }
2226 }
2227 tc.Result = text.String()
2228 }
2229 if i.cfg.OnToolResult != nil {
2230 i.cfg.OnToolResult(e.ID, e.Result)
2231 }
2232 case core.EvUsage:
2233 i.cumUsage = e.Cumulative
2234 if e.Usage.InputTokens > 0 {
2235 i.lastCtxInput = e.Usage.InputTokens + e.Usage.CacheReadTokens + e.Usage.CacheWriteTokens
2236 }
2237 case core.EvTurnEnd:
2238 if e.Stop == provider.StopAborted {
2239 // Aborted turn: discard the partial streaming text (it is not
2240 // persisted in the transcript) and clear any transient error.
2241 i.streaming.Reset()
2242 i.streamOn = false
2243 i.statusErr = ""
2244 i.statusOK = "cancelled"
2245 return
2246 }
2247 if e.Err != nil && !strings.Contains(e.Err.Error(), "context canceled") {
2248 i.statusErr = e.Err.Error()
2249 }
2250 }
2251}
2252
2253// Agent returns the current agent, if any. Used by cli.go to flush the
2254// final transcript to the session file.
2255func (i *Interactive) Agent() *core.Agent {
2256 i.mu.Lock()
2257 defer i.mu.Unlock()
2258 return i.agent
2259}
2260
2261func shortArgs(raw json.RawMessage) string {
2262 var v any
2263 if err := json.Unmarshal(raw, &v); err != nil {
2264 return ""
2265 }
2266 if m, ok := v.(map[string]any); ok {
2267 for _, k := range []string{"path", "file_path", "command"} {
2268 if s, ok := m[k].(string); ok {
2269 if len(s) > 60 {
2270 s = s[:57] + "..."
2271 }
2272 return s
2273 }
2274 }
2275 }
2276 b, _ := json.Marshal(v)
2277 s := string(b)
2278 if len(s) > 60 {
2279 s = s[:57] + "..."
2280 }
2281 return s
2282}
2283
2284// silence unused import in some build configs
2285var _ = fmt.Sprintf
2286
2287// runReloadExt triggers a live reload of every extension (discovered
2288// + explicit). Runs on a goroutine so the TUI stays responsive; the
2289// Manager.Reload takes a couple of hundred ms to shut down subprocs
2290// and respawn them. Shows a status line throughout.
2291func (i *Interactive) runReloadExt(ctx context.Context) {
2292 if i.cfg.Extensions == nil {
2293 i.mu.Lock()
2294 i.statusErr = "no extension manager in this build"
2295 i.mu.Unlock()
2296 i.invalidate()
2297 return
2298 }
2299 i.mu.Lock()
2300 i.statusOK = "reloading extensions…"
2301 i.statusErr = ""
2302 i.mu.Unlock()
2303 i.invalidate()
2304
2305 go func() {
2306 stats := i.cfg.Extensions.Reload(ctx, 2*time.Second)
2307 msg := fmt.Sprintf("reloaded: %d stopped, %d loaded (%d ready)", stats.Stopped, stats.Loaded, stats.Ready)
2308 if len(stats.Errors) > 0 {
2309 msg += fmt.Sprintf(", %d error(s)", len(stats.Errors))
2310 }
2311 i.mu.Lock()
2312 i.statusOK = msg
2313 i.statusErr = ""
2314 i.mu.Unlock()
2315 i.invalidate()
2316 }()
2317}
2318
2319// Confirm implements core.Confirmer. The agent goroutine calls
2320// this synchronously before every tool invocation when --no-yolo is
2321// active. We push the request onto the confirmDialog queue, trigger
2322// a redraw, and block the caller until the user answers.
2323//
2324// If the session is cancelled or the TUI exits mid-prompt, any
2325// pending request is refused via CancelAll so the agent doesn't
2326// deadlock.
2327func (i *Interactive) Confirm(toolName string, preview string) core.ConfirmDecision {
2328 resp := make(chan core.ConfirmDecision, 1)
2329 i.confirmDialog.Enqueue(&confirmRequest{
2330 toolName: toolName,
2331 preview: preview,
2332 resp: resp,
2333 })
2334 i.invalidate()
2335 return <-resp
2336}
2337
2338// runYoloOn disables --no-yolo for the rest of the session. Tool
2339// calls run without prompting after this; there's intentionally no
2340// way to re-enable gating mid-session, if the user wants that back
2341// they can exit and restart with --no-yolo.
2342func (i *Interactive) runYoloOn() {
2343 if i.cfg.ConfirmGate == nil {
2344 i.mu.Lock()
2345 i.statusOK = "yolo mode is already on (no --no-yolo in this session)"
2346 i.statusErr = ""
2347 i.mu.Unlock()
2348 i.invalidate()
2349 return
2350 }
2351 i.cfg.ConfirmGate.AllowAll()
2352 // Also auto-allow any currently pending confirmation so the
2353 // agent doesn't deadlock if /yolo is typed while a prompt is
2354 // open.
2355 i.confirmDialog.AllowAllPending()
2356 i.mu.Lock()
2357 i.statusOK = "yolo engaged: no more tool-call confirmations this session"
2358 i.statusErr = ""
2359 i.mu.Unlock()
2360 i.invalidate()
2361}
2362
2363// openTelegramDialog shows the picker for `/telegram` with no arg.
2364// Items depend on current state: disconnect + status when running,
2365// connect + status when stopped.
2366func (i *Interactive) openTelegramDialog() {
2367 items := i.telegramMenuItems()
2368 if len(items) == 0 {
2369 i.mu.Lock()
2370 i.statusErr = "telegram not configured. run `zot telegram-bot setup` first."
2371 i.mu.Unlock()
2372 i.invalidate()
2373 return
2374 }
2375 i.telegramDialog.Open(items)
2376 i.invalidate()
2377}
2378
2379// telegramMenuItems builds the dialog entries for the current
2380// bridge state. Returns empty when no bot.json exists so the
2381// caller can show a helpful status line instead of an empty menu.
2382func (i *Interactive) telegramMenuItems() []telegramItem {
2383 cfg, err := telegram.LoadConfig(i.cfg.ZotHome)
2384 if err != nil || cfg.BotToken == "" {
2385 return nil
2386 }
2387 var items []telegramItem
2388 if i.telegramBridge != nil && i.telegramBridge.Active() {
2389 items = append(items, telegramItem{label: "disconnect", action: "disconnect", hint: "stop mirroring"})
2390 st := i.telegramBridge.State()
2391 hint := "active"
2392 if st.Username != "" {
2393 hint += " as @" + st.Username
2394 }
2395 items = append(items, telegramItem{label: "status", action: "status", hint: hint})
2396 } else {
2397 label := "connect"
2398 hint := "start mirroring dms into this session"
2399 if cfg.AllowedUserID == 0 {
2400 hint = "awaiting pairing (send /start to the bot once connected)"
2401 }
2402 items = append(items, telegramItem{label: label, action: "connect", hint: hint})
2403 items = append(items, telegramItem{label: "status", action: "status", hint: "disconnected"})
2404 }
2405 return items
2406}
2407
2408// doTelegram dispatches one of the three explicit actions. Called
2409// from /telegram <action> or after the picker selects a row.
2410func (i *Interactive) doTelegram(action string) {
2411 switch action {
2412 case "connect":
2413 i.telegramConnect()
2414 case "disconnect":
2415 i.telegramDisconnect()
2416 case "status":
2417 i.telegramStatus()
2418 default:
2419 i.mu.Lock()
2420 i.statusErr = "unknown telegram action: " + action + " (use connect, disconnect, or status)"
2421 i.mu.Unlock()
2422 i.invalidate()
2423 }
2424}
2425
2426// telegramConnect starts the bridge. Refuses if it's already
2427// running or if the on-disk bot.json is missing a token.
2428func (i *Interactive) telegramConnect() {
2429 if i.telegramBridge != nil && i.telegramBridge.Active() {
2430 i.mu.Lock()
2431 i.statusOK = "telegram already connected"
2432 i.statusErr = ""
2433 i.mu.Unlock()
2434 i.invalidate()
2435 return
2436 }
2437 cfg, err := telegram.LoadConfig(i.cfg.ZotHome)
2438 if err != nil {
2439 i.mu.Lock()
2440 i.statusErr = "telegram: " + err.Error()
2441 i.mu.Unlock()
2442 i.invalidate()
2443 return
2444 }
2445 if cfg.BotToken == "" {
2446 i.mu.Lock()
2447 i.statusErr = "telegram: no bot token configured. run `zot telegram-bot setup` first."
2448 i.mu.Unlock()
2449 i.invalidate()
2450 return
2451 }
2452 // Refuse to start when a background daemon is already polling
2453 // the same bot. Two concurrent long-poll consumers race each
2454 // update and one always loses, so DMs get half-delivered. The
2455 // user can `zot telegram-bot stop` first, then /telegram connect.
2456 if pid, alive, _ := telegram.IsRunning(i.cfg.ZotHome); alive && pid > 0 {
2457 i.mu.Lock()
2458 i.statusErr = fmt.Sprintf("telegram: bot daemon already running (pid %d). stop it with `zot telegram-bot stop` first.", pid)
2459 i.mu.Unlock()
2460 i.invalidate()
2461 return
2462 }
2463 i.telegramBridge = &telegram.Bridge{
2464 Client: telegram.NewClient(cfg.BotToken),
2465 Config: cfg,
2466 Save: func(next telegram.Config) error {
2467 return telegram.SaveConfig(i.cfg.ZotHome, next)
2468 },
2469 Host: &telegramHost{iv: i},
2470 }
2471 if err := i.telegramBridge.Start(i.runCtx); err != nil {
2472 i.telegramBridge = nil
2473 i.mu.Lock()
2474 i.statusErr = "telegram connect failed: " + err.Error()
2475 i.mu.Unlock()
2476 i.invalidate()
2477 return
2478 }
2479 state := i.telegramBridge.State()
2480 label := "telegram connected"
2481 if state.Username != "" {
2482 label += " as @" + state.Username
2483 }
2484 if state.PairedID == 0 {
2485 label += " — send /start to the bot from your phone to claim it"
2486 }
2487 i.mu.Lock()
2488 i.statusOK = label
2489 i.statusErr = ""
2490 i.mu.Unlock()
2491 i.invalidate()
2492}
2493
2494// telegramDisconnect stops the bridge. No-op when already stopped.
2495func (i *Interactive) telegramDisconnect() {
2496 if i.telegramBridge == nil || !i.telegramBridge.Active() {
2497 i.mu.Lock()
2498 i.statusOK = "telegram already disconnected"
2499 i.statusErr = ""
2500 i.mu.Unlock()
2501 i.invalidate()
2502 return
2503 }
2504 i.telegramBridge.Stop()
2505 i.mu.Lock()
2506 i.statusOK = "telegram disconnected"
2507 i.statusErr = ""
2508 i.mu.Unlock()
2509 i.invalidate()
2510}
2511
2512// telegramStatus writes a one-liner describing the bridge state.
2513// Reports on both the in-tui bridge and the background daemon so
2514// the user isn't confused when the daemon owns the poll loop.
2515func (i *Interactive) telegramStatus() {
2516 var msg string
2517 if i.telegramBridge != nil && i.telegramBridge.Active() {
2518 s := i.telegramBridge.State()
2519 msg = "telegram: connected (tui bridge)"
2520 if s.Username != "" {
2521 msg += " as @" + s.Username
2522 }
2523 if s.PairedID != 0 {
2524 msg += fmt.Sprintf(" · paired with user %d", s.PairedID)
2525 } else {
2526 msg += " · awaiting pairing"
2527 }
2528 } else if pid, alive, _ := telegram.IsRunning(i.cfg.ZotHome); alive && pid > 0 {
2529 msg = fmt.Sprintf("telegram: background daemon running (pid %d) · /telegram connect won't work until you stop it", pid)
2530 } else {
2531 cfg, _ := telegram.LoadConfig(i.cfg.ZotHome)
2532 if cfg.BotToken == "" {
2533 msg = "telegram: not configured. run `zot telegram-bot setup` first."
2534 } else {
2535 msg = "telegram: disconnected"
2536 if cfg.BotUsername != "" {
2537 msg += " (@" + cfg.BotUsername + " ready to connect)"
2538 }
2539 }
2540 }
2541 i.mu.Lock()
2542 i.statusOK = msg
2543 i.statusErr = ""
2544 i.mu.Unlock()
2545 i.invalidate()
2546}
2547
2548// telegramHost adapts *Interactive to telegram.Host so the bridge
2549// can call back into the TUI without importing modes directly.
2550type telegramHost struct{ iv *Interactive }
2551
2552func (h *telegramHost) SubmitOrQueue(prompt string, images []provider.ImageBlock) {
2553 h.iv.SubmitOrQueue(prompt, images)
2554}
2555
2556func (h *telegramHost) CancelTurn() { h.iv.CancelTurn() }
2557
2558func (h *telegramHost) Notify(level, message string) {
2559 h.iv.mu.Lock()
2560 switch level {
2561 case "error", "warn":
2562 h.iv.statusErr = message
2563 h.iv.statusOK = ""
2564 default:
2565 h.iv.statusOK = message
2566 h.iv.statusErr = ""
2567 }
2568 h.iv.mu.Unlock()
2569 h.iv.invalidate()
2570}
2571
2572// openSessionOpsDialog shows the picker for `/session` with no arg.
2573// Always offers export, import, fork, tree; the handlers bail with
2574// a clear status message when the precondition isn't met (empty
2575// transcript on fork; no parent/siblings on tree).
2576func (i *Interactive) openSessionOpsDialog() {
2577 items := []sessionOpsItem{
2578 {label: "export", action: "export", hint: "write the current session to a .zotsession file"},
2579 {label: "import", action: "import", hint: "load a .zotsession file into this directory"},
2580 {label: "fork", action: "fork", hint: "branch from a past user message into a new session"},
2581 {label: "tree", action: "tree", hint: "switch between branches in this directory"},
2582 }
2583 i.sessionOpsDialog.Open(items)
2584 i.invalidate()
2585}
2586
2587// doSessionOp dispatches export, import, fork, or tree. arg is the
2588// optional positional argument from e.g. /session export <path>
2589// or /session import <path>; fork and tree ignore it.
2590func (i *Interactive) doSessionOp(action, arg string) {
2591 switch action {
2592 case "export":
2593 i.doSessionExport(arg)
2594 case "import":
2595 i.doSessionImport(arg)
2596 case "fork":
2597 i.doSessionFork()
2598 case "tree":
2599 i.doSessionTree()
2600 default:
2601 i.mu.Lock()
2602 i.statusErr = "unknown /session action: " + action + " (use export, import, fork, or tree)"
2603 i.mu.Unlock()
2604 i.invalidate()
2605 }
2606}
2607
2608// doSessionExport writes the live session file to destination path
2609// dst. When dst is empty we default to ~/Downloads (falling back to
2610// the user's home directory if it doesn't exist). The helper
2611// expands a leading `~` and creates any missing parent directories.
2612func (i *Interactive) doSessionExport(dst string) {
2613 if i.cfg.CurrentSessionPath == nil {
2614 i.mu.Lock()
2615 i.statusErr = "export: no session is active (running with --no-session?)"
2616 i.mu.Unlock()
2617 i.invalidate()
2618 return
2619 }
2620 src := i.cfg.CurrentSessionPath()
2621 if src == "" {
2622 i.mu.Lock()
2623 i.statusErr = "export: no session is active (running with --no-session?)"
2624 i.mu.Unlock()
2625 i.invalidate()
2626 return
2627 }
2628 // Persist any in-memory agent messages to the session file so
2629 // the export carries the full conversation. Without this, the
2630 // default lazy-flush-at-exit strategy leaves most of a running
2631 // session unwritten and the export ends up with only the meta.
2632 if i.cfg.FlushSession != nil {
2633 i.cfg.FlushSession()
2634 }
2635 dst = unquotePath(dst)
2636 if dst == "" {
2637 dst = defaultExportDir()
2638 } else {
2639 dst = expandTilde(dst)
2640 }
2641 out, err := core.ExportSession(src, dst)
2642 if err != nil {
2643 i.mu.Lock()
2644 i.statusErr = "export: " + err.Error()
2645 i.mu.Unlock()
2646 i.invalidate()
2647 return
2648 }
2649 i.mu.Lock()
2650 i.statusOK = "exported session to " + friendlyPath(out)
2651 i.statusErr = ""
2652 i.mu.Unlock()
2653 i.invalidate()
2654}
2655
2656// doSessionImport copies the .zotsession file at src into the
2657// running cwd's sessions directory and loads it as the active
2658// session, same as `/sessions` -> pick. When src is empty we ask
2659// the user to pass a path (no usable default here).
2660func (i *Interactive) doSessionImport(src string) {
2661 src = unquotePath(src)
2662 if src == "" {
2663 i.mu.Lock()
2664 i.statusErr = "import: pass a path — e.g. /session import ~/Downloads/work.zotsession"
2665 i.mu.Unlock()
2666 i.invalidate()
2667 return
2668 }
2669 src = expandTilde(src)
2670 if _, err := os.Stat(src); err != nil {
2671 i.mu.Lock()
2672 i.statusErr = "import: " + err.Error()
2673 i.mu.Unlock()
2674 i.invalidate()
2675 return
2676 }
2677 newPath, err := core.ImportSession(src, i.cfg.ZotHome, i.cfg.CWD, i.cfg.Version)
2678 if err != nil {
2679 i.mu.Lock()
2680 i.statusErr = "import: " + err.Error()
2681 i.mu.Unlock()
2682 i.invalidate()
2683 return
2684 }
2685 if i.cfg.LoadSession == nil {
2686 i.mu.Lock()
2687 i.statusOK = "imported session at " + friendlyPath(newPath) + " (run /sessions to resume it)"
2688 i.statusErr = ""
2689 i.mu.Unlock()
2690 i.invalidate()
2691 return
2692 }
2693 if err := i.cfg.LoadSession(newPath); err != nil {
2694 i.mu.Lock()
2695 i.statusErr = "import: load failed: " + err.Error()
2696 i.mu.Unlock()
2697 i.invalidate()
2698 return
2699 }
2700 i.mu.Lock()
2701 i.statusOK = "imported and switched to session " + friendlyPath(newPath)
2702 i.statusErr = ""
2703 i.mu.Unlock()
2704 i.invalidate()
2705}
2706
2707// defaultExportDir returns ~/Downloads when it exists, or ~ as a
2708// fallback, or /tmp on exotic machines with no home dir.
2709func defaultExportDir() string {
2710 home, err := os.UserHomeDir()
2711 if err != nil || home == "" {
2712 return os.TempDir()
2713 }
2714 downloads := filepath.Join(home, "Downloads")
2715 if fi, err := os.Stat(downloads); err == nil && fi.IsDir() {
2716 return downloads
2717 }
2718 return home
2719}
2720
2721// expandTilde turns a leading ~ into the user's home directory.
2722// Returns the input unchanged when there's no tilde or no home.
2723func expandTilde(p string) string {
2724 if p == "" || p[0] != '~' {
2725 return p
2726 }
2727 home, err := os.UserHomeDir()
2728 if err != nil || home == "" {
2729 return p
2730 }
2731 if len(p) == 1 {
2732 return home
2733 }
2734 if p[1] == '/' || p[1] == filepath.Separator {
2735 return filepath.Join(home, p[2:])
2736 }
2737 return p
2738}
2739
2740// unquotePath strips a matching pair of surrounding single or
2741// double quotes. Drag-drop paste in the tui auto-quotes dropped
2742// file paths so the shell-like `/session import 'foo bar.zs'`
2743// stays well-formed; when the TUI's own slash handler consumes
2744// the arg, we want the raw path back.
2745func unquotePath(p string) string {
2746 p = strings.TrimSpace(p)
2747 if len(p) >= 2 {
2748 first := p[0]
2749 last := p[len(p)-1]
2750 if (first == '\'' && last == '\'') || (first == '"' && last == '"') {
2751 p = p[1 : len(p)-1]
2752 }
2753 }
2754 return p
2755}
2756
2757// friendlyPath collapses the user's home directory to a leading ~
2758// so status messages read cleanly. Falls back to the raw path when
2759// the home dir is unknown.
2760func friendlyPath(p string) string {
2761 home, err := os.UserHomeDir()
2762 if err != nil || home == "" {
2763 return p
2764 }
2765 if strings.HasPrefix(p, home+string(filepath.Separator)) {
2766 return "~" + p[len(home):]
2767 }
2768 return p
2769}
2770
2771// doSessionFork opens the /jump turn picker in "fork mode". The
2772// next selection branches the current session at that user turn
2773// instead of scrolling the viewport to it.
2774func (i *Interactive) doSessionFork() {
2775 if i.cfg.CurrentSessionPath == nil || i.cfg.CurrentSessionPath() == "" {
2776 i.mu.Lock()
2777 i.statusErr = "fork: no session is active (running with --no-session?)"
2778 i.mu.Unlock()
2779 i.invalidate()
2780 return
2781 }
2782 msgs := []provider.Message{}
2783 if i.agent != nil {
2784 msgs = i.agent.Messages()
2785 }
2786 if len(msgs) == 0 {
2787 i.mu.Lock()
2788 i.statusErr = "fork: transcript is empty; nothing to fork from"
2789 i.mu.Unlock()
2790 i.invalidate()
2791 return
2792 }
2793 i.pendingFork = true
2794 i.jumpDialog.Open(msgs, "")
2795 i.invalidate()
2796}
2797
2798// doSessionTree shows the branch topology picker for this cwd.
2799// Pick an entry to switch into it (same semantics as /sessions
2800// picking a past session, but with the parent/child indentation).
2801func (i *Interactive) doSessionTree() {
2802 if i.cfg.ZotHome == "" || i.cfg.CWD == "" {
2803 i.mu.Lock()
2804 i.statusErr = "tree: session storage not configured"
2805 i.mu.Unlock()
2806 i.invalidate()
2807 return
2808 }
2809 // Flush the running agent's transcript first so its message
2810 // count + latest preview are accurate in the tree.
2811 if i.cfg.FlushSession != nil {
2812 i.cfg.FlushSession()
2813 }
2814 roots := core.BuildSessionTree(i.cfg.ZotHome, i.cfg.CWD)
2815 if len(roots) == 0 {
2816 i.mu.Lock()
2817 i.statusErr = "tree: no sessions in this directory yet"
2818 i.mu.Unlock()
2819 i.invalidate()
2820 return
2821 }
2822 current := ""
2823 if i.cfg.CurrentSessionPath != nil {
2824 current = i.cfg.CurrentSessionPath()
2825 }
2826 if !i.sessionTreeDialog.Open(roots, current) {
2827 i.mu.Lock()
2828 i.statusErr = "tree: no branches to show"
2829 i.mu.Unlock()
2830 }
2831 i.invalidate()
2832}
2833
2834// applySessionTreeSelection switches the running agent to the
2835// session file at path. Thin wrapper around LoadSession that also
2836// writes a status line.
2837func (i *Interactive) applySessionTreeSelection(path string) {
2838 if i.cfg.LoadSession == nil {
2839 i.mu.Lock()
2840 i.statusErr = "tree: session swap not available in this build"
2841 i.mu.Unlock()
2842 i.invalidate()
2843 return
2844 }
2845 if err := i.cfg.LoadSession(path); err != nil {
2846 i.mu.Lock()
2847 i.statusErr = "tree: load failed: " + err.Error()
2848 i.mu.Unlock()
2849 i.invalidate()
2850 return
2851 }
2852 i.mu.Lock()
2853 i.statusOK = "switched to branch " + friendlyPath(path)
2854 i.statusErr = ""
2855 i.mu.Unlock()
2856 i.invalidate()
2857}
2858
2859// applyForkSelection branches the current session at msgIdx+1 (so
2860// the selected user message and everything before it is included
2861// in the new branch), then switches the running agent to the new
2862// file. Called from the jump-dialog handler when pendingFork=true.
2863func (i *Interactive) applyForkSelection(msgIdx int) {
2864 i.pendingFork = false
2865 if i.cfg.CurrentSessionPath == nil {
2866 i.mu.Lock()
2867 i.statusErr = "fork: no session is active"
2868 i.mu.Unlock()
2869 i.invalidate()
2870 return
2871 }
2872 src := i.cfg.CurrentSessionPath()
2873 if src == "" {
2874 i.mu.Lock()
2875 i.statusErr = "fork: no session is active"
2876 i.mu.Unlock()
2877 i.invalidate()
2878 return
2879 }
2880 if i.cfg.FlushSession != nil {
2881 i.cfg.FlushSession()
2882 }
2883 // msgIdx is 0-indexed message position; copy msgIdx+1 rows so
2884 // the selected user message is included.
2885 upTo := msgIdx + 1
2886 newPath, err := core.BranchSession(src, i.cfg.ZotHome, i.cfg.CWD, i.cfg.Version, upTo)
2887 if err != nil {
2888 i.mu.Lock()
2889 i.statusErr = "fork: " + err.Error()
2890 i.mu.Unlock()
2891 i.invalidate()
2892 return
2893 }
2894 if i.cfg.LoadSession == nil {
2895 i.mu.Lock()
2896 i.statusOK = "forked at message " + formatInt(upTo) + " (run /sessions to resume)"
2897 i.statusErr = ""
2898 i.mu.Unlock()
2899 i.invalidate()
2900 return
2901 }
2902 if err := i.cfg.LoadSession(newPath); err != nil {
2903 i.mu.Lock()
2904 i.statusErr = "fork: switch failed: " + err.Error()
2905 i.mu.Unlock()
2906 i.invalidate()
2907 return
2908 }
2909 i.mu.Lock()
2910 i.statusOK = "forked and switched to new branch at " + friendlyPath(newPath)
2911 i.statusErr = ""
2912 i.mu.Unlock()
2913 i.invalidate()
2914}
2915
2916// formatInt is a tiny strconv.Itoa shim; keeps the handler above
2917// from needing a strconv import just for one call.
2918func formatInt(n int) string {
2919 return fmt.Sprintf("%d", n)
2920}