v0.3.34 4548953

interactive.go RawHistoryFolder

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

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 476

↑↓ moveenter openesc close