@pi-archimedes/core 2.10.4 → 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/README.md +3 -3
- package/package.json +7 -4
- package/src/bridge/events.test.ts +438 -0
- package/src/bridge/events.ts +98 -39
- package/src/bridge/index.test.ts +50 -22
- package/src/bridge/index.ts +18 -5
- package/src/bridge/self-usage.test.ts +90 -12
- package/src/bridge/self-usage.ts +19 -6
- package/src/chrome-old-host.test.ts +84 -0
- package/src/chrome.test.ts +435 -2
- package/src/chrome.ts +138 -20
- package/src/events-host-bus.test.ts +113 -0
- package/src/events.test.ts +85 -0
- package/src/events.ts +107 -0
- package/src/index.test.ts +3 -9
- package/src/index.ts +5 -5
- package/src/log-discipline.test.ts +187 -0
- package/src/log.test.ts +172 -0
- package/src/log.ts +69 -0
- package/src/overlay.test.ts +178 -6
- package/src/overlay.ts +46 -62
- package/src/pi-support.test.ts +444 -0
- package/src/pi-support.ts +190 -0
- package/src/profiler.ts +14 -5
- package/src/screen-mode-discipline.test.ts +214 -0
- package/src/screen-mode.test.ts +48 -0
- package/src/screen-mode.ts +45 -0
- package/src/settings-io.test.ts +174 -1
- package/src/settings-io.ts +54 -0
- package/src/text.test.ts +58 -0
- package/src/text.ts +21 -20
- package/src/tool-render.test.ts +48 -0
- package/src/tool-render.ts +44 -0
- package/src/bus.test.ts +0 -154
- package/src/bus.ts +0 -137
package/src/settings-io.test.ts
CHANGED
|
@@ -17,7 +17,7 @@ vi.mock("@earendil-works/pi-coding-agent", () => ({
|
|
|
17
17
|
}));
|
|
18
18
|
|
|
19
19
|
// Import after mocks are set up
|
|
20
|
-
const { loadConfig, saveConfig, removeConfig, isConfigEnabled, setConfigEnabled, updateConfig } = await import("./settings-io.js");
|
|
20
|
+
const { loadConfig, saveConfig, removeConfig, isConfigEnabled, setConfigEnabled, updateConfig, readEffectiveSettings } = await import("./settings-io.js");
|
|
21
21
|
|
|
22
22
|
describe("removeConfig", () => {
|
|
23
23
|
beforeEach(() => {
|
|
@@ -468,3 +468,176 @@ describe("symlink preservation", () => {
|
|
|
468
468
|
expect(data["archimedes.test"]).toEqual({ d: 4 });
|
|
469
469
|
});
|
|
470
470
|
});
|
|
471
|
+
|
|
472
|
+
// ── readEffectiveSettings ───────────────────────────────────────────────────
|
|
473
|
+
//
|
|
474
|
+
// `loadConfig` reads `~/.pi/agent/settings.json` by hand, which cannot see
|
|
475
|
+
// project-scoped settings or overrides. `pi.getSettings()` returns the merged
|
|
476
|
+
// effective object, so it is the right source when the running pi has it — and
|
|
477
|
+
// the file read is the fallback that keeps core's peer floor where ADR 0013 put
|
|
478
|
+
// it (getSettings is an ExtensionAPI method that older pi does not expose).
|
|
479
|
+
// Note the direction of the whole thing: this is a READER. The writer stays on
|
|
480
|
+
// the hand-rolled symlink-preserving path because pi has no public write API.
|
|
481
|
+
|
|
482
|
+
describe("readEffectiveSettings", () => {
|
|
483
|
+
const settingsPath = () => join(tempDir, "settings.json");
|
|
484
|
+
|
|
485
|
+
beforeEach(() => {
|
|
486
|
+
const p = settingsPath();
|
|
487
|
+
if (fs.existsSync(p)) fs.unlinkSync(p);
|
|
488
|
+
});
|
|
489
|
+
|
|
490
|
+
afterEach(() => {
|
|
491
|
+
const p = settingsPath();
|
|
492
|
+
try { fs.unlinkSync(p); } catch { /* ignore */ }
|
|
493
|
+
try { fs.unlinkSync(p + ".tmp"); } catch { /* ignore */ }
|
|
494
|
+
});
|
|
495
|
+
|
|
496
|
+
it("returns pi.getSettings() when the host exposes it", () => {
|
|
497
|
+
const pi = { getSettings: () => ({ "archimedes.web": { braveApiKey: "from-host" } }) };
|
|
498
|
+
expect(readEffectiveSettings(pi)).toEqual({ "archimedes.web": { braveApiKey: "from-host" } });
|
|
499
|
+
});
|
|
500
|
+
|
|
501
|
+
it("its value wins over settings.json (project scope and overrides are invisible to the file read)", () => {
|
|
502
|
+
fs.writeFileSync(
|
|
503
|
+
settingsPath(),
|
|
504
|
+
JSON.stringify({ "archimedes.web": { braveApiKey: "from-file" } }),
|
|
505
|
+
"utf-8",
|
|
506
|
+
);
|
|
507
|
+
const pi = { getSettings: () => ({ "archimedes.web": { braveApiKey: "from-effective" } }) };
|
|
508
|
+
const result = readEffectiveSettings(pi) as { "archimedes.web": { braveApiKey: string } };
|
|
509
|
+
expect(result["archimedes.web"].braveApiKey).toBe("from-effective");
|
|
510
|
+
});
|
|
511
|
+
|
|
512
|
+
it("falls back to the file read when the handle has no getSettings (a pi older than the API)", () => {
|
|
513
|
+
fs.writeFileSync(
|
|
514
|
+
settingsPath(),
|
|
515
|
+
JSON.stringify({ "archimedes.web": { braveApiKey: "from-file" } }),
|
|
516
|
+
"utf-8",
|
|
517
|
+
);
|
|
518
|
+
const result = readEffectiveSettings({}) as { "archimedes.web": { braveApiKey: string } };
|
|
519
|
+
expect(result["archimedes.web"].braveApiKey).toBe("from-file");
|
|
520
|
+
});
|
|
521
|
+
|
|
522
|
+
it("falls back to the file read when no handle is passed at all", () => {
|
|
523
|
+
fs.writeFileSync(settingsPath(), JSON.stringify({ "test.ns": { a: 1 } }), "utf-8");
|
|
524
|
+
expect(readEffectiveSettings()).toEqual({ "test.ns": { a: 1 } });
|
|
525
|
+
expect(readEffectiveSettings(undefined)).toEqual({ "test.ns": { a: 1 } });
|
|
526
|
+
expect(readEffectiveSettings(null)).toEqual({ "test.ns": { a: 1 } });
|
|
527
|
+
});
|
|
528
|
+
|
|
529
|
+
it("treats a non-function getSettings property as absent", () => {
|
|
530
|
+
fs.writeFileSync(settingsPath(), JSON.stringify({ "test.ns": { a: 1 } }), "utf-8");
|
|
531
|
+
expect(readEffectiveSettings({ getSettings: "not a function" })).toEqual({ "test.ns": { a: 1 } });
|
|
532
|
+
});
|
|
533
|
+
|
|
534
|
+
it("falls back when getSettings() throws — pi asserts an active extension context and can refuse", () => {
|
|
535
|
+
fs.writeFileSync(settingsPath(), JSON.stringify({ "test.ns": { a: 1 } }), "utf-8");
|
|
536
|
+
const pi = {
|
|
537
|
+
getSettings: () => {
|
|
538
|
+
throw new Error("Extension is not active");
|
|
539
|
+
},
|
|
540
|
+
};
|
|
541
|
+
expect(() => readEffectiveSettings(pi)).not.toThrow();
|
|
542
|
+
expect(readEffectiveSettings(pi)).toEqual({ "test.ns": { a: 1 } });
|
|
543
|
+
});
|
|
544
|
+
|
|
545
|
+
it("falls back when getSettings() returns something unusable (null / undefined / a scalar)", () => {
|
|
546
|
+
fs.writeFileSync(settingsPath(), JSON.stringify({ "test.ns": { a: 1 } }), "utf-8");
|
|
547
|
+
expect(readEffectiveSettings({ getSettings: () => undefined })).toEqual({ "test.ns": { a: 1 } });
|
|
548
|
+
expect(readEffectiveSettings({ getSettings: () => null })).toEqual({ "test.ns": { a: 1 } });
|
|
549
|
+
expect(readEffectiveSettings({ getSettings: () => 42 })).toEqual({ "test.ns": { a: 1 } });
|
|
550
|
+
});
|
|
551
|
+
|
|
552
|
+
it("returns an empty object for malformed JSON with no handle (existing loadConfig behaviour preserved)", () => {
|
|
553
|
+
fs.writeFileSync(settingsPath(), "{ invalid json }", "utf-8");
|
|
554
|
+
expect(readEffectiveSettings()).toEqual({});
|
|
555
|
+
// …and the malformed file still does not break the namespaced reader.
|
|
556
|
+
expect(loadConfig("test.ns", { foo: "bar" })).toEqual({ foo: "bar" });
|
|
557
|
+
});
|
|
558
|
+
|
|
559
|
+
it("returns {} when there is no file and no handle", () => {
|
|
560
|
+
expect(fs.existsSync(settingsPath())).toBe(false);
|
|
561
|
+
expect(readEffectiveSettings()).toEqual({});
|
|
562
|
+
});
|
|
563
|
+
|
|
564
|
+
it("never writes: it is a reader, so the file and the writer's tmp path are untouched", () => {
|
|
565
|
+
fs.writeFileSync(settingsPath(), JSON.stringify({ "test.ns": { a: 1 } }), "utf-8");
|
|
566
|
+
const before = fs.readFileSync(settingsPath(), "utf-8");
|
|
567
|
+
const beforeMtimeMs = fs.statSync(settingsPath()).mtimeMs;
|
|
568
|
+
readEffectiveSettings({});
|
|
569
|
+
readEffectiveSettings({ getSettings: () => ({}) });
|
|
570
|
+
expect(fs.readFileSync(settingsPath(), "utf-8")).toBe(before);
|
|
571
|
+
expect(fs.statSync(settingsPath()).mtimeMs).toBe(beforeMtimeMs);
|
|
572
|
+
expect(fs.existsSync(settingsPath() + ".tmp")).toBe(false);
|
|
573
|
+
});
|
|
574
|
+
|
|
575
|
+
it("never creates settings.json when it is absent", () => {
|
|
576
|
+
readEffectiveSettings({});
|
|
577
|
+
expect(fs.existsSync(settingsPath())).toBe(false);
|
|
578
|
+
});
|
|
579
|
+
|
|
580
|
+
it("does not disturb the writer: saveConfig still round-trips after an effective read", () => {
|
|
581
|
+
readEffectiveSettings({ getSettings: () => ({ "test.ns": { from: "host" } }) });
|
|
582
|
+
saveConfig("test.ns", { from: "file" });
|
|
583
|
+
expect(loadConfig("test.ns", { from: "" })).toEqual({ from: "file" });
|
|
584
|
+
});
|
|
585
|
+
});
|
|
586
|
+
|
|
587
|
+
// ── The catch must not be invisible ────────────────────────────────────────
|
|
588
|
+
//
|
|
589
|
+
// `pi.getSettings()` refuses for the expected reason (the handle's wrapper
|
|
590
|
+
// asserts an active extension context) AND for reasons that are bugs: the
|
|
591
|
+
// runtime stub throws "Extension runtime not initialized" for a call made
|
|
592
|
+
// during extension loading, and the real implementation can throw for anything
|
|
593
|
+
// it chokes on. Falling back to the file is right in every case; falling back
|
|
594
|
+
// without a trace is not, because the symptom of each of those is a silently
|
|
595
|
+
// project-blind settings read.
|
|
596
|
+
|
|
597
|
+
describe("readEffectiveSettings reports the reason it fell back", () => {
|
|
598
|
+
const ENV = "PI_ARCHIMEDES_LOG";
|
|
599
|
+
const settingsPath = () => join(tempDir, "settings.json");
|
|
600
|
+
const previous = process.env[ENV];
|
|
601
|
+
|
|
602
|
+
afterEach(() => {
|
|
603
|
+
if (previous === undefined) delete process.env[ENV];
|
|
604
|
+
else process.env[ENV] = previous;
|
|
605
|
+
const p = settingsPath();
|
|
606
|
+
try { fs.unlinkSync(p); } catch { /* ignore */ }
|
|
607
|
+
});
|
|
608
|
+
|
|
609
|
+
it("logs the error when getSettings() throws, and still returns the file read", () => {
|
|
610
|
+
const target = join(tempDir, "effective-fallback.log");
|
|
611
|
+
process.env[ENV] = target;
|
|
612
|
+
fs.writeFileSync(
|
|
613
|
+
settingsPath(),
|
|
614
|
+
JSON.stringify({ "test.ns": { from: "file" } }),
|
|
615
|
+
"utf-8",
|
|
616
|
+
);
|
|
617
|
+
|
|
618
|
+
const pi = {
|
|
619
|
+
getSettings: () => {
|
|
620
|
+
throw new Error("Extension runtime not initialized");
|
|
621
|
+
},
|
|
622
|
+
};
|
|
623
|
+
expect(readEffectiveSettings(pi)).toEqual({ "test.ns": { from: "file" } });
|
|
624
|
+
|
|
625
|
+
const logged = fs.readFileSync(target, "utf-8");
|
|
626
|
+
expect(logged).toContain("[core]");
|
|
627
|
+
expect(logged).toContain("Extension runtime not initialized");
|
|
628
|
+
// With the stack, which is the part that says which call site did it.
|
|
629
|
+
expect(logged).toMatch(/\n\s+at\s+\S/);
|
|
630
|
+
});
|
|
631
|
+
|
|
632
|
+
it("says nothing on the normal paths (supported host, older pi, no handle)", () => {
|
|
633
|
+
const target = join(tempDir, "effective-quiet.log");
|
|
634
|
+
process.env[ENV] = target;
|
|
635
|
+
fs.writeFileSync(settingsPath(), JSON.stringify({ "test.ns": { a: 1 } }), "utf-8");
|
|
636
|
+
|
|
637
|
+
readEffectiveSettings({ getSettings: () => ({ "test.ns": { a: 2 } }) });
|
|
638
|
+
readEffectiveSettings({});
|
|
639
|
+
readEffectiveSettings();
|
|
640
|
+
|
|
641
|
+
expect(fs.existsSync(target)).toBe(false);
|
|
642
|
+
});
|
|
643
|
+
});
|
package/src/settings-io.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { readFileSync, writeFileSync, existsSync, renameSync, unlinkSync, lstatSync, realpathSync } from "node:fs";
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
4
|
+
import { diag, describeError } from "./log.js";
|
|
4
5
|
|
|
5
6
|
const SETTINGS_PATH = join(getAgentDir(), "settings.json");
|
|
6
7
|
|
|
@@ -33,6 +34,59 @@ function readSettings(): Record<string, unknown> {
|
|
|
33
34
|
return parseSettings(readRawSettings());
|
|
34
35
|
}
|
|
35
36
|
|
|
37
|
+
/**
|
|
38
|
+
* Read the settings the running pi actually applies: `pi.getSettings()` when
|
|
39
|
+
* the host exposes it, otherwise the file read.
|
|
40
|
+
*
|
|
41
|
+
* `getSettings()` returns the *effective* object — global settings merged with
|
|
42
|
+
* project settings (`<cwd>/.pi/settings.json`) and any in-process overrides —
|
|
43
|
+
* which a hand-rolled read of `~/.pi/agent/settings.json` cannot see. It lives
|
|
44
|
+
* on **`ExtensionAPI`**, not on `ExtensionContext` (`extensions/types.d.ts:1286`
|
|
45
|
+
* in pi 1.2.0), so a call site must pass the `pi` handle, never `ctx`.
|
|
46
|
+
*
|
|
47
|
+
* The fallback is not decoration: it is what keeps `core`'s peer floor at
|
|
48
|
+
* `>=0.85.0` (ADR 0013 / ADR 0026), because `getSettings` is younger than that
|
|
49
|
+
* floor. It also covers pi refusing the call — the handle's wrapper asserts an
|
|
50
|
+
* active extension context and throws otherwise — and a host that hands back
|
|
51
|
+
* something that is not an object. Anything unusable falls through to the file,
|
|
52
|
+
* and a *thrown* refusal is recorded in the log on the way: the same catch hides
|
|
53
|
+
* a call made before the runtime bound itself, whose only symptom would be a
|
|
54
|
+
* settings read that quietly cannot see project scope.
|
|
55
|
+
*
|
|
56
|
+
* Reads only. There is no matching write path: pi exposes no public settings
|
|
57
|
+
* writer, so `saveConfig` keeps its symlink-preserving tmp+rename behaviour
|
|
58
|
+
* (docs/features/symlink-preserving-settings-writes.md).
|
|
59
|
+
*
|
|
60
|
+
* @param pi the `ExtensionAPI` handle, or nothing at all (headless call sites,
|
|
61
|
+
* tests, and every pi without the API).
|
|
62
|
+
*/
|
|
63
|
+
export function readEffectiveSettings(pi?: unknown): Record<string, unknown> {
|
|
64
|
+
const getSettings = (pi as { getSettings?: unknown } | null | undefined)?.getSettings;
|
|
65
|
+
if (typeof getSettings === "function") {
|
|
66
|
+
try {
|
|
67
|
+
const effective = (getSettings as () => unknown).call(pi);
|
|
68
|
+
// An object is the only usable answer; `undefined`/`null`/a scalar mean
|
|
69
|
+
// the host had nothing to say, which is the file read's job.
|
|
70
|
+
if (typeof effective === "object" && effective !== null && !Array.isArray(effective)) {
|
|
71
|
+
return effective as Record<string, unknown>;
|
|
72
|
+
}
|
|
73
|
+
} catch (err) {
|
|
74
|
+
// pi asserts the extension is active before answering, so the call can
|
|
75
|
+
// refuse. A settings read is never allowed to be the reason something
|
|
76
|
+
// failed, so fall through to the file — but never fall through silently:
|
|
77
|
+
// the same catch also swallows the handle's "runtime not initialized"
|
|
78
|
+
// stub (a call made too early, i.e. a bug) and anything the real
|
|
79
|
+
// implementation chokes on, and every one of those looks like nothing
|
|
80
|
+
// more than a settings read that cannot see project scope.
|
|
81
|
+
diag(
|
|
82
|
+
"core",
|
|
83
|
+
`[archimedes:settings-io] getSettings() refused; falling back to settings.json: ${describeError(err)}`,
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return readSettings();
|
|
88
|
+
}
|
|
89
|
+
|
|
36
90
|
/**
|
|
37
91
|
* Load a config section from settings.json, merged with defaults.
|
|
38
92
|
*/
|
package/src/text.test.ts
CHANGED
|
@@ -61,6 +61,64 @@ describe("stripAnsi", () => {
|
|
|
61
61
|
});
|
|
62
62
|
});
|
|
63
63
|
|
|
64
|
+
// ── stripAnsi regression fence ───────────────────────────────────────────────
|
|
65
|
+
//
|
|
66
|
+
// stripAnsi is a wrapper, not a delegation: the startup section parser and the
|
|
67
|
+
// footer both consume its output, and they depend on the *whole* pattern set
|
|
68
|
+
// (pi-tui's stripTerminalSequences leaves DCS / SOS / PM and the charset
|
|
69
|
+
// designators in place, and it never trims). These assertions are the fence
|
|
70
|
+
// that keeps the wrapper honest — they must read the same before and after the
|
|
71
|
+
// pi-tui migration, and the DCS/SOS/APC/PM/charset/trim cases below are the
|
|
72
|
+
// ones a pure delegation would break.
|
|
73
|
+
|
|
74
|
+
describe("stripAnsi (wrapper fence)", () => {
|
|
75
|
+
it("strips a DCS string terminated by ST as well as by BEL", () => {
|
|
76
|
+
expect(stripAnsi("before\x1bPq;status\x1b\\after")).toBe("beforeafter");
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("strips an APC string terminated by ST", () => {
|
|
80
|
+
expect(stripAnsi("before\x1b_privacy\x1b\\after")).toBe("beforeafter");
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("strips a SOS string terminated by ST", () => {
|
|
84
|
+
expect(stripAnsi("before\x1b^message\x1b\\after")).toBe("beforeafter");
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("strips both charset designators and keeps the designator byte it names", () => {
|
|
88
|
+
// Same shape as the case above: the pattern drops `ESC (` / `ESC )` and
|
|
89
|
+
// leaves the designator character, which is what the section parser saw
|
|
90
|
+
// before the wrapper existed.
|
|
91
|
+
expect(stripAnsi("\x1b(B\x1b)0plain")).toBe("B0plain");
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("strips pi's own control sequences, so callers see only what is drawn", () => {
|
|
95
|
+
// A captured frame line: SGR + the cursor marker (APC) + an OSC 8 link.
|
|
96
|
+
const line = `\x1b[38;2;9;9;9m ${"日本語"}\x1b[0m\x1b_pi:c\x07`;
|
|
97
|
+
expect(stripAnsi(line)).toBe("日本語");
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it("trims the padding a surface left behind the styling", () => {
|
|
101
|
+
expect(stripAnsi("\x1b[90m│\x1b[0m \n")).toBe("│");
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
// ── stripAnsi: what the pi-tui pass adds ────────────────────────────────────
|
|
106
|
+
//
|
|
107
|
+
// The only *new* coverage the wrapper buys: a CSI sequence whose parameters use
|
|
108
|
+
// the `:` sub-parameter form, which our `[0-9;?]*` run stops at. Before the
|
|
109
|
+
// wrapper such an escape survived into the section parser's input; it is not
|
|
110
|
+
// part of the fence above.
|
|
111
|
+
|
|
112
|
+
describe("stripAnsi (pi-tui pass)", () => {
|
|
113
|
+
it("strips an SGR sequence whose parameters use the colon form", () => {
|
|
114
|
+
expect(stripAnsi("\x1b[38:2:255:0:0mred\x1b[0m")).toBe("red");
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it("strips a private-mode CSI our pattern does not describe", () => {
|
|
118
|
+
expect(stripAnsi("\x1b[>4;2mhello\x1b[0m")).toBe("hello");
|
|
119
|
+
});
|
|
120
|
+
});
|
|
121
|
+
|
|
64
122
|
// ── isParentBorder ──────────────────────────────────────────────────────────
|
|
65
123
|
|
|
66
124
|
describe("isParentBorder", () => {
|
package/src/text.ts
CHANGED
|
@@ -1,36 +1,37 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { stripTerminalSequences } from "@earendil-works/pi-tui";
|
|
2
2
|
|
|
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
6
|
/**
|
|
7
|
-
* Strip ALL ANSI escape sequences. Covers:
|
|
7
|
+
* Strip ALL ANSI escape sequences and trim. Covers:
|
|
8
8
|
* - CSI: ESC [ ... letter (SGR, cursor movement, etc.)
|
|
9
|
-
* - OSC: ESC ] ... ST (operating system commands)
|
|
9
|
+
* - OSC: ESC ] ... ST (operating system commands, OSC 8 hyperlinks)
|
|
10
10
|
* - DCS: ESC P ... ST (device control strings)
|
|
11
|
-
* - APC: ESC _ ... ST (application program commands)
|
|
11
|
+
* - APC: ESC _ ... ST (application program commands — pi's CURSOR_MARKER)
|
|
12
12
|
* - SOS: ESC ^ ... ST (start of string)
|
|
13
13
|
* - PM: ESC \x5c ... ST (privacy message)
|
|
14
14
|
* - Character set: ESC ( or ESC ) (DECSET/DECRST)
|
|
15
|
+
*
|
|
16
|
+
* This is a wrapper, not a delegation, and the **local pattern set is the
|
|
17
|
+
* load-bearing half**. pi-tui's `stripTerminalSequences` leaves DCS, SOS, PM and
|
|
18
|
+
* the charset designators in place and never trims, so delegating to it alone
|
|
19
|
+
* would feed control bytes — and stray padding — to the startup section parser
|
|
20
|
+
* (`ui/src/startup/sections.ts`) and the footer, both of which parse this
|
|
21
|
+
* output. The `stripTerminalSequences` pass that follows is the backstop for the
|
|
22
|
+
* sequences our patterns do *not* describe, chiefly the colon sub-parameter
|
|
23
|
+
* form of SGR (`\x1b[38:2:R:G:Bm`) and other private CSI shapes, which would
|
|
24
|
+
* otherwise survive into the parsed text.
|
|
25
|
+
*
|
|
15
26
|
* Trims whitespace. Use for text extraction.
|
|
16
27
|
*/
|
|
17
28
|
export function stripAnsi(s: string): string {
|
|
18
|
-
|
|
19
|
-
.replace(/\x1b\[[0-9;?]*[a-zA-Z]/g, "")
|
|
20
|
-
.replace(/\x1b\].*?(?:\x07|\x1b\\)/g, "")
|
|
21
|
-
.replace(/\x1b[P^_\x5c].*?(?:\x07|\x1b\\)/g, "")
|
|
22
|
-
.replace(/\x1b[()]/g, "")
|
|
23
|
-
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
/** Clamp a line to maxW visible characters, preserving ANSI escapes. */
|
|
27
|
-
export function clampLine(line: string, maxW: number): string {
|
|
28
|
-
return truncateToWidth(line, maxW);
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
/** Clamp an array of lines to maxW visible characters each. */
|
|
32
|
-
function clampLines(lines: string[], maxW: number): string[] {
|
|
33
|
-
return lines.map((l) => clampLine(l, maxW));
|
|
29
|
+
const ours = s
|
|
30
|
+
.replace(/\x1b\[[0-9;?]*[a-zA-Z]/g, "") // CSI
|
|
31
|
+
.replace(/\x1b\].*?(?:\x07|\x1b\\)/g, "") // OSC
|
|
32
|
+
.replace(/\x1b[P^_\x5c].*?(?:\x07|\x1b\\)/g, "") // DCS, SOS, APC, PM
|
|
33
|
+
.replace(/\x1b[()]/g, ""); // character set
|
|
34
|
+
return stripTerminalSequences(ours).trim();
|
|
34
35
|
}
|
|
35
36
|
|
|
36
37
|
// isParentBorder uses the narrow SGR-only strip (no trim) for char-level checks
|
package/src/tool-render.test.ts
CHANGED
|
@@ -4,6 +4,7 @@ import {
|
|
|
4
4
|
renderStatusLabel,
|
|
5
5
|
renderToolCallLine,
|
|
6
6
|
STATUS_GLYPH,
|
|
7
|
+
toolRowDuration,
|
|
7
8
|
type ToolRenderTheme,
|
|
8
9
|
} from "./tool-render.js";
|
|
9
10
|
|
|
@@ -77,3 +78,50 @@ describe("renderToolCallLine", () => {
|
|
|
77
78
|
);
|
|
78
79
|
});
|
|
79
80
|
});
|
|
81
|
+
|
|
82
|
+
describe("toolRowDuration", () => {
|
|
83
|
+
it("takes a settled row's duration from the result, so it survives a session reload", () => {
|
|
84
|
+
// A row re-rendered from a stored session has no renderer state at all —
|
|
85
|
+
// only the duration the result recorded.
|
|
86
|
+
expect(toolRowDuration({ durationMs: 4200 }, { isPartial: false })).toEqual({
|
|
87
|
+
label: "Took",
|
|
88
|
+
ms: 4200,
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("prefers the recorded duration over the row's own clock, so wall-clock steps are excluded", () => {
|
|
93
|
+
// The row started 90s of real time ago; the tool itself reported 1.5s.
|
|
94
|
+
const startedAt = Date.now() - 90_000;
|
|
95
|
+
expect(toolRowDuration({ durationMs: 1500, startedAt }, { isPartial: false })).toEqual({
|
|
96
|
+
label: "Took",
|
|
97
|
+
ms: 1500,
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it("counts from the row's own clock while the result is still partial", () => {
|
|
102
|
+
const startedAt = Date.now() - 2500;
|
|
103
|
+
const out = toolRowDuration({ startedAt }, { isPartial: true });
|
|
104
|
+
expect(out?.label).toBe("Elapsed");
|
|
105
|
+
expect(out?.ms).toBeGreaterThanOrEqual(2400);
|
|
106
|
+
expect(out?.ms).toBeLessThan(5000);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it("falls back to the row's clock for a settled result that recorded no duration", () => {
|
|
110
|
+
expect(
|
|
111
|
+
toolRowDuration({ startedAt: Date.now() - 1000, endedAt: Date.now() }, { isPartial: false }),
|
|
112
|
+
).toMatchObject({ label: "Took" });
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it("says nothing about a row that never started and recorded nothing", () => {
|
|
116
|
+
expect(toolRowDuration({}, { isPartial: false })).toBeUndefined();
|
|
117
|
+
expect(toolRowDuration({}, { isPartial: true })).toBeUndefined();
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it("finalises an errored row from its clock even while the result is partial", () => {
|
|
121
|
+
const out = toolRowDuration(
|
|
122
|
+
{ startedAt: Date.now() - 800, endedAt: Date.now() },
|
|
123
|
+
{ isPartial: true, isError: true },
|
|
124
|
+
);
|
|
125
|
+
expect(out?.label).toBe("Took");
|
|
126
|
+
});
|
|
127
|
+
});
|
package/src/tool-render.ts
CHANGED
|
@@ -99,3 +99,47 @@ export function renderToolCallLine(
|
|
|
99
99
|
theme.fg(token, STATUS_GLYPH[status] + " ") + theme.fg(token, name);
|
|
100
100
|
return suffix ? head + theme.fg("dim", suffix) : head;
|
|
101
101
|
}
|
|
102
|
+
|
|
103
|
+
/** What a row knows about how long its tool took. */
|
|
104
|
+
export interface ToolRowTiming {
|
|
105
|
+
/** Milliseconds the tool recorded on its final result, if it recorded any. */
|
|
106
|
+
durationMs?: number | undefined;
|
|
107
|
+
/** When this row first saw the call running, from the row's own clock. */
|
|
108
|
+
startedAt?: number | undefined;
|
|
109
|
+
/** When the row saw the call settle, from the row's own clock. */
|
|
110
|
+
endedAt?: number | undefined;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** The timing line of a tool row: which word to use, and how long to show. */
|
|
114
|
+
export interface ToolRowDuration {
|
|
115
|
+
label: "Elapsed" | "Took";
|
|
116
|
+
ms: number;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* The duration line a tool row shows, following pi's own rows:
|
|
121
|
+
*
|
|
122
|
+
* - A settled result carries `durationMs`, measured by the tool. That number
|
|
123
|
+
* wins, because it is monotonic, it excludes whatever the row was doing
|
|
124
|
+
* between the call and the paint, and it is still there when the session is
|
|
125
|
+
* reloaded and the row's own clock is gone.
|
|
126
|
+
* - While the result is still partial the row counts from its own clock
|
|
127
|
+
* (`Elapsed`).
|
|
128
|
+
* - A settled result that recorded no duration falls back to the row's clock.
|
|
129
|
+
* - A row that never saw the call start and recorded nothing says nothing.
|
|
130
|
+
*
|
|
131
|
+
* `isError` finalises the row: a failed call is not still running, whatever the
|
|
132
|
+
* partial flag says.
|
|
133
|
+
*/
|
|
134
|
+
export function toolRowDuration(
|
|
135
|
+
timing: ToolRowTiming,
|
|
136
|
+
options: { isPartial: boolean; isError?: boolean },
|
|
137
|
+
): ToolRowDuration | undefined {
|
|
138
|
+
const settled = !options.isPartial || options.isError === true;
|
|
139
|
+
if (settled && typeof timing.durationMs === "number") {
|
|
140
|
+
return { label: "Took", ms: Math.max(0, timing.durationMs) };
|
|
141
|
+
}
|
|
142
|
+
if (timing.startedAt === undefined) return undefined;
|
|
143
|
+
const end = timing.endedAt ?? Date.now();
|
|
144
|
+
return { label: settled ? "Took" : "Elapsed", ms: Math.max(0, end - timing.startedAt) };
|
|
145
|
+
}
|
package/src/bus.test.ts
DELETED
|
@@ -1,154 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect, afterEach } from "vitest";
|
|
2
|
-
import { getBus, initBus, Events } from "./bus.js";
|
|
3
|
-
|
|
4
|
-
// ── globalThis cleanup ──────────────────────────────────────────────────────
|
|
5
|
-
|
|
6
|
-
const BUS_KEY = Symbol.for("archimedes:bus");
|
|
7
|
-
const QUEUE_KEY = Symbol.for("archimedes:busQueue");
|
|
8
|
-
|
|
9
|
-
afterEach(() => {
|
|
10
|
-
// Reset globalThis bus and queue to avoid test pollution
|
|
11
|
-
delete (globalThis as Record<symbol, unknown>)[BUS_KEY];
|
|
12
|
-
delete (globalThis as Record<symbol, unknown>)[QUEUE_KEY];
|
|
13
|
-
});
|
|
14
|
-
|
|
15
|
-
// ── emit → on delivery ─────────────────────────────────────────────────────
|
|
16
|
-
|
|
17
|
-
describe("emit → on delivery", () => {
|
|
18
|
-
it("subscriber receives payload", () => {
|
|
19
|
-
const bus = getBus();
|
|
20
|
-
const received: unknown[] = [];
|
|
21
|
-
bus.on("test:event", (payload) => received.push(payload));
|
|
22
|
-
bus.emit("test:event", { hello: "world" });
|
|
23
|
-
expect(received).toEqual([{ hello: "world" }]);
|
|
24
|
-
});
|
|
25
|
-
});
|
|
26
|
-
|
|
27
|
-
// ── multiple subscribers ────────────────────────────────────────────────────
|
|
28
|
-
|
|
29
|
-
describe("multiple subscribers", () => {
|
|
30
|
-
it("all receive the event", () => {
|
|
31
|
-
const bus = getBus();
|
|
32
|
-
const a: unknown[] = [];
|
|
33
|
-
const b: unknown[] = [];
|
|
34
|
-
bus.on("multi", (p) => a.push(p));
|
|
35
|
-
bus.on("multi", (p) => b.push(p));
|
|
36
|
-
bus.emit("multi", "shared");
|
|
37
|
-
expect(a).toEqual(["shared"]);
|
|
38
|
-
expect(b).toEqual(["shared"]);
|
|
39
|
-
});
|
|
40
|
-
});
|
|
41
|
-
|
|
42
|
-
// ── unsubscribe ─────────────────────────────────────────────────────────────
|
|
43
|
-
|
|
44
|
-
describe("unsubscribe", () => {
|
|
45
|
-
it("removed subscriber does not receive subsequent events", () => {
|
|
46
|
-
const bus = getBus();
|
|
47
|
-
const received: unknown[] = [];
|
|
48
|
-
const unsub = bus.on("unsub:test", (p) => received.push(p));
|
|
49
|
-
bus.emit("unsub:test", "first");
|
|
50
|
-
unsub();
|
|
51
|
-
bus.emit("unsub:test", "second");
|
|
52
|
-
expect(received).toEqual(["first"]);
|
|
53
|
-
});
|
|
54
|
-
});
|
|
55
|
-
|
|
56
|
-
// ── error isolation ─────────────────────────────────────────────────────────
|
|
57
|
-
|
|
58
|
-
describe("error isolation", () => {
|
|
59
|
-
it("one listener throwing does not prevent others from receiving", () => {
|
|
60
|
-
const bus = getBus();
|
|
61
|
-
const received: unknown[] = [];
|
|
62
|
-
bus.on("err:test", () => {
|
|
63
|
-
throw new Error("boom");
|
|
64
|
-
});
|
|
65
|
-
bus.on("err:test", (p) => received.push(p));
|
|
66
|
-
bus.emit("err:test", "payload");
|
|
67
|
-
expect(received).toEqual(["payload"]);
|
|
68
|
-
});
|
|
69
|
-
});
|
|
70
|
-
|
|
71
|
-
// ── late subscriber queue ───────────────────────────────────────────────────
|
|
72
|
-
|
|
73
|
-
describe("late subscriber queue", () => {
|
|
74
|
-
it("events emitted before on() are delivered when subscriber registers", () => {
|
|
75
|
-
// Create a fresh bus
|
|
76
|
-
const bus = getBus();
|
|
77
|
-
|
|
78
|
-
// Emit before anyone subscribes — this queues the event
|
|
79
|
-
bus.emit("queued:event", "queued-payload");
|
|
80
|
-
|
|
81
|
-
// Now subscribe — queued events should be delivered
|
|
82
|
-
const received: unknown[] = [];
|
|
83
|
-
bus.on("queued:event", (p) => received.push(p));
|
|
84
|
-
|
|
85
|
-
expect(received).toEqual(["queued-payload"]);
|
|
86
|
-
});
|
|
87
|
-
});
|
|
88
|
-
|
|
89
|
-
// ── initBus ─────────────────────────────────────────────────────────────────
|
|
90
|
-
|
|
91
|
-
describe("initBus", () => {
|
|
92
|
-
it("flushes queued events to subscribers that registered before init", () => {
|
|
93
|
-
// Emit before any subscriber exists — queues the event
|
|
94
|
-
const bus = getBus();
|
|
95
|
-
bus.emit("init:test", "queued-value");
|
|
96
|
-
|
|
97
|
-
// Subscribe — on() drains queue and delivers
|
|
98
|
-
const received: unknown[] = [];
|
|
99
|
-
bus.on("init:test", (p) => received.push(p));
|
|
100
|
-
expect(received).toEqual(["queued-value"]);
|
|
101
|
-
});
|
|
102
|
-
|
|
103
|
-
it("initBus re-emits queued events through the bus", () => {
|
|
104
|
-
// Emit with no subscriber — queues
|
|
105
|
-
const bus = getBus();
|
|
106
|
-
bus.emit("init:test2", "queued");
|
|
107
|
-
|
|
108
|
-
// initBus re-emits through the bus
|
|
109
|
-
initBus();
|
|
110
|
-
|
|
111
|
-
// Since no subscriber for test2, re-emit goes back to queue.
|
|
112
|
-
// Now subscribe — on() drains the re-queued event
|
|
113
|
-
const received: unknown[] = [];
|
|
114
|
-
bus.on("init:test2", (p) => received.push(p));
|
|
115
|
-
expect(received).toEqual(["queued"]);
|
|
116
|
-
});
|
|
117
|
-
|
|
118
|
-
it("initBus snapshots and clears queue before iterating", () => {
|
|
119
|
-
// Emit with no subscriber — queues
|
|
120
|
-
const bus = getBus();
|
|
121
|
-
bus.emit("snapshot:test", "a");
|
|
122
|
-
bus.emit("snapshot:test", "b");
|
|
123
|
-
|
|
124
|
-
// Subscribe — on() drains queue and delivers (but queue array persists)
|
|
125
|
-
const received: unknown[] = [];
|
|
126
|
-
bus.on("snapshot:test", (p) => received.push(p));
|
|
127
|
-
expect(received).toEqual(["a", "b"]);
|
|
128
|
-
|
|
129
|
-
// Queue array still has items (on() delivers but doesn't remove)
|
|
130
|
-
const queueBefore = (globalThis as Record<symbol, unknown>)[QUEUE_KEY] as Array<unknown>;
|
|
131
|
-
expect(queueBefore.length).toBe(2);
|
|
132
|
-
|
|
133
|
-
// initBus snapshots, clears queue, and re-emits
|
|
134
|
-
initBus();
|
|
135
|
-
// Re-emits go to existing subscriber (not re-queued)
|
|
136
|
-
expect(received).toEqual(["a", "b", "a", "b"]);
|
|
137
|
-
|
|
138
|
-
// Queue is now empty (initBus cleared it)
|
|
139
|
-
const queueAfter = (globalThis as Record<symbol, unknown>)[QUEUE_KEY] as Array<unknown>;
|
|
140
|
-
expect(queueAfter.length).toBe(0);
|
|
141
|
-
});
|
|
142
|
-
});
|
|
143
|
-
|
|
144
|
-
// ── Events constant ─────────────────────────────────────────────────────────
|
|
145
|
-
|
|
146
|
-
describe("Events constant", () => {
|
|
147
|
-
it("all event names are defined", () => {
|
|
148
|
-
expect(Events.COST_UPDATE).toBe("archimedes:cost_update");
|
|
149
|
-
expect(Events.TODOS_UPDATE).toBe("archimedes:todos_update");
|
|
150
|
-
expect(Events.TODOS_CLEAR).toBe("archimedes:todos_clear");
|
|
151
|
-
expect(Events.ASK_REQUEST).toBe("archimedes:ask_request");
|
|
152
|
-
expect(Events.ASK_RESPONSE).toBe("archimedes:ask_response");
|
|
153
|
-
});
|
|
154
|
-
});
|