v0.3.20 03bd33c

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

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