pi-minimal 0.2.3 → 0.3.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/CHANGELOG.md +23 -0
- package/README.md +2 -2
- package/dist/codemode-rows.js +109 -0
- package/dist/tools.js +139 -1
- package/package.json +1 -1
- package/release-notes.md +21 -20
- package/src/codemode-rows.ts +121 -0
- package/src/tools.ts +138 -1
- package/test/codemode-rows.test.ts +61 -0
- package/test/package.test.ts +1 -1
- package/test/theme.test.ts +28 -0
- package/test/tools.test.ts +72 -2
- package/themes/minimal-dark.json +7 -7
package/CHANGELOG.md
CHANGED
|
@@ -10,6 +10,29 @@ Releases are cut automatically: when the `version` in `package.json` changes on
|
|
|
10
10
|
|
|
11
11
|
## [Unreleased]
|
|
12
12
|
|
|
13
|
+
## [0.3.0] - 2026-10-25
|
|
14
|
+
|
|
15
|
+
### Added
|
|
16
|
+
- `codemode` renders as a tool row: `✓ code 3 calls · plan_tracker ×2, bash … 54 lines · 1.2s`,
|
|
17
|
+
with one child row per nested call. Built-in tools inside a script read exactly like their
|
|
18
|
+
top-level rows (`bash` shows its command); other tools show up to three scalar arguments as
|
|
19
|
+
`key=value` instead of raw JSON. Children update live while the script runs. The script source
|
|
20
|
+
and output appear only on `ctrl+o`, in plain dim text, so syntax colours no longer compete with
|
|
21
|
+
status colours. A failed nested call or script error shows its first line under the row, like
|
|
22
|
+
`bash`. Previously codemode fell through to pi-core's default panel: a background slab with the
|
|
23
|
+
highlighted script, JSON arguments, an output preview and a `Took …` timer.
|
|
24
|
+
|
|
25
|
+
### Changed
|
|
26
|
+
- `minimal-dark` contrast: `dim` (verbs, outcomes, tool output) L 48 % → 56 % and `thinkingText`
|
|
27
|
+
52 % → 58 %, estimated ~3.1:1 → ~4.4:1 and ~3.7:1 → ~4.8:1 on the background. They were within
|
|
28
|
+
4 % of each other and below WCAG's 4.5:1 text target. `faint` 34 % → 36 %.
|
|
29
|
+
- `minimal-dark` keeps syntax colours off the status hues: `syntaxFunction` was warning-yellow
|
|
30
|
+
(now lavender) and `syntaxNumber` was success-green (now teal), so code no longer looks like a
|
|
31
|
+
spinner or a check mark.
|
|
32
|
+
- `minimal-dark` draws no background behind pending/successful tool output (`toolPendingBg`,
|
|
33
|
+
`toolSuccessBg` → `bg`). Tools pi-minimal does not restyle yet (`subagent`, MCP tools) lose
|
|
34
|
+
their slab; failures keep `toolErrorBg`.
|
|
35
|
+
|
|
13
36
|
## [0.2.3] - 2026-10-24
|
|
14
37
|
|
|
15
38
|
### Added
|
package/README.md
CHANGED
|
@@ -30,14 +30,14 @@ Do not load this package beside the live `grok-chrome` extension. Disable or rem
|
|
|
30
30
|
|
|
31
31
|
## What it changes
|
|
32
32
|
|
|
33
|
-
- **Tool rows** – every built-in tool call (`bash`, `read`, `edit`, `write`, `grep`, `find`, `ls`) renders as one line: status glyph (`◐` running, `✓` ok, `✗` failed), verb, item, and a right-aligned outcome (`34 lines · 1.0s`, `exit 1`, `+3 −1`, `4 matches`). Failed rows show the first line of output underneath; `ctrl+o` expands full output in a `│`-ruled block.
|
|
33
|
+
- **Tool rows** – every built-in tool call (`bash`, `read`, `edit`, `write`, `grep`, `find`, `ls`) renders as one line: status glyph (`◐` running, `✓` ok, `✗` failed), verb, item, and a right-aligned outcome (`34 lines · 1.0s`, `exit 1`, `+3 −1`, `4 matches`). Failed rows show the first line of output underneath; `ctrl+o` expands full output in a `│`-ruled block. `codemode` renders the same way, as a `code` row with one child row per nested tool call; `ctrl+o` shows the script and its output.
|
|
34
34
|
- **Turn rule** – after each agent run a dim `── 15:51 · 12s · 4 tools ──` separator is appended (stored as a custom entry; never sent to the model).
|
|
35
35
|
- **Thinking gutter** – thinking paragraphs are prefixed with `┊` so they read as secondary.
|
|
36
36
|
- **Status line** – one line above the editor: model · thinking level · context meter (amber at 70 %, red at 90 %) · cwd · `⎇ branch ±dirty` · Herdr siblings.
|
|
37
37
|
- **Footer** – key hints appear only when the agent is idle and the editor is empty; `/compact` is suggested under context pressure.
|
|
38
38
|
- **Code blocks** – fenced code renders as a closed box with the language as its title, keeping syntax highlighting, instead of literal ``` lines; long lines wrap inside the box.
|
|
39
39
|
- **Alignment** – chrome follows pi's `outputPad` setting. Pi's editor uses the separate `editorPaddingX` setting (default `0`); set `"editorPaddingX": 1` for a flush left edge with the transcript.
|
|
40
|
-
- **Theme** – ships `minimal-dark`:
|
|
40
|
+
- **Theme** – ships `minimal-dark`: no background behind tool rows (failures excepted), a grey ladder whose readable tiers stay near 4.5:1, syntax colours kept off the status hues, visible user-message tint, dimmer thinking text, violet headings (yellow is reserved for warnings), and faint code-block frames. Select it in `/settings` → Theme, or set `"theme": "minimal-dark"`.
|
|
41
41
|
|
|
42
42
|
## Configuration
|
|
43
43
|
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { firstLine, formatDuration, resultText, toolItem } from "./tool-rows.js";
|
|
2
|
+
const KINDS = new Set(["bash", "read", "edit", "write", "grep", "find", "ls"]);
|
|
3
|
+
const SCRIPT_HEADER = /^Script (?:completed|failed)\nWall time [\d.]+ seconds\nOutput:\n?/;
|
|
4
|
+
/** Argument keys toolItem() reads, in the order it prefers them. */
|
|
5
|
+
const ITEM_KEYS = ["command", "path", "file_path", "target_directory", "pattern"];
|
|
6
|
+
export function nestedCalls(result) {
|
|
7
|
+
const calls = result?.details?.calls;
|
|
8
|
+
if (!Array.isArray(calls))
|
|
9
|
+
return [];
|
|
10
|
+
return calls.filter((call) => Boolean(call) && typeof call === "object");
|
|
11
|
+
}
|
|
12
|
+
/** Script output without pi-core's "Script completed / Wall time / Output:" header. */
|
|
13
|
+
export function scriptOutput(result) {
|
|
14
|
+
return resultText(result).replace(SCRIPT_HEADER, "").trim();
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Parse the host's argument preview. Complete JSON parses directly; a preview cut at 200 chars
|
|
18
|
+
* still yields the first string value of each known key, which is all a one-line row needs.
|
|
19
|
+
*/
|
|
20
|
+
export function parseArgs(args) {
|
|
21
|
+
if (!args)
|
|
22
|
+
return {};
|
|
23
|
+
try {
|
|
24
|
+
const value = JSON.parse(args);
|
|
25
|
+
return value && typeof value === "object" && !Array.isArray(value) ? value : {};
|
|
26
|
+
}
|
|
27
|
+
catch {
|
|
28
|
+
const out = {};
|
|
29
|
+
const pair = /"([A-Za-z_$][\w$]*)":(?:"((?:[^"\\]|\\.)*)"?|(-?\d+(?:\.\d+)?|true|false|null))/g;
|
|
30
|
+
for (const m of args.matchAll(pair)) {
|
|
31
|
+
if (m[1] in out)
|
|
32
|
+
continue;
|
|
33
|
+
if (m[2] !== undefined)
|
|
34
|
+
out[m[1]] = unescape(m[2]);
|
|
35
|
+
else if (m[3] !== undefined)
|
|
36
|
+
out[m[1]] = JSON.parse(m[3]);
|
|
37
|
+
}
|
|
38
|
+
return out;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
function unescape(raw) {
|
|
42
|
+
// A cut can land inside an escape; drop a dangling backslash so JSON.parse accepts the rest.
|
|
43
|
+
const safe = raw.replace(/\\(u[0-9a-fA-F]{0,3})?$/, "");
|
|
44
|
+
try {
|
|
45
|
+
return JSON.parse(`"${safe}"`);
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
return safe;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
function scalar(value) {
|
|
52
|
+
if (typeof value === "string")
|
|
53
|
+
return value.split("\n")[0]?.trim();
|
|
54
|
+
if (typeof value === "number" || typeof value === "boolean")
|
|
55
|
+
return String(value);
|
|
56
|
+
return undefined;
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* The item column of a nested call. Built-in tools read exactly like their top-level rows
|
|
60
|
+
* (`bash` shows its command, `read` a short path). Other tools show up to three scalar
|
|
61
|
+
* arguments as `key=value`, strings unquoted, instead of raw JSON.
|
|
62
|
+
*/
|
|
63
|
+
export function nestedItem(call) {
|
|
64
|
+
const name = call.name ?? "tool";
|
|
65
|
+
const args = parseArgs(call.args);
|
|
66
|
+
if (KINDS.has(name)) {
|
|
67
|
+
const picked = {};
|
|
68
|
+
for (const key of ITEM_KEYS)
|
|
69
|
+
if (key in args)
|
|
70
|
+
picked[key] = args[key];
|
|
71
|
+
return toolItem(name, picked);
|
|
72
|
+
}
|
|
73
|
+
const parts = [];
|
|
74
|
+
for (const [key, value] of Object.entries(args)) {
|
|
75
|
+
const shown = scalar(value);
|
|
76
|
+
if (shown === undefined || shown === "")
|
|
77
|
+
continue;
|
|
78
|
+
parts.push(`${key}=${shown}`);
|
|
79
|
+
if (parts.length === 3)
|
|
80
|
+
break;
|
|
81
|
+
}
|
|
82
|
+
return parts.join(" ");
|
|
83
|
+
}
|
|
84
|
+
/** `3 calls · plan_tracker ×2, bash` — names in first-call order with repeat counts. */
|
|
85
|
+
export function callSummary(calls) {
|
|
86
|
+
if (calls.length === 0)
|
|
87
|
+
return "script";
|
|
88
|
+
const counts = new Map();
|
|
89
|
+
for (const call of calls)
|
|
90
|
+
counts.set(call.name ?? "tool", (counts.get(call.name ?? "tool") ?? 0) + 1);
|
|
91
|
+
const names = [...counts].map(([name, n]) => (n > 1 ? `${name} ×${n}` : name)).join(", ");
|
|
92
|
+
return `${calls.length} call${calls.length === 1 ? "" : "s"} · ${names}`;
|
|
93
|
+
}
|
|
94
|
+
/** Right-hand outcome of the parent row, and the line shown under it when something failed. */
|
|
95
|
+
export function codemodeOutcome(result, isError, durationMs) {
|
|
96
|
+
const calls = nestedCalls(result);
|
|
97
|
+
const output = scriptOutput(result);
|
|
98
|
+
const failedCalls = calls.filter((call) => call.status === "error");
|
|
99
|
+
const dur = formatDuration(durationMs);
|
|
100
|
+
const join = (...parts) => parts.filter(Boolean).join(" · ");
|
|
101
|
+
if (isError || failedCalls.length > 0) {
|
|
102
|
+
const label = failedCalls.length > 0 ? `${failedCalls.length} failed` : "failed";
|
|
103
|
+
const scriptError = /Script error:\s*(.*)/.exec(output)?.[1];
|
|
104
|
+
const errorLine = firstLine(failedCalls[0]?.error ?? "") ?? scriptError ?? firstLine(output);
|
|
105
|
+
return { text: join(label, dur), failed: true, errorLine };
|
|
106
|
+
}
|
|
107
|
+
const lines = output ? output.split("\n").filter((line) => line.length > 0).length : 0;
|
|
108
|
+
return { text: join(lines === 0 ? "ok" : `${lines} line${lines === 1 ? "" : "s"}`, dur), failed: false };
|
|
109
|
+
}
|
package/dist/tools.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
import { Container, truncateToWidth } from "@earendil-works/pi-tui";
|
|
1
|
+
import { Container, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
|
2
2
|
import { rowIsRunning } from "./activity.js";
|
|
3
3
|
import * as chrome from "./chrome.js";
|
|
4
|
+
import { callSummary, codemodeOutcome, nestedCalls, nestedItem, scriptOutput } from "./codemode-rows.js";
|
|
4
5
|
import { createLiveWatch } from "./live-watch.js";
|
|
5
6
|
import { bashBody, formatDuration, formatOutcome, layoutRow, resultText, rowGlyph, toolItem, } from "./tool-rows.js";
|
|
6
7
|
/** Tolerates a stale chrome module after /reload, where only CHROME_MARGIN may exist. */
|
|
@@ -157,6 +158,141 @@ export function compactRenderers(kind) {
|
|
|
157
158
|
},
|
|
158
159
|
};
|
|
159
160
|
}
|
|
161
|
+
/** Max nested calls shown under a collapsed codemode row; the rest are counted. */
|
|
162
|
+
const NESTED_PREVIEW = 8;
|
|
163
|
+
/** Width of the nested tool-name column. */
|
|
164
|
+
const NESTED_NAME_MAX = 14;
|
|
165
|
+
function nestedGlyph(call, frame) {
|
|
166
|
+
if (call.status === "running")
|
|
167
|
+
return { glyph: rowGlyph("running", frame), color: "warning" };
|
|
168
|
+
if (call.status === "error")
|
|
169
|
+
return { glyph: "\u2717", color: "error" };
|
|
170
|
+
if (call.status === "cancelled")
|
|
171
|
+
return { glyph: "\u2298", color: "dim" };
|
|
172
|
+
return { glyph: "\u2713", color: "success" };
|
|
173
|
+
}
|
|
174
|
+
/** Lines of a `\u2502`-ruled block, dim and truncated, for script source or output on expand. */
|
|
175
|
+
function ruledLines(text, width, theme, indent, label) {
|
|
176
|
+
const inner = Math.max(0, width - indent.length - 2);
|
|
177
|
+
const rule = theme.fg("borderMuted", "\u2502");
|
|
178
|
+
let lines = text.split("\n");
|
|
179
|
+
if (lines.length > EXPANDED_MAX_LINES)
|
|
180
|
+
lines = [...lines.slice(0, EXPANDED_MAX_LINES), `\u2026 ${lines.length - EXPANDED_MAX_LINES} more lines`];
|
|
181
|
+
const out = label ? [`${indent}${rule} ${theme.fg("dim", truncateToWidth(label, inner))}`] : [];
|
|
182
|
+
return out.concat(lines.map((line) => `${indent}${rule} ${theme.fg("dim", truncateToWidth(line, inner))}`));
|
|
183
|
+
}
|
|
184
|
+
/**
|
|
185
|
+
* codemode as a tool row: `\u2713 code 3 calls \u00b7 plan_tracker \u00d72, bash \u2026 54 lines \u00b7 1.2s`, then one
|
|
186
|
+
* child row per nested call. The script and its output appear only when expanded, undecorated,
|
|
187
|
+
* so syntax colours never compete with status colours in the transcript.
|
|
188
|
+
*/
|
|
189
|
+
export function codemodeRenderers() {
|
|
190
|
+
return {
|
|
191
|
+
renderShell: "self",
|
|
192
|
+
renderCall(args, theme, rawContext) {
|
|
193
|
+
const context = rawContext;
|
|
194
|
+
const code = typeof args?.code === "string" ? (args.code) : "";
|
|
195
|
+
context.state.code = code;
|
|
196
|
+
const id = context.toolCallId || "codemode";
|
|
197
|
+
const running = rowIsRunning(context);
|
|
198
|
+
context.state.running = running;
|
|
199
|
+
if (running) {
|
|
200
|
+
context.state.startedAt ??= Date.now();
|
|
201
|
+
live.watch(id, context.invalidate);
|
|
202
|
+
}
|
|
203
|
+
else
|
|
204
|
+
live.unwatch(id);
|
|
205
|
+
return {
|
|
206
|
+
render(width) {
|
|
207
|
+
const state = context.state;
|
|
208
|
+
const status = state.outcome?.errorLine !== undefined || context.isError ? "error" : rowIsRunning(context) ? "running" : "ok";
|
|
209
|
+
const frame = status === "running" && live.has(id) ? live.frame : 0;
|
|
210
|
+
const calls = state.calls ?? [];
|
|
211
|
+
const right = status === "running" ? (state.startedAt ? formatDuration(Date.now() - state.startedAt) : "") : (state.outcome?.text ?? "");
|
|
212
|
+
const inner = Math.max(8, width - rowIndent());
|
|
213
|
+
const row = layoutRow(inner, "code", callSummary(calls), right);
|
|
214
|
+
const rightColor = status === "running" ? "warning" : status === "error" ? "error" : "dim";
|
|
215
|
+
const lines = [
|
|
216
|
+
" ".repeat(rowIndent()) +
|
|
217
|
+
theme.fg(statusColor(status), rowGlyph(status, frame)) +
|
|
218
|
+
" " +
|
|
219
|
+
theme.fg("dim", row.verb) +
|
|
220
|
+
" " +
|
|
221
|
+
theme.fg(status === "error" ? "text" : "muted", row.item) +
|
|
222
|
+
" ".repeat(row.gap) +
|
|
223
|
+
theme.fg(rightColor, row.outcome),
|
|
224
|
+
];
|
|
225
|
+
const expanded = Boolean(context.expanded);
|
|
226
|
+
const shown = expanded ? calls : calls.slice(-NESTED_PREVIEW);
|
|
227
|
+
const childIndent = " ".repeat(rowIndent() + 4);
|
|
228
|
+
if (shown.length < calls.length) {
|
|
229
|
+
lines.push(`${childIndent}${theme.fg("dim", `\u2026 ${calls.length - shown.length} earlier calls`)}`);
|
|
230
|
+
}
|
|
231
|
+
const nameWidth = Math.min(NESTED_NAME_MAX, Math.max(0, ...shown.map((call) => (call.name ?? "tool").length)));
|
|
232
|
+
for (const call of shown) {
|
|
233
|
+
const { glyph, color } = nestedGlyph(call, frame);
|
|
234
|
+
const name = truncateToWidth(call.name ?? "tool", nameWidth, "\u2026").padEnd(nameWidth);
|
|
235
|
+
const dur = call.status === "error" ? `failed \u00b7 ${formatDuration(call.durationMs)}` : formatDuration(call.durationMs);
|
|
236
|
+
const room = Math.max(8, width - childIndent.length);
|
|
237
|
+
// Reuse the row grid with the name as the "verb" column, then fix the glyph slot.
|
|
238
|
+
const prefixWidth = 2 + nameWidth + 1;
|
|
239
|
+
const itemRoom = Math.max(0, room - prefixWidth - (dur ? visibleWidth(dur) + 2 : 0));
|
|
240
|
+
const item = truncateToWidth(nestedItem(call), itemRoom, "\u2026");
|
|
241
|
+
const gap = dur ? Math.max(2, room - prefixWidth - visibleWidth(item) - visibleWidth(dur)) : 0;
|
|
242
|
+
lines.push(childIndent +
|
|
243
|
+
theme.fg(color, glyph) +
|
|
244
|
+
" " +
|
|
245
|
+
theme.fg("dim", name) +
|
|
246
|
+
" " +
|
|
247
|
+
theme.fg(call.status === "error" ? "text" : "muted", item) +
|
|
248
|
+
" ".repeat(gap) +
|
|
249
|
+
theme.fg(call.status === "error" ? "error" : "dim", dur));
|
|
250
|
+
}
|
|
251
|
+
return lines;
|
|
252
|
+
},
|
|
253
|
+
invalidate() { },
|
|
254
|
+
};
|
|
255
|
+
},
|
|
256
|
+
renderResult(result, options, theme, rawContext) {
|
|
257
|
+
const context = rawContext;
|
|
258
|
+
const typed = result;
|
|
259
|
+
context.state.calls = nestedCalls(typed);
|
|
260
|
+
if (!context.isPartial) {
|
|
261
|
+
context.state.done = true;
|
|
262
|
+
context.state.running = false;
|
|
263
|
+
const outcome = codemodeOutcome(typed, Boolean(context.isError), context.durationMs);
|
|
264
|
+
context.state.outcome = { text: outcome.text, errorLine: outcome.failed ? (outcome.errorLine ?? "") : undefined };
|
|
265
|
+
context.state.output = scriptOutput(typed);
|
|
266
|
+
live.unwatch(context.toolCallId || "codemode");
|
|
267
|
+
}
|
|
268
|
+
// No invalidate here: the host runs renderCall and renderResult in one updateDisplay() and
|
|
269
|
+
// paints afterwards, so the call row's lazy render() already sees these calls. Calling
|
|
270
|
+
// invalidate() would re-enter updateDisplay() and recurse.
|
|
271
|
+
const container = new Container();
|
|
272
|
+
const expanded = Boolean(options.expanded);
|
|
273
|
+
const errorLine = context.state.outcome?.errorLine;
|
|
274
|
+
if (errorLine && !expanded)
|
|
275
|
+
container.addChild(errorLineBlock(errorLine, theme));
|
|
276
|
+
if (expanded) {
|
|
277
|
+
const indent = " ".repeat(rowIndent() + 4);
|
|
278
|
+
const code = context.state.code ?? "";
|
|
279
|
+
const output = context.state.output ?? "";
|
|
280
|
+
container.addChild({
|
|
281
|
+
render(width) {
|
|
282
|
+
const lines = [];
|
|
283
|
+
if (code)
|
|
284
|
+
lines.push(...ruledLines(code.trimEnd(), width, theme, indent, `// script \u00b7 ${code.trimEnd().split("\n").length} lines`));
|
|
285
|
+
if (output)
|
|
286
|
+
lines.push(...ruledLines(output, width, theme, indent, "// output"));
|
|
287
|
+
return lines;
|
|
288
|
+
},
|
|
289
|
+
invalidate() { },
|
|
290
|
+
});
|
|
291
|
+
}
|
|
292
|
+
return container;
|
|
293
|
+
},
|
|
294
|
+
};
|
|
295
|
+
}
|
|
160
296
|
/**
|
|
161
297
|
* Attach the compact renderers to the built-in tools without re-registering them. Registered at
|
|
162
298
|
* extension load so a resumed transcript draws its earlier tool calls the same way as new ones.
|
|
@@ -165,6 +301,8 @@ export function installToolRenderers(pi) {
|
|
|
165
301
|
if (typeof pi.registerToolRenderer !== "function")
|
|
166
302
|
return;
|
|
167
303
|
pi.registerToolRenderer((toolName, next) => {
|
|
304
|
+
if (toolName === "codemode")
|
|
305
|
+
return codemodeRenderers();
|
|
168
306
|
if (!isToolKind(toolName))
|
|
169
307
|
return next();
|
|
170
308
|
return compactRenderers(toolName);
|
package/package.json
CHANGED
package/release-notes.md
CHANGED
|
@@ -1,31 +1,32 @@
|
|
|
1
1
|
### Added
|
|
2
|
-
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
plain
|
|
7
|
-
|
|
8
|
-
-
|
|
9
|
-
|
|
10
|
-
- Independent plan-summary contribution slot with violet slanted progress segments
|
|
11
|
-
and exact accepted/total counts, rendered below the input. One blank row
|
|
12
|
-
separates nonempty footer groups; activity rows stay compact. The summary is
|
|
13
|
-
display-only and does not add task storage or infer acceptance from agent runs.
|
|
14
|
-
- Release checks for matching manifest/lockfile versions and a dated changelog entry.
|
|
2
|
+
- `codemode` renders as a tool row: `✓ code 3 calls · plan_tracker ×2, bash … 54 lines · 1.2s`,
|
|
3
|
+
with one child row per nested call. Built-in tools inside a script read exactly like their
|
|
4
|
+
top-level rows (`bash` shows its command); other tools show up to three scalar arguments as
|
|
5
|
+
`key=value` instead of raw JSON. Children update live while the script runs. The script source
|
|
6
|
+
and output appear only on `ctrl+o`, in plain dim text, so syntax colours no longer compete with
|
|
7
|
+
status colours. A failed nested call or script error shows its first line under the row, like
|
|
8
|
+
`bash`. Previously codemode fell through to pi-core's default panel: a background slab with the
|
|
9
|
+
highlighted script, JSON arguments, an output preview and a `Took …` timer.
|
|
15
10
|
|
|
16
|
-
###
|
|
17
|
-
-
|
|
18
|
-
and
|
|
19
|
-
|
|
11
|
+
### Changed
|
|
12
|
+
- `minimal-dark` contrast: `dim` (verbs, outcomes, tool output) L 48 % → 56 % and `thinkingText`
|
|
13
|
+
52 % → 58 %, estimated ~3.1:1 → ~4.4:1 and ~3.7:1 → ~4.8:1 on the background. They were within
|
|
14
|
+
4 % of each other and below WCAG's 4.5:1 text target. `faint` 34 % → 36 %.
|
|
15
|
+
- `minimal-dark` keeps syntax colours off the status hues: `syntaxFunction` was warning-yellow
|
|
16
|
+
(now lavender) and `syntaxNumber` was success-green (now teal), so code no longer looks like a
|
|
17
|
+
spinner or a check mark.
|
|
18
|
+
- `minimal-dark` draws no background behind pending/successful tool output (`toolPendingBg`,
|
|
19
|
+
`toolSuccessBg` → `bg`). Tools pi-minimal does not restyle yet (`subagent`, MCP tools) lose
|
|
20
|
+
their slab; failures keep `toolErrorBg`.
|
|
20
21
|
|
|
21
22
|
## What's Changed
|
|
22
|
-
* feat:
|
|
23
|
+
* feat: render codemode as a tool row; raise minimal-dark contrast (0.3.0) by @raviy06 in https://github.com/raviy06/pi-minimal/pull/15
|
|
23
24
|
|
|
24
25
|
|
|
25
|
-
**Full Changelog**: https://github.com/raviy06/pi-minimal/compare/v0.2.
|
|
26
|
+
**Full Changelog**: https://github.com/raviy06/pi-minimal/compare/v0.2.3...v0.3.0
|
|
26
27
|
|
|
27
28
|
---
|
|
28
29
|
**Install / upgrade**
|
|
29
30
|
```bash
|
|
30
|
-
pi install npm:pi-minimal@0.
|
|
31
|
+
pi install npm:pi-minimal@0.3.0 # or: pi update --extensions
|
|
31
32
|
```
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { firstLine, formatDuration, resultText, toolItem, type ToolKind, type ToolResultLike } from "./tool-rows.js"
|
|
2
|
+
|
|
3
|
+
/** Mirrors pi-core's `CodemodeNestedCall`; every field is optional so older hosts degrade quietly. */
|
|
4
|
+
export type NestedCall = {
|
|
5
|
+
id?: string
|
|
6
|
+
name?: string
|
|
7
|
+
/** Compact JSON of the arguments, truncated by the host at 200 chars (may not parse). */
|
|
8
|
+
args?: string
|
|
9
|
+
status?: "running" | "ok" | "error" | "cancelled"
|
|
10
|
+
durationMs?: number
|
|
11
|
+
error?: string
|
|
12
|
+
cost?: number
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const KINDS: ReadonlySet<string> = new Set<ToolKind>(["bash", "read", "edit", "write", "grep", "find", "ls"])
|
|
16
|
+
const SCRIPT_HEADER = /^Script (?:completed|failed)\nWall time [\d.]+ seconds\nOutput:\n?/
|
|
17
|
+
/** Argument keys toolItem() reads, in the order it prefers them. */
|
|
18
|
+
const ITEM_KEYS = ["command", "path", "file_path", "target_directory", "pattern"] as const
|
|
19
|
+
|
|
20
|
+
export function nestedCalls(result: ToolResultLike | undefined): NestedCall[] {
|
|
21
|
+
const calls = (result?.details as { calls?: unknown } | undefined)?.calls
|
|
22
|
+
if (!Array.isArray(calls)) return []
|
|
23
|
+
return calls.filter((call): call is NestedCall => Boolean(call) && typeof call === "object")
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Script output without pi-core's "Script completed / Wall time / Output:" header. */
|
|
27
|
+
export function scriptOutput(result: ToolResultLike | undefined): string {
|
|
28
|
+
return resultText(result).replace(SCRIPT_HEADER, "").trim()
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Parse the host's argument preview. Complete JSON parses directly; a preview cut at 200 chars
|
|
33
|
+
* still yields the first string value of each known key, which is all a one-line row needs.
|
|
34
|
+
*/
|
|
35
|
+
export function parseArgs(args: string | undefined): Record<string, unknown> {
|
|
36
|
+
if (!args) return {}
|
|
37
|
+
try {
|
|
38
|
+
const value = JSON.parse(args) as unknown
|
|
39
|
+
return value && typeof value === "object" && !Array.isArray(value) ? (value as Record<string, unknown>) : {}
|
|
40
|
+
} catch {
|
|
41
|
+
const out: Record<string, unknown> = {}
|
|
42
|
+
const pair = /"([A-Za-z_$][\w$]*)":(?:"((?:[^"\\]|\\.)*)"?|(-?\d+(?:\.\d+)?|true|false|null))/g
|
|
43
|
+
for (const m of args.matchAll(pair)) {
|
|
44
|
+
if (m[1] in out) continue
|
|
45
|
+
if (m[2] !== undefined) out[m[1]] = unescape(m[2])
|
|
46
|
+
else if (m[3] !== undefined) out[m[1]] = JSON.parse(m[3])
|
|
47
|
+
}
|
|
48
|
+
return out
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function unescape(raw: string): string {
|
|
53
|
+
// A cut can land inside an escape; drop a dangling backslash so JSON.parse accepts the rest.
|
|
54
|
+
const safe = raw.replace(/\\(u[0-9a-fA-F]{0,3})?$/, "")
|
|
55
|
+
try {
|
|
56
|
+
return JSON.parse(`"${safe}"`) as string
|
|
57
|
+
} catch {
|
|
58
|
+
return safe
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function scalar(value: unknown): string | undefined {
|
|
63
|
+
if (typeof value === "string") return value.split("\n")[0]?.trim()
|
|
64
|
+
if (typeof value === "number" || typeof value === "boolean") return String(value)
|
|
65
|
+
return undefined
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* The item column of a nested call. Built-in tools read exactly like their top-level rows
|
|
70
|
+
* (`bash` shows its command, `read` a short path). Other tools show up to three scalar
|
|
71
|
+
* arguments as `key=value`, strings unquoted, instead of raw JSON.
|
|
72
|
+
*/
|
|
73
|
+
export function nestedItem(call: NestedCall): string {
|
|
74
|
+
const name = call.name ?? "tool"
|
|
75
|
+
const args = parseArgs(call.args)
|
|
76
|
+
if (KINDS.has(name)) {
|
|
77
|
+
const picked: Record<string, unknown> = {}
|
|
78
|
+
for (const key of ITEM_KEYS) if (key in args) picked[key] = args[key]
|
|
79
|
+
return toolItem(name as ToolKind, picked)
|
|
80
|
+
}
|
|
81
|
+
const parts: string[] = []
|
|
82
|
+
for (const [key, value] of Object.entries(args)) {
|
|
83
|
+
const shown = scalar(value)
|
|
84
|
+
if (shown === undefined || shown === "") continue
|
|
85
|
+
parts.push(`${key}=${shown}`)
|
|
86
|
+
if (parts.length === 3) break
|
|
87
|
+
}
|
|
88
|
+
return parts.join(" ")
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** `3 calls · plan_tracker ×2, bash` — names in first-call order with repeat counts. */
|
|
92
|
+
export function callSummary(calls: readonly NestedCall[]): string {
|
|
93
|
+
if (calls.length === 0) return "script"
|
|
94
|
+
const counts = new Map<string, number>()
|
|
95
|
+
for (const call of calls) counts.set(call.name ?? "tool", (counts.get(call.name ?? "tool") ?? 0) + 1)
|
|
96
|
+
const names = [...counts].map(([name, n]) => (n > 1 ? `${name} ×${n}` : name)).join(", ")
|
|
97
|
+
return `${calls.length} call${calls.length === 1 ? "" : "s"} · ${names}`
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export type CodemodeOutcome = { text: string; failed: boolean; errorLine?: string }
|
|
101
|
+
|
|
102
|
+
/** Right-hand outcome of the parent row, and the line shown under it when something failed. */
|
|
103
|
+
export function codemodeOutcome(
|
|
104
|
+
result: ToolResultLike | undefined,
|
|
105
|
+
isError: boolean,
|
|
106
|
+
durationMs: number | undefined,
|
|
107
|
+
): CodemodeOutcome {
|
|
108
|
+
const calls = nestedCalls(result)
|
|
109
|
+
const output = scriptOutput(result)
|
|
110
|
+
const failedCalls = calls.filter((call) => call.status === "error")
|
|
111
|
+
const dur = formatDuration(durationMs)
|
|
112
|
+
const join = (...parts: string[]) => parts.filter(Boolean).join(" · ")
|
|
113
|
+
if (isError || failedCalls.length > 0) {
|
|
114
|
+
const label = failedCalls.length > 0 ? `${failedCalls.length} failed` : "failed"
|
|
115
|
+
const scriptError = /Script error:\s*(.*)/.exec(output)?.[1]
|
|
116
|
+
const errorLine = firstLine(failedCalls[0]?.error ?? "") ?? scriptError ?? firstLine(output)
|
|
117
|
+
return { text: join(label, dur), failed: true, errorLine }
|
|
118
|
+
}
|
|
119
|
+
const lines = output ? output.split("\n").filter((line) => line.length > 0).length : 0
|
|
120
|
+
return { text: join(lines === 0 ? "ok" : `${lines} line${lines === 1 ? "" : "s"}`, dur), failed: false }
|
|
121
|
+
}
|
package/src/tools.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import type { ExtensionAPI, Theme, ToolRenderers } from "@earendil-works/pi-coding-agent"
|
|
2
|
-
import { Container, truncateToWidth } from "@earendil-works/pi-tui"
|
|
2
|
+
import { Container, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui"
|
|
3
3
|
import { rowIsRunning } from "./activity.js"
|
|
4
4
|
import * as chrome from "./chrome.js"
|
|
5
|
+
import { callSummary, codemodeOutcome, nestedCalls, nestedItem, scriptOutput, type NestedCall } from "./codemode-rows.js"
|
|
5
6
|
import { createLiveWatch } from "./live-watch.js"
|
|
6
7
|
import {
|
|
7
8
|
|
|
@@ -203,6 +204,141 @@ export function compactRenderers(kind: ToolKind): ToolRenderers {
|
|
|
203
204
|
}
|
|
204
205
|
}
|
|
205
206
|
|
|
207
|
+
/** Max nested calls shown under a collapsed codemode row; the rest are counted. */
|
|
208
|
+
const NESTED_PREVIEW = 8
|
|
209
|
+
/** Width of the nested tool-name column. */
|
|
210
|
+
const NESTED_NAME_MAX = 14
|
|
211
|
+
|
|
212
|
+
type CodemodeState = RowState & { calls?: NestedCall[]; output?: string; code?: string }
|
|
213
|
+
|
|
214
|
+
function nestedGlyph(call: NestedCall, frame: 0 | 1): { glyph: string; color: "error" | "warning" | "success" | "dim" } {
|
|
215
|
+
if (call.status === "running") return { glyph: rowGlyph("running", frame), color: "warning" }
|
|
216
|
+
if (call.status === "error") return { glyph: "\u2717", color: "error" }
|
|
217
|
+
if (call.status === "cancelled") return { glyph: "\u2298", color: "dim" }
|
|
218
|
+
return { glyph: "\u2713", color: "success" }
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** Lines of a `\u2502`-ruled block, dim and truncated, for script source or output on expand. */
|
|
222
|
+
function ruledLines(text: string, width: number, theme: Theme, indent: string, label?: string): string[] {
|
|
223
|
+
const inner = Math.max(0, width - indent.length - 2)
|
|
224
|
+
const rule = theme.fg("borderMuted", "\u2502")
|
|
225
|
+
let lines = text.split("\n")
|
|
226
|
+
if (lines.length > EXPANDED_MAX_LINES) lines = [...lines.slice(0, EXPANDED_MAX_LINES), `\u2026 ${lines.length - EXPANDED_MAX_LINES} more lines`]
|
|
227
|
+
const out = label ? [`${indent}${rule} ${theme.fg("dim", truncateToWidth(label, inner))}`] : []
|
|
228
|
+
return out.concat(lines.map((line) => `${indent}${rule} ${theme.fg("dim", truncateToWidth(line, inner))}`))
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* codemode as a tool row: `\u2713 code 3 calls \u00b7 plan_tracker \u00d72, bash \u2026 54 lines \u00b7 1.2s`, then one
|
|
233
|
+
* child row per nested call. The script and its output appear only when expanded, undecorated,
|
|
234
|
+
* so syntax colours never compete with status colours in the transcript.
|
|
235
|
+
*/
|
|
236
|
+
export function codemodeRenderers(): ToolRenderers {
|
|
237
|
+
return {
|
|
238
|
+
renderShell: "self",
|
|
239
|
+
renderCall(args, theme, rawContext) {
|
|
240
|
+
const context = rawContext as RenderContext & { state: CodemodeState }
|
|
241
|
+
const code = typeof (args as { code?: unknown })?.code === "string" ? ((args as { code: string }).code) : ""
|
|
242
|
+
context.state.code = code
|
|
243
|
+
const id = context.toolCallId || "codemode"
|
|
244
|
+
const running = rowIsRunning(context)
|
|
245
|
+
context.state.running = running
|
|
246
|
+
if (running) {
|
|
247
|
+
context.state.startedAt ??= Date.now()
|
|
248
|
+
live.watch(id, context.invalidate)
|
|
249
|
+
} else live.unwatch(id)
|
|
250
|
+
return {
|
|
251
|
+
render(width: number): string[] {
|
|
252
|
+
const state = context.state
|
|
253
|
+
const status: RowStatus = state.outcome?.errorLine !== undefined || context.isError ? "error" : rowIsRunning(context) ? "running" : "ok"
|
|
254
|
+
const frame = status === "running" && live.has(id) ? live.frame : 0
|
|
255
|
+
const calls = state.calls ?? []
|
|
256
|
+
const right =
|
|
257
|
+
status === "running" ? (state.startedAt ? formatDuration(Date.now() - state.startedAt) : "") : (state.outcome?.text ?? "")
|
|
258
|
+
const inner = Math.max(8, width - rowIndent())
|
|
259
|
+
const row = layoutRow(inner, "code", callSummary(calls), right)
|
|
260
|
+
const rightColor = status === "running" ? "warning" : status === "error" ? "error" : "dim"
|
|
261
|
+
const lines = [
|
|
262
|
+
" ".repeat(rowIndent()) +
|
|
263
|
+
theme.fg(statusColor(status), rowGlyph(status, frame)) +
|
|
264
|
+
" " +
|
|
265
|
+
theme.fg("dim", row.verb) +
|
|
266
|
+
" " +
|
|
267
|
+
theme.fg(status === "error" ? "text" : "muted", row.item) +
|
|
268
|
+
" ".repeat(row.gap) +
|
|
269
|
+
theme.fg(rightColor, row.outcome),
|
|
270
|
+
]
|
|
271
|
+
const expanded = Boolean(context.expanded)
|
|
272
|
+
const shown = expanded ? calls : calls.slice(-NESTED_PREVIEW)
|
|
273
|
+
const childIndent = " ".repeat(rowIndent() + 4)
|
|
274
|
+
if (shown.length < calls.length) {
|
|
275
|
+
lines.push(`${childIndent}${theme.fg("dim", `\u2026 ${calls.length - shown.length} earlier calls`)}`)
|
|
276
|
+
}
|
|
277
|
+
const nameWidth = Math.min(NESTED_NAME_MAX, Math.max(0, ...shown.map((call) => (call.name ?? "tool").length)))
|
|
278
|
+
for (const call of shown) {
|
|
279
|
+
const { glyph, color } = nestedGlyph(call, frame)
|
|
280
|
+
const name = truncateToWidth(call.name ?? "tool", nameWidth, "\u2026").padEnd(nameWidth)
|
|
281
|
+
const dur = call.status === "error" ? `failed \u00b7 ${formatDuration(call.durationMs)}` : formatDuration(call.durationMs)
|
|
282
|
+
const room = Math.max(8, width - childIndent.length)
|
|
283
|
+
// Reuse the row grid with the name as the "verb" column, then fix the glyph slot.
|
|
284
|
+
const prefixWidth = 2 + nameWidth + 1
|
|
285
|
+
const itemRoom = Math.max(0, room - prefixWidth - (dur ? visibleWidth(dur) + 2 : 0))
|
|
286
|
+
const item = truncateToWidth(nestedItem(call), itemRoom, "\u2026")
|
|
287
|
+
const gap = dur ? Math.max(2, room - prefixWidth - visibleWidth(item) - visibleWidth(dur)) : 0
|
|
288
|
+
lines.push(
|
|
289
|
+
childIndent +
|
|
290
|
+
theme.fg(color, glyph) +
|
|
291
|
+
" " +
|
|
292
|
+
theme.fg("dim", name) +
|
|
293
|
+
" " +
|
|
294
|
+
theme.fg(call.status === "error" ? "text" : "muted", item) +
|
|
295
|
+
" ".repeat(gap) +
|
|
296
|
+
theme.fg(call.status === "error" ? "error" : "dim", dur),
|
|
297
|
+
)
|
|
298
|
+
}
|
|
299
|
+
return lines
|
|
300
|
+
},
|
|
301
|
+
invalidate() {},
|
|
302
|
+
}
|
|
303
|
+
},
|
|
304
|
+
renderResult(result, options, theme, rawContext) {
|
|
305
|
+
const context = rawContext as RenderContext & { state: CodemodeState }
|
|
306
|
+
const typed = result as ToolResultLike
|
|
307
|
+
context.state.calls = nestedCalls(typed)
|
|
308
|
+
if (!context.isPartial) {
|
|
309
|
+
context.state.done = true
|
|
310
|
+
context.state.running = false
|
|
311
|
+
const outcome = codemodeOutcome(typed, Boolean(context.isError), context.durationMs)
|
|
312
|
+
context.state.outcome = { text: outcome.text, errorLine: outcome.failed ? (outcome.errorLine ?? "") : undefined }
|
|
313
|
+
context.state.output = scriptOutput(typed)
|
|
314
|
+
live.unwatch(context.toolCallId || "codemode")
|
|
315
|
+
}
|
|
316
|
+
// No invalidate here: the host runs renderCall and renderResult in one updateDisplay() and
|
|
317
|
+
// paints afterwards, so the call row's lazy render() already sees these calls. Calling
|
|
318
|
+
// invalidate() would re-enter updateDisplay() and recurse.
|
|
319
|
+
const container = new Container()
|
|
320
|
+
const expanded = Boolean((options as RenderOptions).expanded)
|
|
321
|
+
const errorLine = context.state.outcome?.errorLine
|
|
322
|
+
if (errorLine && !expanded) container.addChild(errorLineBlock(errorLine, theme)!)
|
|
323
|
+
if (expanded) {
|
|
324
|
+
const indent = " ".repeat(rowIndent() + 4)
|
|
325
|
+
const code = context.state.code ?? ""
|
|
326
|
+
const output = context.state.output ?? ""
|
|
327
|
+
container.addChild({
|
|
328
|
+
render(width: number): string[] {
|
|
329
|
+
const lines: string[] = []
|
|
330
|
+
if (code) lines.push(...ruledLines(code.trimEnd(), width, theme, indent, `// script \u00b7 ${code.trimEnd().split("\n").length} lines`))
|
|
331
|
+
if (output) lines.push(...ruledLines(output, width, theme, indent, "// output"))
|
|
332
|
+
return lines
|
|
333
|
+
},
|
|
334
|
+
invalidate() {},
|
|
335
|
+
})
|
|
336
|
+
}
|
|
337
|
+
return container
|
|
338
|
+
},
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
206
342
|
/**
|
|
207
343
|
* Attach the compact renderers to the built-in tools without re-registering them. Registered at
|
|
208
344
|
* extension load so a resumed transcript draws its earlier tool calls the same way as new ones.
|
|
@@ -210,6 +346,7 @@ export function compactRenderers(kind: ToolKind): ToolRenderers {
|
|
|
210
346
|
export function installToolRenderers(pi: ExtensionAPI): void {
|
|
211
347
|
if (typeof pi.registerToolRenderer !== "function") return
|
|
212
348
|
pi.registerToolRenderer((toolName, next) => {
|
|
349
|
+
if (toolName === "codemode") return codemodeRenderers()
|
|
213
350
|
if (!isToolKind(toolName)) return next()
|
|
214
351
|
return compactRenderers(toolName)
|
|
215
352
|
})
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import assert from "node:assert/strict"
|
|
2
|
+
import test from "node:test"
|
|
3
|
+
import { callSummary, codemodeOutcome, nestedCalls, nestedItem, parseArgs, scriptOutput } from "../src/codemode-rows.js"
|
|
4
|
+
|
|
5
|
+
const result = (text: string, calls: unknown[] = []) => ({
|
|
6
|
+
content: [
|
|
7
|
+
{ type: "text", text: "Script completed\nWall time 1.2 seconds\nOutput:\n" },
|
|
8
|
+
{ type: "text", text },
|
|
9
|
+
],
|
|
10
|
+
details: { calls },
|
|
11
|
+
})
|
|
12
|
+
|
|
13
|
+
test("parseArgs reads complete JSON and recovers keys from a preview cut at 200 chars", () => {
|
|
14
|
+
assert.deepEqual(parseArgs('{"action":"update","index":6}'), { action: "update", index: 6 })
|
|
15
|
+
const cut = '{"command":"cd ~/repo && sed -n 168,180p docs/a.md && echo \\"===\\" && rg -n x docs/b.md | sed -n 1,30p && ech...'
|
|
16
|
+
assert.equal(parseArgs(cut).command, 'cd ~/repo && sed -n 168,180p docs/a.md && echo "===" && rg -n x docs/b.md | sed -n 1,30p && ech...')
|
|
17
|
+
assert.deepEqual(parseArgs(""), {})
|
|
18
|
+
assert.deepEqual(parseArgs("[1,2]"), {})
|
|
19
|
+
// A cut inside an escape must not throw.
|
|
20
|
+
assert.equal(typeof parseArgs('{"command":"echo \\').command, "string")
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
test("nestedItem reads built-ins like their top-level rows and others as key=value", () => {
|
|
24
|
+
assert.equal(nestedItem({ name: "bash", args: '{"command":"git status\\ngit diff","timeout":10}' }), "git status")
|
|
25
|
+
assert.equal(nestedItem({ name: "read", args: '{"path":"/a/b/c/file.ts"}' }), "c/file.ts")
|
|
26
|
+
assert.equal(
|
|
27
|
+
nestedItem({ name: "plan_tracker", args: '{"action":"update","index":6,"status":"complete"}' }),
|
|
28
|
+
"action=update index=6 status=complete",
|
|
29
|
+
)
|
|
30
|
+
assert.equal(nestedItem({ name: "mcp__x__q", args: '{"query":"a","nested":{"b":1},"limit":5,"extra":1,"more":2}' }), "query=a limit=5 extra=1")
|
|
31
|
+
assert.equal(nestedItem({ name: "subagents_enable", args: "{}" }), "")
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
test("callSummary names tools in first-call order with repeat counts", () => {
|
|
35
|
+
assert.equal(callSummary([]), "script")
|
|
36
|
+
assert.equal(callSummary([{ name: "bash" }]), "1 call · bash")
|
|
37
|
+
assert.equal(
|
|
38
|
+
callSummary([{ name: "plan_tracker" }, { name: "plan_tracker" }, { name: "bash" }]),
|
|
39
|
+
"3 calls · plan_tracker ×2, bash",
|
|
40
|
+
)
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
test("scriptOutput drops pi-core's script header; nestedCalls tolerates missing details", () => {
|
|
44
|
+
assert.equal(scriptOutput(result("hello\n")), "hello")
|
|
45
|
+
assert.deepEqual(nestedCalls({ content: [] }), [])
|
|
46
|
+
assert.deepEqual(nestedCalls({ content: [], details: { calls: [null, { name: "bash" }] } }), [{ name: "bash" }])
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
test("codemodeOutcome counts output lines and surfaces the first failure", () => {
|
|
50
|
+
assert.deepEqual(codemodeOutcome(result("a\nb\n\nc"), false, 1200), { text: "3 lines · 1.2s", failed: false })
|
|
51
|
+
assert.deepEqual(codemodeOutcome(result(""), false, 300), { text: "ok · 0.3s", failed: false })
|
|
52
|
+
const failed = codemodeOutcome(
|
|
53
|
+
result("TEST_EXIT=1", [{ name: "bash", status: "ok" }, { name: "bash", status: "error", error: "\nboom: exit 2\nmore" }]),
|
|
54
|
+
false,
|
|
55
|
+
274_000,
|
|
56
|
+
)
|
|
57
|
+
assert.deepEqual(failed, { text: "1 failed · 4m 34s", failed: true, errorLine: "boom: exit 2" })
|
|
58
|
+
const scriptError = codemodeOutcome(result("partial\nScript error: ReferenceError: x is not defined"), true, 50)
|
|
59
|
+
assert.equal(scriptError.failed, true)
|
|
60
|
+
assert.equal(scriptError.errorLine, "ReferenceError: x is not defined")
|
|
61
|
+
})
|
package/test/package.test.ts
CHANGED
|
@@ -5,7 +5,7 @@ import { readFileSync } from "node:fs"
|
|
|
5
5
|
test("package manifest exposes the committed extension", () => {
|
|
6
6
|
const pkg = JSON.parse(readFileSync("package.json", "utf8"))
|
|
7
7
|
assert.equal(pkg.name, "pi-minimal")
|
|
8
|
-
assert.equal(pkg.version, "0.
|
|
8
|
+
assert.equal(pkg.version, "0.3.0")
|
|
9
9
|
const lock = JSON.parse(readFileSync("package-lock.json", "utf8"))
|
|
10
10
|
assert.equal(lock.version, pkg.version, "lockfile root version matches package")
|
|
11
11
|
assert.equal(lock.packages[""].version, pkg.version, "lockfile package version matches package")
|
package/test/theme.test.ts
CHANGED
|
@@ -43,3 +43,31 @@ test("minimal-dark frames code quietly and neutralises tool backgrounds", () =>
|
|
|
43
43
|
assert.equal(theme.colors.toolSuccessBg, theme.colors.toolPendingBg)
|
|
44
44
|
assert.notEqual(theme.colors.mdHeading, theme.colors.warning)
|
|
45
45
|
})
|
|
46
|
+
|
|
47
|
+
const lightness = (value: string) => {
|
|
48
|
+
const resolved = value in theme.vars ? theme.vars[value] : value
|
|
49
|
+
const m = /okhsl\(\s*[\d.]+\s+[\d.]+%\s+([\d.]+)%\s*\)/.exec(resolved)
|
|
50
|
+
assert.ok(m, `okhsl literal for ${value}`)
|
|
51
|
+
return Number(m![1]) / 100
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
test("minimal-dark keeps a readable grey ladder: text > muted > thinking > dim > faint", () => {
|
|
55
|
+
const ladder = [theme.vars.text, theme.vars.muted, theme.colors.thinkingText, theme.vars.dim, theme.vars.faint].map(lightness)
|
|
56
|
+
for (let i = 1; i < ladder.length; i++) assert.ok(ladder[i] < ladder[i - 1], `step ${i}: ${ladder.join(" > ")}`)
|
|
57
|
+
// Readable tiers stay at or above L .56 (~4.4:1 on bg L .11, estimating Y ≈ L³).
|
|
58
|
+
assert.ok(lightness(theme.vars.dim) >= 0.56)
|
|
59
|
+
assert.ok(lightness(theme.colors.thinkingText) >= 0.56)
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
test("minimal-dark keeps status hues out of syntax colours", () => {
|
|
63
|
+
const status = new Set([theme.colors.success, theme.colors.warning, theme.colors.error])
|
|
64
|
+
for (const role of ["syntaxFunction", "syntaxNumber", "syntaxString", "syntaxVariable"]) {
|
|
65
|
+
assert.ok(!status.has(theme.colors[role]), `${role} reuses a status colour`)
|
|
66
|
+
}
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
test("minimal-dark draws no slab behind tool rows; only failures get a background", () => {
|
|
70
|
+
assert.equal(theme.colors.toolSuccessBg, "bg")
|
|
71
|
+
assert.equal(theme.colors.toolPendingBg, "bg")
|
|
72
|
+
assert.notEqual(theme.colors.toolErrorBg, "bg")
|
|
73
|
+
})
|
package/test/tools.test.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import assert from "node:assert/strict"
|
|
2
2
|
import test from "node:test"
|
|
3
3
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"
|
|
4
|
-
import { compactRenderers, installToolRenderers, isToolKind } from "../src/tools.js"
|
|
4
|
+
import { codemodeRenderers, compactRenderers, installToolRenderers, isToolKind, stopLiveWatch } from "../src/tools.js"
|
|
5
5
|
|
|
6
6
|
test("isToolKind recognises the built-in tools only", () => {
|
|
7
7
|
for (const name of ["bash", "read", "edit", "write", "grep", "find", "ls"]) assert.equal(isToolKind(name), true)
|
|
@@ -19,7 +19,9 @@ test("installToolRenderers registers a resolver at load time that only claims bu
|
|
|
19
19
|
installToolRenderers(pi)
|
|
20
20
|
assert.ok(resolver, "resolver registered synchronously, before session_start")
|
|
21
21
|
const fallback = { renderShell: "default" }
|
|
22
|
-
assert.equal(resolver!("
|
|
22
|
+
assert.equal(resolver!("mcp__foo__bar", () => fallback), fallback)
|
|
23
|
+
const codemode = resolver!("codemode", () => fallback) as { renderShell?: string }
|
|
24
|
+
assert.equal(codemode.renderShell, "self")
|
|
23
25
|
const mine = resolver!("bash", () => fallback) as { renderShell?: string }
|
|
24
26
|
assert.equal(mine.renderShell, "self")
|
|
25
27
|
assert.equal(typeof (mine as { renderCall?: unknown }).renderCall, "function")
|
|
@@ -53,3 +55,71 @@ test("compactRenderers render a one-line row with the outcome once the result la
|
|
|
53
55
|
const [line] = row.render(60)
|
|
54
56
|
assert.match(line, /✓ bash\s+ls\s+2 lines · 1.2s$/)
|
|
55
57
|
})
|
|
58
|
+
|
|
59
|
+
function codemodeContext(extra: Record<string, unknown> = {}) {
|
|
60
|
+
return {
|
|
61
|
+
args: { code: "await tools.bash({command:'ls'})\ntext(1)" },
|
|
62
|
+
toolCallId: "c1",
|
|
63
|
+
invalidate() {},
|
|
64
|
+
state: {} as Record<string, unknown>,
|
|
65
|
+
isPartial: false,
|
|
66
|
+
isError: false,
|
|
67
|
+
durationMs: 1200,
|
|
68
|
+
...extra,
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const plainTheme = { fg: (_c: string, s: string) => s } as never
|
|
73
|
+
const codemodeResult = (calls: unknown[], output = "a\nb") => ({
|
|
74
|
+
content: [
|
|
75
|
+
{ type: "text", text: "Script completed\nWall time 1.2 seconds\nOutput:\n" },
|
|
76
|
+
{ type: "text", text: output },
|
|
77
|
+
],
|
|
78
|
+
details: { calls },
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
test("codemode renders one parent row plus one child row per nested call, no script", () => {
|
|
82
|
+
const r = codemodeRenderers()
|
|
83
|
+
const context = codemodeContext()
|
|
84
|
+
const calls = [
|
|
85
|
+
{ name: "plan_tracker", args: '{"action":"update","index":6,"status":"complete"}', status: "ok", durationMs: 309 },
|
|
86
|
+
{ name: "bash", args: '{"command":"cd ~/r && sed -n 1,2p a.md"}', status: "ok", durationMs: 636 },
|
|
87
|
+
]
|
|
88
|
+
const body = r.renderResult!(codemodeResult(calls) as never, { expanded: false, isPartial: false } as never, plainTheme, context as never)
|
|
89
|
+
const lines = r.renderCall!(context.args as never, plainTheme, context as never).render(100)
|
|
90
|
+
assert.equal(lines.length, 3)
|
|
91
|
+
assert.match(lines[0], /✓ code\s+2 calls · plan_tracker, bash\s+2 lines · 1.2s$/)
|
|
92
|
+
assert.match(lines[1], /✓ plan_tracker action=update index=6 status=complete\s+0.3s$/)
|
|
93
|
+
assert.match(lines[2], /✓ bash\s+cd ~\/r && sed -n 1,2p a.md\s+0.6s$/)
|
|
94
|
+
assert.ok(lines.every((line) => !line.includes("await")), "script hidden when collapsed")
|
|
95
|
+
assert.deepEqual(body.render(100), [])
|
|
96
|
+
for (const line of lines) assert.ok(line.length <= 100)
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
test("codemode shows the first failure under the row, and script + output when expanded", () => {
|
|
100
|
+
const r = codemodeRenderers()
|
|
101
|
+
const context = codemodeContext({ durationMs: 274_000 })
|
|
102
|
+
const calls = [{ name: "bash", args: '{"command":"npm test"}', status: "error", durationMs: 274_000, error: "fail 2" }]
|
|
103
|
+
const collapsed = r.renderResult!(codemodeResult(calls, "TEST_EXIT=1") as never, { expanded: false, isPartial: false } as never, plainTheme, context as never)
|
|
104
|
+
const [row, child] = r.renderCall!(context.args as never, plainTheme, context as never).render(100)
|
|
105
|
+
assert.match(row, /✗ code\s+1 call · bash\s+1 failed · 4m 34s$/)
|
|
106
|
+
assert.match(child, /✗ bash npm test\s+failed · 4m 34s$/)
|
|
107
|
+
assert.match(collapsed.render(100).join("\n"), /│ fail 2/)
|
|
108
|
+
|
|
109
|
+
const open = r.renderResult!(codemodeResult(calls, "TEST_EXIT=1") as never, { expanded: true, isPartial: false } as never, plainTheme, { ...context, expanded: true } as never)
|
|
110
|
+
const text = open.render(100).join("\n")
|
|
111
|
+
assert.match(text, /\/\/ script · 2 lines/)
|
|
112
|
+
assert.match(text, /await tools.bash/)
|
|
113
|
+
assert.match(text, /\/\/ output\n.*TEST_EXIT=1/)
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
test("codemode shows nested calls live while the script is still running", () => {
|
|
117
|
+
const r = codemodeRenderers()
|
|
118
|
+
const context = codemodeContext({ isPartial: true, durationMs: undefined })
|
|
119
|
+
r.renderResult!(codemodeResult([{ name: "bash", args: '{"command":"sleep 5"}', status: "running" }], "") as never, { expanded: false, isPartial: true } as never, plainTheme, context as never)
|
|
120
|
+
const lines = r.renderCall!(context.args as never, plainTheme, context as never).render(80)
|
|
121
|
+
assert.match(lines[0], /^\s+[◐◓] code\s+1 call · bash/)
|
|
122
|
+
assert.match(lines[1], /[◐◓] bash sleep 5/)
|
|
123
|
+
assert.equal((context.state as { done?: boolean }).done, undefined)
|
|
124
|
+
stopLiveWatch()
|
|
125
|
+
})
|
package/themes/minimal-dark.json
CHANGED
|
@@ -7,8 +7,8 @@
|
|
|
7
7
|
"panel": "okhsl(230 5% 17%)",
|
|
8
8
|
"text": "okhsl(234 3% 90%)",
|
|
9
9
|
"muted": "okhsl(229 6% 64%)",
|
|
10
|
-
"dim": "okhsl(229 7%
|
|
11
|
-
"faint": "okhsl(230 6%
|
|
10
|
+
"dim": "okhsl(229 7% 56%)",
|
|
11
|
+
"faint": "okhsl(230 6% 36%)",
|
|
12
12
|
"violet": "okhsl(295 50% 67%)",
|
|
13
13
|
"violetSoft": "okhsl(295 45% 74%)",
|
|
14
14
|
"blue": "okhsl(232 54% 67%)",
|
|
@@ -29,7 +29,7 @@
|
|
|
29
29
|
"muted": "muted",
|
|
30
30
|
"dim": "dim",
|
|
31
31
|
"text": "text",
|
|
32
|
-
"thinkingText": "okhsl(229 6%
|
|
32
|
+
"thinkingText": "okhsl(229 6% 58%)",
|
|
33
33
|
"selectedBg": "userBg",
|
|
34
34
|
"scrollbarTrack": "faint",
|
|
35
35
|
"scrollbarThumb": "dim",
|
|
@@ -40,8 +40,8 @@
|
|
|
40
40
|
"customMessageBg": "okhsl(295 30% 20%)",
|
|
41
41
|
"customMessageText": "muted",
|
|
42
42
|
"customMessageLabel": "violet",
|
|
43
|
-
"toolPendingBg": "
|
|
44
|
-
"toolSuccessBg": "
|
|
43
|
+
"toolPendingBg": "bg",
|
|
44
|
+
"toolSuccessBg": "bg",
|
|
45
45
|
"toolErrorBg": "errorBg",
|
|
46
46
|
"toolTitle": "text",
|
|
47
47
|
"toolOutput": "dim",
|
|
@@ -60,10 +60,10 @@
|
|
|
60
60
|
"toolDiffContext": "dim",
|
|
61
61
|
"syntaxComment": "dim",
|
|
62
62
|
"syntaxKeyword": "blue",
|
|
63
|
-
"syntaxFunction": "
|
|
63
|
+
"syntaxFunction": "okhsl(265 45% 76%)",
|
|
64
64
|
"syntaxVariable": "okhsl(202 58% 70%)",
|
|
65
65
|
"syntaxString": "okhsl(52 67% 70%)",
|
|
66
|
-
"syntaxNumber": "
|
|
66
|
+
"syntaxNumber": "okhsl(180 40% 68%)",
|
|
67
67
|
"syntaxType": "violet",
|
|
68
68
|
"syntaxOperator": "muted",
|
|
69
69
|
"syntaxPunctuation": "muted",
|