@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,260 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* edit.test.ts — the `edit` row spec through its seam: the exported RowSpec's three functions.
|
|
3
|
+
*
|
|
4
|
+
* Fixtures are the host builtin's own shapes (provenance per fixture below). Every block names its
|
|
5
|
+
* ticket AC and its `fails_when`, and each was inverted — with the behavior-driving line mutated the
|
|
6
|
+
* block goes RED (T-17 report, INVERSION-LOG).
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { readFileSync } from "node:fs";
|
|
10
|
+
import { fileURLToPath } from "node:url";
|
|
11
|
+
import { describe, expect, it } from "vitest";
|
|
12
|
+
import { assertNoRealEscapes, markerPaint, testRenderContext } from "../../../../test/fakes/index.ts";
|
|
13
|
+
import type { RowView, ToolResult } from "../../../core/types.ts";
|
|
14
|
+
import { editRow } from "./edit.ts";
|
|
15
|
+
|
|
16
|
+
/* ── fixtures ────────────────────────────────────────────────────────── */
|
|
17
|
+
|
|
18
|
+
// recorded-from @earendil-works/pi-coding-agent@1.0.4, dist/core/tools/edit-diff.js generateDiffString
|
|
19
|
+
// (executed 2026-10-06): the diff string for a one-line body change is exactly this line shape —
|
|
20
|
+
// " <num> ctx" | "-<num> old" | "+<num> new", numbers padded to the width of the highest line.
|
|
21
|
+
const DIFF =
|
|
22
|
+
' 1 import { a } from "./a.ts";\n 2 \n 3 export function total(items) {\n-4 return items.length;\n+4 return items.reduce((n, i) => n + i, 0);\n 5 }';
|
|
23
|
+
|
|
24
|
+
// recorded-from the same host generator (executed 2026-10-06): an asymmetric change — one line
|
|
25
|
+
// replaced and one appended — so counts read from the wrong side cannot pass as the golden.
|
|
26
|
+
const DIFF_ASYMMETRIC =
|
|
27
|
+
' 1 const a = 1;\n-2 export const sum = () => a;\n+2 export const sum = () => a + a;\n 3 export const label = "sum";\n+4 export const version = 2;';
|
|
28
|
+
|
|
29
|
+
// recorded-from the same host, dist/core/tools/edit.js:123-134 (read 2026-10-06): args are
|
|
30
|
+
// {path, edits[]} and a successful result carries details {diff, patch, firstChangedLine}.
|
|
31
|
+
const ARGS: Record<string, unknown> = {
|
|
32
|
+
path: "/work/repo/src/total.ts",
|
|
33
|
+
edits: [{ oldText: " return items.length;", newText: " return items.reduce((n, i) => n + i, 0);" }],
|
|
34
|
+
};
|
|
35
|
+
const CWD = "/work/repo";
|
|
36
|
+
const RESULT: ToolResult = {
|
|
37
|
+
content: [{ type: "text", text: "Successfully replaced 1 block(s) in src/total.ts." }],
|
|
38
|
+
details: { diff: DIFF, patch: "@@ -4,1 +4,1 @@", firstChangedLine: 4 },
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
// recorded-from the same host, dist/core/tools/edit.js:118 — its thrown-message template
|
|
42
|
+
// `Could not edit file: ${path}. ${errorMessage}.`
|
|
43
|
+
const ERROR_RESULT: ToolResult = {
|
|
44
|
+
content: [{ type: "text", text: "Could not edit file: src/total.ts. Error code: ENOENT." }],
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
// invented: the host carries no fixture for a settled result with no details; this is the state the
|
|
48
|
+
// predecessor's pending branch exists for (a result rendered before its diff streamed in).
|
|
49
|
+
const DIFFLESS_RESULT: ToolResult = {
|
|
50
|
+
content: [{ type: "text", text: "Successfully replaced 1 block(s) in src/total.ts." }],
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
// invented: the predecessor's 80-char summary cap needs an error longer than 80 to be observable.
|
|
54
|
+
const LONG_ERROR_RESULT: ToolResult = {
|
|
55
|
+
content: [
|
|
56
|
+
{
|
|
57
|
+
type: "text",
|
|
58
|
+
text: "Error: oldText matched 3 locations in src/total.ts; make it unique by adding surrounding context.",
|
|
59
|
+
},
|
|
60
|
+
],
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
/* ── marker goldens ──────────────────────────────────────────────────── */
|
|
64
|
+
|
|
65
|
+
// test/fakes/paint.ts's role table, written out independently so a golden pins the exact bytes AND the
|
|
66
|
+
// role: output 201/221, muted 202/222, accent 203/223, error 204/224.
|
|
67
|
+
const OUTPUT = (text: string) => `\u001b[38;5;201m${text}\u001b[38;5;221m`;
|
|
68
|
+
const MUTED = (text: string) => `\u001b[38;5;202m${text}\u001b[38;5;222m`;
|
|
69
|
+
const ACCENT = (text: string) => `\u001b[38;5;212m${text}\u001b[38;5;232m`;
|
|
70
|
+
const ERROR = (text: string) => `\u001b[38;5;204m${text}\u001b[38;5;224m`;
|
|
71
|
+
const DIFF_ADDED = (text: string) => `\u001b[38;5;212m${text}\u001b[38;5;232m`;
|
|
72
|
+
const DIFF_REMOVED = (text: string) => `\u001b[38;5;213m${text}\u001b[38;5;233m`;
|
|
73
|
+
const DIFF_CONTEXT = (text: string) => `\u001b[38;5;214m${text}\u001b[38;5;234m`;
|
|
74
|
+
|
|
75
|
+
const paint = markerPaint();
|
|
76
|
+
|
|
77
|
+
/** The runtime's view derivation (T-12): host options + ctx flags arrive here. */
|
|
78
|
+
function view(over: Partial<RowView> = {}): RowView {
|
|
79
|
+
return { expanded: false, isError: false, isPartial: false, ...over };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/* ── the exported record ─────────────────────────────────────────────── */
|
|
83
|
+
|
|
84
|
+
describe("edit row spec — the exported record", () => {
|
|
85
|
+
it("AC-1: the record names the tool and its registry descriptor", () => {
|
|
86
|
+
// fails_when: the resolver table or the /render panel reads the wrong name or key
|
|
87
|
+
expect(editRow.name).toBe("edit");
|
|
88
|
+
expect(editRow.descriptor).toEqual({
|
|
89
|
+
key: "row.edit",
|
|
90
|
+
name: "Edit row",
|
|
91
|
+
defaultEnabled: true,
|
|
92
|
+
settings: [],
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
/* ── buildCall ───────────────────────────────────────────────────────── */
|
|
98
|
+
|
|
99
|
+
describe("edit row spec — buildCall", () => {
|
|
100
|
+
it("AC-4: a completed call names the path relative to ctx.cwd", () => {
|
|
101
|
+
const ctx = testRenderContext({ args: ARGS, cwd: CWD });
|
|
102
|
+
// fails_when: the title drops the path, or shows it raw with the cwd prefix
|
|
103
|
+
expect(editRow.spec.buildCall(ARGS, ctx)).toEqual({ title: "edit: src/total.ts" });
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it("AC-4: a half-streamed arg bag yields the tool name only", () => {
|
|
107
|
+
const ctx = testRenderContext({ args: ARGS, argsComplete: false });
|
|
108
|
+
// fails_when: a partial arg set is rendered as a path (the gate is dropped)
|
|
109
|
+
expect(editRow.spec.buildCall(ARGS, ctx)).toEqual({ title: "edit" });
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it("AC-4: empty args and a legacy `file_path` each render without inventing a value", () => {
|
|
113
|
+
const empty = testRenderContext({ args: {} });
|
|
114
|
+
// fails_when: an absent path renders as a placeholder or a stray separator
|
|
115
|
+
expect(editRow.spec.buildCall({}, empty)).toEqual({ title: "edit" });
|
|
116
|
+
// fails_when: a non-string argument is coerced into the title (a fabricated value)
|
|
117
|
+
expect(editRow.spec.buildCall({ path: 42 }, empty)).toEqual({ title: "edit" });
|
|
118
|
+
const legacy = testRenderContext({ args: { file_path: "src/a.ts" } });
|
|
119
|
+
// fails_when: the predecessor's arg spelling renders as the tool name only
|
|
120
|
+
expect(editRow.spec.buildCall({ file_path: "src/a.ts" }, legacy)).toEqual({ title: "edit: src/a.ts" });
|
|
121
|
+
const outside = { path: "/other/place/a.ts" };
|
|
122
|
+
const ctx = testRenderContext({ args: outside, cwd: CWD });
|
|
123
|
+
// fails_when: a path outside the cwd is rendered as a relative walk instead of the path itself
|
|
124
|
+
expect(editRow.spec.buildCall(outside, ctx)).toEqual({ title: "edit: /other/place/a.ts" });
|
|
125
|
+
});
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
/* ── project ─────────────────────────────────────────────────────────── */
|
|
129
|
+
|
|
130
|
+
describe("edit row spec — project", () => {
|
|
131
|
+
it("AC-1: collapsed is the golden changed-line summary", () => {
|
|
132
|
+
const model = editRow.spec.build(RESULT);
|
|
133
|
+
const out = editRow.spec.project(model, view(), paint);
|
|
134
|
+
// fails_when: a plausible-looking row renders the wrong counts or the wrong roles
|
|
135
|
+
expect(out).toBe(` ${ACCENT("+1")} ${DIFF_REMOVED("−1")}`);
|
|
136
|
+
assertNoRealEscapes(out);
|
|
137
|
+
// The runtime paints buildCall's title (the call line) above this body (T-12): the two lines
|
|
138
|
+
// together are the collapsed row, so this golden covers the name and the counts.
|
|
139
|
+
const ctx = testRenderContext({ args: ARGS, cwd: CWD });
|
|
140
|
+
expect(`${editRow.spec.buildCall(ARGS, ctx).title}\n${out}`).toBe(
|
|
141
|
+
`edit: src/total.ts\n ${ACCENT("+1")} ${DIFF_REMOVED("−1")}`,
|
|
142
|
+
);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it("AC-1: an asymmetric diff counts each side separately", () => {
|
|
146
|
+
const model = editRow.spec.build({ ...RESULT, details: { diff: DIFF_ASYMMETRIC, firstChangedLine: 2 } });
|
|
147
|
+
const out = editRow.spec.project(model, view(), paint);
|
|
148
|
+
// fails_when: the two counts are swapped or summed — a symmetric fixture hides exactly this
|
|
149
|
+
expect(out).toBe(` ${ACCENT("+2")} ${DIFF_REMOVED("−1")}`);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it("AC-1: expanded is the golden diff, one painted line per host line", () => {
|
|
153
|
+
const model = editRow.spec.build(RESULT);
|
|
154
|
+
const out = editRow.spec.project(model, view({ expanded: true }), paint);
|
|
155
|
+
const golden = [
|
|
156
|
+
DIFF_CONTEXT(' 1 import { a } from "./a.ts";'),
|
|
157
|
+
DIFF_CONTEXT(" 2 "),
|
|
158
|
+
DIFF_CONTEXT(" 3 export function total(items) {"),
|
|
159
|
+
DIFF_REMOVED("-4 return items.length;"),
|
|
160
|
+
DIFF_ADDED("+4 return items.reduce((n, i) => n + i, 0);"),
|
|
161
|
+
DIFF_CONTEXT(" 5 }"),
|
|
162
|
+
].join("\n");
|
|
163
|
+
// fails_when: a line loses its tone, is dropped, reordered, or split differently
|
|
164
|
+
expect(out).toBe(golden);
|
|
165
|
+
assertNoRealEscapes(out);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it("AC-1: a diff-less result reads pending collapsed and shows its text expanded", () => {
|
|
169
|
+
const model = editRow.spec.build(DIFFLESS_RESULT);
|
|
170
|
+
// fails_when: a result with no diff renders as though it carried changes
|
|
171
|
+
expect(editRow.spec.project(model, view(), paint)).toBe(` ${MUTED("· pending")}`);
|
|
172
|
+
expect(editRow.spec.project(model, view({ expanded: true }), paint)).toBe(
|
|
173
|
+
`${MUTED("diff unavailable")}\n${OUTPUT("Successfully replaced 1 block(s) in src/total.ts.")}`,
|
|
174
|
+
);
|
|
175
|
+
// fails_when: a malformed details payload renders as a diff line instead of degrading
|
|
176
|
+
for (const details of [{ diff: 42 }, "oops", undefined]) {
|
|
177
|
+
expect(editRow.spec.project(editRow.spec.build({ ...DIFFLESS_RESULT, details }), view(), paint)).toBe(
|
|
178
|
+
` ${MUTED("· pending")}`,
|
|
179
|
+
);
|
|
180
|
+
}
|
|
181
|
+
const bare = editRow.spec.build({ content: [] });
|
|
182
|
+
// fails_when: a result with no text paints an empty output line under the reason
|
|
183
|
+
expect(editRow.spec.project(bare, view({ expanded: true }), paint)).toBe(MUTED("diff unavailable"));
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it("AC-2: a failed call summarises the error on the collapsed line", () => {
|
|
187
|
+
const model = editRow.spec.build(ERROR_RESULT);
|
|
188
|
+
const out = editRow.spec.project(model, view({ isError: true }), paint);
|
|
189
|
+
// fails_when: errors render blank or crash
|
|
190
|
+
expect(out).toBe(` ${ERROR("✗ Could not edit file: src/total.ts. Error code: ENOENT.")}`);
|
|
191
|
+
assertNoRealEscapes(out);
|
|
192
|
+
const bare = editRow.spec.build({ content: [{ type: "image", text: "PNG bytes" }] });
|
|
193
|
+
// invented: a non-text block carrying text — the contract renders text blocks only, so this pins the skip.
|
|
194
|
+
// fails_when: a failed call whose result carries no text block renders a blank row (or the block's caption)
|
|
195
|
+
expect(editRow.spec.project(bare, view({ isError: true }), paint)).toBe(` ${ERROR("✗ Error")}`);
|
|
196
|
+
expect(editRow.spec.project(bare, view({ isError: true, expanded: true }), paint)).toBe(ERROR("Error"));
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
it("AC-2: an error longer than 80 characters is summarised, not wrapped", () => {
|
|
200
|
+
const model = editRow.spec.build(LONG_ERROR_RESULT);
|
|
201
|
+
const first = LONG_ERROR_RESULT.content?.[0]?.text ?? "";
|
|
202
|
+
const out = editRow.spec.project(model, view({ isError: true }), paint);
|
|
203
|
+
// fails_when: a long error blows the one-line row (the 80-char cap is dropped)
|
|
204
|
+
expect(out).toBe(` ${ERROR(`✗ ${first.slice(0, 79)}…`)}`);
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
it("AC-2: expanded shows the error body with blank runs collapsed", () => {
|
|
208
|
+
const model = editRow.spec.build({
|
|
209
|
+
content: [{ type: "text", text: "Error: oldText not found.\n\n\n\nCheck the file and retry." }],
|
|
210
|
+
});
|
|
211
|
+
const out = editRow.spec.project(model, view({ isError: true, expanded: true }), paint);
|
|
212
|
+
// fails_when: the expanded error body is blank, untrimmed, or the wrong role
|
|
213
|
+
expect(out).toBe([ERROR("Error: oldText not found."), "", ERROR("Check the file and retry.")].join("\n"));
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
it("AC-5: every escape in every state is a paint marker", () => {
|
|
217
|
+
const states = [
|
|
218
|
+
editRow.spec.project(editRow.spec.build(RESULT), view(), paint),
|
|
219
|
+
editRow.spec.project(editRow.spec.build(RESULT), view({ expanded: true }), paint),
|
|
220
|
+
editRow.spec.project(editRow.spec.build(ERROR_RESULT), view({ isError: true }), paint),
|
|
221
|
+
editRow.spec.project(editRow.spec.build(ERROR_RESULT), view({ isError: true, expanded: true }), paint),
|
|
222
|
+
editRow.spec.project(editRow.spec.build(DIFFLESS_RESULT), view(), paint),
|
|
223
|
+
editRow.spec.project(editRow.spec.build(DIFFLESS_RESULT), view({ expanded: true }), paint),
|
|
224
|
+
];
|
|
225
|
+
// fails_when: a hardcoded colour appears (this catches it; so does the source scan below)
|
|
226
|
+
for (const out of states) {
|
|
227
|
+
assertNoRealEscapes(out);
|
|
228
|
+
}
|
|
229
|
+
});
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
/* ── source contract ─────────────────────────────────────────────────── */
|
|
233
|
+
|
|
234
|
+
const SOURCE = readFileSync(fileURLToPath(new URL("./edit.ts", import.meta.url)), "utf8");
|
|
235
|
+
|
|
236
|
+
describe("edit row spec — source contract", () => {
|
|
237
|
+
it("AC-3: the port carries no registration, execute wrapper, or model-facing text", () => {
|
|
238
|
+
const forbidden = [
|
|
239
|
+
{ pattern: /\bregisterTool\s*\(/, what: "a tool-name registration" },
|
|
240
|
+
{ pattern: /\bexecute\s*[(:]/, what: "an execute wrapper" },
|
|
241
|
+
{ pattern: /\b(?:promptSnippet|promptGuidelines|systemPrompt|description)\b/, what: "model-facing text" },
|
|
242
|
+
{ pattern: /\bas\s+unknown\s+as\b/, what: "an `as unknown as` cast" },
|
|
243
|
+
];
|
|
244
|
+
// fails_when: the port smuggles execution or model-facing text back in
|
|
245
|
+
expect(forbidden.filter(({ pattern }) => pattern.test(SOURCE)).map(({ what }) => what)).toEqual([]);
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
it("AC-3: the spec imports the type contract and nothing that can execute", () => {
|
|
249
|
+
const specifiers = [...SOURCE.matchAll(/from\s+"([^"]+)"/g)].map((match) => match[1] ?? "");
|
|
250
|
+
// fails_when: the spec grows a host, filesystem, or sibling-lane import
|
|
251
|
+
expect(specifiers).toEqual(["node:path", "../../../core/types.ts"]);
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
it("AC-5: no escape literal in the row source — the scan half", () => {
|
|
255
|
+
// biome-ignore lint/suspicious/noControlCharactersInRegex: the ESC introducer is what the scan looks for
|
|
256
|
+
const ESCAPE = /\\x1[bB]|\\u001[bB]|\u001b/;
|
|
257
|
+
// fails_when: a colour is hardcoded instead of coming from a paint role
|
|
258
|
+
expect(ESCAPE.test(SOURCE)).toBe(false);
|
|
259
|
+
});
|
|
260
|
+
});
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
// ported from pi-pretty-tui/src/features/edit-write-collapse/render.ts (EDIT half) — survives because:
|
|
2
|
+
// the host builtin edit already returns `details.diff` (recorded: @earendil-works/pi-coding-agent@1.0.4,
|
|
3
|
+
// dist/core/tools/edit.js:132), so the collapsed count summary and the expanded diff need no
|
|
4
|
+
// registration; the registration, its execute wrapper and its model instructions stay behind.
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* edit.ts — the `edit` row: collapsed changed-line counts, expanded diff.
|
|
8
|
+
*
|
|
9
|
+
* Boundary: `details` and the result content blocks are host payloads, so both are narrowed by
|
|
10
|
+
* `isRecord` before a field is read; args are read as strings, and only once `argsComplete`.
|
|
11
|
+
*
|
|
12
|
+
* shape: none — dispatch object does not apply: one tool name, no discriminator. The export below is
|
|
13
|
+
* trigger #7 (fixed-shape record, fields known statically); `TONE_ROLE` declares its own shape.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { relative } from "node:path";
|
|
17
|
+
import type {
|
|
18
|
+
CallModel,
|
|
19
|
+
ModuleDescriptor,
|
|
20
|
+
RenderContext,
|
|
21
|
+
RowPaint,
|
|
22
|
+
RowSpec,
|
|
23
|
+
RowView,
|
|
24
|
+
ToolResult,
|
|
25
|
+
} from "../../../core/types.ts";
|
|
26
|
+
|
|
27
|
+
const TOOL = "edit";
|
|
28
|
+
const PENDING = "pending";
|
|
29
|
+
const UNAVAILABLE = "diff unavailable";
|
|
30
|
+
const ADDED_MARK = "+";
|
|
31
|
+
/** U+2212: a hyphen would read as diff-body text, not as a count. */
|
|
32
|
+
const REMOVED_MARK = "−";
|
|
33
|
+
const ERROR_MARK = "✗";
|
|
34
|
+
const ELLIPSIS = "…";
|
|
35
|
+
const MAX_ERROR_SUMMARY = 80;
|
|
36
|
+
|
|
37
|
+
type DiffTone = "added" | "removed" | "context";
|
|
38
|
+
|
|
39
|
+
interface DiffLine {
|
|
40
|
+
tone: DiffTone;
|
|
41
|
+
/** The host's line verbatim, sign and line number included. */
|
|
42
|
+
text: string;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
interface DiffModel {
|
|
46
|
+
lines: readonly DiffLine[];
|
|
47
|
+
added: number;
|
|
48
|
+
removed: number;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
interface EditModel {
|
|
52
|
+
/** Result text blocks joined; the error body and the diff-less fallback read it. */
|
|
53
|
+
text: string;
|
|
54
|
+
/** Absent when the result carried no diff — the host streams none until the edit lands. */
|
|
55
|
+
diff?: DiffModel;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Diff tones map straight onto the host toolDiff* tokens (paint exposes them as roles). */
|
|
59
|
+
// shape: dispatch object — trigger #1, three tones → three stateless role lookups.
|
|
60
|
+
const TONE_ROLE = {
|
|
61
|
+
added: "diffAdded",
|
|
62
|
+
removed: "diffRemoved",
|
|
63
|
+
context: "diffContext",
|
|
64
|
+
} as const satisfies Record<DiffTone, keyof RowPaint>;
|
|
65
|
+
|
|
66
|
+
// shape: none — one straight-line parse; the branch is a boundary check, not a discriminator.
|
|
67
|
+
function build(result: ToolResult): EditModel {
|
|
68
|
+
const text = resultText(result);
|
|
69
|
+
const diff = diffOf(result.details);
|
|
70
|
+
return diff === undefined ? { text } : { text, diff: parseDiff(diff) };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// shape: none — one pass; the tone branch is a two-value classification of the line's first character.
|
|
74
|
+
function parseDiff(diff: string): DiffModel {
|
|
75
|
+
const lines: DiffLine[] = [];
|
|
76
|
+
let added = 0;
|
|
77
|
+
let removed = 0;
|
|
78
|
+
for (const raw of normalizeLineEndings(diff).split("\n")) {
|
|
79
|
+
const tone = toneOf(raw);
|
|
80
|
+
if (tone === "added") added += 1;
|
|
81
|
+
else if (tone === "removed") removed += 1;
|
|
82
|
+
lines.push({ tone, text: raw });
|
|
83
|
+
}
|
|
84
|
+
return { lines, added, removed };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function toneOf(line: string): DiffTone {
|
|
88
|
+
if (line.startsWith("+")) return "added";
|
|
89
|
+
if (line.startsWith("-")) return "removed";
|
|
90
|
+
return "context";
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** The host's own edit call line is name + short path; the args gate keeps a half-streamed path out. */
|
|
94
|
+
// shape: none — single collapsed-title builder; no dispatch over a discriminator.
|
|
95
|
+
function buildCall(args: Record<string, unknown>, ctx: RenderContext): CallModel {
|
|
96
|
+
const path = ctx.argsComplete === false ? undefined : pathOf(args);
|
|
97
|
+
return { title: path === undefined ? TOOL : `${TOOL}: ${displayPath(ctx.cwd, path)}` };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// shape: none — three view-state branches of straight-line text; no dispatch over a discriminator value.
|
|
101
|
+
function project(m: EditModel, view: RowView, paint: RowPaint): string {
|
|
102
|
+
if (view.isError) {
|
|
103
|
+
const lines = errorLines(m.text);
|
|
104
|
+
return view.expanded ? errorBody(lines, paint) : errorSummary(lines, paint);
|
|
105
|
+
}
|
|
106
|
+
const diff = m.diff;
|
|
107
|
+
// A diff-less result reads pending collapsed (the predecessor's port) but shows its text expanded.
|
|
108
|
+
if (diff === undefined) return view.expanded ? fallbackBody(m.text, paint) : pendingSuffix(paint);
|
|
109
|
+
return view.expanded ? diffBody(diff.lines, paint) : countsSuffix(diff, paint);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** The runtime draws this body on its own line under the call line; the one-space lead is the
|
|
113
|
+
predecessor's tool-body indent (TOOL_RESULT_INDENT), so the counts read as the title's subordinate. */
|
|
114
|
+
function countsSuffix(diff: DiffModel, paint: RowPaint): string {
|
|
115
|
+
return ` ${paint.diffAdded(`${ADDED_MARK}${diff.added}`)} ${paint.diffRemoved(`${REMOVED_MARK}${diff.removed}`)}`;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function pendingSuffix(paint: RowPaint): string {
|
|
119
|
+
return ` ${paint.muted(`· ${PENDING}`)}`;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function errorSummary(lines: readonly string[], paint: RowPaint): string {
|
|
123
|
+
const first = (lines.find((line) => line.trim() !== "") ?? "Error").trim();
|
|
124
|
+
const summary = first.length > MAX_ERROR_SUMMARY ? `${first.slice(0, MAX_ERROR_SUMMARY - 1)}${ELLIPSIS}` : first;
|
|
125
|
+
return ` ${paint.error(`${ERROR_MARK} ${summary}`)}`;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function errorBody(lines: readonly string[], paint: RowPaint): string {
|
|
129
|
+
return lines.map((line) => (line === "" ? "" : paint.error(line))).join("\n");
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function diffBody(lines: readonly DiffLine[], paint: RowPaint): string {
|
|
133
|
+
return lines.map((line) => paint[TONE_ROLE[line.tone]](line.text)).join("\n");
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function fallbackBody(text: string, paint: RowPaint): string {
|
|
137
|
+
const rows = [paint.muted(UNAVAILABLE)];
|
|
138
|
+
const raw = normalizeLineEndings(text).trim();
|
|
139
|
+
if (raw !== "") rows.push(...raw.split("\n").map((line) => paint.output(line)));
|
|
140
|
+
return rows.join("\n");
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** `details` is a host payload: the diff is read only when it is a string. */
|
|
144
|
+
function diffOf(details: unknown): string | undefined {
|
|
145
|
+
if (!isRecord(details)) return undefined;
|
|
146
|
+
const diff = details.diff;
|
|
147
|
+
return typeof diff === "string" ? diff : undefined;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** Only text blocks with a string body contribute; a malformed block is skipped, never thrown on. */
|
|
151
|
+
function resultText(result: ToolResult): string {
|
|
152
|
+
const blocks = result.content;
|
|
153
|
+
if (!Array.isArray(blocks)) return "";
|
|
154
|
+
const parts: string[] = [];
|
|
155
|
+
for (const block of blocks) {
|
|
156
|
+
if (isRecord(block) && block.type === "text" && typeof block.text === "string") parts.push(block.text);
|
|
157
|
+
}
|
|
158
|
+
return parts.join("\n");
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** The host builtin's schema is `path`; `file_path` is the predecessor's legacy spelling. */
|
|
162
|
+
function pathOf(args: Record<string, unknown> | undefined): string | undefined {
|
|
163
|
+
return stringArg(args, "path") ?? stringArg(args, "file_path");
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function stringArg(args: Record<string, unknown> | undefined, key: string): string | undefined {
|
|
167
|
+
const value = args?.[key];
|
|
168
|
+
return typeof value === "string" && value !== "" ? value : undefined;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** The predecessor's shortPath without its HOME arm: a spec reads no env, so only cwd shortens. */
|
|
172
|
+
function displayPath(cwd: string | undefined, raw: string): string {
|
|
173
|
+
if (cwd === undefined) return raw;
|
|
174
|
+
const rel = relative(cwd, raw);
|
|
175
|
+
return rel === "" || rel.startsWith("..") ? raw : rel;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Predecessor renderToolError: CRLF normalized, outer blanks trimmed, blank runs collapsed; a payload
|
|
180
|
+
* with no text reads "Error" so a failed call never renders blank.
|
|
181
|
+
*/
|
|
182
|
+
function errorLines(text: string): readonly string[] {
|
|
183
|
+
const lines: string[] = [];
|
|
184
|
+
for (const line of normalizeLineEndings(text).trim().split("\n")) {
|
|
185
|
+
if (line === "" && lines[lines.length - 1] === "") continue;
|
|
186
|
+
lines.push(line);
|
|
187
|
+
}
|
|
188
|
+
return lines[0] === "" ? ["Error"] : lines;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function normalizeLineEndings(text: string): string {
|
|
192
|
+
return text.replace(/\r\n?/g, "\n");
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
196
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const descriptor: ModuleDescriptor = {
|
|
200
|
+
key: "row.edit",
|
|
201
|
+
name: "Edit row",
|
|
202
|
+
defaultEnabled: true,
|
|
203
|
+
settings: [],
|
|
204
|
+
};
|
|
205
|
+
|
|
206
|
+
// shape: object literal satisfying the lane's fixed-shape record — trigger #7; the spec holds no
|
|
207
|
+
// closure state, so trigger #4 does not apply.
|
|
208
|
+
export const editRow = {
|
|
209
|
+
name: TOOL,
|
|
210
|
+
// An `edit` result is either a diff or text and both render, so build never returns "unknown".
|
|
211
|
+
spec: { buildCall, build, project } satisfies RowSpec<EditModel>,
|
|
212
|
+
descriptor,
|
|
213
|
+
};
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ls.test.ts — the ls row judged at its seam: buildCall's argument gate, build's content parse, and
|
|
3
|
+
* project's collapsed count / expanded tree under the marker paint.
|
|
4
|
+
*
|
|
5
|
+
* Every assertion drives the exported `lsRow.spec`, the same object the resolver table hands the
|
|
6
|
+
* runtime; module internals are never imported.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
// shape: fixtures + pinned goldens — trigger #7, a fixed set of result shapes, each with its literal
|
|
10
|
+
// expected row; a fixture framework or a table-driven runner would be ceremony over five strings.
|
|
11
|
+
import { readFileSync } from "node:fs";
|
|
12
|
+
import { homedir } from "node:os";
|
|
13
|
+
import { join } from "node:path";
|
|
14
|
+
import { describe, expect, it } from "vitest";
|
|
15
|
+
import { assertNoRealEscapes, markerPaint, testRenderContext } from "../../../../test/fakes/index.ts";
|
|
16
|
+
import type { ToolResult } from "../../../core/types/host.ts";
|
|
17
|
+
import type { RowView } from "../types.ts";
|
|
18
|
+
import type { LsModel } from "./ls.ts";
|
|
19
|
+
import { lsRow } from "./ls.ts";
|
|
20
|
+
|
|
21
|
+
const PAINT = markerPaint();
|
|
22
|
+
const INDENT = " ";
|
|
23
|
+
const COLLAPSED: RowView = { expanded: false, isError: false, isPartial: false };
|
|
24
|
+
const EXPANDED: RowView = { expanded: true, isError: false, isPartial: false };
|
|
25
|
+
|
|
26
|
+
// Golden wraps recorded-from test/fakes/paint.ts MARKER_CODES (T-11, 2026-10-06): each role frames its
|
|
27
|
+
// text in its own escape pair, so a dropped, reordered or wrong-role marker fails the golden.
|
|
28
|
+
const muted = (text: string): string => `\u001b[38;5;202m${text}\u001b[38;5;222m`;
|
|
29
|
+
const gutter = (text: string): string => `\u001b[38;5;206m${text}\u001b[38;5;226m`;
|
|
30
|
+
const accent = (text: string): string => `\u001b[38;5;203m${text}\u001b[38;5;223m`;
|
|
31
|
+
const error = (text: string): string => `\u001b[38;5;204m${text}\u001b[38;5;224m`;
|
|
32
|
+
|
|
33
|
+
// Fixture provenance — shape recorded-from @earendil-works/pi-coding-agent@1.0.4
|
|
34
|
+
// dist/core/tools/ls.js:90-108 (read 2026-10-06): ONE text block whose lines are "<name>", a
|
|
35
|
+
// directory carrying a trailing "/", sorted case-insensitively.
|
|
36
|
+
// Fixture provenance — entries invented: the concrete names only drive the tree layout; any real
|
|
37
|
+
// listing has this identical shape.
|
|
38
|
+
const LISTING = "docs/\npackage.json\nREADME.md\nsrc/\nvitest.config.ts";
|
|
39
|
+
const RESULT: ToolResult = { content: [{ type: "text", text: LISTING }] };
|
|
40
|
+
|
|
41
|
+
/** Drives the seam the way the runtime does: parse once through build, then project. */
|
|
42
|
+
function parse(result: ToolResult): LsModel {
|
|
43
|
+
const model = lsRow.spec.build(result, testRenderContext());
|
|
44
|
+
if (model === "unknown") throw new Error("fixture did not parse as an ls result");
|
|
45
|
+
return model;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function title(args: Record<string, unknown>, cwd: string, argsComplete = true): string {
|
|
49
|
+
const ctx = testRenderContext({ args, cwd, argsComplete });
|
|
50
|
+
return lsRow.spec.buildCall(args, ctx).title;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
describe("lsRow.spec.buildCall — the call line", () => {
|
|
54
|
+
it("AC-1: a path inside cwd reads relative, and the limit rides along", () => {
|
|
55
|
+
const shown = title({ path: "/Users/dev/repo/src", limit: 20 }, "/Users/dev/repo");
|
|
56
|
+
// fails_when: the call line drops the path, shows an absolute path, or hides the limit
|
|
57
|
+
expect(shown).toBe("ls src (limit 20)");
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("AC-4: until argsComplete the title is the tool name only", () => {
|
|
61
|
+
const ctx = testRenderContext({ argsComplete: false, args: {}, cwd: "/Users/dev/repo" });
|
|
62
|
+
// fails_when: a partial or already-streamed argument value reaches the call line
|
|
63
|
+
expect(lsRow.spec.buildCall({ path: "/Users/dev/repo/src" }, ctx).title).toBe("ls");
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("AC-1: a relative argument reads as written; a path outside cwd is home-shortened", () => {
|
|
67
|
+
const outside = join(homedir(), "elsewhere", "notes");
|
|
68
|
+
// fails_when: a relative argument is joined against the wrong base, or an outside path stays absolute
|
|
69
|
+
expect(title({ path: "src" }, "/Users/dev/repo")).toBe("ls src");
|
|
70
|
+
expect(title({ path: outside }, "/Users/dev/repo")).toBe("ls ~/elsewhere/notes");
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
describe("lsRow.spec — the result projection", () => {
|
|
75
|
+
it("AC-1: the canonical listing renders the golden collapsed summary and expanded tree", () => {
|
|
76
|
+
const collapsed = lsRow.spec.project(parse(RESULT), COLLAPSED, PAINT);
|
|
77
|
+
const expanded = lsRow.spec.project(parse(RESULT), EXPANDED, PAINT);
|
|
78
|
+
// fails_when: a plausible-looking row renders the wrong count, a missing tree, or a directory as a file
|
|
79
|
+
expect(collapsed).toBe(muted("5 entries · ctrl+o to expand"));
|
|
80
|
+
expect(expanded).toBe(
|
|
81
|
+
[
|
|
82
|
+
muted("5 entries"),
|
|
83
|
+
"",
|
|
84
|
+
`${INDENT}${gutter("├── ")}${accent("docs")}`,
|
|
85
|
+
`${INDENT}${gutter("├── ")}package.json`,
|
|
86
|
+
`${INDENT}${gutter("├── ")}README.md`,
|
|
87
|
+
`${INDENT}${gutter("├── ")}${accent("src")}`,
|
|
88
|
+
`${INDENT}${gutter("└── ")}vitest.config.ts`,
|
|
89
|
+
].join("\n"),
|
|
90
|
+
);
|
|
91
|
+
const one = lsRow.spec.project(parse({ content: [{ type: "text", text: "src/" }] }), COLLAPSED, PAINT);
|
|
92
|
+
// fails_when: the summary reads "1 entries"
|
|
93
|
+
expect(one).toBe(muted("1 entry · ctrl+o to expand"));
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("AC-2: an error result puts the first error line on the collapsed row and the body when expanded", () => {
|
|
97
|
+
// invented: the host rejects ls with one line (ls.js:60,66,71); two message lines plus a blank
|
|
98
|
+
// run is the multi-line error shape the row must still fold into one collapsed line.
|
|
99
|
+
const failed: ToolResult = {
|
|
100
|
+
content: [{ type: "text", text: "Path not found: /nope\ncheck the path and retry\n\n\nmore detail" }],
|
|
101
|
+
};
|
|
102
|
+
const model = parse(failed);
|
|
103
|
+
// fails_when: errors render blank, render their body collapsed, or crash the row
|
|
104
|
+
expect(lsRow.spec.project(model, { ...COLLAPSED, isError: true }, PAINT)).toBe(
|
|
105
|
+
`${error("Path not found: /nope")}${muted(" · ctrl+o to expand")}`,
|
|
106
|
+
);
|
|
107
|
+
expect(lsRow.spec.project(model, { ...EXPANDED, isError: true }, PAINT)).toBe(
|
|
108
|
+
[
|
|
109
|
+
`${INDENT}${error("Path not found: /nope")}`,
|
|
110
|
+
`${INDENT}${error("check the path and retry")}`,
|
|
111
|
+
"",
|
|
112
|
+
`${INDENT}${error("more detail")}`,
|
|
113
|
+
].join("\n"),
|
|
114
|
+
);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it("AC-1: past the preview cap the tree ends in a count, not a last-entry connector", () => {
|
|
118
|
+
// invented: the host caps at 500 entries (ls.js:22), so the predecessor's 80-line render cap is
|
|
119
|
+
// what triggers the fold; the names only need to be 85 distinct entries.
|
|
120
|
+
const many = Array.from({ length: 85 }, (_, i) => `f${String(i).padStart(2, "0")}.ts`).join("\n");
|
|
121
|
+
const lines = lsRow.spec.project(parse({ content: [{ type: "text", text: many }] }), EXPANDED, PAINT).split("\n");
|
|
122
|
+
// fails_when: a truncated tree claims a final entry, or the hidden count is absent or wrong
|
|
123
|
+
expect(lines).toHaveLength(83);
|
|
124
|
+
expect(lines[81]).toBe(`${INDENT}${gutter("├── ")}f79.ts`);
|
|
125
|
+
expect(lines[82]).toBe(`${INDENT}${gutter("└── ")}${muted("… 5 more entries")}`);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it("AC-1: a result with no text content is unknown, so the runtime degrades to the raw row", () => {
|
|
129
|
+
// fails_when: an empty result renders a plausible-but-empty ls row
|
|
130
|
+
expect(lsRow.spec.build({ content: [] }, testRenderContext())).toBe("unknown");
|
|
131
|
+
expect(lsRow.spec.build({}, testRenderContext())).toBe("unknown");
|
|
132
|
+
expect(lsRow.spec.build({ content: [{ type: "text", text: " \n " }] }, testRenderContext())).toBe("unknown");
|
|
133
|
+
expect(lsRow.spec.build({ content: [{ type: "image" }] }, testRenderContext())).toBe("unknown");
|
|
134
|
+
});
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
describe("lsRow — the render-only and paint-only contracts", () => {
|
|
138
|
+
// recorded-from: src/renderers/tool/specs/ls.ts, read from disk at test time.
|
|
139
|
+
const SOURCE = readFileSync(new URL("./ls.ts", import.meta.url), "utf8");
|
|
140
|
+
|
|
141
|
+
it("AC-3: the ported source carries no execute wrapper and no model-facing text", () => {
|
|
142
|
+
const patterns = [
|
|
143
|
+
/\bregisterTool\b/,
|
|
144
|
+
/\bexecute\s*[:(]/,
|
|
145
|
+
/\bwrapExecuteWithMetrics\b/,
|
|
146
|
+
/\bpromptSnippet\b/,
|
|
147
|
+
/\bsnippet\b/,
|
|
148
|
+
/\bdescription\s*:/,
|
|
149
|
+
/\bparameters\s*:/,
|
|
150
|
+
];
|
|
151
|
+
// fails_when: the port smuggles a registration, an execute wrapper, or prompt text back in
|
|
152
|
+
expect(patterns.filter((pattern) => pattern.test(SOURCE)).map(String)).toEqual([]);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it("AC-5: every escape project emits is a paint marker, and the source holds none", () => {
|
|
156
|
+
const outputs = [
|
|
157
|
+
lsRow.spec.project(parse(RESULT), COLLAPSED, PAINT),
|
|
158
|
+
lsRow.spec.project(parse(RESULT), EXPANDED, PAINT),
|
|
159
|
+
lsRow.spec.project(parse(RESULT), { ...EXPANDED, isError: true }, PAINT),
|
|
160
|
+
];
|
|
161
|
+
for (const output of outputs) assertNoRealEscapes(output);
|
|
162
|
+
// biome-ignore lint/suspicious/noControlCharactersInRegex: the escape introducer is exactly what we scan for
|
|
163
|
+
const escapeLiteral = /\\x1[bB]|\\u001[bB]|\u001b/;
|
|
164
|
+
// fails_when: a hardcoded color or reset ships in the spec instead of a paint role
|
|
165
|
+
expect(escapeLiteral.test(SOURCE)).toBe(false);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it("AC-5: the descriptor defaults the row on, with no settings", () => {
|
|
169
|
+
// fails_when: T-28 registers a row under an unexpected key, or one that starts disabled
|
|
170
|
+
expect(lsRow.name).toBe("ls");
|
|
171
|
+
expect(lsRow.descriptor).toEqual({ key: "row.ls", name: "Ls row", defaultEnabled: true, settings: [] });
|
|
172
|
+
});
|
|
173
|
+
});
|