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