@pi-archimedes/core 0.7.0 → 0.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-archimedes/core",
3
- "version": "0.7.0",
3
+ "version": "0.9.0",
4
4
  "type": "module",
5
5
  "keywords": [
6
6
  "pi-package"
@@ -16,7 +16,8 @@
16
16
  "./chrome": "./src/chrome.ts",
17
17
  "./text": "./src/text.ts",
18
18
  "./color": "./src/color.ts",
19
- "./config": "./src/config.ts"
19
+ "./config": "./src/config.ts",
20
+ "./settings-io": "./src/settings-io.ts"
20
21
  },
21
22
  "peerDependencies": {
22
23
  "@earendil-works/pi-coding-agent": ">=0.1.0",
package/src/color.ts CHANGED
@@ -1,177 +1,177 @@
1
1
  // HSL / RGB / ANSI color utilities. Pure: no external imports.
2
2
 
3
3
  export interface RGB {
4
- r: number;
5
- g: number;
6
- b: number;
4
+ r: number;
5
+ g: number;
6
+ b: number;
7
7
  } // each 0..255 integer
8
8
 
9
9
  export interface HSL {
10
- h: number;
11
- s: number;
12
- l: number;
10
+ h: number;
11
+ s: number;
12
+ l: number;
13
13
  } // h: 0..360, s/l: 0..1
14
14
 
15
15
  // xterm standard 16-color palette (ANSI codes 0..15).
16
16
  // Source: https://en.wikipedia.org/wiki/ANSI_escape_code#3-bit_and_4-bit
17
17
  const XTERM_16: readonly RGB[] = [
18
- { r: 0x00, g: 0x00, b: 0x00 }, // 0 black
19
- { r: 0x80, g: 0x00, b: 0x00 }, // 1 maroon
20
- { r: 0x00, g: 0x80, b: 0x00 }, // 2 green
21
- { r: 0x80, g: 0x80, b: 0x00 }, // 3 olive
22
- { r: 0x00, g: 0x00, b: 0x80 }, // 4 navy
23
- { r: 0x80, g: 0x80, b: 0x80 }, // 5 purple
24
- { r: 0x00, g: 0x80, b: 0x80 }, // 6 teal
25
- { r: 0xc0, g: 0xc0, b: 0xc0 }, // 7 silver
26
- { r: 0x80, g: 0x80, b: 0x80 }, // 8 grey
27
- { r: 0xff, g: 0x00, b: 0x00 }, // 9 red
28
- { r: 0x00, g: 0xff, b: 0x00 }, // 10 lime
29
- { r: 0xff, g: 0xff, b: 0x00 }, // 11 yellow
30
- { r: 0x00, g: 0x00, b: 0xff }, // 12 blue
31
- { r: 0xff, g: 0x00, b: 0xff }, // 13 fuchsia
32
- { r: 0x00, g: 0xff, b: 0xff }, // 14 aqua
33
- { r: 0xff, g: 0xff, b: 0xff }, // 15 white
18
+ { r: 0x00, g: 0x00, b: 0x00 }, // 0 black
19
+ { r: 0x80, g: 0x00, b: 0x00 }, // 1 maroon
20
+ { r: 0x00, g: 0x80, b: 0x00 }, // 2 green
21
+ { r: 0x80, g: 0x80, b: 0x00 }, // 3 olive
22
+ { r: 0x00, g: 0x00, b: 0x80 }, // 4 navy
23
+ { r: 0x80, g: 0x80, b: 0x80 }, // 5 purple
24
+ { r: 0x00, g: 0x80, b: 0x80 }, // 6 teal
25
+ { r: 0xc0, g: 0xc0, b: 0xc0 }, // 7 silver
26
+ { r: 0x80, g: 0x80, b: 0x80 }, // 8 grey
27
+ { r: 0xff, g: 0x00, b: 0x00 }, // 9 red
28
+ { r: 0x00, g: 0xff, b: 0x00 }, // 10 lime
29
+ { r: 0xff, g: 0xff, b: 0x00 }, // 11 yellow
30
+ { r: 0x00, g: 0x00, b: 0xff }, // 12 blue
31
+ { r: 0xff, g: 0x00, b: 0xff }, // 13 fuchsia
32
+ { r: 0x00, g: 0xff, b: 0xff }, // 14 aqua
33
+ { r: 0xff, g: 0xff, b: 0xff }, // 15 white
34
34
  ];
35
35
 
36
36
  // 6x6x6 cube levels for ANSI 16..231.
37
37
  const CUBE_LEVELS: readonly number[] = [0, 95, 135, 175, 215, 255];
38
38
 
39
39
  const clamp = (v: number, lo: number, hi: number): number =>
40
- v < lo ? lo : v > hi ? hi : v;
40
+ v < lo ? lo : v > hi ? hi : v;
41
41
 
42
42
  const toByte = (v: number): number => clamp(Math.round(v), 0, 255);
43
43
 
44
44
  export function hexToRgb(hex: string): RGB {
45
- let s = hex.trim();
46
- if (s.startsWith("#")) s = s.slice(1);
47
- if (s.length === 3) {
48
- s = s[0]! + s[0]! + s[1]! + s[1]! + s[2]! + s[2]!;
49
- }
50
- if (s.length !== 6 || !/^[0-9a-fA-F]{6}$/.test(s)) {
51
- throw new Error(`Invalid hex color: ${hex}`);
52
- }
53
- const n = parseInt(s, 16);
54
- return {
55
- r: (n >> 16) & 0xff,
56
- g: (n >> 8) & 0xff,
57
- b: n & 0xff,
58
- };
45
+ let s = hex.trim();
46
+ if (s.startsWith("#")) s = s.slice(1);
47
+ if (s.length === 3) {
48
+ s = s[0]! + s[0]! + s[1]! + s[1]! + s[2]! + s[2]!;
49
+ }
50
+ if (s.length !== 6 || !/^[0-9a-fA-F]{6}$/.test(s)) {
51
+ throw new Error(`Invalid hex color: ${hex}`);
52
+ }
53
+ const n = parseInt(s, 16);
54
+ return {
55
+ r: (n >> 16) & 0xff,
56
+ g: (n >> 8) & 0xff,
57
+ b: n & 0xff,
58
+ };
59
59
  }
60
60
 
61
61
  export function rgbToHex(rgb: RGB): string {
62
- const hex = (v: number) => toByte(v).toString(16).padStart(2, "0");
63
- return `#${hex(rgb.r)}${hex(rgb.g)}${hex(rgb.b)}`;
62
+ const hex = (v: number) => toByte(v).toString(16).padStart(2, "0");
63
+ return `#${hex(rgb.r)}${hex(rgb.g)}${hex(rgb.b)}`;
64
64
  }
65
65
 
66
66
  export function rgbToHsl(rgb: RGB): HSL {
67
- const r = rgb.r / 255;
68
- const g = rgb.g / 255;
69
- const b = rgb.b / 255;
70
- const max = Math.max(r, g, b);
71
- const min = Math.min(r, g, b);
72
- const l = (max + min) / 2;
73
- let h = 0;
74
- let s = 0;
75
- const d = max - min;
76
- if (d !== 0) {
77
- s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
78
- switch (max) {
79
- case r:
80
- h = ((g - b) / d + (g < b ? 6 : 0)) * 60;
81
- break;
82
- case g:
83
- h = ((b - r) / d + 2) * 60;
84
- break;
85
- default:
86
- h = ((r - g) / d + 4) * 60;
87
- break;
88
- }
89
- }
90
- return { h, s, l };
67
+ const r = rgb.r / 255;
68
+ const g = rgb.g / 255;
69
+ const b = rgb.b / 255;
70
+ const max = Math.max(r, g, b);
71
+ const min = Math.min(r, g, b);
72
+ const l = (max + min) / 2;
73
+ let h = 0;
74
+ let s = 0;
75
+ const d = max - min;
76
+ if (d !== 0) {
77
+ s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
78
+ switch (max) {
79
+ case r:
80
+ h = ((g - b) / d + (g < b ? 6 : 0)) * 60;
81
+ break;
82
+ case g:
83
+ h = ((b - r) / d + 2) * 60;
84
+ break;
85
+ default:
86
+ h = ((r - g) / d + 4) * 60;
87
+ break;
88
+ }
89
+ }
90
+ return { h, s, l };
91
91
  }
92
92
 
93
93
  export function hslToRgb(hsl: HSL): RGB {
94
- const { h, s, l } = hsl;
95
- if (s === 0) {
96
- const v = toByte(l * 255);
97
- return { r: v, g: v, b: v };
98
- }
99
- const q = l < 0.5 ? l * (1 + s) : l + s - l * s;
100
- const p = 2 * l - q;
101
- const hk = (((h % 360) + 360) % 360) / 360;
102
- const hue2rgb = (t: number): number => {
103
- let tt = t;
104
- if (tt < 0) tt += 1;
105
- if (tt > 1) tt -= 1;
106
- if (tt < 1 / 6) return p + (q - p) * 6 * tt;
107
- if (tt < 1 / 2) return q;
108
- if (tt < 2 / 3) return p + (q - p) * (2 / 3 - tt) * 6;
109
- return p;
110
- };
111
- return {
112
- r: toByte(hue2rgb(hk + 1 / 3) * 255),
113
- g: toByte(hue2rgb(hk) * 255),
114
- b: toByte(hue2rgb(hk - 1 / 3) * 255),
115
- };
94
+ const { h, s, l } = hsl;
95
+ if (s === 0) {
96
+ const v = toByte(l * 255);
97
+ return { r: v, g: v, b: v };
98
+ }
99
+ const q = l < 0.5 ? l * (1 + s) : l + s - l * s;
100
+ const p = 2 * l - q;
101
+ const hk = (((h % 360) + 360) % 360) / 360;
102
+ const hue2rgb = (t: number): number => {
103
+ let tt = t;
104
+ if (tt < 0) tt += 1;
105
+ if (tt > 1) tt -= 1;
106
+ if (tt < 1 / 6) return p + (q - p) * 6 * tt;
107
+ if (tt < 1 / 2) return q;
108
+ if (tt < 2 / 3) return p + (q - p) * (2 / 3 - tt) * 6;
109
+ return p;
110
+ };
111
+ return {
112
+ r: toByte(hue2rgb(hk + 1 / 3) * 255),
113
+ g: toByte(hue2rgb(hk) * 255),
114
+ b: toByte(hue2rgb(hk - 1 / 3) * 255),
115
+ };
116
116
  }
117
117
 
118
118
  export function ansi256ToRgb(code: number): RGB {
119
- const c = Math.floor(code);
120
- if (c < 0 || c > 255) {
121
- throw new Error(`ANSI 256 code out of range: ${code}`);
122
- }
123
- if (c < 16) {
124
- return { ...XTERM_16[c]! };
125
- }
126
- if (c < 232) {
127
- const idx = c - 16;
128
- const r = CUBE_LEVELS[Math.floor(idx / 36) % 6]!;
129
- const g = CUBE_LEVELS[Math.floor(idx / 6) % 6]!;
130
- const b = CUBE_LEVELS[idx % 6]!;
131
- return { r, g, b };
132
- }
133
- const v = 8 + (c - 232) * 10;
134
- return { r: v, g: v, b: v };
119
+ const c = Math.floor(code);
120
+ if (c < 0 || c > 255) {
121
+ throw new Error(`ANSI 256 code out of range: ${code}`);
122
+ }
123
+ if (c < 16) {
124
+ return { ...XTERM_16[c]! };
125
+ }
126
+ if (c < 232) {
127
+ const idx = c - 16;
128
+ const r = CUBE_LEVELS[Math.floor(idx / 36) % 6]!;
129
+ const g = CUBE_LEVELS[Math.floor(idx / 6) % 6]!;
130
+ const b = CUBE_LEVELS[idx % 6]!;
131
+ return { r, g, b };
132
+ }
133
+ const v = 8 + (c - 232) * 10;
134
+ return { r: v, g: v, b: v };
135
135
  }
136
136
 
137
137
  export function parseAnsiFgToRgb(ansi: string): RGB | null {
138
- if (!ansi) return null;
139
- // Match a leading CSI SGR sequence: ESC [ params m
140
- const tc = /^\x1b\[38;2;(\d{1,3});(\d{1,3});(\d{1,3})m/.exec(ansi);
141
- if (tc) {
142
- return {
143
- r: clamp(parseInt(tc[1]!, 10), 0, 255),
144
- g: clamp(parseInt(tc[2]!, 10), 0, 255),
145
- b: clamp(parseInt(tc[3]!, 10), 0, 255),
146
- };
147
- }
148
- const palette = /^\x1b\[38;5;(\d{1,3})m/.exec(ansi);
149
- if (palette) {
150
- const code = parseInt(palette[1]!, 10);
151
- if (code < 0 || code > 255) return null;
152
- return ansi256ToRgb(code);
153
- }
154
- return null;
138
+ if (!ansi) return null;
139
+ // Match a leading CSI SGR sequence: ESC [ params m
140
+ const tc = /^\x1b\[38;2;(\d{1,3});(\d{1,3});(\d{1,3})m/.exec(ansi);
141
+ if (tc) {
142
+ return {
143
+ r: clamp(parseInt(tc[1]!, 10), 0, 255),
144
+ g: clamp(parseInt(tc[2]!, 10), 0, 255),
145
+ b: clamp(parseInt(tc[3]!, 10), 0, 255),
146
+ };
147
+ }
148
+ const palette = /^\x1b\[38;5;(\d{1,3})m/.exec(ansi);
149
+ if (palette) {
150
+ const code = parseInt(palette[1]!, 10);
151
+ if (code < 0 || code > 255) return null;
152
+ return ansi256ToRgb(code);
153
+ }
154
+ return null;
155
155
  }
156
156
 
157
157
  export function deriveDimColor(
158
- input: string | number,
159
- anchorLightness: number,
160
- saturationFactor?: number,
158
+ input: string | number,
159
+ anchorLightness: number,
160
+ saturationFactor?: number,
161
161
  ): string {
162
- const rgb = typeof input === "number" ? ansi256ToRgb(input) : hexToRgb(input);
163
- const hsl = rgbToHsl(rgb);
164
- const factor = saturationFactor ?? 0.5;
165
- const target: HSL = {
166
- h: hsl.h,
167
- s: hsl.s * factor,
168
- l: Math.min(hsl.l, anchorLightness),
169
- };
170
- return rgbToHex(hslToRgb(target));
162
+ const rgb = typeof input === "number" ? ansi256ToRgb(input) : hexToRgb(input);
163
+ const hsl = rgbToHsl(rgb);
164
+ const factor = saturationFactor ?? 0.5;
165
+ const target: HSL = {
166
+ h: hsl.h,
167
+ s: hsl.s * factor,
168
+ l: Math.min(hsl.l, anchorLightness),
169
+ };
170
+ return rgbToHex(hslToRgb(target));
171
171
  }
172
172
 
173
173
  export function rgbToTruecolorFg(rgb: RGB): string {
174
- return `\x1b[38;2;${toByte(rgb.r)};${toByte(rgb.g)};${toByte(rgb.b)}m`;
174
+ return `\x1b[38;2;${toByte(rgb.r)};${toByte(rgb.g)};${toByte(rgb.b)}m`;
175
175
  }
176
176
 
177
177
  // ── Color helpers (from utils/ansi.ts) ───────────────────────────────────────
package/src/config.ts CHANGED
@@ -1,6 +1,4 @@
1
- import { readFileSync, writeFileSync, existsSync } from "node:fs";
2
- import { join } from "node:path";
3
- import { getAgentDir } from "@earendil-works/pi-coding-agent";
1
+ import { loadConfig, saveConfig } from "./settings-io.js";
4
2
 
5
3
  export const ANIMATION_STYLES = [
6
4
  "diagonal",
@@ -23,8 +21,6 @@ export interface CoreConfig {
23
21
  animationStyle: AnimationStyle;
24
22
  }
25
23
 
26
- const SETTINGS_PATH = join(getAgentDir(), "settings.json");
27
-
28
24
  export const DEFAULT_CORE_CONFIG: CoreConfig = {
29
25
  mutedTheme: false,
30
26
  codeUnindent: true,
@@ -33,27 +29,12 @@ export const DEFAULT_CORE_CONFIG: CoreConfig = {
33
29
  animationStyle: "vertical-up",
34
30
  };
35
31
 
32
+ const NAMESPACE = "archimedes.core";
33
+
36
34
  export function loadCoreConfig(): CoreConfig {
37
- if (existsSync(SETTINGS_PATH)) {
38
- try {
39
- const full = JSON.parse(readFileSync(SETTINGS_PATH, "utf-8"));
40
- return { ...DEFAULT_CORE_CONFIG, ...(full["archimedes.core"] ?? {}) };
41
- } catch {
42
- /* ignore corrupt file */
43
- }
44
- }
45
- return DEFAULT_CORE_CONFIG;
35
+ return loadConfig(NAMESPACE, DEFAULT_CORE_CONFIG);
46
36
  }
47
37
 
48
38
  export function saveCoreConfig(config: CoreConfig): void {
49
- let full: Record<string, unknown> = {};
50
- if (existsSync(SETTINGS_PATH)) {
51
- try {
52
- full = JSON.parse(readFileSync(SETTINGS_PATH, "utf-8"));
53
- } catch {
54
- /* ignore corrupt file */
55
- }
56
- }
57
- full["archimedes.core"] = config;
58
- writeFileSync(SETTINGS_PATH, JSON.stringify(full, null, 2), "utf-8");
39
+ saveConfig(NAMESPACE, config);
59
40
  }
package/src/index.ts CHANGED
@@ -5,12 +5,15 @@ import { TUI, type EditorTheme, type Component, type SettingItem } from "@earend
5
5
  import { HephaestusEditor } from "./editor/index.js";
6
6
 
7
7
  import { renderHeader, patchStartupListing, type ListingRef } from "./startup/index.js";
8
- import { patchConsoleLog } from "./startup/capture.js";
8
+ import { patchConsoleLog, unpatchConsoleLog } from "./startup/capture.js";
9
9
  import { patchThinkingRenderer } from "./thinking/patch.js";
10
10
  import { transformThinkingContent } from "./thinking/transform.js";
11
11
  import { loadCoreConfig, saveCoreConfig, DEFAULT_CORE_CONFIG, ANIMATION_STYLES, type CoreConfig } from "./config.js";
12
12
  import { initBus } from "./bus.js";
13
13
 
14
+ // Re-export for session lifecycle management
15
+ export { unpatchConsoleLog } from "./startup/capture.js";
16
+
14
17
  // ── Settings items ────────────────────────────────────────────────────────
15
18
 
16
19
  export function getCoreSettingsItems(config: CoreConfig): SettingItem[] {
@@ -69,6 +72,27 @@ export function registerCore(pi: ExtensionAPI): void {
69
72
  const listingRef = g["listingRef"] as ListingRef | undefined;
70
73
  if (listingRef) { listingRef.settled = true; }
71
74
 
75
+ // Restore patched chat.addChild to prevent closure accumulation across reloads
76
+ const ORIG_ADD_CHILD = Symbol.for("splashscreen:origAddChild");
77
+ const PATCHED_LISTING = Symbol.for("splashscreen:listingPatched");
78
+ if (coreCtx) {
79
+ try {
80
+ const tui = (coreCtx.ui as any).tui;
81
+ if (tui?.children) {
82
+ for (const child of tui.children) {
83
+ const cc = child as any;
84
+ if (cc[PATCHED_LISTING] && cc[ORIG_ADD_CHILD]) {
85
+ child.addChild = cc[ORIG_ADD_CHILD];
86
+ cc[PATCHED_LISTING] = false;
87
+ cc[ORIG_ADD_CHILD] = undefined;
88
+ }
89
+ }
90
+ }
91
+ } catch {
92
+ /* TUI structure may have changed — ignore */
93
+ }
94
+ }
95
+
72
96
  // Clear editor component override
73
97
  if (coreCtx) { coreCtx.ui.setEditorComponent(undefined); }
74
98
  });
@@ -0,0 +1,42 @@
1
+ import { readFileSync, writeFileSync, existsSync, renameSync, unlinkSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { getAgentDir } from "@earendil-works/pi-coding-agent";
4
+
5
+ const SETTINGS_PATH = join(getAgentDir(), "settings.json");
6
+
7
+ /** Read the full settings.json, returning empty object if missing/corrupt. */
8
+ function readSettings(): Record<string, unknown> {
9
+ if (!existsSync(SETTINGS_PATH)) return {};
10
+ try {
11
+ return JSON.parse(readFileSync(SETTINGS_PATH, "utf-8"));
12
+ } catch {
13
+ return {};
14
+ }
15
+ }
16
+
17
+ /**
18
+ * Load a config section from settings.json, merged with defaults.
19
+ */
20
+ export function loadConfig<T>(
21
+ namespace: string,
22
+ defaults: T,
23
+ ): T {
24
+ const full = readSettings();
25
+ return { ...defaults, ...(full[namespace] ?? {}) } as T;
26
+ }
27
+
28
+ /**
29
+ * Save a config section to settings.json (atomic: write to .tmp then rename).
30
+ */
31
+ export function saveConfig(namespace: string, config: object): void {
32
+ const full = readSettings();
33
+ full[namespace] = config;
34
+ const tmpPath = SETTINGS_PATH + ".tmp";
35
+ writeFileSync(tmpPath, JSON.stringify(full, null, 2), "utf-8");
36
+ try {
37
+ renameSync(tmpPath, SETTINGS_PATH);
38
+ } catch {
39
+ try { unlinkSync(tmpPath); } catch { /* ignore */ }
40
+ writeFileSync(SETTINGS_PATH, JSON.stringify(full, null, 2), "utf-8");
41
+ }
42
+ }
@@ -4,10 +4,14 @@ const MODEL_SCOPE_RE = /Model scope:\s*(.+)/;
4
4
  export const CAPTURED_MODELS = Symbol.for("splashscreen:capturedModels");
5
5
  export const PATCHED_LOG = Symbol.for("splashscreen:logPatched");
6
6
 
7
+ // Store original console.log for restoration
8
+ let _origLog: typeof console.log | undefined;
9
+
7
10
  export function patchConsoleLog(): void {
8
11
  if (g[PATCHED_LOG]) return;
9
12
  g[PATCHED_LOG] = true;
10
- const origLog = console.log;
13
+ _origLog = console.log;
14
+
11
15
  console.log = (...args: unknown[]) => {
12
16
  try {
13
17
  if (args.length === 1 && typeof args[0] === "string") {
@@ -20,14 +24,24 @@ export function patchConsoleLog(): void {
20
24
  .map((s: string) => s.trim())
21
25
  .filter(Boolean);
22
26
  // Unpatch once captured — no need to keep intercepting
23
- console.log = origLog;
24
- g[PATCHED_LOG] = false;
27
+ unpatchConsoleLog();
25
28
  return;
26
29
  }
27
30
  }
28
- } catch {
29
- /* ignore errors in patching logic */
31
+ } catch (err) {
32
+ // Log the error but don't suppress the original call
33
+ console.error("[archimedes] Error in console.log patch:", err);
30
34
  }
31
- origLog.apply(console, args);
35
+ _origLog!.apply(console, args);
32
36
  };
33
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
+ }
@@ -3,7 +3,6 @@ import { getShinedLogo, TRUECOLOR, LOGO_PAD, LOGO_SETTLE_FRAME } from "./logo.js
3
3
  import { loadCoreConfig } from "../config.js";
4
4
  import { detectSection, parseSectionText, parseModelScope, formatColumns, buildItemWrapper, type ParsedSection, SECTION_KEYS } from "./sections.js";
5
5
  import { fetchLatestVersion, compareVersions } from "./version.js";
6
- import { patchConsoleLog } from "./capture.js";
7
6
  import { stripAnsi } from "../text.js";
8
7
  import { Text, Spacer, Container, TUI, truncateToWidth, visibleWidth, type Component } from "@earendil-works/pi-tui";
9
8
 
@@ -13,6 +12,7 @@ const ANIM_INTERVAL = Symbol.for("splashscreen:animInterval");
13
12
  const DEBOUNCE_TIMER = Symbol.for("splashscreen:debounceTimer");
14
13
  const PATCHED_CLEAR = Symbol.for("splashscreen:clearPatched");
15
14
  const PATCHED_LISTING = Symbol.for("splashscreen:listingPatched");
15
+ const ORIG_ADD_CHILD = Symbol.for("splashscreen:origAddChild");
16
16
 
17
17
  // Animation constants
18
18
  const MAX_RENDER_WIDTH = 9999;
@@ -165,6 +165,9 @@ export function patchStartupListing(
165
165
  ): void {
166
166
  const chat = findChatContainer(tui);
167
167
  if (!chat) {
168
+ // Graceful degradation: if we can't find the chat container,
169
+ // the startup listing won't render but the extension continues.
170
+ console.warn("[archimedes] Could not find chat container — startup listing disabled. This may indicate a pi TUI structure change.");
168
171
  return;
169
172
  }
170
173
  const cc = chat as any;
@@ -232,6 +235,7 @@ export function patchStartupListing(
232
235
  cc[PATCHED_LISTING] = true;
233
236
 
234
237
  const origAddChild = chat.addChild.bind(chat);
238
+ cc[ORIG_ADD_CHILD] = origAddChild; // Store for cleanup on shutdown
235
239
  chat.clear();
236
240
 
237
241
  chat.addChild = (component: Component) => {
@@ -40,26 +40,32 @@ export function parseSectionText(plain: string): ParsedSection | undefined {
40
40
  const sectionName = detectSection(plain);
41
41
  if (!sectionName) return undefined;
42
42
 
43
+ const names = extractItemsFromSection(plain, sectionName);
44
+ const items = deduplicateItems(names);
45
+ return { name: sectionName, items };
46
+ }
47
+
48
+ /**
49
+ * Extract item names from a section's plain text.
50
+ * Tracks source prefixes (npm:/git:) for Extensions/Skills sections.
51
+ */
52
+ function extractItemsFromSection(plain: string, sectionName: SectionKey): string[] {
43
53
  const names: string[] = [];
44
54
  const lines = plain.split("\n");
45
55
  let currentSource = "";
46
56
  let sourceIndent = 0;
57
+ const showSource = sectionName === "Extensions" || sectionName === "Skills";
47
58
 
48
59
  for (const line of lines) {
49
60
  const trimmed = line.trim();
50
- if (!trimmed) continue;
51
- if (trimmed.startsWith("[")) continue;
52
- if (/^(user|project|path)$/.test(trimmed)) { currentSource = ""; sourceIndent = 0; continue; }
61
+ if (shouldSkipLine(trimmed, currentSource)) continue;
53
62
 
54
63
  const indent = line.length - line.trimStart().length;
55
64
 
56
65
  // Track package source headers (e.g. "git:github.com/...", "npm:@foo/bar")
57
- // Extract name from the header itself + let children inherit the prefix
58
66
  if (/^(git:|npm:)\S+\//.test(trimmed)) {
59
67
  currentSource = trimmed.startsWith("git:") ? "git:" : "npm:";
60
68
  sourceIndent = indent;
61
- // Extract name from source header (e.g. "npm:@foo/pi-tavily-tools" → "npm:pi-tavily-tools")
62
- const showSource = sectionName === "Extensions" || sectionName === "Skills";
63
69
  const name = extractName(trimmed, sectionName);
64
70
  if (name && showSource) names.push(name);
65
71
  continue;
@@ -71,27 +77,38 @@ export function parseSectionText(plain: string): ParsedSection | undefined {
71
77
  sourceIndent = 0;
72
78
  }
73
79
 
74
- if (/^(index\.(ts|js)|src|dist|out|build|lib|bin)$/i.test(trimmed)) continue;
75
- // Skip resolved file paths only under source headers (e.g. "dist/index.js" under npm:)
76
- if (currentSource) {
77
- if (/\/(src|dist|out|build|lib|bin)\//.test(trimmed)) continue;
78
- if (/\.(ts|js)$/.test(trimmed) && trimmed.includes("/") && !/SKILL\.(ts|js)$/i.test(trimmed)) continue;
79
- }
80
-
81
80
  const name = extractName(trimmed, sectionName);
82
- // Prompts/Context don't need source prefix — only Extensions/Skills
83
- const showSource = sectionName === "Extensions" || sectionName === "Skills";
84
81
  if (name) names.push(showSource && currentSource ? currentSource + name : name);
85
82
  }
83
+ return names;
84
+ }
85
+
86
+ /** Check if a line should be skipped during section parsing. */
87
+ function shouldSkipLine(trimmed: string, currentSource: string): boolean {
88
+ if (!trimmed) return true;
89
+ if (trimmed.startsWith("[")) return true;
90
+ if (/^(user|project|path)$/.test(trimmed)) return true;
91
+ if (/^(index\.(ts|js)|src|dist|out|build|lib|bin)$/i.test(trimmed)) return true;
92
+ // Skip resolved file paths only under source headers
93
+ if (currentSource) {
94
+ if (/\/(src|dist|out|build|lib|bin)\//.test(trimmed)) return true;
95
+ if (/\.(ts|js)$/.test(trimmed) && trimmed.includes("/") && !/SKILL\.(ts|js)$/i.test(trimmed)) return true;
96
+ }
97
+ return false;
98
+ }
86
99
 
87
- // Deduplicate by bare name (without prefix) — prefer prefixed version
100
+ /**
101
+ * Deduplicate items by bare name (without prefix).
102
+ * Prefers prefixed versions (npm:/git:) over bare names.
103
+ */
104
+ function deduplicateItems(names: string[]): string[] {
88
105
  const seen = new Map<string, string>();
89
106
  for (const n of names) {
90
107
  if (/^(index|dist|src|out|lib|bin)$/i.test(n)) continue;
91
108
  const bare = n.replace(/^(npm:|git:)/, "");
92
109
  if (!seen.has(bare) || n.includes(":")) seen.set(bare, n);
93
110
  }
94
- return { name: sectionName, items: [...seen.values()] };
111
+ return [...seen.values()];
95
112
  }
96
113
 
97
114
  export function parseModelScope(plain: string): ParsedSection | undefined {
package/src/text.ts CHANGED
@@ -3,9 +3,24 @@ import { truncateToWidth } from "@earendil-works/pi-tui";
3
3
  // Inline stripSgr — narrow SGR-only strip (no trim) for char-level checks
4
4
  const stripSgr = (s: string) => s.replace(/\x1b\[[0-9;]*m/g, "");
5
5
 
6
- /** Strip ALL ANSI escapes (SGR color/style + OSC sequences). Trims whitespace. Use for text extraction. */
6
+ /**
7
+ * Strip ALL ANSI escape sequences. Covers:
8
+ * - CSI: ESC [ ... letter (SGR, cursor movement, etc.)
9
+ * - OSC: ESC ] ... ST (operating system commands)
10
+ * - DCS: ESC P ... ST (device control strings)
11
+ * - APC: ESC _ ... ST (application program commands)
12
+ * - SOS: ESC ^ ... ST (start of string)
13
+ * - PM: ESC \x5c ... ST (privacy message)
14
+ * - Character set: ESC ( or ESC ) (DECSET/DECRST)
15
+ * Trims whitespace. Use for text extraction.
16
+ */
7
17
  export function stripAnsi(s: string): string {
8
- return s.replace(/\x1b\[[0-9;]*[a-zA-Z]|\x1b\].*?(?:\x07|\x1b\\)/g, "").trim();
18
+ return s
19
+ .replace(/\x1b\[[0-9;?]*[a-zA-Z]/g, "") // CSI
20
+ .replace(/\x1b\].*?(?:\x07|\x1b\\)/g, "") // OSC
21
+ .replace(/\x1b[P^_\x5c].*?(?:\x07|\x1b\\)/g, "") // DCS, SOS, APC, PM
22
+ .replace(/\x1b[()]/g, "") // character set
23
+ .trim();
9
24
  }
10
25
 
11
26
  /** Clamp a line to maxW visible characters, preserving ANSI escapes. */
@@ -30,10 +30,15 @@ export function patchThinkingRenderer(getTheme: () => Theme): void {
30
30
  }
31
31
 
32
32
  const src = proto.updateContent.toString();
33
- if (
34
- !src.includes('content.type === "thinking"') ||
35
- !src.includes("this.markdownTheme")
36
- ) {
33
+ const hasThinkingCheck = src.includes('content.type === "thinking"');
34
+ const hasMarkdownTheme = src.includes("this.markdownTheme");
35
+ if (!hasThinkingCheck || !hasMarkdownTheme) {
36
+ console.warn(
37
+ `[archimedes] Skipping thinking renderer patch — signature mismatch
38
+ hasThinkingCheck: ${hasThinkingCheck}, hasMarkdownTheme: ${hasMarkdownTheme}
39
+ This likely means pi's AssistantMessageComponent changed. The muted theme
40
+ for thinking blocks will not be applied.`,
41
+ );
37
42
  return;
38
43
  }
39
44