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