main
   1import type { ExtensionAPI, ExtensionContext, ModelSelectEvent, ThinkingLevel } from "@mariozechner/pi-coding-agent";
   2import { CustomEditor, DynamicBorder, ModelSelectorComponent, SettingsManager } from "@mariozechner/pi-coding-agent";
   3import { Container, type SelectItem, SelectList, Text, matchesKey, Key } from "@mariozechner/pi-tui";
   4import path from "node:path";
   5import os from "node:os";
   6import fs from "node:fs/promises";
   7import type { Dirent } from "node:fs";
   8
   9// =============================================================================
  10// Modes
  11// =============================================================================
  12
  13type ModeName = string;
  14
  15type ModeSpec = {
  16	provider?: string;
  17	modelId?: string;
  18	thinkingLevel?: ThinkingLevel;
  19	/**
  20	 * Optional theme color token to use for the editor border.
  21	 * If unset, the border color is derived from the (current) thinking level.
  22	 */
  23	color?: string;
  24};
  25
  26type ModesFile = {
  27	version: 1;
  28	currentMode: ModeName;
  29	modes: Record<ModeName, ModeSpec>;
  30};
  31
  32// Only "default" is a forced/built-in mode. Others are just initial suggestions and can be renamed/deleted.
  33const DEFAULT_MODE_ORDER = ["default"] as const;
  34const CUSTOM_MODE_NAME = "custom" as const;
  35
  36function expandUserPath(p: string): string {
  37	if (p === "~") return os.homedir();
  38	if (p.startsWith("~/")) return path.join(os.homedir(), p.slice(2));
  39	return p;
  40}
  41
  42function getGlobalAgentDir(): string {
  43	// Mirror pi-coding-agent's getAgentDir() behavior (best-effort).
  44	// For the canonical implementation see pi-mono/packages/coding-agent/src/config.ts
  45	const env = process.env.PI_CODING_AGENT_DIR;
  46	if (env) return expandUserPath(env);
  47	return path.join(os.homedir(), ".pi", "agent");
  48}
  49
  50function getGlobalModesPath(): string {
  51	return path.join(getGlobalAgentDir(), "modes.json");
  52}
  53
  54function getProjectModesPath(cwd: string): string {
  55	return path.join(cwd, ".pi", "modes.json");
  56}
  57
  58async function fileExists(p: string): Promise<boolean> {
  59	try {
  60		await fs.stat(p);
  61		return true;
  62	} catch {
  63		return false;
  64	}
  65}
  66
  67async function ensureDirForFile(filePath: string): Promise<void> {
  68	await fs.mkdir(path.dirname(filePath), { recursive: true });
  69}
  70
  71async function getMtimeMs(p: string): Promise<number | null> {
  72	try {
  73		const st = await fs.stat(p);
  74		return st.mtimeMs;
  75	} catch {
  76		return null;
  77	}
  78}
  79
  80function sleep(ms: number): Promise<void> {
  81	return new Promise((resolve) => setTimeout(resolve, ms));
  82}
  83
  84function getLockPathForFile(filePath: string): string {
  85	// Lock file next to the json so it works across processes.
  86	return `${filePath}.lock`;
  87}
  88
  89async function withFileLock<T>(filePath: string, fn: () => Promise<T>): Promise<T> {
  90	const lockPath = getLockPathForFile(filePath);
  91	await ensureDirForFile(lockPath);
  92
  93	const start = Date.now();
  94	while (true) {
  95		try {
  96			const handle = await fs.open(lockPath, "wx");
  97			try {
  98				// Best-effort metadata for debugging stale locks.
  99				await handle.writeFile(
 100					JSON.stringify({ pid: process.pid, createdAt: new Date().toISOString() }) + "\n",
 101					"utf8"
 102				);
 103			} catch {
 104				// ignore
 105			}
 106
 107			try {
 108				return await fn();
 109			} finally {
 110				await handle.close().catch(() => {});
 111				await fs.unlink(lockPath).catch(() => {});
 112			}
 113		} catch (err: any) {
 114			if (err?.code !== "EEXIST") throw err;
 115
 116			// If the lock looks stale (crash), break it.
 117			try {
 118				const st = await fs.stat(lockPath);
 119				if (Date.now() - st.mtimeMs > 30_000) {
 120					await fs.unlink(lockPath);
 121					continue;
 122				}
 123			} catch {
 124				// ignore
 125			}
 126
 127			if (Date.now() - start > 5_000) {
 128				// Don't hang the UI forever.
 129				throw new Error(`Timed out waiting for lock: ${lockPath}`);
 130			}
 131			await sleep(40 + Math.random() * 80);
 132		}
 133	}
 134}
 135
 136async function atomicWriteUtf8(filePath: string, content: string): Promise<void> {
 137	await ensureDirForFile(filePath);
 138
 139	const dir = path.dirname(filePath);
 140	const base = path.basename(filePath);
 141	const tmpPath = path.join(dir, `.${base}.tmp.${process.pid}.${Math.random().toString(16).slice(2)}`);
 142
 143	await fs.writeFile(tmpPath, content, "utf8");
 144
 145	try {
 146		// POSIX: atomic replace.
 147		await fs.rename(tmpPath, filePath);
 148	} catch (err: any) {
 149		// Windows: rename can't overwrite.
 150		if (err?.code === "EEXIST" || err?.code === "EPERM") {
 151			await fs.unlink(filePath).catch(() => {});
 152			await fs.rename(tmpPath, filePath);
 153		} else {
 154			// best-effort cleanup
 155			await fs.unlink(tmpPath).catch(() => {});
 156			throw err;
 157		}
 158	}
 159}
 160
 161function cloneModesFile(file: ModesFile): ModesFile {
 162	// JSON-based clone is fine here (small, plain data structure).
 163	return JSON.parse(JSON.stringify(file)) as ModesFile;
 164}
 165
 166type ModeSpecPatch = {
 167	provider?: string | null;
 168	modelId?: string | null;
 169	thinkingLevel?: ThinkingLevel | null;
 170	color?: string | null;
 171};
 172
 173type ModesPatch = {
 174	currentMode?: ModeName;
 175	modes?: Record<ModeName, ModeSpecPatch | null>;
 176};
 177
 178function computeModesPatch(base: ModesFile, next: ModesFile, includeCurrentMode: boolean): ModesPatch | null {
 179	const patch: ModesPatch = {};
 180
 181	if (includeCurrentMode && base.currentMode !== next.currentMode) {
 182		patch.currentMode = next.currentMode;
 183	}
 184
 185	const keys = new Set([...Object.keys(base.modes), ...Object.keys(next.modes)]);
 186	const modesPatch: Record<ModeName, ModeSpecPatch | null> = {};
 187
 188	for (const k of keys) {
 189		const a = base.modes[k];
 190		const b = next.modes[k];
 191
 192		if (!b) {
 193			if (a) modesPatch[k] = null;
 194			continue;
 195		}
 196		if (!a) {
 197			modesPatch[k] = { ...b };
 198			continue;
 199		}
 200
 201		const diff: ModeSpecPatch = {};
 202		const fields: (keyof ModeSpec)[] = ["provider", "modelId", "thinkingLevel", "color"];
 203		for (const f of fields) {
 204			const av = a[f];
 205			const bv = b[f];
 206			if (av !== bv) {
 207				(diff as any)[f] = bv === undefined ? null : bv;
 208			}
 209		}
 210		if (Object.keys(diff).length > 0) {
 211			modesPatch[k] = diff;
 212		}
 213	}
 214
 215	if (Object.keys(modesPatch).length > 0) {
 216		patch.modes = modesPatch;
 217	}
 218
 219	if (!patch.modes && patch.currentMode === undefined) return null;
 220	return patch;
 221}
 222
 223function applyModesPatch(target: ModesFile, patch: ModesPatch): void {
 224	if (patch.currentMode !== undefined) {
 225		target.currentMode = patch.currentMode;
 226	}
 227
 228	if (!patch.modes) return;
 229	for (const [mode, specPatch] of Object.entries(patch.modes)) {
 230		if (specPatch === null) {
 231			delete target.modes[mode];
 232			continue;
 233		}
 234
 235		const targetSpec: Record<string, unknown> = ((target.modes[mode] ??= {}) as any) ?? {};
 236		for (const [k, v] of Object.entries(specPatch)) {
 237			if (v === null || v === undefined) {
 238				delete targetSpec[k];
 239			} else {
 240				targetSpec[k] = v;
 241			}
 242		}
 243	}
 244}
 245
 246function normalizeThinkingLevel(level: unknown): ThinkingLevel | undefined {
 247	if (typeof level !== "string") return undefined;
 248	const v = level as ThinkingLevel;
 249	// Keep the list local to avoid importing internal enums.
 250	const allowed: ThinkingLevel[] = ["off", "minimal", "low", "medium", "high", "xhigh"];
 251	return allowed.includes(v) ? v : undefined;
 252}
 253
 254function sanitizeModeSpec(spec: unknown): ModeSpec {
 255	const obj = (spec && typeof spec === "object" ? spec : {}) as Record<string, unknown>;
 256	return {
 257		provider: typeof obj.provider === "string" ? obj.provider : undefined,
 258		modelId: typeof obj.modelId === "string" ? obj.modelId : undefined,
 259		thinkingLevel: normalizeThinkingLevel(obj.thinkingLevel),
 260		color: typeof obj.color === "string" ? obj.color : undefined,
 261	};
 262}
 263
 264function createDefaultModes(ctx: ExtensionContext, pi: ExtensionAPI): ModesFile {
 265	const currentModel = ctx.model;
 266	const currentThinking = pi.getThinkingLevel();
 267
 268	const base: ModeSpec = {
 269		provider: currentModel?.provider,
 270		modelId: currentModel?.id,
 271		thinkingLevel: currentThinking,
 272	};
 273
 274	return {
 275		version: 1,
 276		currentMode: "default",
 277		modes: {
 278			// Forced default mode
 279			default: { ...base },
 280			// Convenience mode (user can delete/rename)
 281			fast: { ...base, thinkingLevel: "off" },
 282		},
 283	};
 284}
 285
 286function ensureDefaultModeEntries(file: ModesFile, ctx: ExtensionContext, pi: ExtensionAPI): void {
 287	for (const name of DEFAULT_MODE_ORDER) {
 288		if (!file.modes[name]) {
 289			const defaults = createDefaultModes(ctx, pi);
 290			file.modes[name] = defaults.modes[name];
 291		}
 292	}
 293
 294	// "custom" is an overlay mode; never treat it as a valid persisted current mode.
 295	if (file.currentMode === CUSTOM_MODE_NAME) {
 296		file.currentMode = "" as any;
 297	}
 298
 299	if (!file.currentMode || !(file.currentMode in file.modes) || file.currentMode === CUSTOM_MODE_NAME) {
 300		const first = Object.keys(file.modes).find((k) => k !== CUSTOM_MODE_NAME);
 301		file.currentMode = file.modes.default ? "default" : first || "default";
 302	}
 303}
 304
 305async function loadModesFile(filePath: string, ctx: ExtensionContext, pi: ExtensionAPI): Promise<ModesFile> {
 306	try {
 307		const raw = await fs.readFile(filePath, "utf8");
 308		const parsed = JSON.parse(raw) as Record<string, unknown>;
 309		const currentMode = typeof parsed.currentMode === "string" ? parsed.currentMode : "default";
 310		const modesRaw = parsed.modes && typeof parsed.modes === "object" ? (parsed.modes as Record<string, unknown>) : {};
 311		const modes: Record<string, ModeSpec> = {};
 312		for (const [k, v] of Object.entries(modesRaw)) {
 313			modes[k] = sanitizeModeSpec(v);
 314		}
 315		const file: ModesFile = {
 316			version: 1,
 317			currentMode,
 318			modes,
 319		};
 320		ensureDefaultModeEntries(file, ctx, pi);
 321		return file;
 322	} catch {
 323		return createDefaultModes(ctx, pi);
 324	}
 325}
 326
 327async function saveModesFile(filePath: string, data: ModesFile): Promise<void> {
 328	await atomicWriteUtf8(filePath, JSON.stringify(data, null, 2) + "\n");
 329}
 330
 331function orderedModeNames(modes: Record<string, ModeSpec>): string[] {
 332	// Preserve insertion order from the JSON file.
 333	// Object key iteration order is stable in modern JS runtimes.
 334	// NOTE: "custom" is an overlay mode and must not be selectable/persisted.
 335	return Object.keys(modes).filter((name) => name !== CUSTOM_MODE_NAME);
 336}
 337
 338/** Convert hex color (#rrggbb) to 24-bit ANSI foreground escape sequence */
 339function hexToAnsi(hex: string): string {
 340	const h = hex.replace("#", "");
 341	const r = parseInt(h.substring(0, 2), 16);
 342	const g = parseInt(h.substring(2, 4), 16);
 343	const b = parseInt(h.substring(4, 6), 16);
 344	return `\x1b[38;2;${r};${g};${b}m`;
 345}
 346
 347/**
 348 * Patch Editor.prototype.render so that every editor instance (file-picker,
 349 * shell-completions, default, etc.) picks up the current mode's border color
 350 * at render time. This avoids fighting with setEditorComponent ordering and
 351 * pi's updateEditorBorderColor which we can't trigger from the extension API.
 352 *
 353 * On each render call, if the current mode has a custom color, we temporarily
 354 * swap the editor's borderColor, call the original render, then restore it.
 355 */
 356let editorPatchInstalled = false;
 357function installEditorBorderPatch(): void {
 358	if (editorPatchInstalled) return;
 359
 360	// Get Editor.prototype via the CustomEditor import
 361	const EditorProto = Object.getPrototypeOf(CustomEditor.prototype);
 362	if (!EditorProto || typeof EditorProto.render !== "function") return;
 363
 364	const originalRender = EditorProto.render as (this: { borderColor: (text: string) => string }, width: number) => string[];
 365
 366	EditorProto.render = function patchedRender(this: { borderColor: (text: string) => string }, width: number): string[] {
 367		const spec = runtime.data.modes[runtime.currentMode];
 368		if (spec?.color && /^#[0-9a-fA-F]{6}$/.test(spec.color)) {
 369			const ansi = hexToAnsi(spec.color);
 370			const saved = this.borderColor;
 371			this.borderColor = (text: string) => `${ansi}${text}\x1b[39m`;
 372			const result = originalRender.call(this, width);
 373			this.borderColor = saved;
 374			return result;
 375		}
 376		return originalRender.call(this, width);
 377	};
 378
 379	editorPatchInstalled = true;
 380}
 381
 382async function resolveModesPath(cwd: string): Promise<string> {
 383	const projectPath = getProjectModesPath(cwd);
 384	if (await fileExists(projectPath)) return projectPath;
 385	return getGlobalModesPath();
 386}
 387
 388function inferModeFromSelection(ctx: ExtensionContext, pi: ExtensionAPI, data: ModesFile): string | null {
 389	const provider = ctx.model?.provider;
 390	const modelId = ctx.model?.id;
 391	const thinkingLevel = pi.getThinkingLevel();
 392	if (!provider || !modelId) return null;
 393
 394	// Only consider persisted/real modes (exclude the overlay "custom").
 395	const names = orderedModeNames(data.modes);
 396
 397	const supportsThinking = Boolean(ctx.model?.reasoning);
 398
 399	// 1) If thinking is supported, require an exact match so modes can differ by thinking level.
 400	if (supportsThinking) {
 401		for (const name of names) {
 402			const spec = data.modes[name];
 403			if (!spec) continue;
 404			if (spec.provider !== provider || spec.modelId !== modelId) continue;
 405			if ((spec.thinkingLevel ?? undefined) !== thinkingLevel) continue;
 406			return name;
 407		}
 408		return null;
 409	}
 410
 411	// 2) If thinking is NOT supported by the model, the effective level will always be "off".
 412	// In that case, treat thinkingLevel differences in modes.json as non-distinguishing.
 413	const candidates: string[] = [];
 414	for (const name of names) {
 415		const spec = data.modes[name];
 416		if (!spec) continue;
 417		if (spec.provider !== provider || spec.modelId !== modelId) continue;
 418		candidates.push(name);
 419	}
 420	if (candidates.length === 0) return null;
 421
 422	// Prefer a candidate that explicitly matches the effective thinking level.
 423	for (const name of candidates) {
 424		const spec = data.modes[name];
 425		if (!spec) continue;
 426		if ((spec.thinkingLevel ?? "off") === thinkingLevel) return name;
 427	}
 428
 429	// Next prefer a candidate with no thinkingLevel configured.
 430	for (const name of candidates) {
 431		const spec = data.modes[name];
 432		if (!spec) continue;
 433		if (!spec.thinkingLevel) return name;
 434	}
 435
 436	return candidates[0] ?? null;
 437}
 438
 439type ModeRuntime = {
 440	filePath: string;
 441	fileMtimeMs: number | null;
 442	/**
 443	 * Snapshot of what we last loaded/synced from disk. Used to compute patches so
 444	 * multiple running pi processes don't clobber each other's mode edits.
 445	 */
 446	baseline: ModesFile | null;
 447	data: ModesFile;
 448
 449	/**
 450	 * Last non-overlay mode. Used as cycle base while in the overlay "custom" mode.
 451	 */
 452	lastRealMode: string;
 453
 454	/**
 455	 * The effective current mode. Can temporarily be "custom" (overlay),
 456	 * which is *not* persisted and not selectable via /mode.
 457	 */
 458	currentMode: string;
 459	// guard against feedback loops when we switch model ourselves
 460	applying: boolean;
 461};
 462
 463const runtime: ModeRuntime = {
 464	filePath: "",
 465	fileMtimeMs: null,
 466	baseline: null,
 467	data: { version: 1, currentMode: "default", modes: {} },
 468	lastRealMode: "default",
 469	currentMode: "default",
 470	applying: false,
 471};
 472
 473// Updated by setEditor() when the custom editor is instantiated.
 474let requestEditorRender: (() => void) | undefined;
 475
 476// Update the mode status in the footer via ctx.ui.setStatus
 477// Sets "mode" (name) and "mode-color" (hex color from modes.json)
 478function updateModeStatus(ctx: ExtensionContext): void {
 479	if (!ctx.hasUI) return;
 480	const mode = runtime.currentMode;
 481	if (mode && mode !== "default") {
 482		const spec = runtime.data.modes[mode];
 483		ctx.ui.setStatus("mode", mode);
 484		ctx.ui.setStatus("mode-color", spec?.color || undefined);
 485	} else {
 486		ctx.ui.setStatus("mode", undefined);
 487		ctx.ui.setStatus("mode-color", undefined);
 488	}
 489}
 490
 491async function ensureRuntime(pi: ExtensionAPI, ctx: ExtensionContext): Promise<void> {
 492	const filePath = await resolveModesPath(ctx.cwd);
 493
 494	const mtimeMs = await getMtimeMs(filePath);
 495	const filePathChanged = runtime.filePath !== filePath;
 496	const fileChanged = filePathChanged || runtime.fileMtimeMs !== mtimeMs;
 497
 498	if (fileChanged) {
 499		runtime.filePath = filePath;
 500		runtime.fileMtimeMs = mtimeMs;
 501
 502		const loaded = await loadModesFile(filePath, ctx, pi);
 503		// Normalize/ensure defaults *before* we snapshot baseline so later persistence
 504		// only reflects explicit user actions ("store").
 505		ensureDefaultModeEntries(loaded, ctx, pi);
 506		runtime.data = loaded;
 507		runtime.baseline = cloneModesFile(runtime.data);
 508
 509		// Reset overlay when switching projects.
 510		if (filePathChanged && runtime.currentMode !== CUSTOM_MODE_NAME) {
 511			runtime.currentMode = runtime.data.currentMode;
 512			runtime.lastRealMode = runtime.currentMode;
 513		}
 514	}
 515
 516	// If we're not in the overlay "custom" mode, ensure currentMode is valid.
 517	if (runtime.currentMode !== CUSTOM_MODE_NAME) {
 518		if (!runtime.currentMode || !(runtime.currentMode in runtime.data.modes)) {
 519			runtime.currentMode = runtime.data.currentMode;
 520		}
 521		if (!runtime.lastRealMode || !(runtime.lastRealMode in runtime.data.modes)) {
 522			runtime.lastRealMode = runtime.currentMode;
 523		}
 524	}
 525}
 526
 527async function persistRuntime(pi: ExtensionAPI, ctx: ExtensionContext): Promise<void> {
 528	if (!runtime.filePath) return;
 529
 530	// Do not persist currentMode; multiple running pi sessions would fight over it.
 531	// Instead we infer the mode on startup from the active model + thinking level.
 532	runtime.baseline ??= cloneModesFile(runtime.data);
 533	const patch = computeModesPatch(runtime.baseline, runtime.data, false);
 534	if (!patch) return;
 535
 536	await withFileLock(runtime.filePath, async () => {
 537		// Merge our local patch into the latest on disk to avoid clobbering other agents.
 538		const latest = await loadModesFile(runtime.filePath, ctx, pi);
 539		applyModesPatch(latest, patch);
 540		ensureDefaultModeEntries(latest, ctx, pi);
 541		await saveModesFile(runtime.filePath, latest);
 542
 543		runtime.data = latest;
 544		runtime.baseline = cloneModesFile(latest);
 545		runtime.fileMtimeMs = await getMtimeMs(runtime.filePath);
 546	});
 547}
 548
 549// We cannot reliably read the *current* model immediately after pi.setModel() in the same tick,
 550// because ctx.model is a snapshot-ish view that is updated via the model_select event.
 551// Track the last observed model ourselves and use it for overlays / storing.
 552let lastObservedModel: { provider?: string; modelId?: string } = {};
 553
 554function getCurrentSelectionSpec(pi: ExtensionAPI, _ctx: ExtensionContext): ModeSpec {
 555	return {
 556		provider: lastObservedModel.provider,
 557		modelId: lastObservedModel.modelId,
 558		thinkingLevel: pi.getThinkingLevel(),
 559	};
 560}
 561
 562async function storeSelectionIntoMode(pi: ExtensionAPI, ctx: ExtensionContext, mode: string, selection: ModeSpec): Promise<void> {
 563	// "custom" is an overlay; it is not persisted.
 564	if (mode === CUSTOM_MODE_NAME) return;
 565
 566	await ensureRuntime(pi, ctx);
 567
 568	const existingTarget = runtime.data.modes[mode] ?? {};
 569	const next: ModeSpec = { ...existingTarget };
 570
 571	// Only overwrite fields that we can actually observe.
 572	if (selection.provider && selection.modelId) {
 573		next.provider = selection.provider;
 574		next.modelId = selection.modelId;
 575	}
 576	if (selection.thinkingLevel) next.thinkingLevel = selection.thinkingLevel;
 577
 578	runtime.data.modes[mode] = next;
 579	await persistRuntime(pi, ctx);
 580}
 581
 582async function applyMode(pi: ExtensionAPI, ctx: ExtensionContext, mode: string): Promise<void> {
 583	await ensureRuntime(pi, ctx);
 584
 585	// "custom" is a runtime-only overlay mode.
 586	if (mode === CUSTOM_MODE_NAME) {
 587		runtime.currentMode = CUSTOM_MODE_NAME;
 588		customOverlay = getCurrentSelectionSpec(pi, ctx);
 589		if (ctx.hasUI) {
 590			updateModeStatus(ctx);
 591			requestEditorRender?.();
 592		}
 593		return;
 594	}
 595
 596	const spec = runtime.data.modes[mode];
 597	if (!spec) {
 598		if (ctx.hasUI) {
 599			ctx.ui.notify(`Unknown mode: ${mode}`, "warning");
 600		}
 601		return;
 602	}
 603
 604	runtime.currentMode = mode;
 605	runtime.lastRealMode = mode;
 606	customOverlay = null;
 607
 608	runtime.applying = true;
 609	let modelAppliedOk = true;
 610	try {
 611		// Apply model
 612		if (spec.provider && spec.modelId) {
 613			const m = ctx.modelRegistry.find(spec.provider, spec.modelId);
 614			if (m) {
 615				const ok = await pi.setModel(m);
 616				modelAppliedOk = ok;
 617				if (!ok && ctx.hasUI) {
 618					ctx.ui.notify(`No API key available for ${spec.provider}/${spec.modelId}`, "warning");
 619				}
 620			} else {
 621				modelAppliedOk = false;
 622				if (ctx.hasUI) {
 623					ctx.ui.notify(`Mode "${mode}" references unknown model ${spec.provider}/${spec.modelId}`, "warning");
 624				}
 625			}
 626		}
 627
 628		// Apply thinking level
 629		if (spec.thinkingLevel) {
 630			pi.setThinkingLevel(spec.thinkingLevel);
 631		}
 632	} finally {
 633		runtime.applying = false;
 634	}
 635
 636	// If we couldn't apply the requested model (e.g. missing API key), switch to overlay.
 637	// We do *not* treat thinking-level clamping as a failure: clamping is expected when
 638	// switching between models with different thinking capabilities.
 639	if (!modelAppliedOk) {
 640		runtime.currentMode = CUSTOM_MODE_NAME;
 641		customOverlay = getCurrentSelectionSpec(pi, ctx);
 642	}
 643
 644	if (ctx.hasUI) {
 645		updateModeStatus(ctx);
 646		requestEditorRender?.();
 647	}
 648}
 649
 650const MODE_UI_CONFIGURE = "Configure modes…";
 651const MODE_UI_ADD = "Add mode…";
 652const MODE_UI_BACK = "Back";
 653
 654const ALL_THINKING_LEVELS: ThinkingLevel[] = ["off", "minimal", "low", "medium", "high", "xhigh"];
 655const THINKING_UNSET_LABEL = "(don't change)";
 656
 657function isDefaultModeName(name: string): boolean {
 658	return (DEFAULT_MODE_ORDER as readonly string[]).includes(name);
 659}
 660
 661function isReservedModeName(name: string): boolean {
 662	return name === CUSTOM_MODE_NAME || name === MODE_UI_CONFIGURE || name === MODE_UI_ADD || name === MODE_UI_BACK;
 663}
 664
 665function normalizeModeNameInput(name: string | undefined): string {
 666	return (name ?? "").trim();
 667}
 668
 669function validateModeNameOrError(
 670	name: string,
 671	existing: Record<string, ModeSpec>,
 672	opts?: { allowExisting?: boolean },
 673): string | null {
 674	if (!name) return "Mode name cannot be empty";
 675	if (/\s/.test(name)) return "Mode name cannot contain whitespace";
 676	if (isReservedModeName(name)) return `Mode name \"${name}\" is reserved`;
 677	if (!opts?.allowExisting && existing[name]) return `Mode \"${name}\" already exists`;
 678	return null;
 679}
 680
 681async function handleModeChoiceUI(pi: ExtensionAPI, ctx: ExtensionContext, choice: string): Promise<void> {
 682	// Special behavior: when we're in "custom" and select another mode,
 683	// offer to either *use* it (switch) or *store* the current custom selection into it.
 684	if (runtime.currentMode === CUSTOM_MODE_NAME && choice !== CUSTOM_MODE_NAME) {
 685		const action = await ctx.ui.select(`Mode \"${choice}\"`, ["use", "store"]);
 686		if (!action) return;
 687
 688		if (action === "use") {
 689			await applyMode(pi, ctx, choice);
 690			return;
 691		}
 692
 693		// "store": overwrite target mode with the current overlay selection (keep target color if set)
 694		await ensureRuntime(pi, ctx);
 695		const overlay = customOverlay ?? getCurrentSelectionSpec(pi, ctx);
 696		await storeSelectionIntoMode(pi, ctx, choice, overlay);
 697		await applyMode(pi, ctx, choice);
 698		ctx.ui.notify(`Stored ${CUSTOM_MODE_NAME} into \"${choice}\"`, "info");
 699		return;
 700	}
 701
 702	await applyMode(pi, ctx, choice);
 703}
 704
 705async function selectModeUI(pi: ExtensionAPI, ctx: ExtensionContext): Promise<void> {
 706	if (!ctx.hasUI) return;
 707
 708	while (true) {
 709		await ensureRuntime(pi, ctx);
 710		const names = orderedModeNames(runtime.data.modes);
 711
 712		const items: SelectItem[] = names.map((name) => {
 713			const spec = runtime.data.modes[name];
 714			const parts: string[] = [];
 715			if (spec?.provider && spec?.modelId) parts.push(`${spec.provider}/${spec.modelId}`);
 716			if (spec?.thinkingLevel) parts.push(`thinking: ${spec.thinkingLevel}`);
 717			const current = name === runtime.currentMode ? " (current)" : "";
 718			return {
 719				value: name,
 720				label: name + current,
 721				description: parts.join("  "),
 722			};
 723		});
 724		items.push({ value: MODE_UI_CONFIGURE, label: MODE_UI_CONFIGURE });
 725
 726		const choice = await ctx.ui.custom<string | null>((tui, theme, _kb, done) => {
 727			let filterText = "";
 728			const container = new Container();
 729			container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
 730			const titleText = new Text(theme.fg("accent", theme.bold(`Mode (current: ${runtime.currentMode})`)), 1, 0);
 731			container.addChild(titleText);
 732
 733			const filterDisplay = new Text(theme.fg("dim", "Type to filter..."), 1, 0);
 734			container.addChild(filterDisplay);
 735
 736			const selectList = new SelectList(items, Math.min(items.length, 15), {
 737				selectedPrefix: (t: string) => theme.fg("accent", t),
 738				selectedText: (t: string) => theme.fg("accent", t),
 739				description: (t: string) => theme.fg("muted", t),
 740				scrollInfo: (t: string) => theme.fg("dim", t),
 741				noMatch: (t: string) => theme.fg("warning", t),
 742			});
 743			selectList.onSelect = (item) => done(item.value);
 744			selectList.onCancel = () => done(null);
 745			container.addChild(selectList);
 746
 747			container.addChild(new Text(theme.fg("dim", "↑↓ navigate • enter select • esc cancel"), 1, 0));
 748			container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
 749
 750			const updateFilterDisplay = () => {
 751				if (filterText) {
 752					filterDisplay.setText(theme.fg("accent", "Filter: ") + theme.fg("text", filterText) + theme.fg("dim", "▏"));
 753				} else {
 754					filterDisplay.setText(theme.fg("dim", "Type to filter..."));
 755				}
 756			};
 757
 758			return {
 759				render: (w: number) => container.render(w),
 760				invalidate: () => container.invalidate(),
 761				handleInput: (data: string) => {
 762					if (matchesKey(data, Key.backspace)) {
 763						if (filterText.length > 0) {
 764							filterText = filterText.slice(0, -1);
 765							selectList.setFilter(filterText);
 766							updateFilterDisplay();
 767						}
 768					} else if (data.length === 1 && data.charCodeAt(0) >= 32) {
 769						// Printable character
 770						filterText += data;
 771						selectList.setFilter(filterText);
 772						updateFilterDisplay();
 773					} else {
 774						selectList.handleInput(data);
 775					}
 776					tui.requestRender();
 777				},
 778			};
 779		});
 780
 781		if (!choice) return;
 782
 783		if (choice === MODE_UI_CONFIGURE) {
 784			await configureModesUI(pi, ctx);
 785			continue;
 786		}
 787
 788		await handleModeChoiceUI(pi, ctx, choice);
 789		return;
 790	}
 791}
 792
 793async function configureModesUI(pi: ExtensionAPI, ctx: ExtensionContext): Promise<void> {
 794	if (!ctx.hasUI) return;
 795
 796	while (true) {
 797		await ensureRuntime(pi, ctx);
 798		const names = orderedModeNames(runtime.data.modes);
 799		const choice = await ctx.ui.select("Configure modes", [...names, MODE_UI_ADD, MODE_UI_BACK]);
 800		if (!choice || choice === MODE_UI_BACK) return;
 801
 802		if (choice === MODE_UI_ADD) {
 803			const created = await addModeUI(pi, ctx);
 804			if (created) {
 805				await editModeUI(pi, ctx, created);
 806			}
 807			continue;
 808		}
 809
 810		await editModeUI(pi, ctx, choice);
 811	}
 812}
 813
 814async function addModeUI(pi: ExtensionAPI, ctx: ExtensionContext): Promise<string | undefined> {
 815	if (!ctx.hasUI) return undefined;
 816	await ensureRuntime(pi, ctx);
 817
 818	while (true) {
 819		const raw = await ctx.ui.input("New mode name", "e.g. docs, review, planning");
 820		if (raw === undefined) return undefined;
 821
 822		const name = normalizeModeNameInput(raw);
 823		const err = validateModeNameOrError(name, runtime.data.modes);
 824		if (err) {
 825			ctx.ui.notify(err, "warning");
 826			continue;
 827		}
 828
 829		// Default new modes to the current selection so they behave as expected immediately.
 830		const selection = customOverlay ?? getCurrentSelectionSpec(pi, ctx);
 831		runtime.data.modes[name] = {
 832			provider: selection.provider,
 833			modelId: selection.modelId,
 834			thinkingLevel: selection.thinkingLevel,
 835		};
 836		await persistRuntime(pi, ctx);
 837		ctx.ui.notify(`Added mode \"${name}\"`, "info");
 838		return name;
 839	}
 840}
 841
 842async function editModeUI(pi: ExtensionAPI, ctx: ExtensionContext, mode: string): Promise<void> {
 843	if (!ctx.hasUI) return;
 844
 845	let modeName = mode;
 846
 847	while (true) {
 848		await ensureRuntime(pi, ctx);
 849		const spec = runtime.data.modes[modeName];
 850		if (!spec) return;
 851
 852		const modelLabel = spec.provider && spec.modelId ? `${spec.provider}/${spec.modelId}` : "(no model)";
 853		const thinkingLabel = spec.thinkingLevel ?? THINKING_UNSET_LABEL;
 854
 855		const actions = ["Change name", "Change model", "Change thinking level"];
 856		if (!isDefaultModeName(modeName)) actions.push("Delete mode");
 857		actions.push(MODE_UI_BACK);
 858
 859		const action = await ctx.ui.select(
 860			`Edit mode \"${modeName}\"  model: ${modelLabel}  thinking: ${thinkingLabel}`,
 861			actions,
 862		);
 863		if (!action || action === MODE_UI_BACK) return;
 864
 865		if (action === "Change name") {
 866			const renamed = await renameModeUI(pi, ctx, modeName);
 867			if (renamed) modeName = renamed;
 868			continue;
 869		}
 870
 871		if (action === "Change model") {
 872			const selected = await pickModelForModeUI(ctx, spec);
 873			if (!selected) continue;
 874			spec.provider = selected.provider;
 875			spec.modelId = selected.modelId;
 876			runtime.data.modes[modeName] = spec;
 877			await persistRuntime(pi, ctx);
 878			ctx.ui.notify(`Updated model for \"${modeName}\"`, "info");
 879
 880			if (runtime.currentMode === modeName) {
 881				await applyMode(pi, ctx, modeName);
 882			}
 883			continue;
 884		}
 885
 886		if (action === "Change thinking level") {
 887			const level = await pickThinkingLevelForModeUI(ctx, spec.thinkingLevel);
 888			if (level === undefined) continue;
 889
 890			if (level === null) {
 891				delete spec.thinkingLevel;
 892			} else {
 893				spec.thinkingLevel = level;
 894			}
 895
 896			runtime.data.modes[modeName] = spec;
 897			await persistRuntime(pi, ctx);
 898			ctx.ui.notify(`Updated thinking level for \"${modeName}\"`, "info");
 899
 900			if (runtime.currentMode === modeName) {
 901				await applyMode(pi, ctx, modeName);
 902			}
 903			continue;
 904		}
 905
 906		if (action === "Delete mode") {
 907			const ok = await ctx.ui.confirm("Delete mode", `Delete mode \"${modeName}\"?`);
 908			if (!ok) continue;
 909
 910			delete runtime.data.modes[modeName];
 911			await persistRuntime(pi, ctx);
 912
 913			if (runtime.currentMode === modeName) {
 914				runtime.currentMode = CUSTOM_MODE_NAME;
 915				customOverlay = getCurrentSelectionSpec(pi, ctx);
 916			}
 917			if (runtime.lastRealMode === modeName) {
 918				runtime.lastRealMode = "default";
 919			}
 920			updateModeStatus(ctx);
 921			requestEditorRender?.();
 922			ctx.ui.notify(`Deleted mode \"${modeName}\"`, "info");
 923			return;
 924		}
 925	}
 926}
 927
 928function renameModesRecord(modes: Record<string, ModeSpec>, oldName: string, newName: string): Record<string, ModeSpec> {
 929	const out: Record<string, ModeSpec> = {};
 930	for (const [k, v] of Object.entries(modes)) {
 931		if (k === oldName) out[newName] = v;
 932		else out[k] = v;
 933	}
 934	return out;
 935}
 936
 937async function renameModeUI(pi: ExtensionAPI, ctx: ExtensionContext, oldName: string): Promise<string | undefined> {
 938	if (!ctx.hasUI) return undefined;
 939
 940	if (isDefaultModeName(oldName)) {
 941		ctx.ui.notify(`Cannot rename default mode \"${oldName}\"`, "warning");
 942		return oldName;
 943	}
 944
 945	await ensureRuntime(pi, ctx);
 946
 947	while (true) {
 948		const raw = await ctx.ui.input(`Rename mode \"${oldName}\"`, oldName);
 949		if (raw === undefined) return undefined;
 950
 951		const newName = normalizeModeNameInput(raw);
 952		if (!newName || newName === oldName) return oldName;
 953
 954		const err = validateModeNameOrError(newName, runtime.data.modes);
 955		if (err) {
 956			ctx.ui.notify(err, "warning");
 957			continue;
 958		}
 959
 960		runtime.data.modes = renameModesRecord(runtime.data.modes, oldName, newName);
 961		await persistRuntime(pi, ctx);
 962
 963		if (runtime.currentMode === oldName) runtime.currentMode = newName;
 964		if (runtime.lastRealMode === oldName) runtime.lastRealMode = newName;
 965		updateModeStatus(ctx);
 966		requestEditorRender?.();
 967
 968		ctx.ui.notify(`Renamed \"${oldName}\" → \"${newName}\"`, "info");
 969		return newName;
 970	}
 971}
 972
 973async function pickModelForModeUI(
 974	ctx: ExtensionContext,
 975	spec: ModeSpec,
 976): Promise<{ provider: string; modelId: string } | undefined> {
 977	if (!ctx.hasUI) return undefined;
 978
 979	const settingsManager = SettingsManager.inMemory();
 980	const currentModel = spec.provider && spec.modelId ? ctx.modelRegistry.find(spec.provider, spec.modelId) : ctx.model;
 981
 982	const scopedModels: Array<{ model: any; thinkingLevel: string }> = [];
 983
 984	return ctx.ui.custom<{ provider: string; modelId: string } | undefined>((tui, _theme, _keybindings, done) => {
 985		const selector = new ModelSelectorComponent(
 986			tui,
 987			currentModel,
 988			settingsManager,
 989			ctx.modelRegistry as any,
 990			scopedModels as any,
 991			(model) => done({ provider: model.provider, modelId: model.id }),
 992			() => done(undefined),
 993		);
 994		return selector;
 995	});
 996}
 997
 998async function pickThinkingLevelForModeUI(
 999	ctx: ExtensionContext,
1000	current: ThinkingLevel | undefined,
1001): Promise<ThinkingLevel | null | undefined> {
1002	if (!ctx.hasUI) return undefined;
1003
1004	const defaultValue = current ?? "off";
1005	const options = [...ALL_THINKING_LEVELS, THINKING_UNSET_LABEL];
1006	// Prefer the current selection by ordering it first.
1007	const ordered = [defaultValue, ...options.filter((x) => x !== defaultValue)];
1008
1009	const choice = await ctx.ui.select("Thinking level", ordered);
1010	if (!choice) return undefined;
1011	if (choice === THINKING_UNSET_LABEL) return null;
1012	if (ALL_THINKING_LEVELS.includes(choice as ThinkingLevel)) return choice as ThinkingLevel;
1013	return undefined;
1014}
1015
1016async function cycleMode(pi: ExtensionAPI, ctx: ExtensionContext, direction: 1 | -1 = 1): Promise<void> {
1017	if (!ctx.hasUI) return;
1018	await ensureRuntime(pi, ctx);
1019	const names = orderedModeNames(runtime.data.modes);
1020	if (names.length === 0) return;
1021
1022	// If we're currently in the overlay mode, cycle relative to the last real mode.
1023	const baseMode = runtime.currentMode === CUSTOM_MODE_NAME ? runtime.lastRealMode : runtime.currentMode;
1024	const idx = Math.max(0, names.indexOf(baseMode));
1025	const next = names[(idx + direction + names.length) % names.length] ?? names[0]!;
1026	await applyMode(pi, ctx, next);
1027}
1028
1029// =============================================================================
1030// Prompt history
1031// =============================================================================
1032
1033const MAX_HISTORY_ENTRIES = 100;
1034const MAX_RECENT_PROMPTS = 30;
1035
1036interface PromptEntry {
1037	text: string;
1038	timestamp: number;
1039}
1040
1041class PromptEditor extends CustomEditor {
1042	public requestRenderNow(): void {
1043		this.tui.requestRender();
1044	}
1045}
1046
1047function extractText(content: Array<{ type: string; text?: string }>): string {
1048	return content
1049		.filter((item) => item.type === "text" && typeof item.text === "string")
1050		.map((item) => item.text ?? "")
1051		.join("")
1052		.trim();
1053}
1054
1055function collectUserPromptsFromEntries(entries: Array<any>): PromptEntry[] {
1056	const prompts: PromptEntry[] = [];
1057
1058	for (const entry of entries) {
1059		if (entry?.type !== "message") continue;
1060		const message = entry?.message;
1061		if (!message || message.role !== "user" || !Array.isArray(message.content)) continue;
1062		const text = extractText(message.content);
1063		if (!text) continue;
1064		const timestamp = Number(message.timestamp ?? entry.timestamp ?? Date.now());
1065		prompts.push({ text, timestamp });
1066	}
1067
1068	return prompts;
1069}
1070
1071function getSessionDirForCwd(cwd: string): string {
1072	const safePath = `--${cwd.replace(/^[/\\]/, "").replace(/[/\\:]/g, "-")}--`;
1073	return path.join(getGlobalAgentDir(), "sessions", safePath);
1074}
1075
1076async function readTail(filePath: string, maxBytes = 256 * 1024): Promise<string> {
1077	let fileHandle: fs.FileHandle | undefined;
1078	try {
1079		const stats = await fs.stat(filePath);
1080		const size = stats.size;
1081		const start = Math.max(0, size - maxBytes);
1082		const length = size - start;
1083		if (length <= 0) return "";
1084
1085		const buffer = Buffer.alloc(length);
1086		fileHandle = await fs.open(filePath, "r");
1087		const { bytesRead } = await fileHandle.read(buffer, 0, length, start);
1088		if (bytesRead === 0) return "";
1089		let chunk = buffer.subarray(0, bytesRead).toString("utf8");
1090		if (start > 0) {
1091			const firstNewline = chunk.indexOf("\n");
1092			if (firstNewline !== -1) {
1093				chunk = chunk.slice(firstNewline + 1);
1094			}
1095		}
1096		return chunk;
1097	} catch {
1098		return "";
1099	} finally {
1100		await fileHandle?.close();
1101	}
1102}
1103
1104async function loadPromptHistoryForCwd(cwd: string, excludeSessionFile?: string): Promise<PromptEntry[]> {
1105	const sessionDir = getSessionDirForCwd(path.resolve(cwd));
1106	const resolvedExclude = excludeSessionFile ? path.resolve(excludeSessionFile) : undefined;
1107	const prompts: PromptEntry[] = [];
1108
1109	let entries: Dirent[] = [];
1110	try {
1111		entries = await fs.readdir(sessionDir, { withFileTypes: true });
1112	} catch {
1113		return prompts;
1114	}
1115
1116	const files = await Promise.all(
1117		entries
1118			.filter((entry) => entry.isFile() && entry.name.endsWith(".jsonl"))
1119			.map(async (entry) => {
1120				const filePath = path.join(sessionDir, entry.name);
1121				try {
1122					const stats = await fs.stat(filePath);
1123					return { filePath, mtimeMs: stats.mtimeMs };
1124				} catch {
1125					return undefined;
1126				}
1127			}),
1128	);
1129
1130	const sortedFiles = files
1131		.filter((file): file is { filePath: string; mtimeMs: number } => Boolean(file))
1132		.sort((a, b) => b.mtimeMs - a.mtimeMs);
1133
1134	for (const file of sortedFiles) {
1135		if (resolvedExclude && path.resolve(file.filePath) === resolvedExclude) continue;
1136
1137		const tail = await readTail(file.filePath);
1138		if (!tail) continue;
1139		const lines = tail.split("\n").filter(Boolean);
1140		for (const line of lines) {
1141			let entry: any;
1142			try {
1143				entry = JSON.parse(line);
1144			} catch {
1145				continue;
1146			}
1147			if (entry?.type !== "message") continue;
1148			const message = entry?.message;
1149			if (!message || message.role !== "user" || !Array.isArray(message.content)) continue;
1150			const text = extractText(message.content);
1151			if (!text) continue;
1152			const timestamp = Number(message.timestamp ?? entry.timestamp ?? Date.now());
1153			prompts.push({ text, timestamp });
1154			if (prompts.length >= MAX_RECENT_PROMPTS) break;
1155		}
1156		if (prompts.length >= MAX_RECENT_PROMPTS) break;
1157	}
1158
1159	return prompts;
1160}
1161
1162function buildHistoryList(currentSession: PromptEntry[], previousSessions: PromptEntry[]): PromptEntry[] {
1163	const all = [...currentSession, ...previousSessions];
1164	all.sort((a, b) => a.timestamp - b.timestamp);
1165
1166	const seen = new Set<string>();
1167	const deduped: PromptEntry[] = [];
1168	for (const prompt of all) {
1169		const key = `${prompt.timestamp}:${prompt.text}`;
1170		if (seen.has(key)) continue;
1171		seen.add(key);
1172		deduped.push(prompt);
1173	}
1174
1175	return deduped.slice(-MAX_HISTORY_ENTRIES);
1176}
1177
1178// Overlay mode state ("custom"). Not selectable, not cycled into.
1179let customOverlay: ModeSpec | null = null;
1180
1181let loadCounter = 0;
1182
1183function historiesMatch(a: PromptEntry[], b: PromptEntry[]): boolean {
1184	if (a.length !== b.length) return false;
1185	for (let i = 0; i < a.length; i += 1) {
1186		if (a[i]?.text !== b[i]?.text || a[i]?.timestamp !== b[i]?.timestamp) return false;
1187	}
1188	return true;
1189}
1190
1191function setEditor(pi: ExtensionAPI, ctx: ExtensionContext, history: PromptEntry[]) {
1192	ctx.ui.setEditorComponent((tui, theme, keybindings) => {
1193		const editor = new PromptEditor(tui, theme, keybindings);
1194		requestEditorRender = () => editor.requestRenderNow();
1195		for (const prompt of history) {
1196			editor.addToHistory?.(prompt.text);
1197		}
1198		return editor;
1199	});
1200}
1201
1202function applyEditor(pi: ExtensionAPI, ctx: ExtensionContext) {
1203	if (!ctx.hasUI) return;
1204
1205	const sessionFile = ctx.sessionManager.getSessionFile();
1206	const currentEntries = ctx.sessionManager.getBranch();
1207	const currentPrompts = collectUserPromptsFromEntries(currentEntries);
1208	const immediateHistory = buildHistoryList(currentPrompts, []);
1209
1210	const currentLoad = ++loadCounter;
1211	const initialText = ctx.ui.getEditorText();
1212	setEditor(pi, ctx, immediateHistory);
1213
1214	void (async () => {
1215		const previousPrompts = await loadPromptHistoryForCwd(ctx.cwd, sessionFile ?? undefined);
1216		if (currentLoad !== loadCounter) return;
1217		if (ctx.ui.getEditorText() !== initialText) return;
1218		const history = buildHistoryList(currentPrompts, previousPrompts);
1219		if (historiesMatch(history, immediateHistory)) return;
1220		setEditor(pi, ctx, history);
1221	})();
1222}
1223
1224// =============================================================================
1225// Extension Export
1226// =============================================================================
1227
1228export default function (pi: ExtensionAPI) {
1229	// Register --start-mode CLI flag
1230	pi.registerFlag("start-mode", {
1231		description: "Start with a specific mode from modes.json",
1232		type: "string",
1233		default: "",
1234	});
1235
1236	pi.registerCommand("mode", {
1237		description: "Select prompt mode",
1238		handler: async (args, ctx) => {
1239			const tokens = args
1240				.split(/\s+/)
1241				.map((x) => x.trim())
1242				.filter(Boolean);
1243
1244			// /mode
1245			if (tokens.length === 0) {
1246				await selectModeUI(pi, ctx);
1247				return;
1248			}
1249
1250			// /mode store [name]
1251			if (tokens[0] === "store") {
1252				await ensureRuntime(pi, ctx);
1253
1254				let target = tokens[1];
1255				if (!target) {
1256					if (!ctx.hasUI) return;
1257					const names = orderedModeNames(runtime.data.modes);
1258					target = await ctx.ui.select("Store current selection into mode", names);
1259					if (!target) return;
1260				}
1261
1262				if (target === CUSTOM_MODE_NAME) {
1263					if (ctx.hasUI) ctx.ui.notify(`Cannot store into "${CUSTOM_MODE_NAME}"`, "warning");
1264					return;
1265				}
1266
1267				const selection = customOverlay ?? getCurrentSelectionSpec(pi, ctx);
1268				await storeSelectionIntoMode(pi, ctx, target, selection);
1269				if (ctx.hasUI) ctx.ui.notify(`Stored current selection into "${target}"`, "info");
1270				return;
1271			}
1272
1273			// /mode <name> — with fuzzy matching
1274			await ensureRuntime(pi, ctx);
1275			const query = tokens.join(" ").toLowerCase();
1276			const names = orderedModeNames(runtime.data.modes);
1277
1278			// Exact match first
1279			const exact = names.find((n) => n.toLowerCase() === query);
1280			if (exact) {
1281				await applyMode(pi, ctx, exact);
1282				return;
1283			}
1284
1285			// Prefix match
1286			const prefixMatches = names.filter((n) => n.toLowerCase().startsWith(query));
1287			if (prefixMatches.length === 1) {
1288				await applyMode(pi, ctx, prefixMatches[0]!);
1289				return;
1290			}
1291
1292			// Substring match
1293			const substringMatches = names.filter((n) => n.toLowerCase().includes(query));
1294			if (substringMatches.length === 1) {
1295				await applyMode(pi, ctx, substringMatches[0]!);
1296				return;
1297			}
1298
1299			// Fuzzy match: all query chars appear in order
1300			const fuzzyMatch = (name: string, q: string): boolean => {
1301				let qi = 0;
1302				for (let i = 0; i < name.length && qi < q.length; i++) {
1303					if (name[i] === q[qi]) qi++;
1304				}
1305				return qi === q.length;
1306			};
1307			const fuzzyMatches = names.filter((n) => fuzzyMatch(n.toLowerCase(), query));
1308			if (fuzzyMatches.length === 1) {
1309				await applyMode(pi, ctx, fuzzyMatches[0]!);
1310				return;
1311			}
1312
1313			// Multiple matches or no match
1314			if (fuzzyMatches.length > 1) {
1315				if (ctx.hasUI) {
1316					const choice = await ctx.ui.select(`Multiple modes match "${query}"`, fuzzyMatches);
1317					if (choice) await handleModeChoiceUI(pi, ctx, choice);
1318				}
1319				return;
1320			}
1321
1322			// No match at all — try original name
1323			await applyMode(pi, ctx, tokens[0]!);
1324		},
1325	});
1326
1327	pi.registerShortcut("ctrl+shift+m", {
1328		description: "Select prompt mode",
1329		handler: async (ctx) => {
1330			await selectModeUI(pi, ctx);
1331		},
1332	});
1333
1334	pi.registerShortcut("ctrl+space", {
1335		description: "Cycle prompt mode",
1336		handler: async (ctx) => {
1337			await cycleMode(pi, ctx, 1);
1338		},
1339	});
1340
1341	pi.on("session_start", async (_event, ctx) => {
1342		lastObservedModel = { provider: ctx.model?.provider, modelId: ctx.model?.id };
1343		await ensureRuntime(pi, ctx);
1344		customOverlay = null;
1345		installEditorBorderPatch();
1346
1347		// Check --start-mode CLI flag — apply mode ephemerally without
1348		// persisting provider/model to settings.json so subsequent launches
1349		// aren't affected.
1350		const startMode = pi.getFlag("start-mode") as string;
1351		if (startMode) {
1352			const settingsPath = path.join(getGlobalAgentDir(), "settings.json");
1353			const savedSettings = await fs.readFile(settingsPath, "utf8").catch(() => null);
1354			await applyMode(pi, ctx, startMode);
1355			// Restore settings.json so defaultProvider/defaultModel aren't changed
1356			if (savedSettings) {
1357				await fs.writeFile(settingsPath, savedSettings, "utf8");
1358			}
1359			return;
1360		}
1361
1362		const inferred = inferModeFromSelection(ctx, pi, runtime.data);
1363		if (inferred) {
1364			runtime.currentMode = inferred;
1365			runtime.lastRealMode = inferred;
1366		} else {
1367			// No exact match → treat as overlay.
1368			runtime.currentMode = CUSTOM_MODE_NAME;
1369			customOverlay = getCurrentSelectionSpec(pi, ctx);
1370		}
1371
1372		updateModeStatus(ctx);
1373		applyEditor(pi, ctx);
1374	});
1375
1376	pi.on("session_switch", async (_event, ctx) => {
1377		lastObservedModel = { provider: ctx.model?.provider, modelId: ctx.model?.id };
1378		await ensureRuntime(pi, ctx);
1379		customOverlay = null;
1380
1381		const inferred = inferModeFromSelection(ctx, pi, runtime.data);
1382		if (inferred) {
1383			runtime.currentMode = inferred;
1384			runtime.lastRealMode = inferred;
1385		} else {
1386			runtime.currentMode = CUSTOM_MODE_NAME;
1387			customOverlay = getCurrentSelectionSpec(pi, ctx);
1388		}
1389
1390		updateModeStatus(ctx);
1391		applyEditor(pi, ctx);
1392	});
1393
1394
1395	pi.on("model_select", async (event: ModelSelectEvent, ctx) => {
1396		// Always track the last observed model for overlay/store correctness.
1397		lastObservedModel = { provider: event.model.provider, modelId: event.model.id };
1398
1399		// Skip mode switching triggered by applyMode() itself, otherwise we'd jump to "custom"
1400		// while we are in the middle of applying a mode.
1401		if (runtime.applying) return;
1402
1403		// Manual model changes always go into the overlay "custom" mode.
1404		await ensureRuntime(pi, ctx);
1405		if (runtime.currentMode !== CUSTOM_MODE_NAME) {
1406			runtime.lastRealMode = runtime.currentMode;
1407		}
1408		runtime.currentMode = CUSTOM_MODE_NAME;
1409
1410		customOverlay = {
1411			provider: event.model.provider,
1412			modelId: event.model.id,
1413			thinkingLevel: pi.getThinkingLevel(),
1414		};
1415
1416		// Do not persist/select custom.
1417		if (ctx.hasUI) {
1418			updateModeStatus(ctx);
1419			requestEditorRender?.();
1420		}
1421	});
1422
1423}