@zenodinh/pi-render 0.0.0-stage → 0.1.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/LICENSE +21 -0
- package/README.md +110 -2
- package/index.ts +279 -0
- package/package.json +66 -5
- package/src/commands/canvas.test.ts +150 -0
- package/src/commands/canvas.ts +57 -0
- package/src/core/code-theme.test.ts +266 -0
- package/src/core/code-theme.ts +275 -0
- package/src/core/log.test.ts +127 -0
- package/src/core/log.ts +57 -0
- package/src/core/paint.test.ts +322 -0
- package/src/core/paint.ts +64 -0
- package/src/core/registry.test.ts +307 -0
- package/src/core/registry.ts +135 -0
- package/src/core/settings.test.ts +183 -0
- package/src/core/settings.ts +119 -0
- package/src/core/types/code-theme.ts +24 -0
- package/src/core/types/host.ts +160 -0
- package/src/core/types/log.ts +28 -0
- package/src/core/types/paint.ts +54 -0
- package/src/core/types/registry.ts +44 -0
- package/src/core/types/settings.ts +31 -0
- package/src/core/types.ts +34 -0
- package/src/renderers/content/artifacts/artifacts.test.ts +199 -0
- package/src/renderers/content/artifacts/cache.ts +234 -0
- package/src/renderers/content/artifacts/cards.test.ts +216 -0
- package/src/renderers/content/artifacts/cards.ts +136 -0
- package/src/renderers/content/artifacts/engines-extra.test.ts +556 -0
- package/src/renderers/content/artifacts/engines.ts +396 -0
- package/src/renderers/content/artifacts/local-binary.test.ts +207 -0
- package/src/renderers/content/artifacts/local-binary.ts +80 -0
- package/src/renderers/content/artifacts/prereqs.ts +128 -0
- package/src/renderers/content/artifacts/server.ts +181 -0
- package/src/renderers/content/code-panel.ts +161 -0
- package/src/renderers/content/image-card.test.ts +170 -0
- package/src/renderers/content/image-card.ts +252 -0
- package/src/renderers/content/index.ts +79 -0
- package/src/renderers/content/json-panel.ts +116 -0
- package/src/renderers/content/panels.test.ts +188 -0
- package/src/renderers/content/table.test.ts +209 -0
- package/src/renderers/content/table.ts +174 -0
- package/src/renderers/content/transformer.test.ts +254 -0
- package/src/renderers/content/types.ts +20 -0
- package/src/renderers/tool/index.ts +113 -0
- package/src/renderers/tool/resolver.test.ts +257 -0
- package/src/renderers/tool/runtime.test.ts +313 -0
- package/src/renderers/tool/runtime.ts +267 -0
- package/src/renderers/tool/specs/bash.test.ts +110 -0
- package/src/renderers/tool/specs/bash.ts +168 -0
- package/src/renderers/tool/specs/codemode.test.ts +212 -0
- package/src/renderers/tool/specs/codemode.ts +248 -0
- package/src/renderers/tool/specs/edit.test.ts +260 -0
- package/src/renderers/tool/specs/edit.ts +213 -0
- package/src/renderers/tool/specs/ls.test.ts +173 -0
- package/src/renderers/tool/specs/ls.ts +136 -0
- package/src/renderers/tool/specs/read.test.ts +340 -0
- package/src/renderers/tool/specs/read.ts +296 -0
- package/src/renderers/tool/specs/search.test.ts +197 -0
- package/src/renderers/tool/specs/search.ts +325 -0
- package/src/renderers/tool/specs/write.test.ts +145 -0
- package/src/renderers/tool/specs/write.ts +142 -0
- package/src/renderers/tool/types.ts +45 -0
- package/themes/dracula-soft.json +81 -0
- package/themes/one-dark.json +80 -0
- package/themes/themes.test.ts +251 -0
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
// ported from pi-pretty-tui/src/tools/ls.ts — survives because: the collapsed entry count and the
|
|
2
|
+
// expanded tree are pure projections of the result text. The predecessor's registration, execute
|
|
3
|
+
// wrapper and injected `entryCount` (tools/metrics.ts) die with it — the count is parsed from the
|
|
4
|
+
// content lines, so the row needs no wrapper to know what it drew.
|
|
5
|
+
// Two deliberate deviations: file-type icon glyphs are dropped (the predecessor's tables hold
|
|
6
|
+
// hardcoded RGB escapes and paint owns every escape), and `process.env.HOME` becomes `homedir()` so
|
|
7
|
+
// no raw environment read crosses this boundary.
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* ls.ts — the ls row spec: a collapsed entry count, an expanded directory tree.
|
|
11
|
+
*
|
|
12
|
+
* Boundary: the host result is a structural shape, never a validated one — content blocks narrow
|
|
13
|
+
* field by field, and `result.details` is never read. Argument values stay behind the argsComplete
|
|
14
|
+
* gate: an unread argument can never be rendered as a guessed one.
|
|
15
|
+
*
|
|
16
|
+
* shape: object literal implementing RowSpec — trigger #7, the three contract fields are fixed-shape
|
|
17
|
+
* functions with no per-row state of their own, so a closure or class would be ceremony.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { homedir } from "node:os";
|
|
21
|
+
import { isAbsolute, relative } from "node:path";
|
|
22
|
+
import type { ToolResult } from "../../../core/types/host.ts";
|
|
23
|
+
import type { RowPaint } from "../../../core/types/paint.ts";
|
|
24
|
+
import type { ModuleDescriptor } from "../../../core/types/registry.ts";
|
|
25
|
+
import type { RowSpec } from "../types.ts";
|
|
26
|
+
|
|
27
|
+
const INDENT = " ";
|
|
28
|
+
const HINT = "ctrl+o to expand";
|
|
29
|
+
/** Entries past this fold into a "… N more entries" line; the predecessor's 80-line preview cap. */
|
|
30
|
+
const TREE_PREVIEW_LINES = 80;
|
|
31
|
+
|
|
32
|
+
/** The parsed ls result: the entry lines, plus the raw text the error rows render from. */
|
|
33
|
+
export interface LsModel {
|
|
34
|
+
/** Content-derived entry lines in host order; a directory keeps its trailing "/". */
|
|
35
|
+
entries: string[];
|
|
36
|
+
/** The result text verbatim — never re-derived, so an error body renders unedited. */
|
|
37
|
+
text: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** boundary: a result block is unvalidated at runtime; each field is checked before it is read. */
|
|
41
|
+
function resultText(result: ToolResult): string {
|
|
42
|
+
const blocks = result.content;
|
|
43
|
+
if (!Array.isArray(blocks)) return "";
|
|
44
|
+
const parts: string[] = [];
|
|
45
|
+
for (const block of blocks) {
|
|
46
|
+
if (typeof block !== "object" || block === null) continue;
|
|
47
|
+
if (block.type === "text" && typeof block.text === "string") parts.push(block.text);
|
|
48
|
+
}
|
|
49
|
+
return parts.join("\n");
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Inside cwd: relative. Outside: home-shortened. A relative argument already reads as written. */
|
|
53
|
+
function shortPath(cwd: string | undefined, path: string): string {
|
|
54
|
+
if (path.length === 0) return "";
|
|
55
|
+
if (!isAbsolute(path)) return path;
|
|
56
|
+
if (cwd === undefined || cwd.length === 0) return path;
|
|
57
|
+
const rel = relative(cwd, path);
|
|
58
|
+
if (!rel.startsWith("..")) return rel;
|
|
59
|
+
const home = homedir();
|
|
60
|
+
return home.length > 1 && path.startsWith(home) ? `~${path.slice(home.length)}` : path;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Blank-run collapse only: one blank line survives, consecutive ones fold — an error body stays legible. */
|
|
64
|
+
function compactLines(text: string): string[] {
|
|
65
|
+
const out: string[] = [];
|
|
66
|
+
let blank = false;
|
|
67
|
+
for (const line of text.replaceAll("\r\n", "\n").replaceAll("\r", "\n").trim().split("\n")) {
|
|
68
|
+
const isBlank = line.trim().length === 0;
|
|
69
|
+
if (isBlank && blank) continue;
|
|
70
|
+
out.push(line);
|
|
71
|
+
blank = isBlank;
|
|
72
|
+
}
|
|
73
|
+
return out;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Collapsed: the error's first line, then the hint. Expanded: the compacted body, indented. */
|
|
77
|
+
function errorBody(text: string, expanded: boolean, paint: RowPaint): string {
|
|
78
|
+
const [first = "Error", ...rest] = compactLines(text);
|
|
79
|
+
if (!expanded) return `${paint.error(first)}${paint.muted(` · ${HINT}`)}`;
|
|
80
|
+
return [first, ...rest].map((line) => (line.trim().length === 0 ? "" : `${INDENT}${paint.error(line)}`)).join("\n");
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** One line per entry: the connector from the gutter role, a directory name from accent. */
|
|
84
|
+
function tree(entries: string[], paint: RowPaint): string {
|
|
85
|
+
const shown = entries.slice(0, TREE_PREVIEW_LINES);
|
|
86
|
+
const lines = shown.map((entry, index) => {
|
|
87
|
+
const isDir = entry.endsWith("/");
|
|
88
|
+
const name = isDir ? entry.slice(0, -1) : entry;
|
|
89
|
+
// Only a fully-shown list ends in "└──"; a truncated list ends in its own "… more" line.
|
|
90
|
+
const last = index === shown.length - 1 && entries.length <= TREE_PREVIEW_LINES;
|
|
91
|
+
return `${INDENT}${paint.gutter(last ? "└── " : "├── ")}${isDir ? paint.accent(name) : name}`;
|
|
92
|
+
});
|
|
93
|
+
if (entries.length > TREE_PREVIEW_LINES) {
|
|
94
|
+
const hidden = entries.length - TREE_PREVIEW_LINES;
|
|
95
|
+
lines.push(`${INDENT}${paint.gutter("└── ")}${paint.muted(`… ${hidden} more entries`)}`);
|
|
96
|
+
}
|
|
97
|
+
return lines.join("\n");
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const spec: RowSpec<LsModel> = {
|
|
101
|
+
buildCall(args, ctx) {
|
|
102
|
+
// argsComplete is the gate: the host streams arguments, and a guessed value is worse than none.
|
|
103
|
+
if (ctx.argsComplete !== true) return { title: "ls" };
|
|
104
|
+
const rawPath = args.path;
|
|
105
|
+
const path = typeof rawPath === "string" ? shortPath(ctx.cwd, rawPath) : "";
|
|
106
|
+
const limit = typeof args.limit === "number" && Number.isFinite(args.limit) ? args.limit : undefined;
|
|
107
|
+
return { title: `ls${path.length > 0 ? ` ${path}` : ""}${limit === undefined ? "" : ` (limit ${limit})`}` };
|
|
108
|
+
},
|
|
109
|
+
|
|
110
|
+
build(result) {
|
|
111
|
+
const text = resultText(result);
|
|
112
|
+
if (text.trim().length === 0) return "unknown";
|
|
113
|
+
return {
|
|
114
|
+
text,
|
|
115
|
+
entries: text
|
|
116
|
+
.split("\n")
|
|
117
|
+
.map((line) => line.trim())
|
|
118
|
+
.filter((line) => line.length > 0),
|
|
119
|
+
};
|
|
120
|
+
},
|
|
121
|
+
|
|
122
|
+
project(model, view, paint) {
|
|
123
|
+
if (view.isError) return errorBody(model.text, view.expanded, paint);
|
|
124
|
+
const count = model.entries.length;
|
|
125
|
+
const summary = `${count} ${count === 1 ? "entry" : "entries"}`;
|
|
126
|
+
if (!view.expanded) return paint.muted(`${summary} · ${HINT}`);
|
|
127
|
+
return [paint.muted(summary), "", tree(model.entries, paint)].join("\n");
|
|
128
|
+
},
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
// shape: object literal — trigger #7, the spec module record T-28 composes; fields are known statically.
|
|
132
|
+
export const lsRow: { name: string; spec: RowSpec<LsModel>; descriptor: ModuleDescriptor } = {
|
|
133
|
+
name: "ls",
|
|
134
|
+
spec,
|
|
135
|
+
descriptor: { key: "row.ls", name: "Ls row", defaultEnabled: true, settings: [] },
|
|
136
|
+
};
|
|
@@ -0,0 +1,340 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* read.test.ts — the read spec at its seam: buildCall, build and project, with the marker paint double.
|
|
3
|
+
*
|
|
4
|
+
* Spec basis (from the captured host payloads below, not from the implementation):
|
|
5
|
+
* buildCall(incomplete args) -> { title: "read" } — the tool name only.
|
|
6
|
+
* build(complete text read) -> a file model: the cwd-relative label, the 1-indexed offset, the
|
|
7
|
+
* content's lines without the trailing terminator, no notice.
|
|
8
|
+
* build(offset/limit read) -> the same, with the host's own continuation notice kept apart
|
|
9
|
+
* from the lines, and the numbering starting at the offset.
|
|
10
|
+
* build(details.truncation) -> the body comes from the cut body in details, not from the text.
|
|
11
|
+
* build(no text block) -> "unknown" (the runtime draws its raw row).
|
|
12
|
+
* build(isError) -> the compacted failure body, labelled with the path.
|
|
13
|
+
* project(model, {expanded: false}) -> one summary line: header, range, count, expand hint.
|
|
14
|
+
* project(model, {expanded: true}) -> the header, the numbered lines, then the notice when one exists.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { readFileSync } from "node:fs";
|
|
18
|
+
import { describe, expect, it } from "vitest";
|
|
19
|
+
import { assertNoRealEscapes, markerPaint, testRenderContext } from "../../../../test/fakes/index.ts";
|
|
20
|
+
import type { RenderContext, RowSpec, RowView, ToolResult } from "../../../core/types.ts";
|
|
21
|
+
import { readRow, readSpec } from "./read.ts";
|
|
22
|
+
|
|
23
|
+
const PAINT = markerPaint();
|
|
24
|
+
const COLLAPSED: RowView = { expanded: false, isError: false, isPartial: false };
|
|
25
|
+
const EXPANDED: RowView = { expanded: true, isError: false, isPartial: false };
|
|
26
|
+
|
|
27
|
+
/** The results below are captured read-tool payloads; only the harness varies between tests. */
|
|
28
|
+
const ctxOf = (args: Record<string, unknown>): RenderContext =>
|
|
29
|
+
testRenderContext({ args, cwd: "/work", argsComplete: true });
|
|
30
|
+
|
|
31
|
+
/** The spec's parse for one result, with "unknown" ruled out by the caller's fixture choice. */
|
|
32
|
+
function modelOf(result: ToolResult, ctx: RenderContext) {
|
|
33
|
+
const model = readSpec.build(result, ctx);
|
|
34
|
+
if (model === "unknown") throw new Error("fixture parsed as unknown");
|
|
35
|
+
return model;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// ---------------------------------------------------------------------------
|
|
39
|
+
// Fixtures
|
|
40
|
+
// ---------------------------------------------------------------------------
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* recorded-from: executed @earendil-works/pi-coding-agent@1.0.4 `createReadTool(tmp).execute(…)` 2026-10-06 —
|
|
44
|
+
* a complete text read returns `{ content: [{ type: "text", text }] }` with no `details` key at all, and the
|
|
45
|
+
* text carries the file's trailing newline verbatim. The five file lines are the probe's own fixture file.
|
|
46
|
+
*/
|
|
47
|
+
const FILE_RESULT: ToolResult = {
|
|
48
|
+
content: [{ type: "text", text: "const a = 1;\nlet b = 2;\nfunction f(): number {\n\treturn a + b;\n}\n" }],
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* recorded-from: same probe, `{ path, offset: 3, limit: 2 }` — the host stops at the caller's limit and appends
|
|
53
|
+
* its own notice after a blank line, with NO details block (read.js:132-137). Only the path is replaced.
|
|
54
|
+
*/
|
|
55
|
+
const LIMIT_RESULT: ToolResult = {
|
|
56
|
+
content: [
|
|
57
|
+
{
|
|
58
|
+
type: "text",
|
|
59
|
+
text: "function f(): number {\n\treturn a + b;\n\n[2 more lines in file. Use offset=5 to continue.]",
|
|
60
|
+
},
|
|
61
|
+
],
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* recorded-from: same probe, a 4200-line file read from line 1 — the host cut it at the line limit and reported
|
|
66
|
+
* the cut in `details.truncation`, whose `content` is the body WITHOUT the notice. Body and the notice's range
|
|
67
|
+
* are scaled down (2000 -> 2 lines) so the fixture stays readable; totals, limits, flags and the notice wording
|
|
68
|
+
* are the capture's, and no key was dropped.
|
|
69
|
+
*/
|
|
70
|
+
const TRUNCATED_RESULT: ToolResult = {
|
|
71
|
+
content: [{ type: "text", text: "line 1\nline 2\n\n[Showing lines 1-2 of 4200. Use offset=3 to continue.]" }],
|
|
72
|
+
details: {
|
|
73
|
+
truncation: {
|
|
74
|
+
content: "line 1\nline 2",
|
|
75
|
+
truncated: true,
|
|
76
|
+
truncatedBy: "lines",
|
|
77
|
+
totalLines: 4200,
|
|
78
|
+
totalBytes: 40892,
|
|
79
|
+
outputLines: 2,
|
|
80
|
+
outputBytes: 12,
|
|
81
|
+
lastLinePartial: false,
|
|
82
|
+
firstLineExceedsLimit: false,
|
|
83
|
+
maxLines: 2000,
|
|
84
|
+
maxBytes: 51200,
|
|
85
|
+
},
|
|
86
|
+
},
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* recorded-from: ~/.agents/skills/typescript-coding/SKILL.md (read 2026-10-06), trimmed to its frontmatter and
|
|
91
|
+
* first heading — the YAML `name:` is what the skill header shows, and the closing `---` is what ends the scan.
|
|
92
|
+
*/
|
|
93
|
+
const SKILL_RESULT: ToolResult = {
|
|
94
|
+
content: [{ type: "text", text: "---\nname: typescript-coding\ndescription: Dev+test standard\n---\n\n# Mission\n" }],
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
// invented: the docs label keys on the path, not the body, so three stable lines are enough here.
|
|
98
|
+
const DOCS_RESULT: ToolResult = { content: [{ type: "text", text: "# Usage\n\nRun it.\n" }] };
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* recorded-from: the host's read tool threw `ENOENT: no such file or directory, access '<path>'` for a missing
|
|
102
|
+
* path (probe 2026-10-06); a failure reaches a renderer as a result with the message in a text block and the
|
|
103
|
+
* error flag set (agent-session.js:390-397). The path is the probe's own fixture name, cwd-relative.
|
|
104
|
+
*/
|
|
105
|
+
const ERROR_RESULT: ToolResult = {
|
|
106
|
+
content: [{ type: "text", text: "ENOENT: no such file or directory, access '/work/src/gone.ts'" }],
|
|
107
|
+
isError: true,
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
// invented: the multi-line failure shape the predecessor compacted (P helpers.ts:63-72) — consecutive blanks
|
|
111
|
+
// collapse to one, a single blank line survives.
|
|
112
|
+
const MULTILINE_ERROR: ToolResult = {
|
|
113
|
+
content: [{ type: "text", text: "Error: read failed\n\n\n at read (gone.ts:1)\n" }],
|
|
114
|
+
isError: true,
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
// invented: the message the collapse cap exists for; the path is long enough to pass the limit.
|
|
118
|
+
const LONG_ERROR = `ENOENT: no such file or directory, access '${"/nested".repeat(20)}'`;
|
|
119
|
+
|
|
120
|
+
// invented: a result whose only block is not text — the shape a future content kind would have (the declared
|
|
121
|
+
// ToolResultContent carries no image fields, so the block stays at its minimal shape).
|
|
122
|
+
const NO_TEXT_RESULT: ToolResult = { content: [{ type: "image" }] };
|
|
123
|
+
|
|
124
|
+
// ---------------------------------------------------------------------------
|
|
125
|
+
// AC-4 — the call line
|
|
126
|
+
// ---------------------------------------------------------------------------
|
|
127
|
+
|
|
128
|
+
describe("buildCall — the args gate", () => {
|
|
129
|
+
it("AC-4: an incomplete argument set yields the tool name only", () => {
|
|
130
|
+
const ctx = testRenderContext({ args: {}, argsComplete: false });
|
|
131
|
+
// fails_when: the call line invents an argument value before the host has delivered the arguments
|
|
132
|
+
expect(readSpec.buildCall({}, ctx).title).toBe("read");
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it("AC-4: no argument value ever reaches the call line", () => {
|
|
136
|
+
const ctx = ctxOf({ path: "secret/gone.ts", offset: 7, limit: 2 });
|
|
137
|
+
// fails_when: the path or range is fabricated into the title (the summary line is where they belong)
|
|
138
|
+
expect(readSpec.buildCall({ path: "secret/gone.ts", offset: 7, limit: 2 }, ctx).title).toBe("read");
|
|
139
|
+
});
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
// ---------------------------------------------------------------------------
|
|
143
|
+
// AC-1 — the golden rows
|
|
144
|
+
// ---------------------------------------------------------------------------
|
|
145
|
+
|
|
146
|
+
describe("project — the file row", () => {
|
|
147
|
+
it("AC-1: a complete read summarises the file, and numbers it from line 1 when expanded", () => {
|
|
148
|
+
const model = modelOf(FILE_RESULT, ctxOf({ path: "src/small.ts" }));
|
|
149
|
+
// fails_when: the summary renders a wrong path or count, or the content loses the trailing newline's trim
|
|
150
|
+
expect(readSpec.project(model, COLLAPSED, PAINT)).toBe(
|
|
151
|
+
`${PAINT.title("→ read")} ${PAINT.title("src/small.ts")} ${PAINT.muted("5 lines — ctrl+o to expand")}`,
|
|
152
|
+
);
|
|
153
|
+
expect(readSpec.project(model, EXPANDED, PAINT)).toBe(
|
|
154
|
+
[
|
|
155
|
+
`${PAINT.title("→ read")} ${PAINT.title("src/small.ts")}`,
|
|
156
|
+
`${PAINT.gutter(" 1")} ${PAINT.muted("│")} ${PAINT.output("const a = 1;")}`,
|
|
157
|
+
`${PAINT.gutter(" 2")} ${PAINT.muted("│")} ${PAINT.output("let b = 2;")}`,
|
|
158
|
+
`${PAINT.gutter(" 3")} ${PAINT.muted("│")} ${PAINT.output("function f(): number {")}`,
|
|
159
|
+
`${PAINT.gutter(" 4")} ${PAINT.muted("│")} ${PAINT.output("\treturn a + b;")}`,
|
|
160
|
+
`${PAINT.gutter(" 5")} ${PAINT.muted("│")} ${PAINT.output("}")}`,
|
|
161
|
+
].join("\n"),
|
|
162
|
+
);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it("AC-1: an offset read numbers from the offset and keeps the host's notice out of the lines", () => {
|
|
166
|
+
const model = modelOf(LIMIT_RESULT, ctxOf({ path: "src/small.ts", offset: 3, limit: 2 }));
|
|
167
|
+
// fails_when: the numbering starts at 1 (the predecessor's 0-based leak), or the notice is numbered as a line
|
|
168
|
+
expect(readSpec.project(model, COLLAPSED, PAINT)).toBe(
|
|
169
|
+
`${PAINT.title("→ read")} ${PAINT.title("src/small.ts")}${PAINT.muted(":3")} ${PAINT.muted("2 lines — ctrl+o to expand")}`,
|
|
170
|
+
);
|
|
171
|
+
expect(readSpec.project(model, EXPANDED, PAINT)).toBe(
|
|
172
|
+
[
|
|
173
|
+
`${PAINT.title("→ read")} ${PAINT.title("src/small.ts")}${PAINT.muted(":3")}`,
|
|
174
|
+
`${PAINT.gutter(" 3")} ${PAINT.muted("│")} ${PAINT.output("function f(): number {")}`,
|
|
175
|
+
`${PAINT.gutter(" 4")} ${PAINT.muted("│")} ${PAINT.output("\treturn a + b;")}`,
|
|
176
|
+
PAINT.warning("[2 more lines in file. Use offset=5 to continue.]"),
|
|
177
|
+
].join("\n"),
|
|
178
|
+
);
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it("AC-1: a truncated read takes its body from details and warns on the cut", () => {
|
|
182
|
+
const model = modelOf(TRUNCATED_RESULT, ctxOf({ path: "src/many.txt" }));
|
|
183
|
+
// fails_when: the displayed body comes from the notice-bearing text (so the notice becomes line 3), or
|
|
184
|
+
// a cut read renders silently
|
|
185
|
+
expect(readSpec.project(model, COLLAPSED, PAINT)).toBe(
|
|
186
|
+
`${PAINT.title("→ read")} ${PAINT.title("src/many.txt")} ${PAINT.muted("2 lines — ctrl+o to expand")}`,
|
|
187
|
+
);
|
|
188
|
+
expect(readSpec.project(model, EXPANDED, PAINT)).toBe(
|
|
189
|
+
[
|
|
190
|
+
`${PAINT.title("→ read")} ${PAINT.title("src/many.txt")}`,
|
|
191
|
+
`${PAINT.gutter(" 1")} ${PAINT.muted("│")} ${PAINT.output("line 1")}`,
|
|
192
|
+
`${PAINT.gutter(" 2")} ${PAINT.muted("│")} ${PAINT.output("line 2")}`,
|
|
193
|
+
PAINT.warning("[Showing lines 1-2 of 4200. Use offset=3 to continue.]"),
|
|
194
|
+
].join("\n"),
|
|
195
|
+
);
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it("AC-1: a SKILL.md read draws the skill header from the frontmatter name", () => {
|
|
199
|
+
const model = modelOf(SKILL_RESULT, ctxOf({ path: ".agents/skills/typescript-coding/SKILL.md" }));
|
|
200
|
+
// fails_when: the frontmatter name is missed (a directory name or a path appears instead), or the expanded
|
|
201
|
+
// header loses its collapse hint
|
|
202
|
+
expect(readSpec.project(model, COLLAPSED, PAINT)).toBe(
|
|
203
|
+
`${PAINT.accent("[skill]")} ${PAINT.title("typescript-coding")} ${PAINT.muted("6 lines — ctrl+o to expand")}`,
|
|
204
|
+
);
|
|
205
|
+
const expanded = readSpec.project(model, EXPANDED, PAINT).split("\n");
|
|
206
|
+
expect(expanded[0]).toBe(
|
|
207
|
+
`${PAINT.accent("[skill]")} ${PAINT.title("typescript-coding")} ${PAINT.muted("ctrl+o to collapse")}`,
|
|
208
|
+
);
|
|
209
|
+
expect(expanded.slice(1)).toEqual([
|
|
210
|
+
`${PAINT.gutter(" 1")} ${PAINT.muted("│")} ${PAINT.output("---")}`,
|
|
211
|
+
`${PAINT.gutter(" 2")} ${PAINT.muted("│")} ${PAINT.output("name: typescript-coding")}`,
|
|
212
|
+
`${PAINT.gutter(" 3")} ${PAINT.muted("│")} ${PAINT.output("description: Dev+test standard")}`,
|
|
213
|
+
`${PAINT.gutter(" 4")} ${PAINT.muted("│")} ${PAINT.output("---")}`,
|
|
214
|
+
`${PAINT.gutter(" 5")} ${PAINT.muted("│")} ${PAINT.output("")}`,
|
|
215
|
+
`${PAINT.gutter(" 6")} ${PAINT.muted("│")} ${PAINT.output("# Mission")}`,
|
|
216
|
+
]);
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
it("AC-1: only docs paths read as documents", () => {
|
|
220
|
+
const docs = modelOf(DOCS_RESULT, ctxOf({ path: "docs/usage.md" }));
|
|
221
|
+
// fails_when: every path is labelled a docs row, or a docs path is not
|
|
222
|
+
expect(readSpec.project(docs, COLLAPSED, PAINT)).toBe(
|
|
223
|
+
`${PAINT.title("→ read docs")} ${PAINT.title("docs/usage.md")} ${PAINT.muted("3 lines — ctrl+o to expand")}`,
|
|
224
|
+
);
|
|
225
|
+
const other = modelOf(DOCS_RESULT, ctxOf({ path: "src/notes.md" }));
|
|
226
|
+
expect(readSpec.project(other, COLLAPSED, PAINT)).toBe(
|
|
227
|
+
`${PAINT.title("→ read")} ${PAINT.title("src/notes.md")} ${PAINT.muted("3 lines — ctrl+o to expand")}`,
|
|
228
|
+
);
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
it("AC-1: the count follows the content, including the empty and single-line cases", () => {
|
|
232
|
+
const empty = modelOf({ content: [{ type: "text", text: "" }] }, ctxOf({ path: "src/empty.ts" }));
|
|
233
|
+
const single = modelOf({ content: [{ type: "text", text: "only\n" }] }, ctxOf({ path: "src/one.ts" }));
|
|
234
|
+
// fails_when: an empty file reports a phantom line, or a one-line file reads "1 lines"
|
|
235
|
+
expect(readSpec.project(empty, COLLAPSED, PAINT)).toBe(
|
|
236
|
+
`${PAINT.title("→ read")} ${PAINT.title("src/empty.ts")} ${PAINT.muted("0 lines — ctrl+o to expand")}`,
|
|
237
|
+
);
|
|
238
|
+
expect(readSpec.project(single, COLLAPSED, PAINT)).toBe(
|
|
239
|
+
`${PAINT.title("→ read")} ${PAINT.title("src/one.ts")} ${PAINT.muted("1 line — ctrl+o to expand")}`,
|
|
240
|
+
);
|
|
241
|
+
});
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
// ---------------------------------------------------------------------------
|
|
245
|
+
// AC-2 — error rows
|
|
246
|
+
// ---------------------------------------------------------------------------
|
|
247
|
+
|
|
248
|
+
describe("project — the error row", () => {
|
|
249
|
+
it("AC-2: a failed read carries its message on the collapsed line and its body when expanded", () => {
|
|
250
|
+
const model = modelOf(ERROR_RESULT, ctxOf({ path: "src/gone.ts" }));
|
|
251
|
+
// fails_when: an error row renders blank, loses the path, or shows the whole body collapsed
|
|
252
|
+
expect(readSpec.project(model, COLLAPSED, PAINT)).toBe(
|
|
253
|
+
`${PAINT.error("→ read")} ${PAINT.title("src/gone.ts")} ${PAINT.error("ENOENT: no such file or directory, access '/work/src/gone.ts'")}`,
|
|
254
|
+
);
|
|
255
|
+
expect(readSpec.project(model, EXPANDED, PAINT)).toBe(
|
|
256
|
+
PAINT.error("ENOENT: no such file or directory, access '/work/src/gone.ts'"),
|
|
257
|
+
);
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
it("AC-2: a multi-line failure compacts to one blank separator and keeps a blank line blank", () => {
|
|
261
|
+
const model = modelOf(MULTILINE_ERROR, ctxOf({ path: "src/gone.ts" }));
|
|
262
|
+
// fails_when: the collapsed line shows a blank first line, or the body paints blank lines
|
|
263
|
+
expect(readSpec.project(model, COLLAPSED, PAINT)).toBe(
|
|
264
|
+
`${PAINT.error("→ read")} ${PAINT.title("src/gone.ts")} ${PAINT.error("Error: read failed")}`,
|
|
265
|
+
);
|
|
266
|
+
expect(readSpec.project(model, EXPANDED, PAINT)).toBe(
|
|
267
|
+
[PAINT.error("Error: read failed"), "", PAINT.error(" at read (gone.ts:1)")].join("\n"),
|
|
268
|
+
);
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
it("AC-2: a failure with no body still renders a row, and a long message stays one line", () => {
|
|
272
|
+
const empty = modelOf({ content: [], isError: true }, ctxOf({ path: "src/gone.ts" }));
|
|
273
|
+
const long = modelOf(
|
|
274
|
+
{ content: [{ type: "text", text: LONG_ERROR }], isError: true },
|
|
275
|
+
ctxOf({ path: "src/gone.ts" }),
|
|
276
|
+
);
|
|
277
|
+
// fails_when: an empty error body renders a driverless blank line, or the collapsed line grows past one line
|
|
278
|
+
expect(readSpec.project(empty, COLLAPSED, PAINT)).toBe(
|
|
279
|
+
`${PAINT.error("→ read")} ${PAINT.title("src/gone.ts")} ${PAINT.error("failed")}`,
|
|
280
|
+
);
|
|
281
|
+
expect(readSpec.project(long, COLLAPSED, PAINT)).toBe(
|
|
282
|
+
`${PAINT.error("→ read")} ${PAINT.title("src/gone.ts")} ${PAINT.error(`${LONG_ERROR.slice(0, 119)}…`)}`,
|
|
283
|
+
);
|
|
284
|
+
});
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
// ---------------------------------------------------------------------------
|
|
288
|
+
// Degradation and the seam's structural contract
|
|
289
|
+
// ---------------------------------------------------------------------------
|
|
290
|
+
|
|
291
|
+
describe("the read spec's degradation and exports", () => {
|
|
292
|
+
it("degrades to unknown when the result carries no text block", () => {
|
|
293
|
+
// fails_when: a content kind the spec cannot read is drawn as an empty file row instead of the raw row
|
|
294
|
+
expect(readSpec.build(NO_TEXT_RESULT, ctxOf({ path: "shot.png" }))).toBe("unknown");
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
it("AC-3: the source carries no execution, registration or prompt text", () => {
|
|
298
|
+
const source = readFileSync(new URL("./read.ts", import.meta.url), "utf8");
|
|
299
|
+
// fails_when: an execute wrapper, a tool registration, or model-facing prompt text comes back in
|
|
300
|
+
for (const pattern of [
|
|
301
|
+
/registerTool/,
|
|
302
|
+
/\bexecute\s*[:(=]/,
|
|
303
|
+
/promptSnippet|promptGuidelines/,
|
|
304
|
+
/\bdescription\s*:/,
|
|
305
|
+
/\bparameters\s*:/,
|
|
306
|
+
/as unknown as/,
|
|
307
|
+
]) {
|
|
308
|
+
expect(source).not.toMatch(pattern);
|
|
309
|
+
}
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
it("AC-5: every escape a row draws is a paint marker", () => {
|
|
313
|
+
const ctx = ctxOf({ path: "src/small.ts" });
|
|
314
|
+
const outputs = [
|
|
315
|
+
readSpec.project(modelOf(FILE_RESULT, ctx), COLLAPSED, PAINT),
|
|
316
|
+
readSpec.project(modelOf(FILE_RESULT, ctx), EXPANDED, PAINT),
|
|
317
|
+
readSpec.project(modelOf(SKILL_RESULT, ctxOf({ path: "s/SKILL.md" })), COLLAPSED, PAINT),
|
|
318
|
+
readSpec.project(modelOf(ERROR_RESULT, ctxOf({ path: "src/gone.ts" })), COLLAPSED, PAINT),
|
|
319
|
+
readSpec.project(modelOf(ERROR_RESULT, ctxOf({ path: "src/gone.ts" })), EXPANDED, PAINT),
|
|
320
|
+
];
|
|
321
|
+
for (const output of outputs) {
|
|
322
|
+
// fails_when: a hardcoded color or reset leaks past paint
|
|
323
|
+
assertNoRealEscapes(output);
|
|
324
|
+
// Non-vacuity: a row always carries the marker paint's own codes (200..231), never an empty string.
|
|
325
|
+
expect(output).toMatch(/\[38;5;2[0-3]\dm/);
|
|
326
|
+
}
|
|
327
|
+
const source = readFileSync(new URL("./read.ts", import.meta.url), "utf8");
|
|
328
|
+
// fails_when: an escape literal appears in the source, painting around the paint seam
|
|
329
|
+
expect(source).not.toMatch(/\\x1b|\\u001b|\\033|\[38;5;/);
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
it("exports the record T-28 composes, and erases into the resolver's unknown-typed table", () => {
|
|
333
|
+
const table: Record<string, RowSpec> = { [readRow.name]: readRow.spec };
|
|
334
|
+
// fails_when: the table key, the descriptor or the RowSpec<M> erasure stops matching the seam map
|
|
335
|
+
expect(readRow.name).toBe("read");
|
|
336
|
+
expect(readRow.spec).toBe(readSpec);
|
|
337
|
+
expect(readRow.descriptor).toEqual({ key: "row.read", name: "Read row", defaultEnabled: true, settings: [] });
|
|
338
|
+
expect(table.read).toBe(readSpec);
|
|
339
|
+
});
|
|
340
|
+
});
|