@pi-archimedes/ui 2.10.4 → 2.11.1

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.
@@ -3,11 +3,13 @@ import { describe, it, expect, vi, beforeEach } from "vitest";
3
3
  vi.mock("@pi-archimedes/core/settings-io", () => ({
4
4
  loadConfig: vi.fn(),
5
5
  saveConfig: vi.fn(),
6
+ readEffectiveSettings: vi.fn(),
6
7
  }));
7
8
 
8
9
  import {
9
10
  loadUIConfig,
10
11
  saveUIConfig,
12
+ readQuietStartup,
11
13
  DEFAULT_UI_CONFIG,
12
14
  ANIMATION_STYLES,
13
15
  THINKING_STYLE_VALUES,
@@ -17,7 +19,7 @@ import {
17
19
  normalizeToolStyle,
18
20
  SPIN_SPEED_MULT,
19
21
  } from "./config.js";
20
- import { loadConfig, saveConfig } from "@pi-archimedes/core/settings-io";
22
+ import { loadConfig, saveConfig, readEffectiveSettings } from "@pi-archimedes/core/settings-io";
21
23
 
22
24
  describe("loadUIConfig", () => {
23
25
  beforeEach(() => {
@@ -43,6 +45,7 @@ describe("loadUIConfig", () => {
43
45
  codeUnindent: true,
44
46
  labelText: "Thinking...",
45
47
  labelColor: "255,215,0",
48
+ startupSplash: false,
46
49
  animationStyle: "vertical-up",
47
50
  colorfulLogo: false,
48
51
  editorSpinBorder: true,
@@ -75,6 +78,18 @@ describe("saveUIConfig", () => {
75
78
  saveUIConfig(config);
76
79
  expect(saveConfig).toHaveBeenCalledWith("archimedes.ui", config);
77
80
  });
81
+
82
+ // The splash toggle is saved by the same whole-object write as every other UI
83
+ // key, so `/archimedes` turning it on reaches settings.json as
84
+ // `archimedes.ui.startupSplash` — nothing strips an unknown key on the way.
85
+ it("persists startupSplash under archimedes.ui", () => {
86
+ const config = { ...DEFAULT_UI_CONFIG, startupSplash: true };
87
+ saveUIConfig(config);
88
+ expect(saveConfig).toHaveBeenCalledWith(
89
+ "archimedes.ui",
90
+ expect.objectContaining({ startupSplash: true }),
91
+ );
92
+ });
78
93
  });
79
94
 
80
95
  describe("DEFAULT_UI_CONFIG", () => {
@@ -89,6 +104,7 @@ describe("DEFAULT_UI_CONFIG", () => {
89
104
  codeUnindent: true,
90
105
  labelText: "Thinking...",
91
106
  labelColor: "255,215,0",
107
+ startupSplash: false,
92
108
  animationStyle: "vertical-up",
93
109
  colorfulLogo: false,
94
110
  editorSpinBorder: true,
@@ -98,6 +114,12 @@ describe("DEFAULT_UI_CONFIG", () => {
98
114
  });
99
115
  });
100
116
 
117
+ // The maintainer's call on the startup splash: keep the code, flip the
118
+ // default. pi's own startup is what a fresh install sees.
119
+ it("leaves the startup splash off, so pi's own startup is the default", () => {
120
+ expect(DEFAULT_UI_CONFIG.startupSplash).toBe(false);
121
+ });
122
+
101
123
  it("exposes a speed→multiplier map (slow/normal/fast = 1.5/1/0.6)", () => {
102
124
  expect(SPIN_SPEED_MULT).toEqual({ slow: 1.5, normal: 1, fast: 0.6 });
103
125
  });
@@ -199,3 +221,42 @@ describe("normalizeToolStyle", () => {
199
221
  expect(normalizeToolStyle(true)).toBe("Minimal");
200
222
  });
201
223
  });
224
+
225
+ // ── readQuietStartup ─────────────────────────────────────────────────
226
+ //
227
+ // pi 1.x's `quietStartup` is what decides how much of the startup screen the
228
+ // splash may draw: `false` = header + details, `"header"` = header alone,
229
+ // `true` = nothing. These are pi's own semantics, mirrored — a value that is
230
+ // not one of the three (including an absent key, whose pi default is `false`)
231
+ // reads as `false`.
232
+
233
+ describe("readQuietStartup", () => {
234
+ beforeEach(() => {
235
+ vi.clearAllMocks();
236
+ });
237
+
238
+ const answering = (settings: unknown) => {
239
+ vi.mocked(readEffectiveSettings).mockReturnValue(settings as Record<string, unknown>);
240
+ return readQuietStartup({});
241
+ };
242
+
243
+ it("reads each of the three values pi accepts", () => {
244
+ expect(answering({ quietStartup: false })).toBe(false);
245
+ expect(answering({ quietStartup: true })).toBe(true);
246
+ expect(answering({ quietStartup: "header" })).toBe("header");
247
+ });
248
+
249
+ it("reads false when the key is absent or unusable", () => {
250
+ expect(answering({})).toBe(false);
251
+ expect(answering({ quietStartup: "false" })).toBe(false);
252
+ expect(answering({ quietStartup: 0 })).toBe(false);
253
+ expect(answering({ quietStartup: null })).toBe(false);
254
+ });
255
+
256
+ it("passes the pi handle through — getSettings() lives on ExtensionAPI, not the context", () => {
257
+ const pi = {};
258
+ vi.mocked(readEffectiveSettings).mockReturnValue({});
259
+ readQuietStartup(pi);
260
+ expect(readEffectiveSettings).toHaveBeenCalledWith(pi);
261
+ });
262
+ });
package/src/config.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { loadConfig, saveConfig } from "@pi-archimedes/core/settings-io";
1
+ import { loadConfig, saveConfig, readEffectiveSettings } from "@pi-archimedes/core/settings-io";
2
2
 
3
3
  export const ANIMATION_STYLES = [
4
4
  "diagonal",
@@ -86,6 +86,12 @@ export interface UIConfig {
86
86
  codeUnindent: boolean;
87
87
  labelText: string;
88
88
  labelColor: string;
89
+ /**
90
+ * Opt in to the Archimedes startup splash (logo reveal + `[Version]`/`[Models]`
91
+ * rows). Default **off**: pi owns the startup screen unless the user asks.
92
+ * See the truth table in `index.ts` for how this and `quietStartup` interact.
93
+ */
94
+ startupSplash: boolean;
89
95
  animationStyle: AnimationStyle;
90
96
  colorfulLogo: boolean;
91
97
  editorSpinBorder: boolean;
@@ -106,6 +112,9 @@ export const DEFAULT_UI_CONFIG: UIConfig = {
106
112
  codeUnindent: true,
107
113
  labelText: "Thinking...",
108
114
  labelColor: "255,215,0",
115
+ // Off by default: the splash stays in the codebase, pi's own startup is what
116
+ // a user sees unless they turn it on (`/archimedes` → Startup Splash).
117
+ startupSplash: false,
109
118
  animationStyle: "vertical-up",
110
119
  colorfulLogo: false,
111
120
  editorSpinBorder: true,
@@ -126,6 +135,41 @@ export function loadUIConfig(): UIConfig {
126
135
  return loadConfig(NAMESPACE, DEFAULT_UI_CONFIG);
127
136
  }
128
137
 
138
+ /**
139
+ * Write the whole `archimedes.ui` namespace (core's `saveConfig` replaces the
140
+ * section, it does not merge into it), so every key the caller holds lands —
141
+ * including any this package has not heard of. A new field therefore needs no
142
+ * handling here: it arrives on the `UIConfig` the `/archimedes` panel hands
143
+ * back, and `loadUIConfig()` reads it back merged over `DEFAULT_UI_CONFIG` (an
144
+ * absent key means the default, which is how `startupSplash: false` works for a
145
+ * settings file written before the key existed).
146
+ */
129
147
  export function saveUIConfig(config: UIConfig): void {
130
148
  saveConfig(NAMESPACE, config);
131
149
  }
150
+
151
+ /**
152
+ * pi's own `quietStartup`, normalised to the three answers pi acts on.
153
+ *
154
+ * pi's semantics (`shouldShowStartupHeader()` / `shouldShowStartupDetails()` in
155
+ * its interactive mode): the startup header is hidden only by `true`; the
156
+ * startup *details* — the `Model scope:` line and the loaded-resource listing —
157
+ * are shown only by `false`. `"header"` is the middle case: keep the header,
158
+ * drop the details. Anything else, including an absent key (pi's default is
159
+ * `false`), reads as `false`.
160
+ *
161
+ * Read through `readEffectiveSettings`, so a project-scoped `quietStartup` and
162
+ * any in-process override count — the same object pi applies. One input is out
163
+ * of reach: pi's verbose-startup CLI flag overrides the setting for pi's own
164
+ * rendering and is not exposed to extensions, so on such a run the splash is
165
+ * quieter than pi's output. Taking the `pi` handle (not `ctx`) is required —
166
+ * `getSettings()` lives on `ExtensionAPI`.
167
+ *
168
+ * Only consulted when `startupSplash` is on (see the gate in `index.ts`): with
169
+ * the splash off this package installs no header at all and has no use for the
170
+ * answer.
171
+ */
172
+ export function readQuietStartup(pi: unknown): boolean | "header" {
173
+ const value = (readEffectiveSettings(pi) as { quietStartup?: unknown }).quietStartup;
174
+ return value === true ? true : value === "header" ? "header" : false;
175
+ }
@@ -20,7 +20,11 @@ const widthProbe = vi.hoisted(() => ({
20
20
  probe: (s: string): number => s.length,
21
21
  }));
22
22
 
23
- vi.mock("@earendil-works/pi-tui", () => ({
23
+ // `foregroundAnsi` comes from the real module: `core/chrome.ts` asks the host's
24
+ // own colour emitter to turn a token's `Color` into a foreground escape, so
25
+ // stubbing it here would make the slab-colour assertions below tautological.
26
+ vi.mock("@earendil-works/pi-tui", async (importOriginal) => ({
27
+ ...(await importOriginal<typeof import("@earendil-works/pi-tui")>()),
24
28
  truncateToWidth: (s: string, _w: number, _pad?: string, _incl?: boolean) => s,
25
29
  isKeyRelease: () => false,
26
30
  visibleWidth: (s: string) => widthProbe.probe!(s),
@@ -95,6 +99,8 @@ const stubTheme = {
95
99
 
96
100
  interface ConstructOpts {
97
101
  spin?: boolean;
102
+ /** The theme `getTheme()` hands the palette resolver; defaults to `stubTheme`. */
103
+ theme?: Theme;
98
104
  /** The `editorSpinSpeed` setting; × multiplies the style's native per-tick tempo (× 1.5 / × 1 / × 0.6), 32 ms tick floor. */
99
105
  spinSpeed?: "slow" | "normal" | "fast";
100
106
  /** The `editorSpinStyle` setting (raw setting string tolerated; a style not registered in `SPIN_VARIANTS` yet (batch 4) normalizes to typing frames — the fallback, which stays typing even though the default setting is now pendulum). */
@@ -123,7 +129,7 @@ function makeEditor(opts: ConstructOpts = {}): {
123
129
  ? opts.onSpinInterval
124
130
  : vi.fn();
125
131
  const editor = new HephaestusEditor(tui, editorTheme, keybindings, {
126
- getTheme: () => stubTheme,
132
+ getTheme: () => opts.theme ?? stubTheme,
127
133
  isIdle: () => (seq.length > 0 ? (seq.shift() as boolean) : true),
128
134
  shutdown: vi.fn(),
129
135
  spin: opts.spin,
@@ -818,3 +824,113 @@ describe("EAW terminal fallback", () => {
818
824
  }
819
825
  });
820
826
  });
827
+
828
+ // ── 6. Edge rows: a cushion in the panel's own colour, or nothing at all ───
829
+
830
+ describe("editor edge rows", () => {
831
+ /**
832
+ * The shape pi 1.2.0's DEFAULT `system` theme answers with on a terminal that
833
+ * did not report a background (`indexedColors` in pi's system-theme.js gives
834
+ * every panel token `""`, and the neutral foreground tokens `""` plus SGR 2):
835
+ * measured against the installed package, not written from the prose —
836
+ * `getBgAnsi("userMessageBg")` is `"\x1b[49m"` and `getFgAnsi("borderMuted")`
837
+ * is `"\x1b[39m\x1b[2m"`. `resolvePalette` reads both as *no colour*, so the
838
+ * palette hands the editor `panelBg: ""` / `panelEdge: ""`.
839
+ */
840
+ const terminalDefaultTheme = {
841
+ appearance: "dark",
842
+ fg: (_key: string, text: string) => text,
843
+ getBgAnsi: () => "\x1b[49m",
844
+ getFgAnsi: () => "\x1b[39m\x1b[2m",
845
+ } as unknown as Theme;
846
+
847
+ /**
848
+ * A theme that gives the panel a tint, shaped like the installed dark theme:
849
+ * `userMessageBg: "#213b49"` — so `theme.colors.userMessageBg` is that rgb —
850
+ * and a *different* `borderMuted: "#768186"`. Having the two colours far apart
851
+ * is the point: the slab must pick the panel's own, or the editor grows a grey
852
+ * rule hugging the text area.
853
+ */
854
+ const tintedTheme = {
855
+ appearance: "dark",
856
+ fg: (_key: string, text: string) => text,
857
+ getBgAnsi: (key: string) =>
858
+ key === "userMessageBg" ? "\x1b[48;2;33;59;73m" : "\x1b[49m",
859
+ getFgAnsi: (key: string) =>
860
+ key === "borderMuted" ? "\x1b[38;2;118;129;134m" : "\x1b[39m",
861
+ colors: { userMessageBg: { kind: "rgb", r: 33, g: 59, b: 73 } },
862
+ getColorMode: () => "truecolor",
863
+ } as unknown as Theme;
864
+
865
+ /** The panel's own tint and the theme's border, as foreground escapes. */
866
+ const PANEL_TINT = "\x1b[38;2;33;59;73m";
867
+ const BORDER_MUTED = "\x1b[38;2;118;129;134m";
868
+
869
+ const SLABS = ["▁", "▔"];
870
+
871
+ it("paints no slab glyphs when the palette has no edge colour", () => {
872
+ // `panelEdge: ""` documents "no edge". Prepending nothing to a row of `▁`
873
+ // does not remove the row: the glyphs then paint in the terminal's OWN
874
+ // foreground, so the default theme on a dark terminal got bright bars above
875
+ // and below the editor — the opposite of "no colour".
876
+ const { editor } = makeEditor({ theme: terminalDefaultTheme });
877
+ const lines = editor.render(60);
878
+ for (const line of lines) {
879
+ for (const slab of SLABS) {
880
+ expect(line, JSON.stringify(line)).not.toContain(slab);
881
+ }
882
+ }
883
+ });
884
+
885
+ it("blanks the rows when the panel IS tinted but the edge has no colour", () => {
886
+ // The realistic degradation, not the empty-theme case: a modern theme (so a
887
+ // real panel tint) on a host that cannot render a colour as a foreground.
888
+ // `panelEdge` is "" while `panelBg` is a colour, so what these rows do is
889
+ // decided by the edge alone — painting on the body's colour instead would
890
+ // put a truecolour background behind the glyphs, and dropping the rows
891
+ // outright would break the frame's height.
892
+ const { colors: _noColors, getColorMode: _noMode, ...noForeground } =
893
+ tintedTheme as unknown as Record<string, unknown>;
894
+ const lines = makeEditor({
895
+ theme: noForeground as unknown as Theme,
896
+ }).editor.render(60);
897
+ expect(plain(lines[0]!)).toBe(" ".repeat(60));
898
+ expect(plain(lines[lines.length - 1]!)).toBe(" ".repeat(60));
899
+ for (const slab of SLABS) {
900
+ expect(lines[0]!).not.toContain(slab);
901
+ expect(lines[lines.length - 1]!).not.toContain(slab);
902
+ }
903
+ // The body kept its tint: only the cushion gave up.
904
+ expect(lines[1]).toContain("\x1b[48;2;33;59;73m");
905
+ });
906
+
907
+ it("paints the slab rows in the panel's OWN colour, so they vanish into the panel", () => {
908
+ const { editor } = makeEditor({ theme: tintedTheme });
909
+ const lines = editor.render(60);
910
+ // The slab sits directly against the panel body, so painted in the panel's
911
+ // own tint it is invisible by construction — the colour cushion it was
912
+ // invented for.
913
+ expect(lines[0]).toContain(PANEL_TINT);
914
+ expect(lines[lines.length - 1]).toContain(PANEL_TINT);
915
+ // And NOT in the border token, which is what turned the cushion into a
916
+ // visible grey rule hugging the text area.
917
+ expect(lines[0]).not.toContain(BORDER_MUTED);
918
+ expect(lines[lines.length - 1]).not.toContain(BORDER_MUTED);
919
+ // The colour source is not allowed to move anything: same glyphs, same run.
920
+ expect(plain(lines[0]!)).toBe("▁".repeat(60));
921
+ expect(plain(lines[lines.length - 1]!)).toBe("▔".repeat(60));
922
+ });
923
+
924
+ it("keeps the row count identical either way (blank row, not a removed row)", () => {
925
+ const painted = makeEditor({ theme: tintedTheme }).editor.render(60);
926
+ const blank = makeEditor({ theme: terminalDefaultTheme }).editor.render(60);
927
+ // The frame's height is a layout contract: the edge rows go blank, they do
928
+ // not disappear.
929
+ expect(blank).toHaveLength(painted.length);
930
+ expect(plain(blank[0]!)).toBe(" ".repeat(60));
931
+ expect(plain(blank[blank.length - 1]!)).toBe(" ".repeat(60));
932
+ // Which row carries the frame is part of the same contract.
933
+ expect(plain(painted[1]!).trim().startsWith("┌")).toBe(true);
934
+ expect(plain(blank[1]!).trim().startsWith("┌")).toBe(true);
935
+ });
936
+ });
@@ -22,6 +22,7 @@ import {
22
22
  resolvePalette,
23
23
  } from "@pi-archimedes/core/chrome";
24
24
  import { isParentBorder, formatKey } from "@pi-archimedes/core/text";
25
+ import { diag, describeError } from "@pi-archimedes/core/log";
25
26
  import { SPIN_INTERVALS, BorderTypeSpinner } from "./spin.js";
26
27
  export { spinFrame } from "./spin.js";
27
28
  import { pickQuip, QUIP_ROTATION_MAX_SECS, QUIP_ROTATION_MIN_SECS } from "./spin-quips.js";
@@ -82,6 +83,19 @@ export class HephaestusEditor extends CustomEditor {
82
83
  onSpinInterval,
83
84
  }: {
84
85
  getTheme: () => Theme;
86
+ /**
87
+ * Whether the agent is idle right now — the only source of busy/idle
88
+ * state for the spinner and the quip. It is injected rather than read
89
+ * from a stored renderer so a screen-mode switch cannot strand it.
90
+ *
91
+ * pi 1.2.0 semantics, checked rather than assumed: `ctx.isIdle()` turns
92
+ * false at `before_agent_start`, i.e. when a run begins — including one
93
+ * an extension started with `pi.sendMessage(…, { triggerTurn: true })` —
94
+ * not when the first token arrives. That is the *Busy episode* boundary
95
+ * in `CONTEXT.md` (idle → working → idle), so the spinner starting on the
96
+ * run's first tick is what the term promises; there is no early-start
97
+ * bug to correct here.
98
+ */
85
99
  isIdle: () => boolean;
86
100
  shutdown: () => void;
87
101
  /** Type a 4-cell spinner window into the editor's top border while the agent is busy (the animation mechanism lives in `BorderTypeSpinner`, `./spin.js`): the style-configured 4-cell window (the gallery-derived styles in `SPIN_VARIANTS` — the 2×4 braille dot block (⠁ → ⣿, Unicode chart order, `typing`) grows cell-by-cell left→right — each cell walking the 8 stages in 2-step line pairs (⠁⠉/⠋⠛/⠟⠿/⡿⣿) — then holds, clears, repeats (in EAW terminals the stage set falls back to the width-1 shading ░ → █; raw setting strings are tolerated — unknown names normalize to typing frames). */
@@ -368,12 +382,29 @@ export class HephaestusEditor extends CustomEditor {
368
382
  return p.panelBg + pad + patched + pad + RESET;
369
383
  };
370
384
 
371
- const topEdge = p.panelEdge + "▁".repeat(width) + RESET;
372
- const botEdge = p.panelEdge + "▔".repeat(width) + RESET;
385
+ // The slab rows are the panel's own colour, extending its tint flush to
386
+ // the frame (see `resolvePalette`: the edge is the panel token asked for
387
+ // as a foreground, never a border token — a border colour here is a
388
+ // visible rule hugging the text area).
389
+ //
390
+ // No edge colour means the theme declined a panel colour entirely (a
391
+ // terminal-default token) or cannot answer its own token as a foreground.
392
+ // Either way an un-prefixed row of `▁`/`▔` would paint those glyphs in the
393
+ // terminal's own FOREGROUND — a hard, bright bar above and below the
394
+ // editor (what pi's default `system` theme on a terminal that reported no
395
+ // background produces). No colour, no slab: the rows go blank rather than
396
+ // disappearing, because the frame's height is a layout contract.
397
+ const edgeRow = (glyph: string): string =>
398
+ p.panelEdge ? p.panelEdge + glyph.repeat(width) + RESET : " ".repeat(width);
399
+ const topEdge = edgeRow("▁");
400
+ const botEdge = edgeRow("▔");
373
401
 
374
402
  return [topEdge, ...raw.map(wrap), botEdge];
375
403
  } catch (e) {
376
- console.error("HephaestusEditor render error:", e);
404
+ // Re-thrown, so pi's own error handling still sees it — and logged with
405
+ // its stack, because a console write here would land inside the frame
406
+ // being rendered and the log is the only record that survives.
407
+ diag("ui", `HephaestusEditor render error: ${describeError(e)}`);
377
408
  throw e;
378
409
  }
379
410
  }
@@ -20,7 +20,7 @@ export const SPIN_QUIPS: readonly string[] = Object.freeze([
20
20
  "Collapsing quantum wavefunctions just by looking at them...",
21
21
  "Hunting for rogue gravitons inside the server rack...",
22
22
  "Paging memory to disk because we refused to optimize...",
23
- "Optimizing the optimizer so it can optimize faster...",
23
+ "Optimizing the optimizer so it can optimize optimially...",
24
24
  "Searching the entire keyboard for the legendary 'Any' key...",
25
25
  "Reticulating multi-dimensional splines...",
26
26
  "Running gradient ascent until we reach enlightenment...",