@hypercarrier/rarebit 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +35 -0
- package/README.md +322 -161
- package/RUNBOOK.md +7 -5
- package/package.json +1 -1
- package/schemas/rarebit.schema.json +2 -1
- package/scripts/e2e-recap-pi.mjs +10 -8
- package/scripts/e2e-v020-pi.mjs +12 -10
- package/scripts/e2e-view-pi.mjs +500 -0
- package/scripts/experiment-recap-fullscreen.mjs +56 -0
- package/src/extension.mjs +85 -15
- package/src/rarebit-artifact-state.mjs +8 -12
- package/src/rarebit-command.mjs +10 -0
- package/src/rarebit-core.mjs +42 -21
- package/src/rarebit-read-checkpoint.mjs +59 -0
- package/src/rarebit-recap.mjs +183 -125
- package/src/rarebit-service.mjs +5 -2
- package/src/rarebit-settings-ui.mjs +2 -0
- package/src/rarebit-view.mjs +426 -0
- package/src/types.d.ts +5 -2
- package/test/rarebit-artifact-state.test.mjs +10 -4
- package/test/rarebit-command.test.mjs +13 -2
- package/test/rarebit-core.test.mjs +50 -0
- package/test/rarebit-recap.test.mjs +157 -8
- package/test/rarebit-settings-ui.test.mjs +34 -22
- package/test/rarebit-sidecar-protocol.test.mjs +20 -0
- package/test/rarebit-v020.test.mjs +16 -11
- package/test/rarebit-view.test.mjs +270 -0
- package/test/recap-read-checkpoint.test.mjs +126 -0
package/scripts/e2e-recap-pi.mjs
CHANGED
|
@@ -318,7 +318,7 @@ try {
|
|
|
318
318
|
const providerRequestCountBeforeTyping = requestCountAtWidget();
|
|
319
319
|
|
|
320
320
|
// Keystrokes do not emit a Rarebit lifecycle event. The human can keep
|
|
321
|
-
// editing while the display-only
|
|
321
|
+
// editing while the display-only Recap remains in the transcript.
|
|
322
322
|
await sendKeys(socket, session, "typed while recap is visible");
|
|
323
323
|
const typingPane = await waitFor(
|
|
324
324
|
"typed editor text while recap remains",
|
|
@@ -340,7 +340,7 @@ try {
|
|
|
340
340
|
"typing/display must not trigger another Summary",
|
|
341
341
|
);
|
|
342
342
|
|
|
343
|
-
// Submitted input
|
|
343
|
+
// Submitted input preserves the historical transcript Recap.
|
|
344
344
|
await press(socket, session, "Enter");
|
|
345
345
|
await waitFor(
|
|
346
346
|
"second provider request",
|
|
@@ -351,14 +351,14 @@ try {
|
|
|
351
351
|
15_000,
|
|
352
352
|
);
|
|
353
353
|
const afterSubmitPane = await waitFor(
|
|
354
|
-
"recap
|
|
354
|
+
"recap persists after submitted input",
|
|
355
355
|
async () => {
|
|
356
356
|
const pane = await capturePane(socket, session);
|
|
357
|
-
return
|
|
357
|
+
return pane.includes("RECAP_SENTINEL") ? pane : false;
|
|
358
358
|
},
|
|
359
359
|
5_000,
|
|
360
360
|
);
|
|
361
|
-
assert.
|
|
361
|
+
assert.match(afterSubmitPane, /RECAP_SENTINEL/);
|
|
362
362
|
|
|
363
363
|
const loggedRequests = (await readFile(requestLog, "utf8"))
|
|
364
364
|
.trim()
|
|
@@ -382,7 +382,9 @@ try {
|
|
|
382
382
|
const sessionText = (
|
|
383
383
|
await Promise.all(sessionFiles.map((path) => readFile(path, "utf8")))
|
|
384
384
|
).join("\n");
|
|
385
|
-
|
|
385
|
+
const sessionEntries = sessionText.split("\n").filter(Boolean).map(JSON.parse);
|
|
386
|
+
assert.ok(sessionEntries.some((entry) => entry.type === "custom" && entry.customType === "rarebit-recap" && entry.data.summary.includes("RECAP_SENTINEL")));
|
|
387
|
+
assert.ok(!sessionEntries.filter((entry) => entry.type === "message").some((entry) => JSON.stringify(entry).includes("RECAP_SENTINEL")));
|
|
386
388
|
const receiptFiles = await walkFiles(rarebitRoot);
|
|
387
389
|
assert.ok(receiptFiles.length > 0, "the real Rarebit sidecar was written");
|
|
388
390
|
|
|
@@ -422,9 +424,9 @@ try {
|
|
|
422
424
|
checks: [
|
|
423
425
|
"real Pi TUI rendered recap widget after 60 seconds",
|
|
424
426
|
"typing preserved widget",
|
|
425
|
-
"submitted input
|
|
427
|
+
"submitted input preserved transcript Recap",
|
|
426
428
|
"next provider request excluded recap sentinel",
|
|
427
|
-
"Session JSONL
|
|
429
|
+
"Session JSONL stored Recap as custom UI state, outside provider messages",
|
|
428
430
|
],
|
|
429
431
|
}, null, 2),
|
|
430
432
|
);
|
package/scripts/e2e-v020-pi.mjs
CHANGED
|
@@ -204,14 +204,15 @@ export default function registerFixtureRarebit(pi) {
|
|
|
204
204
|
return output.includes("FULL_RECAP_TAIL_24") ? output : false;
|
|
205
205
|
}, 75_000);
|
|
206
206
|
const widgetAnsi = await pane(socket, session, true);
|
|
207
|
-
assert.match(widgetPane, /Recap/);
|
|
207
|
+
assert.match(widgetPane, /Recap·appearsfinished·[0-9]{2}\/[0-9]{2} [0-9]{2}:[0-9]{2}\(GMT[+-][0-9][^\n]*\[got it\]/);
|
|
208
|
+
assert.match(widgetPane, /\[got it\]/);
|
|
208
209
|
assert.match(widgetPane, /FULL_RECAP_TAIL_1/);
|
|
209
210
|
assert.match(widgetPane, /FULL_RECAP_TAIL_24/);
|
|
210
211
|
assert.doesNotMatch(widgetPane, /recap expand/i);
|
|
211
212
|
assert.match(widgetAnsi, /\x1b\[[0-9;]*m/);
|
|
212
213
|
assert.match(widgetAnsi, /Recap/);
|
|
213
|
-
assert.match(widgetAnsi, /\x1b\[38;5;
|
|
214
|
-
assert.match(widgetAnsi, /\x1b\[38;5;
|
|
214
|
+
assert.match(widgetAnsi, /\x1b\[38;(?:5;241|2;102;102;102)m[^\n]*Recap/, "Recap header uses the muted foreground");
|
|
215
|
+
assert.match(widgetAnsi, /\x1b\[38;(?:5;244|2;128;128;128)m[^\n]*FULL_RECAP_TAIL_1/, "Recap body uses the faded foreground");
|
|
215
216
|
assert.ok(Date.now() - summaryAt >= 59_750, "Recap rendered before the one-minute default");
|
|
216
217
|
|
|
217
218
|
const requestCountAtWidget = requests.length;
|
|
@@ -225,17 +226,18 @@ export default function registerFixtureRarebit(pi) {
|
|
|
225
226
|
|
|
226
227
|
await press(socket, session, "Enter");
|
|
227
228
|
await waitFor("second provider request", async () => requests.filter((entry) => entry.kind === "interactive").length >= 2, 15_000);
|
|
228
|
-
const afterSubmit = await waitFor("
|
|
229
|
+
const afterSubmit = await waitFor("second interactive response", async () => {
|
|
229
230
|
const output = await pane(socket, session);
|
|
230
|
-
return
|
|
231
|
-
},
|
|
232
|
-
assert.doesNotMatch(afterSubmit, /FULL_RECAP_TAIL_24/);
|
|
231
|
+
return output.includes("INTERACTIVE_2_DONE") ? output : false;
|
|
232
|
+
}, 15_000);
|
|
233
233
|
|
|
234
234
|
const secondInteractive = requests.filter((entry) => entry.kind === "interactive")[1];
|
|
235
235
|
assert.doesNotMatch(JSON.stringify(secondInteractive.body), /FULL_RECAP_TAIL/);
|
|
236
236
|
const sessionFiles = (await walk(sessionDir)).filter((file) => file.endsWith(".jsonl"));
|
|
237
237
|
const sessionText = (await Promise.all(sessionFiles.map((file) => readFile(file, "utf8")))).join("\n");
|
|
238
|
-
|
|
238
|
+
const nativeEntries = sessionText.split("\n").filter(Boolean).map(JSON.parse);
|
|
239
|
+
assert.ok(nativeEntries.some((entry) => entry.type === "custom" && entry.customType === "rarebit-recap" && entry.data?.summary?.includes("FULL_RECAP_TAIL_24")), "the scrollable Recap is persisted as native custom evidence");
|
|
240
|
+
assert.ok(!nativeEntries.filter((entry) => entry.type === "message").some((entry) => JSON.stringify(entry).includes("FULL_RECAP_TAIL")), "Recap prose stays out of model conversation messages");
|
|
239
241
|
const settings = JSON.parse(await readFile(join(agentDir, "settings.json"), "utf8"));
|
|
240
242
|
assert.deepEqual(settings.other_extension, { preserve: true });
|
|
241
243
|
assert.equal(settings.rarebit.summary_prompt, "Use short factual bullets and state uncertainty.");
|
|
@@ -251,7 +253,7 @@ export default function registerFixtureRarebit(pi) {
|
|
|
251
253
|
elapsedFromSummaryMs: Date.now() - summaryAt,
|
|
252
254
|
providerRequests: requests.length,
|
|
253
255
|
evidenceRoot: keepTemp ? temp : null,
|
|
254
|
-
checks: [artifactLabel, "custom summary_prompt reached provider", "full multiline Recap
|
|
256
|
+
checks: [artifactLabel, "custom summary_prompt reached provider", "full multiline native Recap rendered", "ANSI themed Recap rendered", "typing preserved Recap", "native Recap evidence survived submit", "display content excluded from next provider conversation messages"],
|
|
255
257
|
}, null, 2));
|
|
256
258
|
} catch (error) {
|
|
257
259
|
console.error(`Rarebit v0.2 Pi E2E failed: ${error?.stack ?? error}`);
|
|
@@ -265,5 +267,5 @@ export default function registerFixtureRarebit(pi) {
|
|
|
265
267
|
await execFile(tmux, ["-L", socket, "kill-server"]).catch(() => {});
|
|
266
268
|
}
|
|
267
269
|
if (provider?.listening) await new Promise((resolvePromise) => provider.close(resolvePromise));
|
|
268
|
-
if (!keepTemp) await rm(temp, { recursive: true, force: true });
|
|
270
|
+
if (!keepTemp) await rm(temp, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
|
|
269
271
|
}
|
|
@@ -0,0 +1,500 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Real Pi TUI acceptance harness for the Rarebit view feature.
|
|
5
|
+
*
|
|
6
|
+
* Run from any directory with `node packages/hc-rarebit/scripts/e2e-view-pi.mjs`
|
|
7
|
+
* (or `node scripts/e2e-view-pi.mjs` from this package). The local Pi peer is
|
|
8
|
+
* the default. Set RAREBIT_PI_ROOT to another Pi package root; set
|
|
9
|
+
* RAREBIT_TUI_MODE=fullscreen to cover Pi 1.0 mouse-wheel routing. It starts
|
|
10
|
+
* Pi in a unique detached tmux server, loads only this package's extension, and uses
|
|
11
|
+
* a fully mocked Session. It sends no model prompts and never reads user
|
|
12
|
+
* Sessions. Pi runs against disposable agent state and the script kills its
|
|
13
|
+
* tmux server and removes its temporary files on exit.
|
|
14
|
+
*
|
|
15
|
+
* Assertions: context filters tool/thinking rows and older Rarebits, keeps
|
|
16
|
+
* selected prose and Recaps, and resets across reload/session changes. All reads
|
|
17
|
+
* across compactions, both Kitty Ctrl+Super byte sequences toggle/cycle views,
|
|
18
|
+
* search wrap, status, footer, resize, peek input, layering, and cleanup are
|
|
19
|
+
* checked against real Pi without model prompts.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import assert from "node:assert/strict";
|
|
23
|
+
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
|
24
|
+
import { tmpdir } from "node:os";
|
|
25
|
+
import { dirname, join, resolve } from "node:path";
|
|
26
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
27
|
+
import { spawn } from "node:child_process";
|
|
28
|
+
import { execFile as execFileCallback } from "node:child_process";
|
|
29
|
+
import { promisify } from "node:util";
|
|
30
|
+
import { selectRarebits } from "../src/rarebit-core.mjs";
|
|
31
|
+
import { latestRecap } from "../src/rarebit-read-checkpoint.mjs";
|
|
32
|
+
|
|
33
|
+
const execFile = promisify(execFileCallback);
|
|
34
|
+
const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
|
35
|
+
const piEntry = fileURLToPath(await import.meta.resolve("@earendil-works/pi-coding-agent"));
|
|
36
|
+
const piRoot = resolve(process.env.RAREBIT_PI_ROOT ?? dirname(dirname(piEntry)));
|
|
37
|
+
const piVersion = JSON.parse(await readFile(join(piRoot, "package.json"), "utf8")).version;
|
|
38
|
+
const tuiMode = process.env.RAREBIT_TUI_MODE ?? "regular";
|
|
39
|
+
const piCli = join(piRoot, "dist/cli.js");
|
|
40
|
+
const tmux = process.env.TMUX_BIN ?? "tmux";
|
|
41
|
+
const sleep = (ms) => new Promise((resolvePromise) => setTimeout(resolvePromise, ms));
|
|
42
|
+
|
|
43
|
+
async function pane(socket, session) {
|
|
44
|
+
try {
|
|
45
|
+
return (await execFile(tmux, ["-L", socket, "capture-pane", "-p", "-J", "-t", session, "-S", "-240"])).stdout;
|
|
46
|
+
} catch {
|
|
47
|
+
return "";
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
async function keys(socket, session, text) {
|
|
51
|
+
await execFile(tmux, ["-L", socket, "send-keys", "-l", "-t", session, text]);
|
|
52
|
+
}
|
|
53
|
+
async function press(socket, session, key) {
|
|
54
|
+
await execFile(tmux, ["-L", socket, "send-keys", "-t", session, key]);
|
|
55
|
+
}
|
|
56
|
+
async function resizeWindow(socket, session, width, height) {
|
|
57
|
+
await execFile(tmux, ["-L", socket, "resize-window", "-t", session, "-x", String(width), "-y", String(height)]);
|
|
58
|
+
}
|
|
59
|
+
async function waitFor(label, predicate, timeout = 10_000, interval = 100) {
|
|
60
|
+
const deadline = Date.now() + timeout;
|
|
61
|
+
while (Date.now() < deadline) {
|
|
62
|
+
const value = await predicate();
|
|
63
|
+
if (value) return value;
|
|
64
|
+
await sleep(interval);
|
|
65
|
+
}
|
|
66
|
+
const diagnostic = await pane(socket, session);
|
|
67
|
+
throw new Error(`Timed out waiting for ${label}${diagnostic ? `; last pane:\n${diagnostic}` : ""}`);
|
|
68
|
+
}
|
|
69
|
+
async function submitSlash(socket, session, command) {
|
|
70
|
+
await keys(socket, session, command);
|
|
71
|
+
// Accept Pi's selected slash-command completion, then submit the command.
|
|
72
|
+
await sleep(500);
|
|
73
|
+
await press(socket, session, "Tab");
|
|
74
|
+
await sleep(100);
|
|
75
|
+
await press(socket, session, "Enter");
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const temp = await mkdtemp(join(tmpdir(), "rarebit-view-pi-e2e-"));
|
|
79
|
+
const agentDir = join(temp, "agent");
|
|
80
|
+
const projectDir = join(temp, "project");
|
|
81
|
+
const sessionDir = join(temp, "sessions");
|
|
82
|
+
const sessionFile = join(sessionDir, "rarebit-view-first.jsonl");
|
|
83
|
+
const secondSessionFile = join(sessionDir, "rarebit-view-second.jsonl");
|
|
84
|
+
const socket = `rarebit-view-${process.pid}`;
|
|
85
|
+
const session = "rarebit-view-e2e";
|
|
86
|
+
const fixtureExtension = pathToFileURL(join(packageRoot, "src/extension.mjs")).href;
|
|
87
|
+
const defects = [];
|
|
88
|
+
|
|
89
|
+
function mockSession(cwd) {
|
|
90
|
+
const entries = [];
|
|
91
|
+
let parentId = null;
|
|
92
|
+
let id = 0;
|
|
93
|
+
const tick = (n) => new Date(Date.UTC(2026, 0, 1, 12, n)).toISOString();
|
|
94
|
+
const push = (type, fields = {}) => {
|
|
95
|
+
const entry = { type, id: String(++id).padStart(8, "0"), parentId, timestamp: tick(id), ...fields };
|
|
96
|
+
entries.push(entry);
|
|
97
|
+
parentId = entry.id;
|
|
98
|
+
return entry.id;
|
|
99
|
+
};
|
|
100
|
+
const message = (role, content, stopReason) => push("message", {
|
|
101
|
+
message: {
|
|
102
|
+
role,
|
|
103
|
+
content,
|
|
104
|
+
timestamp: Date.parse(tick(id + 1)),
|
|
105
|
+
...(stopReason ? { stopReason } : {}),
|
|
106
|
+
...(role === "assistant" ? { api: "mock", provider: "mock", model: "mock", usage: { input: 1, output: 1, cacheRead: 0, cacheWrite: 0, totalTokens: 2, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } } } : {}),
|
|
107
|
+
},
|
|
108
|
+
});
|
|
109
|
+
entries.push({ type: "session", version: 3, id: "rarebit-view-session", timestamp: tick(0), cwd });
|
|
110
|
+
|
|
111
|
+
message("user", "PRECOMPACTION_RAREBIT: earlier conversation sentinel");
|
|
112
|
+
message("assistant", [{ type: "text", text: "PRECOMPACTION_RAREBIT: earlier assistant sentinel" }], "stop");
|
|
113
|
+
for (let n = 1; n <= 12; n++) {
|
|
114
|
+
message("user", `PRECOMPACTION_HISTORY_${String(n).padStart(2, "0")}: ${Array.from({ length: 5 }, (_, line) => `mock line ${line + 1}`).join("\n")}`);
|
|
115
|
+
}
|
|
116
|
+
const compactionId = push("compaction", {
|
|
117
|
+
summary: "Mock context summary. No private session data.",
|
|
118
|
+
firstKeptEntryId: null,
|
|
119
|
+
tokensBefore: 1000,
|
|
120
|
+
});
|
|
121
|
+
entries.find((entry) => entry.id === compactionId).firstKeptEntryId = message("user", "CURRENT_CONTEXT_USER: request after compaction");
|
|
122
|
+
message("user", "CURRENT_CONTEXT_RAREBIT: keep this conversational sentinel");
|
|
123
|
+
message("assistant", [
|
|
124
|
+
{ type: "thinking", thinking: "CURRENT_THINKING_SENTINEL: hidden internal text" },
|
|
125
|
+
{ type: "text", text: "CURRENT_CONTEXT_ASSISTANT_RAREBIT: current answer sentinel" },
|
|
126
|
+
{ type: "toolCall", id: "mock-call", name: "mock_tool", arguments: {} },
|
|
127
|
+
], "toolUse");
|
|
128
|
+
message("assistant", [
|
|
129
|
+
{ type: "thinking", thinking: "THINKING_BLOCK_SENTINEL: hide this reasoning" },
|
|
130
|
+
{ type: "text", text: "Thinking..." },
|
|
131
|
+
], "stop");
|
|
132
|
+
message("assistant", [{ type: "text", text: "Thinking..." }], "stop");
|
|
133
|
+
push("message", { message: {
|
|
134
|
+
role: "toolResult", toolCallId: "mock-call", toolName: "mock_tool",
|
|
135
|
+
content: [{ type: "text", text: "CURRENT_TOOL_OUTPUT_SENTINEL: hidden tool output" }],
|
|
136
|
+
isError: false, timestamp: Date.parse(tick(id + 1)),
|
|
137
|
+
} });
|
|
138
|
+
const coveredEntryId = message("assistant", [{ type: "text", text: "CURRENT_FINAL_RAREBIT: final answer sentinel" }], "stop");
|
|
139
|
+
const selection = selectRarebits(entries);
|
|
140
|
+
const recapId = push("custom", { customType: "rarebit-recap", data: {
|
|
141
|
+
version: 1, jobId: "mock-recap-job", coveredEntryId, selectionHash: selection.manifestHash,
|
|
142
|
+
heading: "Mock Recap", summary: "RECAP_SHORTCUT_SENTINEL: generic mock recap", sessionStatus: "finished",
|
|
143
|
+
statusReason: "all_requests_accomplished", observedAt: Date.parse(tick(id + 1)),
|
|
144
|
+
} });
|
|
145
|
+
assert.equal(latestRecap(entries)?.id, recapId, "the generic Recap fixture is valid");
|
|
146
|
+
return `${entries.map((entry) => JSON.stringify(entry)).join("\n")}\n`;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
try {
|
|
150
|
+
await mkdir(agentDir, { recursive: true });
|
|
151
|
+
await mkdir(projectDir, { recursive: true });
|
|
152
|
+
await mkdir(sessionDir, { recursive: true });
|
|
153
|
+
const firstSession = mockSession(projectDir);
|
|
154
|
+
await writeFile(sessionFile, firstSession, { mode: 0o600 });
|
|
155
|
+
const secondSession = firstSession
|
|
156
|
+
.replaceAll("rarebit-view-session", "rarebit-second-session")
|
|
157
|
+
.replaceAll("PRECOMPACTION_RAREBIT", "SECOND_PRECOMPACTION_RAREBIT")
|
|
158
|
+
.replaceAll("PRECOMPACTION_HISTORY", "SECOND_PRECOMPACTION_HISTORY")
|
|
159
|
+
.replaceAll("CURRENT_CONTEXT_USER", "SECOND_CONTEXT_USER")
|
|
160
|
+
.replaceAll("CURRENT_CONTEXT_RAREBIT", "SECOND_SESSION_RAREBIT")
|
|
161
|
+
.replaceAll("CURRENT_CONTEXT_ASSISTANT_RAREBIT", "SECOND_CONTEXT_ASSISTANT_RAREBIT")
|
|
162
|
+
.replaceAll("CURRENT_FINAL_RAREBIT", "SECOND_FINAL_RAREBIT")
|
|
163
|
+
.replaceAll("CURRENT_TOOL_OUTPUT_SENTINEL", "SECOND_TOOL_OUTPUT_SENTINEL")
|
|
164
|
+
.replaceAll("CURRENT_THINKING_SENTINEL", "SECOND_THINKING_SENTINEL");
|
|
165
|
+
await writeFile(secondSessionFile, secondSession, { mode: 0o600 });
|
|
166
|
+
const childEnv = {
|
|
167
|
+
...process.env,
|
|
168
|
+
PI_CODING_AGENT_DIR: agentDir,
|
|
169
|
+
PI_OFFLINE: "1",
|
|
170
|
+
PI_TELEMETRY: "0",
|
|
171
|
+
};
|
|
172
|
+
const piCommand = [
|
|
173
|
+
"env", `PI_CODING_AGENT_DIR=${agentDir}`, "PI_OFFLINE=1", "PI_TELEMETRY=0",
|
|
174
|
+
process.execPath, piCli, "--no-extensions", "--no-skills", "--no-prompt-templates",
|
|
175
|
+
"--no-themes", "--no-context-files", "--approve", "--extension", fixtureExtension,
|
|
176
|
+
"--session-dir", sessionDir, "--session", sessionFile, "--tui-mode", tuiMode,
|
|
177
|
+
];
|
|
178
|
+
const quote = (value) => `'${value.replaceAll("'", "'\\''")}'`;
|
|
179
|
+
const guardedCommand = `${piCommand.map(quote).join(" ")}; result=$?; printf '\\nPI_EXIT:%s\\n' "$result"; sleep 30; exit "$result"`;
|
|
180
|
+
const command = [
|
|
181
|
+
"-L", socket, "new-session", "-d", "-x", "120", "-y", "36", "-s", session,
|
|
182
|
+
"/bin/sh", "-c", guardedCommand,
|
|
183
|
+
];
|
|
184
|
+
await new Promise((resolvePromise, reject) => {
|
|
185
|
+
const child = spawn(tmux, command, { cwd: projectDir, env: childEnv, stdio: "ignore" });
|
|
186
|
+
child.once("error", reject);
|
|
187
|
+
child.once("exit", (code) => code === 0 ? resolvePromise() : reject(new Error(`tmux new-session exited ${code}`)));
|
|
188
|
+
});
|
|
189
|
+
await waitFor("mock Session transcript", async () => {
|
|
190
|
+
const output = await pane(socket, session);
|
|
191
|
+
return output.includes("CURRENT_FINAL_RAREBIT") ? output : false;
|
|
192
|
+
});
|
|
193
|
+
await sleep(500);
|
|
194
|
+
|
|
195
|
+
const inactive = await pane(socket, session);
|
|
196
|
+
assert.doesNotMatch(inactive, /Rarebit view:/, "the view has no status while off");
|
|
197
|
+
const kitty = (code) => `\u001b[${code};13u`;
|
|
198
|
+
await keys(socket, session, kitty(114));
|
|
199
|
+
const firstToggle = await waitFor("first Ctrl+Super+R opens all", async () => {
|
|
200
|
+
const output = await pane(socket, session);
|
|
201
|
+
return output.includes("Rarebits ·") ? output : false;
|
|
202
|
+
});
|
|
203
|
+
assert.match(firstToggle, /Rarebits ·/);
|
|
204
|
+
await keys(socket, session, kitty(110));
|
|
205
|
+
const cycledPeek = await waitFor("Ctrl+Super+N cycles all to peek", async () => {
|
|
206
|
+
const output = await pane(socket, session);
|
|
207
|
+
return output.includes("Rarebit view: peek · next ctrl+alt+n · off ctrl+alt+r") ? output : false;
|
|
208
|
+
});
|
|
209
|
+
assert.match(cycledPeek, /Rarebits ·/);
|
|
210
|
+
await press(socket, session, "C-u");
|
|
211
|
+
await submitSlash(socket, session, "/rarebit view off");
|
|
212
|
+
await keys(socket, session, kitty(114));
|
|
213
|
+
const rememberedPeek = await waitFor("toggle reopens the last used mode", async () => {
|
|
214
|
+
const output = await pane(socket, session);
|
|
215
|
+
return output.includes("Rarebit view: peek · next ctrl+alt+n · off ctrl+alt+r") ? output : false;
|
|
216
|
+
});
|
|
217
|
+
assert.match(rememberedPeek, /Rarebits ·/);
|
|
218
|
+
await press(socket, session, "C-u");
|
|
219
|
+
await submitSlash(socket, session, "/rarebit view off");
|
|
220
|
+
await keys(socket, session, kitty(110));
|
|
221
|
+
const cycledContext = await waitFor("Ctrl+Super+N cycles off to context", async () => {
|
|
222
|
+
const output = await pane(socket, session);
|
|
223
|
+
return output.includes("Rarebit view: context · next ctrl+alt+n · off ctrl+alt+r") ? output : false;
|
|
224
|
+
});
|
|
225
|
+
assert.match(cycledContext, /CURRENT_CONTEXT_RAREBIT/);
|
|
226
|
+
await keys(socket, session, kitty(114));
|
|
227
|
+
await waitFor("Ctrl+Super+R toggles context off", async () => !(await pane(socket, session)).includes("Rarebit view:"));
|
|
228
|
+
await keys(socket, session, kitty(114));
|
|
229
|
+
await waitFor("toggle remembers context", async () => (await pane(socket, session)).includes("Rarebit view: context · next ctrl+alt+n · off ctrl+alt+r"));
|
|
230
|
+
await submitSlash(socket, session, "/rarebit view off");
|
|
231
|
+
|
|
232
|
+
await submitSlash(socket, session, "/rarebit help");
|
|
233
|
+
await waitFor("pre-attach command notice", async () => (await pane(socket, session)).includes("Usage: /rarebit"));
|
|
234
|
+
await submitSlash(socket, session, "/rarebit view context");
|
|
235
|
+
const context = await waitFor("context view status", async () => {
|
|
236
|
+
const output = await pane(socket, session);
|
|
237
|
+
return output.includes("Rarebit view: context") ? output : false;
|
|
238
|
+
});
|
|
239
|
+
assert.match(context, /CURRENT_CONTEXT_RAREBIT/);
|
|
240
|
+
assert.doesNotMatch(context, /Usage: \/rarebit/, "notices from before context attached remain hidden");
|
|
241
|
+
assert.match(context, /CURRENT_FINAL_RAREBIT/);
|
|
242
|
+
assert.equal([...context.matchAll(/Thinking\.\.\./g)].length, 2, "selected prose survives when it equals Pi's thinking label, with and without a thinking block");
|
|
243
|
+
assert.doesNotMatch(context, /THINKING_BLOCK_SENTINEL/);
|
|
244
|
+
assert.match(context, /Compacted from 1,000 tokens/);
|
|
245
|
+
assert.match(context, /RECAP_SHORTCUT_SENTINEL/);
|
|
246
|
+
assert.ok(context.includes("Rarebit view: context · next ctrl+alt+n · off ctrl+alt+r"), "status names the active mode and first shortcuts");
|
|
247
|
+
assert.doesNotMatch(context, /PRECOMPACTION_RAREBIT/);
|
|
248
|
+
assert.doesNotMatch(context, /CURRENT_TOOL_OUTPUT_SENTINEL|CURRENT_THINKING_SENTINEL/);
|
|
249
|
+
await submitSlash(socket, session, "/rarebit status");
|
|
250
|
+
const contextStatusNotice = await waitFor("status notice remains visible in context", async () => {
|
|
251
|
+
const output = await pane(socket, session);
|
|
252
|
+
return output.includes("Rarebit status:") ? output : false;
|
|
253
|
+
}, 1_500).catch(() => null);
|
|
254
|
+
if (!contextStatusNotice) defects.push("/rarebit status emits no visible status notice while context is active");
|
|
255
|
+
else assert.match(contextStatusNotice, /Rarebit status: view=context/);
|
|
256
|
+
|
|
257
|
+
await submitSlash(socket, session, "/rarebit view off");
|
|
258
|
+
const off = await waitFor("view disabled", async () => {
|
|
259
|
+
const output = await pane(socket, session);
|
|
260
|
+
return !output.includes("Rarebit view: context") && output.includes("CURRENT_TOOL_OUTPUT_SENTINEL") ? output : false;
|
|
261
|
+
});
|
|
262
|
+
assert.doesNotMatch(off, /Rarebit view:/, "off clears the view status line");
|
|
263
|
+
|
|
264
|
+
await submitSlash(socket, session, "/rarebit view all");
|
|
265
|
+
const initialAll = await waitFor("all view", async () => {
|
|
266
|
+
const output = await pane(socket, session);
|
|
267
|
+
return output.includes("Rarebits ·") ? output : false;
|
|
268
|
+
});
|
|
269
|
+
assert.match(initialAll, /ctrl\+alt\+n next/, "all footer advertises the cycle shortcut");
|
|
270
|
+
const initialPosition = initialAll.match(/(\d+-\d+\/\d+)\s*$/m)?.[1];
|
|
271
|
+
await resizeWindow(socket, session, 120, 44);
|
|
272
|
+
const resizedAll = await waitFor("all view adapts to a taller terminal", async () => {
|
|
273
|
+
const output = await pane(socket, session);
|
|
274
|
+
const position = output.match(/(\d+-\d+\/\d+)\s*$/m)?.[1];
|
|
275
|
+
return position && position !== initialPosition ? output : false;
|
|
276
|
+
});
|
|
277
|
+
assert.match(resizedAll, /Rarebits ·/);
|
|
278
|
+
await resizeWindow(socket, session, 120, 36);
|
|
279
|
+
await waitFor("all view adapts after terminal shrinks", async () => {
|
|
280
|
+
const output = await pane(socket, session);
|
|
281
|
+
const position = output.match(/(\d+-\d+\/\d+)\s*$/m)?.[1];
|
|
282
|
+
return position === initialPosition ? output : false;
|
|
283
|
+
});
|
|
284
|
+
await press(socket, session, "C-M-g");
|
|
285
|
+
assert.ok(await waitFor("Recap shortcut does not close all", async () => (await pane(socket, session)).includes("Rarebits ·")));
|
|
286
|
+
await sleep(250);
|
|
287
|
+
const entriesAfterRecapKey = (await readFile(sessionFile, "utf8")).trim().split("\n").map(JSON.parse);
|
|
288
|
+
assert.ok(!entriesAfterRecapKey.some((entry) => entry.customType === "rarebit-recap-read"), "Ctrl+Alt+G does not acknowledge a Recap while all is focused");
|
|
289
|
+
await keys(socket, session, "\u001b[114;13u");
|
|
290
|
+
await waitFor("Ctrl+Super+R closes focused all", async () => !(await pane(socket, session)).includes("Rarebits ·"));
|
|
291
|
+
await keys(socket, session, "\u001b[114;13u");
|
|
292
|
+
await waitFor("Ctrl+Super+R reopens focused all", async () => (await pane(socket, session)).includes("Rarebits ·"));
|
|
293
|
+
if (piVersion === "1.0.0" && tuiMode === "fullscreen") {
|
|
294
|
+
await press(socket, session, "g");
|
|
295
|
+
const atStart = (await pane(socket, session)).match(/(\d+-\d+\/\d+)\s*$/m)?.[1];
|
|
296
|
+
await keys(socket, session, "\u001b[<65;60;20M");
|
|
297
|
+
const wheel = await waitFor("fullscreen Pi 1.0 routes wheel scroll to all", async () => {
|
|
298
|
+
const output = await pane(socket, session);
|
|
299
|
+
const position = output.match(/(\d+-\d+\/\d+)\s*$/m)?.[1];
|
|
300
|
+
return position && position !== atStart ? output : false;
|
|
301
|
+
});
|
|
302
|
+
assert.match(wheel, /Rarebits ·/);
|
|
303
|
+
}
|
|
304
|
+
await press(socket, session, "g");
|
|
305
|
+
const all = await waitFor("all view shows older entries and compaction marker", async () => {
|
|
306
|
+
const output = await pane(socket, session);
|
|
307
|
+
return output.includes("PRECOMPACTION_RAREBIT") && /compaction/i.test(output) ? output : false;
|
|
308
|
+
});
|
|
309
|
+
assert.match(all, /PRECOMPACTION_RAREBIT/);
|
|
310
|
+
assert.match(all, /compaction/i);
|
|
311
|
+
|
|
312
|
+
// Search must accept both directions, preserve the query for n/N, and show
|
|
313
|
+
// the live query/count in the overlay footer. The source sentinels make the
|
|
314
|
+
// search interaction deterministic without a model or external fixture.
|
|
315
|
+
await press(socket, session, "/");
|
|
316
|
+
await keys(socket, session, "PRECOMPACTION_RAREBIT");
|
|
317
|
+
await press(socket, session, "Enter");
|
|
318
|
+
const forwardSearch = await waitFor("forward search result", async () => {
|
|
319
|
+
const output = await pane(socket, session);
|
|
320
|
+
return output.includes("/PRECOMPACTION_RAREBIT · 1/2 · n/N") ? output : false;
|
|
321
|
+
});
|
|
322
|
+
assert.match(forwardSearch, /PRECOMPACTION_RAREBIT/);
|
|
323
|
+
await press(socket, session, "n");
|
|
324
|
+
const nextMatch = await waitFor("n advances search match", async () => {
|
|
325
|
+
const output = await pane(socket, session);
|
|
326
|
+
return output.includes("/PRECOMPACTION_RAREBIT · 2/2 · n/N") ? output : false;
|
|
327
|
+
});
|
|
328
|
+
assert.match(nextMatch, /2\/2/);
|
|
329
|
+
await press(socket, session, "N");
|
|
330
|
+
const previousMatch = await waitFor("N reverses search match", async () => {
|
|
331
|
+
const output = await pane(socket, session);
|
|
332
|
+
return output.includes("/PRECOMPACTION_RAREBIT · 1/2 · n/N") ? output : false;
|
|
333
|
+
});
|
|
334
|
+
assert.match(previousMatch, /1\/2/);
|
|
335
|
+
await press(socket, session, "N");
|
|
336
|
+
const topWrap = await waitFor("N wrap notice", async () => {
|
|
337
|
+
const output = await pane(socket, session);
|
|
338
|
+
return output.includes("search hit TOP, continuing at BOTTOM") ? output : false;
|
|
339
|
+
});
|
|
340
|
+
assert.match(topWrap, /search hit TOP, continuing at BOTTOM/);
|
|
341
|
+
await press(socket, session, "n");
|
|
342
|
+
const bottomWrap = await waitFor("n wrap notice", async () => {
|
|
343
|
+
const output = await pane(socket, session);
|
|
344
|
+
return output.includes("search hit BOTTOM, continuing at TOP") ? output : false;
|
|
345
|
+
});
|
|
346
|
+
assert.match(bottomWrap, /search hit BOTTOM, continuing at TOP/);
|
|
347
|
+
await press(socket, session, "q");
|
|
348
|
+
await waitFor("close view after wrap checks", async () => !(await pane(socket, session)).includes("Rarebits ·"));
|
|
349
|
+
await submitSlash(socket, session, "/rarebit view all");
|
|
350
|
+
await waitFor("reopen all for reverse search", async () => (await pane(socket, session)).includes("Rarebits ·"));
|
|
351
|
+
await press(socket, session, "?");
|
|
352
|
+
await keys(socket, session, "CURRENT_FINAL_RAREBIT");
|
|
353
|
+
await press(socket, session, "Enter");
|
|
354
|
+
const reverseSearch = await waitFor("reverse search result", async () => {
|
|
355
|
+
const output = await pane(socket, session);
|
|
356
|
+
return output.includes("?CURRENT_FINAL_RAREBIT · 1/1 · n/N") ? output : false;
|
|
357
|
+
});
|
|
358
|
+
assert.match(reverseSearch, /CURRENT_FINAL_RAREBIT/);
|
|
359
|
+
|
|
360
|
+
const beforeScroll = await pane(socket, session);
|
|
361
|
+
await press(socket, session, "u");
|
|
362
|
+
const up = await waitFor("up half-page movement", async () => {
|
|
363
|
+
const output = await pane(socket, session);
|
|
364
|
+
return output !== beforeScroll ? output : false;
|
|
365
|
+
});
|
|
366
|
+
await press(socket, session, "d");
|
|
367
|
+
const down = await waitFor("down half-page movement", async () => {
|
|
368
|
+
const output = await pane(socket, session);
|
|
369
|
+
return output !== up ? output : false;
|
|
370
|
+
});
|
|
371
|
+
assert.notEqual(down, up);
|
|
372
|
+
|
|
373
|
+
// With the search active, Esc first clears it. The next Esc closes the view.
|
|
374
|
+
await press(socket, session, "Escape");
|
|
375
|
+
const cleared = await waitFor("search cleared without closing overlay", async () => {
|
|
376
|
+
const output = await pane(socket, session);
|
|
377
|
+
return output.includes("Rarebits") && output.includes("j/k · u/d") && !output.includes("?CURRENT_FINAL_RAREBIT ·") ? output : false;
|
|
378
|
+
});
|
|
379
|
+
assert.match(cleared, /Rarebits/);
|
|
380
|
+
await press(socket, session, "Escape");
|
|
381
|
+
await waitFor("Escape closes overlay", async () => !(await pane(socket, session)).includes("Rarebits ·"));
|
|
382
|
+
|
|
383
|
+
await submitSlash(socket, session, "/rarebit view all");
|
|
384
|
+
await waitFor("all view reopens", async () => (await pane(socket, session)).includes("Rarebits ·"));
|
|
385
|
+
await press(socket, session, "q");
|
|
386
|
+
await waitFor("q closes capturing overlay", async () => !(await pane(socket, session)).includes("Rarebits ·"));
|
|
387
|
+
|
|
388
|
+
await submitSlash(socket, session, "/rarebit view peek");
|
|
389
|
+
const peekInitial = await waitFor("peek overlay", async () => {
|
|
390
|
+
const output = await pane(socket, session);
|
|
391
|
+
return output.includes("Rarebits ·") && output.includes("Rarebit view: peek") ? output : false;
|
|
392
|
+
});
|
|
393
|
+
assert.match(peekInitial, /keys go to the editor · wheel scrolls · ctrl\+alt\+r closes · replies appear when finished/);
|
|
394
|
+
await submitSlash(socket, session, "/rarebit view");
|
|
395
|
+
const picker = await waitFor("view picker closes peek first", async () => {
|
|
396
|
+
const output = await pane(socket, session);
|
|
397
|
+
return output.includes("Rarebit view") && !output.includes("Rarebits ·") ? output : false;
|
|
398
|
+
});
|
|
399
|
+
assert.match(picker, /all — All Rarebits/);
|
|
400
|
+
await press(socket, session, "Escape");
|
|
401
|
+
await waitFor("view picker closes", async () => !(await pane(socket, session)).includes("all — All Rarebits"));
|
|
402
|
+
|
|
403
|
+
await submitSlash(socket, session, "/rarebit view peek");
|
|
404
|
+
await waitFor("peek reopens before Rarebit palette", async () => (await pane(socket, session)).includes("Rarebits ·"));
|
|
405
|
+
await submitSlash(socket, session, "/rarebit");
|
|
406
|
+
const palette = await waitFor("Rarebit palette closes peek first", async () => {
|
|
407
|
+
const output = await pane(socket, session);
|
|
408
|
+
return output.includes("Human-only command palette") && !output.includes("Rarebits ·") ? output : false;
|
|
409
|
+
});
|
|
410
|
+
assert.match(palette, /Command help/);
|
|
411
|
+
await press(socket, session, "Escape");
|
|
412
|
+
await waitFor("Rarebit palette closes", async () => !(await pane(socket, session)).includes("Human-only command palette"));
|
|
413
|
+
|
|
414
|
+
await submitSlash(socket, session, "/rarebit view peek");
|
|
415
|
+
await waitFor("peek reopens before settings", async () => (await pane(socket, session)).includes("Rarebits ·"));
|
|
416
|
+
await submitSlash(socket, session, "/rarebit settings");
|
|
417
|
+
const settings = await waitFor("Rarebit settings closes peek first", async () => {
|
|
418
|
+
const output = await pane(socket, session);
|
|
419
|
+
return output.includes("Rarebit Settings") && !output.includes("Rarebits ·") ? output : false;
|
|
420
|
+
});
|
|
421
|
+
assert.match(settings, /Summary/);
|
|
422
|
+
await press(socket, session, "Escape");
|
|
423
|
+
await waitFor("settings closes", async () => !(await pane(socket, session)).includes("Rarebit Settings"));
|
|
424
|
+
|
|
425
|
+
await submitSlash(socket, session, "/rarebit view peek");
|
|
426
|
+
await waitFor("peek reopens for editor check", async () => (await pane(socket, session)).includes("Rarebits ·"));
|
|
427
|
+
await keys(socket, session, "PEEK_EDITOR_INPUT_SENTINEL");
|
|
428
|
+
const peek = await waitFor("editor accepts input with peek open", async () => {
|
|
429
|
+
const output = await pane(socket, session);
|
|
430
|
+
return output.includes("PEEK_EDITOR_INPUT_SENTINEL") && output.includes("Rarebits ·") ? output : false;
|
|
431
|
+
});
|
|
432
|
+
assert.match(peek, /PEEK_EDITOR_INPUT_SENTINEL/);
|
|
433
|
+
assert.match(peek, /Rarebits ·/);
|
|
434
|
+
await press(socket, session, "C-u");
|
|
435
|
+
await submitSlash(socket, session, "/rarebit view off");
|
|
436
|
+
await waitFor("off closes peek view", async () => !(await pane(socket, session)).includes("Rarebits ·"));
|
|
437
|
+
|
|
438
|
+
// Pi lifecycle boundaries must dispose the view before a fresh transcript
|
|
439
|
+
// or extension registration appears. Use only built-in /reload and /resume.
|
|
440
|
+
const assertNormalCurrentSession = async (marker, label) => {
|
|
441
|
+
const output = await waitFor(label, async () => {
|
|
442
|
+
const current = await pane(socket, session);
|
|
443
|
+
return current.includes(marker) && !current.includes("Rarebits ·") && !current.includes("Rarebit view:") ? current : false;
|
|
444
|
+
});
|
|
445
|
+
assert.match(output, new RegExp(marker));
|
|
446
|
+
assert.doesNotMatch(output, /Rarebits ·|Rarebit view:/);
|
|
447
|
+
return output;
|
|
448
|
+
};
|
|
449
|
+
await submitSlash(socket, session, "/rarebit view context");
|
|
450
|
+
await waitFor("context active before reload", async () => (await pane(socket, session)).includes("Rarebit view: context"));
|
|
451
|
+
await submitSlash(socket, session, "/reload");
|
|
452
|
+
await assertNormalCurrentSession("CURRENT_TOOL_OUTPUT_SENTINEL", "reload resets context to normal transcript");
|
|
453
|
+
await submitSlash(socket, session, "/rarebit view context");
|
|
454
|
+
await waitFor("context active before Session switch", async () => (await pane(socket, session)).includes("Rarebit view: context"));
|
|
455
|
+
await submitSlash(socket, session, "/resume");
|
|
456
|
+
await waitFor("Session picker shows second mock", async () => (await pane(socket, session)).includes("SECOND_PRECOMPACTION_RAREBIT"));
|
|
457
|
+
await keys(socket, session, "SECOND_PRECOMPACTION_RAREBIT");
|
|
458
|
+
await sleep(250);
|
|
459
|
+
await press(socket, session, "Enter");
|
|
460
|
+
await assertNormalCurrentSession("SECOND_FINAL_RAREBIT", "Session switch resets context to second normal transcript");
|
|
461
|
+
await submitSlash(socket, session, "/rarebit view context");
|
|
462
|
+
const secondContext = await waitFor("reopened context shows second Session", async () => {
|
|
463
|
+
const output = await pane(socket, session);
|
|
464
|
+
return output.includes("SECOND_SESSION_RAREBIT") && output.includes("Rarebit view: context") ? output : false;
|
|
465
|
+
});
|
|
466
|
+
assert.match(secondContext, /SECOND_SESSION_RAREBIT/);
|
|
467
|
+
assert.doesNotMatch(secondContext, /CURRENT_CONTEXT_RAREBIT|Rarebits ·/);
|
|
468
|
+
await submitSlash(socket, session, "/rarebit view peek");
|
|
469
|
+
await waitFor("peek active before second Session switch", async () => (await pane(socket, session)).includes("Rarebit view: peek"));
|
|
470
|
+
await submitSlash(socket, session, "/resume");
|
|
471
|
+
await waitFor("Session picker shows first mock", async () => (await pane(socket, session)).includes("PRECOMPACTION_RAREBIT"));
|
|
472
|
+
await keys(socket, session, "PRECOMPACTION_RAREBIT");
|
|
473
|
+
await sleep(250);
|
|
474
|
+
await press(socket, session, "Enter");
|
|
475
|
+
await assertNormalCurrentSession("CURRENT_FINAL_RAREBIT", "Session switch resets peek to first normal transcript");
|
|
476
|
+
await submitSlash(socket, session, "/rarebit view peek");
|
|
477
|
+
const firstPeek = await waitFor("reopened peek shows first Session", async () => {
|
|
478
|
+
const output = await pane(socket, session);
|
|
479
|
+
return output.includes("CURRENT_FINAL_RAREBIT") && output.includes("Rarebit view: peek") ? output : false;
|
|
480
|
+
});
|
|
481
|
+
assert.doesNotMatch(firstPeek, /SECOND_FINAL_RAREBIT/);
|
|
482
|
+
await submitSlash(socket, session, "/reload");
|
|
483
|
+
await assertNormalCurrentSession("CURRENT_TOOL_OUTPUT_SENTINEL", "reload resets peek to normal transcript");
|
|
484
|
+
|
|
485
|
+
const finalOff = await waitFor("status absent after off", async () => {
|
|
486
|
+
const output = await pane(socket, session);
|
|
487
|
+
return !output.includes("Rarebit view:") ? output : false;
|
|
488
|
+
});
|
|
489
|
+
assert.doesNotMatch(finalOff, /Rarebit view:/);
|
|
490
|
+
const assertions = ["R1 selected Thinking... prose with and without thinking blocks", "R2 context hides pre-attach notices and keeps /rarebit status", "context filters tool/thinking rows and shows Recap beside compaction", "R3 all footer cycle hint", "R4 search wrap notices and / ? n N", "first toggle defaults to all", "Ctrl+Super+R/N Kitty sequences", "preferred mode restored on toggle", "resize-aware all layout", "u/d scroll", "Esc clears then closes; q closes", "peek editor input and footer", "picker, palette, and settings close view", "R6 /reload and Session switch reset context/peek; reopen shows current Session", "off clears status"];
|
|
491
|
+
if (piVersion === "1.0.0" && tuiMode === "fullscreen") assertions.push("fullscreen wheel scrolling");
|
|
492
|
+
console.log(JSON.stringify({ ok: defects.length === 0, pi: piVersion, tuiMode, assertions, defects }));
|
|
493
|
+
if (defects.length) process.exitCode = 1;
|
|
494
|
+
} catch (error) {
|
|
495
|
+
console.log(JSON.stringify({ ok: false, pi: piVersion, tuiMode, completedAssertions: ["R1 selected Thinking... prose", "R2 pre-attach notice hidden", "context Recap and compaction visible", "R3 footer cycle hint", "R4 both search wrap notices", "R6 /reload reset"], defects: [...defects, error?.message ?? String(error)] }));
|
|
496
|
+
process.exitCode = 1;
|
|
497
|
+
} finally {
|
|
498
|
+
await execFile(tmux, ["-L", socket, "kill-server"]).catch(() => {});
|
|
499
|
+
await rm(temp, { recursive: true, force: true });
|
|
500
|
+
}
|