@pi-archimedes/core 0.6.0 → 0.8.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.6.0",
3
+ "version": "0.8.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
@@ -3,14 +3,17 @@ import type { Theme } from "@earendil-works/pi-coding-agent";
3
3
  import { TUI, type EditorTheme, type Component, type SettingItem } from "@earendil-works/pi-tui";
4
4
 
5
5
  import { HephaestusEditor } from "./editor/index.js";
6
- import { patchUserMessage, resetInstanceCount } from "./message/index.js";
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[] {
@@ -55,7 +58,6 @@ export function getCoreSettingsItems(config: CoreConfig): SettingItem[] {
55
58
 
56
59
  // Module-level state for session lifecycle (shared between session_start and session_shutdown)
57
60
  let coreRef: ListingRef | undefined;
58
- let coreResponseTimes: number[] | undefined;
59
61
  let coreCtx: ExtensionContext | undefined;
60
62
 
61
63
  export function registerCore(pi: ExtensionAPI): void {
@@ -70,11 +72,26 @@ export function registerCore(pi: ExtensionAPI): void {
70
72
  const listingRef = g["listingRef"] as ListingRef | undefined;
71
73
  if (listingRef) { listingRef.settled = true; }
72
74
 
73
- // Clear response times
74
- if (coreResponseTimes) { coreResponseTimes.length = 0; }
75
-
76
- // Reset instance count
77
- resetInstanceCount();
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
+ }
78
95
 
79
96
  // Clear editor component override
80
97
  if (coreCtx) { coreCtx.ui.setEditorComponent(undefined); }
@@ -110,10 +127,6 @@ export function registerCore(pi: ExtensionAPI): void {
110
127
  };
111
128
  ctx.ui.setHeader(headerFactory);
112
129
 
113
- // Shared response times array (used by both patchUserMessage and message_end)
114
- coreResponseTimes = [];
115
- const responseTimes = coreResponseTimes;
116
-
117
130
  // Set editor component
118
131
  ctx.ui.setEditorComponent((tui: TUI, editorTheme: EditorTheme, keybindings: KeybindingsManager) => {
119
132
  const theme = ctx.ui.theme;
@@ -127,9 +140,6 @@ export function registerCore(pi: ExtensionAPI): void {
127
140
  // Patch thinking renderer
128
141
  patchThinkingRenderer(() => ctx.ui.theme);
129
142
 
130
- // Patch user message response time
131
- patchUserMessage(() => ctx.ui.theme, responseTimes);
132
-
133
143
  // Load config for thinking transformation
134
144
  const config = loadCoreConfig();
135
145
 
@@ -139,13 +149,6 @@ export function registerCore(pi: ExtensionAPI): void {
139
149
  if (config.codeUnindent) {
140
150
  transformThinkingContent(event.message as any);
141
151
  }
142
-
143
- // Track response time from the raw message
144
- const rawMsg = event.message as any;
145
- if (rawMsg.duration) {
146
- const idx = rawMsg.instanceIndex ?? responseTimes.length;
147
- responseTimes[idx] = rawMsg.duration;
148
- }
149
152
  });
150
153
  });
151
154
  }
@@ -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,9 +3,7 @@ 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
- import { resetInstanceCount } from "../message/index.js";
9
7
  import { Text, Spacer, Container, TUI, truncateToWidth, visibleWidth, type Component } from "@earendil-works/pi-tui";
10
8
 
11
9
  // Symbol keys (survive hot-reload)
@@ -14,6 +12,7 @@ const ANIM_INTERVAL = Symbol.for("splashscreen:animInterval");
14
12
  const DEBOUNCE_TIMER = Symbol.for("splashscreen:debounceTimer");
15
13
  const PATCHED_CLEAR = Symbol.for("splashscreen:clearPatched");
16
14
  const PATCHED_LISTING = Symbol.for("splashscreen:listingPatched");
15
+ const ORIG_ADD_CHILD = Symbol.for("splashscreen:origAddChild");
17
16
 
18
17
  // Animation constants
19
18
  const MAX_RENDER_WIDTH = 9999;
@@ -166,6 +165,9 @@ export function patchStartupListing(
166
165
  ): void {
167
166
  const chat = findChatContainer(tui);
168
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.");
169
171
  return;
170
172
  }
171
173
  const cc = chat as any;
@@ -216,12 +218,11 @@ export function patchStartupListing(
216
218
  }
217
219
  });
218
220
 
219
- // Patch clear() to reset message instance tracking on container rebuild
221
+ // Patch clear() to intercept container rebuild
220
222
  if (!cc[PATCHED_CLEAR]) {
221
223
  cc[PATCHED_CLEAR] = true;
222
224
  const origClear = chat.clear.bind(chat);
223
225
  chat.clear = () => {
224
- resetInstanceCount();
225
226
  return origClear();
226
227
  };
227
228
  }
@@ -234,6 +235,7 @@ export function patchStartupListing(
234
235
  cc[PATCHED_LISTING] = true;
235
236
 
236
237
  const origAddChild = chat.addChild.bind(chat);
238
+ cc[ORIG_ADD_CHILD] = origAddChild; // Store for cleanup on shutdown
237
239
  chat.clear();
238
240
 
239
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
 
@@ -1,147 +0,0 @@
1
- import type { Theme, UserMessageComponent } from "@earendil-works/pi-coding-agent";
2
- import { VERSION } from "@earendil-works/pi-coding-agent";
3
- import { RESET, resolvePalette, setThemeBg } from "../chrome.js";
4
-
5
- type UserMsgCtor = typeof UserMessageComponent & { [PATCHED]?: boolean; [PATCH_VERSION]?: string };
6
-
7
- // ── Constants ──────────────────────────────────────────────
8
-
9
- const PATCHED = Symbol.for("splashscreen:userMsgPatched");
10
- const PATCH_VERSION = Symbol.for("splashscreen:userMsgPatchVersion");
11
- // Match OSC133 B (zone end) or C (zone final). v0.67 moved these from line
12
- // tail to line head — we strip from wherever they sit and re-emit at the end.
13
- const OSC133_RE = /\x1b\]133;[BC]\x07/g;
14
- const MSG_PADDING_X = 3;
15
- const TIME_COL = 9;
16
-
17
- // ── Instance tracking ──────────────────────────────────────
18
-
19
- const instanceIndex = new WeakMap<object, number>();
20
- let instanceCount = 0;
21
-
22
- export function resetInstanceCount(): void {
23
- instanceCount = 0;
24
- }
25
-
26
- function formatTime(ms: number): string {
27
- if (ms < 1000) return `${ms}ms`;
28
- if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`;
29
- const m = Math.floor(ms / 60000);
30
- const s = Math.round((ms % 60000) / 1000);
31
- return `${m}m ${s}s`;
32
- }
33
-
34
- // ── Patch ──────────────────────────────────────────────────
35
-
36
- export function patchUserMessage(
37
- getTheme: () => Theme,
38
- responseTimes: number[],
39
- ): void {
40
- try {
41
- let lastBg = "";
42
- const theme = getTheme();
43
- const p = resolvePalette(theme);
44
- lastBg = p.panelBg;
45
- setThemeBg(theme, "userMessageBg", lastBg);
46
-
47
- import("@earendil-works/pi-coding-agent").then(
48
- ({ UserMessageComponent }: { UserMessageComponent: UserMsgCtor }) => {
49
- const currentVersion = VERSION ?? "unknown";
50
- const patchVersion = UserMessageComponent[PATCH_VERSION];
51
- // Skip if already patched for this version
52
- if (UserMessageComponent[PATCHED] && patchVersion === currentVersion) return;
53
- if (UserMessageComponent[PATCHED] && patchVersion && patchVersion !== currentVersion) {
54
- console.warn(`[archimedes] Re-patching user message: pi version changed ${patchVersion} → ${currentVersion}`);
55
- }
56
-
57
- if (
58
- typeof UserMessageComponent.prototype.addChild !== "function" ||
59
- typeof UserMessageComponent.prototype.render !== "function"
60
- ) {
61
- console.warn("[splashscreen] UserMessageComponent shape changed — skipping patch");
62
- return;
63
- }
64
-
65
- UserMessageComponent[PATCHED] = true;
66
- UserMessageComponent[PATCH_VERSION] = currentVersion;
67
-
68
- const origAddChild = UserMessageComponent.prototype.addChild;
69
- UserMessageComponent.prototype.addChild = function (child: any) {
70
- if (child.paddingX !== undefined && !child._hephaestusPatched) {
71
- child.paddingX = MSG_PADDING_X;
72
- child._hephaestusPatched = true;
73
- }
74
- if (!instanceIndex.has(this)) {
75
- instanceIndex.set(this, instanceCount++);
76
- }
77
- return origAddChild.call(this, child);
78
- };
79
-
80
- const origRender = UserMessageComponent.prototype.render;
81
- UserMessageComponent.prototype.render = function (
82
- width: number,
83
- ): string[] {
84
- try {
85
- const currentTheme = getTheme();
86
- const p = resolvePalette(currentTheme);
87
- const bg = p.panelBg;
88
- if (bg !== lastBg) { setThemeBg(currentTheme, "userMessageBg", bg); lastBg = bg; }
89
-
90
- const idx = instanceIndex.get(this);
91
- const elapsed = idx !== undefined ? (responseTimes[idx] ?? 0) : 0;
92
- const hasTime = idx !== undefined;
93
-
94
- const contentWidth = width - TIME_COL;
95
- const lines: string[] = origRender.call(this, contentWidth);
96
- if (lines.length < 3) return lines;
97
-
98
- const timeStr = elapsed > 0 ? formatTime(elapsed) : "";
99
- const timeRight = 2;
100
- const timeLabel = timeStr.length > 0 ? p.time(timeStr) : "";
101
- // Column that shows "42ms" right-aligned with bg — spaces for visual
102
- // alignment remain, but they're wrapped in panelBg so the copy buffer
103
- // only contains the label text.
104
- const timeContent =
105
- p.panelBg +
106
- " ".repeat(Math.max(0, TIME_COL - timeStr.length - timeRight)) +
107
- timeLabel +
108
- " ".repeat(timeRight);
109
- // Use \x1b[K (erase-to-end-of-line) instead of literal spaces for the
110
- // gap between content and time column. Visually fills with panelBg but
111
- // leaves nothing in the copy buffer.
112
- const gapFill = p.panelBg + "\x1b[K";
113
- // Empty column: just bg fill to the end — no spaces in copy buffer
114
- const emptyTimeCol = p.panelBg + "\x1b[K";
115
-
116
- const firstContent = 0;
117
-
118
- for (let i = 0; i < lines.length; i++) {
119
- let line = lines[i]!;
120
-
121
- // Extract any OSC133 B/C markers (shell zone end/final). v0.67 placed
122
- // these at the head of the last line; older versions at the tail.
123
- // Strip them from the content line; re-emit after the time column.
124
- const oscMatches = line.match(OSC133_RE);
125
- const oscSuffix = oscMatches ? oscMatches.join("") : "";
126
- if (oscSuffix) line = line.replace(OSC133_RE, "");
127
-
128
- // Strip trailing spaces from the content portion (PI pads to contentWidth
129
- // with plain spaces) and replace with \x1b[K for visual fill without spaces
130
- // in the copy buffer.
131
- const strippedLine = line.replace(/((?:\x1b\[[\d;]*m)*)\s+$/, "$1");
132
- const col = i === firstContent && hasTime ? timeContent : emptyTimeCol;
133
- lines[i] = strippedLine + gapFill + col + oscSuffix;
134
- }
135
-
136
- return lines;
137
- } catch {
138
- // During /resume, getTheme() may throw — fall back to default render
139
- return origRender.call(this, width);
140
- }
141
- };
142
- },
143
- );
144
- } catch {
145
- /* skip if theme not ready */
146
- }
147
- }