main
   1import type { ExtensionAPI, ExtensionContext, ModelSelectEvent, ThinkingLevel } from "@earendil-works/pi-coding-agent";
   2import { CustomEditor, DynamicBorder, ModelSelectorComponent, SettingsManager } from "@earendil-works/pi-coding-agent";
   3import { Container, type SelectItem, SelectList, Text, matchesKey, Key } from "@earendil-works/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		// Confirm the switch explicitly. The TUI reflects the model change in its
 648		// header, but RPC frontends (e.g. the Emacs client) get no model-change
 649		// event, so without this notify a successful `/mode <name>` looks like a
 650		// no-op there.
 651		if (modelAppliedOk && mode !== CUSTOM_MODE_NAME) {
 652			const label =
 653				spec.provider && spec.modelId ? `${spec.provider}/${spec.modelId}` : "unchanged model";
 654			ctx.ui.notify(`Mode "${mode}" → ${label}`, "info");
 655		}
 656	}
 657}
 658
 659const MODE_UI_CONFIGURE = "Configure modes…";
 660const MODE_UI_ADD = "Add mode…";
 661const MODE_UI_BACK = "Back";
 662
 663const ALL_THINKING_LEVELS: ThinkingLevel[] = ["off", "minimal", "low", "medium", "high", "xhigh"];
 664const THINKING_UNSET_LABEL = "(don't change)";
 665
 666function isDefaultModeName(name: string): boolean {
 667	return (DEFAULT_MODE_ORDER as readonly string[]).includes(name);
 668}
 669
 670function isReservedModeName(name: string): boolean {
 671	return name === CUSTOM_MODE_NAME || name === MODE_UI_CONFIGURE || name === MODE_UI_ADD || name === MODE_UI_BACK;
 672}
 673
 674function normalizeModeNameInput(name: string | undefined): string {
 675	return (name ?? "").trim();
 676}
 677
 678function validateModeNameOrError(
 679	name: string,
 680	existing: Record<string, ModeSpec>,
 681	opts?: { allowExisting?: boolean },
 682): string | null {
 683	if (!name) return "Mode name cannot be empty";
 684	if (/\s/.test(name)) return "Mode name cannot contain whitespace";
 685	if (isReservedModeName(name)) return `Mode name \"${name}\" is reserved`;
 686	if (!opts?.allowExisting && existing[name]) return `Mode \"${name}\" already exists`;
 687	return null;
 688}
 689
 690async function handleModeChoiceUI(pi: ExtensionAPI, ctx: ExtensionContext, choice: string): Promise<void> {
 691	// Special behavior: when we're in "custom" and select another mode,
 692	// offer to either *use* it (switch) or *store* the current custom selection into it.
 693	if (runtime.currentMode === CUSTOM_MODE_NAME && choice !== CUSTOM_MODE_NAME) {
 694		const action = await ctx.ui.select(`Mode \"${choice}\"`, ["use", "store"]);
 695		if (!action) return;
 696
 697		if (action === "use") {
 698			await applyMode(pi, ctx, choice);
 699			return;
 700		}
 701
 702		// "store": overwrite target mode with the current overlay selection (keep target color if set)
 703		await ensureRuntime(pi, ctx);
 704		const overlay = customOverlay ?? getCurrentSelectionSpec(pi, ctx);
 705		await storeSelectionIntoMode(pi, ctx, choice, overlay);
 706		await applyMode(pi, ctx, choice);
 707		ctx.ui.notify(`Stored ${CUSTOM_MODE_NAME} into \"${choice}\"`, "info");
 708		return;
 709	}
 710
 711	await applyMode(pi, ctx, choice);
 712}
 713
 714async function selectModeUI(pi: ExtensionAPI, ctx: ExtensionContext): Promise<void> {
 715	if (!ctx.hasUI) return;
 716
 717	while (true) {
 718		await ensureRuntime(pi, ctx);
 719		const names = orderedModeNames(runtime.data.modes);
 720
 721		const items: SelectItem[] = names.map((name) => {
 722			const spec = runtime.data.modes[name];
 723			const parts: string[] = [];
 724			if (spec?.provider && spec?.modelId) parts.push(`${spec.provider}/${spec.modelId}`);
 725			if (spec?.thinkingLevel) parts.push(`thinking: ${spec.thinkingLevel}`);
 726			const current = name === runtime.currentMode ? " (current)" : "";
 727			return {
 728				value: name,
 729				label: name + current,
 730				description: parts.join("  "),
 731			};
 732		});
 733		items.push({ value: MODE_UI_CONFIGURE, label: MODE_UI_CONFIGURE });
 734
 735		const choice =
 736			ctx.mode === "tui"
 737				? await ctx.ui.custom<string | null>((tui, theme, _kb, done) => {
 738						let filterText = "";
 739						const container = new Container();
 740						container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
 741						const titleText = new Text(theme.fg("accent", theme.bold(`Mode (current: ${runtime.currentMode})`)), 1, 0);
 742						container.addChild(titleText);
 743
 744						const filterDisplay = new Text(theme.fg("dim", "Type to filter..."), 1, 0);
 745						container.addChild(filterDisplay);
 746
 747						const selectList = new SelectList(items, Math.min(items.length, 15), {
 748							selectedPrefix: (t: string) => theme.fg("accent", t),
 749							selectedText: (t: string) => theme.fg("accent", t),
 750							description: (t: string) => theme.fg("muted", t),
 751							scrollInfo: (t: string) => theme.fg("dim", t),
 752							noMatch: (t: string) => theme.fg("warning", t),
 753						});
 754						selectList.onSelect = (item) => done(item.value);
 755						selectList.onCancel = () => done(null);
 756						container.addChild(selectList);
 757
 758						container.addChild(new Text(theme.fg("dim", "↑↓ navigate • enter select • esc cancel"), 1, 0));
 759						container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
 760
 761						const updateFilterDisplay = () => {
 762							if (filterText) {
 763								filterDisplay.setText(theme.fg("accent", "Filter: ") + theme.fg("text", filterText) + theme.fg("dim", "▏"));
 764							} else {
 765								filterDisplay.setText(theme.fg("dim", "Type to filter..."));
 766							}
 767						};
 768
 769						return {
 770							render: (w: number) => container.render(w),
 771							invalidate: () => container.invalidate(),
 772							handleInput: (data: string) => {
 773								if (matchesKey(data, Key.backspace)) {
 774									if (filterText.length > 0) {
 775										filterText = filterText.slice(0, -1);
 776										selectList.setFilter(filterText);
 777										updateFilterDisplay();
 778									}
 779								} else if (data.length === 1 && data.charCodeAt(0) >= 32) {
 780									// Printable character
 781									filterText += data;
 782									selectList.setFilter(filterText);
 783									updateFilterDisplay();
 784								} else {
 785									selectList.handleInput(data);
 786								}
 787								tui.requestRender();
 788							},
 789						};
 790				  })
 791				: await ctx.ui.select(`Mode (current: ${runtime.currentMode})`, [...names, MODE_UI_CONFIGURE]);
 792
 793		if (!choice) return;
 794
 795		if (choice === MODE_UI_CONFIGURE) {
 796			await configureModesUI(pi, ctx);
 797			continue;
 798		}
 799
 800		await handleModeChoiceUI(pi, ctx, choice);
 801		return;
 802	}
 803}
 804
 805async function configureModesUI(pi: ExtensionAPI, ctx: ExtensionContext): Promise<void> {
 806	if (!ctx.hasUI) return;
 807
 808	while (true) {
 809		await ensureRuntime(pi, ctx);
 810		const names = orderedModeNames(runtime.data.modes);
 811		const choice = await ctx.ui.select("Configure modes", [...names, MODE_UI_ADD, MODE_UI_BACK]);
 812		if (!choice || choice === MODE_UI_BACK) return;
 813
 814		if (choice === MODE_UI_ADD) {
 815			const created = await addModeUI(pi, ctx);
 816			if (created) {
 817				await editModeUI(pi, ctx, created);
 818			}
 819			continue;
 820		}
 821
 822		await editModeUI(pi, ctx, choice);
 823	}
 824}
 825
 826async function addModeUI(pi: ExtensionAPI, ctx: ExtensionContext): Promise<string | undefined> {
 827	if (!ctx.hasUI) return undefined;
 828	await ensureRuntime(pi, ctx);
 829
 830	while (true) {
 831		const raw = await ctx.ui.input("New mode name", "e.g. docs, review, planning");
 832		if (raw === undefined) return undefined;
 833
 834		const name = normalizeModeNameInput(raw);
 835		const err = validateModeNameOrError(name, runtime.data.modes);
 836		if (err) {
 837			ctx.ui.notify(err, "warning");
 838			continue;
 839		}
 840
 841		// Default new modes to the current selection so they behave as expected immediately.
 842		const selection = customOverlay ?? getCurrentSelectionSpec(pi, ctx);
 843		runtime.data.modes[name] = {
 844			provider: selection.provider,
 845			modelId: selection.modelId,
 846			thinkingLevel: selection.thinkingLevel,
 847		};
 848		await persistRuntime(pi, ctx);
 849		ctx.ui.notify(`Added mode \"${name}\"`, "info");
 850		return name;
 851	}
 852}
 853
 854async function editModeUI(pi: ExtensionAPI, ctx: ExtensionContext, mode: string): Promise<void> {
 855	if (!ctx.hasUI) return;
 856
 857	let modeName = mode;
 858
 859	while (true) {
 860		await ensureRuntime(pi, ctx);
 861		const spec = runtime.data.modes[modeName];
 862		if (!spec) return;
 863
 864		const modelLabel = spec.provider && spec.modelId ? `${spec.provider}/${spec.modelId}` : "(no model)";
 865		const thinkingLabel = spec.thinkingLevel ?? THINKING_UNSET_LABEL;
 866
 867		const actions = ["Change name", "Change model", "Change thinking level"];
 868		if (!isDefaultModeName(modeName)) actions.push("Delete mode");
 869		actions.push(MODE_UI_BACK);
 870
 871		const action = await ctx.ui.select(
 872			`Edit mode \"${modeName}\"  model: ${modelLabel}  thinking: ${thinkingLabel}`,
 873			actions,
 874		);
 875		if (!action || action === MODE_UI_BACK) return;
 876
 877		if (action === "Change name") {
 878			const renamed = await renameModeUI(pi, ctx, modeName);
 879			if (renamed) modeName = renamed;
 880			continue;
 881		}
 882
 883		if (action === "Change model") {
 884			const selected = await pickModelForModeUI(ctx, spec);
 885			if (!selected) continue;
 886			spec.provider = selected.provider;
 887			spec.modelId = selected.modelId;
 888			runtime.data.modes[modeName] = spec;
 889			await persistRuntime(pi, ctx);
 890			ctx.ui.notify(`Updated model for \"${modeName}\"`, "info");
 891
 892			if (runtime.currentMode === modeName) {
 893				await applyMode(pi, ctx, modeName);
 894			}
 895			continue;
 896		}
 897
 898		if (action === "Change thinking level") {
 899			const level = await pickThinkingLevelForModeUI(ctx, spec.thinkingLevel);
 900			if (level === undefined) continue;
 901
 902			if (level === null) {
 903				delete spec.thinkingLevel;
 904			} else {
 905				spec.thinkingLevel = level;
 906			}
 907
 908			runtime.data.modes[modeName] = spec;
 909			await persistRuntime(pi, ctx);
 910			ctx.ui.notify(`Updated thinking level for \"${modeName}\"`, "info");
 911
 912			if (runtime.currentMode === modeName) {
 913				await applyMode(pi, ctx, modeName);
 914			}
 915			continue;
 916		}
 917
 918		if (action === "Delete mode") {
 919			const ok = await ctx.ui.confirm("Delete mode", `Delete mode \"${modeName}\"?`);
 920			if (!ok) continue;
 921
 922			delete runtime.data.modes[modeName];
 923			await persistRuntime(pi, ctx);
 924
 925			if (runtime.currentMode === modeName) {
 926				runtime.currentMode = CUSTOM_MODE_NAME;
 927				customOverlay = getCurrentSelectionSpec(pi, ctx);
 928			}
 929			if (runtime.lastRealMode === modeName) {
 930				runtime.lastRealMode = "default";
 931			}
 932			updateModeStatus(ctx);
 933			requestEditorRender?.();
 934			ctx.ui.notify(`Deleted mode \"${modeName}\"`, "info");
 935			return;
 936		}
 937	}
 938}
 939
 940function renameModesRecord(modes: Record<string, ModeSpec>, oldName: string, newName: string): Record<string, ModeSpec> {
 941	const out: Record<string, ModeSpec> = {};
 942	for (const [k, v] of Object.entries(modes)) {
 943		if (k === oldName) out[newName] = v;
 944		else out[k] = v;
 945	}
 946	return out;
 947}
 948
 949async function renameModeUI(pi: ExtensionAPI, ctx: ExtensionContext, oldName: string): Promise<string | undefined> {
 950	if (!ctx.hasUI) return undefined;
 951
 952	if (isDefaultModeName(oldName)) {
 953		ctx.ui.notify(`Cannot rename default mode \"${oldName}\"`, "warning");
 954		return oldName;
 955	}
 956
 957	await ensureRuntime(pi, ctx);
 958
 959	while (true) {
 960		const raw = await ctx.ui.input(`Rename mode \"${oldName}\"`, oldName);
 961		if (raw === undefined) return undefined;
 962
 963		const newName = normalizeModeNameInput(raw);
 964		if (!newName || newName === oldName) return oldName;
 965
 966		const err = validateModeNameOrError(newName, runtime.data.modes);
 967		if (err) {
 968			ctx.ui.notify(err, "warning");
 969			continue;
 970		}
 971
 972		runtime.data.modes = renameModesRecord(runtime.data.modes, oldName, newName);
 973		await persistRuntime(pi, ctx);
 974
 975		if (runtime.currentMode === oldName) runtime.currentMode = newName;
 976		if (runtime.lastRealMode === oldName) runtime.lastRealMode = newName;
 977		updateModeStatus(ctx);
 978		requestEditorRender?.();
 979
 980		ctx.ui.notify(`Renamed \"${oldName}\" → \"${newName}\"`, "info");
 981		return newName;
 982	}
 983}
 984
 985async function pickModelForModeUI(
 986	ctx: ExtensionContext,
 987	spec: ModeSpec,
 988): Promise<{ provider: string; modelId: string } | undefined> {
 989	if (!ctx.hasUI) return undefined;
 990
 991	const settingsManager = SettingsManager.inMemory();
 992	const currentModel = spec.provider && spec.modelId ? ctx.modelRegistry.find(spec.provider, spec.modelId) : ctx.model;
 993
 994	const scopedModels: Array<{ model: any; thinkingLevel: string }> = [];
 995
 996	return ctx.ui.custom<{ provider: string; modelId: string } | undefined>((tui, _theme, _keybindings, done) => {
 997		const selector = new ModelSelectorComponent(
 998			tui,
 999			currentModel,
1000			settingsManager,
1001			ctx.modelRegistry as any,
1002			scopedModels as any,
1003			(model) => done({ provider: model.provider, modelId: model.id }),
1004			() => done(undefined),
1005		);
1006		return selector;
1007	});
1008}
1009
1010async function pickThinkingLevelForModeUI(
1011	ctx: ExtensionContext,
1012	current: ThinkingLevel | undefined,
1013): Promise<ThinkingLevel | null | undefined> {
1014	if (!ctx.hasUI) return undefined;
1015
1016	const defaultValue = current ?? "off";
1017	const options = [...ALL_THINKING_LEVELS, THINKING_UNSET_LABEL];
1018	// Prefer the current selection by ordering it first.
1019	const ordered = [defaultValue, ...options.filter((x) => x !== defaultValue)];
1020
1021	const choice = await ctx.ui.select("Thinking level", ordered);
1022	if (!choice) return undefined;
1023	if (choice === THINKING_UNSET_LABEL) return null;
1024	if (ALL_THINKING_LEVELS.includes(choice as ThinkingLevel)) return choice as ThinkingLevel;
1025	return undefined;
1026}
1027
1028async function cycleMode(pi: ExtensionAPI, ctx: ExtensionContext, direction: 1 | -1 = 1): Promise<void> {
1029	if (!ctx.hasUI) return;
1030	await ensureRuntime(pi, ctx);
1031	const names = orderedModeNames(runtime.data.modes);
1032	if (names.length === 0) return;
1033
1034	// If we're currently in the overlay mode, cycle relative to the last real mode.
1035	const baseMode = runtime.currentMode === CUSTOM_MODE_NAME ? runtime.lastRealMode : runtime.currentMode;
1036	const idx = Math.max(0, names.indexOf(baseMode));
1037	const next = names[(idx + direction + names.length) % names.length] ?? names[0]!;
1038	await applyMode(pi, ctx, next);
1039}
1040
1041// =============================================================================
1042// Prompt history
1043// =============================================================================
1044
1045const MAX_HISTORY_ENTRIES = 100;
1046const MAX_RECENT_PROMPTS = 30;
1047
1048interface PromptEntry {
1049	text: string;
1050	timestamp: number;
1051}
1052
1053class PromptEditor extends CustomEditor {
1054	public requestRenderNow(): void {
1055		this.tui.requestRender();
1056	}
1057}
1058
1059function extractText(content: Array<{ type: string; text?: string }>): string {
1060	return content
1061		.filter((item) => item.type === "text" && typeof item.text === "string")
1062		.map((item) => item.text ?? "")
1063		.join("")
1064		.trim();
1065}
1066
1067function collectUserPromptsFromEntries(entries: Array<any>): PromptEntry[] {
1068	const prompts: PromptEntry[] = [];
1069
1070	for (const entry of entries) {
1071		if (entry?.type !== "message") continue;
1072		const message = entry?.message;
1073		if (!message || message.role !== "user" || !Array.isArray(message.content)) continue;
1074		const text = extractText(message.content);
1075		if (!text) continue;
1076		const timestamp = Number(message.timestamp ?? entry.timestamp ?? Date.now());
1077		prompts.push({ text, timestamp });
1078	}
1079
1080	return prompts;
1081}
1082
1083function getSessionDirForCwd(cwd: string): string {
1084	const safePath = `--${cwd.replace(/^[/\\]/, "").replace(/[/\\:]/g, "-")}--`;
1085	return path.join(getGlobalAgentDir(), "sessions", safePath);
1086}
1087
1088async function readTail(filePath: string, maxBytes = 256 * 1024): Promise<string> {
1089	let fileHandle: fs.FileHandle | undefined;
1090	try {
1091		const stats = await fs.stat(filePath);
1092		const size = stats.size;
1093		const start = Math.max(0, size - maxBytes);
1094		const length = size - start;
1095		if (length <= 0) return "";
1096
1097		const buffer = Buffer.alloc(length);
1098		fileHandle = await fs.open(filePath, "r");
1099		const { bytesRead } = await fileHandle.read(buffer, 0, length, start);
1100		if (bytesRead === 0) return "";
1101		let chunk = buffer.subarray(0, bytesRead).toString("utf8");
1102		if (start > 0) {
1103			const firstNewline = chunk.indexOf("\n");
1104			if (firstNewline !== -1) {
1105				chunk = chunk.slice(firstNewline + 1);
1106			}
1107		}
1108		return chunk;
1109	} catch {
1110		return "";
1111	} finally {
1112		await fileHandle?.close();
1113	}
1114}
1115
1116async function loadPromptHistoryForCwd(cwd: string, excludeSessionFile?: string): Promise<PromptEntry[]> {
1117	const sessionDir = getSessionDirForCwd(path.resolve(cwd));
1118	const resolvedExclude = excludeSessionFile ? path.resolve(excludeSessionFile) : undefined;
1119	const prompts: PromptEntry[] = [];
1120
1121	let entries: Dirent[] = [];
1122	try {
1123		entries = await fs.readdir(sessionDir, { withFileTypes: true });
1124	} catch {
1125		return prompts;
1126	}
1127
1128	const files = await Promise.all(
1129		entries
1130			.filter((entry) => entry.isFile() && entry.name.endsWith(".jsonl"))
1131			.map(async (entry) => {
1132				const filePath = path.join(sessionDir, entry.name);
1133				try {
1134					const stats = await fs.stat(filePath);
1135					return { filePath, mtimeMs: stats.mtimeMs };
1136				} catch {
1137					return undefined;
1138				}
1139			}),
1140	);
1141
1142	const sortedFiles = files
1143		.filter((file): file is { filePath: string; mtimeMs: number } => Boolean(file))
1144		.sort((a, b) => b.mtimeMs - a.mtimeMs);
1145
1146	for (const file of sortedFiles) {
1147		if (resolvedExclude && path.resolve(file.filePath) === resolvedExclude) continue;
1148
1149		const tail = await readTail(file.filePath);
1150		if (!tail) continue;
1151		const lines = tail.split("\n").filter(Boolean);
1152		for (const line of lines) {
1153			let entry: any;
1154			try {
1155				entry = JSON.parse(line);
1156			} catch {
1157				continue;
1158			}
1159			if (entry?.type !== "message") continue;
1160			const message = entry?.message;
1161			if (!message || message.role !== "user" || !Array.isArray(message.content)) continue;
1162			const text = extractText(message.content);
1163			if (!text) continue;
1164			const timestamp = Number(message.timestamp ?? entry.timestamp ?? Date.now());
1165			prompts.push({ text, timestamp });
1166			if (prompts.length >= MAX_RECENT_PROMPTS) break;
1167		}
1168		if (prompts.length >= MAX_RECENT_PROMPTS) break;
1169	}
1170
1171	return prompts;
1172}
1173
1174function buildHistoryList(currentSession: PromptEntry[], previousSessions: PromptEntry[]): PromptEntry[] {
1175	const all = [...currentSession, ...previousSessions];
1176	all.sort((a, b) => a.timestamp - b.timestamp);
1177
1178	const seen = new Set<string>();
1179	const deduped: PromptEntry[] = [];
1180	for (const prompt of all) {
1181		const key = `${prompt.timestamp}:${prompt.text}`;
1182		if (seen.has(key)) continue;
1183		seen.add(key);
1184		deduped.push(prompt);
1185	}
1186
1187	return deduped.slice(-MAX_HISTORY_ENTRIES);
1188}
1189
1190// Overlay mode state ("custom"). Not selectable, not cycled into.
1191let customOverlay: ModeSpec | null = null;
1192
1193let loadCounter = 0;
1194
1195function historiesMatch(a: PromptEntry[], b: PromptEntry[]): boolean {
1196	if (a.length !== b.length) return false;
1197	for (let i = 0; i < a.length; i += 1) {
1198		if (a[i]?.text !== b[i]?.text || a[i]?.timestamp !== b[i]?.timestamp) return false;
1199	}
1200	return true;
1201}
1202
1203function setEditor(pi: ExtensionAPI, ctx: ExtensionContext, history: PromptEntry[]) {
1204	ctx.ui.setEditorComponent((tui, theme, keybindings) => {
1205		const editor = new PromptEditor(tui, theme, keybindings);
1206		requestEditorRender = () => editor.requestRenderNow();
1207		for (const prompt of history) {
1208			editor.addToHistory?.(prompt.text);
1209		}
1210		return editor;
1211	});
1212}
1213
1214function applyEditor(pi: ExtensionAPI, ctx: ExtensionContext) {
1215	if (!ctx.hasUI) return;
1216
1217	const sessionFile = ctx.sessionManager.getSessionFile();
1218	const currentEntries = ctx.sessionManager.getBranch();
1219	const currentPrompts = collectUserPromptsFromEntries(currentEntries);
1220	const immediateHistory = buildHistoryList(currentPrompts, []);
1221
1222	const currentLoad = ++loadCounter;
1223	const initialText = ctx.ui.getEditorText();
1224	setEditor(pi, ctx, immediateHistory);
1225
1226	void (async () => {
1227		const previousPrompts = await loadPromptHistoryForCwd(ctx.cwd, sessionFile ?? undefined);
1228		if (currentLoad !== loadCounter) return;
1229		if (ctx.ui.getEditorText() !== initialText) return;
1230		const history = buildHistoryList(currentPrompts, previousPrompts);
1231		if (historiesMatch(history, immediateHistory)) return;
1232		setEditor(pi, ctx, history);
1233	})();
1234}
1235
1236// =============================================================================
1237// Extension Export
1238// =============================================================================
1239
1240export default function (pi: ExtensionAPI) {
1241	// Register --start-mode CLI flag
1242	pi.registerFlag("start-mode", {
1243		description: "Start with a specific mode from modes.json (env: PI_START_MODE)",
1244		type: "string",
1245		default: "",
1246	});
1247
1248	pi.registerCommand("mode", {
1249		description: "Select prompt mode",
1250		handler: async (args, ctx) => {
1251			const tokens = args
1252				.split(/\s+/)
1253				.map((x) => x.trim())
1254				.filter(Boolean);
1255
1256			// /mode
1257			if (tokens.length === 0) {
1258				await selectModeUI(pi, ctx);
1259				return;
1260			}
1261
1262			// /mode store [name]
1263			if (tokens[0] === "store") {
1264				await ensureRuntime(pi, ctx);
1265
1266				let target = tokens[1];
1267				if (!target) {
1268					if (!ctx.hasUI) return;
1269					const names = orderedModeNames(runtime.data.modes);
1270					target = await ctx.ui.select("Store current selection into mode", names);
1271					if (!target) return;
1272				}
1273
1274				if (target === CUSTOM_MODE_NAME) {
1275					if (ctx.hasUI) ctx.ui.notify(`Cannot store into "${CUSTOM_MODE_NAME}"`, "warning");
1276					return;
1277				}
1278
1279				const selection = customOverlay ?? getCurrentSelectionSpec(pi, ctx);
1280				await storeSelectionIntoMode(pi, ctx, target, selection);
1281				if (ctx.hasUI) ctx.ui.notify(`Stored current selection into "${target}"`, "info");
1282				return;
1283			}
1284
1285			// /mode <name> — with fuzzy matching
1286			await ensureRuntime(pi, ctx);
1287			const query = tokens.join(" ").toLowerCase();
1288			const names = orderedModeNames(runtime.data.modes);
1289
1290			// Exact match first
1291			const exact = names.find((n) => n.toLowerCase() === query);
1292			if (exact) {
1293				await applyMode(pi, ctx, exact);
1294				return;
1295			}
1296
1297			// Prefix match
1298			const prefixMatches = names.filter((n) => n.toLowerCase().startsWith(query));
1299			if (prefixMatches.length === 1) {
1300				await applyMode(pi, ctx, prefixMatches[0]!);
1301				return;
1302			}
1303
1304			// Substring match
1305			const substringMatches = names.filter((n) => n.toLowerCase().includes(query));
1306			if (substringMatches.length === 1) {
1307				await applyMode(pi, ctx, substringMatches[0]!);
1308				return;
1309			}
1310
1311			// Fuzzy match: all query chars appear in order
1312			const fuzzyMatch = (name: string, q: string): boolean => {
1313				let qi = 0;
1314				for (let i = 0; i < name.length && qi < q.length; i++) {
1315					if (name[i] === q[qi]) qi++;
1316				}
1317				return qi === q.length;
1318			};
1319			const fuzzyMatches = names.filter((n) => fuzzyMatch(n.toLowerCase(), query));
1320			if (fuzzyMatches.length === 1) {
1321				await applyMode(pi, ctx, fuzzyMatches[0]!);
1322				return;
1323			}
1324
1325			// Multiple matches or no match
1326			if (fuzzyMatches.length > 1) {
1327				if (ctx.hasUI) {
1328					const choice = await ctx.ui.select(`Multiple modes match "${query}"`, fuzzyMatches);
1329					if (choice) await handleModeChoiceUI(pi, ctx, choice);
1330				}
1331				return;
1332			}
1333
1334			// No match at all — try original name
1335			await applyMode(pi, ctx, tokens[0]!);
1336		},
1337	});
1338
1339	pi.registerShortcut("ctrl+shift+m", {
1340		description: "Select prompt mode",
1341		handler: async (ctx) => {
1342			await selectModeUI(pi, ctx);
1343		},
1344	});
1345
1346	pi.registerShortcut("ctrl+space", {
1347		description: "Cycle prompt mode",
1348		handler: async (ctx) => {
1349			await cycleMode(pi, ctx, 1);
1350		},
1351	});
1352
1353	pi.on("session_start", async (_event, ctx) => {
1354		lastObservedModel = { provider: ctx.model?.provider, modelId: ctx.model?.id };
1355		await ensureRuntime(pi, ctx);
1356		customOverlay = null;
1357		installEditorBorderPatch();
1358
1359		// Check --start-mode CLI flag — apply mode ephemerally without
1360		// persisting provider/model to settings.json so subsequent launches
1361		// aren't affected.
1362		const startMode = ((pi.getFlag("start-mode") as string) || process.env.PI_START_MODE || "").trim();
1363		if (startMode) {
1364			const settingsPath = path.join(getGlobalAgentDir(), "settings.json");
1365			const savedSettings = await fs.readFile(settingsPath, "utf8").catch(() => null);
1366			await applyMode(pi, ctx, startMode);
1367			// Restore settings.json so defaultProvider/defaultModel aren't changed
1368			if (savedSettings) {
1369				await fs.writeFile(settingsPath, savedSettings, "utf8");
1370			}
1371			return;
1372		}
1373
1374		const inferred = inferModeFromSelection(ctx, pi, runtime.data);
1375		if (inferred) {
1376			runtime.currentMode = inferred;
1377			runtime.lastRealMode = inferred;
1378		} else {
1379			// No exact match → treat as overlay.
1380			runtime.currentMode = CUSTOM_MODE_NAME;
1381			customOverlay = getCurrentSelectionSpec(pi, ctx);
1382		}
1383
1384		updateModeStatus(ctx);
1385		applyEditor(pi, ctx);
1386	});
1387
1388	pi.on("session_switch", async (_event, ctx) => {
1389		lastObservedModel = { provider: ctx.model?.provider, modelId: ctx.model?.id };
1390		await ensureRuntime(pi, ctx);
1391		customOverlay = null;
1392
1393		const inferred = inferModeFromSelection(ctx, pi, runtime.data);
1394		if (inferred) {
1395			runtime.currentMode = inferred;
1396			runtime.lastRealMode = inferred;
1397		} else {
1398			runtime.currentMode = CUSTOM_MODE_NAME;
1399			customOverlay = getCurrentSelectionSpec(pi, ctx);
1400		}
1401
1402		updateModeStatus(ctx);
1403		applyEditor(pi, ctx);
1404	});
1405
1406
1407	pi.on("model_select", async (event: ModelSelectEvent, ctx) => {
1408		// Always track the last observed model for overlay/store correctness.
1409		lastObservedModel = { provider: event.model.provider, modelId: event.model.id };
1410
1411		// Skip mode switching triggered by applyMode() itself, otherwise we'd jump to "custom"
1412		// while we are in the middle of applying a mode.
1413		if (runtime.applying) return;
1414
1415		// Manual model changes always go into the overlay "custom" mode.
1416		await ensureRuntime(pi, ctx);
1417		if (runtime.currentMode !== CUSTOM_MODE_NAME) {
1418			runtime.lastRealMode = runtime.currentMode;
1419		}
1420		runtime.currentMode = CUSTOM_MODE_NAME;
1421
1422		customOverlay = {
1423			provider: event.model.provider,
1424			modelId: event.model.id,
1425			thinkingLevel: pi.getThinkingLevel(),
1426		};
1427
1428		// Do not persist/select custom.
1429		if (ctx.hasUI) {
1430			updateModeStatus(ctx);
1431			requestEditorRender?.();
1432		}
1433	});
1434
1435}