1package modes
2
3import (
4 "context"
5 "encoding/json"
6 "fmt"
7 "os"
8 "path/filepath"
9 "runtime"
10 "strconv"
11 "strings"
12 "sync"
13 "time"
14
15 "github.com/patriceckhart/zot/packages/agent/extensions"
16 "github.com/patriceckhart/zot/packages/agent/extproto"
17 "github.com/patriceckhart/zot/packages/agent/modes/telegram"
18 "github.com/patriceckhart/zot/packages/agent/skills"
19 "github.com/patriceckhart/zot/packages/agent/swarm"
20 "github.com/patriceckhart/zot/packages/agent/tools"
21 "github.com/patriceckhart/zot/packages/core"
22 "github.com/patriceckhart/zot/packages/provider"
23 "github.com/patriceckhart/zot/packages/provider/auth"
24 "github.com/patriceckhart/zot/packages/tui"
25)
26
27// InteractiveConfig configures the interactive loop.
28type InteractiveConfig struct {
29 Terminal tui.Terminal
30 Theme tui.Theme
31 Model string
32 Provider string
33 AuthMethod string // "apikey" | "oauth" — used to tag cost as (sub) in status bar
34 BaseURL string
35 Reasoning string
36 SystemPrompt string
37 Tools core.Registry
38 MaxSteps int
39 CWD string
40
41 // InlineImagesEnabled overrides terminal image rendering. nil means
42 // auto-detect and render when supported; false disables; true uses
43 // the detected protocol when available.
44 InlineImagesEnabled *bool
45
46 // AutoSwarmEnabled mirrors the persisted config flag at startup so
47 // the /settings dialog can render the current state without
48 // re-reading config.json on every open.
49 AutoSwarmEnabled *bool
50
51 // JailByDefault mirrors the persisted jail_by_default preference.
52 // The current sandbox may differ after a session-scoped /jail or /unjail.
53 JailByDefault *bool
54
55 // RecursiveFileSuggest mirrors the persisted recursive_file_suggest
56 // flag at startup. When true the @-mention picker fuzzy-searches the
57 // whole project tree instead of browsing one directory at a time.
58 RecursiveFileSuggest *bool
59
60 // RespectGitignore mirrors the persisted respect_gitignore flag at
61 // startup. nil means the default (on); when false the @-mention
62 // picker shows files matched by the project's root .gitignore.
63 RespectGitignore *bool
64
65 // ThemeName mirrors the persisted config theme value. Empty means auto.
66 ThemeName string
67
68 // FlatTools renders tool calls without the bordered panel (a quiet
69 // header line plus indented, frameless output). Mirrors the
70 // resolved tool_render config / ZOT_FLAT_TOOLS env at startup.
71 FlatTools bool
72
73 // CompactUser renders sent user messages as a single quiet gutter
74 // line instead of a padded, tinted bubble. Mirrors the resolved
75 // compact_input config / ZOT_COMPACT_INPUT env at startup.
76 CompactUser bool
77
78 // CompactMode mirrors the persisted compact_mode flag at startup.
79 // When true, it enables both flat tool calls and compact user turns.
80 CompactMode *bool
81
82 // TUIInputStyle controls the main input rendering: plain or lines.
83 TUIInputStyle string
84
85 // TUIStatusPosition controls whether the status block renders above
86 // or below the main input.
87 TUIStatusPosition string
88
89 // TUIWorkingPosition controls whether the busy/working spinner renders
90 // above or below the main input.
91 TUIWorkingPosition string
92
93 // QuickModelShortcuts maps slots 1-9 to provider/model pairs. The
94 // shortcuts are Ctrl+1..9. Cmd+1..9 may also work when the terminal
95 // forwards Command/Super keypresses, but Ctrl is the displayed chord.
96 QuickModelShortcuts []QuickModelShortcut
97
98 // ExtensionThemes returns themes bundled with loaded extensions.
99 ExtensionThemes func() []tui.ThemeOption
100
101 // AutoSwarmSystemAddendum is the system-prompt block that gets
102 // appended/stripped when the user toggles auto-swarm at runtime.
103 // Plumbed in from the cli so this package doesn't have to import
104 // agent (cycle).
105 AutoSwarmSystemAddendum string
106 SettingsStore SettingsStore
107
108 // Agent is optional. If nil, zot opens without credentials; the
109 // user must /login before they can prompt.
110 Agent *core.Agent
111
112 InitialInput string
113
114 // Auth is required. When the user runs /login, Interactive talks to
115 // AuthManager to open a browser and wait for the callback.
116 AuthManager *auth.Manager
117
118 // LlamaCPPConfig resolves the router URL and optional API key from env or
119 // the credential saved by /login.
120 LlamaCPPConfig func() (baseURL, apiKey string, err error)
121
122 // RefreshLlamaCPPModels synchronizes currently loaded router models into
123 // the catalog before /model opens.
124 RefreshLlamaCPPModels func(context.Context) error
125
126 // BuildAgent is called after a successful login to (re)construct the
127 // agent with the fresh credential. It returns the new agent and
128 // the concrete provider/model in use.
129 BuildAgent func() (*core.Agent, string, string, error)
130
131 // SetKimiCLIFallbackDisabled controls whether zot may fall back to
132 // the official Kimi Code CLI token when zot has no stored Kimi token.
133 SetKimiCLIFallbackDisabled func(disabled bool) error
134
135 // BuildAgentFor rebuilds the agent with an explicit provider/model
136 // override (used by the /model picker when switching providers).
137 // If providerOverride is empty, the current provider is kept.
138 BuildAgentFor func(providerOverride, modelOverride string) (*core.Agent, string, string, error)
139
140 // BuildAgentForRescue rebuilds the agent for the rescue picker that
141 // opens after a recoverable provider failure. Unlike BuildAgentFor,
142 // this builder drops launch-time --api-key and --base-url overrides
143 // because those are usually the reason rescue triggered. Re-resolves
144 // credentials from env vars / auth.json / provider defaults so the
145 // retry has a real chance of succeeding. Falls back to BuildAgentFor
146 // when nil so embedders that don't wire it keep working.
147 BuildAgentForRescue func(providerOverride, modelOverride string) (*core.Agent, string, string, error)
148
149 // LoggedInProviders returns the list of provider names that
150 // currently have credentials. Used by /model to filter the
151 // picker to only show reachable models.
152 LoggedInProviders func() []string
153
154 // ZotHome is the root directory for sessions/, used by /sessions
155 // and the update-check cache.
156 ZotHome string
157
158 // Version is the binary's current version (from main.version).
159 // Used only for display; the update check itself is done outside
160 // this package to avoid an import cycle.
161 Version string
162
163 // UpdateInfoChan is an optional channel that delivers the result
164 // of the github-release update check. Interactive reads at most
165 // one value, drops it if the check reported nothing, and otherwise
166 // surfaces a yellow "update available" banner at the top of the
167 // chat. Nil channel = no banner, no startup cost.
168 UpdateInfoChan <-chan UpdateInfo
169
170 // Sandbox is the shared sandbox pointer. Toggled by /jail and /unjail.
171 Sandbox *tools.Sandbox
172
173 // LoadSession swaps the current session for the one at path. The
174 // callback returns the new agent message slice so the TUI can invalidate.
175 LoadSession func(path string) error
176
177 // ChangeCWD switches the running zot session's working directory
178 // to path. The host closes the current session, rebuilds the
179 // agent so tools / AGENTS.md / sandbox bind to the new cwd, and
180 // opens a fresh session there. Returns an error if path doesn't
181 // exist, isn't a directory, or the host can't rebuild the agent.
182 //
183 // Optional: not wired by every embedder. When nil the hidden /cd
184 // command surfaces a clear error rather than no-oping.
185 ChangeCWD func(path string) error
186
187 // CurrentSessionPath returns the path of the live session file
188 // on disk (the one every AppendMessage writes to). Used by
189 // /session export so the exporter ships the exact bytes on
190 // disk. Returns an empty string when --no-session is set or
191 // no session is open.
192 CurrentSessionPath func() string
193
194 // FlushSession writes any in-memory agent messages to the
195 // session file that haven't been persisted yet. Called by
196 // /session export right before reading the file so the
197 // exported bytes reflect the full current conversation, not
198 // just the rows the agent happened to write synchronously.
199 // The default WriteNewTranscript-at-exit strategy means most
200 // of a running session lives only in memory until the tui
201 // closes; without a flush hook, /session export writes a
202 // file that only has the meta row.
203 FlushSession func()
204
205 // PersistModel is called whenever the user switches model or provider.
206 // It should update config.json and (if there's an active session)
207 // write a new meta row so resume picks up the same model.
208 PersistModel func(providerName, model string)
209
210 OnAssistant func(m provider.Message)
211 OnToolResult func(id string, r core.ToolResult)
212
213 // Extensions, if non-nil, lets users invoke extension-registered
214 // slash commands. Commands declared by extensions are looked up
215 // AFTER the built-in catalog so a built-in name always wins.
216 Extensions *extensions.Manager
217
218 // Swarm, if non-nil, enables the /swarm slash command and the
219 // dashboard dialog. The cli constructs the Swarm once per
220 // interactive run and tears it down on exit. nil disables the
221 // feature entirely (used by embedders / tests that don't want
222 // subprocesses).
223 Swarm *swarm.Swarm
224
225 // SkillSnapshot, if non-nil, returns the current list of
226 // discovered SKILL.md files. Re-invoked each time /skills opens
227 // so the picker reflects edits made during the session.
228 SkillSnapshot func() []*skills.Skill
229
230 // ChangelogChan, if non-nil, delivers release-notes for the
231 // current binary version once at startup. Interactive opens a
232 // dismissible overlay when the channel produces a non-empty
233 // body. Receiver fires at most once.
234 ChangelogChan <-chan ChangelogPayload
235
236 // OnChangelogDismiss, if non-nil, is called once the user
237 // closes the changelog overlay. The cli wires this to a
238 // MarkChangelogShown call so the same version doesn't show
239 // again on the next launch.
240 OnChangelogDismiss func()
241
242 // NoYolo is true when --no-yolo was passed. Interactive opens
243 // a confirmation dialog before every tool call and blocks the
244 // tool until the user picks yes / always-this-tool /
245 // always-all / no. When false (default), tools run freely.
246 NoYolo bool
247
248 // ConfirmGate is the session-scoped gate wrapping this
249 // interactive's Confirmer. When non-nil, /yolo can call
250 // AllowAll() on it to disable confirmation for the rest of the
251 // session. When nil (yolo mode), /yolo reports that there's
252 // nothing to disable.
253 ConfirmGate *core.ConfirmGate
254}
255
256// ChangelogPayload mirrors agent.ChangelogInfo without the import
257// cycle. The cli builds one from the http response, the tui opens
258// the overlay when one arrives.
259type ChangelogPayload struct {
260 Version string
261 Body string
262 URL string
263}
264
265type modelRefreshResult struct{ err error }
266
267// Interactive is the TUI chat loop.
268type chatCacheKey struct {
269 cols int
270 agentRev uint64
271 statusOK string
272 statusErr string
273 help string
274 extNotes string
275 shellBlock string
276 updateAvailable bool
277 updateCurrent string
278 updateLatest string
279 updateURL string
280 welcomeShowVer bool
281 expandAll bool
282 tailLimit int
283}
284
285// QuickModelShortcut is one configured quick model switch slot.
286type QuickModelShortcut struct {
287 Provider string
288 Model string
289}
290
291// SettingsStore persists user-toggleable settings surfaced by /settings.
292type SettingsStore interface {
293 SetQuickModelShortcut(slot int, providerName, model string) error
294 SetInlineImages(enabled bool) error
295 SetAutoSwarm(enabled bool) error
296 SetJailByDefault(enabled bool) error
297 SetRecursiveFileSuggest(enabled bool) error
298 SetRespectGitignore(enabled bool) error
299 SetCompactMode(enabled bool) error
300 SetTUIInputStyle(style string) error
301 SetTUIStatusPosition(position string) error
302 SetTUIWorkingPosition(position string) error
303 SetReasoning(level string) error
304 SetTheme(name string) error
305}
306
307type Interactive struct {
308 cfg InteractiveConfig
309 view *tui.View
310 ed *tui.Editor
311 rend *tui.Renderer
312
313 mu sync.Mutex
314 agent *core.Agent
315 streaming strings.Builder // what's currently painted on screen
316 streamOn bool
317
318 // streamPending is the runes buffered after each EvTextDelta that
319 // haven't yet been promoted into `streaming` for rendering. It
320 // exists because some provider paths (notably Anthropic via the
321 // oauth/subscription channel) coalesce the model's output into a
322 // few fat chunks instead of drip-streaming. Painting those fat
323 // chunks verbatim looks like the summary "just appears". The
324 // paintPace goroutine drains a handful of runes per tick from
325 // this buffer into `streaming`, giving every path the same
326 // typewriter feel regardless of upstream chunk size.
327 streamPending []rune
328 // streamFlushPending is set when EvAssistantMessage fires while
329 // streamPending still has unrendered runes. The ticker flushes
330 // them, then closes out the stream (clearing flags, resetting
331 // buffers) so the final paint matches the on-disk message.
332 streamFlushPending bool
333 toolCalls map[string]*tui.ToolCallView
334 toolOrder []string
335 // toolGate records, per tool-call id, how many runes of paced
336 // assistant text must have drained into `streaming` before that
337 // tool block may appear. It exists to make stream ordering
338 // deterministic: a tool call can arrive from the provider while
339 // the prose that logically precedes it is still being typed out
340 // by the pacer. Without gating, the tool block would render
341 // immediately while the intro paragraph keeps filling in below
342 // it. We snapshot the total expected stream length (already
343 // streamed + still pending) at the moment the tool starts, and
344 // hold the block back until the pacer reaches it.
345 toolGate map[string]int
346 statusErr string
347 statusOK string
348 liveBlock []string // live streaming/tool progress rendered outside scrollback
349 helpBlock []string // rendered above the chat when /help was typed
350 cumUsage provider.Usage
351 lastCtxInput int // input_tokens of the most recent turn — approximates current context size
352 busy bool
353 dirty chan struct{}
354 modelRefresh chan modelRefreshResult
355 cancelTurn context.CancelFunc
356 scrollOffset int // rows from the bottom; 0 = pinned to latest
357 prevScrollOffset int // last value redraw snapped against; tracks intent
358
359 // prevChatLen and prevChatCols track the chat buffer's size at the
360 // last redraw so that when content grows below the user's viewport
361 // while they're scrolled up reading history, we can bump
362 // scrollOffset by exactly the growth and keep the visible content
363 // pinned. Without this, every streamed line shifts the visible
364 // window down through the buffer (because scrollOffset is measured
365 // from the bottom) and the user's reading position drifts upward
366 // and off the top.
367 prevChatLen int
368 prevChatCols int
369 prevChatRows int
370 prevOverlayOpen bool
371
372 // chatCache stores the built transcript/status-note rows for idle
373 // frames. Editor typing changes only the bottom input region, so
374 // reusing this cache avoids copying/walking/reassembling a long
375 // session on every keypress.
376 chatCache []string
377 chatCacheKey chatCacheKey
378 chatCacheValid bool
379
380 // Messages typed while a turn is in flight. Each is delivered as
381 // its own follow-up turn once the current one finishes. Rendered
382 // above the status bar as "sliding in: ..." chips.
383 queued []string
384
385 // runCtx is the top-level context passed to Run(). Follow-up turns
386 // drained from `queued` are started against this context so they
387 // survive past the ctx of the key event that enqueued them.
388 runCtx context.Context
389
390 // pendingPostCompactNote is a status_ok message to surface after
391 // a successful auto-compact pass triggered by a 413 or by the
392 // pre-turn fraction guard. Cleared by runCompact once shown.
393 pendingPostCompactNote string
394
395 // autoCompacting is true while a model-triggered compaction is in
396 // flight. Surfaced in the status bar so the user can tell a
397 // condense pass from a regular assistant turn.
398 autoCompacting bool
399
400 // updateInfo is the result of the async update check. Zero value
401 // while the check hasn't completed or nothing is available.
402 updateInfo UpdateInfo
403
404 dialog *loginDialog
405 modelDialog *modelDialog
406 llamaDialog *llamaDialog
407 rescueDialog *rescueDialog
408 sessionDialog *sessionDialog
409 swarmDialog *swarmDialog
410 jumpDialog *jumpDialog
411 btwDialog *btwDialog
412 skillsDialog *skillsDialog
413 changelogDialog *changelogDialog
414 confirmDialog *confirmDialog
415 logoutDialog *logoutDialog
416 telegramDialog *telegramDialog
417 settingsDialog *settingsDialog
418 quickModelAssign int
419 telegramBridge *telegram.Bridge
420 sessionOpsDialog *sessionOpsDialog
421 sessionTreeDialog *sessionTreeDialog
422 extPanel *extPanelDialog
423 llamaConfigured bool
424
425 // swarmWatch tracks auto-swarm sub-agents the main agent spawned
426 // via swarm_spawn. Each entry holds the agent + the task text;
427 // a per-entry goroutine waits on the agent's terminal state. When
428 // every tracked entry has finished, the watcher flushes a single
429 // summary turn into the main chat (queued if the main agent is
430 // busy, run immediately if idle).
431 swarmWatchMu sync.Mutex
432 swarmWatch []*swarmWatchEntry
433
434 // pendingFork is true when the user ran /session fork: the next
435 // jump-picker selection should branch off that message instead
436 // of scrolling. Flag resets after the action fires or the dialog
437 // is dismissed, so repeated /jump calls don't turn into forks.
438 pendingFork bool
439 suggest *slashSuggester
440 fileSuggest *fileSuggester
441 spin *spinner
442
443 // parkedTurn is the 1-based turn number the viewport is currently
444 // scrolled to by /jump. 0 = not parked, showing the tail as usual.
445 // Rendered as a muted footer at the bottom of the chat so users
446 // don't forget they're looking at history.
447 parkedTurn int
448 parkedTotal int
449
450 // inputHistoryIndex is -1 when not browsing history. When the
451 // editor is empty, Up/Down can walk previous user prompts without
452 // stealing normal vertical cursor movement in non-empty input.
453 inputHistoryIndex int
454
455 // lastCtrlC is when the user last pressed ctrl+c. The first press
456 // clears the editor / cancels a turn / shows a hint; a second press
457 // within ctrlCExitWindow exits. Mirrors the python-repl convention.
458 lastCtrlC time.Time
459
460 // welcomeStart is when the interactive run began. The welcome
461 // banner shows the binary version for welcomeVersionDuration
462 // after this point and reverts to plain text after.
463 welcomeStart time.Time
464
465 // extNotes are one-shot styled lines pushed by extensions via
466 // Notify / Display. They live above the editor (just below the
467 // transcript) until cleared by /clear or another reset.
468 extNotes []string
469
470 // shellBlock holds the rendered terminal-log lines of the most
471 // recent !command shell escape. It lives below the transcript
472 // (under extNotes) until the user sends their next prompt or runs
473 // /clear. shellRunning is true while a !command is executing; it
474 // shares i.busy/i.cancelTurn so esc cancels it and no turn or
475 // other shell escape can start while one is in flight.
476 shellBlock []string
477 shellRunning bool
478
479 // sessionLoading is true while a /sessions selection is being read
480 // on a background goroutine. Keeping this off the input goroutine
481 // lets ctrl+c/exit remain responsive for very large JSONL sessions.
482 sessionLoading bool
483
484 // pendingRescuePrompt / pendingRescueImages stash the prompt and
485 // images that should be re-run after the user picks a model in
486 // the rescue dialog. Cleared once applyRescueSelection consumes
487 // them (or when the dialog is dismissed via esc).
488 pendingRescuePrompt string
489 pendingRescueImages []provider.ImageBlock
490
491 clipboardImages []clipboardImageAttachment
492}
493
494// welcomeVersionDuration is how long the welcome banner shows the
495// version suffix before reverting to the plain headline. 1.5s is
496// enough to read at a glance and keeps the splash short.
497const welcomeVersionDuration = 1500 * time.Millisecond
498
499// initialResumeTailLimit caps how many messages from a freshly-resumed
500// transcript we render on the first paint. The full transcript is
501// still in memory; older messages are rendered (and their cached
502// lines kept for the lifetime of the View) as soon as the user
503// scrolls past the rendered tail. Picked to comfortably fill the
504// largest realistic terminal viewport while keeping first paint
505// snappy on multi-thousand-message sessions where markdown / syntax
506// highlighting dominates the redraw cost.
507const initialResumeTailLimit = 80
508
509// resumeTailExpandStep is how many additional messages the tail
510// limit grows by each time the user scrolls past the currently
511// rendered top. Pre-rendering this many messages on a single tick
512// keeps scroll-up smooth without falling back to a one-by-one
513// reveal that would feel jerky.
514const resumeTailExpandStep = 80
515
516// NewInteractive constructs an Interactive from cfg.
517func NewInteractive(cfg InteractiveConfig) *Interactive {
518 renderer := tui.NewRenderer(cfg.Terminal)
519 renderer.SetTheme(cfg.Theme)
520 i := &Interactive{
521 cfg: cfg,
522 view: &tui.View{
523 Theme: cfg.Theme,
524 ImageProto: effectiveImageProtocol(cfg.InlineImagesEnabled),
525 FlatTools: cfg.FlatTools,
526 CompactUser: cfg.CompactUser,
527 CompactMode: cfg.CompactMode != nil && *cfg.CompactMode,
528 },
529 // Prompt is the standard half-block accent bar used by chat
530 // speaker labels too, so the input gutter matches the rest
531 // of the UI.
532 ed: tui.NewEditor(cfg.Theme.AccentBar(cfg.Theme.Accent)),
533 rend: renderer,
534 toolCalls: map[string]*tui.ToolCallView{},
535 toolGate: map[string]int{},
536 dirty: make(chan struct{}, 8),
537 modelRefresh: make(chan modelRefreshResult, 1),
538 dialog: newLoginDialog(),
539 modelDialog: newModelDialog(),
540 llamaDialog: newLlamaDialog(),
541 rescueDialog: newRescueDialog(),
542 sessionDialog: newSessionDialog(),
543 swarmDialog: newSwarmDialog(),
544 jumpDialog: newJumpDialog(),
545 btwDialog: newBtwDialog(),
546 skillsDialog: newSkillsDialog(),
547 changelogDialog: newChangelogDialog(),
548 confirmDialog: newConfirmDialog(),
549 logoutDialog: newLogoutDialog(),
550 telegramDialog: newTelegramDialog(),
551 settingsDialog: newSettingsDialog(),
552 sessionOpsDialog: newSessionOpsDialog(),
553 sessionTreeDialog: newSessionTreeDialog(),
554 extPanel: newExtPanelDialog(),
555 suggest: newSlashSuggester(),
556 fileSuggest: newFileSuggester(),
557 spin: newSpinner(cfg.Theme),
558 inputHistoryIndex: -1,
559 }
560 i.fileSuggest.SetRecursive(cfg.RecursiveFileSuggest != nil && *cfg.RecursiveFileSuggest)
561 i.fileSuggest.SetRespectGitignore(cfg.RespectGitignore == nil || *cfg.RespectGitignore)
562 if cfg.LlamaCPPConfig != nil {
563 baseURL, _, err := cfg.LlamaCPPConfig()
564 i.llamaConfigured = err == nil && baseURL != ""
565 }
566 if cfg.Agent != nil {
567 i.agent = cfg.Agent
568 i.view.Messages = cfg.Agent.Messages()
569 i.cumUsage = cfg.Agent.Cost()
570 // Rehydrate the "context used" gauge from the last persisted
571 // turn. Without this the status bar reads 0.0% after a resume
572 // until the next turn lands a usage event.
573 if last := cfg.Agent.LastTurnUsage(); last.InputTokens > 0 || last.CacheReadTokens > 0 || last.CacheWriteTokens > 0 {
574 i.lastCtxInput = last.InputTokens + last.CacheReadTokens + last.CacheWriteTokens
575 }
576 // Cap the first paint at the tail of the transcript so
577 // resuming a multi-thousand-message session doesn't block
578 // on rendering every prior turn before showing anything.
579 if len(i.view.Messages) > initialResumeTailLimit {
580 i.view.TailLimit = initialResumeTailLimit
581 }
582 }
583 if cfg.AutoSwarmEnabled != nil && *cfg.AutoSwarmEnabled {
584 i.applyAutoSwarmTool(true)
585 }
586 return i
587}
588
589// Run blocks until the user quits.
590func (i *Interactive) Run(ctx context.Context) error {
591 i.runCtx = ctx
592 term := i.cfg.Terminal
593 restore, err := term.EnterRaw()
594 if err != nil {
595 return err
596 }
597 defer restore()
598 defer func() {
599 if i.telegramBridge != nil {
600 i.telegramBridge.Stop()
601 }
602 }()
603
604 // Enabling mouse reporting steals click-drag selection from the
605 // host terminal (VS Code, Ghostty, iTerm). The user prefers native
606 // selection over the wheel-speed boost, so we no longer turn it
607 // on automatically. Wheel events fall through to the terminal's
608 // own scrollback handler.
609 // Keep zot on the terminal's main screen. We intentionally do not
610 // enter the alternate-screen buffer (CSI ?1049h). The renderer emits
611 // chat as normal terminal flow/scrollback and redraws only the live
612 // input/status block on normal typing.
613 _, _ = term.Write([]byte(tui.SeqBracketedPasteOn + tui.SeqEnhancedKeyboardOn + tui.SeqResetScrollRegion + tui.SeqDeleteKittyImages + tui.SeqClearScreenNoHome + tui.SeqClearScrollback + tui.MoveTo(1, 1)))
614 // Tell the terminal our working directory (OSC 7) so "new tab /
615 // split here" opens in the launch cwd instead of inheriting a stale
616 // directory from an extension subprocess (see issue #38). Harmless
617 // on terminals that ignore it.
618 if seq := tui.ReportCWD(i.cfg.CWD); seq != "" {
619 _, _ = term.Write([]byte(seq))
620 }
621 defer term.Write([]byte(tui.SeqResetScrollRegion + tui.SeqDeleteKittyImages + tui.SeqEnhancedKeyboardOff + tui.SeqBracketedPasteOff + tui.ResetCursorColor() + tui.ResetCursorShape() + tui.SeqShowCursor))
622 i.applyInputCursorColor()
623
624 // Streaming pacer: drains buffered text deltas at a steady rate
625 // so typewriter feel is identical across providers regardless of
626 // upstream chunk size. Starts here so it lives for the whole
627 // session and exits with ctx.
628 go i.runStreamPacer(ctx)
629
630 cols, rows := term.Size()
631 i.rend.Resize(cols, rows)
632 term.OnResize(func() {
633 c, r := term.Size()
634 i.rend.Resize(c, r)
635 // Force an immediate redraw on resize. The throttled invalidate
636 // path is fine for animation, but a window resize is a discrete
637 // user action where any visible delay (or stale frame) reads as
638 // brokenness. redraw() is mutex-safe; the worst that happens is
639 // a duplicate paint if the throttler is mid-flight, which is
640 // invisible.
641 i.redraw()
642 })
643
644 if i.cfg.InitialInput != "" {
645 i.ed.SetValue(i.cfg.InitialInput)
646 }
647
648 // Stamp the welcome time and schedule a one-shot redraw at the
649 // expiry so the version suffix disappears on its own even if the
650 // user hasn't typed anything yet.
651 i.welcomeStart = time.Now()
652 time.AfterFunc(welcomeVersionDuration, i.invalidate)
653
654 // If the agent was constructed with a pre-loaded transcript
655 // (--continue, --resume, --session) pin the viewport at the
656 // bottom so the most recent reply (and any prompt the user just
657 // typed) is fully visible. Earlier behaviour parked the view at
658 // the last user turn, which could leave the latest message clipped
659 // off the bottom of the page on long sessions.
660 if i.agent != nil {
661 if msgs := i.agent.Messages(); len(msgs) > 0 {
662 i.scrollToBottom()
663 }
664 }
665
666 // No credential at startup? Auto-open the login dialog, and mark
667 // the status line. The user can Esc out of the dialog if they
668 // want to dismiss it (e.g. to check /help or /exit first).
669 if i.agent == nil {
670 i.statusErr = "not logged in. pick a login method below or press esc to dismiss."
671 i.dialog.Open(i.cfg.ZotHome)
672 }
673
674 // Input goroutine. Buffered generously so a drag-drop that the
675 // terminal delivers as a burst of single-character key events
676 // (no bracketed paste) can be drained in one main-loop pass
677 // instead of triggering a redraw per character.
678 keys := make(chan tui.Key, 256)
679 go func() {
680 reader := tui.NewReaderWithPeek(term.ReadByte, term.PeekByteTimeout)
681 for {
682 k, err := reader.Read()
683 if err != nil {
684 return
685 }
686 keys <- k
687 }
688 }()
689
690 // Subscribe to auth events.
691 var authEvents <-chan auth.Event
692 if i.cfg.AuthManager != nil {
693 authEvents = i.cfg.AuthManager.Events()
694 }
695
696 // Animation ticker: drives spinner and dialog-related redraws when
697 // nothing else changed. 120ms is slow enough that highlighting a huge
698 // transcript doesn't spin the cpu.
699 tick := time.NewTicker(120 * time.Millisecond)
700 defer tick.Stop()
701
702 // Redraw throttle: coalesce bursts of invalidate() calls so we paint
703 // at most once every redrawMinInterval. Huge tool-result dumps can
704 // fire hundreds of invalidations while the user is typing; without
705 // this, the input goroutine never gets CPU and keystrokes lag.
706 const redrawMinInterval = 16 * time.Millisecond
707 var lastRedraw time.Time
708 var pendingRedraw bool
709 var pendingTimer *time.Timer
710
711 drainPending := func() {
712 if pendingTimer != nil {
713 pendingTimer.Stop()
714 pendingTimer = nil
715 }
716 if pendingRedraw {
717 pendingRedraw = false
718 lastRedraw = time.Now()
719 i.redraw()
720 }
721 }
722
723 requestRedraw := func() {
724 since := time.Since(lastRedraw)
725 if since >= redrawMinInterval {
726 // Redrawing right now subsumes any pending redraw, so clear
727 // the throttle state. Without this, a pending flag stays
728 // stuck at true and subsequent invalidate() calls within
729 // redrawMinInterval get dropped — which is exactly how the
730 // final "turn finished" frame went missing until the user
731 // nudged the ui by typing or scrolling.
732 if pendingTimer != nil {
733 pendingTimer.Stop()
734 }
735 pendingRedraw = false
736 lastRedraw = time.Now()
737 i.redraw()
738 return
739 }
740 if pendingRedraw {
741 return // already scheduled
742 }
743 pendingRedraw = true
744 wait := redrawMinInterval - since
745 if pendingTimer == nil {
746 pendingTimer = time.AfterFunc(wait, func() {
747 // Poke the dirty channel so the main loop wakes and
748 // drains the pending redraw on its own goroutine. We
749 // can't call drainPending here directly — it touches
750 // closure state shared with the main loop.
751 i.invalidate()
752 })
753 } else {
754 pendingTimer.Reset(wait)
755 }
756 }
757
758 i.invalidate()
759
760 updates := i.cfg.UpdateInfoChan // nil-safe; nil channel blocks forever in select
761 changelog := i.cfg.ChangelogChan // single-shot, see case below
762
763 for {
764 select {
765 case <-ctx.Done():
766 return ctx.Err()
767 case k := <-keys:
768 if done := i.handleKey(ctx, k); done {
769 return nil
770 }
771 // Drain any keystrokes that arrived during this iteration.
772 // VS Code (and other terminals that don't bracket drops as
773 // paste) deliver a path one rune at a time — without this
774 // loop the editor would render between every rune and a
775 // long path on a heavy transcript would visibly type in.
776 drain:
777 for {
778 select {
779 case k2 := <-keys:
780 if done := i.handleKey(ctx, k2); done {
781 return nil
782 }
783 default:
784 break drain
785 }
786 }
787 i.invalidate()
788 case ev := <-authEvents:
789 i.handleAuthEvent(ev)
790 i.invalidate()
791 case result := <-i.modelRefresh:
792 i.openModelPickerAfterRefresh(result.err)
793 i.invalidate()
794 case info, ok := <-updates:
795 if ok && info.Available {
796 i.mu.Lock()
797 i.updateInfo = info
798 i.mu.Unlock()
799 i.invalidate()
800 }
801 updates = nil // single-shot; subsequent iterations skip this case
802 case cl, ok := <-changelog:
803 if ok && cl.Body != "" {
804 i.changelogDialog.Open(cl.Version, cl.URL, cl.Body)
805 i.invalidate()
806 }
807 changelog = nil // single-shot
808 case <-i.dirty:
809 requestRedraw()
810 case <-tick.C:
811 // Always drain a pending redraw on the tick. This is the
812 // safety net that catches the case where the dirty channel
813 // was saturated when the final "turn finished" invalidate
814 // fired, or where the throttle scheduled a deferred redraw
815 // and the AfterFunc-driven invalidate got dropped on a
816 // full channel.
817 drainPending()
818 // Only force a periodic redraw when something is actually
819 // animating (the main spinner during a busy turn, or the
820 // btw side-chat spinner while it's awaiting a response).
821 // Static pickers (model, session, jump, etc.) don't need
822 // the tick and firing it cancels the terminal's cursor
823 // blink inside dialogs that host their own editor (btw),
824 // because each frame re-emits hide-cursor + show-cursor.
825 //
826 // The swarm dashboard is also animated: its rows reflect
827 // background subprocesses whose activity / age change
828 // without any user input. Without the tick redraw the
829 // dashboard freezes on the snapshot taken when the user
830 // last pressed a key. We exclude the dashboard when one
831 // of its inline editors (spawn task or prompt composer)
832 // is active so the cursor blink in those editors works
833 // the same way it does inside btw.
834 if i.busy || i.btwDialog.Loading() || i.swarmDialog.NeedsTickRefresh() {
835 requestRedraw()
836 }
837 }
838 }
839}
840
841func (i *Interactive) invalidate() {
842 select {
843 case i.dirty <- struct{}{}:
844 default:
845 }
846}
847
848func (i *Interactive) cachedChatLocked(cols int) []string {
849 key, cacheable := i.chatCacheKeyLocked(cols)
850 if cacheable && i.chatCacheValid && i.chatCacheKey == key {
851 return append([]string(nil), i.chatCache...)
852 }
853 chat := i.buildChatLocked(cols)
854 if cacheable {
855 i.chatCache = append(i.chatCache[:0], chat...)
856 i.chatCacheKey = key
857 i.chatCacheValid = true
858 } else {
859 i.chatCacheValid = false
860 }
861 return chat
862}
863
864func (i *Interactive) chatCacheKeyLocked(cols int) (chatCacheKey, bool) {
865 // Live turns mutate streaming/tool-call state at high frequency;
866 // keep those on the old rebuild path. The cache targets the common
867 // idle case where only the editor contents changed between redraws.
868 if i.busy || i.streamOn || i.streamFlushPending {
869 return chatCacheKey{}, false
870 }
871 var rev uint64
872 if i.agent != nil {
873 rev = i.agent.Revision()
874 }
875 showVer := len(i.view.Messages) == 0 && !i.streamOn && len(i.toolOrder) == 0 && !i.welcomeStart.IsZero() && time.Since(i.welcomeStart) < welcomeVersionDuration
876 return chatCacheKey{
877 cols: cols,
878 agentRev: rev,
879 statusOK: i.statusOK,
880 statusErr: i.statusErr,
881 help: strings.Join(i.helpBlock, "\n"),
882 extNotes: strings.Join(i.extNotes, "\n"),
883 shellBlock: strings.Join(i.shellBlock, "\n"),
884 updateAvailable: i.updateInfo.Available,
885 updateCurrent: i.updateInfo.Current,
886 updateLatest: i.updateInfo.Latest,
887 updateURL: i.updateInfo.URL,
888 welcomeShowVer: showVer,
889 expandAll: i.view.ExpandAll,
890 tailLimit: i.view.TailLimit,
891 }, true
892}
893
894func (i *Interactive) buildChatLocked(cols int) []string {
895 if i.agent != nil {
896 i.view.Messages = filterHiddenTranscriptMessages(i.agent.Messages())
897 } else {
898 i.view.Messages = nil
899 }
900 // Pacer flush: while the streaming pacer is still draining the
901 // buffer (i.e. EvAssistantMessage already fired but more runes
902 // are queued), the final assistant message is already in
903 // i.agent.Messages() in full. Painting it in the transcript
904 // AND the streaming block at the same time shows the user the
905 // complete text immediately — which defeats the whole pacer.
906 // Hide the last message until the pacer catches up; once the
907 // flush-pending latch clears, the message is revealed (the
908 // streaming block disappears the same frame because streamOn
909 // flips off, so the transition is seamless).
910 if i.streamFlushPending && len(i.view.Messages) > 0 {
911 i.view.Messages = i.view.Messages[:len(i.view.Messages)-1]
912 }
913 i.view.Streaming = i.streaming.String()
914 i.view.StreamingActive = i.streamOn
915 // Guard against the narrow race where EvAssistantMessage has
916 // just promoted a streaming reply into the transcript but a
917 // render tick hasn't flipped streamOn off yet. Without the
918 // guard, the same text would appear twice (once as the
919 // in-flight streaming block, once as the last transcript
920 // message). We detect the duplicate strictly: the last
921 // assistant message's visible text must equal the streaming
922 // buffer. Just matching on role is too broad — it also hides
923 // the next round's typewriter streaming after a tool turn,
924 // because the last transcript message is always assistant
925 // (the tool-use block) until the follow-up summary lands.
926 if i.streamOn && i.streaming.Len() > 0 {
927 if n := len(i.view.Messages); n > 0 && i.view.Messages[n-1].Role == provider.RoleAssistant {
928 if assistantText(i.view.Messages[n-1]) == i.streaming.String() {
929 i.view.StreamingActive = false
930 }
931 }
932 }
933 // Live tool-call view: only shown while a turn is in flight. Once
934 // the agent is idle, every tool call has already been folded into
935 // the transcript (as assistant.ToolCallBlock + a tool-role message),
936 // so showing v.ToolCalls a second time would duplicate them below
937 // the final assistant text — which looks like the summary came
938 // "before" the tools.
939 i.view.ToolCalls = i.view.ToolCalls[:0]
940 if i.busy {
941 for _, id := range i.toolOrder {
942 // Deterministic ordering: a tool block stays hidden until
943 // the paced assistant text that preceded it has finished
944 // typing out. toolOrder is append-only in arrival order,
945 // so once one tool is still gated, every later tool is too,
946 // so stop here to avoid showing a tool out of sequence.
947 if !i.toolGateOpenLocked(id) {
948 break
949 }
950 if tc, ok := i.toolCalls[id]; ok {
951 i.view.ToolCalls = append(i.view.ToolCalls, *tc)
952 }
953 }
954 }
955 i.view.Err = i.statusErr
956 // Live streaming/tool rows are appended to the chat buffer (not
957 // hoisted into a separate live block above the editor). That keeps
958 // the renderer's diff view append-only: when a tool finishes the
959 // rows update in place at the end of the buffer, instead of the
960 // whole bottom band shrinking and shifting chat lines around.
961 i.liveBlock = nil
962 chat := i.view.Build(cols)
963
964 // Welcome banner: shown at the top of the chat area when there is
965 // no transcript yet. Disappears after the first message is sent.
966 // The version suffix is shown for welcomeVersionDuration after
967 // startup, then drops off automatically.
968 if len(i.view.Messages) == 0 && !i.streamOn && len(i.toolOrder) == 0 {
969 showVer := !i.welcomeStart.IsZero() && time.Since(i.welcomeStart) < welcomeVersionDuration
970 chat = append(welcomeBanner(i.cfg.Theme, i.cfg.Version, showVer), chat...)
971 }
972
973 // Update-available banner: prepended above everything else so it's
974 // the first thing the user sees when opening a new zot session.
975 // Once rendered, it stays until the user updates to a newer
976 // version — we don't persist a "dismissed" flag because this is
977 // cheap and re-showing it is how most users remember to update.
978 if i.updateInfo.Available {
979 banner := renderUpdateBanner(i.cfg.Theme, i.updateInfo, cols)
980 chat = append(banner, chat...)
981 }
982
983 // /help block: appended to the transcript so it appears at the
984 // bottom of the chat area (right above the status bar / editor).
985 // Prepending it would push long conversations off the top of the
986 // viewport, which users would miss entirely.
987 if len(i.helpBlock) > 0 {
988 chat = append(chat, i.helpBlock...)
989 }
990
991 if i.statusOK != "" {
992 // Hard-truncate the OK line to the visible width so a long
993 // session path ("resumed session: /Users/.../sessions/...")
994 // doesn't overflow past the right edge and look broken on a
995 // narrow terminal.
996 line := "✓ " + i.statusOK
997 if cols > 4 && len(line) > cols {
998 line = line[:cols-3] + "..."
999 }
1000 chat = append(chat, i.cfg.Theme.FG256(i.cfg.Theme.Tool, line), "")
1001 }
1002
1003 // Extension notes (notify / display) live just under the
1004 // transcript, above the dialog/editor band. Cleared by /clear.
1005 if len(i.extNotes) > 0 {
1006 chat = append(chat, i.extNotes...)
1007 chat = append(chat, "")
1008 }
1009
1010 // Shell-escape terminal-log block (!command). Rendered below the
1011 // transcript and extension notes; cleared when the next prompt is
1012 // sent or on /clear so it never leaks into the model conversation.
1013 if len(i.shellBlock) > 0 {
1014 chat = append(chat, i.shellBlock...)
1015 chat = append(chat, "")
1016 }
1017
1018 // Strip trailing blank rows so the chat content sits flush
1019 // against the new "blank above status bar" row added by the
1020 // bottom-region assembly. Build() ends every message with a
1021 // blank separator; without this trim, the final message in
1022 // the transcript would have its own trailing blank plus the
1023 // status block's leading blank, doubling the gap.
1024 for len(chat) > 0 && strings.TrimSpace(chat[len(chat)-1]) == "" {
1025 chat = chat[:len(chat)-1]
1026 }
1027 return chat
1028}
1029
1030// lastCols returns the current terminal width in columns.
1031func (i *Interactive) lastCols() int {
1032 cols, _ := i.cfg.Terminal.Size()
1033 return cols
1034}
1035
1036// chatPage returns the number of chat rows currently visible, used
1037// as the page size for PageUp/PageDown.
1038func (i *Interactive) chatPage() int {
1039 _, rows := i.cfg.Terminal.Size()
1040 p := rows - 6 // rough reservation for status + editor + a dialog line
1041 if p < 4 {
1042 p = 4
1043 }
1044 return p
1045}
1046
1047// scrollBy adjusts the scroll offset. Positive = up (into history).
1048// Clearing the parked-turn label when we're back at the bottom means
1049// the "viewing turn N" footer goes away automatically as soon as you
1050// scroll back to the live tail.
1051func (i *Interactive) scrollBy(delta int) {
1052 i.mu.Lock()
1053 i.scrollOffset += delta
1054 if i.scrollOffset < 0 {
1055 i.scrollOffset = 0
1056 }
1057 if i.scrollOffset == 0 {
1058 i.parkedTurn = 0
1059 i.parkedTotal = 0
1060 }
1061 if i.rend != nil {
1062 // VS Code's terminal is especially prone to leaving stray
1063 // wrapped-character fragments behind during scroll-driven
1064 // viewport changes. Force a full repaint on scroll, but
1065 // avoid a whole-screen clear because that visibly flickers.
1066 i.rend.Invalidate()
1067 }
1068 i.mu.Unlock()
1069 i.invalidate()
1070}
1071
1072// anchorScrollOffset keeps the user's reading position pinned when the
1073// chat buffer grows/shrinks or the viewport height changes between two
1074// redraws while they're scrolled up.
1075//
1076// scrollOffset is measured from the bottom of the chat buffer, so the
1077// top visible row is start = chatLen - scrollOffset - chatRows. To hold
1078// `start` constant we adjust the offset by the buffer-length delta minus
1079// the viewport-height delta. The result is clamped to [0, newLen] so a
1080// shrinking buffer can't push it negative.
1081func anchorScrollOffset(offset, prevLen, newLen, prevRows, newRows int) int {
1082 adj := (newLen - prevLen) - (newRows - prevRows)
1083 offset += adj
1084 if offset < 0 {
1085 offset = 0
1086 }
1087 if offset > newLen {
1088 offset = newLen
1089 }
1090 return offset
1091}
1092
1093// scrollToBottom pins the view to the latest content.
1094func (i *Interactive) scrollToBottom() {
1095 i.mu.Lock()
1096 i.scrollOffset = 0
1097 i.parkedTurn = 0
1098 i.parkedTotal = 0
1099 // Reset the auto-follow baseline. scrollToBottom is the resume /
1100 // session-swap snap point, where the chat buffer changes length
1101 // wholesale. Without zeroing these, the next render's follow guard
1102 // compares the fresh transcript's length against a stale baseline
1103 // and nudges scrollOffset, which reads as a viewport jump right
1104 // after resume. See commit 43da5e5 for the same fix on new turns.
1105 i.prevChatLen = 0
1106 i.prevChatCols = 0
1107 if i.rend != nil {
1108 i.rend.Invalidate()
1109 }
1110 i.mu.Unlock()
1111 i.invalidate()
1112}
1113
1114func (i *Interactive) redraw() {
1115 i.mu.Lock()
1116 defer i.mu.Unlock()
1117
1118 cols, _ := i.cfg.Terminal.Size()
1119 chat := i.cachedChatLocked(cols)
1120
1121 // Dialogs (login or model picker) render between chat and the editor.
1122 var dialog []string
1123 switch {
1124 case i.dialog.Active():
1125 dialog = i.dialog.Render(i.cfg.Theme, cols)
1126 case i.modelDialog.Active():
1127 dialog = i.modelDialog.Render(i.cfg.Theme, cols)
1128 case i.llamaDialog.Active():
1129 dialog = i.llamaDialog.Render(i.cfg.Theme, cols)
1130 case i.rescueDialog.Active():
1131 dialog = i.rescueDialog.Render(i.cfg.Theme, cols)
1132 case i.sessionDialog.Active():
1133 // Reserve rows for the editor (~3), status line (1-2),
1134 // dialog chrome (header + hint + rule + indicators, ~5),
1135 // and leave the remainder for session rows. Minimum of 3
1136 // rows so even a very small terminal shows something.
1137 _, rows := i.cfg.Terminal.Size()
1138 avail := rows - 12
1139 if avail < 3 {
1140 avail = 3
1141 }
1142 i.sessionDialog.MaxRows = avail
1143 dialog = i.sessionDialog.Render(i.cfg.Theme, cols)
1144 case i.swarmDialog.Active():
1145 dialog = i.swarmDialog.Render(i.cfg.Theme, cols)
1146 case i.jumpDialog.Active():
1147 dialog = i.jumpDialog.Render(i.cfg.Theme, cols)
1148 case i.confirmDialog.Active():
1149 if i.btwDialog.Active() {
1150 // Keep the side-chat transcript and its live tool preview visible
1151 // above confirmation, matching how the main transcript remains
1152 // visible while --no-yolo waits for a decision.
1153 dialog = renderBtwConfirmation(i.cfg.Theme, cols, i.btwDialog, i.confirmDialog)
1154 } else {
1155 dialog = i.confirmDialog.Render(i.cfg.Theme, cols)
1156 }
1157 case i.btwDialog.Active():
1158 dialog = i.btwDialog.Render(i.cfg.Theme, cols)
1159 case i.skillsDialog.Active():
1160 dialog = i.skillsDialog.Render(i.cfg.Theme, cols)
1161 case i.changelogDialog.Active():
1162 dialog = i.changelogDialog.Render(i.cfg.Theme, cols)
1163 case i.logoutDialog.Active():
1164 dialog = i.logoutDialog.Render(i.cfg.Theme, cols)
1165 case i.telegramDialog.Active():
1166 dialog = i.telegramDialog.Render(i.cfg.Theme, cols)
1167 case i.settingsDialog.Active():
1168 dialog = i.settingsDialog.Render(i.cfg.Theme, cols)
1169 case i.sessionOpsDialog.Active():
1170 dialog = i.sessionOpsDialog.Render(i.cfg.Theme, cols)
1171 case i.sessionTreeDialog.Active():
1172 dialog = i.sessionTreeDialog.Render(i.cfg.Theme, cols)
1173 case i.extPanel.Active():
1174 dialog = i.extPanel.Render(i.cfg.Theme, cols)
1175 }
1176 if len(dialog) > 0 {
1177 dialog = padDialogFrame(dialog)
1178 }
1179
1180 // Slash-command autocomplete: popup above the status line, only
1181 // when the editor starts with "/" and no dialog is already open.
1182 // Feed extension-registered commands into the suggester first so
1183 // they show up in tab-complete + the popup alongside the built-ins.
1184 i.suggest.SetJailed(i.cfg.Sandbox.Locked())
1185 i.suggest.SetLlamaConfigured(i.llamaConfigured)
1186 if i.cfg.Extensions != nil {
1187 catalog := i.cfg.Extensions.Commands()
1188 extra := make([]slashCommand, 0, len(catalog))
1189 for _, c := range catalog {
1190 // The popup renders extension commands under a dedicated
1191 // "── extensions ───" divider, so the description doesn't
1192 // need to repeat the source. If the description is empty,
1193 // fall back to the extension name so the row isn't blank.
1194 desc := c.Description
1195 if strings.TrimSpace(desc) == "" {
1196 desc = "(" + c.Extension + ")"
1197 }
1198 extra = append(extra, slashCommand{
1199 Name: "/" + c.Name,
1200 Desc: desc,
1201 })
1202 }
1203 i.suggest.SetExtra(extra)
1204 }
1205 var suggest []string
1206 currentInput := i.ed.Value()
1207 // Slash popup renders even while the agent is busy so the user
1208 // can queue a destructive command (/clear, /compact, /logout,
1209 // /model) or a read-only one (/help, /jump, /sessions, etc.)
1210 // without waiting for the current turn to finish. The dispatcher
1211 // in runSlash already handles the busy case per-command: safe
1212 // ones run immediately, destructive ones cancel the turn first.
1213 i.fileSuggest.SetCWD(i.cfg.CWD)
1214 if len(dialog) == 0 && i.suggest.Active(currentInput) {
1215 suggest = i.suggest.Render(currentInput, i.cfg.Theme, cols)
1216 } else if len(dialog) == 0 && i.fileSuggest.Active(currentInput) {
1217 suggest = i.fileSuggest.Render(currentInput, i.cfg.Theme, cols)
1218 }
1219
1220 // Detect overlay close (any dialog or slash/file suggestion popup
1221 // just transitioned from open to closed). Force a full redraw so
1222 // the rows the overlay occupied are guaranteed to be repainted
1223 // from the chat below, instead of the diff path leaving stale
1224 // dialog content behind. Equivalent to the user pressing ctrl+l.
1225 overlayOpen := len(dialog) > 0 || len(suggest) > 0
1226 if i.rend != nil && i.prevOverlayOpen && !overlayOpen {
1227 // An overlay (dialog or slash/file popup) just closed, so the
1228 // bottom band shrinks. On terminals where we can drop
1229 // scrollback, a full Clear is the simplest way to guarantee
1230 // the vacated rows are repainted from the chat below.
1231 //
1232 // On VS Code's terminal closing a dialog leaves the stale
1233 // overlay rows in the retained scrollback (we can't drop them
1234 // with the quiet in-place diff). Run the same full Clear() that
1235 // Ctrl+L uses so the scrollback is purged and the conversation
1236 // is repainted clean, matching what the user expects after
1237 // dismissing a picker. Clear() is keepScrollback-aware and
1238 // emits \x1b[3J there.
1239 i.rend.Clear()
1240 }
1241 i.prevOverlayOpen = overlayOpen
1242 if len(suggest) > 0 {
1243 // One blank row above the popup so it doesn't sit flush
1244 // against the chat / welcome content above.
1245 suggest = append([]string{""}, suggest...)
1246 }
1247
1248 // Busy prefix shown at the far left of the status bar. The
1249 // spinner glyph and its funny-line message share the `zot`
1250 // label colour (Theme.Assistant) so the whole "who's working"
1251 // band reads at a glance. Elapsed time stays muted because it
1252 // drifts every second and shouldn't grab focus.
1253 var busyPrefix string
1254 if i.busy {
1255 busyPrefix = fmt.Sprintf("%s %s %s %s",
1256 i.cfg.Theme.FG256(i.cfg.Theme.Assistant, i.spin.Frame()),
1257 i.cfg.Theme.FG256(i.cfg.Theme.Assistant, i.spin.Message()),
1258 i.cfg.Theme.FG256(i.cfg.Theme.Muted, "-"),
1259 i.cfg.Theme.FG256(i.cfg.Theme.Muted, i.spin.Elapsed().String()),
1260 )
1261 }
1262
1263 ctxMax := 0
1264 if m, err := provider.FindModel(i.cfg.Provider, i.cfg.Model); err == nil {
1265 ctxMax = m.ContextWindow
1266 }
1267 statusPosition := tui.NormalizeStatusPosition(i.cfg.TUIStatusPosition)
1268 workingPosition := tui.NormalizeWorkingPosition(i.cfg.TUIWorkingPosition)
1269 workingWithStatus := statusPosition == workingPosition
1270 statusBusyPrefix := ""
1271 if workingWithStatus {
1272 statusBusyPrefix = busyPrefix
1273 }
1274 statusLines := tui.StatusBar(tui.StatusBarParams{
1275 Theme: i.cfg.Theme,
1276 Provider: i.cfg.Provider,
1277 Model: i.cfg.Model,
1278 Reasoning: i.cfg.Reasoning,
1279 Busy: i.busy,
1280 BusyPrefix: statusBusyPrefix,
1281 CWD: i.cfg.CWD,
1282 Locked: i.cfg.Sandbox.Locked(),
1283 NoYolo: i.cfg.NoYolo,
1284 Usage: i.cumUsage,
1285 Subscription: i.cfg.AuthMethod == "oauth",
1286 ContextUsed: i.lastCtxInput,
1287 ContextMax: ctxMax,
1288 AutoCompacting: i.autoCompacting,
1289 Telegram: i.telegramBridge != nil && i.telegramBridge.Active(),
1290 Cols: cols,
1291 })
1292 inputStyle := tui.NormalizeInputStyle(i.cfg.TUIInputStyle)
1293 if inputStyle == tui.InputStyleLines || inputStyle == tui.InputStyleBlock {
1294 i.ed.Prompt = ""
1295 } else {
1296 i.ed.Prompt = i.cfg.Theme.AccentBar(i.cfg.Theme.Accent)
1297 }
1298 edLines, curR, curC := i.ed.Render(cols)
1299 var workingLines []string
1300 if busyPrefix != "" && !workingWithStatus {
1301 workingLines = []string{" " + busyPrefix}
1302 }
1303 inputCursorOffset := 0
1304 inputCursorColOffset := 0
1305 switch inputStyle {
1306 case tui.InputStyleLines:
1307 edLines = tui.InputLines(i.cfg.Theme, edLines, cols)
1308 inputCursorOffset = 1
1309 case tui.InputStyleBlock:
1310 edLines = tui.InputBlock(i.cfg.Theme, edLines, cols)
1311 inputCursorOffset = 1
1312 inputCursorColOffset = 2
1313 }
1314
1315 // "Sliding in" chips for messages the user typed while a turn is
1316 // in flight. Shown directly above the status bar so they're close
1317 // to the editor but don't push the chat around.
1318 var queue []string
1319 queued := append([]string(nil), i.queued...)
1320 if i.agent != nil {
1321 queued = append(queued, i.agent.PendingQueuedMessages()...)
1322 }
1323 if len(queued) > 0 {
1324 queue = append(queue, "")
1325 for _, q := range queued {
1326 label := i.cfg.Theme.FG256(i.cfg.Theme.Accent, " sliding in: ")
1327 text := truncateLine(q, cols-17)
1328 queue = append(queue, label+i.cfg.Theme.FG256(i.cfg.Theme.Muted, text))
1329 }
1330 // Hint row, rendered in the same muted tone as the model
1331 // info on the status bar so it reads as ambient metadata
1332 // rather than a chip. Tells the user how to recover the
1333 // most recent queued message back into the editor.
1334 hint := " Press " + slideBackChordHint() + " to slide back into input"
1335 queue = append(queue, i.cfg.Theme.FG256(i.cfg.Theme.Muted, hint))
1336 }
1337
1338 // Bottom-sticky sections (always visible, never scroll). Each
1339 // non-empty subsection (dialog, suggest popup, sliding-in queue)
1340 // is preceded by one blank row so it has air above the chat
1341 // content. The status block and editor get their own dedicated
1342 // blanks so spacing stays consistent whether or not a dialog or
1343 // popup is showing.
1344 bottom := make([]string, 0, len(dialog)+len(suggest)+len(queue)+len(statusLines)+len(edLines)+9)
1345 inputStartRow := -1
1346 if len(dialog) > 0 {
1347 bottom = append(bottom, "")
1348 }
1349 bottom = append(bottom, dialog...)
1350 // The swarm dashboard owns the bottom of the screen while it's
1351 // active: it has its own inline editors for spawn (`n`) and
1352 // prompt (`p`), so the main input would be a confusing second
1353 // caret. The suggest popup, sliding-in queue, status block, and
1354 // main editor are all hidden underneath it. Keystrokes still
1355 // reach handleKey — it routes them to swarmDialog.HandleKey
1356 // before the editor ever sees them — so the only effect of this
1357 // branch is visual.
1358 if !i.swarmDialog.Active() {
1359 bottom = append(bottom, suggest...)
1360 bottom = append(bottom, queue...)
1361 lineInput := inputStyle == tui.InputStyleLines
1362 statusBelow := statusPosition == tui.StatusPositionBelowInput
1363 workingBelow := workingPosition == tui.WorkingPositionBelowInput
1364
1365 var aboveInput []string
1366 if !statusBelow {
1367 aboveInput = append(aboveInput, statusLines...)
1368 }
1369 if !workingBelow {
1370 aboveInput = append(aboveInput, workingLines...)
1371 }
1372 var belowInput []string
1373 if workingBelow {
1374 belowInput = append(belowInput, workingLines...)
1375 }
1376 if statusBelow {
1377 belowInput = append(belowInput, statusLines...)
1378 }
1379
1380 bottom = append(bottom, "")
1381 bottom = append(bottom, aboveInput...)
1382 needsGapAboveInput := len(aboveInput) > 0 && !lineInput
1383 if lineInput && statusBelow && !workingBelow && len(workingLines) > 0 {
1384 needsGapAboveInput = true
1385 }
1386 if needsGapAboveInput {
1387 bottom = append(bottom, "")
1388 }
1389 inputStartRow = len(bottom)
1390 bottom = append(bottom, edLines...)
1391 if len(belowInput) > 0 {
1392 if !lineInput {
1393 bottom = append(bottom, "")
1394 }
1395 bottom = append(bottom, belowInput...)
1396 }
1397 }
1398
1399 _, rows := i.cfg.Terminal.Size()
1400 chatRows := rows - len(bottom)
1401 if chatRows < 1 {
1402 chatRows = 1
1403 }
1404
1405 // Auto-follow guard: when the user has scrolled up (scrollOffset
1406 // > 0) and the agent appends new content below the viewport while
1407 // they're reading, compensate so the visible content stays
1408 // anchored. scrollOffset is measured from the bottom of `chat`,
1409 // so without compensation a growing buffer pushes the window
1410 // downward through the content and the lines the user was
1411 // reading scroll off the top.
1412 //
1413 // Skip compensation when the terminal width changed (a resize
1414 // reflows the whole buffer and the line-count delta no longer
1415 // corresponds to appended content) and when scrollOffset is 0
1416 // (the user is following the tail and wants new content to push
1417 // the view down as usual).
1418 //
1419 // The window the user sees starts at row
1420 // start = len(chat) - scrollOffset - chatRows
1421 // so to keep `start` fixed across a redraw we must offset by both
1422 // the buffer growth (len delta) AND the viewport-height change
1423 // (chatRows delta, e.g. the status band or sliding-in queue
1424 // appearing during a turn). Compensating only for the len delta
1425 // let a shrinking chatRows pull the window toward the tail, which
1426 // read as the viewport jumping to the bottom whenever the agent
1427 // streamed text or a tool call grew the bottom band.
1428 if i.scrollOffset > 0 && i.prevChatCols == cols && i.prevChatLen > 0 {
1429 i.scrollOffset = anchorScrollOffset(i.scrollOffset,
1430 i.prevChatLen, len(chat), i.prevChatRows, chatRows)
1431 }
1432 i.prevChatLen = len(chat)
1433 i.prevChatCols = cols
1434 i.prevChatRows = chatRows
1435
1436 // Apply scroll offset to the chat slice.
1437 maxOffset := len(chat) - chatRows
1438 if maxOffset < 0 {
1439 maxOffset = 0
1440 }
1441 // Tail-render expansion: if the user has scrolled to (or above)
1442 // the top of the currently rendered tail and there are still
1443 // truncated messages above, widen view.TailLimit and rebuild.
1444 // The chat cache is keyed on tailLimit so the next cachedChatLocked
1445 // will re-issue Build instead of returning the stale slice. We
1446 // rebuild immediately so the same redraw shows the freshly-revealed
1447 // rows; otherwise the user would have to scroll again to see them.
1448 if i.scrollOffset >= maxOffset && i.view.TailLimit > 0 && i.view.TailLimit < len(i.view.Messages) {
1449 prevLen := len(chat)
1450 i.view.TailLimit += resumeTailExpandStep
1451 if i.view.TailLimit >= len(i.view.Messages) {
1452 i.view.TailLimit = 0 // unbounded
1453 }
1454 i.chatCacheValid = false
1455 chat = i.cachedChatLocked(cols)
1456 for len(chat) > 0 && strings.TrimSpace(chat[len(chat)-1]) == "" {
1457 chat = chat[:len(chat)-1]
1458 }
1459 // Newly-revealed rows are older messages prepended at the top.
1460 // scrollOffset counts rows from the bottom, so to keep the
1461 // viewport visually anchored on the same content the user was
1462 // looking at we shift it up by the number of rows added. Keep
1463 // the auto-follow baseline (prevChatLen) in sync with the
1464 // post-expansion length too, so the next render's follow guard
1465 // doesn't see this growth as a synthetic delta and yank the
1466 // viewport again.
1467 if grew := len(chat) - prevLen; grew > 0 {
1468 i.scrollOffset += grew
1469 }
1470 i.prevChatLen = len(chat)
1471 i.prevChatCols = cols
1472 maxOffset = len(chat) - chatRows
1473 if maxOffset < 0 {
1474 maxOffset = 0
1475 }
1476 }
1477 if i.scrollOffset > maxOffset {
1478 i.scrollOffset = maxOffset
1479 }
1480 if i.scrollOffset < 0 {
1481 i.scrollOffset = 0
1482 }
1483
1484 var visibleChat []string
1485 if len(chat) <= chatRows {
1486 visibleChat = chat
1487 } else {
1488 end := len(chat) - i.scrollOffset
1489 rawStart := end - chatRows
1490 if rawStart < 0 {
1491 rawStart = 0
1492 }
1493 start := snapViewportStartToImageBlock(chat, rawStart)
1494 // If the snap pulled start upward (an image-block was atomic) while
1495 // the user is scrolling downward, the viewport would sit on the same
1496 // image until the user mashes down past every reserved row. Bump
1497 // scrollOffset past the image so one keypress always clears it.
1498 if start < rawStart && i.scrollOffset < i.prevScrollOffset {
1499 jump := rawStart - start
1500 i.scrollOffset -= jump
1501 if i.scrollOffset < 0 {
1502 i.scrollOffset = 0
1503 }
1504 end = len(chat) - i.scrollOffset
1505 rawStart = end - chatRows
1506 if rawStart < 0 {
1507 rawStart = 0
1508 }
1509 start = snapViewportStartToImageBlock(chat, rawStart)
1510 }
1511 end = start + chatRows
1512 if end > len(chat) {
1513 end = len(chat)
1514 start = end - chatRows
1515 if start < 0 {
1516 start = 0
1517 }
1518 }
1519 visibleChat = chat[start:end]
1520 }
1521 i.prevScrollOffset = i.scrollOffset
1522 visibleChat = clipBottomClippedImages(visibleChat)
1523
1524 // A tiny "scrolled up" indicator in the top-right of the chat pane
1525 // so you know you're not at the bottom. When the viewport was
1526 // parked by /jump we include the turn number so the user remembers
1527 // they're reading history rather than the live conversation.
1528 if i.scrollOffset > 0 && len(visibleChat) > 0 {
1529 var text string
1530 if i.parkedTurn > 0 && i.parkedTotal > 0 {
1531 text = fmt.Sprintf(" ↑ viewing turn %d of %d - %d lines more below (pgdn / end)",
1532 i.parkedTurn, i.parkedTotal, i.scrollOffset)
1533 } else {
1534 text = fmt.Sprintf(" ↑ %d lines more below (end to jump)", i.scrollOffset)
1535 }
1536 note := i.cfg.Theme.FG256(i.cfg.Theme.Muted, text)
1537 visibleChat = append([]string{note}, visibleChat...)
1538 if len(visibleChat) > chatRows {
1539 visibleChat = visibleChat[:chatRows]
1540 }
1541 }
1542
1543 // Default: the real terminal cursor sits on the main editor's
1544 // input position. In main-screen log mode cursor rows are relative
1545 // to the fixed bottom band, not the chat transcript.
1546 // dialogLead is 1 when the bottom region prepends a blank above
1547 // the dialog block (whenever a dialog is showing) so popup-side
1548 // cursor positions still land in the right cell.
1549 dialogLead := 0
1550 if len(dialog) > 0 {
1551 dialogLead = 1
1552 }
1553 cursorRow := inputStartRow + inputCursorOffset + curR
1554 cursorCol := curC + inputCursorColOffset
1555 if i.btwDialog.Active() {
1556 if r, c := i.btwDialog.CursorPos(cols); r >= 0 {
1557 cursorRow = dialogLead + r
1558 cursorCol = c
1559 }
1560 }
1561 if i.dialog.Active() {
1562 if r, c := i.dialog.CursorPos(cols); r >= 0 {
1563 cursorRow = dialogLead + r
1564 cursorCol = c
1565 }
1566 }
1567 if i.llamaDialog.Active() {
1568 if r, c := i.llamaDialog.CursorPos(); r >= 0 {
1569 cursorRow = dialogLead + r
1570 cursorCol = c
1571 } else {
1572 cursorRow = -1
1573 cursorCol = 0
1574 }
1575 }
1576 if i.sessionDialog.Active() {
1577 if r, c := i.sessionDialog.CursorPos(); r >= 0 {
1578 cursorRow = dialogLead + r
1579 cursorCol = c
1580 }
1581 }
1582 if i.sessionTreeDialog.Active() {
1583 if r, c := i.sessionTreeDialog.CursorPos(); r >= 0 {
1584 cursorRow = dialogLead + r
1585 cursorCol = c
1586 }
1587 }
1588 if i.swarmDialog.Active() {
1589 if r, c := i.swarmDialog.CursorPos(cols); r >= 0 {
1590 cursorRow = dialogLead + r
1591 cursorCol = c
1592 } else {
1593 // Dashboard list / transcript view has no caret. Without
1594 // this branch the default cursorRow points at the
1595 // (hidden) main editor row, so the terminal would draw
1596 // a stray block somewhere in the chat region.
1597 cursorRow = -1
1598 cursorCol = 0
1599 }
1600 }
1601 if i.extPanel.Active() {
1602 cursorRow = -1
1603 cursorCol = 0
1604 }
1605 _ = visibleChat // maintained for legacy scroll state/indicators; DrawLog owns chat viewport.
1606 i.rend.DrawLog(chat, bottom, cursorRow, cursorCol)
1607}
1608
1609func hasImageEscape(line string) bool {
1610 return strings.Contains(line, "\x1b]1337;File=") || strings.Contains(line, "\x1b_G")
1611}
1612
1613// snapViewportStartToImageBlock treats inline images as atomic blocks for
1614// scrolling. Terminal image protocols draw from a single escape row into a
1615// separate graphics layer; the following blank rows are only zot's reserved
1616// footprint. If the viewport starts on one of those blank rows, there is no
1617// correct partial-image state to render. Snap back to the escape row instead
1618// so the image is either shown from its beginning or skipped entirely.
1619func snapViewportStartToImageBlock(chat []string, start int) int {
1620 if start <= 0 || start >= len(chat) {
1621 return start
1622 }
1623 if hasImageEscape(chat[start]) || !isBoxBlankLine(chat[start]) {
1624 return start
1625 }
1626 for k := start - 1; k >= 0; k-- {
1627 line := chat[k]
1628 if hasImageEscape(line) {
1629 return k
1630 }
1631 if !isBoxBlankLine(line) {
1632 break
1633 }
1634 }
1635 return start
1636}
1637
1638const hiddenOpenAIImageMirrorPrefix = "Tool output included the following image content:"
1639
1640func filterHiddenTranscriptMessages(msgs []provider.Message) []provider.Message {
1641 if len(msgs) == 0 {
1642 return nil
1643 }
1644 out := make([]provider.Message, 0, len(msgs))
1645 for _, m := range msgs {
1646 if isHiddenTranscriptMessage(m) {
1647 continue
1648 }
1649 out = append(out, m)
1650 }
1651 return out
1652}
1653
1654func isHiddenTranscriptMessage(m provider.Message) bool {
1655 if m.Role != provider.RoleUser || len(m.Content) == 0 {
1656 return false
1657 }
1658 tb, ok := m.Content[0].(provider.TextBlock)
1659 if !ok {
1660 return false
1661 }
1662 return strings.TrimSpace(tb.Text) == hiddenOpenAIImageMirrorPrefix
1663}
1664
1665func clipBottomClippedImages(lines []string) []string {
1666 if len(lines) == 0 {
1667 return lines
1668 }
1669 out := append([]string(nil), lines...)
1670 for i, line := range out {
1671 if !hasImageEscape(line) {
1672 continue
1673 }
1674 // Image blocks render as: image escape, zero or more blank
1675 // reservation rows, then the muted "image - ..." info line,
1676 // then one trailing blank. If the info line isn't visible in
1677 // the current chat slice, the image would paint down into the
1678 // fixed status bar area. Suppress that image for this frame.
1679 //
1680 // When the image lives inside a tool box, the reservation rows
1681 // are wrapped in vertical box edges ("│ ... │"); those rows
1682 // look non-blank under a naive whitespace check but are still
1683 // reservation rows for this scan, so treat them as blank.
1684 foundInfo := false
1685 for j := i + 1; j < len(out); j++ {
1686 if strings.Contains(out[j], "image - ") {
1687 foundInfo = true
1688 break
1689 }
1690 if !isBoxBlankLine(out[j]) {
1691 break
1692 }
1693 }
1694 if !foundInfo {
1695 out[i] = ""
1696 }
1697 }
1698 return out
1699}
1700
1701// isBoxBlankLine reports whether line is visually empty after
1702// stripping ANSI escape sequences, surrounding whitespace, and the
1703// vertical box edges drawn by the tool-box renderer. Used by
1704// clipBottomClippedImages so an image's reservation rows still count
1705// as blank when those rows are wrapped in "│ ... │" inside a tool box.
1706func isBoxBlankLine(line string) bool {
1707 stripped := stripANSIBytes(line)
1708 stripped = strings.TrimSpace(stripped)
1709 stripped = strings.Trim(stripped, "│")
1710 stripped = strings.TrimSpace(stripped)
1711 return stripped == ""
1712}
1713
1714// stripANSIBytes removes ANSI CSI escape sequences (ESC '[' ... final
1715// byte) from s without pulling in the regexp package. Mirrors the
1716// internal helper in package tui; the duplicated copy avoids exporting
1717// it just for one caller.
1718func stripANSIBytes(s string) string {
1719 if !strings.Contains(s, "\x1b") {
1720 return s
1721 }
1722 var b strings.Builder
1723 b.Grow(len(s))
1724 i := 0
1725 for i < len(s) {
1726 if s[i] == 0x1b && i+1 < len(s) && s[i+1] == '[' {
1727 end := i + 2
1728 for end < len(s) {
1729 c := s[end]
1730 end++
1731 if c >= 0x40 && c <= 0x7e {
1732 break
1733 }
1734 }
1735 i = end
1736 continue
1737 }
1738 b.WriteByte(s[i])
1739 i++
1740 }
1741 return b.String()
1742}
1743
1744// truncateLine shortens s so it fits within n display cells, with an
1745// ellipsis if trimmed. Used by the "sliding in" chips so a pasted
1746// novel doesn't blow past the status line.
1747func panelKeyName(k tui.Key) string {
1748 switch k.Kind {
1749 case tui.KeyUp:
1750 return "up"
1751 case tui.KeyDown:
1752 return "down"
1753 case tui.KeyLeft:
1754 return "left"
1755 case tui.KeyRight:
1756 return "right"
1757 case tui.KeyEnter:
1758 return "enter"
1759 case tui.KeyEsc:
1760 return "esc"
1761 case tui.KeyTab:
1762 return "tab"
1763 case tui.KeyBackspace:
1764 return "backspace"
1765 case tui.KeyDelete:
1766 return "delete"
1767 case tui.KeyHome:
1768 return "home"
1769 case tui.KeyEnd:
1770 return "end"
1771 case tui.KeyPageUp:
1772 return "pageup"
1773 case tui.KeyPageDown:
1774 return "pagedown"
1775 case tui.KeyRune:
1776 return "rune"
1777 default:
1778 return "unknown"
1779 }
1780}
1781
1782func panelKeyText(k tui.Key) string {
1783 if k.Kind == tui.KeyRune {
1784 return string(k.Rune)
1785 }
1786 return ""
1787}
1788
1789func truncateLine(s string, n int) string {
1790 if n <= 0 {
1791 return ""
1792 }
1793 // Collapse newlines — chips are single line.
1794 s = strings.ReplaceAll(s, "\n", " ↩ ")
1795 runes := []rune(s)
1796 if len(runes) <= n {
1797 return s
1798 }
1799 if n <= 3 {
1800 return strings.Repeat(".", n)
1801 }
1802 return string(runes[:n-3]) + "..."
1803}
1804
1805// ctrlCExitWindow is how long after a ctrl+c press a *second* press
1806// will exit instead of just clearing input. Long enough to be
1807// deliberate (rules out accidental key chord), short enough that the
1808// hint stays meaningful.
1809const ctrlCExitWindow = 2 * time.Second
1810
1811// armCtrlCExit records the timestamp of the current ctrl+c so the next
1812// one within ctrlCExitWindow exits.
1813func (i *Interactive) armCtrlCExit() {
1814 i.mu.Lock()
1815 i.lastCtrlC = time.Now()
1816 i.mu.Unlock()
1817}
1818
1819// ctrlCExitArmed reports whether a previous ctrl+c was recent enough
1820// that another press should now exit.
1821func (i *Interactive) ctrlCExitArmed() bool {
1822 i.mu.Lock()
1823 t := i.lastCtrlC
1824 i.mu.Unlock()
1825 return !t.IsZero() && time.Since(t) <= ctrlCExitWindow
1826}
1827
1828// clearFileSuggestQuery strips the filter the user typed after the
1829// last "@", leaving the bare "@" so the picker stays open. Called when
1830// navigating between directory levels (Right/Left): the filter applied
1831// to the level the user was on, not the one being entered, so carrying
1832// it forward would wrongly hide the new directory's contents.
1833func (i *Interactive) clearFileSuggestQuery() {
1834 val := i.ed.Value()
1835 if idx := strings.LastIndex(val, "@"); idx >= 0 {
1836 i.ed.SetValue(val[:idx+1])
1837 }
1838}
1839
1840// setToolExpansion updates long tool result expansion and replays the
1841// transcript so already-emitted rows reflect the new state.
1842func (i *Interactive) setToolExpansion(expanded bool) {
1843 i.mu.Lock()
1844 i.view.ExpandAll = expanded
1845 if i.rend != nil {
1846 i.rend.Clear()
1847 }
1848 i.mu.Unlock()
1849 i.invalidate()
1850}
1851
1852func (i *Interactive) toggleToolExpansion() {
1853 i.mu.Lock()
1854 expanded := !i.view.ExpandAll
1855 i.mu.Unlock()
1856 i.setToolExpansion(expanded)
1857}
1858
1859func (i *Interactive) toggleBtwToolExpansion() {
1860 i.btwDialog.ToggleToolExpansion()
1861 i.mu.Lock()
1862 if i.rend != nil {
1863 i.rend.Clear()
1864 }
1865 i.mu.Unlock()
1866 i.invalidate()
1867}
1868
1869func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) {
1870 // Any key that isn't ctrl+c invalidates an armed ctrl+c-exit, so
1871 // pressing ctrl+c then typing then ctrl+c much later doesn't quit
1872 // unexpectedly. The hint message also goes stale; clear it.
1873 if k.Kind != tui.KeyCtrlC {
1874 i.mu.Lock()
1875 if !i.lastCtrlC.IsZero() {
1876 i.lastCtrlC = time.Time{}
1877 if strings.HasPrefix(i.statusOK, "input cleared") || strings.HasPrefix(i.statusOK, "press ctrl+c") {
1878 i.statusOK = ""
1879 }
1880 }
1881 i.mu.Unlock()
1882 }
1883
1884 // Dialogs consume keys while open (except ctrl+c, which always closes them).
1885
1886 // Confirm dialog has highest priority: the agent goroutine is
1887 // blocked waiting for an answer, so we must not let keys leak
1888 // anywhere else while it's up. Ctrl+O remains available so the
1889 // user can expand or collapse a tool preview before deciding.
1890 if i.confirmDialog.Active() {
1891 if k.Kind == tui.KeyCtrlO {
1892 if i.btwDialog != nil && i.btwDialog.Active() {
1893 i.toggleBtwToolExpansion()
1894 } else {
1895 i.toggleToolExpansion()
1896 }
1897 return false
1898 }
1899 i.confirmDialog.HandleKey(k)
1900 i.invalidate()
1901 return false
1902 }
1903 if i.dialog.Active() {
1904 if k.Kind == tui.KeyCtrlC {
1905 i.dialog.Close()
1906 if i.cfg.AuthManager != nil {
1907 i.cfg.AuthManager.CancelOAuth()
1908 }
1909 return false
1910 }
1911 act := i.dialog.HandleKey(k)
1912 if act.StartAPIKey {
1913 i.startAPIKeyFlow(act.Provider)
1914 }
1915 if act.StartOAuth {
1916 i.startOAuthFlow(act.Provider)
1917 }
1918 if act.StartManual {
1919 i.startManualOAuthFlow(act.Provider)
1920 }
1921 if act.SubmitCode != "" {
1922 i.submitManualOAuthCode(act.SubmitCode)
1923 }
1924 if act.SaveLlama {
1925 i.saveLlamaCPPLogin(act.LlamaURL, act.LlamaAPIKey)
1926 }
1927 return false
1928 }
1929 if i.modelDialog.Active() {
1930 if k.Kind == tui.KeyCtrlC {
1931 i.modelDialog.Close()
1932 i.quickModelAssign = 0
1933 return false
1934 }
1935 act := i.modelDialog.HandleKey(k)
1936 if act.Close {
1937 i.quickModelAssign = 0
1938 }
1939 if act.Select {
1940 if i.quickModelAssign > 0 {
1941 i.applyQuickModelSelection(i.quickModelAssign, act.Provider, act.Model)
1942 i.quickModelAssign = 0
1943 } else {
1944 i.applyModelSelection(act.Provider, act.Model)
1945 }
1946 }
1947 return false
1948 }
1949 if i.llamaDialog.Active() {
1950 i.llamaDialog.HandleKey(k)
1951 i.invalidate()
1952 return false
1953 }
1954 if i.rescueDialog.Active() {
1955 if k.Kind == tui.KeyCtrlC {
1956 i.rescueDialog.Close()
1957 i.invalidate()
1958 return false
1959 }
1960 act := i.rescueDialog.HandleKey(k)
1961 if act.Select {
1962 i.applyRescueSelection(act.Provider, act.Model, act.Prompt)
1963 }
1964 i.invalidate()
1965 return false
1966 }
1967 if i.sessionDialog.Active() {
1968 if k.Kind == tui.KeyCtrlC {
1969 i.sessionDialog.Close()
1970 return false
1971 }
1972 act := i.sessionDialog.HandleKey(k)
1973 if act.Select {
1974 i.applySessionSelection(act.Path)
1975 }
1976 // Always request a redraw after handling a key here: when esc
1977 // closes the picker, the overlay-close detection in the render
1978 // pass needs to run so the tall dialog rows get repainted from
1979 // the chat (otherwise VS Code's retained scrollback leaves a
1980 // duplicate frame on screen).
1981 i.invalidate()
1982 return false
1983 }
1984 if i.swarmDialog.Active() {
1985 if k.Kind == tui.KeyCtrlC {
1986 i.swarmDialog.Close()
1987 i.invalidate()
1988 return false
1989 }
1990 _, msg, errMsg := i.swarmDialog.HandleKey(k)
1991 if msg != "" || errMsg != "" {
1992 i.mu.Lock()
1993 i.statusOK = msg
1994 i.statusErr = errMsg
1995 i.mu.Unlock()
1996 }
1997 i.invalidate()
1998 return false
1999 }
2000 if i.logoutDialog.Active() {
2001 if k.Kind == tui.KeyCtrlC {
2002 i.logoutDialog.Close()
2003 i.invalidate()
2004 return false
2005 }
2006 act := i.logoutDialog.HandleKey(k)
2007 if act.Select {
2008 i.doLogout(act.Target)
2009 }
2010 i.invalidate()
2011 return false
2012 }
2013 if i.telegramDialog.Active() {
2014 if k.Kind == tui.KeyCtrlC {
2015 i.telegramDialog.Close()
2016 i.invalidate()
2017 return false
2018 }
2019 act := i.telegramDialog.HandleKey(k)
2020 if act.Select {
2021 i.doTelegram(act.Action)
2022 }
2023 i.invalidate()
2024 return false
2025 }
2026 if i.settingsDialog.Active() {
2027 if k.Kind == tui.KeyCtrlC {
2028 i.settingsDialog.Close()
2029 i.invalidate()
2030 return false
2031 }
2032 act := i.settingsDialog.HandleKey(k)
2033 if act.ModelShortcutSlot > 0 {
2034 i.openQuickModelPicker(act.ModelShortcutSlot)
2035 }
2036 if act.Toggle {
2037 i.applySettingChange(act)
2038 }
2039 i.invalidate()
2040 return false
2041 }
2042 if i.sessionOpsDialog.Active() {
2043 if k.Kind == tui.KeyCtrlC {
2044 i.sessionOpsDialog.Close()
2045 i.invalidate()
2046 return false
2047 }
2048 act := i.sessionOpsDialog.HandleKey(k)
2049 if act.Select {
2050 i.doSessionOp(act.Action, "")
2051 }
2052 i.invalidate()
2053 return false
2054 }
2055 if i.sessionTreeDialog.Active() {
2056 if k.Kind == tui.KeyCtrlC {
2057 i.sessionTreeDialog.Close()
2058 i.invalidate()
2059 return false
2060 }
2061 act := i.sessionTreeDialog.HandleKey(k)
2062 if act.Select {
2063 i.applySessionTreeMessageSelection(act.Path, act.MessageIdx, act.TurnNo, act.Role, act.Prompt)
2064 }
2065 i.invalidate()
2066 return false
2067 }
2068 if i.extPanel.Active() {
2069 if k.Kind == tui.KeyCtrlC || k.Kind == tui.KeyEsc {
2070 if i.cfg.Extensions != nil {
2071 _ = i.cfg.Extensions.SendPanelClose(i.extPanel.ext, i.extPanel.id)
2072 }
2073 i.extPanel.Close()
2074 i.invalidate()
2075 return false
2076 }
2077 if i.cfg.Extensions != nil {
2078 _ = i.cfg.Extensions.SendPanelKey(i.extPanel.ext, i.extPanel.id, panelKeyName(k), panelKeyText(k))
2079 }
2080 return false
2081 }
2082 if i.jumpDialog.Active() {
2083 if k.Kind == tui.KeyCtrlC {
2084 i.jumpDialog.Close()
2085 i.pendingFork = false
2086 return false
2087 }
2088 act := i.jumpDialog.HandleKey(k)
2089 if act.Select {
2090 if i.pendingFork {
2091 i.applyForkSelection(act.MessageIdx)
2092 } else {
2093 i.applyJumpSelection(act.MessageIdx, act.TurnNo)
2094 }
2095 }
2096 // If the user dismissed the dialog without selecting, also
2097 // clear the pending-fork flag so a later plain /jump isn't
2098 // hijacked.
2099 if act.Close {
2100 i.pendingFork = false
2101 }
2102 return false
2103 }
2104 if i.btwDialog.Active() {
2105 if k.Kind == tui.KeyCtrlC {
2106 i.btwDialog.Close()
2107 i.invalidate()
2108 return false
2109 }
2110 if k.Kind == tui.KeyCtrlO {
2111 i.toggleBtwToolExpansion()
2112 return false
2113 }
2114 i.btwDialog.HandleKey(k, i.invalidate)
2115 return false
2116 }
2117 if i.skillsDialog.Active() {
2118 if k.Kind == tui.KeyCtrlC {
2119 i.skillsDialog.Close()
2120 i.invalidate()
2121 return false
2122 }
2123 i.skillsDialog.HandleKey(k)
2124 i.invalidate()
2125 return false
2126 }
2127 if i.changelogDialog.Active() {
2128 if closed := i.changelogDialog.HandleKey(k); closed {
2129 // User dismissed; let the parent persist the
2130 // LastChangelogShown marker via the close callback.
2131 if i.cfg.OnChangelogDismiss != nil {
2132 i.cfg.OnChangelogDismiss()
2133 }
2134 }
2135 i.invalidate()
2136 return false
2137 }
2138
2139 if slot := quickModelShortcutSlot(k); slot > 0 {
2140 i.applyQuickModelShortcut(slot)
2141 return false
2142 }
2143
2144 // Global keys.
2145 switch k.Kind {
2146 case tui.KeyCtrlC:
2147 i.mu.Lock()
2148 loadingSession := i.sessionLoading
2149 i.mu.Unlock()
2150 if loadingSession {
2151 return true
2152 }
2153 // While busy: do NOT cancel the turn. ctrl+c during a
2154 // running turn is almost always reflex muscle memory
2155 // ("be quiet" in a shell) rather than a deliberate
2156 // decision to kill a multi-minute model call that's
2157 // already cost tokens. Use esc to interrupt a turn; use
2158 // a deliberate double-ctrl+c to exit zot entirely. First
2159 // press arms the exit hint, second press within
2160 // ctrlCExitWindow quits.
2161 if i.busy {
2162 if i.ctrlCExitArmed() {
2163 return true
2164 }
2165 i.mu.Lock()
2166 i.statusOK = "press ctrl+c again to exit, esc to cancel the turn"
2167 i.statusErr = ""
2168 i.mu.Unlock()
2169 i.armCtrlCExit()
2170 return false
2171 }
2172 // Idle: first press clears the editor (and any queued
2173 // follow-up messages); a second press within ctrlCExitWindow
2174 // exits. With both an empty editor and no queue the first
2175 // press still just arms — require a deliberate double-tap.
2176 ag := i.agent
2177 pending := 0
2178 if ag != nil {
2179 pending = ag.QueuedMessageCount()
2180 }
2181 hadInput := !i.ed.IsEmpty() || len(i.queued) > 0 || pending > 0
2182 if hadInput {
2183 i.ed.Clear()
2184 i.clipboardImages = nil
2185 i.suggest.Reset()
2186 if ag != nil {
2187 ag.DrainQueuedMessages()
2188 }
2189 i.mu.Lock()
2190 i.queued = nil
2191 i.statusOK = "input cleared"
2192 i.statusErr = ""
2193 i.mu.Unlock()
2194 i.armCtrlCExit()
2195 return false
2196 }
2197 if i.ctrlCExitArmed() {
2198 return true
2199 }
2200 i.mu.Lock()
2201 i.statusOK = "press ctrl+c again to exit"
2202 i.statusErr = ""
2203 i.mu.Unlock()
2204 i.armCtrlCExit()
2205 return false
2206 case tui.KeyEsc:
2207 // Esc interrupts a running turn — but only when nothing
2208 // else on screen wants to consume the key first. The slash
2209 // popup has its own Esc behaviour (close + clear editor),
2210 // and transient overlays like the /help block and extension
2211 // notes should dismiss on Esc before we even consider the
2212 // turn. Without these guards, a casual Esc press after
2213 // running /help on a busy turn rips the turn away.
2214 if i.suggest.Active(i.ed.Value()) || i.fileSuggest.Active(i.ed.Value()) {
2215 break
2216 }
2217 i.mu.Lock()
2218 hadHelp := len(i.helpBlock) > 0
2219 hadNotes := len(i.extNotes) > 0
2220 // Only dismiss a parked shell-escape log on esc when nothing is
2221 // running; if a !command is in flight, esc must fall through to
2222 // the cancel path below instead of just hiding the (empty) block.
2223 hadShell := len(i.shellBlock) > 0 && !i.shellRunning
2224 if hadHelp {
2225 i.helpBlock = nil
2226 }
2227 if hadNotes {
2228 i.extNotes = nil
2229 }
2230 if hadShell {
2231 i.shellBlock = nil
2232 }
2233 i.mu.Unlock()
2234 if hadHelp || hadNotes || hadShell {
2235 i.invalidate()
2236 return false
2237 }
2238 if i.busy && i.cancelTurn != nil {
2239 i.cancelTurn()
2240 // If a confirm dialog is pending, refuse it so the agent
2241 // goroutine unblocks and the context cancellation can
2242 // actually take effect.
2243 i.confirmDialog.CancelAll("turn cancelled")
2244 return false
2245 }
2246 case tui.KeyCtrlD:
2247 if i.ed.IsEmpty() && !i.busy {
2248 return true
2249 }
2250 case tui.KeyCtrlL:
2251 i.rend.Clear()
2252 i.invalidate()
2253 return false
2254 case tui.KeyPasteClipboard:
2255 i.pasteClipboard()
2256 return false
2257 case tui.KeyCtrlO:
2258 i.toggleToolExpansion()
2259 return false
2260 case tui.KeyPageUp:
2261 i.scrollBy(+i.chatPage())
2262 return false
2263 case tui.KeyPageDown:
2264 i.scrollBy(-i.chatPage())
2265 return false
2266 case tui.KeyUp:
2267 // Alt/Option+Up: pop the most recently queued ("sliding in")
2268 // message back into the editor so the user can edit and
2269 // resend it. Repeated presses keep peeling messages off the
2270 // tail of the queue; each press *replaces* the editor
2271 // contents (we don't append/push). When the queue is empty
2272 // the keypress falls through to the normal scroll behavior.
2273 if k.Alt {
2274 i.mu.Lock()
2275 var text string
2276 if i.agent != nil {
2277 text, _ = i.agent.PopQueuedMessage()
2278 }
2279 if text == "" {
2280 if n := len(i.queued); n > 0 {
2281 text = i.queued[n-1]
2282 i.queued = i.queued[:n-1]
2283 }
2284 }
2285 i.mu.Unlock()
2286 if text != "" {
2287 i.ed.SetValue(text)
2288 i.inputHistoryIndex = -1
2289 i.invalidate()
2290 return false
2291 }
2292 }
2293 // In multi-line / wrapped input, Up first moves inside the editor.
2294 // At the editor's top edge, plain Up can browse input history when
2295 // history browsing is safe/active; otherwise it falls back to chat
2296 // scrolling, preserving the old single-line scroll behavior.
2297 if !i.suggest.Active(i.ed.Value()) && !i.fileSuggest.Active(i.ed.Value()) {
2298 if i.ed.MoveVertical(-1) {
2299 i.invalidate()
2300 return false
2301 }
2302 if !k.Alt && i.handleInputHistoryKey(k) {
2303 return false
2304 }
2305 i.scrollBy(+3)
2306 return false
2307 }
2308 case tui.KeyDown:
2309 if !i.suggest.Active(i.ed.Value()) && !i.fileSuggest.Active(i.ed.Value()) {
2310 if i.ed.MoveVertical(+1) {
2311 i.invalidate()
2312 return false
2313 }
2314 if !k.Alt && i.handleInputHistoryKey(k) {
2315 return false
2316 }
2317 if i.scrollOffset > 0 {
2318 i.scrollBy(-3)
2319 }
2320 return false
2321 }
2322 }
2323
2324 // Note: we intentionally do NOT gate the editor on i.busy here.
2325 // Typing while the agent is working is supported — submitted
2326 // messages are queued and delivered as follow-up turns when the
2327 // current turn ends. See the submit handler below.
2328
2329 if k.Kind == tui.KeyEnter && (k.Alt || k.Shift) {
2330 i.ed.HandleKey(k)
2331 return false
2332 }
2333
2334 // Slash suggestions: intercept up/down/tab/enter when the popup is visible.
2335 if i.suggest.Active(i.ed.Value()) {
2336 switch k.Kind {
2337 case tui.KeyUp:
2338 i.suggest.Up()
2339 return false
2340 case tui.KeyDown:
2341 i.suggest.Down()
2342 return false
2343 case tui.KeyPageUp:
2344 i.suggest.PageUp()
2345 return false
2346 case tui.KeyPageDown:
2347 i.suggest.PageDown()
2348 return false
2349 case tui.KeyTab:
2350 if name := i.suggest.Selection(i.ed.Value()); name != "" {
2351 i.ed.SetValue(name)
2352 i.suggest.Reset()
2353 }
2354 return false
2355 case tui.KeyEnter:
2356 // Enter on an ambiguous or partial slash prefix: complete to the
2357 // currently highlighted command and run it. That way typing
2358 // "/lo" + enter picks whichever of /login or /logout is selected
2359 // in the popup instead of submitting "/lo" as unknown. Also
2360 // clear the editor so the command doesn't linger after the
2361 // dialog opens/closes.
2362 if name := i.suggest.Selection(i.ed.Value()); name != "" {
2363 i.ed.Clear()
2364 i.suggest.Reset()
2365 return i.runSlash(ctx, name)
2366 }
2367 case tui.KeyEsc:
2368 i.ed.Clear()
2369 i.suggest.Reset()
2370 return false
2371 }
2372 }
2373
2374 // File suggestions: intercept up/down/tab/enter when the @-popup is visible.
2375 if i.fileSuggest.Active(i.ed.Value()) {
2376 switch k.Kind {
2377 case tui.KeyUp:
2378 i.fileSuggest.Up()
2379 return false
2380 case tui.KeyDown:
2381 i.fileSuggest.Down()
2382 return false
2383 case tui.KeyRight:
2384 // Open selected directory. The filter the user typed picked
2385 // that directory at the current level; once we descend it no
2386 // longer applies to the directory's contents, so clear it.
2387 // Otherwise typing "@eda" then right would re-filter inside
2388 // eda/ by "eda" and show nothing.
2389 if i.fileSuggest.Right() {
2390 i.clearFileSuggestQuery()
2391 }
2392 return false
2393 case tui.KeyLeft:
2394 // Go back to parent directory. Clear the filter for the same
2395 // reason as Right: it was scoped to the level we just left.
2396 if i.fileSuggest.Left() {
2397 i.clearFileSuggestQuery()
2398 }
2399 return false
2400 case tui.KeyEnter:
2401 if entry, ok := i.fileSuggest.SelectedEntry(i.ed.Value()); ok {
2402 var chip string
2403 if entry.isDir {
2404 chip = "[dir:" + entry.rel + "/]"
2405 } else {
2406 chip = "[file:" + entry.rel + "]"
2407 }
2408 val := i.ed.Value()
2409 if idx := strings.LastIndex(val, "@"); idx >= 0 {
2410 val = val[:idx]
2411 }
2412 i.ed.SetValue(val + chip + " ")
2413 i.fileSuggest.Reset()
2414 }
2415 return false
2416 case tui.KeyEsc:
2417 val := i.ed.Value()
2418 if idx := strings.LastIndex(val, "@"); idx >= 0 {
2419 i.ed.SetValue(val[:idx])
2420 }
2421 i.fileSuggest.Reset()
2422 return false
2423 }
2424 }
2425
2426 // Tab-complete a path token in the editor when no popup is open.
2427 // Recognises tokens that look like paths (start with ~, /, ./, ../
2428 // or contain a slash); shell-style completion expands ~, lists the
2429 // parent dir, and completes the basename to the longest common
2430 // prefix. Single match: full replace and trailing / for dirs.
2431 // No match: no-op. Plain bare words (no slash, no tilde) fall
2432 // through so Tab keeps its current no-op behaviour outside paths.
2433 if k.Kind == tui.KeyTab && !i.suggest.Active(i.ed.Value()) && !i.fileSuggest.Active(i.ed.Value()) {
2434 if i.tryPathTabComplete() {
2435 return false
2436 }
2437 }
2438
2439 if i.inputHistoryIndex >= 0 && k.Kind != tui.KeyUp && k.Kind != tui.KeyDown {
2440 i.inputHistoryIndex = -1
2441 }
2442
2443 if k.Kind == tui.KeyEsc {
2444 i.clipboardImages = nil
2445 }
2446 if submit := i.ed.HandleKey(k); submit {
2447 // SubmitValue() expands any [pasted text #N +L lines]
2448 // placeholders back into their bodies; the raw Value()
2449 // is only what the user sees on screen.
2450 text := strings.TrimRight(i.ed.SubmitValue(), "\n")
2451 // Expand [file:name] and [dir:name/] chips to full paths.
2452 text = expandFileChips(text, i.cfg.CWD)
2453 text, images := preparePromptWithClipboardImages(text, i.clipboardImages)
2454 if text == "" && len(images) == 0 {
2455 return false
2456 }
2457 i.clipboardImages = nil
2458 i.ed.Clear()
2459 i.inputHistoryIndex = -1
2460 i.suggest.Reset()
2461 i.fileSuggest.Reset()
2462
2463 if cmd, ok := shellEscapeCommand(text); ok {
2464 i.startShellEscape(ctx, cmd)
2465 return false
2466 }
2467
2468 if looksLikeSlashCommand(text) {
2469 head := text
2470 rest := ""
2471 if idx := strings.IndexAny(text, " \t"); idx >= 0 {
2472 head = text[:idx]
2473 rest = strings.TrimSpace(text[idx:])
2474 }
2475 if !isKnownSlashCommand(text) {
2476 // Try extensions before giving up. Extensions register
2477 // commands by bare name (no leading slash); strip it here.
2478 extName := strings.TrimPrefix(head, "/")
2479 if i.cfg.Extensions != nil && i.cfg.Extensions.HasCommand(extName) {
2480 go i.invokeExtensionCommand(ctx, extName, rest)
2481 return false
2482 }
2483 i.mu.Lock()
2484 i.statusErr = "unknown command " + head + " — type /help to see the list"
2485 i.statusOK = ""
2486 i.mu.Unlock()
2487 return false
2488 }
2489 // Slash commands run regardless of busy state. Commands that
2490 // would mutate the transcript or replace the agent (/clear,
2491 // /compact, /logout, /login, /model) cancel the active turn
2492 // first and wait for the goroutine to wind down so they don't
2493 // race with a streaming response. Safe commands (/help,
2494 // /jump, /sessions, /jail, /unjail, /exit) run immediately
2495 // without disturbing the active turn.
2496 if slashCancelsTurn(head) {
2497 i.cancelAndWaitForIdle()
2498 }
2499 return i.runSlash(ctx, text)
2500 }
2501
2502 if i.agent == nil {
2503 i.mu.Lock()
2504 i.statusErr = "not logged in. type /login first."
2505 i.mu.Unlock()
2506 return false
2507 }
2508 // Mirror the user's typed prompt into the paired Telegram
2509 // chat (when the bridge is active) so the Telegram thread
2510 // stays a complete record of the session, not just the half
2511 // that originated on the phone. On a goroutine so the
2512 // network write doesn't delay the local turn.
2513 if i.telegramBridge != nil && i.telegramBridge.Active() {
2514 go i.telegramBridge.OnUserTyped(text)
2515 }
2516 // If a turn is already in flight, queue this prompt inside the
2517 // agent loop so it is delivered at the next safe model-call
2518 // boundary instead of waiting for the whole run to finish.
2519 i.mu.Lock()
2520 busy := i.busy
2521 ag := i.agent
2522 i.mu.Unlock()
2523 if busy {
2524 if len(images) > 0 {
2525 i.mu.Lock()
2526 i.statusErr = "can't queue clipboard images while a turn is running; wait for the current turn to finish"
2527 i.mu.Unlock()
2528 i.invalidate()
2529 return false
2530 }
2531 if ag != nil {
2532 ag.QueueMessage(text)
2533 } else {
2534 i.mu.Lock()
2535 i.queued = append(i.queued, text)
2536 i.mu.Unlock()
2537 }
2538 i.invalidate()
2539 return false
2540 }
2541 i.startTurnWithImages(ctx, text, images)
2542 }
2543 return false
2544}
2545
2546func (i *Interactive) pasteClipboard() {
2547 _, data, ok, err := tui.ReadClipboardImagePNG()
2548 if err != nil {
2549 i.mu.Lock()
2550 i.statusErr = "clipboard paste failed: " + err.Error()
2551 i.statusOK = ""
2552 i.mu.Unlock()
2553 return
2554 }
2555 if ok {
2556 i.mu.Lock()
2557 marker := fmt.Sprintf("[clipboard image #%d]", len(i.clipboardImages)+1)
2558 i.clipboardImages = append(i.clipboardImages, clipboardImageAttachment{
2559 Marker: marker,
2560 Image: provider.ImageBlock{MimeType: "image/png", Data: data},
2561 })
2562 i.ed.Insert(marker + " ")
2563 i.statusOK = ""
2564 i.statusErr = ""
2565 i.mu.Unlock()
2566 return
2567 }
2568 i.mu.Lock()
2569 i.statusErr = "clipboard does not contain an image"
2570 i.statusOK = ""
2571 i.mu.Unlock()
2572}
2573
2574func (i *Interactive) handleInputHistoryKey(k tui.Key) bool {
2575 if k.Kind != tui.KeyUp && k.Kind != tui.KeyDown {
2576 return false
2577 }
2578 // Do not steal normal vertical cursor movement. History browsing can only
2579 // start from an empty editor; once active, Up/Down keep walking
2580 // the ring so repeated presses work even though the editor now
2581 // contains the selected historical prompt.
2582 if i.inputHistoryIndex < 0 && !i.ed.IsEmpty() {
2583 return false
2584 }
2585 hist := i.inputHistory()
2586 if len(hist) == 0 {
2587 return false
2588 }
2589
2590 if i.inputHistoryIndex < 0 {
2591 // Start just after the newest item so Up lands on the most
2592 // recent user prompt. A lone Down from an empty editor is not
2593 // history navigation; let the caller fall through to normal UI
2594 // behavior instead.
2595 if k.Kind != tui.KeyUp {
2596 return false
2597 }
2598 i.inputHistoryIndex = len(hist)
2599 }
2600
2601 switch k.Kind {
2602 case tui.KeyUp:
2603 if i.inputHistoryIndex > 0 {
2604 i.inputHistoryIndex--
2605 }
2606 case tui.KeyDown:
2607 if i.inputHistoryIndex < len(hist) {
2608 i.inputHistoryIndex++
2609 }
2610 }
2611
2612 if i.inputHistoryIndex >= len(hist) {
2613 i.ed.Clear()
2614 } else {
2615 i.ed.SetValue(hist[i.inputHistoryIndex])
2616 }
2617 return true
2618}
2619
2620func (i *Interactive) inputHistory() []string {
2621 if i.agent == nil {
2622 return nil
2623 }
2624 msgs := i.agent.Messages()
2625 hist := make([]string, 0, len(msgs))
2626 for _, m := range msgs {
2627 if m.Role != provider.RoleUser || isHiddenTranscriptMessage(m) {
2628 continue
2629 }
2630 text := userMessageText(m)
2631 if strings.TrimSpace(text) == "" {
2632 continue
2633 }
2634 hist = append(hist, text)
2635 }
2636 return hist
2637}
2638
2639func userMessageText(m provider.Message) string {
2640 var sb strings.Builder
2641 for _, c := range m.Content {
2642 if tb, ok := c.(provider.TextBlock); ok {
2643 if sb.Len() > 0 {
2644 sb.WriteByte('\n')
2645 }
2646 sb.WriteString(tb.Text)
2647 }
2648 }
2649 return sb.String()
2650}
2651
2652// invokeExtensionCommand fires an extension-registered slash command
2653// in a background goroutine, awaits the response, and applies the
2654// requested action (prompt / insert / display / noop). Errors and
2655// timeouts surface as a status_err line.
2656
2657func (i *Interactive) invokeExtensionCommand(ctx context.Context, name, args string) {
2658 resp, err := i.cfg.Extensions.Invoke(ctx, name, args, 30*time.Second)
2659 if err != nil {
2660 i.mu.Lock()
2661 i.statusErr = "extension /" + name + ": " + err.Error()
2662 i.statusOK = ""
2663 i.mu.Unlock()
2664 i.invalidate()
2665 return
2666 }
2667 if resp.Error != "" {
2668 i.mu.Lock()
2669 i.statusErr = "extension /" + name + ": " + resp.Error
2670 i.statusOK = ""
2671 i.mu.Unlock()
2672 i.invalidate()
2673 return
2674 }
2675 switch resp.Action {
2676 case "open_panel":
2677 if resp.OpenPanel != nil {
2678 extName := name
2679 if i.cfg.Extensions != nil {
2680 if owner := i.cfg.Extensions.CommandOwner(name); owner != "" {
2681 extName = owner
2682 }
2683 }
2684 i.OpenPanel(extName, *resp.OpenPanel)
2685 }
2686 case "prompt":
2687 if strings.TrimSpace(resp.Prompt) == "" {
2688 return
2689 }
2690 i.startTurn(i.runCtx, resp.Prompt)
2691 case "insert":
2692 i.ed.Insert(resp.Insert)
2693 i.invalidate()
2694 case "display":
2695 i.appendExtensionNote(name, resp.Display, "info")
2696 case "noop", "":
2697 // nothing
2698 default:
2699 i.mu.Lock()
2700 i.statusErr = "extension /" + name + ": unknown action " + resp.Action
2701 i.mu.Unlock()
2702 i.invalidate()
2703 }
2704}
2705
2706// appendExtensionNote renders an extension-originated note in the
2707// chat. Levels: "info" (muted), "warn" (warning), "error" (error),
2708// "success" (tool/ok green).
2709func (i *Interactive) appendExtensionNote(extName, msg, level string) {
2710 if msg == "" {
2711 return
2712 }
2713 i.mu.Lock()
2714 defer i.mu.Unlock()
2715 color := i.cfg.Theme.Muted
2716 switch level {
2717 case "warn":
2718 color = i.cfg.Theme.Warning
2719 case "error":
2720 color = i.cfg.Theme.Error
2721 case "success":
2722 color = i.cfg.Theme.Tool
2723 }
2724 prefix := i.cfg.Theme.FG256(i.cfg.Theme.Accent, "["+extName+"] ")
2725 for _, line := range strings.Split(msg, "\n") {
2726 i.statusOK = "" // clear any stale ok
2727 i.statusErr = ""
2728 i.extNotes = append(i.extNotes, prefix+i.cfg.Theme.FG256(color, line))
2729 }
2730}
2731
2732// HostHooks implementation for the extension manager. The manager
2733// holds an interface, not a concrete *Interactive, so these methods
2734// are the only thing the manager sees.
2735
2736// Notify is the manager's NotifyFromExt entry point.
2737func (i *Interactive) Notify(extName, level, message string) {
2738 i.appendExtensionNote(extName, message, level)
2739 i.invalidate()
2740}
2741
2742// ClearNotes removes every note line owned by extName from the
2743// bottom-sticky ext-notes block. Extensions use this to retract a
2744// transient status line (e.g. an approval prompt) once it no longer
2745// applies, instead of leaving it stacked forever. Notes from other
2746// extensions and internal notes (auto-compact) are left untouched.
2747func (i *Interactive) ClearNotes(extName string) {
2748 marker := "[" + extName + "] "
2749 i.mu.Lock()
2750 if len(i.extNotes) == 0 {
2751 i.mu.Unlock()
2752 return
2753 }
2754 kept := i.extNotes[:0:0]
2755 changed := false
2756 for _, line := range i.extNotes {
2757 if strings.Contains(line, marker) {
2758 changed = true
2759 continue
2760 }
2761 kept = append(kept, line)
2762 }
2763 if changed {
2764 i.extNotes = kept
2765 }
2766 i.mu.Unlock()
2767 if changed {
2768 i.invalidate()
2769 }
2770}
2771
2772// Submit feeds text through the agent loop as if the user had typed it.
2773func (i *Interactive) Submit(text string) {
2774 if cmd, ok := shellEscapeCommand(text); ok {
2775 i.startShellEscape(i.runCtx, cmd)
2776 return
2777 }
2778 i.startTurn(i.runCtx, text)
2779}
2780
2781// ApplyChangedCWD is called by the host after a successful /cd hook.
2782// The host has already rebuilt the agent and opened a fresh session
2783// in the new cwd; this method swaps the fresh agent into the running
2784// TUI, updates the displayed cwd, clears the transcript display
2785// caches, and points the file picker at the new directory.
2786//
2787// The fresh agent's transcript is empty (new session) so the chat
2788// view starts blank, matching what relaunching `zot --cwd <path>`
2789// would show. Cost meters reset.
2790func (i *Interactive) ApplyChangedCWD(ag *core.Agent, provider, model, cwd string) {
2791 i.mu.Lock()
2792 i.agent = ag
2793 i.cfg.CWD = cwd
2794 // Re-report the working directory to the terminal so "new tab here"
2795 // tracks the /cd change (OSC 7, see issue #38).
2796 if i.cfg.Terminal != nil {
2797 if seq := tui.ReportCWD(cwd); seq != "" {
2798 _, _ = i.cfg.Terminal.Write([]byte(seq))
2799 }
2800 }
2801 i.cfg.Provider = provider
2802 i.cfg.Model = model
2803 i.toolCalls = map[string]*tui.ToolCallView{}
2804 i.toolOrder = nil
2805 i.toolGate = map[string]int{}
2806 i.helpBlock = nil
2807 i.parkedTurn = 0
2808 i.statusErr = ""
2809 i.mu.Unlock()
2810 i.fileSuggest.Reset()
2811 i.fileSuggest.SetCWD(cwd)
2812 i.invalidate()
2813}
2814
2815// SubmitSlash runs text as a slash command in the TUI as if the user
2816// had typed it. text must start with '/' — callers that hand it
2817// plain prose silently get a no-op so a misbehaving extension can't
2818// run a stray prompt through this path. Read-only commands run in
2819// place; commands that would mutate the transcript or replace the
2820// agent cancel the active turn first via the same path the editor
2821// uses for typed slash commands.
2822func (i *Interactive) SubmitSlash(text string) {
2823 text = strings.TrimSpace(text)
2824 if !strings.HasPrefix(text, "/") {
2825 return
2826 }
2827 head := text
2828 if idx := strings.IndexAny(text, " \t"); idx >= 0 {
2829 head = text[:idx]
2830 }
2831 if slashCancelsTurn(head) {
2832 i.cancelAndWaitForIdle()
2833 }
2834 i.runSlash(i.runCtx, text)
2835 i.invalidate()
2836}
2837
2838// SubmitOrQueue runs text immediately if the agent is idle, or
2839// appends it to the pending queue if a turn is already in flight.
2840// Used by the telegram bridge (and by the editor submit path) so
2841// both input sources share the same "queue behind an active turn"
2842// semantics. Images are ignored for now — only the text prompt is
2843// forwarded — because the queued-prompt path is text-only; a
2844// follow-up can expand the queue entry to carry images.
2845func (i *Interactive) SubmitOrQueue(text string, images []provider.ImageBlock) {
2846 if cmd, ok := shellEscapeCommand(text); ok {
2847 i.startShellEscape(i.runCtx, cmd)
2848 return
2849 }
2850 i.mu.Lock()
2851 if i.agent == nil {
2852 i.statusErr = "not logged in. type /login first."
2853 i.mu.Unlock()
2854 i.invalidate()
2855 return
2856 }
2857 if i.busy {
2858 // Queue text only; images are dropped for queued prompts.
2859 ag := i.agent
2860 i.mu.Unlock()
2861 if ag != nil {
2862 ag.QueueMessage(text)
2863 } else {
2864 i.mu.Lock()
2865 i.queued = append(i.queued, text)
2866 i.mu.Unlock()
2867 }
2868 i.invalidate()
2869 return
2870 }
2871 i.mu.Unlock()
2872 i.startTurnWithImages(i.runCtx, text, images)
2873}
2874
2875// CancelTurn aborts the active turn if one is running. Used by the
2876// telegram bridge when the paired user sends /stop.
2877// ChangelogVersion returns the version string of the changelog
2878// currently shown (or last shown). Used by the dismiss callback
2879// to store the correct version for dev builds.
2880func (i *Interactive) ChangelogVersion() string {
2881 if i.changelogDialog != nil {
2882 return i.changelogDialog.version
2883 }
2884 return ""
2885}
2886
2887func (i *Interactive) CancelTurn() {
2888 i.mu.Lock()
2889 cancel := i.cancelTurn
2890 i.mu.Unlock()
2891 if cancel != nil {
2892 cancel()
2893 i.confirmDialog.CancelAll("turn cancelled")
2894 }
2895}
2896
2897// Insert places text at the cursor in the editor.
2898func (i *Interactive) Insert(text string) {
2899 i.ed.Insert(text)
2900 i.invalidate()
2901}
2902
2903// Display appends a styled note from extName to the chat without a
2904// model call.
2905func (i *Interactive) Display(extName, text string) {
2906 i.appendExtensionNote(extName, text, "info")
2907 i.invalidate()
2908}
2909
2910func (i *Interactive) OpenPanel(extName string, spec extproto.PanelSpec) {
2911 i.mu.Lock()
2912 defer i.mu.Unlock()
2913 i.extPanel.Open(extName, spec.ID, spec.Title, spec.Lines, spec.Footer)
2914 if i.cfg.Extensions != nil {
2915 cols, rows := i.cfg.Terminal.Size()
2916 _ = cols
2917 _ = rows
2918 }
2919 i.invalidate()
2920}
2921
2922func (i *Interactive) UpdatePanel(extName, panelID, title string, lines []string, footer string) {
2923 i.mu.Lock()
2924 defer i.mu.Unlock()
2925 if i.extPanel.Active() && i.extPanel.ext == extName && i.extPanel.id == panelID {
2926 i.extPanel.Update(title, lines, footer)
2927 i.invalidate()
2928 }
2929}
2930
2931func (i *Interactive) ClosePanel(extName, panelID string) {
2932 i.mu.Lock()
2933 defer i.mu.Unlock()
2934 if i.extPanel.Active() && i.extPanel.ext == extName && i.extPanel.id == panelID {
2935 i.extPanel.Close()
2936 i.invalidate()
2937 }
2938}
2939
2940func effectiveImageProtocol(override *bool) tui.ImageProtocol {
2941 detected := tui.DetectImageProtocol()
2942 if override == nil {
2943 return detected
2944 }
2945 if !*override {
2946 return tui.ImageProtocolNone
2947 }
2948 return detected
2949}
2950
2951func imageProtocolName(p tui.ImageProtocol) string {
2952 switch p {
2953 case tui.ImageProtocolKitty:
2954 return "kitty/ghostty"
2955 case tui.ImageProtocolITerm2:
2956 return "iTerm2"
2957 default:
2958 return "none"
2959 }
2960}
2961
2962func onOff(v bool) string {
2963 if v {
2964 return "enabled"
2965 }
2966 return "disabled"
2967}
2968
2969func (i *Interactive) applyInputCursorColor() {
2970 if i == nil || i.cfg.Terminal == nil {
2971 return
2972 }
2973 _, _ = i.cfg.Terminal.Write([]byte(tui.CursorColor256(15) + tui.CursorShapeBlock()))
2974}
2975
2976func (i *Interactive) openSettingsDialog() {
2977 detected := tui.DetectImageProtocol()
2978 imgEnabled := detected != tui.ImageProtocolNone
2979 if i.cfg.InlineImagesEnabled != nil {
2980 imgEnabled = *i.cfg.InlineImagesEnabled
2981 }
2982 imgDisabled := detected == tui.ImageProtocolNone
2983 imgHint := ""
2984 if imgDisabled {
2985 imgEnabled = false
2986 imgHint = "this terminal does not support inline images"
2987 } else {
2988 imgHint = "terminal supports " + imageProtocolName(detected)
2989 }
2990
2991 autoSwarm := false
2992 if i.cfg.AutoSwarmEnabled != nil {
2993 autoSwarm = *i.cfg.AutoSwarmEnabled
2994 }
2995 autoSwarmDisabled := i.cfg.Swarm == nil
2996 autoSwarmHint := ""
2997 if autoSwarmDisabled {
2998 autoSwarm = false
2999 autoSwarmHint = "swarm supervisor not available in this mode"
3000 }
3001
3002 jailByDefault := i.cfg.JailByDefault != nil && *i.cfg.JailByDefault
3003 recursiveFiles := i.cfg.RecursiveFileSuggest != nil && *i.cfg.RecursiveFileSuggest
3004 respectGitignore := i.cfg.RespectGitignore == nil || *i.cfg.RespectGitignore
3005 compactMode := i.compactModeEnabled()
3006 inputStyle := tui.NormalizeInputStyle(i.cfg.TUIInputStyle)
3007 statusPosition := tui.NormalizeStatusPosition(i.cfg.TUIStatusPosition)
3008 workingPosition := tui.NormalizeWorkingPosition(i.cfg.TUIWorkingPosition)
3009 quickItems := i.quickModelSettingItems()
3010
3011 reasoningOptions := []settingsOption{
3012 {value: "", label: "off", desc: "no reasoning"},
3013 {value: "minimum", label: "minimum", desc: "very brief (~1k tokens)"},
3014 {value: "low", label: "low", desc: "light (~2k tokens)"},
3015 {value: "medium", label: "medium", desc: "moderate (~8k tokens)"},
3016 {value: "high", label: "high", desc: "deep (~16k tokens)"},
3017 {value: "xhigh", label: "xhigh", desc: "extra-high effort"},
3018 {value: "max", label: "max", desc: "unconstrained effort on supported models"},
3019 }
3020 reasoning := provider.NormalizeReasoning(i.cfg.Reasoning)
3021 reasoningChoice := 0
3022 for idx, opt := range reasoningOptions {
3023 if opt.value == reasoning {
3024 reasoningChoice = idx
3025 break
3026 }
3027 }
3028 reasoningHint := ""
3029 if m, err := provider.FindModel(i.cfg.Provider, i.cfg.Model); err == nil && !m.Reasoning {
3030 reasoningHint = "current model does not support thinking"
3031 }
3032
3033 themeName := i.cfg.ThemeName
3034 if themeName == "" {
3035 themeName = "auto"
3036 }
3037 if themeName != "auto" && !tui.ThemeExists(i.cfg.ZotHome, themeName) {
3038 themeName = "auto"
3039 i.cfg.ThemeName = ""
3040 if i.cfg.SettingsStore != nil {
3041 _ = i.cfg.SettingsStore.SetTheme("auto")
3042 }
3043 i.applyThemeNow("auto")
3044 }
3045 themeOptions := []settingsOption{}
3046 themeChoice := 0
3047 availableThemes := tui.AvailableThemes(i.cfg.ZotHome)
3048 if i.cfg.ExtensionThemes != nil {
3049 availableThemes = append(availableThemes, i.cfg.ExtensionThemes()...)
3050 }
3051 for idx, opt := range availableThemes {
3052 themeOptions = append(themeOptions, settingsOption{value: opt.Value, label: opt.Label, desc: opt.Description})
3053 if opt.Value == themeName {
3054 themeChoice = idx
3055 }
3056 }
3057
3058 inputStyleOptions := []settingsOption{
3059 {value: tui.InputStylePlain, label: "plain", desc: "render the input as the normal prompt line"},
3060 {value: tui.InputStyleLines, label: "lines", desc: "draw separator lines above and below the input"},
3061 {value: tui.InputStyleBlock, label: "block", desc: "render the input as a user-bubble-style block"},
3062 }
3063 inputStyleChoice := 0
3064 for idx, opt := range inputStyleOptions {
3065 if opt.value == inputStyle {
3066 inputStyleChoice = idx
3067 break
3068 }
3069 }
3070 statusPositionOptions := []settingsOption{
3071 {value: tui.StatusPositionAboveInput, label: "above input", desc: "show model, usage, and working directory above the input"},
3072 {value: tui.StatusPositionBelowInput, label: "below input", desc: "show model, usage, and working directory below the input"},
3073 }
3074 statusPositionChoice := 0
3075 for idx, opt := range statusPositionOptions {
3076 if opt.value == statusPosition {
3077 statusPositionChoice = idx
3078 break
3079 }
3080 }
3081
3082 workingPositionOptions := []settingsOption{
3083 {value: tui.WorkingPositionAboveInput, label: "above input", desc: "show the working spinner above the input"},
3084 {value: tui.WorkingPositionBelowInput, label: "below input", desc: "show the working spinner below the input"},
3085 }
3086 workingPositionChoice := 0
3087 for idx, opt := range workingPositionOptions {
3088 if opt.value == workingPosition {
3089 workingPositionChoice = idx
3090 break
3091 }
3092 }
3093
3094 items := []settingsItem{
3095 {
3096 key: "inline_images_enabled",
3097 label: "render images when supported",
3098 desc: "draw screenshots inline instead of showing a text placeholder",
3099 value: imgEnabled,
3100 disabled: imgDisabled,
3101 hint: imgHint,
3102 },
3103 {
3104 key: "auto_swarm_enabled",
3105 label: "auto-swarm",
3106 desc: "let the agent spawn background sub-agents in parallel via the swarm_spawn tool",
3107 value: autoSwarm,
3108 disabled: autoSwarmDisabled,
3109 hint: autoSwarmHint,
3110 },
3111 {
3112 key: "jail_by_default",
3113 label: "jail new sessions by default",
3114 desc: "confine tools to the session working directory unless /unjail is used",
3115 value: jailByDefault,
3116 },
3117 {
3118 key: "recursive_file_suggest",
3119 label: "recursive @-file search",
3120 desc: "fuzzy-search the whole project tree when picking files with @ instead of browsing one directory at a time",
3121 value: recursiveFiles,
3122 },
3123 {
3124 key: "respect_gitignore",
3125 label: "hide gitignored files in @-picker",
3126 desc: "skip files and directories matched by the project's root .gitignore (and .git) when picking files with @",
3127 value: respectGitignore,
3128 },
3129 {
3130 key: "compact_mode",
3131 label: "compact transcript rendering",
3132 desc: "reduce visual chrome by rendering tool calls without boxes and sent messages without padded bubbles",
3133 value: compactMode,
3134 },
3135 {
3136 key: "tui_settings",
3137 label: "tui settings",
3138 desc: "choose input chrome and where status information appears",
3139 children: []settingsItem{
3140 {
3141 key: "tui_input_style",
3142 label: "input style",
3143 desc: "choose between the plain prompt, lines, and a block input area",
3144 options: inputStyleOptions,
3145 choice: inputStyleChoice,
3146 },
3147 {
3148 key: "tui_status_position",
3149 label: "status position",
3150 desc: "place model, usage, and working directory above or below the input",
3151 options: statusPositionOptions,
3152 choice: statusPositionChoice,
3153 },
3154 {
3155 key: "tui_working_position",
3156 label: "working spinner position",
3157 desc: "place the working spinner above or below the input",
3158 options: workingPositionOptions,
3159 choice: workingPositionChoice,
3160 },
3161 },
3162 },
3163 {
3164 key: "reasoning",
3165 label: "thinking level",
3166 desc: "reasoning depth for thinking-capable models",
3167 options: reasoningOptions,
3168 choice: reasoningChoice,
3169 hint: reasoningHint,
3170 },
3171 {
3172 key: "theme",
3173 label: "color theme",
3174 desc: "choose a theme from $ZOT_HOME/themes or a loaded extension",
3175 options: themeOptions,
3176 choice: themeChoice,
3177 },
3178 }
3179 if len(quickItems) > 0 {
3180 items = append(items, settingsItem{
3181 key: "quick_models",
3182 label: "model shortcuts",
3183 desc: "configure " + quickModelShortcutPrefix() + "+1 through " + quickModelShortcutPrefix() + "+9 quick model switches",
3184 children: quickItems,
3185 })
3186 }
3187 i.settingsDialog.Open(items)
3188}
3189
3190func (i *Interactive) applySettingChange(act settingsAction) {
3191 switch {
3192 case strings.HasPrefix(act.Key, "quick_model_"):
3193 i.applyQuickModelSetting(act.Key, act.StringValue)
3194 case act.Key == "reasoning":
3195 i.applyReasoningSetting(act.StringValue)
3196 case act.Key == "theme":
3197 i.applyThemeSetting(act.StringValue)
3198 case act.Key == "tui_input_style":
3199 i.applyTUIInputStyleSetting(act.StringValue)
3200 case act.Key == "tui_status_position":
3201 i.applyTUIStatusPositionSetting(act.StringValue)
3202 case act.Key == "tui_working_position":
3203 i.applyTUIWorkingPositionSetting(act.StringValue)
3204 default:
3205 i.applySettingToggle(act.Key, act.Value)
3206 }
3207}
3208
3209func (i *Interactive) quickModelSettingItems() []settingsItem {
3210 if len(i.cfg.QuickModelShortcuts) < 9 {
3211 next := make([]QuickModelShortcut, 9)
3212 copy(next, i.cfg.QuickModelShortcuts)
3213 i.cfg.QuickModelShortcuts = next
3214 }
3215 items := make([]settingsItem, 0, 9)
3216 for slot := 1; slot <= 9; slot++ {
3217 items = append(items, i.quickModelSettingItem(slot))
3218 }
3219 return items
3220}
3221
3222func (i *Interactive) quickModelSettingItem(slot int) settingsItem {
3223 current := QuickModelShortcut{}
3224 if slot >= 1 && len(i.cfg.QuickModelShortcuts) >= slot {
3225 current = i.cfg.QuickModelShortcuts[slot-1]
3226 }
3227 hint := "not assigned"
3228 if current.Provider != "" && current.Model != "" {
3229 hint = current.Provider + " / " + current.Model
3230 }
3231 return settingsItem{
3232 key: "quick_model_" + strconv.Itoa(slot),
3233 label: "model " + strconv.Itoa(slot),
3234 desc: quickModelShortcutLabel(slot) + " switches to this model. Enter opens the /model selector, Backspace clears.",
3235 picker: true,
3236 hint: hint,
3237 }
3238}
3239
3240func (i *Interactive) compactModeEnabled() bool {
3241 return i.cfg.CompactMode != nil && *i.cfg.CompactMode
3242}
3243
3244func quickModelShortcutSlot(k tui.Key) int {
3245 if k.Kind != tui.KeyRune || k.Rune < '1' || k.Rune > '9' {
3246 return 0
3247 }
3248 if runtime.GOOS == "darwin" {
3249 if !k.Super && !k.Ctrl {
3250 return 0
3251 }
3252 } else if !k.Ctrl {
3253 return 0
3254 }
3255 return int(k.Rune - '0')
3256}
3257
3258func quickModelShortcutPrefix() string {
3259 return "Ctrl"
3260}
3261
3262func quickModelShortcutLabel(slot int) string {
3263 return quickModelShortcutPrefix() + "+" + strconv.Itoa(slot)
3264}
3265
3266func (i *Interactive) openQuickModelPicker(slot int) {
3267 if slot < 1 || slot > 9 {
3268 return
3269 }
3270 i.quickModelAssign = slot
3271 current := i.cfg.Model
3272 if len(i.cfg.QuickModelShortcuts) >= slot && i.cfg.QuickModelShortcuts[slot-1].Model != "" {
3273 current = i.cfg.QuickModelShortcuts[slot-1].Model
3274 }
3275 var loggedIn []string
3276 if i.cfg.LoggedInProviders != nil {
3277 loggedIn = i.cfg.LoggedInProviders()
3278 }
3279 i.modelDialog.Open(current, loggedIn)
3280}
3281
3282func (i *Interactive) applyQuickModelSelection(slot int, providerName, model string) {
3283 i.setQuickModelShortcut(slot, providerName, model)
3284}
3285
3286func (i *Interactive) applyQuickModelShortcut(slot int) {
3287 if slot < 1 || slot > 9 {
3288 return
3289 }
3290 if i.busy {
3291 i.mu.Lock()
3292 i.statusErr = "cannot switch model while a turn is running"
3293 i.statusOK = ""
3294 i.mu.Unlock()
3295 i.invalidate()
3296 return
3297 }
3298 if len(i.cfg.QuickModelShortcuts) < slot {
3299 i.mu.Lock()
3300 i.statusErr = quickModelShortcutLabel(slot) + " is not assigned"
3301 i.statusOK = ""
3302 i.mu.Unlock()
3303 i.invalidate()
3304 return
3305 }
3306 shortcut := i.cfg.QuickModelShortcuts[slot-1]
3307 if shortcut.Provider == "" || shortcut.Model == "" {
3308 i.mu.Lock()
3309 i.statusErr = quickModelShortcutLabel(slot) + " is not assigned"
3310 i.statusOK = ""
3311 i.mu.Unlock()
3312 i.invalidate()
3313 return
3314 }
3315 i.swapModel(shortcut.Provider, shortcut.Model, i.cfg.BuildAgentFor, false)
3316 i.invalidate()
3317}
3318
3319func (i *Interactive) applyQuickModelSetting(key, value string) {
3320 slotText := strings.TrimPrefix(key, "quick_model_")
3321 slot, err := strconv.Atoi(slotText)
3322 if err != nil || slot < 1 || slot > 9 {
3323 return
3324 }
3325 providerName, model := "", ""
3326 if value != "" {
3327 parts := strings.SplitN(value, "\t", 2)
3328 if len(parts) == 2 {
3329 providerName, model = parts[0], parts[1]
3330 }
3331 }
3332 i.setQuickModelShortcut(slot, providerName, model)
3333}
3334
3335func (i *Interactive) setQuickModelShortcut(slot int, providerName, model string) {
3336 if len(i.cfg.QuickModelShortcuts) < slot {
3337 next := make([]QuickModelShortcut, slot)
3338 copy(next, i.cfg.QuickModelShortcuts)
3339 i.cfg.QuickModelShortcuts = next
3340 }
3341 i.cfg.QuickModelShortcuts[slot-1] = QuickModelShortcut{Provider: providerName, Model: model}
3342 if i.cfg.SettingsStore != nil {
3343 if err := i.cfg.SettingsStore.SetQuickModelShortcut(slot, providerName, model); err != nil {
3344 i.mu.Lock()
3345 i.statusErr = "settings: " + err.Error()
3346 i.mu.Unlock()
3347 return
3348 }
3349 }
3350 i.mu.Lock()
3351 if model == "" {
3352 i.statusOK = quickModelShortcutLabel(slot) + " cleared"
3353 } else {
3354 i.statusOK = quickModelShortcutLabel(slot) + " set to " + providerName + " / " + model
3355 }
3356 i.statusErr = ""
3357 i.mu.Unlock()
3358 i.refreshQuickModelSettingsItem(slot)
3359 i.invalidate()
3360}
3361
3362func (i *Interactive) refreshQuickModelSettingsItem(slot int) {
3363 if i.settingsDialog == nil || !i.settingsDialog.Active() || len(i.settingsDialog.items) == 0 {
3364 return
3365 }
3366 key := "quick_model_" + strconv.Itoa(slot)
3367 for idx, it := range i.settingsDialog.items {
3368 if it.key == key {
3369 i.settingsDialog.items[idx] = i.quickModelSettingItem(slot)
3370 return
3371 }
3372 }
3373}
3374
3375func (i *Interactive) applySettingToggle(key string, value bool) {
3376 // Every setting toggle forces a full repaint at the end — same
3377 // effect as the user pressing Ctrl+L — so any per-setting visual
3378 // change (image rendering, status copy, future toggles) lands
3379 // immediately instead of waiting for the next diff frame.
3380 defer func() {
3381 if i.rend != nil {
3382 i.rend.Clear()
3383 }
3384 i.invalidate()
3385 }()
3386 switch key {
3387 case "inline_images_enabled":
3388 val := value
3389 i.cfg.InlineImagesEnabled = &val
3390 if i.cfg.SettingsStore != nil {
3391 if err := i.cfg.SettingsStore.SetInlineImages(value); err != nil {
3392 i.mu.Lock()
3393 i.statusErr = "settings: " + err.Error()
3394 i.mu.Unlock()
3395 return
3396 }
3397 }
3398 i.mu.Lock()
3399 i.view.ImageProto = effectiveImageProtocol(i.cfg.InlineImagesEnabled)
3400 i.view.InvalidateRenderCache()
3401 i.statusOK = "inline image rendering " + onOff(value)
3402 i.statusErr = ""
3403 i.mu.Unlock()
3404 case "auto_swarm_enabled":
3405 val := value
3406 i.cfg.AutoSwarmEnabled = &val
3407 if i.cfg.SettingsStore != nil {
3408 if err := i.cfg.SettingsStore.SetAutoSwarm(value); err != nil {
3409 i.mu.Lock()
3410 i.statusErr = "settings: " + err.Error()
3411 i.mu.Unlock()
3412 return
3413 }
3414 }
3415 // Add/remove the swarm_spawn tool on the live agent so the
3416 // model's tools[] list reflects the toggle on the next turn.
3417 // Without this the tool stays advertised after a disable and
3418 // the model keeps trying to call it.
3419 i.applyAutoSwarmTool(value)
3420 // Also swap the system-prompt addendum in/out so the model
3421 // knows to use the tool proactively (or stops referencing it
3422 // after a disable).
3423 i.applyAutoSwarmSystemPrompt(value)
3424 i.mu.Lock()
3425 i.statusOK = "auto-swarm " + onOff(value)
3426 i.statusErr = ""
3427 i.mu.Unlock()
3428 case "jail_by_default":
3429 val := value
3430 i.cfg.JailByDefault = &val
3431 if i.cfg.SettingsStore != nil {
3432 if err := i.cfg.SettingsStore.SetJailByDefault(value); err != nil {
3433 i.mu.Lock()
3434 i.statusErr = "settings: " + err.Error()
3435 i.mu.Unlock()
3436 return
3437 }
3438 }
3439 if i.cfg.Sandbox != nil {
3440 if value {
3441 i.cfg.Sandbox.Lock()
3442 } else {
3443 i.cfg.Sandbox.Unlock()
3444 }
3445 }
3446 i.mu.Lock()
3447 i.statusOK = "jail by default " + onOff(value)
3448 i.statusErr = ""
3449 i.mu.Unlock()
3450 case "recursive_file_suggest":
3451 val := value
3452 i.cfg.RecursiveFileSuggest = &val
3453 if i.cfg.SettingsStore != nil {
3454 if err := i.cfg.SettingsStore.SetRecursiveFileSuggest(value); err != nil {
3455 i.mu.Lock()
3456 i.statusErr = "settings: " + err.Error()
3457 i.mu.Unlock()
3458 return
3459 }
3460 }
3461 // Flip the live picker so the next @ reflects the new mode
3462 // without restarting zot. SetRecursive drops its cache.
3463 i.fileSuggest.SetRecursive(value)
3464 i.mu.Lock()
3465 i.statusOK = "recursive @-file search " + onOff(value)
3466 i.statusErr = ""
3467 i.mu.Unlock()
3468 case "respect_gitignore":
3469 val := value
3470 i.cfg.RespectGitignore = &val
3471 if i.cfg.SettingsStore != nil {
3472 if err := i.cfg.SettingsStore.SetRespectGitignore(value); err != nil {
3473 i.mu.Lock()
3474 i.statusErr = "settings: " + err.Error()
3475 i.mu.Unlock()
3476 return
3477 }
3478 }
3479 i.fileSuggest.SetRespectGitignore(value)
3480 i.mu.Lock()
3481 i.statusOK = "hide gitignored files in @-picker " + onOff(value)
3482 i.statusErr = ""
3483 i.mu.Unlock()
3484 case "compact_mode":
3485 val := value
3486 i.cfg.CompactMode = &val
3487 if i.cfg.SettingsStore != nil {
3488 if err := i.cfg.SettingsStore.SetCompactMode(value); err != nil {
3489 i.mu.Lock()
3490 i.statusErr = "settings: " + err.Error()
3491 i.mu.Unlock()
3492 return
3493 }
3494 }
3495 i.mu.Lock()
3496 i.view.CompactMode = value
3497 i.view.InvalidateRenderCache()
3498 i.statusOK = "compact transcript rendering " + onOff(value)
3499 i.statusErr = ""
3500 i.mu.Unlock()
3501 }
3502}
3503
3504func (i *Interactive) applyTUIInputStyleSetting(style string) {
3505 defer func() {
3506 if i.rend != nil {
3507 i.rend.Clear()
3508 }
3509 i.invalidate()
3510 }()
3511 style = tui.NormalizeInputStyle(style)
3512 i.cfg.TUIInputStyle = style
3513 i.applyInputCursorColor()
3514 if i.cfg.SettingsStore != nil {
3515 if err := i.cfg.SettingsStore.SetTUIInputStyle(style); err != nil {
3516 i.mu.Lock()
3517 i.statusErr = "settings: " + err.Error()
3518 i.mu.Unlock()
3519 return
3520 }
3521 }
3522 i.mu.Lock()
3523 i.statusOK = "input style " + style
3524 i.statusErr = ""
3525 i.mu.Unlock()
3526}
3527
3528func (i *Interactive) applyTUIStatusPositionSetting(position string) {
3529 defer func() {
3530 if i.rend != nil {
3531 i.rend.Clear()
3532 }
3533 i.invalidate()
3534 }()
3535 position = tui.NormalizeStatusPosition(position)
3536 i.cfg.TUIStatusPosition = position
3537 if i.cfg.SettingsStore != nil {
3538 if err := i.cfg.SettingsStore.SetTUIStatusPosition(position); err != nil {
3539 i.mu.Lock()
3540 i.statusErr = "settings: " + err.Error()
3541 i.mu.Unlock()
3542 return
3543 }
3544 }
3545 i.mu.Lock()
3546 label := "above input"
3547 if position == tui.StatusPositionBelowInput {
3548 label = "below input"
3549 }
3550 i.statusOK = "status position " + label
3551 i.statusErr = ""
3552 i.mu.Unlock()
3553}
3554
3555func (i *Interactive) applyTUIWorkingPositionSetting(position string) {
3556 defer func() {
3557 if i.rend != nil {
3558 i.rend.Clear()
3559 }
3560 i.invalidate()
3561 }()
3562 position = tui.NormalizeWorkingPosition(position)
3563 i.cfg.TUIWorkingPosition = position
3564 if i.cfg.SettingsStore != nil {
3565 if err := i.cfg.SettingsStore.SetTUIWorkingPosition(position); err != nil {
3566 i.mu.Lock()
3567 i.statusErr = "settings: " + err.Error()
3568 i.mu.Unlock()
3569 return
3570 }
3571 }
3572 i.mu.Lock()
3573 label := "above input"
3574 if position == tui.WorkingPositionBelowInput {
3575 label = "below input"
3576 }
3577 i.statusOK = "working spinner position " + label
3578 i.statusErr = ""
3579 i.mu.Unlock()
3580}
3581
3582func (i *Interactive) applyThemeSetting(name string) {
3583 if i.cfg.SettingsStore != nil {
3584 if err := i.cfg.SettingsStore.SetTheme(name); err != nil {
3585 i.mu.Lock()
3586 i.statusErr = "settings: " + err.Error()
3587 i.mu.Unlock()
3588 return
3589 }
3590 }
3591 i.cfg.ThemeName = name
3592 if name == "auto" {
3593 i.cfg.ThemeName = ""
3594 }
3595 i.applyThemeNow(name)
3596}
3597
3598func (i *Interactive) applyThemeNow(name string) {
3599 if name == "" {
3600 name = "auto"
3601 }
3602 detected := tui.Dark
3603 if tui.IsLightTheme(i.cfg.Theme) {
3604 detected = tui.Light
3605 }
3606 th, applied, err := tui.LoadThemeFromHome(i.cfg.ZotHome, name, detected)
3607 if err != nil {
3608 if i.cfg.SettingsStore != nil {
3609 _ = i.cfg.SettingsStore.SetTheme("auto")
3610 }
3611 i.cfg.ThemeName = ""
3612 th, applied, _ = tui.LoadThemeFromHome(i.cfg.ZotHome, "auto", detected)
3613 i.mu.Lock()
3614 i.statusErr = "theme missing; reset to default"
3615 i.mu.Unlock()
3616 } else {
3617 i.mu.Lock()
3618 label := applied
3619 if label == "" {
3620 label = "auto"
3621 }
3622 i.statusOK = "theme " + label
3623 i.statusErr = ""
3624 i.mu.Unlock()
3625 }
3626 i.cfg.Theme = th
3627 i.view.Theme = th
3628 i.view.InvalidateRenderCache()
3629 i.ed.Prompt = th.AccentBar(th.Accent)
3630 i.applyInputCursorColor()
3631 i.spin.Configure(th)
3632 if i.rend != nil {
3633 i.rend.SetTheme(th)
3634 i.rend.Clear()
3635 }
3636 i.invalidate()
3637}
3638
3639func (i *Interactive) applyReasoningSetting(level string) {
3640 defer func() {
3641 if i.rend != nil {
3642 i.rend.Clear()
3643 }
3644 i.invalidate()
3645 }()
3646 level = provider.NormalizeReasoning(level)
3647 i.cfg.Reasoning = level
3648 if i.cfg.SettingsStore != nil {
3649 if err := i.cfg.SettingsStore.SetReasoning(level); err != nil {
3650 i.mu.Lock()
3651 i.statusErr = "settings: " + err.Error()
3652 i.mu.Unlock()
3653 return
3654 }
3655 }
3656 i.mu.Lock()
3657 if i.agent != nil {
3658 i.agent.Reasoning = level
3659 }
3660 label := level
3661 if label == "" {
3662 label = "off"
3663 }
3664 i.statusOK = "thinking level " + label
3665 i.statusErr = ""
3666 i.mu.Unlock()
3667}
3668
3669// buildStudyPrompt returns the canned prompt the /study command
3670// submits to the agent.
3671//
3672// With no argument, /study targets the current directory — the
3673// historical behaviour. With an argument, /study targets that path
3674// instead; either a directory ("read every file in here") or a
3675// single file ("read this file"). The argument can be:
3676//
3677// - a relative path (resolved against cwd)
3678// - an absolute path
3679// - an @-picker chip, which has already been expanded to an
3680// absolute path by expandFileChips before runSlash sees it
3681//
3682// The path is stat'd to pick the right wording ("directory" vs
3683// "file"). If the path doesn't exist, we still build a sensible
3684// prompt rather than erroring — the agent will surface the
3685// missing-file failure itself when it tries to read it, which is
3686// more useful than a refusal here.
3687func buildStudyPrompt(arg, cwd string) string {
3688 arg = strings.TrimSpace(arg)
3689 if arg == "" {
3690 return "Read and understand everything in the current directory."
3691 }
3692 abs := arg
3693 if !filepath.IsAbs(abs) {
3694 abs = filepath.Join(cwd, abs)
3695 }
3696 display := arg
3697 if rel, err := filepath.Rel(cwd, abs); err == nil && !strings.HasPrefix(rel, "..") {
3698 display = rel
3699 }
3700 if info, err := os.Stat(abs); err == nil && !info.IsDir() {
3701 return "Read and understand the file " + display + "."
3702 }
3703 return "Read and understand everything in the directory " + display + "."
3704}
3705
3706// tryPathTabCompleteEditor looks at ed's current value, finds the
3707// path-like token immediately before the cursor (the cursor is always
3708// at the end of the buffer after a keystroke, so "before the cursor"
3709// is the trailing non-whitespace run), and rewrites it to its shell-
3710// style completion against the filesystem.
3711//
3712// Returns true when it consumed the Tab keystroke (token recognised,
3713// completion attempted — even if no candidates matched, the keystroke
3714// is still consumed so it doesn't insert a literal tab character).
3715// Returns false when the token doesn't look like a path; callers then
3716// let Tab fall through to its normal no-op.
3717//
3718// Recognised path shapes:
3719// - ~ or ~/foo expanded via os.UserHomeDir()
3720// - /abs/path or /abs/path/foo absolute
3721// - ./foo, ../foo, foo/bar relative to cwd
3722//
3723// A bare word like "hello" is not treated as a path so plain text
3724// keeps Tab as a literal no-op.
3725//
3726// Free function (not a method) so the same logic runs against the
3727// editor instances owned by btwDialog and swarmDialog without each
3728// dialog needing its own copy.
3729func tryPathTabCompleteEditor(ed *tui.Editor, cwd string) bool {
3730 if ed == nil {
3731 return false
3732 }
3733 val := ed.Value()
3734 // Find the trailing run of non-whitespace.
3735 start := len(val)
3736 for start > 0 {
3737 r := val[start-1]
3738 if r == ' ' || r == '\t' || r == '\n' {
3739 break
3740 }
3741 start--
3742 }
3743 token := val[start:]
3744 if token == "" {
3745 return false
3746 }
3747 if !looksLikePathToken(token) {
3748 return false
3749 }
3750
3751 // Resolve the absolute parent directory + base prefix to match.
3752 parentAbs, basePrefix, displayParent, ok := resolvePathTabToken(token, cwd)
3753 if !ok {
3754 return true
3755 }
3756 entries, err := os.ReadDir(parentAbs)
3757 if err != nil {
3758 return true
3759 }
3760 var names []string
3761 var isDir []bool
3762 for _, e := range entries {
3763 name := e.Name()
3764 if !strings.HasPrefix(name, basePrefix) {
3765 continue
3766 }
3767 // Hide dotfiles unless the user explicitly typed a leading dot,
3768 // mirroring bash's default behaviour.
3769 if strings.HasPrefix(name, ".") && !strings.HasPrefix(basePrefix, ".") {
3770 continue
3771 }
3772 names = append(names, name)
3773 isDir = append(isDir, e.IsDir())
3774 }
3775 if len(names) == 0 {
3776 return true
3777 }
3778
3779 var completed string
3780 var completedIsDir bool
3781 if len(names) == 1 {
3782 completed = names[0]
3783 completedIsDir = isDir[0]
3784 } else {
3785 completed = longestCommonPrefix(names)
3786 if completed == basePrefix {
3787 // Already at the deepest unambiguous prefix; nothing to add.
3788 return true
3789 }
3790 }
3791
3792 // Build the replacement token in the same display form the user
3793 // typed (preserve ~ vs absolute vs relative).
3794 newToken := displayParent + completed
3795 if len(names) == 1 && completedIsDir {
3796 newToken += "/"
3797 }
3798
3799 ed.SetValue(val[:start] + newToken)
3800 return true
3801}
3802
3803// tryPathTabComplete is the Interactive-bound convenience wrapper.
3804// It calls the free helper against the main editor and invalidates
3805// the frame on a successful rewrite.
3806func (i *Interactive) tryPathTabComplete() bool {
3807 if tryPathTabCompleteEditor(i.ed, i.cfg.CWD) {
3808 i.invalidate()
3809 return true
3810 }
3811 return false
3812}
3813
3814// looksLikePathToken reports whether tok is shaped like a filesystem
3815// path. Paths must either start with ~, /, ./, ../ or contain a /.
3816// Plain words are excluded so Tab on "hello" stays a no-op.
3817func looksLikePathToken(tok string) bool {
3818 if tok == "" {
3819 return false
3820 }
3821 if tok[0] == '~' || tok[0] == '/' {
3822 return true
3823 }
3824 if strings.HasPrefix(tok, "./") || strings.HasPrefix(tok, "../") {
3825 return true
3826 }
3827 return strings.Contains(tok, "/")
3828}
3829
3830// resolvePathTabToken splits tok into (absolute parent dir, basename
3831// prefix to match, display-form parent the user typed). ok is false
3832// when the parent dir can't be resolved (e.g. ~ with no $HOME).
3833func resolvePathTabToken(tok, cwd string) (parentAbs, basePrefix, displayParent string, ok bool) {
3834 // Detect ~ expansion.
3835 expanded := tok
3836 homePrefix := ""
3837 if tok == "~" {
3838 home, err := os.UserHomeDir()
3839 if err != nil || home == "" {
3840 return "", "", "", false
3841 }
3842 // "~" alone: complete in $HOME. parent = home, base = "".
3843 return home, "", "~/", true
3844 }
3845 if strings.HasPrefix(tok, "~/") {
3846 home, err := os.UserHomeDir()
3847 if err != nil || home == "" {
3848 return "", "", "", false
3849 }
3850 expanded = home + tok[1:]
3851 homePrefix = "~"
3852 }
3853
3854 dir, base := splitDirBase(expanded)
3855 if !filepath.IsAbs(dir) {
3856 dir = filepath.Join(cwd, dir)
3857 }
3858
3859 // Reconstruct the display form the user typed for the parent,
3860 // keeping ~ when they used it. The base is dropped — the caller
3861 // substitutes the completed name.
3862 display := tok[:len(tok)-len(base)]
3863 if homePrefix != "" && !strings.HasPrefix(display, "~") {
3864 display = homePrefix + display[len(homePrefix):]
3865 }
3866 return dir, base, display, true
3867}
3868
3869// splitDirBase is like filepath.Split but preserves the trailing
3870// slash convention: "foo" => (".", "foo"); "foo/" => ("foo", "");
3871// "a/b" => ("a/", "b"); "/" => ("/", ""). Returned dir always has
3872// the trailing separator when non-empty so callers can rebuild paths
3873// by concatenation.
3874func splitDirBase(p string) (dir, base string) {
3875 if p == "" {
3876 return ".", ""
3877 }
3878 i := strings.LastIndex(p, "/")
3879 if i < 0 {
3880 return ".", p
3881 }
3882 return p[:i+1], p[i+1:]
3883}
3884
3885func longestCommonPrefix(ss []string) string {
3886 if len(ss) == 0 {
3887 return ""
3888 }
3889 prefix := ss[0]
3890 for _, s := range ss[1:] {
3891 n := 0
3892 for n < len(prefix) && n < len(s) && prefix[n] == s[n] {
3893 n++
3894 }
3895 prefix = prefix[:n]
3896 if prefix == "" {
3897 return ""
3898 }
3899 }
3900 return prefix
3901}
3902
3903func (i *Interactive) runSlash(ctx context.Context, cmd string) (done bool) {
3904 parts := strings.Fields(cmd)
3905 switch parts[0] {
3906 case "/exit":
3907 return true
3908 case "/clear":
3909 if i.agent != nil {
3910 i.agent.SetMessages(nil)
3911 }
3912 i.mu.Lock()
3913 i.toolCalls = map[string]*tui.ToolCallView{}
3914 i.toolOrder = nil
3915 i.toolGate = map[string]int{}
3916 i.statusErr = ""
3917 i.statusOK = ""
3918 i.helpBlock = nil
3919 i.parkedTurn = 0
3920 i.parkedTotal = 0
3921 i.scrollOffset = 0
3922 i.extNotes = nil
3923 i.shellBlock = nil
3924 i.view.InvalidateRenderCache()
3925 i.mu.Unlock()
3926 case "/help":
3927 i.mu.Lock()
3928 i.helpBlock = renderHelpBlock(i.cfg.Theme, i.lastCols(), i.llamaConfigured)
3929 i.statusErr = ""
3930 i.statusOK = ""
3931 // Pin the viewport to the newest content so the help block,
3932 // which we just appended to the end of the transcript, is
3933 // what the user actually sees.
3934 i.scrollOffset = 0
3935 i.mu.Unlock()
3936 case "/login":
3937 i.dialog.Open(i.cfg.ZotHome)
3938 case "/logout":
3939 if len(parts) >= 2 {
3940 // Explicit target: /logout anthropic | openai | all
3941 i.doLogout(parts[1])
3942 break
3943 }
3944 // No arg: open the picker over whichever providers are
3945 // currently logged in. If nothing's logged in, bail with a
3946 // status line.
3947 i.openLogoutDialog()
3948 case "/model":
3949 if len(parts) >= 2 {
3950 i.applyModelSelection("", parts[1])
3951 } else if i.llamaConfigured && i.cfg.RefreshLlamaCPPModels != nil {
3952 i.mu.Lock()
3953 i.statusOK = "refreshing models"
3954 i.statusErr = ""
3955 i.mu.Unlock()
3956 go func() {
3957 refreshCtx, cancel := context.WithTimeout(ctx, 3*time.Second)
3958 defer cancel()
3959 i.modelRefresh <- modelRefreshResult{err: i.cfg.RefreshLlamaCPPModels(refreshCtx)}
3960 }()
3961 } else {
3962 i.openModelPickerAfterRefresh(nil)
3963 }
3964 case "/llama":
3965 if i.cfg.LlamaCPPConfig == nil {
3966 i.mu.Lock()
3967 i.statusErr = "llama.cpp configuration is unavailable"
3968 i.statusOK = ""
3969 i.mu.Unlock()
3970 break
3971 }
3972 baseURL, apiKey, configErr := i.cfg.LlamaCPPConfig()
3973 if configErr == nil {
3974 configErr = i.llamaDialog.Open(baseURL, apiKey, i.invalidate)
3975 }
3976 if configErr != nil {
3977 i.mu.Lock()
3978 i.statusErr = configErr.Error()
3979 i.statusOK = ""
3980 i.mu.Unlock()
3981 }
3982 case "/settings":
3983 i.openSettingsDialog()
3984 case "/sessions":
3985 i.sessionDialog.Open(i.cfg.ZotHome, i.cfg.CWD)
3986 case "/jump":
3987 i.openJumpDialog(parts[1:])
3988 case "/btw":
3989 i.openBtwDialog(parts[1:])
3990 case "/skills":
3991 i.openSkillsDialog()
3992 case "/compact":
3993 i.runCompact(ctx, false)
3994 case "/study":
3995 // Canned prompt that tells the agent to read every file
3996 // in some target so its later turns have the whole thing
3997 // in context. With no argument, the target is the current
3998 // directory. With an argument, the target is whatever the
3999 // user passed — typed by hand, drag-dropped, or selected
4000 // via the @ file picker (which is why we accept both files
4001 // and directories; the @-picker chips for both have already
4002 // been expanded to absolute paths by expandFileChips above).
4003 // Dispatched through the normal queue-or-start path so it
4004 // behaves identically to typing the prompt by hand.
4005 studyPrompt := buildStudyPrompt(strings.TrimSpace(strings.TrimPrefix(cmd, parts[0])), i.cfg.CWD)
4006 i.mu.Lock()
4007 busy := i.busy
4008 ag := i.agent
4009 i.mu.Unlock()
4010 if busy {
4011 if ag != nil {
4012 ag.QueueMessage(studyPrompt)
4013 } else {
4014 i.mu.Lock()
4015 i.queued = append(i.queued, studyPrompt)
4016 i.mu.Unlock()
4017 }
4018 i.invalidate()
4019 break
4020 }
4021 i.startTurn(ctx, studyPrompt)
4022 case "/cd":
4023 // Hidden command: switch the running session's cwd. Not in
4024 // slash_suggest, not in /help. Used by the workspaces
4025 // extension's panel-key Enter handler so picking a row
4026 // jumps zot into that directory without relaunching.
4027 //
4028 // Recovers the raw argument (path) from the original cmd
4029 // string rather than parts, so paths with spaces survive.
4030 // The host's ChangeCWD hook handles validation, session
4031 // close + reopen, agent rebuild, sandbox re-rooting, and
4032 // re-jail-if-jailed semantics.
4033 if i.cfg.ChangeCWD == nil {
4034 i.mu.Lock()
4035 i.statusErr = "/cd unavailable: host did not wire ChangeCWD"
4036 i.mu.Unlock()
4037 break
4038 }
4039 path := strings.TrimSpace(strings.TrimPrefix(cmd, parts[0]))
4040 if path == "" {
4041 i.mu.Lock()
4042 i.statusErr = "/cd: missing path"
4043 i.mu.Unlock()
4044 break
4045 }
4046 if err := i.cfg.ChangeCWD(path); err != nil {
4047 i.mu.Lock()
4048 i.statusErr = "/cd: " + err.Error()
4049 i.statusOK = ""
4050 i.mu.Unlock()
4051 break
4052 }
4053 // ChangeCWD has already updated i.cfg.CWD and swapped the
4054 // agent + session. Reset transient TUI state so the new
4055 // session opens clean.
4056 i.mu.Lock()
4057 i.toolCalls = map[string]*tui.ToolCallView{}
4058 i.toolOrder = nil
4059 i.toolGate = map[string]int{}
4060 i.helpBlock = nil
4061 i.parkedTurn = 0
4062 i.statusOK = "cwd " + i.cfg.CWD
4063 i.statusErr = ""
4064 i.mu.Unlock()
4065 i.fileSuggest.Reset()
4066 i.fileSuggest.SetCWD(i.cfg.CWD)
4067 i.invalidate()
4068 case "/jail":
4069 if i.cfg.Sandbox == nil {
4070 i.mu.Lock()
4071 i.statusErr = "sandbox not available in this build"
4072 i.mu.Unlock()
4073 break
4074 }
4075 i.cfg.Sandbox.Lock()
4076 i.mu.Lock()
4077 i.statusOK = "jailed to " + i.cfg.CWD + " (tools cannot touch paths outside this directory)"
4078 i.statusErr = ""
4079 i.mu.Unlock()
4080 case "/unjail":
4081 if i.cfg.Sandbox == nil {
4082 i.mu.Lock()
4083 i.statusErr = "sandbox not available in this build"
4084 i.mu.Unlock()
4085 break
4086 }
4087 i.cfg.Sandbox.Unlock()
4088 i.mu.Lock()
4089 i.statusOK = "unjailed"
4090 i.statusErr = ""
4091 i.mu.Unlock()
4092 case "/reload-ext":
4093 i.runReloadExt(ctx)
4094 case "/telegram", "/tg":
4095 if len(parts) >= 2 {
4096 i.doTelegram(parts[1])
4097 break
4098 }
4099 i.openTelegramDialog()
4100 case "/session":
4101 if len(parts) >= 2 {
4102 action := parts[1]
4103 arg := ""
4104 if len(parts) >= 3 {
4105 arg = strings.Join(parts[2:], " ")
4106 }
4107 i.doSessionOp(action, arg)
4108 break
4109 }
4110 i.openSessionOpsDialog()
4111 case "/swarm":
4112 i.runSwarm(ctx, parts[1:])
4113 default:
4114 // Last-resort fallback: try the extension manager. Built-in
4115 // cases above always win; this branch only fires for slash
4116 // commands the extension manager registered. Same routing as
4117 // the editor's submit-handler dispatch path so the autocomplete
4118 // "enter on highlighted suggestion" flow also works.
4119 extName := strings.TrimPrefix(parts[0], "/")
4120 if i.cfg.Extensions != nil && i.cfg.Extensions.HasCommand(extName) {
4121 rest := ""
4122 if len(parts) > 1 {
4123 rest = strings.Join(parts[1:], " ")
4124 }
4125 go i.invokeExtensionCommand(ctx, extName, rest)
4126 return false
4127 }
4128 i.mu.Lock()
4129 i.statusErr = "unknown command: " + parts[0]
4130 i.mu.Unlock()
4131 }
4132 return false
4133}
4134
4135func (i *Interactive) openModelPickerAfterRefresh(refreshErr error) {
4136 var loggedIn []string
4137 if i.cfg.LoggedInProviders != nil {
4138 loggedIn = i.cfg.LoggedInProviders()
4139 }
4140 i.modelDialog.Open(i.cfg.Model, loggedIn)
4141 i.mu.Lock()
4142 if refreshErr != nil {
4143 i.statusErr = "llama.cpp model refresh: " + refreshErr.Error()
4144 i.statusOK = ""
4145 } else if i.statusOK == "refreshing models" {
4146 i.statusOK = ""
4147 }
4148 i.mu.Unlock()
4149}
4150
4151// openLogoutDialog shows the provider picker for `/logout` with no
4152// argument. Only providers the user is currently logged into are
4153// listed, plus an "all" entry when more than one is present. If
4154// nothing's logged in, writes a status line instead of opening an
4155// empty dialog.
4156func (i *Interactive) openLogoutDialog() {
4157 if i.cfg.AuthManager == nil {
4158 i.mu.Lock()
4159 i.statusErr = "no auth manager configured"
4160 i.mu.Unlock()
4161 i.invalidate()
4162 return
4163 }
4164 store := i.cfg.AuthManager.Store()
4165 if store == nil {
4166 i.mu.Lock()
4167 i.statusErr = "auth store is not available"
4168 i.mu.Unlock()
4169 i.invalidate()
4170 return
4171 }
4172 creds, err := store.Load()
4173 if err != nil {
4174 i.mu.Lock()
4175 i.statusErr = "read auth store: " + err.Error()
4176 i.mu.Unlock()
4177 i.invalidate()
4178 return
4179 }
4180
4181 var items []logoutItem
4182 for _, p := range []string{"anthropic", "kimi", "google", "github-copilot"} {
4183 if creds.Has(p) {
4184 method := creds.Method(p)
4185 if method == "oauth" {
4186 method = "subscription"
4187 }
4188 items = append(items, logoutItem{
4189 label: providerLabel(p),
4190 target: p,
4191 method: method,
4192 })
4193 }
4194 }
4195 if creds.OpenAI.APIKey != "" {
4196 items = append(items, logoutItem{label: providerLabel("openai"), target: "openai", method: "api key"})
4197 }
4198 if creds.OpenAI.OAuth != nil {
4199 items = append(items, logoutItem{label: providerLabel("openai-codex"), target: "openai-codex", method: "subscription"})
4200 }
4201 for p, c := range creds.AdditionalAPIKeyCreds {
4202 if c.APIKey != "" || c.BaseURL != "" {
4203 items = append(items, logoutItem{label: providerLabel(p), target: p, method: "api key"})
4204 }
4205 }
4206 if len(items) == 0 {
4207 i.mu.Lock()
4208 i.statusOK = "no credentials stored; already logged out"
4209 i.statusErr = ""
4210 i.mu.Unlock()
4211 i.invalidate()
4212 return
4213 }
4214 if len(items) > 1 {
4215 items = append(items, logoutItem{label: "all", target: "all"})
4216 }
4217
4218 i.logoutDialog.Open(items)
4219 i.invalidate()
4220}
4221
4222// doLogout clears credentials for the given provider (or all providers)
4223// from auth.json. If the active agent was using those credentials, it
4224// is torn down so the user is forced through /login before their next
4225// prompt.
4226//
4227// target: "anthropic" | "openai" | "kimi" | "github-copilot" | "all"
4228func (i *Interactive) doLogout(target string) {
4229 if i.cfg.AuthManager == nil {
4230 i.mu.Lock()
4231 i.statusErr = "no auth manager configured"
4232 i.mu.Unlock()
4233 return
4234 }
4235 store := i.cfg.AuthManager.Store()
4236 if store == nil {
4237 i.mu.Lock()
4238 i.statusErr = "auth store is not available"
4239 i.mu.Unlock()
4240 return
4241 }
4242
4243 var providers []string
4244 switch target {
4245 case "", "all":
4246 providers = append([]string{"anthropic", "openai", "openai-codex", "kimi", "google", "github-copilot"}, auth.APIKeyProviders()...)
4247 case "anthropic", "openai", "openai-codex", "kimi", "google", "github-copilot":
4248 providers = []string{target}
4249 default:
4250 known := false
4251 for _, p := range auth.APIKeyProviders() {
4252 if target == p {
4253 known = true
4254 break
4255 }
4256 }
4257 if !known {
4258 i.mu.Lock()
4259 i.statusErr = "unknown provider: " + target
4260 i.mu.Unlock()
4261 return
4262 }
4263 providers = []string{target}
4264 }
4265
4266 var errs []string
4267 clearedCurrent := false
4268 for _, p := range providers {
4269 var err error
4270 switch p {
4271 case "openai":
4272 err = store.ClearAPIKey("openai")
4273 case "openai-codex":
4274 err = store.ClearOAuth("openai")
4275 default:
4276 err = store.Clear(p)
4277 }
4278 if err != nil {
4279 errs = append(errs, p+": "+err.Error())
4280 continue
4281 }
4282 if p == "kimi" && i.cfg.SetKimiCLIFallbackDisabled != nil {
4283 if err := i.cfg.SetKimiCLIFallbackDisabled(true); err != nil {
4284 errs = append(errs, p+": "+err.Error())
4285 continue
4286 }
4287 }
4288 if p == i.cfg.Provider {
4289 clearedCurrent = true
4290 }
4291 }
4292
4293 llamaConfigured := i.llamaConfigured
4294 if i.cfg.LlamaCPPConfig != nil {
4295 baseURL, _, err := i.cfg.LlamaCPPConfig()
4296 llamaConfigured = err == nil && baseURL != ""
4297 }
4298
4299 i.mu.Lock()
4300 defer i.mu.Unlock()
4301 i.llamaConfigured = llamaConfigured
4302 if len(errs) > 0 {
4303 i.statusErr = "logout errors: " + strings.Join(errs, "; ")
4304 return
4305 }
4306 i.statusErr = ""
4307 if clearedCurrent {
4308 // The running agent was using a credential we just wiped. Drop
4309 // it so prompts can't go out with the stale client, and hint at
4310 // /login.
4311 i.agent = nil
4312 i.statusOK = "logged out of " + strings.Join(providers, ", ") + ". type /login to sign back in."
4313 } else {
4314 i.statusOK = "logged out of " + strings.Join(providers, ", ")
4315 }
4316}
4317
4318func providerSetupInfo(provider string) (string, []string, bool) {
4319 const docsURL = "https://raw.githubusercontent.com/patriceckhart/zot/main/docs/providers.md"
4320 switch provider {
4321 case "amazon-bedrock":
4322 return "Amazon Bedrock setup", []string{
4323 "Amazon Bedrock uses AWS credentials instead of a generic zot API-key entry.",
4324 "Configure an AWS profile, IAM keys, bearer token, or role-based credentials.",
4325 "",
4326 "For Bedrock API keys, set:",
4327 " AWS_BEARER_TOKEN_BEDROCK=...",
4328 " AWS_REGION=us-east-1",
4329 "",
4330 "Docs:",
4331 " " + docsURL,
4332 }, true
4333 case "google-vertex":
4334 return "Google Vertex AI setup", []string{
4335 "Google Vertex AI usually uses Google Cloud credentials and project settings.",
4336 "Set a Google API key, application-default credentials, or a service account.",
4337 "",
4338 "Common environment:",
4339 " GOOGLE_CLOUD_API_KEY=...",
4340 " GOOGLE_CLOUD_PROJECT=...",
4341 " GOOGLE_CLOUD_LOCATION=us-central1",
4342 "",
4343 "Docs:",
4344 " " + docsURL,
4345 }, true
4346 case "cloudflare-workers-ai":
4347 return "Cloudflare Workers AI setup", []string{
4348 "Cloudflare Workers AI needs both an API token and an account ID.",
4349 "",
4350 "Set:",
4351 " CLOUDFLARE_API_KEY=...",
4352 " CLOUDFLARE_ACCOUNT_ID=...",
4353 "",
4354 "Docs:",
4355 " " + docsURL,
4356 }, true
4357 case "cloudflare-ai-gateway":
4358 return "Cloudflare AI Gateway setup", []string{
4359 "Cloudflare AI Gateway needs an API token, account ID, and gateway ID.",
4360 "",
4361 "Set:",
4362 " CLOUDFLARE_API_KEY=...",
4363 " CLOUDFLARE_ACCOUNT_ID=...",
4364 " CLOUDFLARE_GATEWAY_ID=...",
4365 "",
4366 "Docs:",
4367 " " + docsURL,
4368 }, true
4369 case "azure-openai-responses":
4370 return "Azure OpenAI Responses setup", []string{
4371 "Azure OpenAI needs an API key plus your Azure endpoint or deployment setup.",
4372 "",
4373 "Set:",
4374 " AZURE_OPENAI_API_KEY=...",
4375 " AZURE_OPENAI_BASE_URL=https://your-resource.openai.azure.com",
4376 " AZURE_OPENAI_API_VERSION=2024-02-01",
4377 "",
4378 "Docs:",
4379 " " + docsURL,
4380 }, true
4381 default:
4382 return "", nil, false
4383 }
4384}
4385
4386func (i *Interactive) saveLlamaCPPLogin(baseURL, apiKey string) {
4387 if i.cfg.AuthManager == nil || i.cfg.AuthManager.Store() == nil {
4388 i.dialog.ShowResult(false, "auth store is unavailable")
4389 return
4390 }
4391 if err := i.cfg.AuthManager.Store().SetEndpointCredential(provider.LlamaCPPProviderID, baseURL, apiKey); err != nil {
4392 i.dialog.ShowResult(false, err.Error())
4393 return
4394 }
4395 i.mu.Lock()
4396 i.llamaConfigured = true
4397 i.statusErr = ""
4398 i.statusOK = "configured llama.cpp router"
4399 i.mu.Unlock()
4400 i.dialog.ShowResult(true, "")
4401}
4402
4403func (i *Interactive) startAPIKeyFlow(provider string) {
4404 if title, lines, ok := providerSetupInfo(provider); ok {
4405 i.dialog.ShowInfo(title, lines)
4406 return
4407 }
4408 if provider == "kimi" && i.cfg.SetKimiCLIFallbackDisabled != nil {
4409 _ = i.cfg.SetKimiCLIFallbackDisabled(false)
4410 }
4411 url, err := i.cfg.AuthManager.StartAPIKey(provider)
4412 if err != nil {
4413 i.dialog.ShowResult(false, err.Error())
4414 return
4415 }
4416 i.dialog.ShowWaiting(url)
4417}
4418
4419func (i *Interactive) startOAuthFlow(provider string) {
4420 if provider == "kimi" && i.cfg.SetKimiCLIFallbackDisabled != nil {
4421 _ = i.cfg.SetKimiCLIFallbackDisabled(false)
4422 }
4423 // Device-code providers already support headless login and must only
4424 // start one polling flow.
4425 if provider == "kimi" || provider == "xai" || provider == "github-copilot" {
4426 loginURL, err := i.cfg.AuthManager.StartOAuth(provider)
4427 if err != nil {
4428 i.dialog.ShowResult(false, err.Error())
4429 return
4430 }
4431 i.dialog.ShowWaiting(loginURL)
4432 return
4433 }
4434 // Always run the manual/copy-code flow in parallel with the local
4435 // callback server so headless environments (docker, SSH) can paste
4436 // the authorization code directly without first pressing 'p'.
4437 _, err := i.cfg.AuthManager.StartOAuth(provider)
4438 if err != nil {
4439 i.dialog.ShowResult(false, err.Error())
4440 return
4441 }
4442 manualURL, mErr := i.cfg.AuthManager.StartManualOAuth(provider)
4443 if mErr == nil {
4444 i.dialog.ShowWaiting(manualURL)
4445 } else {
4446 i.dialog.ShowResult(false, mErr.Error())
4447 }
4448}
4449
4450func (i *Interactive) startManualOAuthFlow(provider string) {
4451 if i.cfg.AuthManager == nil {
4452 return
4453 }
4454 i.cfg.AuthManager.CancelOAuth()
4455 url, err := i.cfg.AuthManager.StartManualOAuth(provider)
4456 if err != nil {
4457 i.dialog.ShowResult(false, err.Error())
4458 return
4459 }
4460 i.dialog.url = url
4461 i.invalidate()
4462}
4463
4464func (i *Interactive) submitManualOAuthCode(code string) {
4465 if i.cfg.AuthManager == nil {
4466 return
4467 }
4468 go func() {
4469 if err := i.cfg.AuthManager.CompleteManualOAuth(i.runCtx, code); err != nil {
4470 i.dialog.ShowResult(false, err.Error())
4471 i.invalidate()
4472 }
4473 }()
4474}
4475
4476// applyModelSelection switches the active model (and provider, if the
4477// new model belongs to a different one). It rebuilds the underlying
4478// client when needed so the provider wire-protocol matches.
4479// cancelAndWaitForIdle cancels the active turn (if any) and blocks
4480// briefly until the turn goroutine has updated i.busy = false. Used
4481// before destructive slash commands so transcript-mutating work
4482// (/clear, /compact, /logout, /login completion, cross-provider
4483// /model swap) doesn't race with the still-running stream.
4484//
4485// The wait is bounded; if the turn doesn't release within the timeout
4486// we proceed anyway. Worst case is a brief overlap that the agent's
4487// own mutex protects against.
4488func (i *Interactive) cancelAndWaitForIdle() {
4489 i.mu.Lock()
4490 busy := i.busy
4491 cancel := i.cancelTurn
4492 i.mu.Unlock()
4493 if !busy {
4494 return
4495 }
4496 if cancel != nil {
4497 cancel()
4498 }
4499 deadline := time.Now().Add(2 * time.Second)
4500 for time.Now().Before(deadline) {
4501 i.mu.Lock()
4502 done := !i.busy
4503 i.mu.Unlock()
4504 if done {
4505 return
4506 }
4507 time.Sleep(10 * time.Millisecond)
4508 }
4509}
4510
4511// openBtwDialog opens the side-chat overlay with a frozen snapshot
4512// of the current main session. The optional argument is auto-
4513// submitted as the first question, so '/btw does X work?' fires the
4514// model call immediately instead of just opening an empty dialog.
4515func (i *Interactive) openBtwDialog(args []string) {
4516 if i.agent == nil {
4517 i.mu.Lock()
4518 i.statusErr = "not logged in. type /login first."
4519 i.mu.Unlock()
4520 return
4521 }
4522 seed := strings.TrimSpace(strings.Join(args, " "))
4523 i.btwDialog.Open(i.cfg.Theme, i.agent, i.agent.System, i.cfg.Model, i.cfg.CWD, seed, i.compactModeEnabled(), i.cfg.FlatTools, tui.NormalizeInputStyle(i.cfg.TUIInputStyle) == tui.InputStyleLines, i.invalidate)
4524 i.invalidate()
4525}
4526
4527// openSkillsDialog opens the skill inspector. The picker reflects
4528// whatever SkillSnapshot returns at call time, so edits to a
4529// SKILL.md made during a session show up on the next /skills.
4530func (i *Interactive) openSkillsDialog() {
4531 var list []*skills.Skill
4532 if i.cfg.SkillSnapshot != nil {
4533 list = i.cfg.SkillSnapshot()
4534 }
4535 i.skillsDialog.Open(list)
4536 i.invalidate()
4537}
4538
4539// openJumpDialog builds a /jump picker from the current transcript.
4540// If the user typed "/jump foo" with a filter and it matches exactly
4541// one turn, jump there directly without showing the dialog.
4542func (i *Interactive) openJumpDialog(args []string) {
4543 if i.view == nil || len(i.view.Messages) == 0 {
4544 i.mu.Lock()
4545 i.statusErr = "nothing to jump to \u2014 the session is empty"
4546 i.mu.Unlock()
4547 return
4548 }
4549 filter := strings.TrimSpace(strings.Join(args, " "))
4550 i.jumpDialog.Open(i.view.Messages, filter)
4551 // Shortcut: with a filter argument that matches exactly one turn,
4552 // jump immediately and skip the picker.
4553 if filter != "" {
4554 if tgts := i.jumpDialog.Targets(); len(tgts) == 1 {
4555 t := tgts[0]
4556 i.jumpDialog.Close()
4557 i.applyJumpSelection(t.MessageIdx, t.TurnNo)
4558 }
4559 }
4560}
4561
4562// applyJumpSelection scrolls the chat viewport so the user message at
4563// msgIdx is visible at (or near) the top of the chat area. Uses the
4564// anchor slice returned by view.BuildWithAnchors so the mapping from
4565// message index to row is exact, regardless of variable-height tool
4566// blocks above the target.
4567func (i *Interactive) applyJumpSelection(msgIdx, turnNo int) {
4568 cols := i.lastCols()
4569 chat, anchors := i.view.BuildWithAnchors(cols)
4570 var row int
4571 found := false
4572 for _, a := range anchors {
4573 if a.MessageIdx == msgIdx {
4574 row = a.Row
4575 found = true
4576 break
4577 }
4578 }
4579 if !found {
4580 i.mu.Lock()
4581 i.statusErr = "could not resolve jump target"
4582 i.mu.Unlock()
4583 return
4584 }
4585
4586 chatLen := len(chat)
4587 page := i.chatPage()
4588 if page < 1 {
4589 page = 1
4590 }
4591 // scrollOffset is measured from the bottom of the chat slice, so
4592 // to place `row` at the top of the viewport we want:
4593 // chatLen - scrollOffset - page == row
4594 // Solve for scrollOffset and clamp to [0, chatLen-page].
4595 offset := chatLen - (row + page)
4596 if offset < 0 {
4597 offset = 0
4598 }
4599 maxOffset := chatLen - page
4600 if maxOffset < 0 {
4601 maxOffset = 0
4602 }
4603 if offset > maxOffset {
4604 offset = maxOffset
4605 }
4606
4607 i.mu.Lock()
4608 i.scrollOffset = offset
4609 i.parkedTurn = turnNo
4610 i.parkedTotal = totalTurnsLocked(i.view.Messages)
4611 i.statusOK = fmt.Sprintf("jumped to turn %d", turnNo)
4612 i.statusErr = ""
4613 i.mu.Unlock()
4614}
4615
4616// totalTurnsLocked counts user messages in the transcript. Caller is
4617// assumed to hold i.mu (the name is a mild reminder; this function
4618// itself doesn't touch shared state beyond the slice it's handed).
4619func totalTurnsLocked(msgs []provider.Message) int {
4620 n := 0
4621 for _, m := range msgs {
4622 if m.Role == provider.RoleUser {
4623 n++
4624 }
4625 }
4626 return n
4627}
4628
4629// applySessionSelection loads the given session via the cli-provided
4630// callback and snaps the viewport to the bottom (the latest message)
4631// so the user lands at the live tail of the resumed conversation.
4632func (i *Interactive) applySessionSelection(path string) {
4633 if i.cfg.LoadSession == nil {
4634 i.mu.Lock()
4635 i.statusErr = "session loading is not wired in this build"
4636 i.mu.Unlock()
4637 return
4638 }
4639 i.mu.Lock()
4640 if i.sessionLoading {
4641 i.statusErr = "already resuming a session"
4642 i.mu.Unlock()
4643 i.invalidate()
4644 return
4645 }
4646 i.sessionLoading = true
4647 i.statusOK = "resuming session: " + path
4648 i.statusErr = ""
4649 i.mu.Unlock()
4650 i.invalidate()
4651
4652 go func() {
4653 err := i.cfg.LoadSession(path)
4654 i.mu.Lock()
4655 defer i.mu.Unlock()
4656 i.sessionLoading = false
4657 if err != nil {
4658 i.statusErr = err.Error()
4659 i.statusOK = ""
4660 i.invalidate()
4661 return
4662 }
4663 i.statusOK = "resumed session: " + path
4664 i.statusErr = ""
4665 i.parkedTurn = 0
4666 i.parkedTotal = 0
4667 i.scrollOffset = 0
4668 // Fresh transcript swapped in: drop the auto-follow baseline so
4669 // the next render's follow guard doesn't see the wholesale
4670 // length change as a delta and jump the viewport.
4671 i.prevChatLen = 0
4672 i.prevChatCols = 0
4673 i.extNotes = nil
4674 i.view.InvalidateRenderCache()
4675 if i.agent != nil {
4676 i.view.Messages = i.agent.Messages()
4677 i.cumUsage = i.agent.Cost()
4678 if last := i.agent.LastTurnUsage(); last.InputTokens > 0 || last.CacheReadTokens > 0 || last.CacheWriteTokens > 0 {
4679 i.lastCtxInput = last.InputTokens + last.CacheReadTokens + last.CacheWriteTokens
4680 } else {
4681 i.lastCtxInput = 0
4682 }
4683 // Snap to the tail again — the swap brought in a fresh
4684 // transcript whose markdown / chroma cost we don't want
4685 // blocking the redraw.
4686 if len(i.view.Messages) > initialResumeTailLimit {
4687 i.view.TailLimit = initialResumeTailLimit
4688 } else {
4689 i.view.TailLimit = 0
4690 }
4691 }
4692 i.invalidate()
4693 }()
4694}
4695
4696// scrollToLastTurn parks the viewport at the most recent user turn,
4697// or at the top if the transcript has no user messages. Used after
4698// resume so the user lands looking at where they left off.
4699func (i *Interactive) scrollToLastTurn(msgs []provider.Message) {
4700 if len(msgs) == 0 {
4701 i.mu.Lock()
4702 i.scrollOffset = 0
4703 i.mu.Unlock()
4704 return
4705 }
4706 // Find the last user message index.
4707 lastUser := -1
4708 turnNo, totalTurns := 0, 0
4709 for idx, m := range msgs {
4710 if m.Role == provider.RoleUser {
4711 totalTurns++
4712 lastUser = idx
4713 }
4714 }
4715 if lastUser < 0 {
4716 i.mu.Lock()
4717 i.scrollOffset = 0
4718 i.mu.Unlock()
4719 return
4720 }
4721 turnNo = totalTurns
4722
4723 cols := i.lastCols()
4724 chat, anchors := i.view.BuildWithAnchors(cols)
4725 var row int
4726 found := false
4727 for _, a := range anchors {
4728 if a.MessageIdx == lastUser {
4729 row = a.Row
4730 found = true
4731 break
4732 }
4733 }
4734 if !found {
4735 i.mu.Lock()
4736 i.scrollOffset = 0
4737 i.mu.Unlock()
4738 return
4739 }
4740
4741 chatLen := len(chat)
4742 page := i.chatPage()
4743 if page < 1 {
4744 page = 1
4745 }
4746 offset := chatLen - (row + page)
4747 if offset < 0 {
4748 offset = 0
4749 }
4750 maxOffset := chatLen - page
4751 if maxOffset < 0 {
4752 maxOffset = 0
4753 }
4754 if offset > maxOffset {
4755 offset = maxOffset
4756 }
4757
4758 i.mu.Lock()
4759 i.scrollOffset = offset
4760 // Mark the parked-turn footer so the user sees "viewing turn N of
4761 // M - pgdn to catch up" — same affordance as /jump. Tells them at
4762 // a glance that they're looking at history, not the live tail.
4763 if offset > 0 {
4764 i.parkedTurn = turnNo
4765 i.parkedTotal = totalTurns
4766 }
4767 i.mu.Unlock()
4768 i.invalidate()
4769}
4770
4771func (i *Interactive) applyModelSelection(prov, model string) {
4772 i.swapModel(prov, model, i.cfg.BuildAgentFor, false)
4773}
4774
4775// applyRescueModelSelection is like applyModelSelection but routes
4776// through BuildAgentForRescue so launch-time --api-key / --base-url
4777// overrides are dropped before the new agent is built. Falls back to
4778// the regular builder when the host doesn't wire a rescue builder.
4779func (i *Interactive) applyRescueModelSelection(prov, model string) {
4780 builder := i.cfg.BuildAgentForRescue
4781 if builder == nil {
4782 builder = i.cfg.BuildAgentFor
4783 }
4784 i.swapModel(prov, model, builder, true)
4785}
4786
4787// swapModel applies a /model selection (or a rescue selection) using
4788// the supplied builder. rescue=true tags the success message so the
4789// user can see that launch-time overrides were ignored.
4790func (i *Interactive) swapModel(prov, model string, builder func(string, string) (*core.Agent, string, string, error), rescue bool) {
4791 if model == "" {
4792 return
4793 }
4794 m, err := provider.FindModel(prov, model)
4795 if err != nil {
4796 i.mu.Lock()
4797 i.statusErr = err.Error()
4798 i.mu.Unlock()
4799 return
4800 }
4801 // Same provider AND not a rescue retry: just swap the model on
4802 // the existing agent. Mixed-API providers dispatch from model metadata,
4803 // so the client remains reusable. Rescue retries always rebuild so a stale
4804 // auth header / base URL can't carry over.
4805 if !rescue && i.agent != nil && m.Provider == i.cfg.Provider {
4806 i.mu.Lock()
4807 i.cfg.Model = m.ID
4808 i.agent.Model = m.ID
4809 i.statusOK = "model: " + m.ID
4810 i.statusErr = ""
4811 i.mu.Unlock()
4812 if i.cfg.PersistModel != nil {
4813 i.cfg.PersistModel(i.cfg.Provider, m.ID)
4814 }
4815 return
4816 }
4817 if builder == nil {
4818 i.mu.Lock()
4819 i.statusErr = "cannot switch provider: no builder configured"
4820 i.mu.Unlock()
4821 return
4822 }
4823 // Snapshot the current transcript and cumulative usage BEFORE we
4824 // build the replacement agent so we can hand them off. Without
4825 // this the user perceives the entire session as wiped on a
4826 // cross-provider /model swap.
4827 var carryMsgs []provider.Message
4828 var carryCost provider.Usage
4829 if i.agent != nil {
4830 carryMsgs = i.agent.Messages()
4831 carryCost = i.agent.Cost()
4832 }
4833
4834 ag, p, md, err := builder(m.Provider, m.ID)
4835 if err != nil {
4836 i.mu.Lock()
4837 i.statusErr = err.Error()
4838 i.mu.Unlock()
4839 return
4840 }
4841
4842 // Replay the transcript and seed the cost on the freshly-built
4843 // agent. Messages travel cleanly between providers because they
4844 // use the same provider.Message shape; tool-call ids are local
4845 // to a turn so cross-provider continuation never confuses the
4846 // new model (it just sees the assistant's reply, no orphan
4847 // tool_use blocks because /model swaps are gated to idle state).
4848 if len(carryMsgs) > 0 {
4849 ag.SetMessages(carryMsgs)
4850 }
4851 ag.SeedCost(carryCost)
4852
4853 i.mu.Lock()
4854 i.agent = ag
4855 i.cfg.Provider = p
4856 i.cfg.Model = md
4857 if rescue {
4858 i.statusOK = "rescue retry: switched to " + p + " / " + md + " (ignored --api-key / --base-url)"
4859 } else {
4860 i.statusOK = "switched to " + p + " / " + md
4861 }
4862 i.statusErr = ""
4863 // Render cache keys are width+content based, so the new agent's
4864 // identical messages will reuse the existing entries. Nothing
4865 // to invalidate.
4866 i.mu.Unlock()
4867 // The new agent was built off the base tool registry, so any
4868 // dynamically-registered tools need to be reattached. The apply
4869 // helpers are no-ops when their feature is inactive, so the
4870 // cross-provider path still works on a vanilla setup.
4871 i.applyAutoSwarmTool(i.autoSwarmEnabled())
4872 i.applyTelegramTools(i.telegramBridge != nil && i.telegramBridge.Active())
4873 if i.cfg.PersistModel != nil {
4874 i.cfg.PersistModel(p, md)
4875 }
4876}
4877
4878func (i *Interactive) handleAuthEvent(ev auth.Event) {
4879 switch ev.Kind {
4880 case "started":
4881 i.dialog.ShowWaiting(ev.URL)
4882 case "browser_open":
4883 // no-op
4884 case "error":
4885 i.dialog.ShowResult(false, ev.Message)
4886 case "success":
4887 // Rebuild the agent with the fresh credential.
4888 ag, prov, model, err := i.cfg.BuildAgent()
4889 if err != nil {
4890 i.dialog.ShowResult(false, err.Error())
4891 return
4892 }
4893 i.mu.Lock()
4894 i.agent = ag
4895 i.cfg.Provider = prov
4896 i.cfg.Model = model
4897 i.statusErr = ""
4898 i.statusOK = "logged in to " + ev.Provider + " via " + ev.Method
4899 i.mu.Unlock()
4900 i.applyAutoSwarmTool(i.autoSwarmEnabled())
4901 i.applyTelegramTools(i.telegramBridge != nil && i.telegramBridge.Active())
4902 i.dialog.ShowResult(true, "")
4903 }
4904}
4905
4906// runCompact invokes core.Agent.Compact and reflects the progress in
4907// the tui. It runs in a goroutine so the ui stays responsive; esc/ctrl+c
4908// cancel via the same cancelTurn channel used for normal turns.
4909//
4910// When auto is true the spinner message is pinned to "condensing
4911// history" and the status bar surfaces "(auto)" next to the context
4912// percentage so it's obvious the system triggered this, not the user.
4913func (i *Interactive) runCompact(parent context.Context, auto bool) {
4914 if i.agent == nil {
4915 i.mu.Lock()
4916 i.statusErr = "not logged in. type /login first."
4917 i.mu.Unlock()
4918 return
4919 }
4920 ctx, cancel := context.WithCancel(parent)
4921 i.mu.Lock()
4922 i.busy = true
4923 if auto {
4924 i.spin.StartFixed("condensing history")
4925 i.autoCompacting = true
4926 } else {
4927 i.spin.StartFixed("compacting")
4928 }
4929 i.cancelTurn = cancel
4930 i.statusErr = ""
4931 i.statusOK = ""
4932 // Do NOT set streamOn: the summary text should not be visible
4933 // in the chat while compacting. The user just sees the spinner
4934 // and can keep typing / queue prompts.
4935 i.scrollOffset = 0
4936 i.helpBlock = nil
4937 i.mu.Unlock()
4938 i.invalidate()
4939
4940 go func() {
4941 // Sink discards deltas — we don't stream the summary to the UI.
4942 sink := func(delta string) {}
4943 summary, err := i.agent.Compact(ctx, 4, sink)
4944 _ = summary
4945 i.mu.Lock()
4946 i.busy = false
4947 i.resetStreamingStateLocked()
4948 i.cancelTurn = nil
4949 i.autoCompacting = false
4950
4951 // Drain the queue: if the user typed a prompt while compacting,
4952 // fire it now that the transcript is clean.
4953 var next string
4954 var hasNext bool
4955
4956 switch {
4957 case err != nil && ctx.Err() != nil:
4958 i.statusErr = ""
4959 if auto {
4960 i.statusOK = "auto-condense cancelled"
4961 } else {
4962 i.statusOK = "compaction cancelled"
4963 }
4964 i.queued = nil // drop queue on cancel
4965 if i.agent != nil {
4966 i.agent.DrainQueuedMessages()
4967 }
4968 case err != nil:
4969 i.statusErr = "compaction failed: " + err.Error()
4970 i.statusOK = ""
4971 i.queued = nil // drop queue on error
4972 if i.agent != nil {
4973 i.agent.DrainQueuedMessages()
4974 }
4975 default:
4976 i.statusErr = ""
4977 // Read token count from the compaction message meta.
4978 tokens := ""
4979 msgs := i.agent.Messages()
4980 if len(msgs) > 0 && msgs[0].Meta["compaction"] == "true" {
4981 tokens = msgs[0].Meta["tokens_before"]
4982 }
4983 switch {
4984 case i.pendingPostCompactNote != "":
4985 i.statusOK = i.pendingPostCompactNote
4986 case tokens != "":
4987 i.statusOK = fmt.Sprintf("compacted from ~%s tokens (ctrl+o to expand)", tokens)
4988 default:
4989 i.statusOK = "compacted (ctrl+o to expand)"
4990 }
4991 i.pendingPostCompactNote = ""
4992 i.extNotes = stripAutoCompactNotes(i.extNotes)
4993 i.lastCtxInput = 0
4994 i.toolCalls = map[string]*tui.ToolCallView{}
4995 i.toolOrder = nil
4996 i.toolGate = map[string]int{}
4997 i.view.InvalidateRenderCache()
4998 // Pop queued prompt if any.
4999 if len(i.queued) > 0 {
5000 next, i.queued = i.queued[0], i.queued[1:]
5001 hasNext = true
5002 }
5003 }
5004 i.mu.Unlock()
5005 i.invalidate()
5006
5007 if hasNext {
5008 p := i.runCtx
5009 if p == nil {
5010 p = context.Background()
5011 }
5012 i.startTurn(p, next)
5013 }
5014 }()
5015}
5016
5017// shellEscapeCommand reports whether text is a "!command" shell
5018// escape and, if so, returns the command with the leading '!' (and
5019// surrounding whitespace) stripped. A bare "!" with no command is
5020// treated as not an escape so it falls through to the normal prompt
5021// path rather than running an empty shell.
5022func shellEscapeCommand(text string) (string, bool) {
5023 trimmed := strings.TrimLeft(text, " \t")
5024 if !strings.HasPrefix(trimmed, "!") {
5025 return "", false
5026 }
5027 cmd := strings.TrimSpace(strings.TrimPrefix(trimmed, "!"))
5028 if cmd == "" {
5029 return "", false
5030 }
5031 return cmd, true
5032}
5033
5034// startShellEscape runs a "!command" in the same shell the bash tool
5035// uses, in the session working directory, honoring the /jail sandbox.
5036// It shares the busy/cancel state with the agent: esc cancels it, and
5037// it refuses to start while a turn or another shell escape is already
5038// in flight. The terminal-log output is parked in i.shellBlock below
5039// the transcript until the next prompt or /clear, so it never enters
5040// the model conversation.
5041func (i *Interactive) startShellEscape(parent context.Context, cmd string) {
5042 i.mu.Lock()
5043 if i.busy || i.shellRunning {
5044 i.statusErr = "busy — wait for the current turn to finish before running a shell command"
5045 i.statusOK = ""
5046 i.mu.Unlock()
5047 i.invalidate()
5048 return
5049 }
5050 if parent == nil {
5051 parent = i.runCtx
5052 }
5053 if parent == nil {
5054 parent = context.Background()
5055 }
5056 ctx, cancel := context.WithCancel(parent)
5057 i.busy = true
5058 i.shellRunning = true
5059 i.cancelTurn = cancel
5060 i.statusErr = ""
5061 i.statusOK = ""
5062 i.spin.StartFixed("running shell command")
5063 // A new shell escape replaces the previous block; clear stale
5064 // extension notes the same way a new turn would so the screen
5065 // doesn't accumulate transient state.
5066 i.shellBlock = nil
5067 i.scrollOffset = 0
5068 i.parkedTurn = 0
5069 i.parkedTotal = 0
5070 i.helpBlock = nil
5071 sandbox := i.cfg.Sandbox
5072 cwd := i.cfg.CWD
5073 i.mu.Unlock()
5074 i.invalidate()
5075
5076 go func() {
5077 defer cancel()
5078 raw, _ := json.Marshal(map[string]any{"command": cmd})
5079 bash := &tools.BashTool{CWD: cwd, Sandbox: sandbox}
5080 res, err := bash.Execute(ctx, raw, nil)
5081
5082 var out string
5083 if err != nil {
5084 out = "$ " + cmd + "\n\n" + err.Error() + "\n\n[error]"
5085 } else {
5086 for _, c := range res.Content {
5087 if tb, ok := c.(provider.TextBlock); ok {
5088 out += tb.Text
5089 }
5090 }
5091 }
5092 cancelled := ctx.Err() != nil
5093 failed := err != nil || res.IsError || cancelled
5094 if cancelled {
5095 out += "\n\n[cancelled]"
5096 }
5097
5098 block := i.renderShellBlock(out, failed)
5099
5100 i.mu.Lock()
5101 i.shellRunning = false
5102 i.busy = false
5103 i.cancelTurn = nil
5104 i.shellBlock = block
5105 if failed {
5106 if cancelled {
5107 i.statusErr = "shell command cancelled"
5108 } else {
5109 i.statusErr = "shell command failed"
5110 }
5111 i.statusOK = ""
5112 } else {
5113 i.statusOK = "shell command finished"
5114 i.statusErr = ""
5115 }
5116 i.mu.Unlock()
5117 i.invalidate()
5118 }()
5119}
5120
5121// renderShellBlock turns merged bash output into a styled terminal-log
5122// block: each line colored by overall success (tool/green) or failure
5123// (error/red), with the [exit ...] / [error] footer dimmed via the
5124// muted color so it reads as metadata.
5125func (i *Interactive) renderShellBlock(out string, failed bool) []string {
5126 th := i.cfg.Theme
5127 base := th.Tool
5128 if failed {
5129 base = th.Error
5130 }
5131 out = strings.TrimRight(out, "\n")
5132 lines := strings.Split(out, "\n")
5133 styled := make([]string, 0, len(lines))
5134 for _, line := range lines {
5135 color := base
5136 trimmed := strings.TrimSpace(line)
5137 if strings.HasPrefix(trimmed, "[exit ") || strings.HasPrefix(trimmed, "[error]") || strings.HasPrefix(trimmed, "[cancelled]") {
5138 color = th.Muted
5139 }
5140 styled = append(styled, th.FG256(color, line))
5141 }
5142 return styled
5143}
5144
5145func (i *Interactive) startTurn(parent context.Context, prompt string) {
5146 i.startTurnWithImages(parent, prompt, nil)
5147}
5148
5149func (i *Interactive) startTurnWithImages(parent context.Context, prompt string, images []provider.ImageBlock) {
5150 if i.agent == nil {
5151 return
5152 }
5153 // Pre-turn safety: if the most recent context measurement is
5154 // already past the auto-compact threshold, condense before
5155 // sending so the next outbound request stays under the limit.
5156 // The condense flow re-fires the user's queued prompt for us, so
5157 // we just hand it off and exit.
5158 i.mu.Lock()
5159 needsPreCompact := !i.autoCompacting && i.shouldAutoCompactLocked()
5160 if needsPreCompact {
5161 if prompt != "" {
5162 i.queued = append([]string{prompt}, i.queued...)
5163 }
5164 i.statusErr = ""
5165 i.extNotes = append(i.extNotes, autoCompactNoteLine(i.cfg.Theme, "context near limit — condensing history before sending..."))
5166 i.pendingPostCompactNote = "context auto-compacted; sending your last message"
5167 i.mu.Unlock()
5168 i.invalidate()
5169 i.runCompact(parent, true)
5170 return
5171 }
5172 i.mu.Unlock()
5173
5174 ctx, cancel := context.WithCancel(parent)
5175 i.mu.Lock()
5176 i.busy = true
5177 i.spin.Start()
5178 i.cancelTurn = cancel
5179 i.statusErr = ""
5180 i.statusOK = ""
5181 i.streaming.Reset()
5182 i.streamOn = true
5183 i.toolCalls = map[string]*tui.ToolCallView{}
5184 i.toolOrder = nil
5185 i.toolGate = map[string]int{}
5186 i.shellBlock = nil // sending a prompt clears any parked shell-escape log
5187 i.extNotes = nil // ext notes are one-shot; a new prompt clears them
5188 i.scrollOffset = 0 // jump back to the bottom on new turn
5189 // Lift the resume tail cap once the user starts interacting. The
5190 // cap is purely a first-paint optimization (don't markdown the
5191 // whole history before showing anything). Keeping it active during
5192 // a turn makes the rendered chat a sliding window: appended
5193 // messages push older ones off the TOP of the buffer, which the
5194 // renderer must treat as a change above the viewport and repaint
5195 // fully, snapping the terminal's native scrollback to the bottom on
5196 // every streamed chunk. A fresh session has no cap (append-only),
5197 // which is why the jump only shows up in resumed sessions. Dropping
5198 // the cap here makes resumed turns append-only too.
5199 i.view.TailLimit = 0
5200 // Reset the auto-follow baseline so the very next render at
5201 // interactive.go:1053 doesn't see a synthetic shrink between
5202 // "last frame had the previous turn's tool overlay" and
5203 // "this frame had it cleared above". Without this, the guard
5204 // reads delta = -(rows in cleared overlay) and decrements
5205 // scrollOffset, which on terminals that mirror zot's pane
5206 // scroll into the host scrollbar visibly yanks the viewport.
5207 // See autofollow_shrink_test.go for the exact arithmetic.
5208 i.prevChatLen = 0
5209 i.prevChatCols = 0
5210 i.parkedTurn = 0 // starting a turn clears the /jump parked state
5211 i.parkedTotal = 0
5212 i.helpBlock = nil // hide the help block once the user asks something
5213 i.mu.Unlock()
5214 i.invalidate()
5215
5216 sink := func(ev core.AgentEvent) {
5217 i.handleEvent(ev)
5218 i.invalidate()
5219 }
5220
5221 go func() {
5222 err := i.agent.Prompt(ctx, prompt, images, sink)
5223 i.mu.Lock()
5224 i.busy = false
5225 // Don't touch streamPending / streamFlushPending here — the
5226 // pacer may still be draining the final deltas and needs to
5227 // paint them even though Prompt has returned. It will reset
5228 // streamOn on its own once the buffer empties.
5229 if len(i.streamPending) == 0 {
5230 i.streamOn = false
5231 }
5232 i.cancelTurn = nil
5233 if err != nil && ctx.Err() == nil {
5234 i.statusErr = err.Error()
5235 }
5236 // Decide whether to offer a model rescue picker for recoverable
5237 // provider failures (auth/rate/temporary). The picker opens after
5238 // the mutex is released so it can take its own locks freely.
5239 var (
5240 offer bool
5241 rescueWhy string
5242 rescueImgs []provider.ImageBlock
5243 rescueModel string
5244 rescueProv string
5245 rescueFprov string
5246 )
5247 if err != nil && ctx.Err() == nil {
5248 if ok, reason := classifyRescueError(err); ok {
5249 offer = true
5250 rescueWhy = reason
5251 rescueImgs = images
5252 rescueModel = i.cfg.Model
5253 rescueProv = i.cfg.Provider
5254 rescueFprov = extractFailedProvider(err)
5255 if rescueFprov == "" {
5256 rescueFprov = i.cfg.Provider
5257 }
5258 // Suppress the red banner — the rescue dialog already
5259 // surfaces the failure.
5260 i.statusErr = ""
5261 }
5262 }
5263 // Detect HTTP 413 "payload too large" responses. The provider
5264 // rejected the request because the request body exceeded its
5265 // per-request limit. Token-based auto-compact can miss this
5266 // because the limit is on raw bytes, not tokens. Re-queue the
5267 // prompt so it survives the condense pass and trigger one.
5268 payloadTooLarge := err != nil && ctx.Err() == nil && isPayloadTooLargeError(err)
5269 if payloadTooLarge {
5270 i.statusErr = ""
5271 i.queued = append([]string{prompt}, i.queued...)
5272 i.extNotes = append(i.extNotes, autoCompactNoteLine(i.cfg.Theme, "request was too large. condensing history before retrying ..."))
5273 i.pendingPostCompactNote = "context auto-compacted; retrying your last message"
5274 }
5275 // Persist the assistant's reply (and every tool row before
5276 // it) to the session file while the turn memory is hot.
5277 // Without this, WriteNewTranscript only fires at zot exit,
5278 // meaning a crash or ungraceful kill drops the whole
5279 // conversation. FlushSession is idempotent (it advances the
5280 // baseline so subsequent flushes only write new rows).
5281 flush := i.cfg.FlushSession
5282 i.mu.Unlock()
5283 if flush != nil {
5284 flush()
5285 }
5286 i.mu.Lock()
5287 // Pop the next queued message, if any, and relaunch.
5288 var next string
5289 var hasNext bool
5290 if len(i.queued) > 0 && ctx.Err() == nil && err == nil {
5291 next, i.queued = i.queued[0], i.queued[1:]
5292 hasNext = true
5293 }
5294 // If the turn was cancelled or errored, drop the queue so the
5295 // user isn't bombarded with stale messages after an interrupt.
5296 if ctx.Err() != nil || err != nil {
5297 i.queued = nil
5298 if i.agent != nil {
5299 i.agent.DrainQueuedMessages()
5300 }
5301 }
5302 // Decide whether the next thing to do is an auto-compaction.
5303 // Only fires when the turn completed cleanly AND no host-side
5304 // or agent-side queued messages are waiting (otherwise a queued
5305 // message would race the condense).
5306 agentQueued := 0
5307 if i.agent != nil {
5308 agentQueued = i.agent.QueuedMessageCount()
5309 }
5310 shouldAutoCompact := !hasNext && agentQueued == 0 && err == nil && ctx.Err() == nil && i.shouldAutoCompactLocked()
5311 i.mu.Unlock()
5312 i.invalidate()
5313 parent := i.runCtx
5314 if parent == nil {
5315 parent = context.Background()
5316 }
5317 switch {
5318 case hasNext:
5319 i.startTurn(parent, next)
5320 case offer:
5321 i.openRescueDialog(rescueProv, rescueFprov, rescueModel, rescueWhy, prompt, rescueImgs)
5322 case payloadTooLarge:
5323 i.runCompact(parent, true)
5324 case shouldAutoCompact:
5325 i.runCompact(parent, true)
5326 }
5327 }()
5328}
5329
5330// openRescueDialog surfaces the rescue model picker after a recoverable
5331// provider failure. The pending prompt + images are stashed on the
5332// Interactive so a later applyRescueSelection can re-run the same turn
5333// against the freshly-picked model. activeProvider/failedProvider are
5334// usually the same, but some clients embed different prefixes in their
5335// errors than the configured provider id, so we accept both.
5336func (i *Interactive) openRescueDialog(activeProvider, failedProvider, failedModel, reason, prompt string, images []provider.ImageBlock) {
5337 if i.rescueDialog == nil {
5338 return
5339 }
5340 loggedIn := []string{}
5341 if i.cfg.LoggedInProviders != nil {
5342 loggedIn = i.cfg.LoggedInProviders()
5343 }
5344 fprov := failedProvider
5345 if fprov == "" {
5346 fprov = activeProvider
5347 }
5348 i.mu.Lock()
5349 i.pendingRescuePrompt = prompt
5350 i.pendingRescueImages = images
5351 i.mu.Unlock()
5352 i.rescueDialog.Open(failedModel, loggedIn, fprov, failedModel, reason, prompt)
5353 i.invalidate()
5354}
5355
5356// applyRescueSelection switches model (cross-provider if needed) and
5357// re-runs the same prompt+images that just failed. Mirrors
5358// applyModelSelection's transcript-carry logic so the user keeps full
5359// session continuity across the swap.
5360func (i *Interactive) applyRescueSelection(prov, model, prompt string) {
5361 if model == "" {
5362 return
5363 }
5364 i.applyRescueModelSelection(prov, model)
5365 i.mu.Lock()
5366 images := i.pendingRescueImages
5367 if prompt == "" {
5368 prompt = i.pendingRescuePrompt
5369 }
5370 i.pendingRescuePrompt = ""
5371 i.pendingRescueImages = nil
5372 i.mu.Unlock()
5373 parent := i.runCtx
5374 if parent == nil {
5375 parent = context.Background()
5376 }
5377 i.startTurnWithImages(parent, prompt, images)
5378}
5379
5380func stripAutoCompactNotes(notes []string) []string {
5381 if len(notes) == 0 {
5382 return notes
5383 }
5384 out := notes[:0]
5385 for _, n := range notes {
5386 if strings.Contains(n, "condensing history") {
5387 continue
5388 }
5389 out = append(out, n)
5390 }
5391 return out
5392}
5393
5394// autoCompactNoteLine returns a styled chat-area note for the
5395// inline auto-compact heads-up. Lives in extNotes so it survives
5396// the busy-spinner overwrite of the status row.
5397func autoCompactNoteLine(th tui.Theme, msg string) string {
5398 return " " + th.FG256(th.Warning, "⚠ "+msg)
5399}
5400
5401// isPayloadTooLargeError matches HTTP 413 responses surfaced by the
5402// provider clients. The error formatting differs slightly between
5403// providers (anthropic and openai both prepend the status code), so
5404// we look for the canonical 413 marker as well as the conventional
5405// 'payload too large' phrase.
5406func isPayloadTooLargeError(err error) bool {
5407 if err == nil {
5408 return false
5409 }
5410 msg := strings.ToLower(err.Error())
5411 return strings.Contains(msg, "http 413") || strings.Contains(msg, " 413") || strings.HasPrefix(msg, "413 ") || strings.Contains(msg, "payload too large") || strings.Contains(msg, "request entity too large")
5412}
5413
5414// autoCompactThreshold is the context-window fraction at which the
5415// agent will auto-compact after a turn ends. 0.85 leaves enough
5416// headroom for one more user prompt + response before we bump the
5417// hard limit.
5418const autoCompactThreshold = 0.85
5419
5420// shouldAutoCompactLocked reports whether the last turn pushed context
5421// usage past the auto-compact threshold. Must be called with i.mu
5422// held; it reads lastCtxInput and the current model's context window.
5423func (i *Interactive) shouldAutoCompactLocked() bool {
5424 if i.agent == nil {
5425 return false
5426 }
5427 if i.autoCompacting {
5428 return false
5429 }
5430 m, err := provider.FindModel(i.cfg.Provider, i.cfg.Model)
5431 if err != nil || m.ContextWindow <= 0 {
5432 return false
5433 }
5434 if i.lastCtxInput <= 0 {
5435 return false
5436 }
5437 return float64(i.lastCtxInput)/float64(m.ContextWindow) >= autoCompactThreshold
5438}
5439
5440func (i *Interactive) handleEvent(ev core.AgentEvent) {
5441 i.mu.Lock()
5442 defer i.mu.Unlock()
5443 switch e := ev.(type) {
5444 case core.EvAssistantStart:
5445 // Fires at the top of every oneTurn, including follow-up
5446 // turns after tool use. Without this, the streaming buffer
5447 // is still marked off from the previous assistant message
5448 // and the final summary text pops in all at once instead
5449 // of typewriter-streaming delta by delta.
5450 i.streaming.Reset()
5451 i.streamPending = i.streamPending[:0]
5452 i.streamFlushPending = false
5453 i.streamOn = true
5454 // Clear the live tool-call overlay. Any tools from the
5455 // previous round are now fully folded into the transcript
5456 // (assistant tool_use block + tool role message with the
5457 // result), so keeping them in the overlay would duplicate
5458 // them in the view — once inside the finalised transcript
5459 // and once below the streaming block, with the streaming
5460 // summary sandwiched in between. The next EvToolUseStart
5461 // will populate fresh entries for this turn's tools.
5462 i.toolCalls = map[string]*tui.ToolCallView{}
5463 i.toolOrder = nil
5464 i.toolGate = map[string]int{}
5465 case core.EvTextDelta:
5466 // Buffer into streamPending; the paintPace ticker drains
5467 // it into i.streaming a few runes at a time for a smooth
5468 // typewriter effect independent of upstream chunk size.
5469 i.streamPending = append(i.streamPending, []rune(e.Delta)...)
5470 i.streamOn = true
5471 case core.EvAssistantMessage:
5472 // OnAssistant + telegram mirroring always fire on message
5473 // arrival — they read the FINAL message content, which is
5474 // complete regardless of what's still in the pacer.
5475 i.assistantMessageSideEffects(e.Message)
5476 // If the pacer still has characters to drain, keep streamOn
5477 // true and mark flush pending; the paintPace ticker will
5478 // drain the remainder and reset streaming state when done.
5479 // Otherwise (rare: full-replay sessions, abort paths) clear
5480 // synchronously so a later render doesn't show stale text.
5481 if len(i.streamPending) > 0 {
5482 i.streamFlushPending = true
5483 return
5484 }
5485 i.resetStreamingStateLocked()
5486 case core.EvToolUseStart:
5487 // Live streaming: pre-create the view so the user sees the
5488 // tool call being composed in real time. Any subsequent
5489 // EvToolCall for the same ID updates the same struct (the
5490 // final parsed args + name are already known here).
5491 if _, exists := i.toolCalls[e.ID]; !exists {
5492 i.toolCalls[e.ID] = &tui.ToolCallView{
5493 ID: e.ID,
5494 Name: e.Name,
5495 Streaming: true,
5496 }
5497 i.toolOrder = append(i.toolOrder, e.ID)
5498 i.gateToolLocked(e.ID)
5499 }
5500 case core.EvToolUseArgs:
5501 if tc, ok := i.toolCalls[e.ID]; ok {
5502 tc.RawJSONBuf += e.Delta
5503 // Refresh the live path as soon as it parses; used in
5504 // the header (write /Users/pat/Desktop/demo.ts)
5505 // while the content is still streaming.
5506 if p, pok, _ := tui.ExtractPartialStringField(tc.RawJSONBuf, "path"); pok {
5507 tc.LivePath = p
5508 } else if p, pok, _ := tui.ExtractPartialStringField(tc.RawJSONBuf, "file_path"); pok {
5509 tc.LivePath = p
5510 }
5511 }
5512 case core.EvToolUseEnd:
5513 if tc, ok := i.toolCalls[e.ID]; ok {
5514 tc.Streaming = false
5515 }
5516 case core.EvToolCall:
5517 // If we already pre-created the view during streaming, just
5518 // refresh the final Args summary. Otherwise create a new one
5519 // (non-streaming providers or legacy paths).
5520 if tc, ok := i.toolCalls[e.ID]; ok {
5521 tc.Args = tui.ShortArgs(e.Name, e.Args)
5522 if tc.RawJSONBuf == "" {
5523 tc.RawJSONBuf = string(e.Args)
5524 }
5525 tc.Streaming = false
5526 } else {
5527 i.toolCalls[e.ID] = &tui.ToolCallView{
5528 ID: e.ID,
5529 Name: e.Name,
5530 Args: tui.ShortArgs(e.Name, e.Args),
5531 RawJSONBuf: string(e.Args),
5532 }
5533 i.toolOrder = append(i.toolOrder, e.ID)
5534 i.gateToolLocked(e.ID)
5535 }
5536 case core.EvToolResult:
5537 if tc, ok := i.toolCalls[e.ID]; ok {
5538 tc.Done = true
5539 tc.Error = e.Result.IsError
5540 tc.Preview = ""
5541 var text strings.Builder
5542 for _, c := range e.Result.Content {
5543 if tb, ok := c.(provider.TextBlock); ok {
5544 if text.Len() > 0 {
5545 text.WriteString("\n")
5546 }
5547 text.WriteString(tb.Text)
5548 }
5549 }
5550 tc.Result = text.String()
5551 }
5552 if i.cfg.OnToolResult != nil {
5553 i.cfg.OnToolResult(e.ID, e.Result)
5554 }
5555 case core.EvUsage:
5556 i.cumUsage = e.Cumulative
5557 if e.Usage.InputTokens > 0 {
5558 i.lastCtxInput = e.Usage.InputTokens + e.Usage.CacheReadTokens + e.Usage.CacheWriteTokens
5559 }
5560 case core.EvTurnEnd:
5561 if e.Stop == provider.StopAborted {
5562 i.resetStreamingStateLocked()
5563 i.statusErr = ""
5564 i.statusOK = "cancelled"
5565 return
5566 }
5567 if e.Stop == provider.StopLength {
5568 // The model hit its output-token cap mid-response, so the
5569 // reply (often a long write/edit) is truncated. Surface it
5570 // explicitly, otherwise the turn just ends and reads like
5571 // the UI gave up. The agent already requests the model's
5572 // full MaxOutput budget, so this means the response genuinely
5573 // exceeded that ceiling; ask the user to continue.
5574 i.statusErr = "response hit the model's output-token limit and was cut off, ask it to continue"
5575 i.statusOK = ""
5576 return
5577 }
5578 // Don't surface mid-loop stream errors as a red banner here.
5579 // EvTurnEnd fires after every step in a multi-step tool loop,
5580 // so a transient 503 / network blip would briefly paint a red
5581 // banner over the still-streaming chat before the agent loop
5582 // either retries or exits. The final error (if any) is set by
5583 // startTurnWithImages once Prompt() returns, and recoverable
5584 // failures are routed to the rescue picker instead — which
5585 // keeps the chat clean while the agent is working.
5586 _ = e.Err
5587 }
5588}
5589
5590// Agent returns the current agent, if any. Used by cli.go to flush the
5591// final transcript to the session file.
5592func (i *Interactive) Agent() *core.Agent {
5593 i.mu.Lock()
5594 defer i.mu.Unlock()
5595 return i.agent
5596}
5597
5598// silence unused import in some build configs
5599var _ = fmt.Sprintf
5600
5601// runReloadExt triggers a live reload of every extension (discovered
5602// + explicit). Runs on a goroutine so the TUI stays responsive; the
5603// Manager.Reload takes a couple of hundred ms to shut down subprocs
5604// and respawn them. Shows a status line throughout.
5605func (i *Interactive) runReloadExt(ctx context.Context) {
5606 if i.cfg.Extensions == nil {
5607 i.mu.Lock()
5608 i.statusErr = "no extension manager in this build"
5609 i.mu.Unlock()
5610 i.invalidate()
5611 return
5612 }
5613 i.mu.Lock()
5614 i.statusOK = "reloading extensions..."
5615 i.statusErr = ""
5616 i.mu.Unlock()
5617 i.invalidate()
5618
5619 go func() {
5620 stats := i.cfg.Extensions.Reload(ctx, 2*time.Second)
5621 msg := fmt.Sprintf("reloaded: %d stopped, %d loaded (%d ready)", stats.Stopped, stats.Loaded, stats.Ready)
5622 if len(stats.Errors) > 0 {
5623 msg += fmt.Sprintf(", %d error(s)", len(stats.Errors))
5624 }
5625 i.mu.Lock()
5626 i.statusOK = msg
5627 i.statusErr = ""
5628 i.mu.Unlock()
5629 i.invalidate()
5630 }()
5631}
5632
5633// Confirm implements core.Confirmer. The agent goroutine calls
5634// this synchronously before every tool invocation when --no-yolo is
5635// active. We push the request onto the confirmDialog queue, trigger
5636// a redraw, and block the caller until the user answers.
5637//
5638// If the session is cancelled or the TUI exits mid-prompt, any
5639// pending request is refused via CancelAll so the agent doesn't
5640// deadlock.
5641func (i *Interactive) Confirm(toolName string, preview string) core.ConfirmDecision {
5642 return i.ConfirmToolCall(core.ToolCallConfirmation{Name: toolName, Summary: preview})
5643}
5644
5645// ConfirmToolCall attaches a side-effect-free preview to the matching live
5646// tool panel, then blocks until the user approves or refuses the call.
5647func (i *Interactive) ConfirmToolCall(call core.ToolCallConfirmation) core.ConfirmDecision {
5648 resp := make(chan core.ConfirmDecision, 1)
5649 if call.ID != "" {
5650 i.mu.Lock()
5651 if tc, ok := i.toolCalls[call.ID]; ok {
5652 tc.Args = call.Summary
5653 tc.Preview = call.Content
5654 }
5655 i.mu.Unlock()
5656 if i.btwDialog != nil && i.btwDialog.Active() {
5657 i.btwDialog.SetToolPreview(call.ID, call.Summary, call.Content)
5658 }
5659 }
5660 i.confirmDialog.Enqueue(&confirmRequest{
5661 toolName: call.Name,
5662 preview: call.Summary,
5663 resp: resp,
5664 })
5665 i.invalidate()
5666 return <-resp
5667}
5668
5669// openTelegramDialog shows the picker for `/telegram` with no arg.
5670// Items depend on current state: disconnect + status when running,
5671// connect + status when stopped.
5672func (i *Interactive) openTelegramDialog() {
5673 items := i.telegramMenuItems()
5674 if len(items) == 0 {
5675 i.mu.Lock()
5676 i.statusErr = "telegram not configured. run `zot telegram-bot setup` first."
5677 i.mu.Unlock()
5678 i.invalidate()
5679 return
5680 }
5681 i.telegramDialog.Open(items)
5682 i.invalidate()
5683}
5684
5685// telegramMenuItems builds the dialog entries for the current
5686// bridge state. Returns empty when no bot.json exists so the
5687// caller can show a helpful status line instead of an empty menu.
5688func (i *Interactive) telegramMenuItems() []telegramItem {
5689 cfg, err := telegram.LoadConfig(i.cfg.ZotHome)
5690 if err != nil || cfg.BotToken == "" {
5691 return nil
5692 }
5693 var items []telegramItem
5694 if i.telegramBridge != nil && i.telegramBridge.Active() {
5695 items = append(items, telegramItem{label: "disconnect", action: "disconnect", hint: "stop mirroring"})
5696 st := i.telegramBridge.State()
5697 hint := "active"
5698 if st.Username != "" {
5699 hint += " as @" + st.Username
5700 }
5701 items = append(items, telegramItem{label: "status", action: "status", hint: hint})
5702 } else {
5703 label := "connect"
5704 hint := "start mirroring dms into this session"
5705 if cfg.AllowedUserID == 0 {
5706 hint = "awaiting pairing (send /start to the bot once connected)"
5707 }
5708 items = append(items, telegramItem{label: label, action: "connect", hint: hint})
5709 items = append(items, telegramItem{label: "status", action: "status", hint: "disconnected"})
5710 }
5711 return items
5712}
5713
5714// doTelegram dispatches one of the three explicit actions. Called
5715// from /telegram <action> or after the picker selects a row.
5716func (i *Interactive) doTelegram(action string) {
5717 switch action {
5718 case "connect":
5719 i.telegramConnect()
5720 case "disconnect":
5721 i.telegramDisconnect()
5722 case "status":
5723 i.telegramStatus()
5724 default:
5725 i.mu.Lock()
5726 i.statusErr = "unknown telegram action: " + action + " (use connect, disconnect, or status)"
5727 i.mu.Unlock()
5728 i.invalidate()
5729 }
5730}
5731
5732// telegramConnect starts the bridge. Refuses if it's already
5733// running or if the on-disk bot.json is missing a token.
5734func (i *Interactive) telegramConnect() {
5735 if i.telegramBridge != nil && i.telegramBridge.Active() {
5736 i.mu.Lock()
5737 i.statusOK = "telegram already connected"
5738 i.statusErr = ""
5739 i.mu.Unlock()
5740 i.invalidate()
5741 return
5742 }
5743 cfg, err := telegram.LoadConfig(i.cfg.ZotHome)
5744 if err != nil {
5745 i.mu.Lock()
5746 i.statusErr = "telegram: " + err.Error()
5747 i.mu.Unlock()
5748 i.invalidate()
5749 return
5750 }
5751 if cfg.BotToken == "" {
5752 i.mu.Lock()
5753 i.statusErr = "telegram: no bot token configured. run `zot telegram-bot setup` first."
5754 i.mu.Unlock()
5755 i.invalidate()
5756 return
5757 }
5758 // Refuse to start when a background daemon is already polling
5759 // the same bot. Two concurrent long-poll consumers race each
5760 // update and one always loses, so DMs get half-delivered. The
5761 // user can `zot telegram-bot stop` first, then /telegram connect.
5762 if pid, alive, _ := telegram.IsRunning(i.cfg.ZotHome); alive && pid > 0 {
5763 i.mu.Lock()
5764 i.statusErr = fmt.Sprintf("telegram: bot daemon already running (pid %d). stop it with `zot telegram-bot stop` first.", pid)
5765 i.mu.Unlock()
5766 i.invalidate()
5767 return
5768 }
5769 i.telegramBridge = &telegram.Bridge{
5770 Client: telegram.NewClient(cfg.BotToken),
5771 Config: cfg,
5772 Save: func(next telegram.Config) error {
5773 return telegram.SaveConfig(i.cfg.ZotHome, next)
5774 },
5775 Host: &telegramHost{iv: i},
5776 }
5777 if err := i.telegramBridge.Start(i.runCtx); err != nil {
5778 i.telegramBridge = nil
5779 i.mu.Lock()
5780 i.statusErr = "telegram connect failed: " + err.Error()
5781 i.mu.Unlock()
5782 i.invalidate()
5783 return
5784 }
5785 i.applyTelegramTools(true)
5786 state := i.telegramBridge.State()
5787 label := "telegram connected"
5788 if state.Username != "" {
5789 label += " as @" + state.Username
5790 }
5791 if state.PairedID == 0 {
5792 label += " — send /start to the bot from your phone to claim it"
5793 }
5794 i.mu.Lock()
5795 i.statusOK = label
5796 i.statusErr = ""
5797 i.mu.Unlock()
5798 i.invalidate()
5799}
5800
5801// telegramDisconnect stops the bridge. No-op when already stopped.
5802func (i *Interactive) telegramDisconnect() {
5803 if i.telegramBridge == nil || !i.telegramBridge.Active() {
5804 i.mu.Lock()
5805 i.statusOK = "telegram already disconnected"
5806 i.statusErr = ""
5807 i.mu.Unlock()
5808 i.invalidate()
5809 return
5810 }
5811 i.telegramBridge.Stop()
5812 i.applyTelegramTools(false)
5813 i.mu.Lock()
5814 i.statusOK = "telegram disconnected"
5815 i.statusErr = ""
5816 i.mu.Unlock()
5817 i.invalidate()
5818}
5819
5820// telegramSenderAdapter wraps the bridge so the tools package can
5821// drive it without importing telegram directly. The Active() check
5822// is forwarded to the bridge so the tool can fail clearly with a
5823// model-readable error when the user disconnected mid-turn.
5824type telegramSenderAdapter struct {
5825 bridge *telegram.Bridge
5826}
5827
5828func (a telegramSenderAdapter) SendImage(ctx context.Context, path, caption string) error {
5829 if a.bridge == nil {
5830 return fmt.Errorf("telegram bridge is not connected")
5831 }
5832 return a.bridge.SendImage(ctx, path, caption)
5833}
5834
5835func (a telegramSenderAdapter) SendDocument(ctx context.Context, path, caption string) error {
5836 if a.bridge == nil {
5837 return fmt.Errorf("telegram bridge is not connected")
5838 }
5839 return a.bridge.SendDocument(ctx, path, caption)
5840}
5841
5842func (a telegramSenderAdapter) Active() bool {
5843 return a.bridge != nil && a.bridge.Active()
5844}
5845
5846// swarmWatchEntry is one tracked auto-swarm sub-agent. Filled in at
5847// spawn time and finalised in trackSwarmAgent's waiter goroutine.
5848type swarmWatchEntry struct {
5849 agent *swarm.Agent
5850 task string
5851 done bool
5852 err string
5853}
5854
5855// TrackSwarmAgent is the exported entry point used by the cli to
5856// hand a freshly-spawned auto-swarm agent off to the watcher.
5857func (i *Interactive) TrackSwarmAgent(a *swarm.Agent, task string) {
5858 i.trackSwarmAgent(a, task)
5859}
5860
5861// trackSwarmAgent records a freshly-spawned auto-swarm agent and
5862// subscribes to its prompt-level task completion events. Sub-agents
5863// are long-lived daemons that keep running on the inbox after the
5864// initial task, so we can't wait on agent.Wait() — it never returns
5865// until the whole daemon dies. Instead we mark each entry done when
5866// the swarm daemon reports the initial prompt has finished, and when
5867// every tracked entry has reported in, flush a single summary into
5868// the main chat.
5869//
5870// Wired in from cli.go via SwarmSpawnTool.OnSpawned only when auto-
5871// swarm is enabled, so this is a no-op when the feature is off.
5872func (i *Interactive) trackSwarmAgent(a *swarm.Agent, task string) {
5873 if i == nil || a == nil {
5874 return
5875 }
5876 entry := &swarmWatchEntry{agent: a, task: task}
5877 i.swarmWatchMu.Lock()
5878 i.swarmWatch = append(i.swarmWatch, entry)
5879 i.swarmWatchMu.Unlock()
5880
5881 a.SetOnTurnEnd(func(step int, errMsg string) {
5882 i.swarmWatchMu.Lock()
5883 if entry.done {
5884 i.swarmWatchMu.Unlock()
5885 return
5886 }
5887 entry.done = true
5888 entry.err = errMsg
5889 allDone := true
5890 for _, e := range i.swarmWatch {
5891 if !e.done {
5892 allDone = false
5893 break
5894 }
5895 }
5896 var batch []*swarmWatchEntry
5897 if allDone {
5898 batch = i.swarmWatch
5899 i.swarmWatch = nil
5900 }
5901 i.swarmWatchMu.Unlock()
5902 if len(batch) == 0 {
5903 return
5904 }
5905 i.flushSwarmSummary(batch)
5906 })
5907}
5908
5909// flushSwarmSummary composes a synthetic user turn describing every
5910// sub-agent's outcome and injects it via SubmitOrQueue so the main
5911// agent picks it up at the next safe boundary. The summary is
5912// phrased as a system update ("Auto-swarm finished: ...") so the
5913// model treats it as observed state, not as a fresh user request.
5914func (i *Interactive) flushSwarmSummary(batch []*swarmWatchEntry) {
5915 if len(batch) == 0 {
5916 return
5917 }
5918 var sb strings.Builder
5919 fmt.Fprintf(&sb, "[auto-swarm update] %d sub-agent(s) finished:\n\n", len(batch))
5920 for idx, e := range batch {
5921 snap := e.agent.Snapshot()
5922 status := string(snap.Status)
5923 task := snap.Task
5924 if task == "" {
5925 task = e.task
5926 }
5927 fmt.Fprintf(&sb, "%d. agent %s \u2014 status: %s\n", idx+1, snap.ID, status)
5928 fmt.Fprintf(&sb, " task: %s\n", truncateForSummary(task, 240))
5929 if snap.Err != "" {
5930 fmt.Fprintf(&sb, " error: %s\n", truncateForSummary(snap.Err, 240))
5931 } else if e.err != "" {
5932 fmt.Fprintf(&sb, " turn error: %s\n", truncateForSummary(e.err, 240))
5933 }
5934 if tail := strings.TrimSpace(snap.Tail); tail != "" {
5935 fmt.Fprintf(&sb, " tail: %s\n", truncateForSummary(tail, 600))
5936 }
5937 sb.WriteString("\n")
5938 }
5939 sb.WriteString("Briefly summarise the collective outcome for the user. Reference the agents by id. If any failed, suggest a follow-up; otherwise confirm completion. Do not spawn new sub-agents unless the user asks.")
5940 i.SubmitOrQueue(sb.String(), nil)
5941}
5942
5943func truncateForSummary(s string, n int) string {
5944 s = strings.TrimSpace(s)
5945 if len(s) <= n {
5946 return s
5947 }
5948 return s[:n-3] + "..."
5949}
5950
5951// applyAutoSwarmSystemPrompt appends (active=true) or strips
5952// (active=false) the auto-swarm system-prompt block on the running
5953// agent so the model proactively considers swarm_spawn when the user
5954// flips the toggle. The block lives at the tail of agent.System so
5955// stripping is a plain suffix-trim; idempotent in both directions.
5956func (i *Interactive) applyAutoSwarmSystemPrompt(active bool) {
5957 if i.agent == nil {
5958 return
5959 }
5960 addendum := i.cfg.AutoSwarmSystemAddendum
5961 if addendum == "" {
5962 return
5963 }
5964 sys := i.agent.System
5965 has := strings.Contains(sys, addendum)
5966 switch {
5967 case active && !has:
5968 if sys != "" && !strings.HasSuffix(sys, "\n\n") {
5969 sys += "\n\n"
5970 }
5971 i.agent.System = sys + addendum
5972 case !active && has:
5973 i.agent.System = strings.TrimRight(strings.ReplaceAll(sys, addendum, ""), "\n") + "\n"
5974 }
5975}
5976
5977// applyAutoSwarmTool registers (active=true) or removes (active=false)
5978// the swarm_spawn tool on the running agent so the model only sees it
5979// when /settings -> auto-swarm is enabled. Mirrors applyTelegramTools'
5980// snapshot+mutate pattern so extension tools and /reload-ext additions
5981// survive a toggle.
5982func (i *Interactive) autoSwarmEnabled() bool {
5983 return i.cfg.AutoSwarmEnabled != nil && *i.cfg.AutoSwarmEnabled
5984}
5985
5986func (i *Interactive) applyAutoSwarmTool(active bool) {
5987 if i.agent == nil {
5988 return
5989 }
5990 current := i.agent.Tools
5991 next := core.Registry{}
5992 for name, t := range current {
5993 if name == "swarm_spawn" {
5994 continue
5995 }
5996 next[name] = t
5997 }
5998 if active && i.cfg.Swarm != nil {
5999 next["swarm_spawn"] = &tools.SwarmSpawnTool{
6000 Swarm: i.cfg.Swarm,
6001 Enabled: func() bool { return true },
6002 DefaultModel: func() string { return i.cfg.Model },
6003 DefaultProvider: func() string { return i.cfg.Provider },
6004 OnSpawned: i.trackSwarmAgent,
6005 }
6006 }
6007 i.agent.SetTools(next)
6008}
6009
6010// applyTelegramTools registers (active=true) or removes (active=false)
6011// the telegram_send_image and telegram_send_file tools on the running
6012// agent so the model only sees them while the bridge is connected.
6013// Snapshots and mutates the live tool registry so any extension or
6014// /reload-ext additions made while Telegram is connected survive a
6015// later /telegram disconnect (we only add or strip the two telegram
6016// entries, never the rest).
6017func (i *Interactive) applyTelegramTools(active bool) {
6018 if i.agent == nil {
6019 return
6020 }
6021 current := i.agent.Tools
6022 next := core.Registry{}
6023 for name, t := range current {
6024 if name == "telegram_send_image" || name == "telegram_send_file" {
6025 continue
6026 }
6027 next[name] = t
6028 }
6029 if active {
6030 sender := telegramSenderAdapter{bridge: i.telegramBridge}
6031 next["telegram_send_image"] = &tools.TelegramSendImageTool{
6032 CWD: i.cfg.CWD, Sandbox: i.cfg.Sandbox, Sender: sender,
6033 }
6034 next["telegram_send_file"] = &tools.TelegramSendFileTool{
6035 CWD: i.cfg.CWD, Sandbox: i.cfg.Sandbox, Sender: sender,
6036 }
6037 }
6038 i.agent.SetTools(next)
6039}
6040
6041// telegramStatus writes a one-liner describing the bridge state.
6042// Reports on both the in-tui bridge and the background daemon so
6043// the user isn't confused when the daemon owns the poll loop.
6044func (i *Interactive) telegramStatus() {
6045 var msg string
6046 if i.telegramBridge != nil && i.telegramBridge.Active() {
6047 s := i.telegramBridge.State()
6048 msg = "telegram: connected (tui bridge)"
6049 if s.Username != "" {
6050 msg += " as @" + s.Username
6051 }
6052 if s.PairedID != 0 {
6053 msg += fmt.Sprintf(" - paired with user %d", s.PairedID)
6054 } else {
6055 msg += " - awaiting pairing"
6056 }
6057 } else if pid, alive, _ := telegram.IsRunning(i.cfg.ZotHome); alive && pid > 0 {
6058 msg = fmt.Sprintf("telegram: background daemon running (pid %d) - /telegram connect won't work until you stop it", pid)
6059 } else {
6060 cfg, _ := telegram.LoadConfig(i.cfg.ZotHome)
6061 if cfg.BotToken == "" {
6062 msg = "telegram: not configured. run `zot telegram-bot setup` first."
6063 } else {
6064 msg = "telegram: disconnected"
6065 if cfg.BotUsername != "" {
6066 msg += " (@" + cfg.BotUsername + " ready to connect)"
6067 }
6068 }
6069 }
6070 i.mu.Lock()
6071 i.statusOK = msg
6072 i.statusErr = ""
6073 i.mu.Unlock()
6074 i.invalidate()
6075}
6076
6077// telegramHost adapts *Interactive to telegram.Host so the bridge
6078// can call back into the TUI without importing modes directly.
6079type telegramHost struct{ iv *Interactive }
6080
6081func (h *telegramHost) SubmitOrQueue(prompt string, images []provider.ImageBlock) {
6082 h.iv.SubmitOrQueue(prompt, images)
6083}
6084
6085func (h *telegramHost) CancelTurn() { h.iv.CancelTurn() }
6086
6087func (h *telegramHost) Status() string {
6088 h.iv.mu.Lock()
6089 providerName := h.iv.cfg.Provider
6090 model := h.iv.cfg.Model
6091 cwd := h.iv.cfg.CWD
6092 usage := h.iv.cumUsage
6093 subscription := h.iv.cfg.AuthMethod == "oauth"
6094 ctxUsed := h.iv.lastCtxInput
6095 busy := h.iv.busy
6096 queued := len(h.iv.queued)
6097 h.iv.mu.Unlock()
6098
6099 ctxMax := 0
6100 if m, err := provider.FindModel(providerName, model); err == nil {
6101 ctxMax = m.ContextWindow
6102 }
6103 return telegram.FormatStatus(telegram.StatusSnapshot{
6104 Provider: providerName,
6105 Model: model,
6106 CWD: cwd,
6107 Usage: usage,
6108 Subscription: subscription,
6109 ContextUsed: ctxUsed,
6110 ContextMax: ctxMax,
6111 Busy: busy,
6112 Queued: queued,
6113 })
6114}
6115
6116func (h *telegramHost) Notify(level, message string) {
6117 h.iv.mu.Lock()
6118 switch level {
6119 case "error", "warn":
6120 h.iv.statusErr = message
6121 h.iv.statusOK = ""
6122 default:
6123 h.iv.statusOK = message
6124 h.iv.statusErr = ""
6125 }
6126 h.iv.mu.Unlock()
6127 h.iv.invalidate()
6128}
6129
6130// openSessionOpsDialog shows the picker for `/session` with no arg.
6131// Always offers export, import, fork, tree; the handlers bail with
6132// a clear status message when the precondition isn't met (empty
6133// transcript on fork; no parent/siblings on tree).
6134func (i *Interactive) openSessionOpsDialog() {
6135 items := []sessionOpsItem{
6136 {label: "export", action: "export", hint: "write the current session to a .zotsession file"},
6137 {label: "import", action: "import", hint: "load a .zotsession file into this directory"},
6138 {label: "fork", action: "fork", hint: "branch from a past user message into a new session"},
6139 {label: "tree", action: "tree", hint: "switch between branches in this directory"},
6140 }
6141 i.sessionOpsDialog.Open(items)
6142 i.invalidate()
6143}
6144
6145// doSessionOp dispatches export, import, fork, or tree. arg is the
6146// optional positional argument from e.g. /session export <path>
6147// or /session import <path>; fork and tree ignore it.
6148func (i *Interactive) doSessionOp(action, arg string) {
6149 switch action {
6150 case "export":
6151 i.doSessionExport(arg)
6152 case "import":
6153 i.doSessionImport(arg)
6154 case "fork":
6155 i.doSessionFork()
6156 case "tree":
6157 i.doSessionTree()
6158 default:
6159 i.mu.Lock()
6160 i.statusErr = "unknown /session action: " + action + " (use export, import, fork, or tree)"
6161 i.mu.Unlock()
6162 i.invalidate()
6163 }
6164}
6165
6166// doSessionExport writes the live session file to destination path
6167// dst. When dst is empty we default to ~/Downloads (falling back to
6168// the user's home directory if it doesn't exist). The helper
6169// expands a leading `~` and creates any missing parent directories.
6170func (i *Interactive) doSessionExport(dst string) {
6171 if i.cfg.CurrentSessionPath == nil {
6172 i.mu.Lock()
6173 i.statusErr = "export: no session is active (running with --no-session?)"
6174 i.mu.Unlock()
6175 i.invalidate()
6176 return
6177 }
6178 src := i.cfg.CurrentSessionPath()
6179 if src == "" {
6180 i.mu.Lock()
6181 i.statusErr = "export: no session is active (running with --no-session?)"
6182 i.mu.Unlock()
6183 i.invalidate()
6184 return
6185 }
6186 // Persist any in-memory agent messages to the session file so
6187 // the export carries the full conversation. Without this, the
6188 // default lazy-flush-at-exit strategy leaves most of a running
6189 // session unwritten and the export ends up with only the meta.
6190 if i.cfg.FlushSession != nil {
6191 i.cfg.FlushSession()
6192 }
6193 dst = unquotePath(dst)
6194 if dst == "" {
6195 dst = defaultExportDir()
6196 } else {
6197 dst = expandTilde(dst)
6198 }
6199 out, err := core.ExportSession(src, dst)
6200 if err != nil {
6201 i.mu.Lock()
6202 i.statusErr = "export: " + err.Error()
6203 i.mu.Unlock()
6204 i.invalidate()
6205 return
6206 }
6207 i.mu.Lock()
6208 i.statusOK = "exported session to " + friendlyPath(out)
6209 i.statusErr = ""
6210 i.mu.Unlock()
6211 i.invalidate()
6212}
6213
6214// doSessionImport copies the .zotsession file at src into the
6215// running cwd's sessions directory and loads it as the active
6216// session, same as `/sessions` -> pick. When src is empty we ask
6217// the user to pass a path (no usable default here).
6218func (i *Interactive) doSessionImport(src string) {
6219 src = unquotePath(src)
6220 if src == "" {
6221 i.mu.Lock()
6222 i.statusErr = "import: pass a path — e.g. /session import ~/Downloads/work.zotsession"
6223 i.mu.Unlock()
6224 i.invalidate()
6225 return
6226 }
6227 src = expandTilde(src)
6228 if _, err := os.Stat(src); err != nil {
6229 i.mu.Lock()
6230 i.statusErr = "import: " + err.Error()
6231 i.mu.Unlock()
6232 i.invalidate()
6233 return
6234 }
6235 newPath, err := core.ImportSession(src, i.cfg.ZotHome, i.cfg.CWD, i.cfg.Version)
6236 if err != nil {
6237 i.mu.Lock()
6238 i.statusErr = "import: " + err.Error()
6239 i.mu.Unlock()
6240 i.invalidate()
6241 return
6242 }
6243 if i.cfg.LoadSession == nil {
6244 i.mu.Lock()
6245 i.statusOK = "imported session at " + friendlyPath(newPath) + " (run /sessions to resume it)"
6246 i.statusErr = ""
6247 i.mu.Unlock()
6248 i.invalidate()
6249 return
6250 }
6251 if err := i.cfg.LoadSession(newPath); err != nil {
6252 i.mu.Lock()
6253 i.statusErr = "import: load failed: " + err.Error()
6254 i.mu.Unlock()
6255 i.invalidate()
6256 return
6257 }
6258 i.mu.Lock()
6259 i.statusOK = "imported and switched to session " + friendlyPath(newPath)
6260 i.statusErr = ""
6261 i.mu.Unlock()
6262 i.invalidate()
6263}
6264
6265// defaultExportDir returns ~/Downloads when it exists, or ~ as a
6266// fallback, or /tmp on exotic machines with no home dir.
6267func defaultExportDir() string {
6268 home, err := os.UserHomeDir()
6269 if err != nil || home == "" {
6270 return os.TempDir()
6271 }
6272 downloads := filepath.Join(home, "Downloads")
6273 if fi, err := os.Stat(downloads); err == nil && fi.IsDir() {
6274 return downloads
6275 }
6276 return home
6277}
6278
6279// expandTilde turns a leading ~ into the user's home directory.
6280// Returns the input unchanged when there's no tilde or no home.
6281func expandTilde(p string) string {
6282 if p == "" || p[0] != '~' {
6283 return p
6284 }
6285 home, err := os.UserHomeDir()
6286 if err != nil || home == "" {
6287 return p
6288 }
6289 if len(p) == 1 {
6290 return home
6291 }
6292 if p[1] == '/' || p[1] == filepath.Separator {
6293 return filepath.Join(home, p[2:])
6294 }
6295 return p
6296}
6297
6298// unquotePath strips a matching pair of surrounding single or
6299// double quotes. Drag-drop paste in the tui auto-quotes dropped
6300// file paths so the shell-like `/session import 'foo bar.zs'`
6301// stays well-formed; when the TUI's own slash handler consumes
6302// the arg, we want the raw path back.
6303func unquotePath(p string) string {
6304 p = strings.TrimSpace(p)
6305 if len(p) >= 2 {
6306 first := p[0]
6307 last := p[len(p)-1]
6308 if (first == '\'' && last == '\'') || (first == '"' && last == '"') {
6309 p = p[1 : len(p)-1]
6310 }
6311 }
6312 return p
6313}
6314
6315// friendlyPath collapses the user's home directory to a leading ~
6316// so status messages read cleanly. Falls back to the raw path when
6317// the home dir is unknown.
6318func friendlyPath(p string) string {
6319 home, err := os.UserHomeDir()
6320 if err != nil || home == "" {
6321 return p
6322 }
6323 if strings.HasPrefix(p, home+string(filepath.Separator)) {
6324 return "~" + p[len(home):]
6325 }
6326 return p
6327}
6328
6329// doSessionFork opens the /jump turn picker in "fork mode". The
6330// next selection branches the current session at that user turn
6331// instead of scrolling the viewport to it.
6332func (i *Interactive) doSessionFork() {
6333 if i.cfg.CurrentSessionPath == nil || i.cfg.CurrentSessionPath() == "" {
6334 i.mu.Lock()
6335 i.statusErr = "fork: no session is active (running with --no-session?)"
6336 i.mu.Unlock()
6337 i.invalidate()
6338 return
6339 }
6340 msgs := []provider.Message{}
6341 if i.agent != nil {
6342 msgs = i.agent.Messages()
6343 }
6344 if len(msgs) == 0 {
6345 i.mu.Lock()
6346 i.statusErr = "fork: transcript is empty; nothing to fork from"
6347 i.mu.Unlock()
6348 i.invalidate()
6349 return
6350 }
6351 i.pendingFork = true
6352 i.jumpDialog.Open(msgs, "")
6353 i.invalidate()
6354}
6355
6356// doSessionTree shows the current session family as an inline branch tree.
6357// Pick an entry to check out that point into a new branch. Subsequent
6358// prompts use only context up to the selected message.
6359func (i *Interactive) doSessionTree() {
6360 current := ""
6361 if i.cfg.CurrentSessionPath != nil {
6362 current = i.cfg.CurrentSessionPath()
6363 }
6364 if current != "" && i.sessionTreeDialog.OpenSessionFamily(i.cfg.ZotHome, i.cfg.CWD, current) {
6365 i.invalidate()
6366 return
6367 }
6368 msgs := []provider.Message{}
6369 if i.agent != nil {
6370 msgs = i.agent.Messages()
6371 }
6372 if len(msgs) == 0 {
6373 i.mu.Lock()
6374 i.statusErr = "tree: no messages in this session yet"
6375 i.mu.Unlock()
6376 i.invalidate()
6377 return
6378 }
6379 if !i.sessionTreeDialog.OpenMessages(msgs) {
6380 i.mu.Lock()
6381 i.statusErr = "tree: no messages to show"
6382 i.mu.Unlock()
6383 }
6384 i.invalidate()
6385}
6386
6387// applySessionTreeMessageSelection checks out the selected message into a
6388// new branch, then switches the running agent to that branch. Unlike /jump,
6389// this changes future model context: only messages up to the selected row are
6390// sent on subsequent turns.
6391func (i *Interactive) applySessionTreeMessageSelection(src string, msgIdx, turnNo int, role provider.Role, prompt string) {
6392 if i.cfg.CurrentSessionPath == nil || i.cfg.LoadSession == nil {
6393 i.mu.Lock()
6394 i.statusErr = "tree: session branching is not available in this build"
6395 i.mu.Unlock()
6396 i.invalidate()
6397 return
6398 }
6399 if src == "" {
6400 src = i.cfg.CurrentSessionPath()
6401 }
6402 if src == "" {
6403 i.mu.Lock()
6404 i.statusErr = "tree: no session is active"
6405 i.mu.Unlock()
6406 i.invalidate()
6407 return
6408 }
6409 if i.cfg.FlushSession != nil {
6410 i.cfg.FlushSession()
6411 }
6412 upTo := msgIdx + 1
6413 if role == provider.RoleUser {
6414 upTo = msgIdx
6415 }
6416 newPath, err := core.BranchSessionHidden(src, i.cfg.ZotHome, i.cfg.CWD, i.cfg.Version, upTo)
6417 if err != nil {
6418 i.mu.Lock()
6419 i.statusErr = "tree: " + err.Error()
6420 i.mu.Unlock()
6421 i.invalidate()
6422 return
6423 }
6424 if err := i.cfg.LoadSession(newPath); err != nil {
6425 i.mu.Lock()
6426 i.statusErr = "tree: checkout failed: " + err.Error()
6427 i.mu.Unlock()
6428 i.invalidate()
6429 return
6430 }
6431 i.scrollToBottom()
6432 i.mu.Lock()
6433 if role == provider.RoleUser {
6434 i.ed.SetValue(prompt)
6435 i.statusOK = fmt.Sprintf("checked out before turn %d; edit and send to branch", turnNo)
6436 } else {
6437 i.statusOK = fmt.Sprintf("checked out turn %d into a new branch", turnNo)
6438 }
6439 i.statusErr = ""
6440 i.mu.Unlock()
6441 i.invalidate()
6442}
6443
6444// applyForkSelection branches the current session at msgIdx+1 (so
6445// the selected user message and everything before it is included
6446// in the new branch), then switches the running agent to the new
6447// file. Called from the jump-dialog handler when pendingFork=true.
6448func (i *Interactive) applyForkSelection(msgIdx int) {
6449 i.pendingFork = false
6450 if i.cfg.CurrentSessionPath == nil {
6451 i.mu.Lock()
6452 i.statusErr = "fork: no session is active"
6453 i.mu.Unlock()
6454 i.invalidate()
6455 return
6456 }
6457 src := i.cfg.CurrentSessionPath()
6458 if src == "" {
6459 i.mu.Lock()
6460 i.statusErr = "fork: no session is active"
6461 i.mu.Unlock()
6462 i.invalidate()
6463 return
6464 }
6465 if i.cfg.FlushSession != nil {
6466 i.cfg.FlushSession()
6467 }
6468 // msgIdx is 0-indexed message position; copy msgIdx+1 rows so
6469 // the selected user message is included.
6470 upTo := msgIdx + 1
6471 newPath, err := core.BranchSession(src, i.cfg.ZotHome, i.cfg.CWD, i.cfg.Version, upTo)
6472 if err != nil {
6473 i.mu.Lock()
6474 i.statusErr = "fork: " + err.Error()
6475 i.mu.Unlock()
6476 i.invalidate()
6477 return
6478 }
6479 if i.cfg.LoadSession == nil {
6480 i.mu.Lock()
6481 i.statusOK = "forked at message " + formatInt(upTo) + " (run /sessions to resume)"
6482 i.statusErr = ""
6483 i.mu.Unlock()
6484 i.invalidate()
6485 return
6486 }
6487 if err := i.cfg.LoadSession(newPath); err != nil {
6488 i.mu.Lock()
6489 i.statusErr = "fork: switch failed: " + err.Error()
6490 i.mu.Unlock()
6491 i.invalidate()
6492 return
6493 }
6494 i.mu.Lock()
6495 i.statusOK = "forked and switched to new branch at " + friendlyPath(newPath)
6496 i.statusErr = ""
6497 i.mu.Unlock()
6498 i.invalidate()
6499}
6500
6501// formatInt is a tiny strconv.Itoa shim; keeps the handler above
6502// from needing a strconv import just for one call.
6503func formatInt(n int) string {
6504 return fmt.Sprintf("%d", n)
6505}
6506
6507// assistantText returns the concatenated text of every TextBlock in
6508// m. Used by the streaming-view dedupe guard to tell when a live
6509// streamed reply has already been promoted into the transcript.
6510func assistantText(m provider.Message) string {
6511 var sb strings.Builder
6512 for _, c := range m.Content {
6513 if tb, ok := c.(provider.TextBlock); ok {
6514 if sb.Len() > 0 {
6515 sb.WriteByte('\n')
6516 }
6517 sb.WriteString(tb.Text)
6518 }
6519 }
6520 return sb.String()
6521}
6522
6523// resetStreamingStateLocked clears every piece of streaming state
6524// in one shot. Used by abort paths (turn cancel, compact finish,
6525// queue drain) so the pacer doesn't keep draining stale runes from
6526// a prior turn. Must be called with i.mu held.
6527func (i *Interactive) resetStreamingStateLocked() {
6528 i.streaming.Reset()
6529 i.streamPending = i.streamPending[:0]
6530 i.streamFlushPending = false
6531 i.streamOn = false
6532 i.openAllToolGatesLocked()
6533}
6534
6535// openAllToolGatesLocked drops every pending tool gate so that any
6536// tool registered during this turn renders unconditionally from now
6537// on. Called when streaming finalizes (the paced text has fully
6538// drained and `streaming` is about to reset to length 0): without
6539// this, the gate comparison against a freshly-reset streaming buffer
6540// would wrongly re-hide tools that had already cleared their gate.
6541// Must be called with i.mu held.
6542func (i *Interactive) openAllToolGatesLocked() {
6543 for id := range i.toolGate {
6544 i.toolGate[id] = 0
6545 }
6546}
6547
6548// gateToolLocked records the stream position at which a tool call may
6549// become visible. The gate is the total length the streaming buffer
6550// will reach once the pacer has drained everything currently queued
6551// (already painted + still pending). Holding the tool block back
6552// until the pacer crosses that mark guarantees the prose emitted
6553// before the tool call finishes typing out above it, instead of the
6554// tool block snapping in while the paragraph is still filling in.
6555//
6556// We only gate while text is actively streaming. If no stream is in
6557// flight (gate 0), the tool shows immediately, which is the correct
6558// behaviour for tool-only turns and replayed sessions. First
6559// registration wins so a later EvToolCall can't move an existing
6560// gate. Must be called with i.mu held.
6561func (i *Interactive) gateToolLocked(id string) {
6562 if _, ok := i.toolGate[id]; ok {
6563 return
6564 }
6565 if !i.streamOn {
6566 i.toolGate[id] = 0
6567 return
6568 }
6569 i.toolGate[id] = i.streaming.Len() + len(i.streamPending)
6570}
6571
6572// toolGateOpenLocked reports whether the gated tool block may render
6573// yet, i.e. the pacer has drained enough text to reach the position
6574// recorded when the tool call arrived. Must be called with i.mu held.
6575func (i *Interactive) toolGateOpenLocked(id string) bool {
6576 gate, ok := i.toolGate[id]
6577 if !ok || gate == 0 {
6578 return true
6579 }
6580 return i.streaming.Len() >= gate
6581}
6582
6583// assistantMessageSideEffects runs the non-visual hooks attached to
6584// EvAssistantMessage: the host-provided OnAssistant callback and the
6585// telegram-bridge mirror. Called with i.mu held.
6586//
6587// Factored out of handleEvent because the streaming pacer may defer
6588// visual reset until after the last buffered rune has painted, but
6589// the callbacks themselves must fire on message arrival so
6590// downstream observers (session persistence, telegram, cost panels)
6591// don't wait on a UI animation to catch up.
6592func (i *Interactive) assistantMessageSideEffects(m provider.Message) {
6593 if i.cfg.OnAssistant != nil {
6594 i.cfg.OnAssistant(m)
6595 }
6596 if i.telegramBridge != nil && i.telegramBridge.Active() {
6597 var sb strings.Builder
6598 for _, c := range m.Content {
6599 if tb, ok := c.(provider.TextBlock); ok {
6600 if sb.Len() > 0 {
6601 sb.WriteString("\n")
6602 }
6603 sb.WriteString(tb.Text)
6604 }
6605 }
6606 if text := sb.String(); strings.TrimSpace(text) != "" {
6607 go i.telegramBridge.OnAssistantText(text)
6608 }
6609 }
6610}
6611
6612// paintPaceRate is how many runes the streaming pacer releases per
6613// tick. With a 16ms tick, 6 runes/tick is ~375 runes/s — fast enough
6614// that a 500-rune summary finishes in ~1.3s, slow enough to look
6615// like a human typing. Empirically matches the feel of provider
6616// paths that already drip-stream natively.
6617const paintPaceRate = 6
6618
6619// paintPaceInterval is the tick interval for the streaming pacer.
6620// 16ms lines up with the redraw throttle so we never paint faster
6621// than the terminal can keep up.
6622const paintPaceInterval = 16 * time.Millisecond
6623
6624// runStreamPacer drains buffered deltas from streamPending into
6625// streaming a small batch per tick, invalidating after each move.
6626// It stops when the context cancels (tui shutdown).
6627//
6628// Why a pacer: providers differ wildly in how they chunk their
6629// text_delta events. The API-key path on Anthropic emits ~30 drips
6630// for a 400-token summary; the OAuth path can coalesce the same
6631// summary into 3 fat chunks, visually indistinguishable from "the
6632// whole reply just appeared". The pacer normalizes that so every
6633// path looks the same on screen.
6634func (i *Interactive) runStreamPacer(ctx context.Context) {
6635 t := time.NewTicker(paintPaceInterval)
6636 defer t.Stop()
6637 for {
6638 select {
6639 case <-ctx.Done():
6640 return
6641 case <-t.C:
6642 i.mu.Lock()
6643 if len(i.streamPending) == 0 {
6644 // EvAssistantMessage already fired but the pacer
6645 // was still draining a tick ago. Everything is now
6646 // painted; clear the streaming flags so the next
6647 // redraw shows the finalised transcript message
6648 // and hides the streaming overlay.
6649 if i.streamFlushPending {
6650 i.streamFlushPending = false
6651 i.streaming.Reset()
6652 i.streamOn = false
6653 i.openAllToolGatesLocked()
6654 i.mu.Unlock()
6655 i.invalidate()
6656 continue
6657 }
6658 i.mu.Unlock()
6659 continue
6660 }
6661 n := paintPaceRate
6662 if n > len(i.streamPending) {
6663 n = len(i.streamPending)
6664 }
6665 i.streaming.WriteString(string(i.streamPending[:n]))
6666 i.streamPending = i.streamPending[n:]
6667 i.mu.Unlock()
6668 i.invalidate()
6669 }
6670 }
6671}