@preapexis/piexis 0.1.2 → 0.2.4

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.
Files changed (154) hide show
  1. package/.github/workflows/publish.yml +35 -0
  2. package/AGENTS.md +21 -41
  3. package/README.md +75 -142
  4. package/extensions/agents.ts +205 -0
  5. package/extensions/ask-question.ts +54 -95
  6. package/extensions/recommended-tools.ts +62 -0
  7. package/extensions/ui.ts +204 -58
  8. package/lib/agent-config.ts +55 -0
  9. package/lib/agents.ts +208 -0
  10. package/lib/question-model.ts +98 -0
  11. package/lib/question-ui.ts +214 -0
  12. package/lib/recommended-tools.ts +119 -0
  13. package/lib/subagent-runner.ts +146 -0
  14. package/lib/ui-tips.json +41 -0
  15. package/package.json +9 -12
  16. package/prompts/init.md +10 -2
  17. package/prompts/summary.md +7 -6
  18. package/schemas/agent.schema.json +48 -0
  19. package/scripts/release.mjs +32 -0
  20. package/skills/{claude-api/LICENSE.txt → caveman/LICENSE} +2 -2
  21. package/skills/caveman/LICENSE-MIT +21 -0
  22. package/skills/caveman/NOTICE +10 -0
  23. package/skills/caveman/SKILL.md +89 -0
  24. package/skills/find-skills/SKILL.md +141 -0
  25. package/skills/ponytail/LICENSE +21 -0
  26. package/skills/ponytail/NOTICE +9 -0
  27. package/skills/ponytail/SKILL.md +52 -0
  28. package/skills-lock.json +6 -0
  29. package/tests/agents.test.mjs +605 -0
  30. package/tests/ask-question-pty.test.mjs +16 -0
  31. package/tests/ask-question.test.mjs +269 -0
  32. package/tests/fixtures/agents-provider.ts +55 -0
  33. package/tests/fixtures/ask-question-pty.py +91 -0
  34. package/tests/fixtures/ask-question-terminal.mjs +69 -0
  35. package/tests/fixtures/recommended-tools-terminal.mjs +103 -0
  36. package/tests/init-workflow.test.mjs +31 -0
  37. package/tests/recommended-tools-pty.test.mjs +16 -0
  38. package/tests/recommended-tools.test.mjs +423 -0
  39. package/tests/resources.test.mjs +66 -0
  40. package/tests/skills-routing.test.mjs +7 -4
  41. package/tests/ui-tip-reload.test.mjs +70 -0
  42. package/tests/ui.test.mjs +450 -0
  43. package/tsconfig.json +1 -0
  44. package/.github/workflows/npm-publish.yml +0 -63
  45. package/PERMISSION_MODES_PLAN.md +0 -279
  46. package/WHAT_YOU_GET.md +0 -153
  47. package/eval/README.md +0 -72
  48. package/eval/permission-cases.json +0 -28
  49. package/eval/permissions.mjs +0 -103
  50. package/extensions/background-tasks.ts +0 -433
  51. package/extensions/background-worker.ts +0 -50
  52. package/extensions/glob.ts +0 -32
  53. package/extensions/mode.ts +0 -397
  54. package/lib/bash-approvals.ts +0 -113
  55. package/lib/bash-policy.ts +0 -164
  56. package/lib/mode-policy.ts +0 -174
  57. package/lib/path-policy.ts +0 -182
  58. package/lib/permission-classifier.ts +0 -128
  59. package/lib/permission-config.ts +0 -91
  60. package/lib/permission-denials.ts +0 -32
  61. package/lib/permission-intent.ts +0 -128
  62. package/lib/permission-taxonomy.ts +0 -15
  63. package/lib/task-broker.ts +0 -65
  64. package/lib/task-checkout.ts +0 -39
  65. package/lib/task-git.ts +0 -44
  66. package/lib/task-patch.ts +0 -106
  67. package/lib/task-permissions.ts +0 -51
  68. package/lib/task-report.ts +0 -62
  69. package/lib/task-worktree-policy.ts +0 -56
  70. package/skills/claude-api/SKILL.md +0 -572
  71. package/skills/claude-api/csharp/claude-api/README.md +0 -362
  72. package/skills/claude-api/csharp/claude-api/batches.md +0 -14
  73. package/skills/claude-api/csharp/claude-api/files-api.md +0 -25
  74. package/skills/claude-api/csharp/claude-api/streaming.md +0 -28
  75. package/skills/claude-api/csharp/claude-api/tool-use.md +0 -164
  76. package/skills/claude-api/curl/examples.md +0 -256
  77. package/skills/claude-api/curl/managed-agents.md +0 -371
  78. package/skills/claude-api/go/claude-api/README.md +0 -188
  79. package/skills/claude-api/go/claude-api/files-api.md +0 -23
  80. package/skills/claude-api/go/claude-api/streaming.md +0 -43
  81. package/skills/claude-api/go/claude-api/tool-use.md +0 -220
  82. package/skills/claude-api/go/managed-agents/README.md +0 -564
  83. package/skills/claude-api/java/claude-api/README.md +0 -238
  84. package/skills/claude-api/java/claude-api/files-api.md +0 -27
  85. package/skills/claude-api/java/claude-api/streaming.md +0 -24
  86. package/skills/claude-api/java/claude-api/tool-use.md +0 -228
  87. package/skills/claude-api/java/managed-agents/README.md +0 -443
  88. package/skills/claude-api/php/claude-api/README.md +0 -174
  89. package/skills/claude-api/php/claude-api/batches.md +0 -15
  90. package/skills/claude-api/php/claude-api/files-api.md +0 -13
  91. package/skills/claude-api/php/claude-api/streaming.md +0 -27
  92. package/skills/claude-api/php/claude-api/tool-use.md +0 -253
  93. package/skills/claude-api/php/managed-agents/README.md +0 -443
  94. package/skills/claude-api/python/claude-api/README.md +0 -565
  95. package/skills/claude-api/python/claude-api/batches.md +0 -198
  96. package/skills/claude-api/python/claude-api/files-api.md +0 -170
  97. package/skills/claude-api/python/claude-api/sdk-upgrade.md +0 -286
  98. package/skills/claude-api/python/claude-api/streaming.md +0 -179
  99. package/skills/claude-api/python/claude-api/tool-use.md +0 -627
  100. package/skills/claude-api/python/managed-agents/README.md +0 -335
  101. package/skills/claude-api/ruby/claude-api/README.md +0 -134
  102. package/skills/claude-api/ruby/claude-api/streaming.md +0 -16
  103. package/skills/claude-api/ruby/claude-api/tool-use.md +0 -41
  104. package/skills/claude-api/ruby/managed-agents/README.md +0 -394
  105. package/skills/claude-api/shared/admin-api.md +0 -179
  106. package/skills/claude-api/shared/agent-design.md +0 -101
  107. package/skills/claude-api/shared/anthropic-cli.md +0 -269
  108. package/skills/claude-api/shared/claude-platform-on-aws.md +0 -59
  109. package/skills/claude-api/shared/cost-optimization.md +0 -233
  110. package/skills/claude-api/shared/error-codes.md +0 -262
  111. package/skills/claude-api/shared/live-sources.md +0 -166
  112. package/skills/claude-api/shared/managed-agents-api-reference.md +0 -455
  113. package/skills/claude-api/shared/managed-agents-client-patterns.md +0 -220
  114. package/skills/claude-api/shared/managed-agents-core.md +0 -359
  115. package/skills/claude-api/shared/managed-agents-environments.md +0 -226
  116. package/skills/claude-api/shared/managed-agents-events.md +0 -290
  117. package/skills/claude-api/shared/managed-agents-memory.md +0 -201
  118. package/skills/claude-api/shared/managed-agents-multiagent.md +0 -260
  119. package/skills/claude-api/shared/managed-agents-onboarding.md +0 -82
  120. package/skills/claude-api/shared/managed-agents-outcomes.md +0 -108
  121. package/skills/claude-api/shared/managed-agents-overview.md +0 -77
  122. package/skills/claude-api/shared/managed-agents-scheduled-deployments.md +0 -156
  123. package/skills/claude-api/shared/managed-agents-self-hosted-sandboxes.md +0 -282
  124. package/skills/claude-api/shared/managed-agents-tools.md +0 -504
  125. package/skills/claude-api/shared/managed-agents-webhooks.md +0 -143
  126. package/skills/claude-api/shared/model-migration.md +0 -1872
  127. package/skills/claude-api/shared/models.md +0 -142
  128. package/skills/claude-api/shared/platform-availability.md +0 -53
  129. package/skills/claude-api/shared/prompt-audit.md +0 -223
  130. package/skills/claude-api/shared/prompt-caching.md +0 -292
  131. package/skills/claude-api/shared/token-counting.md +0 -56
  132. package/skills/claude-api/shared/tool-use-concepts.md +0 -526
  133. package/skills/claude-api/typescript/claude-api/README.md +0 -405
  134. package/skills/claude-api/typescript/claude-api/batches.md +0 -106
  135. package/skills/claude-api/typescript/claude-api/files-api.md +0 -98
  136. package/skills/claude-api/typescript/claude-api/streaming.md +0 -178
  137. package/skills/claude-api/typescript/claude-api/tool-use.md +0 -586
  138. package/skills/claude-api/typescript/managed-agents/README.md +0 -359
  139. package/tests/auto-permissions.test.mjs +0 -315
  140. package/tests/background-tasks.test.mjs +0 -27
  141. package/tests/background-worker.test.mjs +0 -60
  142. package/tests/bash-permissions.test.mjs +0 -276
  143. package/tests/glob.test.mjs +0 -31
  144. package/tests/mode.test.mjs +0 -62
  145. package/tests/permission-evaluation.test.mjs +0 -70
  146. package/tests/permission-foundation.test.mjs +0 -194
  147. package/tests/permission-policy.test.mjs +0 -158
  148. package/tests/runtime/README.md +0 -51
  149. package/tests/runtime/harness.mjs +0 -190
  150. package/tests/runtime/permissions.test.mjs +0 -192
  151. package/tests/runtime/tui.test.mjs +0 -60
  152. package/tests/task-integration.test.mjs +0 -324
  153. package/tests/task-permissions.test.mjs +0 -178
  154. package/tests/task-worktree-policy.test.mjs +0 -53
@@ -0,0 +1,450 @@
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
+ });
package/tsconfig.json CHANGED
@@ -4,6 +4,7 @@
4
4
  "module": "NodeNext",
5
5
  "moduleResolution": "NodeNext",
6
6
  "allowImportingTsExtensions": true,
7
+ "resolveJsonModule": true,
7
8
  "noEmit": true,
8
9
  "strict": true,
9
10
  "skipLibCheck": true,
@@ -1,63 +0,0 @@
1
- name: CI and publish
2
-
3
- on:
4
- push:
5
- branches: [main]
6
- pull_request:
7
- release:
8
- types: [published]
9
-
10
- permissions:
11
- contents: read
12
-
13
- jobs:
14
- test:
15
- name: Check and test
16
- runs-on: ubuntu-24.04
17
- steps:
18
- - name: Checkout
19
- uses: actions/checkout@v5
20
-
21
- - name: Set up Node.js
22
- uses: actions/setup-node@v5
23
- with:
24
- node-version: 24
25
- cache: npm
26
-
27
- - name: Install dependencies
28
- run: npm ci --ignore-scripts
29
-
30
- - name: Type-check
31
- run: npm run check
32
-
33
- - name: Test
34
- run: npm test
35
-
36
- - name: Check package contents
37
- run: npm pack --dry-run
38
-
39
- publish:
40
- name: Publish to npm
41
- if: github.event_name == 'release' && github.event.action == 'published'
42
- needs: test
43
- runs-on: ubuntu-24.04
44
- permissions:
45
- contents: read
46
- id-token: write
47
- steps:
48
- - name: Checkout release tag
49
- uses: actions/checkout@v5
50
-
51
- - name: Set up Node.js
52
- uses: actions/setup-node@v5
53
- with:
54
- node-version: 24
55
- registry-url: https://registry.npmjs.org
56
-
57
- - name: Install dependencies
58
- run: npm ci --ignore-scripts
59
-
60
- - name: Publish package
61
- run: npm publish --provenance
62
- env:
63
- NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}