@pi-archimedes/core 2.7.3 → 2.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/README.md +6 -27
- package/package.json +8 -6
- package/src/bridge/channel.test.ts +52 -0
- package/src/bridge/channel.ts +322 -0
- package/src/bridge/events.ts +197 -0
- package/src/bridge/index.test.ts +722 -0
- package/src/bridge/index.ts +166 -0
- package/src/bridge/self-usage.test.ts +146 -0
- package/src/bridge/self-usage.ts +37 -0
- package/src/bus.ts +8 -1
- package/src/config.test.ts +8 -103
- package/src/config.ts +4 -66
- package/src/index.test.ts +19 -350
- package/src/index.ts +12 -269
- package/src/tool-render.ts +1 -1
- package/src/editor/index.test.ts +0 -820
- package/src/editor/index.ts +0 -379
- package/src/editor/spin-quips.test.ts +0 -106
- package/src/editor/spin-quips.ts +0 -44
- package/src/editor/spin.test.ts +0 -702
- package/src/editor/spin.ts +0 -471
- package/src/startup/capture.ts +0 -47
- package/src/startup/index.ts +0 -330
- package/src/startup/logo.test.ts +0 -260
- package/src/startup/logo.ts +0 -85
- package/src/startup/sections.test.ts +0 -314
- package/src/startup/sections.ts +0 -284
- package/src/startup/version.test.ts +0 -98
- package/src/startup/version.ts +0 -26
- package/src/thinking/patch.test.ts +0 -389
- package/src/thinking/patch.ts +0 -288
- package/src/thinking/theme.test.ts +0 -216
- package/src/thinking/theme.ts +0 -156
- package/src/thinking/transform.test.ts +0 -89
- package/src/thinking/transform.ts +0 -21
- package/src/thinking/unindent.test.ts +0 -74
- package/src/thinking/unindent.ts +0 -74
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
// ── Bridge public API ────────────────────────────────────────────────────
|
|
2
|
+
//
|
|
3
|
+
// getBridge() is the singleton Tasks 2-3 import. The bridge is env-gated and
|
|
4
|
+
// root-only: active iff (all 4 env vars present ∧ ctx.mode !== "tui" ∧
|
|
5
|
+
// PI_SUBAGENT_SOCKET absent), evaluated at session_start. TUI always wins; a
|
|
6
|
+
// subagent child (env present) is inactive. The `active` flag does not depend
|
|
7
|
+
// on a successful connection (lazy, per-message connect).
|
|
8
|
+
//
|
|
9
|
+
// Inactive API: ask → throws BridgeInactiveError (loud); confirm → false;
|
|
10
|
+
// password → "" (cancel).
|
|
11
|
+
|
|
12
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
13
|
+
import type { AskResponsePayload, AskRequestPayload } from "../bus.js";
|
|
14
|
+
import * as channel from "./channel.js";
|
|
15
|
+
import * as events from "./events.js";
|
|
16
|
+
|
|
17
|
+
export { BridgeTransportError, BridgeCancelledError } from "./channel.js";
|
|
18
|
+
|
|
19
|
+
export class BridgeInactiveError extends Error {
|
|
20
|
+
constructor() {
|
|
21
|
+
super("bridge is not active");
|
|
22
|
+
this.name = "BridgeInactiveError";
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function channelActive(): boolean {
|
|
27
|
+
return channel.isActive();
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function eventsState(): "working" | "idle" | "blocked" {
|
|
31
|
+
return events.state();
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function getBridge() {
|
|
35
|
+
return { active: channelActive(), ask, confirm, password, dispatch, state: eventsState };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Delegate a subagent task to the Client (desktop) instead of forking a
|
|
40
|
+
* child pi process. The desktop runs the subagent session (a full ACP
|
|
41
|
+
* session on its worker runtime) and answers with the final output +
|
|
42
|
+
* metrics.
|
|
43
|
+
*
|
|
44
|
+
* The request opts OUT of the 5-minute timeout (`timeoutMs: null`): the
|
|
45
|
+
* desktop's lifecycle (parent close / EOF / app exit) is the cancel path —
|
|
46
|
+
* the tool's AbortSignal cancels the request (the promise settles
|
|
47
|
+
* deterministically with a BridgeCancelledError — NOT a
|
|
48
|
+
* BridgeTransportError, so it can never trigger a fork fallback) and the
|
|
49
|
+
* socket close delivers the desktop's EOF → the subagent session cancels.
|
|
50
|
+
* An unreachable bridge rejects with BridgeTransportError (the subagent's
|
|
51
|
+
* fork-fallback trigger); a response-carrying `error` (incl. the terminal
|
|
52
|
+
* `"cancelled"` frame) is authoritative and never a fallback.
|
|
53
|
+
*/
|
|
54
|
+
export interface DispatchParams {
|
|
55
|
+
agentName: string;
|
|
56
|
+
task: string;
|
|
57
|
+
systemPrompt: string | null;
|
|
58
|
+
model: string | null;
|
|
59
|
+
thinking: string | null;
|
|
60
|
+
tools: string[] | null;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export interface DispatchResult {
|
|
64
|
+
output: string;
|
|
65
|
+
metrics: { inputTokens: number; outputTokens: number; cost: number; durationMs: number };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function dispatch(params: DispatchParams, toolCallId: string | undefined, signal?: AbortSignal): Promise<DispatchResult> {
|
|
69
|
+
if (!channelActive()) throw new BridgeInactiveError();
|
|
70
|
+
const handle = channel.request<DispatchResult>("dispatch_subagent", params, { toolCallId, source: "main", timeoutMs: null });
|
|
71
|
+
if (signal) {
|
|
72
|
+
// Same abort wiring as ask (see there for the full rationale): a
|
|
73
|
+
// pre-aborted signal cancels immediately, the listener is removed on
|
|
74
|
+
// settle via the .then(onFulfilled, onRejected) form.
|
|
75
|
+
if (signal.aborted) handle.cancel();
|
|
76
|
+
else signal.addEventListener("abort", handle.cancel, { once: true });
|
|
77
|
+
handle.promise.then(
|
|
78
|
+
() => { signal?.removeEventListener("abort", handle.cancel); },
|
|
79
|
+
() => { signal?.removeEventListener("abort", handle.cancel); },
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
return handle.promise;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Ask the Client (the root's direct Client request — NOT the relay/bridge bus
|
|
87
|
+
* subscription). The param is typed structurally from the bus payload —
|
|
88
|
+
* AskQuestion lives in packages/ask, which core cannot import.
|
|
89
|
+
*/
|
|
90
|
+
export function ask(params: { questions: AskRequestPayload["questions"] }, toolCallId: string, signal?: AbortSignal): Promise<AskResponsePayload> {
|
|
91
|
+
if (!channelActive()) throw new BridgeInactiveError();
|
|
92
|
+
const handle = channel.request<AskResponsePayload>("ask", params, { toolCallId, source: "main" });
|
|
93
|
+
if (signal) {
|
|
94
|
+
// Abort the turn → cancel the Client request (the tool's catch converts the
|
|
95
|
+
// cancel-rejection into the paired cancelled ASK_RESPONSE, so the refcount
|
|
96
|
+
// stays balanced). A signal that is ALREADY aborted never dispatches its
|
|
97
|
+
// "abort" event (it fires exactly once, at abort time), so an addEventListener
|
|
98
|
+
// alone would leave the request lingering until the Client responds or the
|
|
99
|
+
// 5-minute timeout — call cancel() directly in that case. cancel() is
|
|
100
|
+
// idempotent and settles the promise deterministically (the
|
|
101
|
+
// socket-close handler it triggers is a no-op once settled), so both
|
|
102
|
+
// branches coexist safely. The listener is removed on settle; the
|
|
103
|
+
// .then(onFulfilled, onRejected) form (not .finally) avoids an unhandled
|
|
104
|
+
// rejection from the derived promise when the request rejects.
|
|
105
|
+
if (signal.aborted) handle.cancel();
|
|
106
|
+
else signal.addEventListener("abort", handle.cancel, { once: true });
|
|
107
|
+
handle.promise.then(
|
|
108
|
+
() => { signal?.removeEventListener("abort", handle.cancel); },
|
|
109
|
+
() => { signal?.removeEventListener("abort", handle.cancel); },
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
return handle.promise;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export function confirm(params: { command: string; reason: string }): Promise<boolean> {
|
|
116
|
+
if (!channelActive()) return Promise.resolve(false);
|
|
117
|
+
return channel.request<{ confirmed: boolean }>("confirm", params, { source: "main" }).promise
|
|
118
|
+
.then((r) => r.confirmed)
|
|
119
|
+
.catch(() => false);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export function password(params: { command: string; reason: string }): Promise<string> {
|
|
123
|
+
if (!channelActive()) return Promise.resolve("");
|
|
124
|
+
return channel.request<{ password?: string }>("password", params, { source: "main" }).promise
|
|
125
|
+
.then((r) => r.password ?? "")
|
|
126
|
+
.catch(() => "");
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Bridge-mode gate. PI_ARCHIMEDES_BRIDGE_SERVER_PID is presence-checked only
|
|
131
|
+
* in v1: agent-side server-pid verification is unimplemented on both
|
|
132
|
+
* platforms (Node's `net` exposes neither `GetNamedPipeServerProcessId` nor
|
|
133
|
+
* `SO_PEERCRED`); the desktop-side descendant check is the real defense.
|
|
134
|
+
*/
|
|
135
|
+
function isBridgeMode(ctx: { mode: string }): boolean {
|
|
136
|
+
return (
|
|
137
|
+
process.env.PI_ARCHIMEDES_BRIDGE === "1" &&
|
|
138
|
+
!!process.env.PI_ARCHIMEDES_BRIDGE_SOCKET &&
|
|
139
|
+
!!process.env.PI_ARCHIMEDES_BRIDGE_SESSION &&
|
|
140
|
+
!!process.env.PI_ARCHIMEDES_BRIDGE_SERVER_PID &&
|
|
141
|
+
ctx.mode !== "tui" &&
|
|
142
|
+
!process.env.PI_SUBAGENT_SOCKET
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Subscribe the pi EXTENSION events (NOT the bus — nothing emits
|
|
148
|
+
* agent_start/agent_settled/session_start on the bus). The extension events DO
|
|
149
|
+
* fire in RPC mode (the RPC driver runs the same session machinery).
|
|
150
|
+
*/
|
|
151
|
+
export function registerBridge(pi: ExtensionAPI): void {
|
|
152
|
+
pi.on("session_start", (_e, ctx) => {
|
|
153
|
+
const active = isBridgeMode(ctx);
|
|
154
|
+
channel.configure({ active, socketPath: process.env.PI_ARCHIMEDES_BRIDGE_SOCKET });
|
|
155
|
+
if (active) {
|
|
156
|
+
events.start();
|
|
157
|
+
events.onSessionStart(ctx);
|
|
158
|
+
}
|
|
159
|
+
});
|
|
160
|
+
pi.on("agent_start", () => {
|
|
161
|
+
if (channelActive()) events.onAgentStart();
|
|
162
|
+
});
|
|
163
|
+
pi.on("agent_settled", () => {
|
|
164
|
+
if (channelActive()) events.onAgentSettled();
|
|
165
|
+
});
|
|
166
|
+
}
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from "vitest";
|
|
2
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import { getBus, Events } from "../bus.js";
|
|
4
|
+
import { registerSelfUsage } from "./self-usage.js";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Bus discipline (load-bearing): the bus has NO reset seam, `emit` with zero
|
|
8
|
+
* subscribers queues the payload globally, and `on()` replays queued events
|
|
9
|
+
* WITHOUT removing them. So EVERY test subscribes a per-test listener FIRST,
|
|
10
|
+
* then invokes the handler, asserts only on that test's captured array, and
|
|
11
|
+
* unsubscribes via the `on` return value.
|
|
12
|
+
*/
|
|
13
|
+
function captureCostUpdates(): { events: unknown[]; done: () => void } {
|
|
14
|
+
const events: unknown[] = [];
|
|
15
|
+
const unsub = getBus().on(Events.COST_UPDATE, (p) => events.push(p));
|
|
16
|
+
return { events, done: unsub };
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Register and capture the turn_end handler, using the repo's mock-ExtensionAPI
|
|
21
|
+
* idiom (verbatim from bridge/index.test.ts — a plain object fails `tsc` with
|
|
22
|
+
* TS2740; the `as unknown as ExtensionAPI` cast and the `mock.calls` re-typing
|
|
23
|
+
* are BOTH required).
|
|
24
|
+
*/
|
|
25
|
+
function registerAndGetTurnEndHandler(): (e: unknown, ctx: unknown) => void {
|
|
26
|
+
const onSpy = vi.fn();
|
|
27
|
+
registerSelfUsage({ on: onSpy } as unknown as ExtensionAPI);
|
|
28
|
+
const calls = onSpy.mock.calls as Array<[string, (e: unknown, ctx: unknown) => void]>;
|
|
29
|
+
const handler = calls.find(([event]) => event === "turn_end")![1];
|
|
30
|
+
expect(handler).toBeTruthy();
|
|
31
|
+
return handler;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function assistantMessage(overrides: { usage?: unknown }): { role: "assistant"; usage?: unknown } {
|
|
35
|
+
return { role: "assistant", ...overrides };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// ── 1. per-turn emit ─────────────────────────────────────────────────────
|
|
39
|
+
|
|
40
|
+
describe("per-turn emit", () => {
|
|
41
|
+
it("emits a COST_UPDATE with the turn's usage (delta, exact shape — no reasoning/totalTokens)", () => {
|
|
42
|
+
const handler = registerAndGetTurnEndHandler();
|
|
43
|
+
const { events, done } = captureCostUpdates();
|
|
44
|
+
try {
|
|
45
|
+
handler({
|
|
46
|
+
type: "turn_end",
|
|
47
|
+
turnIndex: 0,
|
|
48
|
+
message: assistantMessage({
|
|
49
|
+
usage: {
|
|
50
|
+
input: 100,
|
|
51
|
+
output: 50,
|
|
52
|
+
cacheRead: 10,
|
|
53
|
+
cacheWrite: 5,
|
|
54
|
+
totalTokens: 165,
|
|
55
|
+
cost: { input: 0.001, output: 0.002, cacheRead: 0.0001, cacheWrite: 0.0002, total: 0.0033 },
|
|
56
|
+
},
|
|
57
|
+
}),
|
|
58
|
+
}, { mode: "tui" });
|
|
59
|
+
expect(events).toHaveLength(1);
|
|
60
|
+
expect(events[0]).toEqual({
|
|
61
|
+
source: "main",
|
|
62
|
+
inputTokens: 100,
|
|
63
|
+
outputTokens: 50,
|
|
64
|
+
cacheReadTokens: 10,
|
|
65
|
+
cacheWriteTokens: 5,
|
|
66
|
+
cost: 0.0033,
|
|
67
|
+
});
|
|
68
|
+
} finally {
|
|
69
|
+
done();
|
|
70
|
+
}
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
// ── 2. no usage → no emit ────────────────────────────────────────────────
|
|
75
|
+
|
|
76
|
+
describe("no usage", () => {
|
|
77
|
+
it("an assistant message without usage emits nothing", () => {
|
|
78
|
+
const handler = registerAndGetTurnEndHandler();
|
|
79
|
+
const { events, done } = captureCostUpdates();
|
|
80
|
+
try {
|
|
81
|
+
handler({
|
|
82
|
+
type: "turn_end",
|
|
83
|
+
turnIndex: 0,
|
|
84
|
+
message: assistantMessage({}),
|
|
85
|
+
}, { mode: "tui" });
|
|
86
|
+
expect(events).toHaveLength(0);
|
|
87
|
+
} finally {
|
|
88
|
+
done();
|
|
89
|
+
}
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
// ── 3. two turns → two events (delta semantics) ──────────────────────────
|
|
94
|
+
|
|
95
|
+
describe("delta semantics", () => {
|
|
96
|
+
it("two turns emit two events with per-turn values (NOT accumulated)", () => {
|
|
97
|
+
const handler = registerAndGetTurnEndHandler();
|
|
98
|
+
const { events, done } = captureCostUpdates();
|
|
99
|
+
try {
|
|
100
|
+
handler({
|
|
101
|
+
type: "turn_end",
|
|
102
|
+
turnIndex: 0,
|
|
103
|
+
message: assistantMessage({
|
|
104
|
+
usage: { input: 100, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 100, cost: { input: 0.001, output: 0, cacheRead: 0, cacheWrite: 0, total: 0.001 } },
|
|
105
|
+
}),
|
|
106
|
+
}, { mode: "tui" });
|
|
107
|
+
handler({
|
|
108
|
+
type: "turn_end",
|
|
109
|
+
turnIndex: 1,
|
|
110
|
+
message: assistantMessage({
|
|
111
|
+
usage: { input: 200, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 200, cost: { input: 0.002, output: 0, cacheRead: 0, cacheWrite: 0, total: 0.002 } },
|
|
112
|
+
}),
|
|
113
|
+
}, { mode: "tui" });
|
|
114
|
+
expect(events).toHaveLength(2);
|
|
115
|
+
expect((events[0] as { inputTokens: number }).inputTokens).toBe(100);
|
|
116
|
+
expect((events[1] as { inputTokens: number }).inputTokens).toBe(200); // NOT 300
|
|
117
|
+
} finally {
|
|
118
|
+
done();
|
|
119
|
+
}
|
|
120
|
+
});
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
// ── 4. unaffected by bridge mode ────────────────────────────────────────
|
|
124
|
+
|
|
125
|
+
describe("bridge independence", () => {
|
|
126
|
+
it("emits without the bridge channel being configured (bus is in-process; only the bridge forwarder is gated)", () => {
|
|
127
|
+
// NO configure() of the bridge channel anywhere in this file — the channel
|
|
128
|
+
// stays inactive by default. The emitter must still fire the bus event
|
|
129
|
+
// (design decision 3: registered unconditionally, not gated on bridge mode).
|
|
130
|
+
const handler = registerAndGetTurnEndHandler();
|
|
131
|
+
const { events, done } = captureCostUpdates();
|
|
132
|
+
try {
|
|
133
|
+
handler({
|
|
134
|
+
type: "turn_end",
|
|
135
|
+
turnIndex: 0,
|
|
136
|
+
message: assistantMessage({
|
|
137
|
+
usage: { input: 1, output: 1, cacheRead: 0, cacheWrite: 0, totalTokens: 2, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } },
|
|
138
|
+
}),
|
|
139
|
+
}, { mode: "tui" });
|
|
140
|
+
expect(events).toHaveLength(1);
|
|
141
|
+
expect((events[0] as { source: string }).source).toBe("main");
|
|
142
|
+
} finally {
|
|
143
|
+
done();
|
|
144
|
+
}
|
|
145
|
+
});
|
|
146
|
+
});
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { getBus, Events } from "../bus.js";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Minimal structural mirror of the pi-ai `Usage` type (core does not depend on
|
|
6
|
+
* @earendil-works/pi-ai — keep the dependency surface unchanged).
|
|
7
|
+
*/
|
|
8
|
+
interface UsageLike {
|
|
9
|
+
input?: number;
|
|
10
|
+
output?: number;
|
|
11
|
+
cacheRead?: number;
|
|
12
|
+
cacheWrite?: number;
|
|
13
|
+
cost?: { total?: number };
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Emit the session's OWN usage as a per-turn COST_UPDATE (source "main").
|
|
18
|
+
* Per design decision 1-3: per-turn (turn_end), delta (the turn's usage is
|
|
19
|
+
* not cumulative), NOT gated on bridge mode (the TUI footer's
|
|
20
|
+
* CostAccumulator sums it — the footer's cost line becomes the true session
|
|
21
|
+
* cost; in bridge mode the existing events.ts forwarder carries it to the
|
|
22
|
+
* desktop). `reasoning` is excluded (a subset of `output` — double-count).
|
|
23
|
+
*/
|
|
24
|
+
export function registerSelfUsage(pi: ExtensionAPI): void {
|
|
25
|
+
pi.on("turn_end", (event) => {
|
|
26
|
+
const usage = (event.message as { usage?: UsageLike } | undefined)?.usage;
|
|
27
|
+
if (!usage) return;
|
|
28
|
+
getBus().emit(Events.COST_UPDATE, {
|
|
29
|
+
source: "main",
|
|
30
|
+
inputTokens: usage.input ?? 0,
|
|
31
|
+
outputTokens: usage.output ?? 0,
|
|
32
|
+
cacheReadTokens: usage.cacheRead ?? 0,
|
|
33
|
+
cacheWriteTokens: usage.cacheWrite ?? 0,
|
|
34
|
+
cost: usage.cost?.total ?? 0,
|
|
35
|
+
});
|
|
36
|
+
});
|
|
37
|
+
}
|
package/src/bus.ts
CHANGED
|
@@ -104,6 +104,7 @@ export const Events = {
|
|
|
104
104
|
TODOS_CLEAR: "archimedes:todos_clear",
|
|
105
105
|
ASK_REQUEST: "archimedes:ask_request",
|
|
106
106
|
ASK_RESPONSE: "archimedes:ask_response",
|
|
107
|
+
ASK_CANCEL: "archimedes:ask_cancel",
|
|
107
108
|
} as const;
|
|
108
109
|
|
|
109
110
|
interface TodoUpdatePayload {
|
|
@@ -118,6 +119,7 @@ interface TodoClearPayload {
|
|
|
118
119
|
interface AskRequestPayload {
|
|
119
120
|
source: string; // "subagent:<agent-name>"
|
|
120
121
|
requestId: string; // unique id to match request → response
|
|
122
|
+
toolCallId?: string; // the tool-call id of the process that invoked the tool (carried for Client correlation)
|
|
121
123
|
questions: Array<{ id: string; question: string; description?: string; options: Array<{ label: string }>; multi?: boolean; recommended?: number }>;
|
|
122
124
|
}
|
|
123
125
|
|
|
@@ -127,4 +129,9 @@ interface AskResponsePayload {
|
|
|
127
129
|
results: Array<{ id: string; selectedOptions: string[]; customInput?: string }>;
|
|
128
130
|
}
|
|
129
131
|
|
|
130
|
-
|
|
132
|
+
interface AskCancelPayload {
|
|
133
|
+
requestId: string;
|
|
134
|
+
source: string;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export type { CostUpdatePayload, TodoUpdatePayload, TodoClearPayload, AskRequestPayload, AskResponsePayload, AskCancelPayload };
|
package/src/config.test.ts
CHANGED
|
@@ -1,115 +1,20 @@
|
|
|
1
|
-
import { describe, it, expect
|
|
2
|
-
|
|
3
|
-
vi.mock("./settings-io.js", () => ({
|
|
4
|
-
loadConfig: vi.fn(),
|
|
5
|
-
saveConfig: vi.fn(),
|
|
6
|
-
}));
|
|
7
|
-
|
|
8
|
-
const { loadCoreConfig, saveCoreConfig, DEFAULT_CORE_CONFIG, ANIMATION_STYLES } =
|
|
9
|
-
await import("./config.js");
|
|
10
|
-
const { loadConfig, saveConfig } = await import("./settings-io.js");
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { loadCoreConfig, saveCoreConfig, DEFAULT_CORE_CONFIG } from "./config.js";
|
|
11
3
|
|
|
12
4
|
describe("loadCoreConfig", () => {
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
});
|
|
16
|
-
|
|
17
|
-
it("uses correct namespace", () => {
|
|
18
|
-
vi.mocked(loadConfig).mockReturnValue(DEFAULT_CORE_CONFIG);
|
|
19
|
-
loadCoreConfig();
|
|
20
|
-
expect(loadConfig).toHaveBeenCalledWith("archimedes.core", DEFAULT_CORE_CONFIG);
|
|
21
|
-
});
|
|
22
|
-
|
|
23
|
-
it("returns default config when no settings exist", () => {
|
|
24
|
-
vi.mocked(loadConfig).mockReturnValue(DEFAULT_CORE_CONFIG);
|
|
25
|
-
const result = loadCoreConfig();
|
|
26
|
-
expect(result).toEqual({
|
|
27
|
-
mutedTheme: false,
|
|
28
|
-
codeUnindent: true,
|
|
29
|
-
labelText: "Thinking...",
|
|
30
|
-
labelColor: "255,215,0",
|
|
31
|
-
animationStyle: "vertical-up",
|
|
32
|
-
editorSpinBorder: true,
|
|
33
|
-
editorSpinSpeed: "normal",
|
|
34
|
-
editorSpinLabel: "Working",
|
|
35
|
-
editorSpinStyle: "pendulum",
|
|
36
|
-
});
|
|
37
|
-
});
|
|
38
|
-
|
|
39
|
-
it("passes through merged config from settings-io", () => {
|
|
40
|
-
const merged = { ...DEFAULT_CORE_CONFIG, mutedTheme: true };
|
|
41
|
-
vi.mocked(loadConfig).mockReturnValue(merged);
|
|
42
|
-
const result = loadCoreConfig();
|
|
43
|
-
expect(result).toEqual(merged);
|
|
5
|
+
it("returns empty config", () => {
|
|
6
|
+
expect(loadCoreConfig()).toEqual({});
|
|
44
7
|
});
|
|
45
8
|
});
|
|
46
9
|
|
|
47
10
|
describe("saveCoreConfig", () => {
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
});
|
|
51
|
-
|
|
52
|
-
it("saves with correct namespace", () => {
|
|
53
|
-
saveCoreConfig(DEFAULT_CORE_CONFIG);
|
|
54
|
-
expect(saveConfig).toHaveBeenCalledWith("archimedes.core", DEFAULT_CORE_CONFIG);
|
|
55
|
-
});
|
|
56
|
-
|
|
57
|
-
it("passes config through unchanged", () => {
|
|
58
|
-
const config = { ...DEFAULT_CORE_CONFIG, mutedTheme: true };
|
|
59
|
-
saveCoreConfig(config);
|
|
60
|
-
expect(saveConfig).toHaveBeenCalledWith("archimedes.core", config);
|
|
11
|
+
it("does nothing", () => {
|
|
12
|
+
expect(() => saveCoreConfig({})).not.toThrow();
|
|
61
13
|
});
|
|
62
14
|
});
|
|
63
15
|
|
|
64
16
|
describe("DEFAULT_CORE_CONFIG", () => {
|
|
65
|
-
it("
|
|
66
|
-
expect(DEFAULT_CORE_CONFIG).toEqual({
|
|
67
|
-
mutedTheme: false,
|
|
68
|
-
codeUnindent: true,
|
|
69
|
-
labelText: "Thinking...",
|
|
70
|
-
labelColor: "255,215,0",
|
|
71
|
-
animationStyle: "vertical-up",
|
|
72
|
-
editorSpinBorder: true,
|
|
73
|
-
editorSpinSpeed: "normal",
|
|
74
|
-
editorSpinLabel: "Working",
|
|
75
|
-
editorSpinStyle: "pendulum",
|
|
76
|
-
});
|
|
77
|
-
});
|
|
78
|
-
|
|
79
|
-
it("exposes a speed→multiplier map (slow/normal/fast = 1.5/1/0.6 × native tempo)", async () => {
|
|
80
|
-
const { SPIN_SPEED_MULT } = await import("./config.js");
|
|
81
|
-
expect(SPIN_SPEED_MULT).toEqual({ slow: 1.5, normal: 1, fast: 0.6 });
|
|
82
|
-
});
|
|
83
|
-
|
|
84
|
-
it("exposes the SPIN_INTERVALS key set (ten gallery-derived styles)", async () => {
|
|
85
|
-
const { SPIN_INTERVALS } = await import("./editor/spin.js");
|
|
86
|
-
expect(Object.keys(SPIN_INTERVALS)).toEqual([
|
|
87
|
-
"typing",
|
|
88
|
-
"pulse",
|
|
89
|
-
"rain",
|
|
90
|
-
"cascade",
|
|
91
|
-
"columns",
|
|
92
|
-
"wave-rows",
|
|
93
|
-
"diagonal-swipe",
|
|
94
|
-
"sparkle",
|
|
95
|
-
"pendulum",
|
|
96
|
-
"marquee",
|
|
97
|
-
]);
|
|
98
|
-
});
|
|
99
|
-
});
|
|
100
|
-
|
|
101
|
-
describe("ANIMATION_STYLES", () => {
|
|
102
|
-
it("contains all expected styles", () => {
|
|
103
|
-
expect(ANIMATION_STYLES).toEqual([
|
|
104
|
-
"diagonal",
|
|
105
|
-
"top-right",
|
|
106
|
-
"bottom-left",
|
|
107
|
-
"bottom-right",
|
|
108
|
-
"center-out",
|
|
109
|
-
"wave",
|
|
110
|
-
"horizontal",
|
|
111
|
-
"vertical",
|
|
112
|
-
"vertical-up",
|
|
113
|
-
]);
|
|
17
|
+
it("is empty", () => {
|
|
18
|
+
expect(DEFAULT_CORE_CONFIG).toEqual({});
|
|
114
19
|
});
|
|
115
20
|
});
|
package/src/config.ts
CHANGED
|
@@ -1,68 +1,6 @@
|
|
|
1
1
|
import { loadConfig, saveConfig } from "./settings-io.js";
|
|
2
2
|
|
|
3
|
-
export
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
"bottom-right",
|
|
8
|
-
"center-out",
|
|
9
|
-
"wave",
|
|
10
|
-
"horizontal",
|
|
11
|
-
"vertical",
|
|
12
|
-
"vertical-up",
|
|
13
|
-
] as const;
|
|
14
|
-
export type AnimationStyle = (typeof ANIMATION_STYLES)[number];
|
|
15
|
-
|
|
16
|
-
export interface CoreConfig {
|
|
17
|
-
mutedTheme: boolean;
|
|
18
|
-
codeUnindent: boolean;
|
|
19
|
-
labelText: string;
|
|
20
|
-
labelColor: string;
|
|
21
|
-
animationStyle: AnimationStyle;
|
|
22
|
-
editorSpinBorder: boolean;
|
|
23
|
-
editorSpinSpeed: "slow" | "normal" | "fast";
|
|
24
|
-
editorSpinLabel: string;
|
|
25
|
-
editorSpinStyle: SpinnerStyle;
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
/** Border-spinner style (the ten gallery-derived variants, all registered in `SPIN_VARIANTS` (`editor/spin.ts`); unknown values normalize to `typing` via `normalizeSpinnerStyle`). */
|
|
29
|
-
export type SpinnerStyle =
|
|
30
|
-
| "typing"
|
|
31
|
-
| "pulse"
|
|
32
|
-
| "rain"
|
|
33
|
-
| "cascade"
|
|
34
|
-
| "columns"
|
|
35
|
-
| "wave-rows"
|
|
36
|
-
| "diagonal-swipe"
|
|
37
|
-
| "sparkle"
|
|
38
|
-
| "pendulum"
|
|
39
|
-
| "marquee";
|
|
40
|
-
|
|
41
|
-
export const DEFAULT_CORE_CONFIG: CoreConfig = {
|
|
42
|
-
mutedTheme: false,
|
|
43
|
-
codeUnindent: true,
|
|
44
|
-
labelText: "Thinking...",
|
|
45
|
-
labelColor: "255,215,0",
|
|
46
|
-
animationStyle: "vertical-up",
|
|
47
|
-
editorSpinBorder: true,
|
|
48
|
-
editorSpinSpeed: "normal",
|
|
49
|
-
editorSpinLabel: "Working",
|
|
50
|
-
editorSpinStyle: "pendulum",
|
|
51
|
-
};
|
|
52
|
-
|
|
53
|
-
/** Border-spinner speed setting → the multipliers the `editorSpinSpeed` setting applies to the style's native per-tick interval (SPIN_INTERVALS, `editor/spin.ts` — typing: 120 / 80 / 48 ms). */
|
|
54
|
-
export const SPIN_SPEED_MULT: Record<CoreConfig["editorSpinSpeed"], number> = {
|
|
55
|
-
slow: 1.5,
|
|
56
|
-
normal: 1,
|
|
57
|
-
fast: 0.6,
|
|
58
|
-
};
|
|
59
|
-
|
|
60
|
-
const NAMESPACE = "archimedes.core";
|
|
61
|
-
|
|
62
|
-
export function loadCoreConfig(): CoreConfig {
|
|
63
|
-
return loadConfig(NAMESPACE, DEFAULT_CORE_CONFIG);
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
export function saveCoreConfig(config: CoreConfig): void {
|
|
67
|
-
saveConfig(NAMESPACE, config);
|
|
68
|
-
}
|
|
3
|
+
export interface CoreConfig {}
|
|
4
|
+
export const DEFAULT_CORE_CONFIG: CoreConfig = {};
|
|
5
|
+
export function loadCoreConfig(): CoreConfig { return {}; }
|
|
6
|
+
export function saveCoreConfig(_config: CoreConfig): void {}
|