@anthodev/pi-tools-style 0.1.3 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,215 +1,196 @@
1
- import {
2
- containsTerminalImage,
3
- frameContentWidth,
4
- frameLines,
5
- trimOuterRules,
6
- type FrameOptions,
7
- } from "./frame.js";
8
-
9
- interface Renderable {
10
- render(width: number): string[];
11
- }
12
-
13
- type RenderableConstructor<T extends Renderable = Renderable> = abstract new (
14
- ...args: never[]
15
- ) => T;
16
-
17
- type RenderFunction = (this: Renderable, width: number) => string[];
18
- type DecoratedPrototype = Renderable & Record<symbol, unknown>;
19
-
20
- export interface RenderDecoratorConfig<T extends Renderable = Renderable>
21
- extends Omit<FrameOptions, "title"> {
22
- enabled?: boolean;
23
- key: symbol;
24
- styleBorderFor?: (
25
- instance: T,
26
- ) => NonNullable<FrameOptions["styleBorder"]>;
27
- title?: (instance: T) => string | undefined;
28
- trimOuterRules?: boolean;
29
- }
30
-
31
- export type InstallStatus = "already-installed" | "installed" | "unsupported";
32
-
33
- interface StoredConfig extends Omit<RenderDecoratorConfig, "enabled"> {
34
- enabled: boolean;
35
- }
36
-
37
- interface ResolvedFrameOptions {
38
- options: FrameOptions;
39
- styleSignature: string | undefined;
40
- title: string | undefined;
1
+ import { DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, keyHint, keyText, truncateTail } from "@earendil-works/pi-coding-agent";
2
+ import type { Theme, TruncationResult } from "@earendil-works/pi-coding-agent";
3
+ import { Text } from "@earendil-works/pi-tui";
4
+ import type { Component } from "@earendil-works/pi-tui";
5
+ import { getIconMode, isShellEnabledByConfig } from "./settings.js";
6
+ import type { IconMode } from "./tool-icon.js";
7
+ import { getToolTheme } from "./tool-category.js";
8
+ import { getToolRendererRuntimeState, reportShellIncompatibility } from "./tool-renderer.js";
9
+ import type { ToolLayout, ToolRendererImplementation } from "./tool-renderer.js";
10
+ import type { ToolSnapshot, ToolStatus, ToolView, ToolViewFactory } from "./tool-presentation.js";
11
+
12
+ export type ShellInstallStatus = "already-installed" | "installed" | "unsupported";
13
+ export interface ShellRendererComponentType { readonly prototype: object; }
14
+ type ShellRender = (this: Component, width: number) => string[];
15
+ type ShellStatus = "running" | "complete" | "error" | "cancelled";
16
+ interface ShellAdapterRecord { originalDescriptor: PropertyDescriptor | undefined; wrapper: ShellRender; }
17
+ interface ShellAdapterRegistry { adapters: WeakMap<object, ShellAdapterRecord>; }
18
+ interface ShellCache {
19
+ command: string; output: string; status: ShellStatus; expanded: boolean; colorKey: string; outputPad: number;
20
+ exitCode: number | null | undefined; privateTruncated: boolean | undefined; maxBytes: number | undefined; fullOutputPath: string | undefined;
21
+ width: number; revision: number; tick: number; mode: IconMode; themeColors: Theme["colors"];
22
+ themeColorMode: string | undefined; themeAppearance: Theme["appearance"]; cancelKey: string; expandHint: string;
23
+ factory: ToolViewFactory; layout: ToolRendererImplementation["layoutToolView"];
24
+ truncation: TruncationResult; view: ToolView; snapshot: ToolSnapshot; prepared: ToolLayout; rows: string[];
41
25
  }
42
-
43
- interface RenderCacheEntry {
44
- config: StoredConfig;
45
- output: string[];
46
- rendered: readonly string[];
47
- styleSignature: string | undefined;
48
- title: string | undefined;
49
- width: number;
26
+ interface ShellItem { state: Record<string, unknown>; invalidate: () => void; cache?: ShellCache; }
27
+ const SHELL_DECORATOR_KEY = Symbol.for("pi-tools-style:shell-render");
28
+ const OUTPUT_LIMITS = { maxLines: DEFAULT_MAX_LINES, maxBytes: DEFAULT_MAX_BYTES };
29
+ const STATUS: Record<ShellStatus, ToolStatus> = { running: "running", complete: "done", error: "error", cancelled: "cancelled" };
30
+
31
+ function readRegistry(): ShellAdapterRegistry | undefined {
32
+ const value: unknown = Reflect.get(globalThis, SHELL_DECORATOR_KEY);
33
+ return typeof value === "object" && value !== null && Reflect.get(value, "adapters") instanceof WeakMap
34
+ ? value as ShellAdapterRegistry : undefined;
50
35
  }
51
36
 
52
- const BORDER_STYLE_PROBE = "╭─╮│╰─╯";
53
-
54
- export function installRenderDecorator<T extends Renderable>(
55
- component: RenderableConstructor<T>,
56
- config: RenderDecoratorConfig<T>,
57
- ): InstallStatus {
58
- const prototype = component.prototype as DecoratedPrototype;
59
-
60
- if (prototype[config.key] === true) {
61
- storeConfig(config);
62
- return "already-installed";
37
+ /** Only BashExecutionComponent.render is adapted; its methods and native child tree stay untouched. */
38
+ export function installShellRenderer(component: ShellRendererComponentType): ShellInstallStatus {
39
+ try {
40
+ const prototype = component.prototype;
41
+ const registry = readRegistry() ?? { adapters: new WeakMap<object, ShellAdapterRecord>() };
42
+ const installed = registry.adapters.get(prototype);
43
+ if (installed) return Object.getOwnPropertyDescriptor(prototype, "render")?.value === installed.wrapper ? "already-installed" : "unsupported";
44
+ const originalDescriptor = Object.getOwnPropertyDescriptor(prototype, "render");
45
+ const original: unknown = Reflect.get(prototype, "render");
46
+ if (typeof original !== "function" || typeof Reflect.get(prototype, "getCommand") !== "function" || typeof Reflect.get(prototype, "getOutput") !== "function") return "unsupported";
47
+ if (originalDescriptor && !("value" in originalDescriptor)) return "unsupported";
48
+ if (!Reflect.set(globalThis, SHELL_DECORATOR_KEY, registry)) return "unsupported";
49
+ const wrapper = createShellRenderer(original as ShellRender);
50
+ Object.defineProperty(prototype, "render", { ...(originalDescriptor ?? { configurable: true, enumerable: false, writable: true }), value: wrapper });
51
+ registry.adapters.set(prototype, { originalDescriptor, wrapper });
52
+ return "installed";
53
+ } catch {
54
+ return "unsupported";
63
55
  }
64
-
65
- const renderOriginal = resolveOriginalRenderer(prototype);
66
- if (!renderOriginal) return "unsupported";
67
-
68
- storeConfig(config);
69
-
70
- Object.defineProperty(prototype, "render", {
71
- configurable: true,
72
- value: createDecoratedRenderer(config.key, renderOriginal),
73
- writable: true,
74
- });
75
- Object.defineProperty(prototype, config.key, {
76
- configurable: false,
77
- value: true,
78
- writable: false,
79
- });
80
-
81
- return "installed";
82
56
  }
83
57
 
84
- export function isRenderDecoratorEnabled(key: symbol): boolean {
85
- return readConfig(key)?.enabled ?? false;
86
- }
87
-
88
- export function setRenderDecoratorEnabled(key: symbol, enabled: boolean): void {
89
- const current = readConfig(key);
90
- if (current) Reflect.set(globalThis, key, { ...current, enabled });
91
- }
92
-
93
- function resolveOriginalRenderer(
94
- prototype: DecoratedPrototype,
95
- ): RenderFunction | undefined {
96
- if (Object.hasOwn(prototype, "render")) {
97
- const ownRenderer = prototype.render;
98
- return typeof ownRenderer === "function" ? ownRenderer : undefined;
58
+ /** Restore the original own descriptor, or inheritance, only while our wrapper still owns render. */
59
+ export function uninstallShellRenderer(component: ShellRendererComponentType): boolean {
60
+ try {
61
+ const registry = readRegistry();
62
+ const prototype = component.prototype;
63
+ const installed = registry?.adapters.get(prototype);
64
+ if (!installed || Object.getOwnPropertyDescriptor(prototype, "render")?.value !== installed.wrapper) return false;
65
+ if (installed.originalDescriptor) Object.defineProperty(prototype, "render", installed.originalDescriptor);
66
+ else if (!Reflect.deleteProperty(prototype, "render")) return false;
67
+ registry!.adapters.delete(prototype);
68
+ return true;
69
+ } catch {
70
+ return false;
99
71
  }
100
-
101
- const parent = Object.getPrototypeOf(prototype) as Renderable | null;
102
- if (typeof parent?.render !== "function") return undefined;
103
-
104
- return function renderInherited(width: number): string[] {
105
- const currentParent = Object.getPrototypeOf(prototype) as Renderable | null;
106
- return typeof currentParent?.render === "function"
107
- ? currentParent.render.call(this, width)
108
- : [];
109
- };
110
72
  }
111
73
 
112
- function createDecoratedRenderer(
113
- key: symbol,
114
- renderOriginal: RenderFunction,
115
- ): RenderFunction {
116
- const cache = new WeakMap<Renderable, RenderCacheEntry>();
117
-
118
- return function decoratedRender(width: number): string[] {
119
- const current = readConfig(key);
120
- if (!current?.enabled) return renderOriginal.call(this, width);
121
-
122
- const innerWidth = frameContentWidth(width, current.horizontalPadding);
123
- if (innerWidth < 1) return renderOriginal.call(this, width);
124
-
125
- const rendered = renderOriginal.call(this, innerWidth);
126
- if (containsTerminalImage(rendered)) {
127
- return renderOriginal.call(this, width);
128
- }
129
-
74
+ function createShellRenderer(original: ShellRender): ShellRender {
75
+ const items = new WeakMap<Component, ShellItem>();
76
+ return function renderShell(width: number): string[] {
77
+ const runtime = getToolRendererRuntimeState();
78
+ if (!runtime.enabled || !isShellEnabledByConfig()) return original.call(this, width);
79
+ let rows: string[] | undefined;
80
+ let checkingInternals = false;
130
81
  try {
131
- const resolved = resolveFrameOptions(this, current);
132
- const cached = cache.get(this);
133
- if (
134
- cached?.config === current &&
135
- cached.width === width &&
136
- cached.title === resolved.title &&
137
- cached.styleSignature === resolved.styleSignature &&
138
- linesEqual(cached.rendered, rendered)
139
- ) {
140
- return cached.output;
82
+ const implementation = runtime.implementation;
83
+ const theme = getToolTheme();
84
+ const outerWidth = Math.floor(width);
85
+ if (implementation && theme && Number.isFinite(width) && outerWidth >= 5) {
86
+ checkingInternals = true;
87
+ const outputPad: unknown = Reflect.get(this, "outputPad");
88
+ checkingInternals = !(typeof outputPad === "number" && Number.isFinite(outputPad) && outputPad >= 0
89
+ && outerWidth < Math.max(5, 3 + 2 * Math.floor(outputPad)));
90
+ const getCommand: unknown = Reflect.get(this, "getCommand");
91
+ const getOutput: unknown = Reflect.get(this, "getOutput");
92
+ const status: unknown = Reflect.get(this, "status");
93
+ const expanded: unknown = Reflect.get(this, "expanded");
94
+ const colorKey: unknown = Reflect.get(this, "colorKey");
95
+ const exitCode: unknown = Reflect.get(this, "exitCode");
96
+ const fullOutputPath: unknown = Reflect.get(this, "fullOutputPath");
97
+ const privateTruncation: unknown = Reflect.get(this, "truncationResult");
98
+ const loader: unknown = Reflect.get(this, "loader");
99
+ const privateTruncated = typeof privateTruncation === "object" && privateTruncation !== null ? Reflect.get(privateTruncation, "truncated") as unknown : undefined;
100
+ const maxBytes = typeof privateTruncation === "object" && privateTruncation !== null ? Reflect.get(privateTruncation, "maxBytes") as unknown : undefined;
101
+ const compatible = typeof getCommand === "function" && typeof getOutput === "function"
102
+ && (status === "running" || status === "complete" || status === "error" || status === "cancelled")
103
+ && typeof expanded === "boolean" && (colorKey === "dim" || colorKey === "bashMode")
104
+ && typeof outputPad === "number" && Number.isFinite(outputPad) && outputPad >= 0
105
+ && (exitCode === undefined || exitCode === null || (typeof exitCode === "number" && Number.isFinite(exitCode)))
106
+ && (fullOutputPath === undefined || typeof fullOutputPath === "string")
107
+ && (privateTruncation === undefined || (typeof privateTruncation === "object" && privateTruncation !== null && !Array.isArray(privateTruncation)))
108
+ && (privateTruncated === undefined || typeof privateTruncated === "boolean")
109
+ && (maxBytes === undefined || (typeof maxBytes === "number" && Number.isFinite(maxBytes) && maxBytes >= 0))
110
+ && (status !== "running" || (typeof loader === "object" && loader !== null && typeof Reflect.get(loader, "render") === "function"));
111
+ if (!compatible && checkingInternals) {
112
+ checkingInternals = false;
113
+ reportShellIncompatibility();
114
+ }
115
+ if (compatible && checkingInternals) {
116
+ const command: unknown = getCommand.call(this);
117
+ const output: unknown = getOutput.call(this);
118
+ checkingInternals = false;
119
+ if (typeof command !== "string" || typeof output !== "string") reportShellIncompatibility();
120
+ if (typeof command === "string" && typeof output === "string") {
121
+ const themeColors = theme.colors;
122
+ const themeColorMode = theme.getColorMode?.();
123
+ const themeAppearance = theme.appearance;
124
+ const cancelKey = keyText("tui.select.cancel");
125
+ const expandHint = keyHint("app.tools.expand", "to expand");
126
+ let item = items.get(this);
127
+ if (!item) { item = { state: {}, invalidate: () => this.invalidate() }; items.set(this, item); }
128
+ const cache = item.cache;
129
+ const mode = getIconMode();
130
+ const tick = status === "running" && mode !== "off" ? Math.floor(Date.now() / 80) : 0;
131
+ if (cache && cache.command === command && cache.output === output && cache.status === status && cache.expanded === expanded
132
+ && cache.colorKey === colorKey && cache.outputPad === outputPad && cache.exitCode === exitCode
133
+ && cache.privateTruncated === privateTruncated && cache.maxBytes === maxBytes && cache.fullOutputPath === fullOutputPath
134
+ && cache.revision === runtime.revision && cache.mode === mode && cache.themeColors === themeColors
135
+ && cache.themeColorMode === themeColorMode && cache.themeAppearance === themeAppearance
136
+ && cache.cancelKey === cancelKey && cache.expandHint === expandHint
137
+ && cache.factory === implementation.createToolView && cache.layout === implementation.layoutToolView) {
138
+ if (cache.width === outerWidth && cache.tick === tick) return cache.rows;
139
+ if (cache.width === outerWidth && cache.prepared.refreshHeader) {
140
+ cache.prepared.refreshHeader(tick);
141
+ cache.rows[0] = cache.prepared.callRows[0]!;
142
+ cache.tick = tick;
143
+ return cache.rows;
144
+ }
145
+ // Resize or an implementation without a prepared header still uses the complete layout.
146
+ const layout = implementation.layoutToolView(cache.view, cache.snapshot, theme, outerWidth, tick);
147
+ cache.rows = layout.callRows.concat(layout.resultRows);
148
+ cache.prepared = layout;
149
+ cache.width = outerWidth;
150
+ cache.tick = tick;
151
+ return cache.rows;
152
+ }
153
+ const truncation = cache?.output === output ? cache.truncation : truncateTail(output, OUTPUT_LIMITS);
154
+ const args = { command };
155
+ const snapshot: ToolSnapshot = {
156
+ args, presentation: "builtin",
157
+ result: { content: [{ type: "text", text: truncation.content }], details: {
158
+ truncation: { truncated: privateTruncated === true || truncation.truncated, maxBytes: truncation.truncated ? truncation.maxBytes : maxBytes ?? truncation.maxBytes },
159
+ ...(fullOutputPath !== undefined ? { fullOutputPath } : {}),
160
+ } },
161
+ // Direct !/!! executions have no public tool-call identity or recorded duration.
162
+ context: { args, toolCallId: "", state: item.state, lastComponent: undefined, invalidate: item.invalidate,
163
+ cwd: process.cwd(), argsComplete: true, executionStarted: true, isPartial: status === "running",
164
+ expanded, isError: status === "error", showImages: false, outputPad, durationMs: undefined },
165
+ };
166
+ const view = implementation.createToolView("bash", snapshot, theme);
167
+ let meta = view.head.meta;
168
+ if (colorKey === "dim" || status === "cancelled" || (status === "error" && typeof exitCode === "number")) {
169
+ const extra = [...meta];
170
+ if (colorKey === "dim") extra.push("!! · excluded from context");
171
+ if (status === "cancelled") extra.push("cancelled");
172
+ else if (status === "error" && typeof exitCode === "number") extra.push(`exit ${exitCode}`);
173
+ meta = extra;
174
+ }
175
+ const viewForShell: ToolView = { ...view, head: { title: "Shell", target: view.head.target, status: STATUS[status], meta },
176
+ ...(status === "cancelled" ? { tone: "warning" } : {}),
177
+ sections: status === "running" ? [...view.sections, { slot: "result", preview: false,
178
+ component: new Text(theme.fg("muted", `Running... (${cancelKey} to cancel)`), 0, 0) }] : view.sections,
179
+ };
180
+ const layout = implementation.layoutToolView(viewForShell, snapshot, theme, outerWidth, tick);
181
+ const renderedRows = layout.callRows.concat(layout.resultRows);
182
+ rows = renderedRows;
183
+ item.cache = { command, output, status, expanded, colorKey, outputPad, exitCode, privateTruncated, maxBytes, fullOutputPath,
184
+ width: outerWidth, revision: runtime.revision, tick, mode, themeColors, factory: implementation.createToolView, layout: implementation.layoutToolView,
185
+ themeColorMode, themeAppearance, cancelKey, expandHint,
186
+ truncation, view: viewForShell, snapshot, prepared: layout, rows: renderedRows };
187
+ }
188
+ }
141
189
  }
142
-
143
- const output = decorateLines(rendered, width, current, resolved.options);
144
- cache.set(this, {
145
- config: current,
146
- output,
147
- rendered: [...rendered],
148
- styleSignature: resolved.styleSignature,
149
- title: resolved.title,
150
- width,
151
- });
152
- return output;
153
190
  } catch {
154
- return renderOriginal.call(this, width);
191
+ if (checkingInternals) reportShellIncompatibility();
192
+ // Native rendering is deliberately outside this try: its errors must not trigger a second call.
155
193
  }
194
+ return rows ?? original.call(this, width);
156
195
  };
157
196
  }
158
-
159
- function resolveFrameOptions(
160
- instance: Renderable,
161
- config: StoredConfig,
162
- ): ResolvedFrameOptions {
163
- const title = config.title?.(instance);
164
- const options: FrameOptions = {};
165
-
166
- if (config.horizontalPadding !== undefined) {
167
- options.horizontalPadding = config.horizontalPadding;
168
- }
169
- const styleBorder = config.styleBorderFor?.(instance) ?? config.styleBorder;
170
- if (styleBorder !== undefined) {
171
- options.styleBorder = styleBorder;
172
- }
173
- if (title) options.title = title;
174
-
175
- return {
176
- options,
177
- // Theme providers can change border output without changing component content.
178
- styleSignature: styleBorder?.(BORDER_STYLE_PROBE),
179
- title,
180
- };
181
- }
182
-
183
- function decorateLines(
184
- rendered: readonly string[],
185
- width: number,
186
- config: StoredConfig,
187
- options: FrameOptions,
188
- ): string[] {
189
- const lines = config.trimOuterRules
190
- ? trimOuterRules(rendered)
191
- : [...rendered];
192
-
193
- return frameLines(lines, width, options);
194
- }
195
-
196
- function linesEqual(left: readonly string[], right: readonly string[]): boolean {
197
- return (
198
- left.length === right.length &&
199
- left.every((line, index) => line === right[index])
200
- );
201
- }
202
-
203
- function storeConfig<T extends Renderable>(
204
- config: RenderDecoratorConfig<T>,
205
- ): void {
206
- const stored: StoredConfig = {
207
- ...config,
208
- enabled: config.enabled ?? true,
209
- } as StoredConfig;
210
- Reflect.set(globalThis, config.key, stored);
211
- }
212
-
213
- function readConfig(key: symbol): StoredConfig | undefined {
214
- return Reflect.get(globalThis, key) as StoredConfig | undefined;
215
- }
package/src/settings.ts CHANGED
@@ -1,65 +1,237 @@
1
- import { mkdir, readFile, writeFile } from "node:fs/promises";
1
+ import { randomUUID } from "node:crypto";
2
+ import { mkdir, readFile, rename, unlink, writeFile } from "node:fs/promises";
2
3
  import { dirname, join } from "node:path";
3
4
  import { getAgentDir } from "@earendil-works/pi-coding-agent";
4
5
 
5
6
  import type { IconMode } from "./tool-icon.js";
6
7
 
7
- interface ToolsStyleSettings {
8
- iconMode: IconMode;
8
+ export interface ToolsStyleSettings {
9
+ readonly enabled: boolean;
10
+ readonly iconMode: IconMode;
11
+ readonly webSearchTools: readonly string[];
12
+ readonly shellEnabled: boolean;
13
+ readonly tools: Readonly<Record<string, boolean>>;
14
+ }
15
+
16
+ export interface ToolsStyleSettingsPatch {
17
+ readonly enabled?: boolean;
18
+ readonly iconMode?: IconMode;
19
+ readonly webSearchTools?: readonly string[];
20
+ readonly shellEnabled?: boolean;
21
+ readonly tools?: Readonly<Record<string, boolean>>;
22
+ }
23
+
24
+ interface SettingsStore {
25
+ value: Readonly<ToolsStyleSettings>;
26
+ writerTail: Promise<void>;
27
+ mutationSequence: number;
9
28
  }
10
29
 
11
30
  const SETTINGS_KEY = Symbol.for("pi-tools-style:settings");
12
- const DEFAULT_SETTINGS: Readonly<ToolsStyleSettings> = {
31
+ const DEFAULT_SETTINGS: Readonly<ToolsStyleSettings> = Object.freeze({
32
+ enabled: true,
13
33
  iconMode: "ascii",
14
- };
34
+ webSearchTools: Object.freeze([] as string[]),
35
+ shellEnabled: true,
36
+ tools: Object.freeze(Object.create(null) as Record<string, boolean>),
37
+ });
38
+ let trustedStore: SettingsStore | undefined;
15
39
 
16
40
  export function getIconMode(): IconMode {
17
41
  return getSettings().iconMode;
18
42
  }
19
43
 
44
+ export function getWebSearchTools(): readonly string[] {
45
+ return getSettings().webSearchTools;
46
+ }
47
+
48
+ export function getSettings(): Readonly<ToolsStyleSettings> {
49
+ return getStore().value;
50
+ }
51
+
52
+ export function isToolEnabledByConfig(toolName: string): boolean {
53
+ const tools = getSettings().tools;
54
+ return Object.hasOwn(tools, toolName) ? tools[toolName] === true : true;
55
+ }
56
+
57
+ export function isShellEnabledByConfig(): boolean {
58
+ return getSettings().shellEnabled;
59
+ }
60
+
20
61
  export function setIconMode(iconMode: IconMode): void {
21
- Reflect.set(globalThis, SETTINGS_KEY, {
22
- iconMode,
23
- } satisfies ToolsStyleSettings);
62
+ applyPatch(getStore(), { iconMode });
24
63
  }
25
64
 
26
- export async function loadSettings(path = getSettingsPath()): Promise<void> {
27
- setIconMode(DEFAULT_SETTINGS.iconMode);
65
+ export function loadSettings(path = getSettingsPath()): Promise<void> {
66
+ const store = getStore();
67
+ const mutationSequence = store.mutationSequence;
68
+ return enqueue(store, async () => {
69
+ let value = DEFAULT_SETTINGS;
70
+ try {
71
+ value = normalizeSettings(JSON.parse(await readFile(path, "utf8")));
72
+ } catch {
73
+ // Missing or malformed user config intentionally falls back to defaults.
74
+ }
75
+ if (store.mutationSequence === mutationSequence) store.value = value;
76
+ });
77
+ }
28
78
 
29
- try {
30
- const parsed = JSON.parse(await readFile(path, "utf8")) as {
31
- iconMode?: unknown;
32
- };
33
- if (isIconMode(parsed.iconMode)) setIconMode(parsed.iconMode);
34
- } catch {
35
- // Missing or malformed user config intentionally falls back to ASCII.
36
- }
79
+ export function saveSettings(
80
+ patch: ToolsStyleSettingsPatch,
81
+ path = getSettingsPath(),
82
+ ): Promise<void> {
83
+ const store = getStore();
84
+ const snapshot = applyPatch(store, patch);
85
+ return enqueue(store, () => persistSettings(snapshot, path));
37
86
  }
38
87
 
39
- export async function saveIconMode(
88
+ export function saveIconMode(
40
89
  iconMode: IconMode,
41
90
  path = getSettingsPath(),
42
91
  ): Promise<void> {
43
- await mkdir(dirname(path), { recursive: true });
44
- await writeFile(path, `${JSON.stringify({ iconMode }, null, 2)}\n`, "utf8");
45
- setIconMode(iconMode);
92
+ return saveSettings({ iconMode }, path);
93
+ }
94
+
95
+ export function flushSettingsWrites(): Promise<void> {
96
+ return getStore().writerTail;
46
97
  }
47
98
 
48
99
  function getSettingsPath(): string {
49
100
  return join(getAgentDir(), "config", "tools-style.json");
50
101
  }
51
102
 
52
- function getSettings(): ToolsStyleSettings {
53
- const current = Reflect.get(globalThis, SETTINGS_KEY) as
54
- | ToolsStyleSettings
55
- | undefined;
56
- if (current) return current;
103
+ function getStore(): SettingsStore {
104
+ const current: unknown = Reflect.get(globalThis, SETTINGS_KEY);
105
+ if (trustedStore && current === trustedStore) return trustedStore;
57
106
 
58
- const initial = { ...DEFAULT_SETTINGS };
59
- Reflect.set(globalThis, SETTINGS_KEY, initial);
60
- return initial;
107
+ const candidate = typeof current === "object" && current !== null
108
+ ? current as Partial<SettingsStore>
109
+ : undefined;
110
+ if (
111
+ candidate &&
112
+ typeof candidate.value === "object" &&
113
+ candidate.value !== null &&
114
+ !Array.isArray(candidate.value) &&
115
+ candidate.writerTail instanceof Promise &&
116
+ typeof candidate.mutationSequence === "number" &&
117
+ Number.isSafeInteger(candidate.mutationSequence) &&
118
+ candidate.mutationSequence >= 0
119
+ ) {
120
+ // A reloaded module joins the existing queue, normalizing its value once.
121
+ trustedStore = candidate as SettingsStore;
122
+ trustedStore.value = normalizeSettings(trustedStore.value);
123
+ } else {
124
+ // Repair an absent, legacy direct settings object or foreign runtime value.
125
+ trustedStore = {
126
+ value: normalizeSettings(current),
127
+ writerTail: Promise.resolve(),
128
+ mutationSequence: 0,
129
+ };
130
+ Reflect.set(globalThis, SETTINGS_KEY, trustedStore);
131
+ }
132
+ return trustedStore;
133
+ }
134
+
135
+ function enqueue(store: SettingsStore, operation: () => Promise<void>): Promise<void> {
136
+ const pending = store.writerTail.then(operation);
137
+ // Return this write's rejection, but never poison subsequent queued operations.
138
+ store.writerTail = pending.catch(() => {});
139
+ return pending;
140
+ }
141
+
142
+ function applyPatch(
143
+ store: SettingsStore,
144
+ patch: ToolsStyleSettingsPatch,
145
+ ): Readonly<ToolsStyleSettings> {
146
+ const current = store.value;
147
+ const enabled = ownValue(patch, "enabled");
148
+ const iconMode = ownValue(patch, "iconMode");
149
+ const webSearchTools = ownValue(patch, "webSearchTools");
150
+ const shellEnabled = ownValue(patch, "shellEnabled");
151
+ const tools = ownValue(patch, "tools");
152
+ const value: Readonly<ToolsStyleSettings> = Object.freeze({
153
+ enabled: typeof enabled === "boolean" ? enabled : current.enabled,
154
+ iconMode: isIconMode(iconMode) ? iconMode : current.iconMode,
155
+ webSearchTools: Array.isArray(webSearchTools)
156
+ ? normalizeWebSearchTools(webSearchTools)
157
+ : current.webSearchTools,
158
+ shellEnabled: typeof shellEnabled === "boolean" ? shellEnabled : current.shellEnabled,
159
+ tools: typeof tools === "object" && tools !== null && !Array.isArray(tools)
160
+ ? normalizeTools(tools, current.tools)
161
+ : current.tools,
162
+ });
163
+ store.value = value;
164
+ store.mutationSequence++;
165
+ return value;
166
+ }
167
+
168
+ async function persistSettings(settings: Readonly<ToolsStyleSettings>, path: string): Promise<void> {
169
+ await mkdir(dirname(path), { recursive: true });
170
+ const temporary = `${path}.${randomUUID()}.tmp`;
171
+ let writeCompleted = false;
172
+ try {
173
+ await writeFile(temporary, `${JSON.stringify(settings, null, 2)}\n`, {
174
+ encoding: "utf8",
175
+ flag: "wx",
176
+ });
177
+ writeCompleted = true;
178
+ await rename(temporary, path);
179
+ } catch (error) {
180
+ // An exclusive-create collision is not our file; later rename failures are.
181
+ const occupied = !writeCompleted && typeof error === "object" && error !== null &&
182
+ "code" in error && error.code === "EEXIST";
183
+ if (!occupied) {
184
+ try {
185
+ await unlink(temporary);
186
+ } catch {
187
+ // Cleanup must not replace the original write/rename failure.
188
+ }
189
+ }
190
+ throw error;
191
+ }
192
+ }
193
+
194
+ function normalizeSettings(value: unknown): Readonly<ToolsStyleSettings> {
195
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return DEFAULT_SETTINGS;
196
+ const enabled = ownValue(value, "enabled");
197
+ const iconMode = ownValue(value, "iconMode");
198
+ const shellEnabled = ownValue(value, "shellEnabled");
199
+ return Object.freeze({
200
+ enabled: typeof enabled === "boolean" ? enabled : DEFAULT_SETTINGS.enabled,
201
+ iconMode: isIconMode(iconMode) ? iconMode : DEFAULT_SETTINGS.iconMode,
202
+ webSearchTools: normalizeWebSearchTools(ownValue(value, "webSearchTools")),
203
+ shellEnabled: typeof shellEnabled === "boolean" ? shellEnabled : DEFAULT_SETTINGS.shellEnabled,
204
+ tools: normalizeTools(ownValue(value, "tools")),
205
+ });
206
+ }
207
+
208
+ function ownValue(value: unknown, key: string): unknown {
209
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return undefined;
210
+ const record = value as Record<string, unknown>;
211
+ return Object.hasOwn(record, key) ? record[key] : undefined;
212
+ }
213
+
214
+ function normalizeTools(
215
+ value: unknown,
216
+ base?: Readonly<Record<string, boolean>>,
217
+ ): Readonly<Record<string, boolean>> {
218
+ const tools = Object.assign(Object.create(null) as Record<string, boolean>, base);
219
+ if (typeof value === "object" && value !== null && !Array.isArray(value)) {
220
+ for (const [name, enabled] of Object.entries(value)) {
221
+ if (name.length > 0 && typeof enabled === "boolean") tools[name] = enabled;
222
+ }
223
+ }
224
+ return Object.freeze(tools);
61
225
  }
62
226
 
63
227
  export function isIconMode(value: unknown): value is IconMode {
64
228
  return value === "ascii" || value === "nerd-font" || value === "off";
65
229
  }
230
+
231
+
232
+ function normalizeWebSearchTools(value: unknown): readonly string[] {
233
+ if (!Array.isArray(value)) return DEFAULT_SETTINGS.webSearchTools;
234
+ return Object.freeze(value.filter(
235
+ (entry): entry is string => typeof entry === "string" && entry.length > 0,
236
+ ));
237
+ }