@hypercarrier/rarebit 0.2.1 → 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 CHANGED
@@ -1,5 +1,20 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.3.0 — 2026-10-04
4
+
5
+ - Add `/rarebit view [context|all|peek|off]` and a View action in the palette.
6
+ Read active-branch Rarebits across compactions, or keep the editor live in
7
+ peek.
8
+ - Add scroll and smartcase search controls, toggle/cycle shortcuts with Super
9
+ aliases, and active-view status. First toggle opens all; off clears status.
10
+ - Add the experimental context view with capability checks and an all-view
11
+ fallback. It relies on unsupported Pi internals and can break after updates.
12
+ - Reset views on Session replacement, reload, and shutdown. Verify views on
13
+ Pi 0.84.2 and 1.0.0; component wheel input requires a supporting fullscreen
14
+ host.
15
+ - Stop retained Recap entries from reading a closed Session after shutdown or
16
+ `/resume`.
17
+
3
18
  ## 0.2.1 — 2026-09-29
4
19
 
5
20
  - Move Recap into the scrollable transcript using native custom-entry renderers,
package/README.md CHANGED
@@ -52,7 +52,7 @@ an existing Summary in the Pi TUI.
52
52
  ```text
53
53
  long Pi Session
54
54
  └─ Distill: selected active-branch prose
55
- ├─ Catch up: read the conversation
55
+ ├─ Catch up: read the conversation through CLI extract or Pi view
56
56
  ├─ Recall: let an agent use the selection for one request
57
57
  ├─ Fork: start a new native Pi Session from a bounded suffix
58
58
  └─ Summary / Title: optional model-derived projections
@@ -72,6 +72,46 @@ records. Selection is deterministic. It is not model summarization. Each
72
72
  selected occurrence keeps its source entry, branch position, role, outcome,
73
73
  timestamp when available, and lineage.
74
74
 
75
+ #### Read in the Pi TUI
76
+
77
+ Use `/rarebit view` beside CLI `extract` to read the current Session without a
78
+ model call. The command opens a picker. Choose a view directly with
79
+ `/rarebit view [context|all|peek|off]`:
80
+
81
+ - **all** reads every Rarebit on the active branch across compactions. Each
82
+ compaction has a marker. This is the first-toggle default.
83
+ - **peek** reads that same branch while you type in the editor. It stays open
84
+ on submit. Replies appear when finished. It reserves a fixed seven-row dock;
85
+ a tall multiline editor can overlap the view.
86
+ - **context** is experimental. The context view filters Pi's own transcript.
87
+ It depends on how Pi draws the transcript, which Pi does not guarantee for
88
+ extensions, so a Pi update can disable it. Rarebit then reports that the
89
+ context view is unavailable on this Pi version; the all and peek views keep
90
+ working. Context shows only Rarebits after the last compaction; use all to
91
+ read across compactions. It keeps Pi's compaction summary and Rarebit Recaps
92
+ visible.
93
+ - **off** restores the normal transcript and removes the view status line.
94
+
95
+ In `all`, use j/k or ↑/↓ for lines, u/d or Ctrl+U/Ctrl+D for half pages,
96
+ b/Space for pages, and g/G for the ends. Search with `/` or `?`, then repeat
97
+ with n/N. Search ignores case unless the query has an uppercase letter. Esc
98
+ clears a search before it closes the view; q closes it. Mouse wheel scrolling
99
+ requires fullscreen Pi with component mouse-event support. Pi 0.84.2 does not
100
+ provide that support; Pi 1.0.0 does. In `peek`, keyboard input goes to the
101
+ editor.
102
+
103
+ Ctrl+Alt+R toggles the view. Ctrl+Alt+N cycles context → all → peek. The
104
+ Ctrl+Super+R/N aliases support terminals that report the Super modifier through
105
+ Kitty keyboard sequences. Later toggles reopen the last used view. Extension
106
+ shortcuts cannot be rebound in `keybindings.json`; `/rarebit view` is the
107
+ fallback. Close `all` before using Ctrl+Alt+G to acknowledge a Recap.
108
+
109
+ The status line names the active view. The palette's **View** action opens the
110
+ picker. Opening the Rarebit palette or settings closes the view. Session
111
+ replacement, reload, and shutdown reset it to off. Turn it off before changing
112
+ TUI mode. The views are verified on Pi 0.84.2 and 1.0.0. Other versions use
113
+ capability checks and can report an unavailable view.
114
+
75
115
  ### 2. Recall for the agent
76
116
 
77
117
  In Pi, run:
package/RUNBOOK.md CHANGED
@@ -1,10 +1,12 @@
1
1
  # Rarebit runbook
2
2
 
3
- Rarebit release automation lives in
4
- [`.github/workflows/publish.yml`](.github/workflows/publish.yml). It validates
5
- an immutable version tag, runs the package gates, verifies one packed artifact,
6
- and publishes that artifact with npm provenance. See [CHANGELOG.md](CHANGELOG.md)
7
- for product changes.
3
+ To release, merge the version bump and its [CHANGELOG.md](CHANGELOG.md) entry
4
+ to `main`, run `git-privacy-scan --ref HEAD history`, then push tag
5
+ `v<version>`. The tag push runs
6
+ [`.github/workflows/publish.yml`](.github/workflows/publish.yml). It runs the
7
+ package gates, publishes one verified packed artifact with npm provenance, and
8
+ creates the GitHub Release. A prerelease version goes to npm `next`; any other
9
+ version goes to `latest`. Publication adds no commit.
8
10
 
9
11
  Set `rarebit.model` in global Pi settings or in a trusted Project's
10
12
  `.pi/settings.json`. It must be `provider/model` or an object with `provider`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hypercarrier/rarebit",
3
- "version": "0.2.1",
3
+ "version": "0.3.0",
4
4
  "description": "Sparse, provenance-bearing conversational evidence recovery for Pi sessions.",
5
5
  "license": "MIT",
6
6
  "author": "HyperCarrier contributors",
@@ -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
+ }