@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 +15 -0
- package/README.md +41 -1
- package/RUNBOOK.md +7 -5
- package/package.json +1 -1
- package/scripts/e2e-view-pi.mjs +500 -0
- package/src/extension.mjs +21 -3
- package/src/rarebit-command.mjs +5 -0
- package/src/rarebit-recap.mjs +2 -1
- package/src/rarebit-settings-ui.mjs +2 -0
- package/src/rarebit-view.mjs +426 -0
- package/test/rarebit-command.test.mjs +10 -2
- package/test/rarebit-view.test.mjs +270 -0
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
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
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
|
@@ -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
|
+
}
|