gentle-pi 3.3.0 → 3.4.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/README.md +63 -59
- package/assets/orchestrator-delegation.md +1 -1
- package/docs/assets/brand/gentle-shell-banner.gif +0 -0
- package/docs/assets/diagrams/odd-workflow.svg +74 -0
- package/docs/assets/features/agents-view.png +0 -0
- package/docs/assets/features/changes-view.png +0 -0
- package/docs/assets/features/command-palette.png +0 -0
- package/docs/assets/features/profiles-routing.png +0 -0
- package/docs/gentle-shell.md +26 -2
- package/docs/readme-reference.md +32 -6
- package/extensions/ask-user-question.ts +210 -0
- package/extensions/gentle-agents.ts +8 -1
- package/extensions/gentle-ai.ts +59 -18
- package/extensions/gentle-shell.ts +99 -10
- package/extensions/quiet-tools.ts +28 -5
- package/extensions/startup-banner.ts +25 -10
- package/lib/animation-policy.ts +52 -0
- package/lib/background-cache-warming.ts +38 -0
- package/lib/command-palette-catalog.ts +1 -0
- package/lib/inprocess-reviewer.ts +38 -1
- package/lib/native-review-cli.ts +36 -10
- package/lib/questionnaire/questionnaire-view.ts +603 -0
- package/lib/questionnaire/schema.ts +82 -0
- package/lib/questionnaire/validate.ts +141 -0
- package/lib/review-candidate-view-owner.ts +20 -5
- package/lib/review-candidate-view.ts +9 -2
- package/lib/review-host-relay.ts +10 -0
- package/lib/review-integration-v2.ts +4 -1
- package/lib/shell-bar.ts +13 -0
- package/lib/shell-sidebar-layout.ts +10 -4
- package/lib/shell-usage-view.ts +5 -2
- package/lib/shell-usage.ts +120 -6
- package/package.json +1 -1
- package/runtime/native-review-cli.mjs +35 -9
- package/runtime/review-integration-v2.mjs +4 -1
- package/scripts/gentle-ai-installer.mjs +10 -10
- package/scripts/verify-package-files.mjs +2 -3
- package/tests/animation-policy.test.ts +42 -0
- package/tests/ask-user-question.test.ts +435 -0
- package/tests/background-cache-warming.test.ts +60 -0
- package/tests/background-subagents.test.ts +68 -0
- package/tests/command-palette.test.ts +9 -0
- package/tests/gentle-agents.test.ts +71 -2
- package/tests/gentle-ai-binary.test.ts +1 -1
- package/tests/gentle-ai-installer.test.ts +47 -47
- package/tests/gentle-ai.test.ts +56 -4
- package/tests/gentle-shell.test.ts +355 -2
- package/tests/inprocess-reviewer.test.ts +92 -0
- package/tests/native-review-capability-contract.test.ts +16 -1
- package/tests/native-review-parity.test.ts +19 -0
- package/tests/package-manifest.test.ts +6 -17
- package/tests/questionnaire-schema.test.ts +274 -0
- package/tests/questionnaire-view.test.ts +446 -0
- package/tests/rdd-status-line.test.ts +21 -4
- package/tests/review-candidate-owner-retry.test.ts +63 -0
- package/tests/review-candidate-view.test.ts +15 -0
- package/tests/review-controller-native-routing.test.ts +86 -0
- package/tests/review-host-relay.test.ts +21 -0
- package/tests/review-integration-v2.test.ts +30 -0
- package/tests/review-ledger-contract.test.ts +1 -2
- package/tests/review-relay-transport-agent.test.ts +107 -2
- package/tests/review-risk-assessment.test.ts +104 -0
- package/tests/shell-bar.test.ts +8 -0
- package/tests/shell-sidebar-layout.test.ts +60 -5
- package/tests/shell-usage.test.ts +129 -0
- package/tests/skill-collision-prefixes.test.ts +1 -1
- package/tests/startup-banner.test.ts +93 -2
- package/docs/assets/brand/gentle-pi-banner.png +0 -0
- package/skills/release/SKILL.md +0 -137
|
@@ -0,0 +1,446 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import {
|
|
4
|
+
CURSOR_MARKER,
|
|
5
|
+
KeybindingsManager,
|
|
6
|
+
TUI_KEYBINDINGS,
|
|
7
|
+
visibleWidth,
|
|
8
|
+
type TuiMouseEvent,
|
|
9
|
+
} from "@earendil-works/pi-tui";
|
|
10
|
+
import {
|
|
11
|
+
MIN_PREVIEW_WIDTH,
|
|
12
|
+
QuestionnaireView,
|
|
13
|
+
type QuestionnaireResult,
|
|
14
|
+
type QuestionnaireTheme,
|
|
15
|
+
} from "../lib/questionnaire/questionnaire-view.ts";
|
|
16
|
+
import { CUSTOM_ROW_LABEL, type OptionData, type QuestionData } from "../lib/questionnaire/schema.ts";
|
|
17
|
+
|
|
18
|
+
const theme: QuestionnaireTheme = {
|
|
19
|
+
fg: (_color: string, text: string) => text,
|
|
20
|
+
bold: (text: string) => text,
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
const option = (label: string, description = `${label} description`, preview?: string): OptionData =>
|
|
24
|
+
preview === undefined ? { label, description } : { label, description, preview };
|
|
25
|
+
|
|
26
|
+
const question = (
|
|
27
|
+
text: string,
|
|
28
|
+
options: OptionData[],
|
|
29
|
+
overrides: { header?: string; multiSelect?: boolean } = {},
|
|
30
|
+
): QuestionData => ({
|
|
31
|
+
question: text,
|
|
32
|
+
header: overrides.header ?? "Header",
|
|
33
|
+
options,
|
|
34
|
+
...(overrides.multiSelect === undefined ? {} : { multiSelect: overrides.multiSelect }),
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
const single = (preview?: string): QuestionData[] => [
|
|
38
|
+
question("Proceed?", [option("Alpha", "First choice", preview), option("Beta", "Second choice")]),
|
|
39
|
+
];
|
|
40
|
+
|
|
41
|
+
const two = (): QuestionData[] => [
|
|
42
|
+
question("First?", [option("Alpha"), option("Beta")], { header: "First" }),
|
|
43
|
+
question("Second?", [option("Gamma"), option("Delta")], { header: "Second" }),
|
|
44
|
+
];
|
|
45
|
+
|
|
46
|
+
function createView(
|
|
47
|
+
questions: QuestionData[],
|
|
48
|
+
options: { onComplete?: (result: QuestionnaireResult) => void; keybindings?: KeybindingsManager } = {},
|
|
49
|
+
): QuestionnaireView {
|
|
50
|
+
return new QuestionnaireView({
|
|
51
|
+
questions,
|
|
52
|
+
theme,
|
|
53
|
+
onComplete: options.onComplete,
|
|
54
|
+
...(options.keybindings === undefined ? {} : { keybindings: options.keybindings }),
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function viewWithResult(questions: QuestionData[], keybindings?: KeybindingsManager) {
|
|
59
|
+
const completed: QuestionnaireResult[] = [];
|
|
60
|
+
const view = createView(questions, {
|
|
61
|
+
onComplete: (result) => completed.push(result),
|
|
62
|
+
...(keybindings === undefined ? {} : { keybindings }),
|
|
63
|
+
});
|
|
64
|
+
return { view, completed };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function render(view: QuestionnaireView, width = 100): string {
|
|
68
|
+
return view.render(width).join("\n");
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Strip SGR styling so assertions see the visible text, not cursor escape codes. */
|
|
72
|
+
function plain(text: string): string {
|
|
73
|
+
return text.replace(/\x1b\[[0-9;]*m/g, "");
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function assertFits(view: QuestionnaireView, width: number): void {
|
|
77
|
+
for (const line of view.render(width)) {
|
|
78
|
+
assert.ok(
|
|
79
|
+
visibleWidth(line) <= width,
|
|
80
|
+
`rendered line exceeds width ${width}: ${JSON.stringify(line)}`,
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function mouseEvent(lines: string[], row: number, type: TuiMouseEvent["type"]): TuiMouseEvent {
|
|
86
|
+
return {
|
|
87
|
+
type,
|
|
88
|
+
button: "left",
|
|
89
|
+
x: 1,
|
|
90
|
+
y: row,
|
|
91
|
+
screenX: 1,
|
|
92
|
+
screenY: row,
|
|
93
|
+
width: 100,
|
|
94
|
+
height: lines.length,
|
|
95
|
+
shift: false,
|
|
96
|
+
alt: false,
|
|
97
|
+
ctrl: false,
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Real terminal key sequences: legacy (ESC [) and application-cursor (ESC O).
|
|
102
|
+
const KEY = {
|
|
103
|
+
up: ["\x1b[A", "\x1bOA"],
|
|
104
|
+
down: ["\x1b[B", "\x1bOB"],
|
|
105
|
+
enter: "\r",
|
|
106
|
+
space: " ",
|
|
107
|
+
tab: "\t",
|
|
108
|
+
shiftTab: "\x1b[Z",
|
|
109
|
+
escape: "\x1b",
|
|
110
|
+
} as const;
|
|
111
|
+
|
|
112
|
+
test("renders exactly one active question plus a tab strip for all questions", () => {
|
|
113
|
+
const { view } = viewWithResult(two());
|
|
114
|
+
const rendered = render(view);
|
|
115
|
+
|
|
116
|
+
// Tab strip: progress plus every header, active one marked.
|
|
117
|
+
assert.match(rendered, /\[1\/2\]/);
|
|
118
|
+
assert.match(rendered, /▸ First/);
|
|
119
|
+
assert.match(rendered, /Second/);
|
|
120
|
+
|
|
121
|
+
// Only the first question body renders; the second body is absent.
|
|
122
|
+
assert.match(rendered, /First\?/);
|
|
123
|
+
assert.match(rendered, /❯ Alpha/);
|
|
124
|
+
assert.doesNotMatch(rendered, /Gamma/);
|
|
125
|
+
assert.doesNotMatch(rendered, /Delta/);
|
|
126
|
+
assert.doesNotMatch(rendered, /Second\?/);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
test("Tab and Shift-Tab switch the active question and preserve cursor and toggles", () => {
|
|
130
|
+
const { view, completed } = viewWithResult([
|
|
131
|
+
question("First?", [option("Alpha"), option("Beta")], { header: "First" }),
|
|
132
|
+
question("Second?", [option("Gamma"), option("Delta")], { header: "Second", multiSelect: true }),
|
|
133
|
+
]);
|
|
134
|
+
|
|
135
|
+
view.handleInput(KEY.down[0]); // First: cursor -> Beta
|
|
136
|
+
view.handleInput(KEY.tab);
|
|
137
|
+
let rendered = render(view);
|
|
138
|
+
assert.match(rendered, /\[2\/2\]/);
|
|
139
|
+
assert.match(rendered, /▸ Second/);
|
|
140
|
+
assert.match(rendered, /❯ \[ \] Gamma/);
|
|
141
|
+
assert.doesNotMatch(rendered, /❯ Beta/);
|
|
142
|
+
assert.doesNotMatch(rendered, /First\?/);
|
|
143
|
+
|
|
144
|
+
view.handleInput(KEY.space); // Second: toggle Gamma
|
|
145
|
+
assert.match(render(view), /❯ \[x\] Gamma/);
|
|
146
|
+
|
|
147
|
+
view.handleInput(KEY.shiftTab);
|
|
148
|
+
rendered = render(view);
|
|
149
|
+
assert.match(rendered, /\[1\/2\]/);
|
|
150
|
+
assert.match(rendered, /❯ Beta/); // cursor preserved
|
|
151
|
+
assert.doesNotMatch(rendered, /Gamma/); // second body hidden again
|
|
152
|
+
|
|
153
|
+
view.handleInput(KEY.tab);
|
|
154
|
+
assert.match(render(view), /❯ \[x\] Gamma/); // toggle preserved
|
|
155
|
+
assert.equal(completed.length, 0);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
test("real key sequences drive the cursor and commit in legacy and application-cursor form", () => {
|
|
159
|
+
for (const down of KEY.down) {
|
|
160
|
+
const { view, completed } = viewWithResult(single());
|
|
161
|
+
view.handleInput(down);
|
|
162
|
+
assert.match(render(view), /❯ Beta/, `down sequence ${JSON.stringify(down)} should move the cursor`);
|
|
163
|
+
view.handleInput(KEY.enter);
|
|
164
|
+
assert.equal(completed.length, 1, `enter after ${JSON.stringify(down)} should commit`);
|
|
165
|
+
assert.deepEqual(view.getResult().answers, [
|
|
166
|
+
{ questionIndex: 0, question: "Proceed?", kind: "option", answer: "Beta" },
|
|
167
|
+
]);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
for (const up of KEY.up) {
|
|
171
|
+
const { view } = viewWithResult(single());
|
|
172
|
+
view.handleInput(KEY.down[0]);
|
|
173
|
+
view.handleInput(KEY.down[0]);
|
|
174
|
+
view.handleInput(up);
|
|
175
|
+
assert.match(render(view), /❯ Beta/, `up sequence ${JSON.stringify(up)} should move the cursor back`);
|
|
176
|
+
}
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
test("the injected KeybindingsManager drives the same navigation and commit", () => {
|
|
180
|
+
const keybindings = new KeybindingsManager(TUI_KEYBINDINGS);
|
|
181
|
+
const { view, completed } = viewWithResult(single(), keybindings);
|
|
182
|
+
|
|
183
|
+
assert.ok(keybindings.matches(KEY.down[0], "tui.select.down"));
|
|
184
|
+
view.handleInput(KEY.down[0]);
|
|
185
|
+
assert.match(render(view), /❯ Beta/);
|
|
186
|
+
view.handleInput(KEY.enter);
|
|
187
|
+
assert.equal(completed.length, 1);
|
|
188
|
+
assert.equal(view.getResult().answers[0]?.answer, "Beta");
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
test("single-select commits on Enter and advances to the next unanswered question", () => {
|
|
192
|
+
const { view, completed } = viewWithResult(two());
|
|
193
|
+
|
|
194
|
+
view.handleInput(KEY.enter);
|
|
195
|
+
assert.equal(completed.length, 0, "the questionnaire is not done while a question is unanswered");
|
|
196
|
+
assert.equal(view.getResult().answers.length, 1);
|
|
197
|
+
assert.equal(view.activeQuestion, 1, "a commit advances to the next unanswered question");
|
|
198
|
+
assert.match(render(view), /\[2\/2\]/);
|
|
199
|
+
assert.match(render(view), /❯ Gamma/);
|
|
200
|
+
|
|
201
|
+
view.handleInput(KEY.enter);
|
|
202
|
+
assert.equal(completed.length, 1);
|
|
203
|
+
assert.deepEqual(view.getResult().answers, [
|
|
204
|
+
{ questionIndex: 0, question: "First?", kind: "option", answer: "Alpha" },
|
|
205
|
+
{ questionIndex: 1, question: "Second?", kind: "option", answer: "Gamma" },
|
|
206
|
+
]);
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
test("multi-select toggles with space and requires a non-empty selection to commit", () => {
|
|
210
|
+
const { view, completed } = viewWithResult([
|
|
211
|
+
question("Pick?", [option("One"), option("Two")], { multiSelect: true }),
|
|
212
|
+
]);
|
|
213
|
+
assert.match(render(view), /\[ \] One/);
|
|
214
|
+
|
|
215
|
+
view.handleInput(KEY.enter);
|
|
216
|
+
assert.equal(completed.length, 0, "an empty multiSelect commit is a no-op");
|
|
217
|
+
assert.equal(view.getResult().answers.length, 0);
|
|
218
|
+
|
|
219
|
+
view.handleInput(KEY.space);
|
|
220
|
+
assert.match(render(view), /\[x\] One/);
|
|
221
|
+
|
|
222
|
+
view.handleInput(KEY.down[0]);
|
|
223
|
+
view.handleInput(KEY.space);
|
|
224
|
+
assert.match(render(view), /\[x\] Two/);
|
|
225
|
+
|
|
226
|
+
view.handleInput(KEY.space);
|
|
227
|
+
assert.match(render(view), /\[ \] Two/);
|
|
228
|
+
|
|
229
|
+
view.handleInput(KEY.enter);
|
|
230
|
+
assert.equal(completed.length, 1);
|
|
231
|
+
assert.deepEqual(view.getResult().answers, [
|
|
232
|
+
{ questionIndex: 0, question: "Pick?", kind: "multi", answer: null, selected: ["One"] },
|
|
233
|
+
]);
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
test("custom row opens the editor, empty submit returns, and the draft survives a tab switch", () => {
|
|
237
|
+
const { view, completed } = viewWithResult(two());
|
|
238
|
+
|
|
239
|
+
// Move to the custom row on question one and open the editor.
|
|
240
|
+
view.handleInput(KEY.down[0]);
|
|
241
|
+
view.handleInput(KEY.down[0]);
|
|
242
|
+
view.handleInput(KEY.enter);
|
|
243
|
+
assert.match(render(view), /Custom response/);
|
|
244
|
+
assert.match(render(view), /> /);
|
|
245
|
+
|
|
246
|
+
view.handleInput(" ");
|
|
247
|
+
view.handleInput(KEY.enter);
|
|
248
|
+
assert.equal(completed.length, 0, "a whitespace-only submission stays uncommitted");
|
|
249
|
+
assert.doesNotMatch(render(view), /Custom response/);
|
|
250
|
+
|
|
251
|
+
// Reopen, type a draft, switch away, and come back: the draft is preserved.
|
|
252
|
+
view.handleInput(KEY.enter);
|
|
253
|
+
view.handleInput("draft text");
|
|
254
|
+
view.handleInput(KEY.tab);
|
|
255
|
+
assert.equal(view.activeQuestion, 1, "tab switches question even while editing");
|
|
256
|
+
view.handleInput(KEY.shiftTab);
|
|
257
|
+
assert.equal(view.activeQuestion, 0);
|
|
258
|
+
view.handleInput(KEY.enter); // still on the custom row
|
|
259
|
+
assert.match(plain(render(view)), /draft text/, "the custom draft is restored on reopen");
|
|
260
|
+
|
|
261
|
+
view.handleInput(KEY.enter);
|
|
262
|
+
assert.equal(completed.length, 0, "one of two questions answered is not completion");
|
|
263
|
+
assert.deepEqual(view.getResult().answers, [
|
|
264
|
+
{ questionIndex: 0, question: "First?", kind: "custom", answer: "draft text" },
|
|
265
|
+
]);
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
test("multi-select can commit a custom answer with the toggled options", () => {
|
|
269
|
+
const { view } = viewWithResult([question("Pick?", [option("One"), option("Two")], { multiSelect: true })]);
|
|
270
|
+
view.handleInput(KEY.space);
|
|
271
|
+
view.handleInput(KEY.down[0]);
|
|
272
|
+
view.handleInput(KEY.down[0]);
|
|
273
|
+
view.handleInput(KEY.enter);
|
|
274
|
+
|
|
275
|
+
assert.match(render(view), /Custom response/);
|
|
276
|
+
view.handleInput("free note");
|
|
277
|
+
view.handleInput(KEY.enter);
|
|
278
|
+
|
|
279
|
+
assert.deepEqual(view.getResult().answers, [
|
|
280
|
+
{ questionIndex: 0, question: "Pick?", kind: "custom", answer: "free note", selected: ["One"] },
|
|
281
|
+
]);
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
test("preview renders beside the list only while the focused option has one", () => {
|
|
285
|
+
const { view } = viewWithResult(single("Preview body line"));
|
|
286
|
+
const wide = render(view, 100);
|
|
287
|
+
assert.match(wide, /Preview body line/);
|
|
288
|
+
assert.match(wide, /❯ Alpha/);
|
|
289
|
+
|
|
290
|
+
view.handleInput(KEY.down[0]); // Beta has no preview
|
|
291
|
+
const collapsed = render(view, 100);
|
|
292
|
+
assert.doesNotMatch(collapsed, /Preview body line/);
|
|
293
|
+
assert.match(collapsed, /❯ Beta/);
|
|
294
|
+
assertFits(view, 100);
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
test("the preview pane wraps cleanly and never exceeds the terminal width", () => {
|
|
298
|
+
const body = "lorem ipsum dolor sit amet ".repeat(12);
|
|
299
|
+
const { view } = viewWithResult([
|
|
300
|
+
question("Proceed?", [option("Alpha", "First choice", body), option("Beta")]),
|
|
301
|
+
]);
|
|
302
|
+
for (const width of [MIN_PREVIEW_WIDTH, 100, 120]) {
|
|
303
|
+
assertFits(view, width);
|
|
304
|
+
}
|
|
305
|
+
assert.match(render(view, 100), /lorem ipsum/);
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
test("narrow widths render the preview inline without corrupting the body", () => {
|
|
309
|
+
const { view } = viewWithResult(single("Inline preview body"));
|
|
310
|
+
const narrow = render(view, 60);
|
|
311
|
+
assert.match(narrow, /Inline preview body/);
|
|
312
|
+
assert.match(narrow, /❯ Alpha/);
|
|
313
|
+
assert.match(narrow, /Type something\./);
|
|
314
|
+
assertFits(view, 60);
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
test("height stays bounded as the question count grows", () => {
|
|
318
|
+
const one = viewWithResult([question("Only?", [option("A"), option("B")], { header: "Only" })]);
|
|
319
|
+
const many = viewWithResult([
|
|
320
|
+
question("One?", [option("A"), option("B")], { header: "One" }),
|
|
321
|
+
question("Two?", [option("C"), option("D")], { header: "Two" }),
|
|
322
|
+
question("Three?", [option("E"), option("F")], { header: "Three" }),
|
|
323
|
+
question("Four?", [option("G"), option("H")], { header: "Four" }),
|
|
324
|
+
]);
|
|
325
|
+
|
|
326
|
+
const oneLines = one.view.render(100).length;
|
|
327
|
+
const manyLines = many.view.render(100).length;
|
|
328
|
+
assert.ok(
|
|
329
|
+
manyLines <= oneLines + 2,
|
|
330
|
+
`four questions must not stack bodies (one=${oneLines}, many=${manyLines})`,
|
|
331
|
+
);
|
|
332
|
+
// Only the first body renders, so later bodies stay absent.
|
|
333
|
+
const rendered = render(many.view, 100);
|
|
334
|
+
assert.doesNotMatch(rendered, /Two\?/);
|
|
335
|
+
assert.doesNotMatch(rendered, /Four\?/);
|
|
336
|
+
assert.match(rendered, /❯ A/);
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
test("Escape cancels the questionnaire with a cancelled result", () => {
|
|
340
|
+
const { view, completed } = viewWithResult(single());
|
|
341
|
+
view.handleInput(KEY.escape);
|
|
342
|
+
assert.equal(completed.length, 1);
|
|
343
|
+
assert.deepEqual(view.getResult(), { cancelled: true, answers: [] });
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
test("focus propagates to the free-text input for IME cursor positioning", () => {
|
|
347
|
+
const { view } = viewWithResult(single());
|
|
348
|
+
view.handleInput(KEY.down[0]);
|
|
349
|
+
view.handleInput(KEY.down[0]);
|
|
350
|
+
view.handleInput(KEY.enter);
|
|
351
|
+
|
|
352
|
+
view.focused = true;
|
|
353
|
+
assert.ok(render(view).includes(CURSOR_MARKER), "a focused view positions the input cursor");
|
|
354
|
+
|
|
355
|
+
view.focused = false;
|
|
356
|
+
assert.ok(!render(view).includes(CURSOR_MARKER), "an unfocused view hides the input cursor");
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
test("a committed option carries its preview on the answer row", () => {
|
|
360
|
+
const { view } = viewWithResult(single("Preview body line"));
|
|
361
|
+
view.handleInput(KEY.enter);
|
|
362
|
+
assert.deepEqual(view.getResult().answers, [
|
|
363
|
+
{ questionIndex: 0, question: "Proceed?", kind: "option", answer: "Alpha", preview: "Preview body line" },
|
|
364
|
+
]);
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
test("pointer press focuses a row and click commits it", () => {
|
|
368
|
+
const { view, completed } = viewWithResult(single());
|
|
369
|
+
const lines = view.render(100);
|
|
370
|
+
const betaRow = lines.findIndex((line) => line.includes("Beta"));
|
|
371
|
+
assert.ok(betaRow >= 0);
|
|
372
|
+
|
|
373
|
+
assert.equal(view.handleMouse(mouseEvent(lines, betaRow, "press"))?.handled, true);
|
|
374
|
+
assert.equal(completed.length, 0, "press focuses but never answers");
|
|
375
|
+
const afterPress = view.render(100);
|
|
376
|
+
const betaRowAfterPress = afterPress.findIndex((line) => line.includes("Beta"));
|
|
377
|
+
assert.equal(view.handleMouse(mouseEvent(afterPress, betaRowAfterPress, "click"))?.handled, true);
|
|
378
|
+
assert.equal(completed.length, 1);
|
|
379
|
+
assert.equal(view.getResult().answers[0]?.answer, "Beta");
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
test("pointer click toggles a multi-select option instead of committing", () => {
|
|
383
|
+
const { view, completed } = viewWithResult([
|
|
384
|
+
question("Pick?", [option("One"), option("Two")], { multiSelect: true }),
|
|
385
|
+
]);
|
|
386
|
+
|
|
387
|
+
let lines = view.render(100);
|
|
388
|
+
assert.equal(view.handleMouse(mouseEvent(lines, lines.findIndex((line) => line.includes("One")), "click"))?.handled, true);
|
|
389
|
+
assert.equal(completed.length, 0, "a multi-select click toggles without committing");
|
|
390
|
+
assert.match(render(view), /\[x\] One/);
|
|
391
|
+
|
|
392
|
+
// Clicking the same option again un-toggles it; still no commit.
|
|
393
|
+
lines = view.render(100);
|
|
394
|
+
view.handleMouse(mouseEvent(lines, lines.findIndex((line) => line.includes("One")), "click"));
|
|
395
|
+
assert.match(render(view), /\[ \] One/);
|
|
396
|
+
assert.equal(completed.length, 0);
|
|
397
|
+
|
|
398
|
+
// Toggle two options with the mouse, then commit with the keyboard.
|
|
399
|
+
lines = view.render(100);
|
|
400
|
+
view.handleMouse(mouseEvent(lines, lines.findIndex((line) => line.includes("One")), "click"));
|
|
401
|
+
lines = view.render(100);
|
|
402
|
+
view.handleMouse(mouseEvent(lines, lines.findIndex((line) => line.includes("Two")), "click"));
|
|
403
|
+
assert.match(render(view), /\[x\] One/);
|
|
404
|
+
assert.match(render(view), /\[x\] Two/);
|
|
405
|
+
|
|
406
|
+
view.handleInput(KEY.enter);
|
|
407
|
+
assert.equal(completed.length, 1);
|
|
408
|
+
assert.deepEqual(view.getResult().answers, [
|
|
409
|
+
{ questionIndex: 0, question: "Pick?", kind: "multi", answer: null, selected: ["One", "Two"] },
|
|
410
|
+
]);
|
|
411
|
+
});
|
|
412
|
+
|
|
413
|
+
test("pointer click on the custom row opens the editor without committing", () => {
|
|
414
|
+
for (const questions of [single(), [question("Pick?", [option("One"), option("Two")], { multiSelect: true })]]) {
|
|
415
|
+
const { view, completed } = viewWithResult(questions);
|
|
416
|
+
const lines = view.render(100);
|
|
417
|
+
const customRow = lines.findIndex((line) => line.includes(CUSTOM_ROW_LABEL));
|
|
418
|
+
assert.ok(customRow >= 0);
|
|
419
|
+
|
|
420
|
+
assert.equal(view.handleMouse(mouseEvent(lines, customRow, "click"))?.handled, true);
|
|
421
|
+
assert.match(render(view), /Custom response/);
|
|
422
|
+
assert.equal(completed.length, 0, "opening the custom editor never commits");
|
|
423
|
+
}
|
|
424
|
+
});
|
|
425
|
+
|
|
426
|
+
test("the custom row is always appended after the authored options", () => {
|
|
427
|
+
const { view } = viewWithResult(single());
|
|
428
|
+
const lines = view.render(100);
|
|
429
|
+
const betaRow = lines.findIndex((line) => line.includes("Beta"));
|
|
430
|
+
const customRow = lines.findIndex((line) => line.includes(CUSTOM_ROW_LABEL));
|
|
431
|
+
assert.ok(betaRow >= 0 && customRow >= 0);
|
|
432
|
+
assert.ok(customRow > betaRow);
|
|
433
|
+
});
|
|
434
|
+
|
|
435
|
+
test("every rendered line fits the width across 1-4 questions", () => {
|
|
436
|
+
const questions = [
|
|
437
|
+
question("One?", [option("A"), option("B", "B description", "preview ".repeat(20))], { header: "One" }),
|
|
438
|
+
question("Two?", [option("C"), option("D")], { header: "Two", multiSelect: true }),
|
|
439
|
+
question("Three?", [option("E"), option("F")], { header: "Three" }),
|
|
440
|
+
question("Four?", [option("G"), option("H")], { header: "Four" }),
|
|
441
|
+
];
|
|
442
|
+
const { view } = viewWithResult(questions);
|
|
443
|
+
for (const width of [40, 60, 80, 100, 140]) {
|
|
444
|
+
assertFits(view, width);
|
|
445
|
+
}
|
|
446
|
+
});
|
|
@@ -55,8 +55,8 @@ function modeResult(effective: "on" | "off", source: (typeof NATIVE_REVIEW_MODE_
|
|
|
55
55
|
operation: NATIVE_REVIEW_MODE_OPERATION.STATUS,
|
|
56
56
|
scope: "global",
|
|
57
57
|
status: {
|
|
58
|
-
global: effective,
|
|
59
|
-
cloneLocal: "",
|
|
58
|
+
global: source === NATIVE_REVIEW_MODE_SOURCE.GLOBAL ? effective : "",
|
|
59
|
+
cloneLocal: source === NATIVE_REVIEW_MODE_SOURCE.CLONE_LOCAL ? effective : "",
|
|
60
60
|
effective,
|
|
61
61
|
source,
|
|
62
62
|
},
|
|
@@ -97,6 +97,17 @@ test("renderRddStatusLine renders the effective mode and deciding source", () =>
|
|
|
97
97
|
);
|
|
98
98
|
});
|
|
99
99
|
|
|
100
|
+
test("synthetic default-ON status renders and is injected without a Shell default override", async () => {
|
|
101
|
+
clearRddStatusMemoForTesting();
|
|
102
|
+
const result = modeResult("on", NATIVE_REVIEW_MODE_SOURCE.DEFAULT);
|
|
103
|
+
assert.deepEqual(result.status, { global: "", cloneLocal: "", effective: "on", source: "default" });
|
|
104
|
+
const expected = "Receipt-driven development: on (decided by default)";
|
|
105
|
+
assert.equal(renderRddStatusLine(result.status), expected);
|
|
106
|
+
const line = await resolveRddStatusLine(fakeReviewMode(result), "/repo-synthetic-default-on");
|
|
107
|
+
assert.equal(line, expected);
|
|
108
|
+
assert.ok(getOrchestratorPrompt(process.cwd(), undefined, line).includes(expected));
|
|
109
|
+
});
|
|
110
|
+
|
|
100
111
|
test("renderRddStatusLine fails closed to unknown for a malformed or partial status object", () => {
|
|
101
112
|
// A bad upstream decode, a future field rename, or a hand-built fixture
|
|
102
113
|
// must never render an unrecognized value verbatim -- the render boundary
|
|
@@ -122,8 +133,8 @@ test("resolveRddModeStatus reads the on status from a stubbed native reviewMode
|
|
|
122
133
|
clearRddStatusMemoForTesting();
|
|
123
134
|
const status = await resolveRddModeStatus(fakeReviewMode(modeResult("on", NATIVE_REVIEW_MODE_SOURCE.CLONE_LOCAL)), "/repo-on");
|
|
124
135
|
assert.deepEqual(status, {
|
|
125
|
-
global: "
|
|
126
|
-
cloneLocal: "",
|
|
136
|
+
global: "",
|
|
137
|
+
cloneLocal: "on",
|
|
127
138
|
effective: "on",
|
|
128
139
|
source: "clone_local",
|
|
129
140
|
});
|
|
@@ -212,6 +223,12 @@ test("resolveRddStatusLine renders on, off, and unavailable from the stubbed nat
|
|
|
212
223
|
await resolveRddStatusLine(fakeReviewMode(modeResult("off", NATIVE_REVIEW_MODE_SOURCE.DEFAULT)), "/repo-line-off"),
|
|
213
224
|
"Receipt-driven development: off (decided by default)",
|
|
214
225
|
);
|
|
226
|
+
for (const source of [NATIVE_REVIEW_MODE_SOURCE.GLOBAL, NATIVE_REVIEW_MODE_SOURCE.CLONE_LOCAL]) {
|
|
227
|
+
assert.equal(
|
|
228
|
+
await resolveRddStatusLine(fakeReviewMode(modeResult("off", source)), `/repo-line-off-${source}`),
|
|
229
|
+
`Receipt-driven development: off (decided by ${source})`,
|
|
230
|
+
);
|
|
231
|
+
}
|
|
215
232
|
assert.equal(
|
|
216
233
|
await resolveRddStatusLine(undefined, "/repo-line-unknown"),
|
|
217
234
|
"Receipt-driven development: unknown (native status unavailable)",
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { test } from "node:test";
|
|
3
|
+
import { withWindowsSystemProbeRetry } from "../lib/review-candidate-view-owner.ts";
|
|
4
|
+
|
|
5
|
+
function failingProbe(code: string, attempts: { count: number }): () => string {
|
|
6
|
+
return () => {
|
|
7
|
+
attempts.count += 1;
|
|
8
|
+
const error = new Error(`fixture probe failure (${code})`) as NodeJS.ErrnoException;
|
|
9
|
+
error.code = code;
|
|
10
|
+
throw error;
|
|
11
|
+
};
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
test("windows system probe retries once on a transient timeout and returns the recovered value", () => {
|
|
15
|
+
const attempts = { count: 0 };
|
|
16
|
+
let recovered = false;
|
|
17
|
+
const value = withWindowsSystemProbeRetry(() => {
|
|
18
|
+
attempts.count += 1;
|
|
19
|
+
if (attempts.count === 1) {
|
|
20
|
+
const error = new Error("fixture probe timeout") as NodeJS.ErrnoException;
|
|
21
|
+
error.code = "ETIMEDOUT";
|
|
22
|
+
throw error;
|
|
23
|
+
}
|
|
24
|
+
recovered = true;
|
|
25
|
+
return "S-1-5-21-1";
|
|
26
|
+
});
|
|
27
|
+
assert.equal(value, "S-1-5-21-1");
|
|
28
|
+
assert.equal(recovered, true);
|
|
29
|
+
assert.equal(attempts.count, 2);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test("windows system probe does not retry authority-class failures", () => {
|
|
33
|
+
for (const code of ["EPERM", "EACCES", "ENOENT"]) {
|
|
34
|
+
const attempts = { count: 0 };
|
|
35
|
+
const probe = failingProbe(code, attempts);
|
|
36
|
+
assert.throws(() => withWindowsSystemProbeRetry(probe), (error: NodeJS.ErrnoException) => error.code === code);
|
|
37
|
+
assert.equal(attempts.count, 1, `code ${code} must fail closed without a retry`);
|
|
38
|
+
}
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("windows system probe fails closed after two transient failures", () => {
|
|
42
|
+
const attempts = { count: 0 };
|
|
43
|
+
const probe = failingProbe("ETIMEDOUT", attempts);
|
|
44
|
+
let thrown: unknown;
|
|
45
|
+
try {
|
|
46
|
+
withWindowsSystemProbeRetry(probe);
|
|
47
|
+
} catch (error) {
|
|
48
|
+
thrown = error;
|
|
49
|
+
}
|
|
50
|
+
assert.equal(attempts.count, 2);
|
|
51
|
+
assert.ok(thrown instanceof Error);
|
|
52
|
+
assert.equal((thrown as NodeJS.ErrnoException).code, "ETIMEDOUT");
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test("windows system probe does not retry when the first attempt succeeds", () => {
|
|
56
|
+
const attempts = { count: 0 };
|
|
57
|
+
const value = withWindowsSystemProbeRetry(() => {
|
|
58
|
+
attempts.count += 1;
|
|
59
|
+
return "S-1-5-18";
|
|
60
|
+
});
|
|
61
|
+
assert.equal(value, "S-1-5-18");
|
|
62
|
+
assert.equal(attempts.count, 1);
|
|
63
|
+
});
|
|
@@ -510,6 +510,21 @@ test("owner fsync failure prevents worktree registration", (t) => {
|
|
|
510
510
|
assert.equal(adds, 0);
|
|
511
511
|
});
|
|
512
512
|
|
|
513
|
+
test("owner preparation failure names the transient cause code in the wrapper message", (t) => {
|
|
514
|
+
const cwd = repository(t);
|
|
515
|
+
t.mock.method(fs, "fsyncSync", () => {
|
|
516
|
+
const error = new Error("fixture probe timeout") as NodeJS.ErrnoException;
|
|
517
|
+
error.code = "ETIMEDOUT";
|
|
518
|
+
throw error;
|
|
519
|
+
});
|
|
520
|
+
syncBuiltinESMExports();
|
|
521
|
+
t.after(() => { t.mock.restoreAll(); syncBuiltinESMExports(); });
|
|
522
|
+
assert.throws(
|
|
523
|
+
() => new CandidateViewRegistry().create({ contributorRoot: cwd }),
|
|
524
|
+
(error: unknown) => error instanceof CandidateViewError && error.reason === "candidate-owner-preparation-failed" && error.cause instanceof Error && (error.cause as NodeJS.ErrnoException).code === "ETIMEDOUT" && /candidate view owner preparation failed \(ETIMEDOUT\)$/.test(error.message),
|
|
525
|
+
);
|
|
526
|
+
});
|
|
527
|
+
|
|
513
528
|
test("ordinary cleanup preserves mismatched owner and cleanupAll continues other records", (t) => {
|
|
514
529
|
const cwd = repository(t);
|
|
515
530
|
const registry = new CandidateViewRegistry();
|
|
@@ -959,6 +959,92 @@ test("ordinary START binds the native workspace candidate and returns the native
|
|
|
959
959
|
assert.equal(startCalls, 1);
|
|
960
960
|
});
|
|
961
961
|
|
|
962
|
+
test("ordinary START materializes an excluded-untracked candidate exactly once before native START", async (t) => {
|
|
963
|
+
const cwd = repository(t);
|
|
964
|
+
writeFileSync(join(cwd, "unrelated.md"), "not part of the review\n");
|
|
965
|
+
const target = startStatus(cwd, undefined, []);
|
|
966
|
+
const candidateViews = new CandidateViewRegistry();
|
|
967
|
+
t.after(() => candidateViews.cleanupAll());
|
|
968
|
+
const creation = t.mock.method(candidateViews, "createOrReuse");
|
|
969
|
+
let startCalls = 0;
|
|
970
|
+
const native = {
|
|
971
|
+
targetStatus: async () => target,
|
|
972
|
+
start: async () => {
|
|
973
|
+
startCalls += 1;
|
|
974
|
+
return { lineageId: "excluded-untracked-start", state: "reviewing", riskLevel: "low", selectedLenses: [], changedFiles: 1, changedLines: 1, correctionBudget: 1, action: "created", lensesRequired: false, riskReasons: [], raw: {} };
|
|
975
|
+
},
|
|
976
|
+
} as unknown as NativeReviewCli;
|
|
977
|
+
|
|
978
|
+
const result = await __testing.executeReviewControllerOperation(
|
|
979
|
+
{ operation: "start", input: JSON.stringify({ mode: "ordinary" }) },
|
|
980
|
+
cwd, native, undefined, candidateViews,
|
|
981
|
+
);
|
|
982
|
+
|
|
983
|
+
assert.equal(result.operation, "start");
|
|
984
|
+
assert.equal(creation.mock.callCount(), 1);
|
|
985
|
+
assert.deepEqual(creation.mock.calls[0]!.arguments[0].intendedUntracked, []);
|
|
986
|
+
assert.equal(startCalls, 1);
|
|
987
|
+
});
|
|
988
|
+
|
|
989
|
+
test("ordinary START adopts the offered committed-range base while an untracked selection is in play", async (t) => {
|
|
990
|
+
// Live-reproduced blocker (gentle-pi#874): the committed-range adoption was
|
|
991
|
+
// skipped whenever an untracked selection was in play, so the provider's
|
|
992
|
+
// base-diff projection was compared against a base-less candidate view and
|
|
993
|
+
// assertNativeStartCandidateBinding failed with
|
|
994
|
+
// candidate-target-projection-drift before native START ever ran. An in-play
|
|
995
|
+
// selection must still pay the second read-only STATUS and materialize the
|
|
996
|
+
// candidate view WITH the offered base.
|
|
997
|
+
const cwd = repository(t);
|
|
998
|
+
// Commit the candidate change so a real committed range exists: the offered
|
|
999
|
+
// base commit's tree is the projection baseTree and the committed changed
|
|
1000
|
+
// path is the candidate scope.
|
|
1001
|
+
execFileSync("git", ["add", "tracked.txt"], { cwd });
|
|
1002
|
+
execFileSync("git", ["-c", "user.name=Routing Test", "-c", "user.email=routing@example.invalid", "commit", "-m", "candidate"], { cwd });
|
|
1003
|
+
const baseRef = execFileSync("git", ["rev-parse", "HEAD~1"], { cwd, encoding: "utf8" }).trim();
|
|
1004
|
+
const selection = { untrackedScope: "exclude", expectedUntrackedInventory: "inventory-sha256", intendedUntracked: [] as string[] };
|
|
1005
|
+
const offered = startStatus(cwd);
|
|
1006
|
+
offered.nextTransition = {
|
|
1007
|
+
kind: "execute",
|
|
1008
|
+
reasonCode: "start_required",
|
|
1009
|
+
execute: {
|
|
1010
|
+
operation: "review.start",
|
|
1011
|
+
arguments: [{ name: "base-ref", value: baseRef }, { name: "committed-only", value: "true" }],
|
|
1012
|
+
preconditions: [],
|
|
1013
|
+
binding: {},
|
|
1014
|
+
},
|
|
1015
|
+
} as unknown as ReviewStatusV3["nextTransition"];
|
|
1016
|
+
const adopted = startStatus(cwd, baseRef, []);
|
|
1017
|
+
const requests: Array<Record<string, unknown>> = [];
|
|
1018
|
+
const candidateViews = new CandidateViewRegistry();
|
|
1019
|
+
t.after(() => candidateViews.cleanupAll());
|
|
1020
|
+
const creation = t.mock.method(candidateViews, "createOrReuse");
|
|
1021
|
+
let startCalls = 0;
|
|
1022
|
+
const native = {
|
|
1023
|
+
targetStatus: async (request: Record<string, unknown>) => {
|
|
1024
|
+
requests.push(request);
|
|
1025
|
+
return requests.length === 1 ? offered : adopted;
|
|
1026
|
+
},
|
|
1027
|
+
start: async () => {
|
|
1028
|
+
startCalls += 1;
|
|
1029
|
+
return { lineageId: "adopted-base-untracked", state: "reviewing", riskLevel: "low", selectedLenses: [], changedFiles: 1, changedLines: 1, correctionBudget: 1, action: "created", lensesRequired: false, riskReasons: [], raw: {} };
|
|
1030
|
+
},
|
|
1031
|
+
} as unknown as NativeReviewCli;
|
|
1032
|
+
|
|
1033
|
+
const result = await __testing.executeReviewControllerOperation(
|
|
1034
|
+
{ operation: "start", input: JSON.stringify({ mode: "ordinary", ...selection }) },
|
|
1035
|
+
cwd, native, undefined, candidateViews,
|
|
1036
|
+
);
|
|
1037
|
+
|
|
1038
|
+
assert.equal(result.operation, "start");
|
|
1039
|
+
assert.deepEqual(requests, [
|
|
1040
|
+
{ cwd, agent: "pi", ...selection },
|
|
1041
|
+
{ cwd, agent: "pi", baseRef, committedOnly: true, ...selection },
|
|
1042
|
+
]);
|
|
1043
|
+
assert.equal(creation.mock.callCount(), 1);
|
|
1044
|
+
assert.equal(creation.mock.calls[0]!.arguments[0].baseRef, baseRef, "the candidate view adopts the offered base");
|
|
1045
|
+
assert.equal(startCalls, 1, "native START is reached exactly once without drift");
|
|
1046
|
+
});
|
|
1047
|
+
|
|
962
1048
|
test("ordinary START preserves sanitized foreign diagnostics through one ambiguous reconciliation", async (t) => {
|
|
963
1049
|
const cwd = repository(t);
|
|
964
1050
|
const target = startStatus(cwd);
|