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