Commit 896740f28ef7

Vincent Demeester <vincent@sbr.pm>
2026-02-18 13:18:54
feat(dots): replace cwd-history with prompt-editor modes system
Adopted mitsuhiko's prompt-editor.ts which is a superset of cwd-history with a full modes system for quick provider/model/ thinking presets. Adds Ctrl+Space cycling, /mode command, and modes.json persistence. Three modes configured: default, fast, and code-work.
1 parent fdde304
Changed files (2)
dots
pi
dots/pi/agent/extensions/cwd-history.ts
@@ -1,13 +1,923 @@
-import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
-import { CustomEditor } from "@mariozechner/pi-coding-agent";
+import type { ExtensionAPI, ExtensionContext, ModelSelectEvent, ThinkingLevel } from "@mariozechner/pi-coding-agent";
+import { CustomEditor, ModelSelectorComponent, SettingsManager } from "@mariozechner/pi-coding-agent";
 import path from "node:path";
 import os from "node:os";
 import fs from "node:fs/promises";
+import type { Dirent } from "node:fs";
+
+// =============================================================================
+// Modes
+// =============================================================================
+
+type ModeName = string;
+
+type ModeSpec = {
+	provider?: string;
+	modelId?: string;
+	thinkingLevel?: ThinkingLevel;
+	/**
+	 * Optional theme color token to use for the editor border.
+	 * If unset, the border color is derived from the (current) thinking level.
+	 */
+	color?: string;
+};
+
+type ModesFile = {
+	version: 1;
+	currentMode: ModeName;
+	modes: Record<ModeName, ModeSpec>;
+};
+
+// Only "default" is a forced/built-in mode. Others are just initial suggestions and can be renamed/deleted.
+const DEFAULT_MODE_ORDER = ["default"] as const;
+const CUSTOM_MODE_NAME = "custom" as const;
+
+function expandUserPath(p: string): string {
+	if (p === "~") return os.homedir();
+	if (p.startsWith("~/")) return path.join(os.homedir(), p.slice(2));
+	return p;
+}
+
+function getGlobalAgentDir(): string {
+	// Mirror pi-coding-agent's getAgentDir() behavior (best-effort).
+	// For the canonical implementation see pi-mono/packages/coding-agent/src/config.ts
+	const env = process.env.PI_CODING_AGENT_DIR;
+	if (env) return expandUserPath(env);
+	return path.join(os.homedir(), ".pi", "agent");
+}
+
+function getGlobalModesPath(): string {
+	return path.join(getGlobalAgentDir(), "modes.json");
+}
+
+function getProjectModesPath(cwd: string): string {
+	return path.join(cwd, ".pi", "modes.json");
+}
+
+async function fileExists(p: string): Promise<boolean> {
+	try {
+		await fs.stat(p);
+		return true;
+	} catch {
+		return false;
+	}
+}
+
+async function ensureDirForFile(filePath: string): Promise<void> {
+	await fs.mkdir(path.dirname(filePath), { recursive: true });
+}
+
+async function getMtimeMs(p: string): Promise<number | null> {
+	try {
+		const st = await fs.stat(p);
+		return st.mtimeMs;
+	} catch {
+		return null;
+	}
+}
+
+function sleep(ms: number): Promise<void> {
+	return new Promise((resolve) => setTimeout(resolve, ms));
+}
+
+function getLockPathForFile(filePath: string): string {
+	// Lock file next to the json so it works across processes.
+	return `${filePath}.lock`;
+}
+
+async function withFileLock<T>(filePath: string, fn: () => Promise<T>): Promise<T> {
+	const lockPath = getLockPathForFile(filePath);
+	await ensureDirForFile(lockPath);
+
+	const start = Date.now();
+	while (true) {
+		try {
+			const handle = await fs.open(lockPath, "wx");
+			try {
+				// Best-effort metadata for debugging stale locks.
+				await handle.writeFile(
+					JSON.stringify({ pid: process.pid, createdAt: new Date().toISOString() }) + "\n",
+					"utf8"
+				);
+			} catch {
+				// ignore
+			}
+
+			try {
+				return await fn();
+			} finally {
+				await handle.close().catch(() => {});
+				await fs.unlink(lockPath).catch(() => {});
+			}
+		} catch (err: any) {
+			if (err?.code !== "EEXIST") throw err;
+
+			// If the lock looks stale (crash), break it.
+			try {
+				const st = await fs.stat(lockPath);
+				if (Date.now() - st.mtimeMs > 30_000) {
+					await fs.unlink(lockPath);
+					continue;
+				}
+			} catch {
+				// ignore
+			}
+
+			if (Date.now() - start > 5_000) {
+				// Don't hang the UI forever.
+				throw new Error(`Timed out waiting for lock: ${lockPath}`);
+			}
+			await sleep(40 + Math.random() * 80);
+		}
+	}
+}
+
+async function atomicWriteUtf8(filePath: string, content: string): Promise<void> {
+	await ensureDirForFile(filePath);
+
+	const dir = path.dirname(filePath);
+	const base = path.basename(filePath);
+	const tmpPath = path.join(dir, `.${base}.tmp.${process.pid}.${Math.random().toString(16).slice(2)}`);
+
+	await fs.writeFile(tmpPath, content, "utf8");
+
+	try {
+		// POSIX: atomic replace.
+		await fs.rename(tmpPath, filePath);
+	} catch (err: any) {
+		// Windows: rename can't overwrite.
+		if (err?.code === "EEXIST" || err?.code === "EPERM") {
+			await fs.unlink(filePath).catch(() => {});
+			await fs.rename(tmpPath, filePath);
+		} else {
+			// best-effort cleanup
+			await fs.unlink(tmpPath).catch(() => {});
+			throw err;
+		}
+	}
+}
+
+function cloneModesFile(file: ModesFile): ModesFile {
+	// JSON-based clone is fine here (small, plain data structure).
+	return JSON.parse(JSON.stringify(file)) as ModesFile;
+}
+
+type ModeSpecPatch = {
+	provider?: string | null;
+	modelId?: string | null;
+	thinkingLevel?: ThinkingLevel | null;
+	color?: string | null;
+};
+
+type ModesPatch = {
+	currentMode?: ModeName;
+	modes?: Record<ModeName, ModeSpecPatch | null>;
+};
+
+function computeModesPatch(base: ModesFile, next: ModesFile, includeCurrentMode: boolean): ModesPatch | null {
+	const patch: ModesPatch = {};
+
+	if (includeCurrentMode && base.currentMode !== next.currentMode) {
+		patch.currentMode = next.currentMode;
+	}
+
+	const keys = new Set([...Object.keys(base.modes), ...Object.keys(next.modes)]);
+	const modesPatch: Record<ModeName, ModeSpecPatch | null> = {};
+
+	for (const k of keys) {
+		const a = base.modes[k];
+		const b = next.modes[k];
+
+		if (!b) {
+			if (a) modesPatch[k] = null;
+			continue;
+		}
+		if (!a) {
+			modesPatch[k] = { ...b };
+			continue;
+		}
+
+		const diff: ModeSpecPatch = {};
+		const fields: (keyof ModeSpec)[] = ["provider", "modelId", "thinkingLevel", "color"];
+		for (const f of fields) {
+			const av = a[f];
+			const bv = b[f];
+			if (av !== bv) {
+				(diff as any)[f] = bv === undefined ? null : bv;
+			}
+		}
+		if (Object.keys(diff).length > 0) {
+			modesPatch[k] = diff;
+		}
+	}
+
+	if (Object.keys(modesPatch).length > 0) {
+		patch.modes = modesPatch;
+	}
+
+	if (!patch.modes && patch.currentMode === undefined) return null;
+	return patch;
+}
+
+function applyModesPatch(target: ModesFile, patch: ModesPatch): void {
+	if (patch.currentMode !== undefined) {
+		target.currentMode = patch.currentMode;
+	}
+
+	if (!patch.modes) return;
+	for (const [mode, specPatch] of Object.entries(patch.modes)) {
+		if (specPatch === null) {
+			delete target.modes[mode];
+			continue;
+		}
+
+		const targetSpec: Record<string, unknown> = ((target.modes[mode] ??= {}) as any) ?? {};
+		for (const [k, v] of Object.entries(specPatch)) {
+			if (v === null || v === undefined) {
+				delete targetSpec[k];
+			} else {
+				targetSpec[k] = v;
+			}
+		}
+	}
+}
+
+function normalizeThinkingLevel(level: unknown): ThinkingLevel | undefined {
+	if (typeof level !== "string") return undefined;
+	const v = level as ThinkingLevel;
+	// Keep the list local to avoid importing internal enums.
+	const allowed: ThinkingLevel[] = ["off", "minimal", "low", "medium", "high", "xhigh"];
+	return allowed.includes(v) ? v : undefined;
+}
+
+function sanitizeModeSpec(spec: unknown): ModeSpec {
+	const obj = (spec && typeof spec === "object" ? spec : {}) as Record<string, unknown>;
+	return {
+		provider: typeof obj.provider === "string" ? obj.provider : undefined,
+		modelId: typeof obj.modelId === "string" ? obj.modelId : undefined,
+		thinkingLevel: normalizeThinkingLevel(obj.thinkingLevel),
+		color: typeof obj.color === "string" ? obj.color : undefined,
+	};
+}
+
+function createDefaultModes(ctx: ExtensionContext, pi: ExtensionAPI): ModesFile {
+	const currentModel = ctx.model;
+	const currentThinking = pi.getThinkingLevel();
+
+	const base: ModeSpec = {
+		provider: currentModel?.provider,
+		modelId: currentModel?.id,
+		thinkingLevel: currentThinking,
+	};
+
+	return {
+		version: 1,
+		currentMode: "default",
+		modes: {
+			// Forced default mode
+			default: { ...base },
+			// Convenience mode (user can delete/rename)
+			fast: { ...base, thinkingLevel: "off" },
+		},
+	};
+}
+
+function ensureDefaultModeEntries(file: ModesFile, ctx: ExtensionContext, pi: ExtensionAPI): void {
+	for (const name of DEFAULT_MODE_ORDER) {
+		if (!file.modes[name]) {
+			const defaults = createDefaultModes(ctx, pi);
+			file.modes[name] = defaults.modes[name];
+		}
+	}
+
+	// "custom" is an overlay mode; never treat it as a valid persisted current mode.
+	if (file.currentMode === CUSTOM_MODE_NAME) {
+		file.currentMode = "" as any;
+	}
+
+	if (!file.currentMode || !(file.currentMode in file.modes) || file.currentMode === CUSTOM_MODE_NAME) {
+		const first = Object.keys(file.modes).find((k) => k !== CUSTOM_MODE_NAME);
+		file.currentMode = file.modes.default ? "default" : first || "default";
+	}
+}
+
+async function loadModesFile(filePath: string, ctx: ExtensionContext, pi: ExtensionAPI): Promise<ModesFile> {
+	try {
+		const raw = await fs.readFile(filePath, "utf8");
+		const parsed = JSON.parse(raw) as Record<string, unknown>;
+		const currentMode = typeof parsed.currentMode === "string" ? parsed.currentMode : "default";
+		const modesRaw = parsed.modes && typeof parsed.modes === "object" ? (parsed.modes as Record<string, unknown>) : {};
+		const modes: Record<string, ModeSpec> = {};
+		for (const [k, v] of Object.entries(modesRaw)) {
+			modes[k] = sanitizeModeSpec(v);
+		}
+		const file: ModesFile = {
+			version: 1,
+			currentMode,
+			modes,
+		};
+		ensureDefaultModeEntries(file, ctx, pi);
+		return file;
+	} catch {
+		return createDefaultModes(ctx, pi);
+	}
+}
+
+async function saveModesFile(filePath: string, data: ModesFile): Promise<void> {
+	await atomicWriteUtf8(filePath, JSON.stringify(data, null, 2) + "\n");
+}
+
+function orderedModeNames(modes: Record<string, ModeSpec>): string[] {
+	// Preserve insertion order from the JSON file.
+	// Object key iteration order is stable in modern JS runtimes.
+	// NOTE: "custom" is an overlay mode and must not be selectable/persisted.
+	return Object.keys(modes).filter((name) => name !== CUSTOM_MODE_NAME);
+}
+
+function getModeBorderColor(ctx: ExtensionContext, pi: ExtensionAPI, mode: string): (text: string) => string {
+	const theme = ctx.ui.theme;
+	const spec = runtime.data.modes[mode];
+
+	// Explicit color override in JSON.
+	if (spec?.color) {
+		try {
+			// Validate early so we don't crash during render.
+			theme.getFgAnsi(spec.color as any);
+			return (text: string) => theme.fg(spec.color as any, text);
+		} catch {
+			// fall through to thinking-based colors
+		}
+	}
+
+	// Default: derive from the current thinking level.
+	return theme.getThinkingBorderColor(pi.getThinkingLevel());
+}
+
+function formatModeLabel(mode: string): string {
+	return mode;
+}
+
+async function resolveModesPath(cwd: string): Promise<string> {
+	const projectPath = getProjectModesPath(cwd);
+	if (await fileExists(projectPath)) return projectPath;
+	return getGlobalModesPath();
+}
+
+function inferModeFromSelection(ctx: ExtensionContext, pi: ExtensionAPI, data: ModesFile): string | null {
+	const provider = ctx.model?.provider;
+	const modelId = ctx.model?.id;
+	const thinkingLevel = pi.getThinkingLevel();
+	if (!provider || !modelId) return null;
+
+	// Only consider persisted/real modes (exclude the overlay "custom").
+	const names = orderedModeNames(data.modes);
+
+	const supportsThinking = Boolean(ctx.model?.reasoning);
+
+	// 1) If thinking is supported, require an exact match so modes can differ by thinking level.
+	if (supportsThinking) {
+		for (const name of names) {
+			const spec = data.modes[name];
+			if (!spec) continue;
+			if (spec.provider !== provider || spec.modelId !== modelId) continue;
+			if ((spec.thinkingLevel ?? undefined) !== thinkingLevel) continue;
+			return name;
+		}
+		return null;
+	}
+
+	// 2) If thinking is NOT supported by the model, the effective level will always be "off".
+	// In that case, treat thinkingLevel differences in modes.json as non-distinguishing.
+	const candidates: string[] = [];
+	for (const name of names) {
+		const spec = data.modes[name];
+		if (!spec) continue;
+		if (spec.provider !== provider || spec.modelId !== modelId) continue;
+		candidates.push(name);
+	}
+	if (candidates.length === 0) return null;
+
+	// Prefer a candidate that explicitly matches the effective thinking level.
+	for (const name of candidates) {
+		const spec = data.modes[name];
+		if (!spec) continue;
+		if ((spec.thinkingLevel ?? "off") === thinkingLevel) return name;
+	}
+
+	// Next prefer a candidate with no thinkingLevel configured.
+	for (const name of candidates) {
+		const spec = data.modes[name];
+		if (!spec) continue;
+		if (!spec.thinkingLevel) return name;
+	}
+
+	return candidates[0] ?? null;
+}
+
+type ModeRuntime = {
+	filePath: string;
+	fileMtimeMs: number | null;
+	/**
+	 * Snapshot of what we last loaded/synced from disk. Used to compute patches so
+	 * multiple running pi processes don't clobber each other's mode edits.
+	 */
+	baseline: ModesFile | null;
+	data: ModesFile;
+
+	/**
+	 * Last non-overlay mode. Used as cycle base while in the overlay "custom" mode.
+	 */
+	lastRealMode: string;
+
+	/**
+	 * The effective current mode. Can temporarily be "custom" (overlay),
+	 * which is *not* persisted and not selectable via /mode.
+	 */
+	currentMode: string;
+	// guard against feedback loops when we switch model ourselves
+	applying: boolean;
+};
+
+const runtime: ModeRuntime = {
+	filePath: "",
+	fileMtimeMs: null,
+	baseline: null,
+	data: { version: 1, currentMode: "default", modes: {} },
+	lastRealMode: "default",
+	currentMode: "default",
+	applying: false,
+};
+
+// Updated by setEditor() when the custom editor is instantiated.
+let requestEditorRender: (() => void) | undefined;
+
+async function ensureRuntime(pi: ExtensionAPI, ctx: ExtensionContext): Promise<void> {
+	const filePath = await resolveModesPath(ctx.cwd);
+
+	const mtimeMs = await getMtimeMs(filePath);
+	const filePathChanged = runtime.filePath !== filePath;
+	const fileChanged = filePathChanged || runtime.fileMtimeMs !== mtimeMs;
+
+	if (fileChanged) {
+		runtime.filePath = filePath;
+		runtime.fileMtimeMs = mtimeMs;
+
+		const loaded = await loadModesFile(filePath, ctx, pi);
+		// Normalize/ensure defaults *before* we snapshot baseline so later persistence
+		// only reflects explicit user actions ("store").
+		ensureDefaultModeEntries(loaded, ctx, pi);
+		runtime.data = loaded;
+		runtime.baseline = cloneModesFile(runtime.data);
+
+		// Reset overlay when switching projects.
+		if (filePathChanged && runtime.currentMode !== CUSTOM_MODE_NAME) {
+			runtime.currentMode = runtime.data.currentMode;
+			runtime.lastRealMode = runtime.currentMode;
+		}
+	}
+
+	// If we're not in the overlay "custom" mode, ensure currentMode is valid.
+	if (runtime.currentMode !== CUSTOM_MODE_NAME) {
+		if (!runtime.currentMode || !(runtime.currentMode in runtime.data.modes)) {
+			runtime.currentMode = runtime.data.currentMode;
+		}
+		if (!runtime.lastRealMode || !(runtime.lastRealMode in runtime.data.modes)) {
+			runtime.lastRealMode = runtime.currentMode;
+		}
+	}
+}
+
+async function persistRuntime(pi: ExtensionAPI, ctx: ExtensionContext): Promise<void> {
+	if (!runtime.filePath) return;
+
+	// Do not persist currentMode; multiple running pi sessions would fight over it.
+	// Instead we infer the mode on startup from the active model + thinking level.
+	runtime.baseline ??= cloneModesFile(runtime.data);
+	const patch = computeModesPatch(runtime.baseline, runtime.data, false);
+	if (!patch) return;
+
+	await withFileLock(runtime.filePath, async () => {
+		// Merge our local patch into the latest on disk to avoid clobbering other agents.
+		const latest = await loadModesFile(runtime.filePath, ctx, pi);
+		applyModesPatch(latest, patch);
+		ensureDefaultModeEntries(latest, ctx, pi);
+		await saveModesFile(runtime.filePath, latest);
+
+		runtime.data = latest;
+		runtime.baseline = cloneModesFile(latest);
+		runtime.fileMtimeMs = await getMtimeMs(runtime.filePath);
+	});
+}
+
+// We cannot reliably read the *current* model immediately after pi.setModel() in the same tick,
+// because ctx.model is a snapshot-ish view that is updated via the model_select event.
+// Track the last observed model ourselves and use it for overlays / storing.
+let lastObservedModel: { provider?: string; modelId?: string } = {};
+
+function getCurrentSelectionSpec(pi: ExtensionAPI, _ctx: ExtensionContext): ModeSpec {
+	return {
+		provider: lastObservedModel.provider,
+		modelId: lastObservedModel.modelId,
+		thinkingLevel: pi.getThinkingLevel(),
+	};
+}
+
+async function storeSelectionIntoMode(pi: ExtensionAPI, ctx: ExtensionContext, mode: string, selection: ModeSpec): Promise<void> {
+	// "custom" is an overlay; it is not persisted.
+	if (mode === CUSTOM_MODE_NAME) return;
+
+	await ensureRuntime(pi, ctx);
+
+	const existingTarget = runtime.data.modes[mode] ?? {};
+	const next: ModeSpec = { ...existingTarget };
+
+	// Only overwrite fields that we can actually observe.
+	if (selection.provider && selection.modelId) {
+		next.provider = selection.provider;
+		next.modelId = selection.modelId;
+	}
+	if (selection.thinkingLevel) next.thinkingLevel = selection.thinkingLevel;
+
+	runtime.data.modes[mode] = next;
+	await persistRuntime(pi, ctx);
+}
+
+async function applyMode(pi: ExtensionAPI, ctx: ExtensionContext, mode: string): Promise<void> {
+	await ensureRuntime(pi, ctx);
+
+	// "custom" is a runtime-only overlay mode.
+	if (mode === CUSTOM_MODE_NAME) {
+		runtime.currentMode = CUSTOM_MODE_NAME;
+		customOverlay = getCurrentSelectionSpec(pi, ctx);
+		if (ctx.hasUI) requestEditorRender?.();
+		return;
+	}
+
+	const spec = runtime.data.modes[mode];
+	if (!spec) {
+		if (ctx.hasUI) {
+			ctx.ui.notify(`Unknown mode: ${mode}`, "warning");
+		}
+		return;
+	}
+
+	runtime.currentMode = mode;
+	runtime.lastRealMode = mode;
+	customOverlay = null;
+
+	runtime.applying = true;
+	let modelAppliedOk = true;
+	try {
+		// Apply model
+		if (spec.provider && spec.modelId) {
+			const m = ctx.modelRegistry.find(spec.provider, spec.modelId);
+			if (m) {
+				const ok = await pi.setModel(m);
+				modelAppliedOk = ok;
+				if (!ok && ctx.hasUI) {
+					ctx.ui.notify(`No API key available for ${spec.provider}/${spec.modelId}`, "warning");
+				}
+			} else {
+				modelAppliedOk = false;
+				if (ctx.hasUI) {
+					ctx.ui.notify(`Mode "${mode}" references unknown model ${spec.provider}/${spec.modelId}`, "warning");
+				}
+			}
+		}
+
+		// Apply thinking level
+		if (spec.thinkingLevel) {
+			pi.setThinkingLevel(spec.thinkingLevel);
+		}
+	} finally {
+		runtime.applying = false;
+	}
+
+	// If we couldn't apply the requested model (e.g. missing API key), switch to overlay.
+	// We do *not* treat thinking-level clamping as a failure: clamping is expected when
+	// switching between models with different thinking capabilities.
+	if (!modelAppliedOk) {
+		runtime.currentMode = CUSTOM_MODE_NAME;
+		customOverlay = getCurrentSelectionSpec(pi, ctx);
+	}
+
+	if (ctx.hasUI) {
+		requestEditorRender?.();
+	}
+}
+
+const MODE_UI_CONFIGURE = "Configure modes…";
+const MODE_UI_ADD = "Add mode…";
+const MODE_UI_BACK = "Back";
+
+const ALL_THINKING_LEVELS: ThinkingLevel[] = ["off", "minimal", "low", "medium", "high", "xhigh"];
+const THINKING_UNSET_LABEL = "(don't change)";
+
+function isDefaultModeName(name: string): boolean {
+	return (DEFAULT_MODE_ORDER as readonly string[]).includes(name);
+}
+
+function isReservedModeName(name: string): boolean {
+	return name === CUSTOM_MODE_NAME || name === MODE_UI_CONFIGURE || name === MODE_UI_ADD || name === MODE_UI_BACK;
+}
+
+function normalizeModeNameInput(name: string | undefined): string {
+	return (name ?? "").trim();
+}
+
+function validateModeNameOrError(
+	name: string,
+	existing: Record<string, ModeSpec>,
+	opts?: { allowExisting?: boolean },
+): string | null {
+	if (!name) return "Mode name cannot be empty";
+	if (/\s/.test(name)) return "Mode name cannot contain whitespace";
+	if (isReservedModeName(name)) return `Mode name \"${name}\" is reserved`;
+	if (!opts?.allowExisting && existing[name]) return `Mode \"${name}\" already exists`;
+	return null;
+}
+
+async function handleModeChoiceUI(pi: ExtensionAPI, ctx: ExtensionContext, choice: string): Promise<void> {
+	// Special behavior: when we're in "custom" and select another mode,
+	// offer to either *use* it (switch) or *store* the current custom selection into it.
+	if (runtime.currentMode === CUSTOM_MODE_NAME && choice !== CUSTOM_MODE_NAME) {
+		const action = await ctx.ui.select(`Mode \"${choice}\"`, ["use", "store"]);
+		if (!action) return;
+
+		if (action === "use") {
+			await applyMode(pi, ctx, choice);
+			return;
+		}
+
+		// "store": overwrite target mode with the current overlay selection (keep target color if set)
+		await ensureRuntime(pi, ctx);
+		const overlay = customOverlay ?? getCurrentSelectionSpec(pi, ctx);
+		await storeSelectionIntoMode(pi, ctx, choice, overlay);
+		await applyMode(pi, ctx, choice);
+		ctx.ui.notify(`Stored ${CUSTOM_MODE_NAME} into \"${choice}\"`, "info");
+		return;
+	}
+
+	await applyMode(pi, ctx, choice);
+}
+
+async function selectModeUI(pi: ExtensionAPI, ctx: ExtensionContext): Promise<void> {
+	if (!ctx.hasUI) return;
+
+	while (true) {
+		await ensureRuntime(pi, ctx);
+		const names = orderedModeNames(runtime.data.modes);
+		const choice = await ctx.ui.select(`Mode (current: ${runtime.currentMode})`, [...names, MODE_UI_CONFIGURE]);
+		if (!choice) return;
+
+		if (choice === MODE_UI_CONFIGURE) {
+			await configureModesUI(pi, ctx);
+			continue;
+		}
+
+		await handleModeChoiceUI(pi, ctx, choice);
+		return;
+	}
+}
+
+async function configureModesUI(pi: ExtensionAPI, ctx: ExtensionContext): Promise<void> {
+	if (!ctx.hasUI) return;
+
+	while (true) {
+		await ensureRuntime(pi, ctx);
+		const names = orderedModeNames(runtime.data.modes);
+		const choice = await ctx.ui.select("Configure modes", [...names, MODE_UI_ADD, MODE_UI_BACK]);
+		if (!choice || choice === MODE_UI_BACK) return;
+
+		if (choice === MODE_UI_ADD) {
+			const created = await addModeUI(pi, ctx);
+			if (created) {
+				await editModeUI(pi, ctx, created);
+			}
+			continue;
+		}
+
+		await editModeUI(pi, ctx, choice);
+	}
+}
+
+async function addModeUI(pi: ExtensionAPI, ctx: ExtensionContext): Promise<string | undefined> {
+	if (!ctx.hasUI) return undefined;
+	await ensureRuntime(pi, ctx);
+
+	while (true) {
+		const raw = await ctx.ui.input("New mode name", "e.g. docs, review, planning");
+		if (raw === undefined) return undefined;
+
+		const name = normalizeModeNameInput(raw);
+		const err = validateModeNameOrError(name, runtime.data.modes);
+		if (err) {
+			ctx.ui.notify(err, "warning");
+			continue;
+		}
+
+		// Default new modes to the current selection so they behave as expected immediately.
+		const selection = customOverlay ?? getCurrentSelectionSpec(pi, ctx);
+		runtime.data.modes[name] = {
+			provider: selection.provider,
+			modelId: selection.modelId,
+			thinkingLevel: selection.thinkingLevel,
+		};
+		await persistRuntime(pi, ctx);
+		ctx.ui.notify(`Added mode \"${name}\"`, "info");
+		return name;
+	}
+}
+
+async function editModeUI(pi: ExtensionAPI, ctx: ExtensionContext, mode: string): Promise<void> {
+	if (!ctx.hasUI) return;
+
+	let modeName = mode;
+
+	while (true) {
+		await ensureRuntime(pi, ctx);
+		const spec = runtime.data.modes[modeName];
+		if (!spec) return;
+
+		const modelLabel = spec.provider && spec.modelId ? `${spec.provider}/${spec.modelId}` : "(no model)";
+		const thinkingLabel = spec.thinkingLevel ?? THINKING_UNSET_LABEL;
+
+		const actions = ["Change name", "Change model", "Change thinking level"];
+		if (!isDefaultModeName(modeName)) actions.push("Delete mode");
+		actions.push(MODE_UI_BACK);
+
+		const action = await ctx.ui.select(
+			`Edit mode \"${modeName}\"  model: ${modelLabel}  thinking: ${thinkingLabel}`,
+			actions,
+		);
+		if (!action || action === MODE_UI_BACK) return;
+
+		if (action === "Change name") {
+			const renamed = await renameModeUI(pi, ctx, modeName);
+			if (renamed) modeName = renamed;
+			continue;
+		}
+
+		if (action === "Change model") {
+			const selected = await pickModelForModeUI(ctx, spec);
+			if (!selected) continue;
+			spec.provider = selected.provider;
+			spec.modelId = selected.modelId;
+			runtime.data.modes[modeName] = spec;
+			await persistRuntime(pi, ctx);
+			ctx.ui.notify(`Updated model for \"${modeName}\"`, "info");
+
+			if (runtime.currentMode === modeName) {
+				await applyMode(pi, ctx, modeName);
+			}
+			continue;
+		}
+
+		if (action === "Change thinking level") {
+			const level = await pickThinkingLevelForModeUI(ctx, spec.thinkingLevel);
+			if (level === undefined) continue;
+
+			if (level === null) {
+				delete spec.thinkingLevel;
+			} else {
+				spec.thinkingLevel = level;
+			}
+
+			runtime.data.modes[modeName] = spec;
+			await persistRuntime(pi, ctx);
+			ctx.ui.notify(`Updated thinking level for \"${modeName}\"`, "info");
+
+			if (runtime.currentMode === modeName) {
+				await applyMode(pi, ctx, modeName);
+			}
+			continue;
+		}
+
+		if (action === "Delete mode") {
+			const ok = await ctx.ui.confirm("Delete mode", `Delete mode \"${modeName}\"?`);
+			if (!ok) continue;
+
+			delete runtime.data.modes[modeName];
+			await persistRuntime(pi, ctx);
+
+			if (runtime.currentMode === modeName) {
+				runtime.currentMode = CUSTOM_MODE_NAME;
+				customOverlay = getCurrentSelectionSpec(pi, ctx);
+			}
+			if (runtime.lastRealMode === modeName) {
+				runtime.lastRealMode = "default";
+			}
+			requestEditorRender?.();
+			ctx.ui.notify(`Deleted mode \"${modeName}\"`, "info");
+			return;
+		}
+	}
+}
+
+function renameModesRecord(modes: Record<string, ModeSpec>, oldName: string, newName: string): Record<string, ModeSpec> {
+	const out: Record<string, ModeSpec> = {};
+	for (const [k, v] of Object.entries(modes)) {
+		if (k === oldName) out[newName] = v;
+		else out[k] = v;
+	}
+	return out;
+}
+
+async function renameModeUI(pi: ExtensionAPI, ctx: ExtensionContext, oldName: string): Promise<string | undefined> {
+	if (!ctx.hasUI) return undefined;
+
+	if (isDefaultModeName(oldName)) {
+		ctx.ui.notify(`Cannot rename default mode \"${oldName}\"`, "warning");
+		return oldName;
+	}
+
+	await ensureRuntime(pi, ctx);
+
+	while (true) {
+		const raw = await ctx.ui.input(`Rename mode \"${oldName}\"`, oldName);
+		if (raw === undefined) return undefined;
+
+		const newName = normalizeModeNameInput(raw);
+		if (!newName || newName === oldName) return oldName;
+
+		const err = validateModeNameOrError(newName, runtime.data.modes);
+		if (err) {
+			ctx.ui.notify(err, "warning");
+			continue;
+		}
+
+		runtime.data.modes = renameModesRecord(runtime.data.modes, oldName, newName);
+		await persistRuntime(pi, ctx);
+
+		if (runtime.currentMode === oldName) runtime.currentMode = newName;
+		if (runtime.lastRealMode === oldName) runtime.lastRealMode = newName;
+		requestEditorRender?.();
+
+		ctx.ui.notify(`Renamed \"${oldName}\" → \"${newName}\"`, "info");
+		return newName;
+	}
+}
+
+async function pickModelForModeUI(
+	ctx: ExtensionContext,
+	spec: ModeSpec,
+): Promise<{ provider: string; modelId: string } | undefined> {
+	if (!ctx.hasUI) return undefined;
+
+	const settingsManager = SettingsManager.inMemory();
+	const currentModel = spec.provider && spec.modelId ? ctx.modelRegistry.find(spec.provider, spec.modelId) : ctx.model;
+
+	const scopedModels: Array<{ model: any; thinkingLevel: string }> = [];
+
+	return ctx.ui.custom<{ provider: string; modelId: string } | undefined>((tui, _theme, _keybindings, done) => {
+		const selector = new ModelSelectorComponent(
+			tui,
+			currentModel,
+			settingsManager,
+			ctx.modelRegistry as any,
+			scopedModels as any,
+			(model) => done({ provider: model.provider, modelId: model.id }),
+			() => done(undefined),
+		);
+		return selector;
+	});
+}
+
+async function pickThinkingLevelForModeUI(
+	ctx: ExtensionContext,
+	current: ThinkingLevel | undefined,
+): Promise<ThinkingLevel | null | undefined> {
+	if (!ctx.hasUI) return undefined;
+
+	const defaultValue = current ?? "off";
+	const options = [...ALL_THINKING_LEVELS, THINKING_UNSET_LABEL];
+	// Prefer the current selection by ordering it first.
+	const ordered = [defaultValue, ...options.filter((x) => x !== defaultValue)];
+
+	const choice = await ctx.ui.select("Thinking level", ordered);
+	if (!choice) return undefined;
+	if (choice === THINKING_UNSET_LABEL) return null;
+	if (ALL_THINKING_LEVELS.includes(choice as ThinkingLevel)) return choice as ThinkingLevel;
+	return undefined;
+}
+
+async function cycleMode(pi: ExtensionAPI, ctx: ExtensionContext, direction: 1 | -1 = 1): Promise<void> {
+	if (!ctx.hasUI) return;
+	await ensureRuntime(pi, ctx);
+	const names = orderedModeNames(runtime.data.modes);
+	if (names.length === 0) return;
+
+	// If we're currently in the overlay mode, cycle relative to the last real mode.
+	const baseMode = runtime.currentMode === CUSTOM_MODE_NAME ? runtime.lastRealMode : runtime.currentMode;
+	const idx = Math.max(0, names.indexOf(baseMode));
+	const next = names[(idx + direction + names.length) % names.length] ?? names[0]!;
+	await applyMode(pi, ctx, next);
+}
+
+// =============================================================================
+// Prompt history
+// =============================================================================
 
-/**
- * Extension that seeds the prompt editor history with recent prompts from the
- * current session and other sessions started in the same working directory.
- */
 const MAX_HISTORY_ENTRIES = 100;
 const MAX_RECENT_PROMPTS = 30;
 
@@ -16,7 +926,13 @@ interface PromptEntry {
 	timestamp: number;
 }
 
-class HistoryEditor extends CustomEditor {
+class PromptEditor extends CustomEditor {
+	public modeLabelProvider?: () => string;
+	/**
+	 * Color function for the mode label. If unset, the label inherits the border color.
+	 * We use this to keep the label consistent (e.g. same as the footer/status bar).
+	 */
+	public modeLabelColor?: (text: string) => string;
 	private lockedBorder = false;
 	private _borderColor?: (text: string) => string;
 
@@ -41,6 +957,45 @@ class HistoryEditor extends CustomEditor {
 	lockBorderColor() {
 		this.lockedBorder = true;
 	}
+
+	render(width: number): string[] {
+		const lines = super.render(width);
+		const mode = this.modeLabelProvider?.();
+		if (!mode) return lines;
+
+		const stripAnsi = (s: string) => s.replace(/\x1b\[[0-9;]*m/g, "");
+		const topPlain = stripAnsi(lines[0] ?? "");
+
+		// If the editor is scrolled, the built-in editor renders a scroll indicator on the top border.
+		// Preserve it, but still show the mode label.
+		const scrollPrefixMatch = topPlain.match(/^(─── ↑ \d+ more )/);
+		const prefix = scrollPrefixMatch?.[1] ?? "──";
+
+		let label = formatModeLabel(mode);
+
+		// Compute how much room we have for the label core (without truncating the prefix).
+		const labelLeftSpace = prefix.endsWith(" ") ? "" : " ";
+		const labelRightSpace = " ";
+		const minRightBorder = 1; // keep at least one border cell on the right
+		const maxLabelLen = Math.max(0, width - prefix.length - labelLeftSpace.length - labelRightSpace.length - minRightBorder);
+		if (maxLabelLen <= 0) return lines;
+		if (label.length > maxLabelLen) label = label.slice(0, maxLabelLen);
+
+		const labelChunk = `${labelLeftSpace}${label}${labelRightSpace}`;
+
+		const remaining = width - prefix.length - labelChunk.length;
+		if (remaining < 0) return lines;
+
+		const right = "─".repeat(Math.max(0, remaining));
+
+		const labelColor = this.modeLabelColor ?? ((text: string) => this.borderColor(text));
+		lines[0] = this.borderColor(prefix) + labelColor(labelChunk) + this.borderColor(right);
+		return lines;
+	}
+
+	public requestRenderNow(): void {
+		this.tui.requestRender();
+	}
 }
 
 function extractText(content: Array<{ type: string; text?: string }>): string {
@@ -69,7 +1024,7 @@ function collectUserPromptsFromEntries(entries: Array<any>): PromptEntry[] {
 
 function getSessionDirForCwd(cwd: string): string {
 	const safePath = `--${cwd.replace(/^[/\\]/, "").replace(/[/\\:]/g, "-")}--`;
-	return path.join(os.homedir(), ".pi", "agent", "sessions", safePath);
+	return path.join(getGlobalAgentDir(), "sessions", safePath);
 }
 
 async function readTail(filePath: string, maxBytes = 256 * 1024): Promise<string> {
@@ -105,7 +1060,7 @@ async function loadPromptHistoryForCwd(cwd: string, excludeSessionFile?: string)
 	const resolvedExclude = excludeSessionFile ? path.resolve(excludeSessionFile) : undefined;
 	const prompts: PromptEntry[] = [];
 
-	let entries: fs.Dirent[] = [];
+	let entries: Dirent[] = [];
 	try {
 		entries = await fs.readdir(sessionDir, { withFileTypes: true });
 	} catch {
@@ -123,7 +1078,7 @@ async function loadPromptHistoryForCwd(cwd: string, excludeSessionFile?: string)
 				} catch {
 					return undefined;
 				}
-			})
+			}),
 	);
 
 	const sortedFiles = files
@@ -174,6 +1129,9 @@ function buildHistoryList(currentSession: PromptEntry[], previousSessions: Promp
 	return deduped.slice(-MAX_HISTORY_ENTRIES);
 }
 
+// Overlay mode state ("custom"). Not selectable, not cycled into.
+let customOverlay: ModeSpec | null = null;
+
 let loadCounter = 0;
 
 function historiesMatch(a: PromptEntry[], b: PromptEntry[]): boolean {
@@ -184,15 +1142,19 @@ function historiesMatch(a: PromptEntry[], b: PromptEntry[]): boolean {
 	return true;
 }
 
-function setEditorHistory(pi: ExtensionAPI, ctx: ExtensionContext, history: PromptEntry[]) {
+function setEditor(pi: ExtensionAPI, ctx: ExtensionContext, history: PromptEntry[]) {
 	ctx.ui.setEditorComponent((tui, theme, keybindings) => {
-		const editor = new HistoryEditor(tui, theme, keybindings);
+		const editor = new PromptEditor(tui, theme, keybindings);
+		requestEditorRender = () => editor.requestRenderNow();
+		editor.modeLabelProvider = () => runtime.currentMode;
+		// Keep the mode label color stable (match footer/status bar).
+		editor.modeLabelColor = (text: string) => ctx.ui.theme.fg("dim", text);
 		const borderColor = (text: string) => {
 			const isBashMode = editor.getText().trimStart().startsWith("!");
-			const colorFn = isBashMode
-				? ctx.ui.theme.getBashModeBorderColor()
-				: ctx.ui.theme.getThinkingBorderColor(pi.getThinkingLevel());
-			return colorFn(text);
+			if (isBashMode) {
+				return ctx.ui.theme.getBashModeBorderColor()(text);
+			}
+			return getModeBorderColor(ctx, pi, runtime.currentMode)(text);
 		};
 
 		editor.borderColor = borderColor;
@@ -204,7 +1166,7 @@ function setEditorHistory(pi: ExtensionAPI, ctx: ExtensionContext, history: Prom
 	});
 }
 
-function applyEditorWithHistory(pi: ExtensionAPI, ctx: ExtensionContext) {
+function applyEditor(pi: ExtensionAPI, ctx: ExtensionContext) {
 	if (!ctx.hasUI) return;
 
 	const sessionFile = ctx.sessionManager.getSessionFile();
@@ -214,7 +1176,7 @@ function applyEditorWithHistory(pi: ExtensionAPI, ctx: ExtensionContext) {
 
 	const currentLoad = ++loadCounter;
 	const initialText = ctx.ui.getEditorText();
-	setEditorHistory(pi, ctx, immediateHistory);
+	setEditor(pi, ctx, immediateHistory);
 
 	void (async () => {
 		const previousPrompts = await loadPromptHistoryForCwd(ctx.cwd, sessionFile ?? undefined);
@@ -222,16 +1184,132 @@ function applyEditorWithHistory(pi: ExtensionAPI, ctx: ExtensionContext) {
 		if (ctx.ui.getEditorText() !== initialText) return;
 		const history = buildHistoryList(currentPrompts, previousPrompts);
 		if (historiesMatch(history, immediateHistory)) return;
-		setEditorHistory(pi, ctx, history);
+		setEditor(pi, ctx, history);
 	})();
 }
 
+// =============================================================================
+// Extension Export
+// =============================================================================
+
 export default function (pi: ExtensionAPI) {
-	pi.on("session_start", (_event, ctx) => {
-		applyEditorWithHistory(pi, ctx);
+	pi.registerCommand("mode", {
+		description: "Select prompt mode",
+		handler: async (args, ctx) => {
+			const tokens = args
+				.split(/\s+/)
+				.map((x) => x.trim())
+				.filter(Boolean);
+
+			// /mode
+			if (tokens.length === 0) {
+				await selectModeUI(pi, ctx);
+				return;
+			}
+
+			// /mode store [name]
+			if (tokens[0] === "store") {
+				await ensureRuntime(pi, ctx);
+
+				let target = tokens[1];
+				if (!target) {
+					if (!ctx.hasUI) return;
+					const names = orderedModeNames(runtime.data.modes);
+					target = await ctx.ui.select("Store current selection into mode", names);
+					if (!target) return;
+				}
+
+				if (target === CUSTOM_MODE_NAME) {
+					if (ctx.hasUI) ctx.ui.notify(`Cannot store into "${CUSTOM_MODE_NAME}"`, "warning");
+					return;
+				}
+
+				const selection = customOverlay ?? getCurrentSelectionSpec(pi, ctx);
+				await storeSelectionIntoMode(pi, ctx, target, selection);
+				if (ctx.hasUI) ctx.ui.notify(`Stored current selection into "${target}"`, "info");
+				return;
+			}
+
+			// /mode <name>
+			await applyMode(pi, ctx, tokens[0]!);
+		},
 	});
 
-	pi.on("session_switch", (_event, ctx) => {
-		applyEditorWithHistory(pi, ctx);
+	pi.registerShortcut("ctrl+shift+m", {
+		description: "Select prompt mode",
+		handler: async (ctx) => {
+			await selectModeUI(pi, ctx);
+		},
 	});
+
+	pi.registerShortcut("ctrl+space", {
+		description: "Cycle prompt mode",
+		handler: async (ctx) => {
+			await cycleMode(pi, ctx, 1);
+		},
+	});
+
+	pi.on("session_start", async (_event, ctx) => {
+		lastObservedModel = { provider: ctx.model?.provider, modelId: ctx.model?.id };
+		await ensureRuntime(pi, ctx);
+		customOverlay = null;
+
+		const inferred = inferModeFromSelection(ctx, pi, runtime.data);
+		if (inferred) {
+			runtime.currentMode = inferred;
+			runtime.lastRealMode = inferred;
+		} else {
+			// No exact match → treat as overlay.
+			runtime.currentMode = CUSTOM_MODE_NAME;
+			customOverlay = getCurrentSelectionSpec(pi, ctx);
+		}
+
+		applyEditor(pi, ctx);
+	});
+
+	pi.on("session_switch", async (_event, ctx) => {
+		lastObservedModel = { provider: ctx.model?.provider, modelId: ctx.model?.id };
+		await ensureRuntime(pi, ctx);
+		customOverlay = null;
+
+		const inferred = inferModeFromSelection(ctx, pi, runtime.data);
+		if (inferred) {
+			runtime.currentMode = inferred;
+			runtime.lastRealMode = inferred;
+		} else {
+			runtime.currentMode = CUSTOM_MODE_NAME;
+			customOverlay = getCurrentSelectionSpec(pi, ctx);
+		}
+
+		applyEditor(pi, ctx);
+	});
+
+
+	pi.on("model_select", async (event: ModelSelectEvent, ctx) => {
+		// Always track the last observed model for overlay/store correctness.
+		lastObservedModel = { provider: event.model.provider, modelId: event.model.id };
+
+		// Skip mode switching triggered by applyMode() itself, otherwise we'd jump to "custom"
+		// while we are in the middle of applying a mode.
+		if (runtime.applying) return;
+
+		// Manual model changes always go into the overlay "custom" mode.
+		await ensureRuntime(pi, ctx);
+		if (runtime.currentMode !== CUSTOM_MODE_NAME) {
+			runtime.lastRealMode = runtime.currentMode;
+		}
+		runtime.currentMode = CUSTOM_MODE_NAME;
+
+		customOverlay = {
+			provider: event.model.provider,
+			modelId: event.model.id,
+			thinkingLevel: pi.getThinkingLevel(),
+		};
+
+		// Do not persist/select custom.
+		if (ctx.hasUI) {
+			requestEditorRender?.();
+		}
+	});
+
 }
dots/pi/agent/modes.json
@@ -0,0 +1,21 @@
+{
+  "version": 1,
+  "currentMode": "default",
+  "modes": {
+    "default": {
+      "provider": "google-vertex-claude",
+      "modelId": "claude-sonnet-4-5@20250929",
+      "thinkingLevel": "minimal"
+    },
+    "fast": {
+      "provider": "google-vertex-claude",
+      "modelId": "claude-sonnet-4-5@20250929",
+      "thinkingLevel": "off"
+    },
+    "code-work": {
+      "provider": "google-vertex-claude",
+      "modelId": "claude-opus-4-6",
+      "thinkingLevel": "low"
+    }
+  }
+}