@preapexis/piexis 0.2.4 → 0.2.5

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/tests/ui.test.mjs DELETED
@@ -1,450 +0,0 @@
1
- import assert from "node:assert/strict";
2
- import test from "node:test";
3
- import { access, mkdtemp, mkdir, readFile, readdir, rm, writeFile } from "node:fs/promises";
4
- import { tmpdir } from "node:os";
5
- import { join } from "node:path";
6
- import { visibleWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
7
- import uiExtension from "../extensions/ui.ts";
8
- import tipList from "../lib/ui-tips.json" with { type: "json" };
9
-
10
- const displayTip = (tip) => `[${tip.category}] ${tip.text}`;
11
-
12
- function fixture(mode = "tui", theme = { fg: (_color, text) => text, bold: (text) => text }) {
13
- const handlers = new Map();
14
- let thinking = "high";
15
- let renders = 0;
16
- let unsubscribed = false;
17
- let branchChanged;
18
- let footer;
19
- let header;
20
- let title;
21
- let entries = [];
22
- let sessionEntries;
23
- let branch = "main";
24
- const statuses = new Map();
25
- let context = { percent: 25, tokens: 50_000, contextWindow: 200_000 };
26
- const ctx = {
27
- mode,
28
- cwd: "/projects/demo",
29
- isProjectTrusted: () => true,
30
- model: { id: "gpt-5.4" },
31
- sessionManager: { getBranch: () => entries, getEntries: () => sessionEntries ?? entries },
32
- getContextUsage: () => context,
33
- ui: {
34
- setTitle: (value) => { title = value; },
35
- setHeader: (factory) => { header = factory({}, theme); },
36
- setFooter: (factory) => {
37
- footer = factory({ requestRender: () => { renders++; } }, theme, {
38
- getGitBranch: () => branch,
39
- getExtensionStatuses: () => statuses,
40
- onBranchChange: (callback) => {
41
- branchChanged = callback;
42
- return () => { unsubscribed = true; };
43
- }
44
- });
45
- }
46
- }
47
- };
48
- uiExtension({
49
- on: (name, handler) => { handlers.set(name, handler); },
50
- getThinkingLevel: () => thinking
51
- });
52
- return {
53
- ctx,
54
- emit: (name, event = {}) => handlers.get(name)(event, ctx),
55
- render: (width = 160) => footer.render(width),
56
- header: (width) => header.render(width),
57
- title: () => title,
58
- setEntries: (value) => { entries = value; },
59
- setSessionEntries: (value) => { sessionEntries = value; },
60
- setContext: (value) => { context = value; },
61
- setBranch: (value) => { branch = value; },
62
- setAgent: (value) => { statuses.set("piexis-agent", value); },
63
- setThinking: (value) => { thinking = value; },
64
- renders: () => renders,
65
- branchChanged: () => branchChanged(),
66
- dispose: () => footer.dispose(),
67
- unsubscribed: () => unsubscribed,
68
- hasFooter: () => Boolean(footer)
69
- };
70
- }
71
-
72
- function assistant(input, output, cacheRead, cacheWrite, cost) {
73
- return { type: "message", message: { role: "assistant", usage: { input, output, cacheRead, cacheWrite, cost: { total: cost } } } };
74
- }
75
-
76
- test("startup header keeps the wordmark left and full usage tips right", async (t) => {
77
- t.mock.method(Math, "random", () => 0);
78
- const wordmark = [
79
- "██████╗ ██╗███████╗██╗ ██╗██╗███████╗",
80
- "██╔══██╗██║██╔════╝╚██╗██╔╝██║██╔════╝",
81
- "██████╔╝██║█████╗ ╚███╔╝ ██║███████╗",
82
- "██╔═══╝ ██║██╔══╝ ██╔██╗ ██║╚════██║",
83
- "██║ ██║███████╗██╔╝ ██╗██║███████║",
84
- "╚═╝ ╚═╝╚══════╝╚═╝ ╚═╝╚═╝╚══════╝"
85
- ];
86
- const tips = [
87
- "Get started with PieXis",
88
- "Questions, skills, prompts & stats.",
89
- ...tipList.slice(0, 3).map(displayTip),
90
- "/help · /summary · /commit"
91
- ];
92
- const f = fixture();
93
- await f.emit("session_start");
94
- try {
95
- const artWidth = Math.max(...wordmark.map((line) => visibleWidth(line)));
96
- const column = artWidth + 3;
97
- for (const width of [80, 120]) {
98
- const header = f.header(width);
99
- for (const [index, line] of wordmark.entries()) {
100
- assert.equal(header[index].slice(0, column), line.padEnd(column));
101
- }
102
- assert.doesNotMatch(header.join("\n"), /demo/);
103
- assert.equal(f.title(), "π demo");
104
- for (const line of header.slice(wordmark.length)) {
105
- assert.equal(line.slice(0, artWidth).trim(), "");
106
- }
107
- const right = tips.flatMap((line) => wrapTextWithAnsi(line, width - column));
108
- assert.deepEqual(header.map((line) => line.slice(column)),
109
- Array.from({ length: Math.max(wordmark.length, right.length) }, (_, index) => right[index] ?? ""));
110
- assert.doesNotMatch(header.join("\n"), /[\u2800-\u28ff]/);
111
- }
112
- const wrapped = f.header(column + 32).map((line) => line.slice(column)).join(" ").replace(/\s+/g, " ");
113
- for (const tip of tips) assert.ok(wrapped.includes(tip), tip);
114
- assert.deepEqual(f.header(artWidth), [...wordmark, "", ...tips.flatMap((line) => wrapTextWithAnsi(line, artWidth))]);
115
- const fallback = f.header(artWidth - 1).join("\n");
116
- assert.match(fallback, /PieXis/);
117
- assert.ok(!fallback.includes(wordmark[0]));
118
- } finally {
119
- f.dispose();
120
- }
121
- });
122
-
123
- test("startup header stacks tips on narrow terminals and fits all resize widths", async (t) => {
124
- t.mock.method(Math, "random", () => 0);
125
- const f = fixture();
126
- f.ctx.cwd = `/projects/${"長いproject".repeat(30)}`;
127
- await f.emit("session_start");
128
- try {
129
- const compact = f.header(20);
130
- assert.match(compact[0], /PieXis/);
131
- assert.equal(compact[1], "");
132
- assert.match(compact.join("\n"), /\/help/);
133
- assert.match(f.header(60).join("\n"), /For ponytail/);
134
- for (let width = 0; width <= 160; width++) {
135
- for (const line of f.header(width)) {
136
- assert.ok(visibleWidth(line) <= width, `${width}: ${line}`);
137
- assert.doesNotMatch(line, /[\u2800-\u28ff]/);
138
- }
139
- }
140
- assert.ok(f.header(80).length >= 7);
141
- assert.ok(f.header(20).length > 7);
142
- } finally {
143
- f.dispose();
144
- }
145
- });
146
-
147
- test("startup header measures styled text without displaying the project name", async () => {
148
- const f = fixture("tui", {
149
- fg: (_color, text) => `\x1b[36m${text}\x1b[39m`,
150
- bold: (text) => `\x1b[1m${text}\x1b[22m`
151
- });
152
- f.ctx.cwd = `/projects/${"研究🚀".repeat(20)}`;
153
- await f.emit("session_start");
154
- try {
155
- for (const width of [1, 20, 38, 60, 73, 80, 120]) {
156
- for (const line of f.header(width)) assert.ok(visibleWidth(line) <= width);
157
- }
158
- assert.match(f.header(80)[0], /\x1b\[36m/);
159
- assert.doesNotMatch(f.header(80).join("\n"), /研究/);
160
- } finally {
161
- f.dispose();
162
- }
163
- });
164
-
165
- test("startup header explains missing project agent configuration", async () => {
166
- const root = await mkdtemp(join(tmpdir(), "piexis-ui-agent-config-"));
167
- const previous = process.env.PI_CODING_AGENT_DIR;
168
- try {
169
- const agentDir = join(root, "agent");
170
- await mkdir(agentDir);
171
- await mkdir(join(root, ".pi"));
172
- await writeFile(join(root, ".pi", "agent.json"), JSON.stringify({ model: "test/main" }));
173
- process.env.PI_CODING_AGENT_DIR = agentDir;
174
- const f = fixture();
175
- f.ctx.cwd = root;
176
- await f.emit("session_start");
177
- try {
178
- const text = f.header(200).join("\n");
179
- assert.match(text, /Agent config: add small_model to use a cheaper model for eligible small tasks\./);
180
- assert.match(text, /Agent config: add delegate_exploration to opt in to cheap, read-only codebase exploration\./);
181
- assert.doesNotMatch(text, /Agent config: add model/);
182
- for (let width = 1; width <= 200; width++) {
183
- for (const line of f.header(width)) assert.ok(visibleWidth(line) <= width);
184
- }
185
- } finally { f.dispose(); }
186
- } finally {
187
- if (previous === undefined) delete process.env.PI_CODING_AGENT_DIR;
188
- else process.env.PI_CODING_AGENT_DIR = previous;
189
- await rm(root, { recursive: true, force: true });
190
- }
191
- });
192
-
193
- test("startup tips stay stable across redraws and cycle without repeats", async (t) => {
194
- const random = t.mock.method(Math, "random", () => 0);
195
- const f = fixture();
196
- const seen = new Set();
197
- const shown = () => {
198
- const lines = f.header(200).map((line) => line.slice(41));
199
- return tipList.filter((tip) => lines.includes(displayTip(tip)));
200
- };
201
- try {
202
- for (let i = 0; i < tipList.length; i += 3) {
203
- await f.emit("session_start");
204
- const selected = shown();
205
- assert.equal(selected.length, 3);
206
- for (const tip of selected) {
207
- assert.ok(!seen.has(tip), `repeated before exhaustion: ${tip}`);
208
- seen.add(tip);
209
- }
210
- const stable = f.header(80);
211
- const calls = random.mock.callCount();
212
- f.header(20);
213
- await f.emit("model_select");
214
- assert.deepEqual(f.header(80), stable);
215
- assert.equal(random.mock.callCount(), calls);
216
- f.dispose();
217
- }
218
- assert.equal(seen.size, tipList.length);
219
- await f.emit("session_start");
220
- assert.deepEqual(shown(), tipList.slice(0, 3));
221
- } finally {
222
- f.dispose();
223
- }
224
- });
225
-
226
- test("startup tips use random choices and mix three different categories", async (t) => {
227
- t.mock.method(Math, "random", () => 0.999);
228
- const f = fixture();
229
- await f.emit("session_start");
230
- try {
231
- const text = f.header(200).join("\n");
232
- const selected = tipList.filter((tip) => text.includes(displayTip(tip)));
233
- assert.equal(selected.length, 3);
234
- assert.equal(new Set(selected.map((tip) => tip.category)).size, 3);
235
- assert.ok(selected.includes(tipList.at(-1)));
236
- assert.ok(!text.includes(displayTip(tipList[0])));
237
- } finally {
238
- f.dispose();
239
- }
240
- });
241
-
242
- test("tip JSON contains unique text and references bundled skills and prompts", async () => {
243
- assert.ok(tipList.length > 3);
244
- assert.equal(new Set(tipList.map((tip) => tip.text)).size, tipList.length);
245
- assert.ok(new Set(tipList.map((tip) => tip.category)).size >= 3);
246
- for (const tip of tipList) {
247
- assert.deepEqual(Object.keys(tip).sort(), ["category", "text"]);
248
- for (const value of [tip.category, tip.text]) {
249
- assert.equal(typeof value, "string");
250
- assert.ok(value.trim().length > 0);
251
- assert.doesNotMatch(value, /\x1b/);
252
- }
253
- assert.doesNotMatch(tip.text, /\/skill:/);
254
- for (const match of tip.text.matchAll(/^For ([a-z-]+),/g)) {
255
- await access(new URL(`../skills/${match[1]}/SKILL.md`, import.meta.url));
256
- }
257
- for (const match of tip.text.matchAll(/\/(init|commit|summary)\b/g)) {
258
- await access(new URL(`../prompts/${match[1]}.md`, import.meta.url));
259
- }
260
- }
261
- });
262
-
263
- test("natural-language skill tips cover bundled skills without explicit-only flags", async () => {
264
- const examples = new Set(tipList.flatMap((tip) =>
265
- [...tip.text.matchAll(/^For ([a-z-]+),/g)].map((match) => match[1])
266
- ));
267
- const entries = await readdir(new URL("../skills/", import.meta.url), { withFileTypes: true });
268
- const names = entries.filter((entry) => entry.isDirectory()).map((entry) => entry.name);
269
- assert.deepEqual([...examples].sort(), names.sort());
270
- for (const name of names) {
271
- const source = await readFile(new URL(`../skills/${name}/SKILL.md`, import.meta.url), "utf8");
272
- const frontmatter = source.match(/^---\r?\n([\s\S]*?)\r?\n---/)?.[1];
273
- assert.ok(frontmatter, name);
274
- assert.doesNotMatch(frontmatter, /^disable-model-invocation:\s*true\b/m, name);
275
- }
276
- const instructions = tipList.map((tip) => tip.text).join("\n");
277
- assert.match(instructions, /selection is not guaranteed/);
278
- assert.match(instructions, /disable-model-invocation: true/);
279
- });
280
-
281
- test("dashboard shows branch totals, full model, thinking, context and cost", async (t) => {
282
- const f = fixture();
283
- f.setEntries([
284
- { type: "message", message: { role: "user" } },
285
- assistant(1000, 200, 3000, 400, 0.01),
286
- assistant(500, 100, 1000, 100, 0.02)
287
- ]);
288
- await f.emit("session_start");
289
- t.after(() => f.dispose());
290
- assert.equal(f.title(), "π demo");
291
- const text = f.render().join("\n");
292
- for (const expected of ["● ready", "gpt-5.4", "think high", "⎇ main", "25%", "50.0k/200.0k", "↑1.5k ↓300", "cache 67%", "$0.03"]) {
293
- assert.ok(text.includes(expected), expected);
294
- }
295
- assert.match(text, /ctx ━{3}─{9}/);
296
- assert.match(f.render()[1], /\$0\.03$/);
297
- f.setEntries([assistant(20, 10, 0, 0, 0)]);
298
- assert.match(f.render().join("\n"), /↑20 ↓10/);
299
- assert.doesNotMatch(f.render().join("\n"), /↑1.5k|cache 67%/);
300
- });
301
-
302
- test("two-row layouts fit every width and preserve core fields at 40 columns", async (t) => {
303
- const f = fixture();
304
- f.setAgent("agent:orchestrator");
305
- await f.emit("session_start");
306
- t.after(() => f.dispose());
307
- assert.deepEqual(f.render(0), []);
308
- for (let width = 1; width <= 160; width++) {
309
- assert.equal(f.render(width).length, 2);
310
- for (const line of [...f.render(width), ...f.header(width)]) {
311
- assert.ok(visibleWidth(line) <= width, `${width}: ${line}`);
312
- }
313
- }
314
- const narrow = f.render(40).join("\n");
315
- for (const label of ["agent:orchestrator", "● ready", "gpt-5.4", "ctx", "25%", "$0.0000"]) {
316
- assert.ok(narrow.includes(label), label);
317
- }
318
- assert.doesNotMatch(narrow, /Alt\+A|prompts|cache|think high|main/);
319
- assert.match(f.render(160)[0], /gpt-5\.4.*think high.*main.*0 prompts.*Alt\+A/);
320
- });
321
-
322
- test("long styled Unicode fields shrink independently without hiding core status", async (t) => {
323
- const f = fixture("tui", {
324
- fg: (_color, text) => `\x1b[36m${text}\x1b[39m`,
325
- bold: (text) => `\x1b[1m${text}\x1b[22m`
326
- });
327
- f.setAgent("agent:orchestrator");
328
- f.setBranch("研究🚀".repeat(80));
329
- f.ctx.model = { id: "研究🚀".repeat(80) };
330
- await f.emit("session_start");
331
- t.after(() => f.dispose());
332
- await f.emit("tool_call", { toolName: "道具🚀".repeat(80) });
333
- for (let width = 0; width <= 160; width++) {
334
- for (const line of f.render(width)) assert.ok(visibleWidth(line) <= width, `${width}: ${line}`);
335
- }
336
- const narrow = f.render(40).join("\n");
337
- assert.match(narrow, /agent:orchestrator/);
338
- assert.match(narrow, /道具/);
339
- assert.match(narrow, /研究/);
340
- assert.match(narrow, /…/);
341
- assert.match(narrow, /25%.*\$0\.0000/);
342
- f.ctx.model = { id: "gpt-5.4" };
343
- await f.emit("agent_end");
344
- assert.match(f.render(160)[0], /gpt-5\.4.*⎇ 研究.*…/);
345
- f.ctx.model = undefined;
346
- f.setBranch(undefined);
347
- assert.match(f.render(160)[0], /ready.*—/);
348
- assert.doesNotMatch(f.render(160)[0], /⎇/);
349
- });
350
-
351
- test("unknown and oversized context values produce a bounded bar and warning tones", async (t) => {
352
- const colors = [];
353
- const f = fixture("tui", {
354
- fg: (color, text) => { colors.push([color, text]); return text; }, bold: (text) => text
355
- });
356
- await f.emit("session_start");
357
- t.after(() => f.dispose());
358
- f.setContext({ percent: null, tokens: null, contextWindow: 200_000 });
359
- assert.match(f.render().join("\n"), /ctx ─{12} —.*—\/200.0k/);
360
- assert.ok(colors.some(([color, value]) => color === "dim" && value === "—"));
361
- f.setContext(undefined);
362
- assert.match(f.render().join("\n"), /—\/—/);
363
- for (const [percent, tone] of [[25, "success"], [70, "warning"], [90, "error"], [120, "error"], [-5, "success"]]) {
364
- f.setContext({ percent, tokens: 240_000, contextWindow: 200_000 });
365
- colors.length = 0;
366
- const text = f.render().join("\n");
367
- assert.ok(colors.some(([color, value]) => color === tone && value === `${percent}%`));
368
- if (percent === 120) assert.match(text, /━{12} 120%/);
369
- if (percent === -5) assert.match(text, /─{12} -5%/);
370
- }
371
- });
372
-
373
- test("events refresh activity, model, thinking and usage; disposal releases branch listener", async () => {
374
- const f = fixture();
375
- await f.emit("session_start");
376
- await f.emit("agent_start");
377
- assert.match(f.render().join("\n"), /[\u2800-\u28ff] thinking/);
378
- await f.emit("tool_call", { toolName: "read" });
379
- assert.match(f.render().join("\n"), /[\u2800-\u28ff] read/);
380
- await f.emit("turn_start");
381
- assert.match(f.render().join("\n"), /[\u2800-\u28ff] thinking/);
382
- await f.emit("agent_end");
383
- assert.match(f.render().join("\n"), /● ready/);
384
- f.ctx.model = { id: "claude-sonnet-4-6" };
385
- f.setThinking("off");
386
- const before = f.renders();
387
- for (const event of ["model_select", "thinking_level_select", "message_end", "session_compact"]) await f.emit(event);
388
- f.branchChanged();
389
- assert.equal(f.renders(), before + 5);
390
- assert.match(f.render().join("\n"), /claude-sonnet-4-6.*think off/);
391
- f.dispose();
392
- assert.equal(f.unsubscribed(), true);
393
- const disposed = f.renders();
394
- await f.emit("agent_start");
395
- assert.equal(f.renders(), disposed);
396
- });
397
-
398
- test("prompt count includes restored session history, not tools or assistant turns", async () => {
399
- const f = fixture();
400
- const user = { type: "message", message: { role: "user", content: "Hello" } };
401
- f.setEntries([user]);
402
- f.setSessionEntries([
403
- user, user,
404
- assistant(10, 20, 0, 0, 0),
405
- { type: "message", message: { role: "toolResult" } },
406
- { type: "custom", data: {} }
407
- ]);
408
- await f.emit("session_start");
409
- try {
410
- assert.match(f.render().join("\n"), /2 prompts/);
411
- assert.doesNotMatch(f.render(40).join("\n"), /prompts/);
412
- f.setSessionEntries([user, user, user]);
413
- await f.emit("message_end");
414
- assert.match(f.render().join("\n"), /3 prompts/);
415
- f.setSessionEntries([]);
416
- assert.match(f.render().join("\n"), /0 prompts/);
417
- } finally {
418
- f.dispose();
419
- }
420
- });
421
-
422
- test("spinner redraws only while busy and disposal clears its timer", async (t) => {
423
- t.mock.timers.enable({ apis: ["setInterval", "Date"], now: 0 });
424
- const f = fixture();
425
- await f.emit("session_start");
426
- t.after(() => f.dispose());
427
- const ready = f.renders();
428
- t.mock.timers.tick(300);
429
- assert.equal(f.renders(), ready);
430
- await f.emit("agent_start");
431
- const first = f.render()[0];
432
- const busy = f.renders();
433
- t.mock.timers.tick(100);
434
- assert.equal(f.renders(), busy + 1);
435
- assert.notEqual(f.render()[0], first);
436
- f.dispose();
437
- const disposed = f.renders();
438
- t.mock.timers.tick(300);
439
- assert.equal(f.renders(), disposed);
440
- });
441
-
442
- test("non-TUI modes do not install terminal components", async () => {
443
- for (const mode of ["rpc", "print", "json"]) {
444
- const f = fixture(mode);
445
- await f.emit("session_start");
446
- await f.emit("agent_start");
447
- assert.equal(f.hasFooter(), false);
448
- assert.equal(f.title(), undefined);
449
- }
450
- });