@pi-archimedes/ui 2.10.3 → 2.11.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.
package/src/index.ts CHANGED
@@ -11,13 +11,21 @@ import type {
11
11
  } from "@earendil-works/pi-tui";
12
12
 
13
13
  import { HephaestusEditor } from "./editor/index.js";
14
- import { renderHeader, patchStartupListing, type ListingRef } from "./startup/index.js";
15
- import { patchConsoleLog, unpatchConsoleLog } from "./startup/capture.js";
14
+ import {
15
+ renderHeader,
16
+ patchStartupListing,
17
+ revealStartupListing,
18
+ stopStartupListing,
19
+ modelScopeSection,
20
+ type ListingRef,
21
+ } from "./startup/index.js";
22
+ import type { ParsedSection } from "./startup/sections.js";
16
23
  import { patchThinkingRenderer } from "./thinking/patch.js";
17
24
  import { transformThinkingContent } from "./thinking/transform.js";
18
25
  import {
19
26
  loadUIConfig,
20
27
  saveUIConfig,
28
+ readQuietStartup,
21
29
  DEFAULT_UI_CONFIG,
22
30
  ANIMATION_STYLES,
23
31
  normalizeThinkingStyle,
@@ -25,13 +33,13 @@ import {
25
33
  type UIConfig,
26
34
  type CoreConfig,
27
35
  } from "./config.js";
36
+ import { reportPiSupport } from "@pi-archimedes/core/pi-support";
28
37
  import { getUISettingsItems } from "./settings.js";
29
38
  import { migrateCoreToUIConfig, migrateCompactThinkingToStyle, migrateRemovedToolPatch } from "./migration.js";
30
- import { registerBashToolOverride, clearActiveBashIntervals } from "./bash/index.js";
31
- import { registerCodemodeToolOverride, clearActiveCodemodeIntervals } from "./codemode/index.js";
39
+ import { renderBashCall, renderBashResult } from "./bash/index.js";
40
+ import { renderCodemodeCall, renderCodemodeResult } from "./codemode/index.js";
32
41
 
33
42
  // Re-exports
34
- export { unpatchConsoleLog } from "./startup/capture.js";
35
43
  export { getUISettingsItems } from "./settings.js";
36
44
  export {
37
45
  loadUIConfig,
@@ -51,11 +59,20 @@ export {
51
59
  // Module-level state for session lifecycle
52
60
  let uiRef: ListingRef | undefined;
53
61
  let uiCtx: ExtensionContext | undefined;
54
- let uiTui: TUI | undefined;
55
62
 
56
63
  let spinFlag = false;
57
64
  let spinInterval: ReturnType<typeof setInterval> | undefined;
58
65
 
66
+ /**
67
+ * Which Archimedes tool rows the current session asks for, refreshed at every
68
+ * `session_start` and read by the renderer resolver. The resolver itself is
69
+ * registered once per extension load — pi keeps a list of resolvers per
70
+ * extension, so registering it from `session_start` would stack a duplicate on
71
+ * every `/reload` — which means the settings have to be read here rather than
72
+ * inside the resolver: `renderCall`/`renderResult` run on every frame.
73
+ */
74
+ let styledTools = { bash: false, codemode: false };
75
+
59
76
  function clearSpinInterval(): void {
60
77
  if (spinInterval) {
61
78
  clearInterval(spinInterval);
@@ -63,7 +80,48 @@ function clearSpinInterval(): void {
63
80
  }
64
81
  }
65
82
 
83
+ /**
84
+ * Draw the bash row through pi's renderer resolution (ADR 0029).
85
+ *
86
+ * `registerToolRenderer()` was added in pi 1.0.1 precisely for presentation
87
+ * overrides: it chooses how calls to a tool are drawn without registering the
88
+ * tool, and `next()` hands the tool to the remaining resolvers and then to
89
+ * whatever is registered. Off settings therefore give back pi's own row rather
90
+ * than the previous arrangement — re-registering `bash` from
91
+ * `createBashToolDefinition()` with our renderers bolted on — which also means
92
+ * anything pi later adds to that tool cannot be silently dropped by us.
93
+ */
94
+ function registerToolRenderers(pi: ExtensionAPI): void {
95
+ // Capability check, not a version wish: on a host without the API there is
96
+ // nothing to resolve through, and pi's own rows stand in.
97
+ if (typeof pi.registerToolRenderer !== "function") return;
98
+
99
+ pi.registerToolRenderer((toolName, next) => {
100
+ // `next()` is spread first so that whatever the built-in definition
101
+ // declares besides the two renderers — `renderShell` is the one that matters
102
+ // today — survives resolution instead of being silently dropped by us.
103
+ const ours =
104
+ toolName === "bash" && styledTools.bash
105
+ ? { renderCall: renderBashCall, renderResult: renderBashResult }
106
+ : toolName === "codemode" && styledTools.codemode
107
+ ? { renderCall: renderCodemodeCall, renderResult: renderCodemodeResult }
108
+ : undefined;
109
+ return ours ? { ...next(), ...ours } : next();
110
+ });
111
+ }
112
+
66
113
  export function registerUI(pi: ExtensionAPI): void {
114
+ // Floor check before anything that can fail (ADR 0026): pi never installs or
115
+ // solves peers, so the peer range below cannot warn anyone — the runtime
116
+ // comparison against the host's own VERSION is the only thing that can. It
117
+ // records a warning for meta to print once, aggregated; nothing here is
118
+ // withheld on a below-floor host, because a `ui` that registered nothing
119
+ // would make /plugins report an enabled package that did nothing.
120
+ reportPiSupport("ui", "1.2.0");
121
+
122
+ // Registered once per extension load, before any session exists (ADR 0029).
123
+ registerToolRenderers(pi);
124
+
67
125
  // One-time migration of any legacy core config to UI config
68
126
  migrateCoreToUIConfig();
69
127
  migrateCompactThinkingToStyle();
@@ -71,9 +129,6 @@ export function registerUI(pi: ExtensionAPI): void {
71
129
  // previous version left on the shared prototype (upgrade + /reload case).
72
130
  migrateRemovedToolPatch();
73
131
 
74
- // Patch console.log for model scope capture
75
- patchConsoleLog();
76
-
77
132
  pi.on("message_end", (event, _ctx) => {
78
133
  if (loadUIConfig().codeUnindent) {
79
134
  transformThinkingContent(event.message as any);
@@ -82,67 +137,29 @@ export function registerUI(pi: ExtensionAPI): void {
82
137
 
83
138
  // session_shutdown handler (top-level to prevent accumulation on /reload)
84
139
  pi.on("session_shutdown", (_event, ctx) => {
85
- unpatchConsoleLog();
86
- clearActiveBashIntervals();
87
- clearActiveCodemodeIntervals();
88
-
89
140
  const targetCtx = ctx?.hasUI ? ctx : uiCtx;
90
141
  if (targetCtx?.hasUI) {
142
+ // Give the working row back whichever way we left it: a session that
143
+ // started with the border spinner on had hidden it (`setWorkingVisible(false)`).
91
144
  targetCtx.ui.setWorkingVisible(true);
145
+ // Give back everything the session borrowed: pi's animated indicator and
146
+ // its own hidden-thinking wording, so a session that starts after this
147
+ // one (or after `/reload` with the plugin off) sees pi's defaults.
148
+ targetCtx.ui.setWorkingIndicator(undefined);
149
+ targetCtx.ui.setHiddenThinkingLabel(undefined);
92
150
  targetCtx.ui.setEditorComponent(undefined);
93
151
  }
94
152
  clearSpinInterval();
95
153
  spinFlag = false;
96
154
 
155
+ // The splash: stop the frame/reveal timers, then freeze the ref so a late
156
+ // render of a header still on screen cannot restart the animation. Nothing
157
+ // else to undo — the startup listing interception and the console.log patch
158
+ // this used to un-install are gone (pi 1.x alignment, Task 8).
159
+ stopStartupListing();
97
160
  if (uiRef) {
98
161
  uiRef.settled = true;
99
162
  }
100
-
101
- const PATCHED_LISTING = Symbol.for("splashscreen:listingPatched");
102
- const ORIG_ADD_CHILD = Symbol.for("splashscreen:origAddChild");
103
- if (uiTui) {
104
- try {
105
- for (const child of uiTui.children) {
106
- const cc = child as any;
107
- if (cc[PATCHED_LISTING] && cc[ORIG_ADD_CHILD]) {
108
- cc.addChild = cc[ORIG_ADD_CHILD];
109
- cc[PATCHED_LISTING] = false;
110
- cc[ORIG_ADD_CHILD] = undefined;
111
- }
112
- }
113
- for (const topChild of uiTui.children) {
114
- const tc = topChild as any;
115
- if (!tc.children) continue;
116
- for (const child of tc.children) {
117
- const cc = child as any;
118
- if (cc[PATCHED_LISTING] && cc[ORIG_ADD_CHILD]) {
119
- cc.addChild = cc[ORIG_ADD_CHILD];
120
- cc[PATCHED_LISTING] = false;
121
- cc[ORIG_ADD_CHILD] = undefined;
122
- }
123
- }
124
- }
125
-
126
- const ANIM_INTERVAL = Symbol.for("splashscreen:animInterval");
127
- const DEBOUNCE_TIMER = Symbol.for("splashscreen:debounceTimer");
128
- for (const child of uiTui.children) {
129
- const cc = child as any;
130
- if (cc[ANIM_INTERVAL]) clearInterval(cc[ANIM_INTERVAL]);
131
- if (cc[DEBOUNCE_TIMER]) clearTimeout(cc[DEBOUNCE_TIMER]);
132
- }
133
- for (const topChild of uiTui.children) {
134
- const tc = topChild as any;
135
- if (!tc.children) continue;
136
- for (const child of tc.children) {
137
- const cc = child as any;
138
- if (cc[ANIM_INTERVAL]) clearInterval(cc[ANIM_INTERVAL]);
139
- if (cc[DEBOUNCE_TIMER]) clearTimeout(cc[DEBOUNCE_TIMER]);
140
- }
141
- }
142
- } catch {
143
- /* TUI structure may have changed — ignore */
144
- }
145
- }
146
163
  });
147
164
 
148
165
  // session_start handler
@@ -151,56 +168,180 @@ export function registerUI(pi: ExtensionAPI): void {
151
168
 
152
169
  const config = loadUIConfig();
153
170
  const toolStyle = normalizeToolStyle(config.toolStyle);
171
+ const minimal = toolStyle === "Minimal";
154
172
 
155
- // Master gate: `Minimal` = archimedes tool styling (the per-tool
156
- // toggles fine-tune it); `Native` = no archimedes tool styling at all
157
- // — the overrides are not registered, so pi's native tool rendering
158
- // stands (and no built-in-takeover startup notice).
159
- if (toolStyle === "Minimal") {
160
- if (config.bashToolStyling !== false) {
161
- registerBashToolOverride(pi, ctx.cwd);
162
- }
163
-
164
- // The codemode override replaces pi's built-in codemode extension
165
- // (the same take-over the bash override does for the core bash
166
- // tool). It loads the native definition from the running CLI and
167
- // swaps in the Archimedes renderers; it is a no-op on pi versions
168
- // without the codemode extension.
169
- if (config.codemodeToolStyling !== false) {
170
- void registerCodemodeToolOverride(pi);
171
- }
172
- }
173
+ // What the resolver answers with for this session (see `styledTools`).
174
+ // `Minimal` = archimedes tool styling (the per-tool toggles fine-tune it);
175
+ // `Native` = none of it, so pi's own rows stand everywhere.
176
+ styledTools = {
177
+ bash: minimal && config.bashToolStyling !== false,
178
+ codemode: minimal && config.codemodeToolStyling !== false,
179
+ };
173
180
 
174
181
  if (ctx.hasUI) {
175
182
  spinFlag = config.editorSpinBorder;
176
183
 
177
- // Set animated header
178
- uiRef = {
179
- sections: [],
180
- frame: 0,
181
- revealed: false,
182
- revealedAt: 0,
183
- scaffoldAt: 0,
184
- settled: false,
185
- };
186
- const ref = uiRef;
187
- const headerFactory = (tui: TUI, theme: Theme): Component & { dispose?(): void } => {
188
- uiTui = tui;
189
- const comp: Component & { dispose?(): void } = {
190
- invalidate(): void {},
191
- render(width: number): string[] {
192
- return renderHeader(theme, ref, width, tui.terminal.rows - 3);
193
- },
184
+ // pi 1.x owns the startup screen, and the suite's splash is opt-in
185
+ // (`archimedes.ui.startupSplash`, default off — the maintainer's call: keep
186
+ // the code, flip the default). `ctx.scopedModels` is the model scope pi
187
+ // would otherwise have printed, so nothing here scrapes pi's stdout or its
188
+ // component tree any more (that was `startup/capture.ts` plus the
189
+ // `addChild` interception in `patchStartupListing`, both removed here).
190
+ //
191
+ // The two settings are independent knobs, so their combination is stated
192
+ // in full. `quietStartup` is pi's own (`false` = header + details,
193
+ // `"header"` = header alone, `true` = nothing);
194
+ // `startupSplash` is ours (off = we never install a header).
195
+ //
196
+ // startupSplash quietStartup what the header slot gets timers
197
+ // ------------- ------------ ---------------------------- ------
198
+ // false (default) false pi's own header none
199
+ // false (default) "header" pi's own header none
200
+ // false (default) true pi's own (empty) header none
201
+ // true false our splash (+ `[Models]` if armed
202
+ // any model is scoped)
203
+ // true "header" our splash, no scope armed
204
+ // true true pi's own (empty) header none
205
+ //
206
+ // Off wins outright: with the splash off we hand the header back to pi
207
+ // whatever `quietStartup` says, and pi's own setting then decides what pi
208
+ // draws — its header and resource listing, its header alone, or its empty
209
+ // `Text("", 0, 0)` — exactly as it does with no archimedes loaded. Handing
210
+ // it back through `setHeader(undefined)` rather than simply doing nothing
211
+ // is what makes a splash left installed by an earlier session in the same
212
+ // process (`/reload` after turning it off) give way: pi puts its
213
+ // `builtInHeader` back in the slot (`setExtensionHeader()`,
214
+ // interactive-mode.js:2006-2011).
215
+ //
216
+ // On means today's behaviour, unchanged. `startupSplash: true` with
217
+ // `quietStartup: true` is the one row worth naming: pi asked for no header
218
+ // at all, so the splash stands down and pi's silence stands — opting in to
219
+ // the splash is not an instruction to overrule pi's own quiet startup, and
220
+ // `quietStartup` keeps deciding whether the resource listing (and so our
221
+ // `[Models]` row) shows. Pinned cell by cell in
222
+ // `startup-splash-gate.test.ts`.
223
+ //
224
+ // `startupSplash` is ours, and read strictly: anything that is not the
225
+ // literal `true` — a hand-edited `"true"`, a `1` — means off, the same way
226
+ // core's `isConfigEnabled` refuses to guess at a boolean.
227
+ const startupSplash = config.startupSplash === true;
228
+ // pi's own answer, asked only when the splash is on: with it off we install
229
+ // no header, so there is nothing for `quietStartup` to decide.
230
+ const quietStartup = startupSplash ? readQuietStartup(pi) : undefined;
231
+
232
+ if (!startupSplash) {
233
+ ctx.ui.setHeader(undefined);
234
+ uiRef = undefined;
235
+ } else if (quietStartup === true) {
236
+ // pi's own header is an empty component in this mode. Handing ours back
237
+ // is what keeps that silence: a splash left installed from an earlier
238
+ // session in the same process (`/reload` after a settings change) would
239
+ // otherwise animate over it.
240
+ ctx.ui.setHeader(undefined);
241
+ uiRef = undefined;
242
+ } else {
243
+ // What the splash reveals after the logo. The version row belongs to
244
+ // the header itself; the model scope is a startup *detail*, so it
245
+ // follows pi's own rule for the `Model scope:` line and the resource
246
+ // listing: visible only when `quietStartup` is `false`.
247
+ const sections: ParsedSection[] = [];
248
+ const models = quietStartup === false ? modelScopeSection(ctx.scopedModels) : undefined;
249
+ if (models) sections.push(models);
250
+
251
+ // Set animated header
252
+ uiRef = {
253
+ sections: [],
254
+ frame: 0,
255
+ revealed: false,
256
+ revealedAt: 0,
257
+ scaffoldAt: 0,
258
+ settled: false,
259
+ };
260
+ const ref = uiRef;
261
+ const headerFactory = (tui: TUI, theme: Theme): Component & { dispose(): void } => {
262
+ // Nothing here is sized by the screen: pi calls this factory again
263
+ // whenever the header is replaced — a new session, `/reload` (which
264
+ // goes through `resetExtensionUI()`), or any `setHeader()` — and never
265
+ // for a `tuiMode` switch (`switchTuiMode()` re-mounts the components it
266
+ // already has), so a per-screen measurement taken here would be the
267
+ // measurement the header kept until the next replacement. The header
268
+ // does not need one: it renders its own content on either screen
269
+ // (`renderHeader`), which is both what keeps a frame-bounded viewport
270
+ // usable (ADR 0027) and what leaves pi's listing directly below it.
271
+ const comp: Component & { dispose(): void } = {
272
+ // pi invalidates the component tree on every theme change
273
+ // (`onThemeChange()` → `ui.invalidate()`) and from the theme file
274
+ // watcher, and the splash's cached lines carry the theme's colours
275
+ // — so this is the hook that keeps a finished splash from rendering
276
+ // yesterday's palette for the rest of the session. The cache key is
277
+ // the width it was drawn at, which a theme switch leaves alone.
278
+ // Only the memo goes: this is a repaint, not an animation restart.
279
+ invalidate(): void {
280
+ delete ref.cachedLines;
281
+ delete ref.cachedWidth;
282
+ },
283
+ render(width: number): string[] {
284
+ return renderHeader(theme, ref, width);
285
+ },
286
+ // pi disposes the header it is replacing before it builds the next
287
+ // one (`setExtensionHeader()` disposes, then calls the factory), so
288
+ // this is where the splash's timers die on a `setHeader()`
289
+ // replacement or a session restart — no stored renderer needed. A
290
+ // `tuiMode` switch does not dispose: it re-mounts this same
291
+ // component, whose timers then run until they settle themselves.
292
+ dispose(): void {
293
+ stopStartupListing();
294
+ },
295
+ };
296
+ patchStartupListing(tui, ref);
297
+ revealStartupListing(tui, ref, sections);
298
+ return comp;
194
299
  };
195
- patchStartupListing(tui, theme, ref);
196
- return comp;
197
- };
198
- ctx.ui.setHeader(headerFactory);
300
+ ctx.ui.setHeader(headerFactory);
301
+ }
199
302
 
200
303
  clearSpinInterval();
304
+ // One announcement, in one place. pi's working row writes "Working" above
305
+ // the editor and our border spinner writes its label — `editorSpinLabel`
306
+ // defaults to "Working" as well — into the editor's own top border, so with
307
+ // both on screen the word appeared twice. The border is ours, so the row is
308
+ // handed pi's own switch: hidden while the spinner runs, given back when it
309
+ // does not.
310
+ //
311
+ // `setWorkingVisible()` is official API (`types.d.ts:88`), and hiding the
312
+ // row is what makes the indicator configuration `#106` needed redundant:
313
+ // pi's `setWorkingVisible(false)` clears the working status indicator
314
+ // outright and its `turn_start` handler honours the flag
315
+ // (`interactive-mode.js:1819-1828`, `:2762-2769`), so there is no row left
316
+ // for a `frames: []` to silence. The indicator is still handed back to pi
317
+ // unconditionally, so the row a user *does* see is pi's default down to its
318
+ // spinner.
319
+ //
320
+ // Read per session rather than remembered: `editorSpinBorder` can change
321
+ // between sessions, and pi puts the row back on whenever it replaces the
322
+ // session (`resetExtensionUI()` → `workingVisible = true`, `:1921`). The
323
+ // toggle needs no keying on the `pi` handle the way a registration does —
324
+ // it is one idempotent setter call inside the `session_start` handler, and
325
+ // every handle gets its own handler.
201
326
  ctx.ui.setWorkingVisible(!spinFlag);
327
+ ctx.ui.setWorkingIndicator(undefined);
328
+
329
+ // The word a collapsed thinking block shows is pi's own row, so the
330
+ // setting is handed to pi rather than rebuilt here. An empty setting
331
+ // hands the label back to pi's default.
332
+ ctx.ui.setHiddenThinkingLabel(config.labelText.trim() || undefined);
202
333
 
203
334
  if (spinFlag) {
335
+ // The border spinner lives in the editor frame, not in scrollback, so
336
+ // it runs on either screen. The renderer is handed straight to the
337
+ // editor and never kept by us, and idleness is read from `ctx` rather
338
+ // than from the renderer, so a screen-mode switch cannot strand it.
339
+ //
340
+ // `ctx.isIdle()` is false from `before_agent_start` onward — i.e. from
341
+ // the moment a run begins, not from the first token — which is exactly
342
+ // the *Busy episode* boundary in `CONTEXT.md` (idle → working → idle),
343
+ // so the spinner starting on the run's first tick is the behaviour the
344
+ // term defines, not an early start.
204
345
  ctx.ui.setEditorComponent(
205
346
  (tui: TUI, editorTheme: EditorTheme, keybindings: KeybindingsManager) => {
206
347
  const theme = ctx.ui.theme;
@@ -220,12 +361,19 @@ export function registerUI(pi: ExtensionAPI): void {
220
361
  );
221
362
  }
222
363
 
223
- patchThinkingRenderer(() => ctx.ui.theme, {
224
- labelText: config.labelText,
225
- labelColor: config.labelColor,
226
- autoCollapseThinking: config.autoCollapseThinking,
227
- thinkingStyle: normalizeThinkingStyle(config.thinkingStyle),
228
- });
364
+ patchThinkingRenderer(
365
+ () => ctx.ui.theme,
366
+ {
367
+ labelText: config.labelText,
368
+ labelColor: config.labelColor,
369
+ autoCollapseThinking: config.autoCollapseThinking,
370
+ thinkingStyle: normalizeThinkingStyle(config.thinkingStyle),
371
+ },
372
+ // The patch's one user-visible diagnostic (a skipped patch) goes to the
373
+ // notification surface — the console is not a surface on either screen
374
+ // mode, and a subagent child discards it.
375
+ (message) => ctx.ui.notify(message, "warning"),
376
+ );
229
377
  }
230
378
  });
231
379
  }
@@ -3,9 +3,9 @@ import { getUISettingsItems } from "./settings.js";
3
3
  import { DEFAULT_UI_CONFIG, ANIMATION_STYLES, type UIConfig } from "./config.js";
4
4
 
5
5
  describe("getUISettingsItems", () => {
6
- it("exposes all 15 UI setting items with correct defaults", () => {
6
+ it("exposes all 16 UI setting items with correct defaults", () => {
7
7
  const items = getUISettingsItems(DEFAULT_UI_CONFIG);
8
- expect(items).toHaveLength(15);
8
+ expect(items).toHaveLength(16);
9
9
 
10
10
  const ids = items.map((i) => i.id);
11
11
  expect(ids).toEqual([
@@ -18,6 +18,7 @@ describe("getUISettingsItems", () => {
18
18
  "codeUnindent",
19
19
  "labelText",
20
20
  "labelColor",
21
+ "startupSplash",
21
22
  "animationStyle",
22
23
  "colorfulLogo",
23
24
  "editorSpinBorder",
@@ -35,6 +36,7 @@ describe("getUISettingsItems", () => {
35
36
  expect(items.find((i) => i.id === "codeUnindent")?.currentValue).toBe("On");
36
37
  expect(items.find((i) => i.id === "labelText")?.currentValue).toBe("Thinking...");
37
38
  expect(items.find((i) => i.id === "labelColor")?.currentValue).toBe("255,215,0");
39
+ expect(items.find((i) => i.id === "startupSplash")?.currentValue).toBe("Off");
38
40
  expect(items.find((i) => i.id === "animationStyle")?.currentValue).toBe("vertical-up");
39
41
  expect(items.find((i) => i.id === "editorSpinBorder")?.currentValue).toBe("On");
40
42
  expect(items.find((i) => i.id === "editorSpinSpeed")?.currentValue).toBe("Normal");
@@ -87,6 +89,22 @@ describe("getUISettingsItems", () => {
87
89
  expect(item?.values).toEqual([...ANIMATION_STYLES]);
88
90
  });
89
91
 
92
+ // The splash is opt-in, so the row has to read as an opt-in to a human: a
93
+ // label that names the splash, and an `Off` default matching the config.
94
+ it("offers the startup splash as a human-readable On/Off row, off by default", () => {
95
+ const off = getUISettingsItems(DEFAULT_UI_CONFIG).find((i) => i.id === "startupSplash");
96
+ expect(off).toBeDefined();
97
+ expect(off!.label).toBe("Startup Splash");
98
+ expect(off!.description).toMatch(/splash/i);
99
+ expect(off!.values).toEqual(["On", "Off"]);
100
+ expect(off!.currentValue).toBe("Off");
101
+
102
+ const on = getUISettingsItems({ ...DEFAULT_UI_CONFIG, startupSplash: true }).find(
103
+ (i) => i.id === "startupSplash",
104
+ );
105
+ expect(on!.currentValue).toBe("On");
106
+ });
107
+
90
108
  describe("robustness with hand-edited (corrupt) values", () => {
91
109
  it("an empty-string editorSpinSpeed projects to 'Normal'", () => {
92
110
  const items = getUISettingsItems({
@@ -119,5 +137,22 @@ describe("getUISettingsItems", () => {
119
137
  });
120
138
  expect(items.find((i) => i.id === "editorSpinStyle")?.currentValue).toBe("Typing");
121
139
  });
140
+
141
+ // `startupSplash` is read strictly by the gate (`config.startupSplash === true`,
142
+ // `index.ts`), so a hand-edited `"true"` or `1` means OFF on screen. The row
143
+ // has to answer with the same reading — a row saying `On` over a session that
144
+ // draws pi's own startup is the panel promising something that never arrives.
145
+ it("a startupSplash that is not the literal true projects to 'Off', like the gate reads it", () => {
146
+ for (const value of ["true", 1, "yes", {}, null]) {
147
+ const items = getUISettingsItems({
148
+ ...DEFAULT_UI_CONFIG,
149
+ startupSplash: value as never,
150
+ });
151
+ expect(
152
+ items.find((i) => i.id === "startupSplash")?.currentValue,
153
+ `startupSplash=${String(value)}`,
154
+ ).toBe("Off");
155
+ }
156
+ });
122
157
  });
123
158
  });
package/src/settings.ts CHANGED
@@ -60,7 +60,7 @@ export function getUISettingsItems(config: UIConfig): SettingItem[] {
60
60
  {
61
61
  id: "labelText",
62
62
  label: "Label Text",
63
- description: "Text shown before thinking blocks",
63
+ description: "Text shown before thinking blocks, and on collapsed ones",
64
64
  currentValue: config.labelText,
65
65
  },
66
66
  {
@@ -69,17 +69,30 @@ export function getUISettingsItems(config: UIConfig): SettingItem[] {
69
69
  description: "RGB color for thinking label (e.g. 255,215,0)",
70
70
  currentValue: config.labelColor,
71
71
  },
72
+ {
73
+ id: "startupSplash",
74
+ label: "Startup Splash",
75
+ description:
76
+ "Archimedes startup splash animation (Off = pi's own startup screen, which is the default)",
77
+ // Strictly `=== true`, because that is how the gate reads it (`index.ts`):
78
+ // a hand-edited `"true"` or `1` is off on screen, so it must read as off
79
+ // here too. Every other boolean row can afford truthiness because every
80
+ // other boolean is *consumed* by truthiness.
81
+ currentValue: config.startupSplash === true ? "On" : "Off",
82
+ values: ["On", "Off"],
83
+ },
72
84
  {
73
85
  id: "animationStyle",
74
86
  label: "Logo Animation",
75
- description: "Splashscreen logo reveal style",
87
+ description: "Splashscreen logo reveal style (applies when Startup Splash is On)",
76
88
  currentValue: config.animationStyle,
77
89
  values: [...ANIMATION_STYLES],
78
90
  },
79
91
  {
80
92
  id: "colorfulLogo",
81
93
  label: "Colorful Logo",
82
- description: "Use pi's brand colors for the splash logo",
94
+ description:
95
+ "Use pi's brand colors for the splash logo (applies when Startup Splash is On)",
83
96
  currentValue: config.colorfulLogo ? "On" : "Off",
84
97
  values: ["On", "Off"],
85
98
  },