v0.3.21 7fe8dbc

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

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 477

↑↓ moveenter openesc close