@pi-archimedes/ui 2.9.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.
Files changed (46) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +60 -0
  3. package/package.json +44 -0
  4. package/src/bash/index.ts +10 -0
  5. package/src/bash/renderer.test.ts +424 -0
  6. package/src/bash/renderer.ts +301 -0
  7. package/src/bash/tool.test.ts +26 -0
  8. package/src/bash/tool.ts +17 -0
  9. package/src/codemode/index.ts +20 -0
  10. package/src/codemode/loader.test.ts +139 -0
  11. package/src/codemode/loader.ts +115 -0
  12. package/src/codemode/renderer.test.ts +717 -0
  13. package/src/codemode/renderer.ts +399 -0
  14. package/src/codemode/tool.test.ts +149 -0
  15. package/src/codemode/tool.ts +96 -0
  16. package/src/config.test.ts +199 -0
  17. package/src/config.ts +129 -0
  18. package/src/editor/index.test.ts +820 -0
  19. package/src/editor/index.ts +380 -0
  20. package/src/editor/spin-quips.test.ts +106 -0
  21. package/src/editor/spin-quips.ts +44 -0
  22. package/src/editor/spin.test.ts +763 -0
  23. package/src/editor/spin.ts +493 -0
  24. package/src/index.test.ts +402 -0
  25. package/src/index.ts +235 -0
  26. package/src/migration.test.ts +256 -0
  27. package/src/migration.ts +94 -0
  28. package/src/settings.test.ts +122 -0
  29. package/src/settings.ts +135 -0
  30. package/src/startup/capture.ts +47 -0
  31. package/src/startup/index.test.ts +175 -0
  32. package/src/startup/index.ts +348 -0
  33. package/src/startup/logo.test.ts +260 -0
  34. package/src/startup/logo.ts +85 -0
  35. package/src/startup/sections.test.ts +333 -0
  36. package/src/startup/sections.ts +306 -0
  37. package/src/startup/version.test.ts +98 -0
  38. package/src/startup/version.ts +26 -0
  39. package/src/thinking/patch.test.ts +857 -0
  40. package/src/thinking/patch.ts +407 -0
  41. package/src/thinking/theme.test.ts +216 -0
  42. package/src/thinking/theme.ts +156 -0
  43. package/src/thinking/transform.test.ts +89 -0
  44. package/src/thinking/transform.ts +21 -0
  45. package/src/thinking/unindent.test.ts +74 -0
  46. package/src/thinking/unindent.ts +74 -0
@@ -0,0 +1,256 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
2
+
3
+ vi.mock("@pi-archimedes/core/settings-io", () => ({
4
+ loadConfig: vi.fn(),
5
+ saveConfig: vi.fn(),
6
+ removeConfig: vi.fn(),
7
+ }));
8
+
9
+ import { migrateCoreToUIConfig, migrateCompactThinkingToStyle, migrateRemovedToolPatch, UI_CONFIG_KEYS } from "./migration.js";
10
+ import { loadConfig, saveConfig, removeConfig } from "@pi-archimedes/core/settings-io";
11
+ import { ToolExecutionComponent } from "@earendil-works/pi-coding-agent";
12
+
13
+ describe("migrateCoreToUIConfig", () => {
14
+ beforeEach(() => {
15
+ vi.clearAllMocks();
16
+ });
17
+
18
+ it("is a no-op when archimedes.core has no keys", () => {
19
+ vi.mocked(loadConfig).mockReturnValue({});
20
+ migrateCoreToUIConfig();
21
+ expect(saveConfig).not.toHaveBeenCalled();
22
+ expect(removeConfig).not.toHaveBeenCalled();
23
+ });
24
+
25
+ it("is a no-op when archimedes.core has no UI keys", () => {
26
+ vi.mocked(loadConfig).mockImplementation((ns: string) => {
27
+ if (ns === "archimedes.core") return { nonUIKey: "value" };
28
+ return {};
29
+ });
30
+ migrateCoreToUIConfig();
31
+ expect(saveConfig).not.toHaveBeenCalled();
32
+ expect(removeConfig).not.toHaveBeenCalled();
33
+ });
34
+
35
+ it("migrates UI keys from archimedes.core to archimedes.ui and removes empty archimedes.core", () => {
36
+ const coreSettings: Record<string, unknown> = {
37
+ mutedTheme: true,
38
+ editorSpinSpeed: "fast",
39
+ animationStyle: "wave",
40
+ };
41
+ const uiSettings: Record<string, unknown> = {};
42
+
43
+ vi.mocked(loadConfig).mockImplementation((ns: string) => {
44
+ if (ns === "archimedes.core") return { ...coreSettings };
45
+ if (ns === "archimedes.ui") return { ...uiSettings };
46
+ return {};
47
+ });
48
+
49
+ migrateCoreToUIConfig();
50
+
51
+ expect(saveConfig).toHaveBeenCalledWith("archimedes.ui", {
52
+ mutedTheme: true,
53
+ editorSpinSpeed: "fast",
54
+ animationStyle: "wave",
55
+ });
56
+ expect(removeConfig).toHaveBeenCalledWith("archimedes.core");
57
+ });
58
+
59
+ it("preserves non-UI keys in archimedes.core", () => {
60
+ const coreSettings: Record<string, unknown> = {
61
+ mutedTheme: true,
62
+ otherPluginKey: 42,
63
+ };
64
+
65
+ vi.mocked(loadConfig).mockImplementation((ns: string) => {
66
+ if (ns === "archimedes.core") return { ...coreSettings };
67
+ if (ns === "archimedes.ui") return {};
68
+ return {};
69
+ });
70
+
71
+ migrateCoreToUIConfig();
72
+
73
+ expect(saveConfig).toHaveBeenCalledWith("archimedes.ui", {
74
+ mutedTheme: true,
75
+ });
76
+ expect(saveConfig).toHaveBeenCalledWith("archimedes.core", {
77
+ otherPluginKey: 42,
78
+ });
79
+ expect(removeConfig).not.toHaveBeenCalled();
80
+ });
81
+
82
+ it("does not overwrite already existing keys in archimedes.ui", () => {
83
+ const coreSettings: Record<string, unknown> = {
84
+ mutedTheme: true,
85
+ labelText: "Old label",
86
+ };
87
+ const uiSettings: Record<string, unknown> = {
88
+ mutedTheme: false,
89
+ };
90
+
91
+ vi.mocked(loadConfig).mockImplementation((ns: string) => {
92
+ if (ns === "archimedes.core") return { ...coreSettings };
93
+ if (ns === "archimedes.ui") return { ...uiSettings };
94
+ return {};
95
+ });
96
+
97
+ migrateCoreToUIConfig();
98
+
99
+ expect(saveConfig).toHaveBeenCalledWith("archimedes.ui", {
100
+ mutedTheme: false,
101
+ labelText: "Old label",
102
+ });
103
+ expect(removeConfig).toHaveBeenCalledWith("archimedes.core");
104
+ });
105
+
106
+ it("exports UI_CONFIG_KEYS containing all UI setting keys", () => {
107
+ expect(UI_CONFIG_KEYS).toContain("bashToolStyling");
108
+ expect(UI_CONFIG_KEYS).toContain("thinkingStyle");
109
+ expect(UI_CONFIG_KEYS).toContain("toolStyle");
110
+ expect(UI_CONFIG_KEYS).toContain("mutedTheme");
111
+ expect(UI_CONFIG_KEYS).toContain("autoCollapseThinking");
112
+ expect(UI_CONFIG_KEYS).toContain("compactThinking");
113
+ expect(UI_CONFIG_KEYS).toContain("codeUnindent");
114
+ expect(UI_CONFIG_KEYS).toContain("labelText");
115
+ expect(UI_CONFIG_KEYS).toContain("labelColor");
116
+ expect(UI_CONFIG_KEYS).toContain("animationStyle");
117
+ expect(UI_CONFIG_KEYS).toContain("editorSpinBorder");
118
+ expect(UI_CONFIG_KEYS).toContain("editorSpinSpeed");
119
+ expect(UI_CONFIG_KEYS).toContain("editorSpinLabel");
120
+ expect(UI_CONFIG_KEYS).toContain("editorSpinStyle");
121
+ });
122
+ });
123
+
124
+ describe("migrateCompactThinkingToStyle", () => {
125
+ beforeEach(() => {
126
+ vi.clearAllMocks();
127
+ });
128
+
129
+ it("converts legacy 'Off' to thinkingStyle 'Full' and deletes compactThinking", () => {
130
+ vi.mocked(loadConfig).mockImplementation((ns: string) => {
131
+ if (ns === "archimedes.ui") return { compactThinking: "Off", mutedTheme: true };
132
+ return {};
133
+ });
134
+
135
+ migrateCompactThinkingToStyle();
136
+
137
+ expect(saveConfig).toHaveBeenCalledWith("archimedes.ui", {
138
+ mutedTheme: true,
139
+ thinkingStyle: "Full",
140
+ });
141
+ });
142
+
143
+ it("converts legacy '1 line' / '3 lines' / '5 lines' to thinkingStyle 'Compact'", () => {
144
+ for (const legacy of ["1 line", "3 lines", "5 lines"] as const) {
145
+ vi.clearAllMocks();
146
+ vi.mocked(loadConfig).mockImplementation((ns: string) => {
147
+ if (ns === "archimedes.ui") return { compactThinking: legacy };
148
+ return {};
149
+ });
150
+
151
+ migrateCompactThinkingToStyle();
152
+
153
+ expect(saveConfig).toHaveBeenCalledWith("archimedes.ui", {
154
+ thinkingStyle: "Compact",
155
+ });
156
+ }
157
+ });
158
+
159
+ it("maps unknown legacy values to thinkingStyle 'Full'", () => {
160
+ vi.mocked(loadConfig).mockImplementation((ns: string) => {
161
+ if (ns === "archimedes.ui") return { compactThinking: "7 lines" };
162
+ return {};
163
+ });
164
+
165
+ migrateCompactThinkingToStyle();
166
+
167
+ expect(saveConfig).toHaveBeenCalledWith("archimedes.ui", {
168
+ thinkingStyle: "Full",
169
+ });
170
+ });
171
+
172
+ it("is a no-op (no saveConfig call) when compactThinking is absent", () => {
173
+ vi.mocked(loadConfig).mockImplementation((ns: string) => {
174
+ if (ns === "archimedes.ui") return { thinkingStyle: "Full" };
175
+ return {};
176
+ });
177
+
178
+ migrateCompactThinkingToStyle();
179
+
180
+ expect(saveConfig).not.toHaveBeenCalled();
181
+ });
182
+
183
+ it("preserves an existing thinkingStyle and deletes compactThinking when both are present", () => {
184
+ vi.mocked(loadConfig).mockImplementation((ns: string) => {
185
+ if (ns === "archimedes.ui")
186
+ return { compactThinking: "1 line", thinkingStyle: "Full", toolStyle: "Compact" };
187
+ return {};
188
+ });
189
+
190
+ migrateCompactThinkingToStyle();
191
+
192
+ expect(saveConfig).toHaveBeenCalledWith("archimedes.ui", {
193
+ thinkingStyle: "Full",
194
+ toolStyle: "Compact",
195
+ });
196
+ });
197
+ });
198
+
199
+ describe("migrateRemovedToolPatch", () => {
200
+ // The old patch saved the TRUE originals on the shared prototype under
201
+ // Symbol.for markers; the migration restores them and drops the markers.
202
+ // The prototype is process-global, so save/restore the real methods.
203
+ const proto: any = ToolExecutionComponent.prototype;
204
+ const ORIG_UPDATE = Symbol.for("archimedes:toolOrigUpdate");
205
+ const ORIG_SET_EXPANDED = Symbol.for("archimedes:toolOrigSetExpanded");
206
+ const realUpdateDisplay = proto.updateDisplay;
207
+ const realSetExpanded = proto.setExpanded;
208
+
209
+ afterEach(() => {
210
+ proto.updateDisplay = realUpdateDisplay;
211
+ proto.setExpanded = realSetExpanded;
212
+ delete proto[ORIG_UPDATE];
213
+ delete proto[ORIG_SET_EXPANDED];
214
+ });
215
+
216
+ it("restores the true originals and drops the markers when the old patch is present", () => {
217
+ const sentinelUpdate = function (this: unknown): unknown {
218
+ return "orig";
219
+ };
220
+ const sentinelSetExpanded = function (this: unknown): void {};
221
+ proto[ORIG_UPDATE] = sentinelUpdate;
222
+ proto[ORIG_SET_EXPANDED] = sentinelSetExpanded;
223
+ proto.updateDisplay = function (this: unknown): unknown {
224
+ return "wrapper";
225
+ };
226
+ proto.setExpanded = function (this: unknown): void {};
227
+
228
+ migrateRemovedToolPatch();
229
+
230
+ expect(proto.updateDisplay).toBe(sentinelUpdate);
231
+ expect(proto.setExpanded).toBe(sentinelSetExpanded);
232
+ expect(proto[ORIG_UPDATE]).toBeUndefined();
233
+ expect(proto[ORIG_SET_EXPANDED]).toBeUndefined();
234
+ });
235
+
236
+ it("is a no-op when the markers are absent (fresh process)", () => {
237
+ migrateRemovedToolPatch();
238
+ expect(proto.updateDisplay).toBe(realUpdateDisplay);
239
+ expect(proto.setExpanded).toBe(realSetExpanded);
240
+ });
241
+
242
+ it("is idempotent (a second run is a no-op)", () => {
243
+ const sentinelUpdate = function (this: unknown): unknown {
244
+ return "orig";
245
+ };
246
+ proto[ORIG_UPDATE] = sentinelUpdate;
247
+ proto.updateDisplay = function (this: unknown): unknown {
248
+ return "wrapper";
249
+ };
250
+
251
+ migrateRemovedToolPatch();
252
+ const restored = proto.updateDisplay;
253
+ migrateRemovedToolPatch();
254
+ expect(proto.updateDisplay).toBe(restored);
255
+ });
256
+ });
@@ -0,0 +1,94 @@
1
+ import { loadConfig, saveConfig, removeConfig } from "@pi-archimedes/core/settings-io";
2
+ import { ToolExecutionComponent } from "@earendil-works/pi-coding-agent";
3
+ import type { UIConfig } from "./config.js";
4
+
5
+ export const UI_CONFIG_KEYS: readonly (keyof UIConfig | "compactThinking")[] = [
6
+ "bashToolStyling",
7
+ "mutedTheme",
8
+ "autoCollapseThinking",
9
+ "compactThinking",
10
+ "thinkingStyle",
11
+ "toolStyle",
12
+ "codeUnindent",
13
+ "labelText",
14
+ "labelColor",
15
+ "animationStyle",
16
+ "editorSpinBorder",
17
+ "editorSpinSpeed",
18
+ "editorSpinLabel",
19
+ "editorSpinStyle",
20
+ ] as const;
21
+
22
+ /**
23
+ * One-time migration: copies any existing UI keys from archimedes.core to archimedes.ui
24
+ * and removes them from archimedes.core. If archimedes.core is left empty, the namespace is removed.
25
+ */
26
+ export function migrateCoreToUIConfig(): void {
27
+ const core = loadConfig("archimedes.core", {}) as Record<string, unknown>;
28
+ const keysToMigrate = UI_CONFIG_KEYS.filter((k) => k in core);
29
+ if (keysToMigrate.length === 0) return;
30
+
31
+ const ui = loadConfig("archimedes.ui", {}) as Record<string, unknown>;
32
+
33
+ for (const key of keysToMigrate) {
34
+ if (!(key in ui)) {
35
+ ui[key] = core[key];
36
+ }
37
+ delete core[key];
38
+ }
39
+
40
+ saveConfig("archimedes.ui", ui);
41
+
42
+ if (Object.keys(core).length === 0) {
43
+ removeConfig("archimedes.core");
44
+ } else {
45
+ saveConfig("archimedes.core", core);
46
+ }
47
+ }
48
+
49
+ /**
50
+ * One-time migration: removes the `toolStyle: "Full"` auto-expand patch from
51
+ * `ToolExecutionComponent`'s prototype, if an older version installed it in
52
+ * this process. The old patch saved the TRUE originals on the prototype under
53
+ * `Symbol.for` markers (which survive a jiti re-evaluation); since the patch
54
+ * is gone from the codebase, the wrappers would otherwise survive a `/reload`
55
+ * in the same process and keep auto-expanding tools even when the reloaded
56
+ * setting is Native/Minimal. Restoring the originals (and dropping the
57
+ * markers) makes the removal stick. Idempotent: a no-op when the markers are
58
+ * absent (fresh process, or never patched).
59
+ */
60
+ export function migrateRemovedToolPatch(): void {
61
+ const proto: any = ToolExecutionComponent?.prototype;
62
+ if (!proto) return;
63
+ const ORIG_UPDATE = Symbol.for("archimedes:toolOrigUpdate");
64
+ const ORIG_SET_EXPANDED = Symbol.for("archimedes:toolOrigSetExpanded");
65
+ if (proto[ORIG_UPDATE]) {
66
+ proto.updateDisplay = proto[ORIG_UPDATE];
67
+ delete proto[ORIG_UPDATE];
68
+ }
69
+ if (proto[ORIG_SET_EXPANDED]) {
70
+ proto.setExpanded = proto[ORIG_SET_EXPANDED];
71
+ delete proto[ORIG_SET_EXPANDED];
72
+ }
73
+ }
74
+
75
+ /**
76
+ * One-time migration: converts the legacy `compactThinking` setting to the new
77
+ * `thinkingStyle` (Full/Compact) and removes `compactThinking` from archimedes.ui.
78
+ * Idempotent: a no-op when `compactThinking` is already gone. An existing
79
+ * `thinkingStyle` is preserved; `toolStyle` is never touched.
80
+ */
81
+ export function migrateCompactThinkingToStyle(): void {
82
+ const raw = loadConfig("archimedes.ui", {});
83
+ if (!("compactThinking" in raw)) return; // idempotent no-op
84
+ const migrated: Record<string, unknown> = { ...raw };
85
+ delete migrated.compactThinking;
86
+ if (!("thinkingStyle" in migrated)) {
87
+ const legacy = raw.compactThinking;
88
+ migrated.thinkingStyle =
89
+ legacy === "Off" ? "Full"
90
+ : legacy === "1 line" || legacy === "3 lines" || legacy === "5 lines" ? "Compact"
91
+ : "Full";
92
+ }
93
+ saveConfig("archimedes.ui", migrated);
94
+ }
@@ -0,0 +1,122 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { getUISettingsItems } from "./settings.js";
3
+ import { DEFAULT_UI_CONFIG, ANIMATION_STYLES, type UIConfig } from "./config.js";
4
+
5
+ describe("getUISettingsItems", () => {
6
+ it("exposes all 14 UI setting items with correct defaults", () => {
7
+ const items = getUISettingsItems(DEFAULT_UI_CONFIG);
8
+ expect(items).toHaveLength(14);
9
+
10
+ const ids = items.map((i) => i.id);
11
+ expect(ids).toEqual([
12
+ "bashToolStyling",
13
+ "codemodeToolStyling",
14
+ "mutedTheme",
15
+ "autoCollapseThinking",
16
+ "thinkingStyle",
17
+ "toolStyle",
18
+ "codeUnindent",
19
+ "labelText",
20
+ "labelColor",
21
+ "animationStyle",
22
+ "editorSpinBorder",
23
+ "editorSpinSpeed",
24
+ "editorSpinStyle",
25
+ "editorSpinLabel",
26
+ ]);
27
+
28
+ expect(items.find((i) => i.id === "bashToolStyling")?.currentValue).toBe("On");
29
+ expect(items.find((i) => i.id === "codemodeToolStyling")?.currentValue).toBe("On");
30
+ expect(items.find((i) => i.id === "mutedTheme")?.currentValue).toBe("Off");
31
+ expect(items.find((i) => i.id === "autoCollapseThinking")?.currentValue).toBe("Off");
32
+ expect(items.find((i) => i.id === "thinkingStyle")?.currentValue).toBe("Full");
33
+ expect(items.find((i) => i.id === "toolStyle")?.currentValue).toBe("Minimal");
34
+ expect(items.find((i) => i.id === "codeUnindent")?.currentValue).toBe("On");
35
+ expect(items.find((i) => i.id === "labelText")?.currentValue).toBe("Thinking...");
36
+ expect(items.find((i) => i.id === "labelColor")?.currentValue).toBe("255,215,0");
37
+ expect(items.find((i) => i.id === "animationStyle")?.currentValue).toBe("vertical-up");
38
+ expect(items.find((i) => i.id === "editorSpinBorder")?.currentValue).toBe("On");
39
+ expect(items.find((i) => i.id === "editorSpinSpeed")?.currentValue).toBe("Normal");
40
+ expect(items.find((i) => i.id === "editorSpinStyle")?.currentValue).toBe("Pendulum");
41
+ expect(items.find((i) => i.id === "editorSpinLabel")?.currentValue).toBe("Working");
42
+ });
43
+
44
+ it("exposes bashToolStyling correctly when toggled off", () => {
45
+ const items = getUISettingsItems({ ...DEFAULT_UI_CONFIG, bashToolStyling: false });
46
+ const item = items.find((i) => i.id === "bashToolStyling");
47
+ expect(item).toBeDefined();
48
+ expect(item!.currentValue).toBe("Off");
49
+ expect(item!.values).toEqual(["On", "Off"]);
50
+ });
51
+
52
+ it("exposes codemodeToolStyling correctly when toggled off", () => {
53
+ const items = getUISettingsItems({ ...DEFAULT_UI_CONFIG, codemodeToolStyling: false });
54
+ const item = items.find((i) => i.id === "codemodeToolStyling");
55
+ expect(item).toBeDefined();
56
+ expect(item!.currentValue).toBe("Off");
57
+ expect(item!.values).toEqual(["On", "Off"]);
58
+ });
59
+
60
+ it("exposes thinkingStyle and toolStyle with their values and normalizes invalid values", () => {
61
+ const items = getUISettingsItems({ ...DEFAULT_UI_CONFIG, thinkingStyle: "invalid" as never });
62
+ const thinkingItem = items.find((i) => i.id === "thinkingStyle");
63
+ expect(thinkingItem).toBeDefined();
64
+ expect(thinkingItem!.currentValue).toBe("Full");
65
+ expect(thinkingItem!.values).toEqual(["Full", "Compact"]);
66
+
67
+ const toolItem = items.find((i) => i.id === "toolStyle");
68
+ expect(toolItem).toBeDefined();
69
+ expect(toolItem!.currentValue).toBe("Minimal");
70
+ expect(toolItem!.values).toEqual(["Native", "Minimal"]);
71
+ });
72
+
73
+ it("normalizes legacy toolStyle values (Full → Minimal, Compact → Minimal)", () => {
74
+ expect(
75
+ getUISettingsItems({ ...DEFAULT_UI_CONFIG, toolStyle: "Full" as never })
76
+ .find((i) => i.id === "toolStyle")?.currentValue,
77
+ ).toBe("Minimal");
78
+ expect(
79
+ getUISettingsItems({ ...DEFAULT_UI_CONFIG, toolStyle: "Compact" as never })
80
+ .find((i) => i.id === "toolStyle")?.currentValue,
81
+ ).toBe("Minimal");
82
+ });
83
+
84
+ it("exposes animationStyle with ANIMATION_STYLES values", () => {
85
+ const item = getUISettingsItems(DEFAULT_UI_CONFIG).find((i) => i.id === "animationStyle");
86
+ expect(item?.values).toEqual([...ANIMATION_STYLES]);
87
+ });
88
+
89
+ describe("robustness with hand-edited (corrupt) values", () => {
90
+ it("an empty-string editorSpinSpeed projects to 'Normal'", () => {
91
+ const items = getUISettingsItems({
92
+ ...DEFAULT_UI_CONFIG,
93
+ editorSpinSpeed: "" as never,
94
+ });
95
+ expect(items.find((i) => i.id === "editorSpinSpeed")?.currentValue).toBe("Normal");
96
+ });
97
+
98
+ it("a number editorSpinSpeed projects to 'Normal'", () => {
99
+ const items = getUISettingsItems({
100
+ ...DEFAULT_UI_CONFIG,
101
+ editorSpinSpeed: 42 as never,
102
+ });
103
+ expect(items.find((i) => i.id === "editorSpinSpeed")?.currentValue).toBe("Normal");
104
+ });
105
+
106
+ it("a trailing-dash editorSpinStyle projects without throwing", () => {
107
+ const items = getUISettingsItems({
108
+ ...DEFAULT_UI_CONFIG,
109
+ editorSpinStyle: "wave-" as never,
110
+ });
111
+ expect(items.find((i) => i.id === "editorSpinStyle")?.currentValue).toBe("Wave");
112
+ });
113
+
114
+ it("a null editorSpinStyle projects to 'Typing'", () => {
115
+ const items = getUISettingsItems({
116
+ ...DEFAULT_UI_CONFIG,
117
+ editorSpinStyle: null as never,
118
+ });
119
+ expect(items.find((i) => i.id === "editorSpinStyle")?.currentValue).toBe("Typing");
120
+ });
121
+ });
122
+ });
@@ -0,0 +1,135 @@
1
+ import type { SettingItem } from "@earendil-works/pi-tui";
2
+ import {
3
+ ANIMATION_STYLES,
4
+ normalizeThinkingStyle,
5
+ normalizeToolStyle,
6
+ type UIConfig,
7
+ } from "./config.js";
8
+
9
+ export function getUISettingsItems(config: UIConfig): SettingItem[] {
10
+ return [
11
+ {
12
+ id: "bashToolStyling",
13
+ label: "Bash Tool Styling",
14
+ description: "Archimedes styling for bash tool execution (applies when Tool Style is Minimal)",
15
+ currentValue: config.bashToolStyling ? "On" : "Off",
16
+ values: ["On", "Off"],
17
+ },
18
+ {
19
+ id: "codemodeToolStyling",
20
+ label: "Codemode Tool Styling",
21
+ description: "Archimedes styling for the codemode tool (applies when Tool Style is Minimal)",
22
+ currentValue: config.codemodeToolStyling ? "On" : "Off",
23
+ values: ["On", "Off"],
24
+ },
25
+ {
26
+ id: "mutedTheme",
27
+ label: "Muted Theme",
28
+ description: "Use muted colors for thinking blocks",
29
+ currentValue: config.mutedTheme ? "On" : "Off",
30
+ values: ["On", "Off"],
31
+ },
32
+ {
33
+ id: "autoCollapseThinking",
34
+ label: "Auto-Collapse Thinking",
35
+ description: "Collapse thinking blocks after thinking completes",
36
+ currentValue: config.autoCollapseThinking ? "On" : "Off",
37
+ values: ["On", "Off"],
38
+ },
39
+ {
40
+ id: "thinkingStyle",
41
+ label: "Thinking Style",
42
+ description: "How thinking blocks display (Full = full text, Compact = one line, click to expand)",
43
+ currentValue: normalizeThinkingStyle(config.thinkingStyle),
44
+ values: ["Full", "Compact"],
45
+ },
46
+ {
47
+ id: "toolStyle",
48
+ label: "Tool Style",
49
+ description: "Tool rendering (Minimal = archimedes styled, Native = pi native, no archimedes styling)",
50
+ currentValue: normalizeToolStyle(config.toolStyle),
51
+ values: ["Native", "Minimal"],
52
+ },
53
+ {
54
+ id: "codeUnindent",
55
+ label: "Code Unindent",
56
+ description: "Remove 2-space indent from code blocks",
57
+ currentValue: config.codeUnindent ? "On" : "Off",
58
+ values: ["On", "Off"],
59
+ },
60
+ {
61
+ id: "labelText",
62
+ label: "Label Text",
63
+ description: "Text shown before thinking blocks",
64
+ currentValue: config.labelText,
65
+ },
66
+ {
67
+ id: "labelColor",
68
+ label: "Label Color",
69
+ description: "RGB color for thinking label (e.g. 255,215,0)",
70
+ currentValue: config.labelColor,
71
+ },
72
+ {
73
+ id: "animationStyle",
74
+ label: "Logo Animation",
75
+ description: "Splashscreen logo reveal style",
76
+ currentValue: config.animationStyle,
77
+ values: [...ANIMATION_STYLES],
78
+ },
79
+ {
80
+ id: "editorSpinBorder",
81
+ label: "Editor Spin Border",
82
+ description: "Type across the editor's top border while the agent is working (hides the “Working” line)",
83
+ currentValue: config.editorSpinBorder ? "On" : "Off",
84
+ values: ["On", "Off"],
85
+ },
86
+ {
87
+ id: "editorSpinSpeed",
88
+ label: "Spin Speed",
89
+ description: "Border spinner speed (slow / normal / fast — the × 1.5 / × 1 / × 0.6 of the style's native tempo)",
90
+ currentValue: (() => {
91
+ // Hand-edited (corrupt) values — non-strings (null/number/boolean) or an
92
+ // empty string — fall back to `normal` (the `typeof` guard keeps the
93
+ // settings panel from TypeError-ing on a non-string setting; the
94
+ // falsy check keeps an empty string from projecting as a `NaN` label).
95
+ const s =
96
+ typeof config.editorSpinSpeed === "string" && config.editorSpinSpeed
97
+ ? config.editorSpinSpeed
98
+ : "normal";
99
+ return s[0]!.toUpperCase() + s.slice(1);
100
+ })(),
101
+ values: ["Slow", "Normal", "Fast"],
102
+ },
103
+ {
104
+ id: "editorSpinStyle",
105
+ label: "Spin Style",
106
+ description: "Which animation the editor border runs while working",
107
+ currentValue:
108
+ typeof config.editorSpinStyle === "string"
109
+ ? config.editorSpinStyle
110
+ .split("-")
111
+ .filter(Boolean)
112
+ .map((w) => w[0]!.toUpperCase() + w.slice(1))
113
+ .join(" ")
114
+ : "Typing",
115
+ values: [
116
+ "Typing",
117
+ "Wave Rows",
118
+ "Columns",
119
+ "Pulse",
120
+ "Marquee",
121
+ "Pendulum",
122
+ "Rain",
123
+ "Cascade",
124
+ "Diagonal Swipe",
125
+ "Sparkle",
126
+ ],
127
+ },
128
+ {
129
+ id: "editorSpinLabel",
130
+ label: "Spinner Label",
131
+ description: "Label typed after the spin window (empty hides it)",
132
+ currentValue: config.editorSpinLabel,
133
+ },
134
+ ];
135
+ }
@@ -0,0 +1,47 @@
1
+ const g: Record<string | symbol, unknown> = globalThis as unknown as typeof global & Record<string | symbol, unknown>;
2
+
3
+ const MODEL_SCOPE_RE = /Model scope:\s*(.+)/;
4
+ export const CAPTURED_MODELS = Symbol.for("splashscreen:capturedModels");
5
+ export const PATCHED_LOG = Symbol.for("splashscreen:logPatched");
6
+
7
+ // Store original console.log for restoration
8
+ let _origLog: typeof console.log | undefined;
9
+
10
+ export function patchConsoleLog(): void {
11
+ if (g[PATCHED_LOG]) return;
12
+ g[PATCHED_LOG] = true;
13
+ _origLog = console.log;
14
+
15
+ console.log = (...args: unknown[]) => {
16
+ try {
17
+ if (args.length === 1 && typeof args[0] === "string") {
18
+ const plain = (args[0] as string).replace(/\x1b\[[0-9;]*[a-zA-Z]/g, "");
19
+ const m = MODEL_SCOPE_RE.exec(plain);
20
+ if (m) {
21
+ const raw = m[1]!.replace(/\s*\(Ctrl\+\w[\w\s]*\)/gi, "");
22
+ g[CAPTURED_MODELS] = raw
23
+ .split(",")
24
+ .map((s: string) => s.trim())
25
+ .filter(Boolean);
26
+ // Unpatch once captured — no need to keep intercepting
27
+ unpatchConsoleLog();
28
+ return;
29
+ }
30
+ }
31
+ } catch (err) {
32
+ // Log the error but don't suppress the original call
33
+ console.error("[archimedes] Error in console.log patch:", err);
34
+ }
35
+ _origLog!.apply(console, args);
36
+ };
37
+ }
38
+
39
+ /** Restore original console.log — call on session_shutdown. */
40
+ export function unpatchConsoleLog(): void {
41
+ if (!g[PATCHED_LOG]) return;
42
+ g[PATCHED_LOG] = false;
43
+ if (_origLog) {
44
+ console.log = _origLog;
45
+ _origLog = undefined;
46
+ }
47
+ }