pi-archimedes 2.7.3 → 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.
@@ -0,0 +1,255 @@
1
+ import { Key, matchesKey, truncateToWidth } from "@earendil-works/pi-tui";
2
+ import {
3
+ padEnd,
4
+ renderFooter,
5
+ renderHeader,
6
+ wrapWithBorder,
7
+ type OverlayTheme,
8
+ } from "@pi-archimedes/core/overlay";
9
+ import type { ThinkingStyle, ToolStyle } from "@pi-archimedes/ui/config";
10
+ import { spinFrame } from "@pi-archimedes/ui/editor";
11
+
12
+ /**
13
+ * The onboarding modal's result. Each `*Answered` flag tells the orchestrator
14
+ * (Task 4) whether the user actually committed that step — an unanswered step
15
+ * is left untouched on the write side. `thinkingValue`/`toolValue` are only
16
+ * meaningful when their step was answered; `pluginSelections` maps every plugin
17
+ * id to its final on/off state (present regardless, so the orchestrator can
18
+ * diff against the current flags).
19
+ */
20
+ export interface OnboardingResult {
21
+ thinkingAnswered: boolean;
22
+ thinkingValue: ThinkingStyle;
23
+ toolAnswered: boolean;
24
+ toolValue: ToolStyle;
25
+ pluginsAnswered: boolean;
26
+ pluginSelections: Record<string, boolean>;
27
+ spinnerAnswered: boolean;
28
+ spinnerValue: string;
29
+ }
30
+
31
+ export interface OnboardingOverlayOptions {
32
+ theme: OverlayTheme;
33
+ thinkingDefault: ThinkingStyle;
34
+ toolDefault: ToolStyle;
35
+ plugins: { id: string; label: string; description: string; selected: boolean }[];
36
+ spinners: readonly string[];
37
+ spinnerDefault: string;
38
+ /** The TUI repaint hook (the `ui.custom` factory's `tui.requestRender`) — when provided, the spinner step's live 4-cell previews animate on a 40 ms tick; without it the previews are static (tick 0) and no timer runs. */
39
+ requestRender?: () => void;
40
+ onDone: (result: OnboardingResult) => void;
41
+ }
42
+
43
+ // The single "style" question was split into two INDEPENDENT ones (thinking +
44
+ // tool) so the user can mix, e.g. "Full thinking" + "Minimal tools". The tool
45
+ // question is the styling master switch: Minimal = archimedes-styled tools,
46
+ // Native = no archimedes tool styling at all. Both steps list the standard
47
+ // option first and the compact option second (matching the /archimedes
48
+ // panel's value order: thinkingStyle [Full, Compact], toolStyle
49
+ // [Native, Minimal]).
50
+ const THINKING_OPTIONS: { label: ThinkingStyle; description: string }[] = [
51
+ { label: "Full", description: "Show all of the model's reasoning" },
52
+ { label: "Compact", description: "One line of reasoning (click to expand)" },
53
+ ];
54
+ const TOOL_OPTIONS: { label: ToolStyle; description: string }[] = [
55
+ { label: "Native", description: "Pi's native tool rendering (no styling)" },
56
+ { label: "Minimal", description: "Archimedes-styled tools (minimal view)" },
57
+ ];
58
+
59
+ /**
60
+ * Build the onboarding wizard as a single pure TUI component: one `ui.custom`
61
+ * overlay with an internal `step` state (0 → 1 → 2 → 3) and no open/close
62
+ * flicker between steps. It is deliberately NOT a reuse of the `ask`
63
+ * package's `askQuestionsWithTabs` (whose "Other" option,
64
+ * cancel-discards-everything semantics, and blocked empty-multi-select don't
65
+ * fit a setup wizard).
66
+ *
67
+ * It is a pure function: it takes the pre-selected values + an `onDone`
68
+ * callback and returns a TUI component; it knows nothing about settings or
69
+ * plugins, so it is testable in isolation. The `theme` is typed as the
70
+ * structural `OverlayTheme` (not the `Theme` class, which has private members
71
+ * and can't be satisfied by a plain object mock).
72
+ */
73
+ export function createOnboardingOverlay(opts: OnboardingOverlayOptions) {
74
+ const theme = opts.theme;
75
+ let activeStep = 0;
76
+ // Clamp the pre-selected indices (a corrupt/unknown value → index 0, never -1).
77
+ const cursorByStep: number[] = [
78
+ Math.max(0, THINKING_OPTIONS.findIndex((o) => o.label === opts.thinkingDefault)),
79
+ Math.max(0, TOOL_OPTIONS.findIndex((o) => o.label === opts.toolDefault)),
80
+ 0,
81
+ Math.max(0, opts.spinners.indexOf(opts.spinnerDefault)),
82
+ ];
83
+ const pluginToggles: boolean[] = opts.plugins.map((p) => p.selected);
84
+ const confirmed: boolean[] = [false, false, false, false];
85
+ let finalized = false;
86
+ // The live spinner-preview tick (the spinner step renders `spinFrame(name, tick)` next to each name); only advances when a repaint hook is provided.
87
+ let tick = 0;
88
+ const onTick = opts.requestRender;
89
+ // Only the spinner step (3) renders a live frame — don't advance the tick or
90
+ // request a repaint while the user is on the thinking/tool/plugin steps.
91
+ const timer = onTick
92
+ ? setInterval(() => {
93
+ if (activeStep === 3) {
94
+ tick += 1;
95
+ onTick();
96
+ }
97
+ }, 40)
98
+ : undefined;
99
+
100
+ function optionsCountForStep(step: number): number {
101
+ if (step === 0) return THINKING_OPTIONS.length;
102
+ if (step === 1) return TOOL_OPTIONS.length;
103
+ if (step === 2) return opts.plugins.length;
104
+ return opts.spinners.length;
105
+ }
106
+
107
+ function finalize(): void {
108
+ if (finalized) return;
109
+ finalized = true;
110
+ // Belt-and-braces: stop the interval the moment the wizard is done (in case
111
+ // the TUI tears down without calling dispose()).
112
+ if (timer) clearInterval(timer);
113
+ const thinkingIdx = cursorByStep[0] ?? 0;
114
+ const toolIdx = cursorByStep[1] ?? 0;
115
+ const spinnerIdx = cursorByStep[3] ?? 0;
116
+ const pluginSelections: Record<string, boolean> = {};
117
+ for (let i = 0; i < opts.plugins.length; i++) {
118
+ const p = opts.plugins[i];
119
+ if (!p) continue;
120
+ pluginSelections[p.id] = pluginToggles[i] ?? false;
121
+ }
122
+ opts.onDone({
123
+ thinkingAnswered: confirmed[0] ?? false,
124
+ thinkingValue: THINKING_OPTIONS[thinkingIdx]?.label ?? "Full",
125
+ toolAnswered: confirmed[1] ?? false,
126
+ toolValue: TOOL_OPTIONS[toolIdx]?.label ?? "Minimal",
127
+ pluginsAnswered: confirmed[2] ?? false,
128
+ pluginSelections,
129
+ spinnerAnswered: confirmed[3] ?? false,
130
+ spinnerValue: opts.spinners[spinnerIdx] ?? "",
131
+ });
132
+ }
133
+
134
+ function handleInput(data: string): void {
135
+ if (matchesKey(data, Key.up)) {
136
+ cursorByStep[activeStep] = Math.max(0, (cursorByStep[activeStep] ?? 0) - 1);
137
+ return;
138
+ }
139
+ if (matchesKey(data, Key.down)) {
140
+ // Math.max(0, …): with an empty options list the min() bound alone is -1,
141
+ // which would drive the cursor negative (same discipline as the up clamp).
142
+ cursorByStep[activeStep] = Math.max(
143
+ 0,
144
+ Math.min(
145
+ optionsCountForStep(activeStep) - 1,
146
+ (cursorByStep[activeStep] ?? 0) + 1,
147
+ ),
148
+ );
149
+ return;
150
+ }
151
+ if (matchesKey(data, Key.space) && activeStep === 2) {
152
+ const cur = cursorByStep[2] ?? 0;
153
+ pluginToggles[cur] = !(pluginToggles[cur] ?? false);
154
+ return;
155
+ }
156
+ if (matchesKey(data, Key.enter)) {
157
+ confirmed[activeStep] = true;
158
+ if (activeStep === 3) finalize();
159
+ else activeStep += 1;
160
+ return;
161
+ }
162
+ if (matchesKey(data, Key.escape)) {
163
+ finalize();
164
+ return;
165
+ }
166
+ }
167
+
168
+ function render(width: number): string[] {
169
+ const lines: string[] = [];
170
+ lines.push(renderHeader(" Welcome to pi-archimedes ", width - 2, theme));
171
+ lines.push(padEnd(`Set up your preferences · ${activeStep + 1}/4`, width - 2));
172
+ lines.push(padEnd("Choices apply from your next session.", width - 2));
173
+
174
+ if (activeStep === 0) {
175
+ lines.push(padEnd("Thinking style", width - 2));
176
+ lines.push("");
177
+ for (let i = 0; i < THINKING_OPTIONS.length; i++) {
178
+ const opt = THINKING_OPTIONS[i];
179
+ if (!opt) continue;
180
+ const marker = i === (cursorByStep[0] ?? 0) ? "> " : " ";
181
+ lines.push(
182
+ padEnd(
183
+ `${marker}${opt.label.padEnd(9)}${truncateToWidth(opt.description, width - 14, "")}`,
184
+ width - 2,
185
+ ),
186
+ );
187
+ }
188
+ lines.push("");
189
+ lines.push(renderFooter(" [↑↓] move [enter] next [esc] finish ", width - 2, theme));
190
+ } else if (activeStep === 1) {
191
+ lines.push(padEnd("Tool output style", width - 2));
192
+ lines.push("");
193
+ for (let i = 0; i < TOOL_OPTIONS.length; i++) {
194
+ const opt = TOOL_OPTIONS[i];
195
+ if (!opt) continue;
196
+ const marker = i === (cursorByStep[1] ?? 0) ? "> " : " ";
197
+ lines.push(
198
+ padEnd(
199
+ `${marker}${opt.label.padEnd(9)}${truncateToWidth(opt.description, width - 14, "")}`,
200
+ width - 2,
201
+ ),
202
+ );
203
+ }
204
+ lines.push("");
205
+ lines.push(renderFooter(" [↑↓] move [enter] next [esc] finish ", width - 2, theme));
206
+ } else if (activeStep === 2) {
207
+ lines.push(padEnd("Which plugins do you want?", width - 2));
208
+ lines.push("");
209
+ for (let i = 0; i < opts.plugins.length; i++) {
210
+ const p = opts.plugins[i];
211
+ if (!p) continue;
212
+ // The cursor (>) marks the row Space will toggle; the ✓/· marker shows
213
+ // the row's enabled state — both, so the user sees what they're about
214
+ // to change and what it currently is.
215
+ const cursor = i === (cursorByStep[2] ?? 0) ? "> " : " ";
216
+ const marker = (pluginToggles[i] ?? false) ? "✓" : "·";
217
+ const label = truncateToWidth(p.label, 15, "");
218
+ lines.push(
219
+ padEnd(
220
+ `${cursor} ${marker} ${label.padEnd(16)}${truncateToWidth(p.description, width - 26, "")}`,
221
+ width - 2,
222
+ ),
223
+ );
224
+ }
225
+ lines.push("");
226
+ lines.push(renderFooter(" [↑↓] move [space] toggle [enter] next [esc] finish ", width - 2, theme));
227
+ } else {
228
+ lines.push(padEnd("Which spinner for the editor border?", width - 2));
229
+ lines.push("");
230
+ for (let i = 0; i < opts.spinners.length; i++) {
231
+ const name = opts.spinners[i] ?? "";
232
+ const marker = i === (cursorByStep[3] ?? 0) ? "> " : " ";
233
+ const preview = spinFrame(name, tick);
234
+ // Options render consecutively (no blank line between them): the 10
235
+ // options must fit the overlay's 80% max height on a small terminal
236
+ // (24 rows → ~19), so the per-option vertical spacing was dropped.
237
+ lines.push(padEnd(`${marker}${name.padEnd(15)}${preview}`, width - 2));
238
+ }
239
+ lines.push("");
240
+ lines.push(renderFooter(" [↑↓] move [enter] done [esc] finish (skip rest) ", width - 2, theme));
241
+ }
242
+
243
+ return wrapWithBorder(lines, width, theme);
244
+ }
245
+
246
+ return {
247
+ focused: true,
248
+ render,
249
+ handleInput,
250
+ invalidate(): void {},
251
+ dispose(): void {
252
+ if (timer) clearInterval(timer);
253
+ },
254
+ };
255
+ }
@@ -51,6 +51,16 @@ vi.mock("@pi-archimedes/core", () => ({
51
51
  ]),
52
52
  }));
53
53
 
54
+ vi.mock("@pi-archimedes/ui", () => ({
55
+ getUISettingsItems: vi.fn(() => [
56
+ { id: "mutedTheme", label: "Muted theme", currentValue: "Off", values: ["On", "Off"] },
57
+ ]),
58
+ loadUIConfig: vi.fn(() => ({})),
59
+ saveUIConfig: vi.fn(),
60
+ registerUI: vi.fn(),
61
+ unpatchConsoleLog: vi.fn(),
62
+ }));
63
+
54
64
  vi.mock("@pi-archimedes/footer/config", () => ({
55
65
  getFooterSettingsItems: vi.fn(() => [
56
66
  { id: "splitThreshold", label: "Footer split threshold", currentValue: "120" },
@@ -76,6 +86,7 @@ vi.mock("@pi-archimedes/diff", () => ({
76
86
  { id: "diffTheme", label: "Diff theme", currentValue: "github-dark" },
77
87
  ]),
78
88
  }));
89
+ const { getUISettingsItems } = await import("@pi-archimedes/ui");
79
90
  const {
80
91
  PLUGINS,
81
92
  isPluginEnabled,
@@ -89,14 +100,14 @@ describe("isPluginEnabled (per-namespace gate)", () => {
89
100
  });
90
101
 
91
102
  it("defaults to enabled when config is empty", () => {
92
- expect(isPluginEnabled("mcp")).toBe(true);
103
+ expect(isPluginEnabled("web")).toBe(true);
93
104
  expect(isPluginEnabled("footer")).toBe(true);
94
105
  expect(isPluginEnabled("diff")).toBe(true);
95
106
  });
96
107
 
97
108
  it("returns false only for the plugin disabled in its own namespace", () => {
98
- mockStore["archimedes.mcp"] = { enabled: false };
99
- expect(isPluginEnabled("mcp")).toBe(false);
109
+ mockStore["archimedes.web"] = { enabled: false };
110
+ expect(isPluginEnabled("web")).toBe(false);
100
111
  expect(isPluginEnabled("footer")).toBe(true);
101
112
  expect(isPluginEnabled("todo")).toBe(true);
102
113
  });
@@ -186,6 +197,7 @@ describe("setPluginEnabled (save path)", () => {
186
197
 
187
198
  describe("PLUGINS manifest integrity", () => {
188
199
  const EXPECTED_IDS = [
200
+ "ui",
189
201
  "footer",
190
202
  "todo",
191
203
  "ask",
@@ -194,11 +206,11 @@ describe("PLUGINS manifest integrity", () => {
194
206
  "diff",
195
207
  "image-paste",
196
208
  "subagent",
197
- "mcp",
209
+ "web",
198
210
  "sudo",
199
211
  ];
200
212
 
201
- it("lists exactly the 10 non-core packages (no drift)", () => {
213
+ it("lists exactly the 11 non-core packages (no drift)", () => {
202
214
  expect([...PLUGINS.map((p) => p.id)].sort()).toEqual([...EXPECTED_IDS].sort());
203
215
  });
204
216
 
@@ -243,12 +255,12 @@ const ARROW_RIGHT = "\x1b[C";
243
255
 
244
256
  /** All enabled — no `enabled` keys persisted anywhere. */
245
257
  function fakeAllConfig(): Parameters<typeof buildSettingsItems>[0] {
246
- // Minimal shape — buildSettingsItems only reads core/notify/sessionName
258
+ // Minimal shape — buildSettingsItems only reads ui/notify/sessionName
247
259
  // through the (mocked) item builders, so missing fields never surface.
248
260
  return {
249
- core: { mutedTheme: false },
261
+ ui: {},
250
262
  footer: { splitThreshold: 120 },
251
- diff: { diffTheme: "github-dark", diffSplitMinWidth: 150, diffSplitMinCodeWidth: 60 },
263
+ diff: { diffTheme: "github-dark", diffSplitMinWidth: 150, diffSplitMinCodeWidth: 60, diffSplitWrapCheck: true },
252
264
  notify: { delayMs: 30000, notifyOnAgentEnd: true, notifyOnQuestion: true },
253
265
  sessionName: {},
254
266
  } as unknown as Parameters<typeof buildSettingsItems>[0];
@@ -258,6 +270,7 @@ describe("buildSettingsItems (settings gate)", () => {
258
270
  beforeEach(() => {
259
271
  for (const key of Object.keys(mockStore)) delete mockStore[key];
260
272
  vi.mocked(getDiffSettingsItems).mockClear();
273
+ vi.mocked(getUISettingsItems).mockClear();
261
274
  });
262
275
 
263
276
  it("includes all packages' items when everything is enabled, and lazy-imports diff", async () => {
@@ -272,9 +285,10 @@ describe("buildSettingsItems (settings gate)", () => {
272
285
  mockStore["archimedes.diff"] = { enabled: false };
273
286
  mockStore["archimedes.sessionName"] = { enabled: false };
274
287
  mockStore["archimedes.notify"] = { enabled: false };
288
+ mockStore["archimedes.ui"] = { enabled: false };
275
289
  const items = await buildSettingsItems(fakeAllConfig());
276
- // Core items are always present
277
- expect(items.map((i) => i.id)).toContain("mutedTheme");
290
+ // Core (UI) items are no longer automatically present
291
+ expect(items.map((i) => i.id)).not.toContain("mutedTheme");
278
292
  // No item from a disabled package
279
293
  for (const id of ["splitThreshold", "diffTheme", "delayMs", "sessionNameModel"]) {
280
294
  expect(items.map((i) => i.id)).not.toContain(id);
package/src/plugins.ts CHANGED
@@ -10,15 +10,16 @@
10
10
  import { isConfigEnabled, setConfigEnabled, loadConfig, removeConfig } from "@pi-archimedes/core/settings-io";
11
11
 
12
12
  export interface PluginDef {
13
- id: string; // matches package npm name suffix, e.g. "mcp"
14
- label: string; // human label, e.g. "MCP"
13
+ id: string; // matches package npm name suffix, e.g. "web"
14
+ label: string; // human label, e.g. "Web"
15
15
  description: string; // one-liner shown in the menu
16
- namespace: string; // "archimedes.mcp" — the settings.json key holding this plugin's { enabled, ...settings }
16
+ namespace: string; // "archimedes.web" — the settings.json key holding this plugin's { enabled, ...settings }
17
17
  load: () => Promise<unknown>; // lazy import for instance probing + future lazy mount
18
18
  }
19
19
 
20
20
  // Array order = menu display order. There is no separate order constant.
21
21
  export const PLUGINS: PluginDef[] = [
22
+ { id: "ui", label: "UI Enhancements", description: "Bash styling, editor spinner, thinking collapse, splash animation", namespace: "archimedes.ui", load: () => import("@pi-archimedes/ui") },
22
23
  { id: "footer", label: "Footer status bar", description: "Status bar with cost/timer", namespace: "archimedes.footer", load: () => import("@pi-archimedes/footer") },
23
24
  { id: "todo", label: "Todo list", description: "manage_todo_list tool + widget", namespace: "archimedes.todo", load: () => import("@pi-archimedes/todo") },
24
25
  { id: "ask", label: "Ask tool", description: "Structured in-conversation questions", namespace: "archimedes.ask", load: () => import("@pi-archimedes/ask") },
@@ -27,7 +28,7 @@ export const PLUGINS: PluginDef[] = [
27
28
  { id: "diff", label: "Diff rendering", description: "Shiki-powered diff display", namespace: "archimedes.diff", load: () => import("@pi-archimedes/diff") },
28
29
  { id: "image-paste", label: "Image paste", description: "Clipboard image paste", namespace: "archimedes.imagePaste", load: () => import("@pi-archimedes/image-paste") },
29
30
  { id: "subagent", label: "Subagents", description: "Live subagent dispatch (general, reviewer, …)", namespace: "archimedes.subagent", load: () => import("@pi-archimedes/subagent") },
30
- { id: "mcp", label: "MCP", description: "MCP client adapter + /mcp commands", namespace: "archimedes.mcp", load: () => import("@pi-archimedes/mcp") },
31
+ { id: "web", label: "Web", description: "Web search and content fetching with multi-provider routing", namespace: "archimedes.web", load: () => import("@pi-archimedes/web") },
31
32
  { id: "sudo", label: "Sudo", description: "Safe privileged execution", namespace: "archimedes.sudo", load: () => import("@pi-archimedes/sudo") },
32
33
  ];
33
34
 
package/src/settings.ts CHANGED
@@ -1,25 +1,20 @@
1
1
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
2
2
  import type { SettingItem } from "@earendil-works/pi-tui";
3
3
 
4
- import { getCoreSettingsItems } from "@pi-archimedes/core";
5
- import { OVERLAY_CHROME } from "@pi-archimedes/core/overlay";
4
+ import { getUISettingsItems, saveUIConfig, normalizeThinkingStyle, normalizeToolStyle, type UIConfig } from "@pi-archimedes/ui";
6
5
  import { getFooterSettingsItems } from "@pi-archimedes/footer/config";
7
- // diff (shiki) is lazy-loaded inside buildSettingsItems AND gated by the
8
- // per-namespace plugin gate (archimedes.diff.enabled — see ADR 0012) —
9
- // disabled means shiki is never imported
10
6
  import { getNotifySettingsItems } from "@pi-archimedes/notify";
11
7
  import { getSessionNameSettingsItems } from "@pi-archimedes/session-name";
12
8
  import {
13
9
  loadAllConfig,
14
- saveCoreConfig,
15
10
  saveFooterConfig,
16
11
  saveDiffConfig,
17
12
  saveNotifyConfig,
18
13
  saveSessionNameConfig,
19
- type CoreConfig,
20
14
  type NotifyConfig,
21
15
  } from "./config.js";
22
16
  import { isPluginEnabled } from "./plugins.js";
17
+ import { OVERLAY_CHROME } from "@pi-archimedes/core/overlay";
23
18
  import { createSettingsManager, type PromptDescriptor } from "./settings-manager.js";
24
19
 
25
20
  // ── Free-input prompt descriptors (keyed by item.id) ───────────────────────
@@ -43,7 +38,11 @@ const PROMPTS: Record<string, PromptDescriptor> = {
43
38
  // not leak back in through the settings overlay. The diff import (heavy —
44
39
  // pulls in shiki) is lazy AND inside the gate: disabled diff is never loaded.
45
40
  export async function buildSettingsItems(allConfig: ReturnType<typeof loadAllConfig>): Promise<SettingItem[]> {
46
- const items: SettingItem[] = [...getCoreSettingsItems({ ...allConfig.core })];
41
+ const items: SettingItem[] = [];
42
+
43
+ if (isPluginEnabled("ui")) {
44
+ items.push(...getUISettingsItems({ ...allConfig.ui }));
45
+ }
47
46
 
48
47
  if (isPluginEnabled("footer")) {
49
48
  items.push(...getFooterSettingsItems());
@@ -51,7 +50,7 @@ export async function buildSettingsItems(allConfig: ReturnType<typeof loadAllCon
51
50
 
52
51
  if (isPluginEnabled("diff")) {
53
52
  const { getDiffSettingsItems } = await import("@pi-archimedes/diff");
54
- items.push(...getDiffSettingsItems());
53
+ items.push(...getDiffSettingsItems(allConfig.diff));
55
54
  }
56
55
 
57
56
  if (isPluginEnabled("notify")) {
@@ -76,7 +75,7 @@ export async function buildSettingsItems(allConfig: ReturnType<typeof loadAllCon
76
75
  export async function openSettings(pi: ExtensionAPI, ctx: ExtensionContext): Promise<void> {
77
76
  const allConfig = loadAllConfig();
78
77
 
79
- const coreConfig: CoreConfig = { ...allConfig.core };
78
+ const uiConfig: UIConfig = { ...allConfig.ui };
80
79
  const notifyConfig: NotifyConfig = { ...allConfig.notify };
81
80
  const footerConfig = { ...allConfig.footer };
82
81
  const diffConfig = { ...allConfig.diff };
@@ -91,16 +90,20 @@ export async function openSettings(pi: ExtensionAPI, ctx: ExtensionContext): Pro
91
90
  theme,
92
91
  onChange: (id: string, newValue: string) => {
93
92
  switch (id) {
94
- // ── Core settings ──
95
- case "mutedTheme": coreConfig.mutedTheme = newValue === "On"; break;
96
- case "codeUnindent": coreConfig.codeUnindent = newValue === "On"; break;
97
- case "editorSpinBorder": coreConfig.editorSpinBorder = newValue === "On"; break;
98
- case "editorSpinSpeed": coreConfig.editorSpinSpeed = newValue.toLowerCase() as CoreConfig["editorSpinSpeed"]; break;
99
- case "editorSpinStyle": coreConfig.editorSpinStyle = newValue.toLowerCase().replace(/ /g, "-") as CoreConfig["editorSpinStyle"]; break;
100
- case "editorSpinLabel": coreConfig.editorSpinLabel = newValue; break;
101
- case "labelText": coreConfig.labelText = newValue; break;
102
- case "labelColor": coreConfig.labelColor = newValue; break;
103
- case "animationStyle": coreConfig.animationStyle = newValue as CoreConfig["animationStyle"]; break;
93
+ // ── UI settings ──
94
+ case "bashToolStyling": uiConfig.bashToolStyling = newValue === "On"; break;
95
+ case "mutedTheme": uiConfig.mutedTheme = newValue === "On"; break;
96
+ case "autoCollapseThinking": uiConfig.autoCollapseThinking = newValue === "On"; break;
97
+ case "thinkingStyle": uiConfig.thinkingStyle = normalizeThinkingStyle(newValue); break;
98
+ case "toolStyle": uiConfig.toolStyle = normalizeToolStyle(newValue); break;
99
+ case "codeUnindent": uiConfig.codeUnindent = newValue === "On"; break;
100
+ case "editorSpinBorder": uiConfig.editorSpinBorder = newValue === "On"; break;
101
+ case "editorSpinSpeed": uiConfig.editorSpinSpeed = newValue.toLowerCase() as UIConfig["editorSpinSpeed"]; break;
102
+ case "editorSpinStyle": uiConfig.editorSpinStyle = newValue.toLowerCase().replace(/ /g, "-") as UIConfig["editorSpinStyle"]; break;
103
+ case "editorSpinLabel": uiConfig.editorSpinLabel = newValue; break;
104
+ case "labelText": uiConfig.labelText = newValue; break;
105
+ case "labelColor": uiConfig.labelColor = newValue; break;
106
+ case "animationStyle": uiConfig.animationStyle = newValue as UIConfig["animationStyle"]; break;
104
107
 
105
108
  // ── Footer settings ──
106
109
  case "splitThreshold": {
@@ -121,6 +124,7 @@ export async function openSettings(pi: ExtensionAPI, ctx: ExtensionContext): Pro
121
124
  if (Number.isFinite(v)) diffConfig.diffSplitMinCodeWidth = v;
122
125
  break;
123
126
  }
127
+ case "diffSplitWrapCheck": diffConfig.diffSplitWrapCheck = newValue === "On"; break;
124
128
 
125
129
  // ── Notify settings ──
126
130
  case "notifyOnAgentEnd": notifyConfig.notifyOnAgentEnd = newValue === "On"; break;
@@ -136,7 +140,7 @@ export async function openSettings(pi: ExtensionAPI, ctx: ExtensionContext): Pro
136
140
  }
137
141
  },
138
142
  onSave: () => {
139
- saveCoreConfig(coreConfig);
143
+ saveUIConfig(uiConfig);
140
144
  saveFooterConfig(footerConfig);
141
145
  saveDiffConfig(diffConfig);
142
146
  saveNotifyConfig(notifyConfig);