@hypercarrier/rarebit 0.2.1 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +23 -0
- package/README.md +41 -1
- package/RUNBOOK.md +7 -5
- package/package.json +1 -1
- package/scripts/e2e-recall-pi0842.mjs +31 -13
- package/scripts/e2e-view-pi.mjs +500 -0
- package/src/extension.mjs +50 -14
- package/src/rarebit-command.mjs +5 -0
- package/src/rarebit-recall.mjs +39 -27
- 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-recall.test.mjs +84 -41
- package/test/rarebit-view.test.mjs +270 -0
|
@@ -140,27 +140,52 @@ test("recall materialization writes private detailed evidence and a chronologica
|
|
|
140
140
|
await readFile(result.conversationPath, "utf8"),
|
|
141
141
|
);
|
|
142
142
|
assert.deepEqual(conversation, {
|
|
143
|
-
schemaVersion:
|
|
143
|
+
schemaVersion: 2,
|
|
144
144
|
type: "rarebit_conversation",
|
|
145
|
-
|
|
146
|
-
|
|
145
|
+
messageCount: 3,
|
|
146
|
+
lastRound: 1,
|
|
147
|
+
messages: [
|
|
147
148
|
{
|
|
148
|
-
|
|
149
|
-
|
|
149
|
+
seq: 1,
|
|
150
|
+
round: 1,
|
|
151
|
+
role: "user",
|
|
152
|
+
kind: "request",
|
|
153
|
+
time: "2026-07-24T01:02:03Z",
|
|
154
|
+
text: "Exact owner evidence.",
|
|
150
155
|
},
|
|
151
156
|
{
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
+
seq: 2,
|
|
158
|
+
round: 1,
|
|
159
|
+
role: "agent",
|
|
160
|
+
kind: "progress",
|
|
161
|
+
time: "2026-07-24T02:00:00Z",
|
|
162
|
+
text: "Exact continuation evidence.",
|
|
163
|
+
},
|
|
164
|
+
{
|
|
165
|
+
seq: 3,
|
|
166
|
+
round: 1,
|
|
167
|
+
role: "agent",
|
|
168
|
+
kind: "reply",
|
|
169
|
+
time: "2026-07-24T02:45:00Z",
|
|
170
|
+
text: "Exact stop evidence.",
|
|
157
171
|
},
|
|
158
172
|
],
|
|
159
173
|
});
|
|
174
|
+
assert.deepEqual(
|
|
175
|
+
detailed.selection.occurrences.map(({ seq, sourceEntryId }) => ({
|
|
176
|
+
seq,
|
|
177
|
+
sourceEntryId,
|
|
178
|
+
})),
|
|
179
|
+
[
|
|
180
|
+
{ seq: 1, sourceEntryId: "owner" },
|
|
181
|
+
{ seq: 2, sourceEntryId: "continue" },
|
|
182
|
+
{ seq: 3, sourceEntryId: "stop" },
|
|
183
|
+
],
|
|
184
|
+
);
|
|
160
185
|
const conversationText = await readFile(result.conversationPath, "utf8");
|
|
161
186
|
assert.doesNotMatch(
|
|
162
187
|
conversationText,
|
|
163
|
-
/sourceEntryId|occurrenceId|manifestHash|contentHash|
|
|
188
|
+
/sourceEntryId|occurrenceId|manifestHash|contentHash|session/i,
|
|
164
189
|
);
|
|
165
190
|
} finally {
|
|
166
191
|
await result.discard();
|
|
@@ -168,7 +193,7 @@ test("recall materialization writes private detailed evidence and a chronologica
|
|
|
168
193
|
}
|
|
169
194
|
});
|
|
170
195
|
|
|
171
|
-
test("conversation
|
|
196
|
+
test("conversation keeps branch order, numbers rounds, and treats producer-tagged input as a normal user message", async () => {
|
|
172
197
|
const root = await fixtureRoot("bucket-order");
|
|
173
198
|
const sessionFile = join(root, "session.jsonl");
|
|
174
199
|
await writeFile(sessionFile, "{}\n");
|
|
@@ -218,23 +243,23 @@ test("conversation buckets sort chronologically and treat producer-tagged input
|
|
|
218
243
|
const conversation = JSON.parse(
|
|
219
244
|
await readFile(result.conversationPath, "utf8"),
|
|
220
245
|
);
|
|
221
|
-
assert.
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
246
|
+
assert.equal(conversation.lastRound, 1);
|
|
247
|
+
assert.deepEqual(
|
|
248
|
+
conversation.messages.map(({ seq, round, role, kind, time, text }) => [
|
|
249
|
+
seq,
|
|
250
|
+
round,
|
|
251
|
+
role,
|
|
252
|
+
kind,
|
|
253
|
+
time,
|
|
254
|
+
text,
|
|
255
|
+
]),
|
|
256
|
+
[
|
|
257
|
+
[1, 1, "user", "request", "2026-07-24T02:10:00Z", "Unknown-origin user evidence."],
|
|
258
|
+
[2, 1, "agent", "reply", "2026-07-24T01:10:00Z", "Earlier agent evidence."],
|
|
259
|
+
[3, 1, "agent", "reply", null, "Unknown-time agent evidence."],
|
|
260
|
+
[4, 1, "agent", "reply", "2026-07-24T02:20:00Z", "Later agent evidence."],
|
|
261
|
+
],
|
|
262
|
+
);
|
|
238
263
|
assert.doesNotMatch(
|
|
239
264
|
await readFile(result.conversationPath, "utf8"),
|
|
240
265
|
/human|producer|rpc/,
|
|
@@ -318,29 +343,47 @@ test("recall sends one exact fenced Markdown steer message in idle and busy cont
|
|
|
318
343
|
"\n# Heading-looking request\n- list-looking item\n```text\ninside backticks\n```\n~~~~\ninside tildes\n~~~~\nTrailing request\n";
|
|
319
344
|
const expectedMessage = `# Rarebit Recall
|
|
320
345
|
|
|
321
|
-
*Use these local private
|
|
322
|
-
|
|
323
|
-
## How to use this bundle
|
|
346
|
+
*Answer the **Current request** below. Use these local private files to recover earlier context from this Session.*
|
|
324
347
|
|
|
325
|
-
|
|
326
|
-
2. Read **Conversation** first for meaning.
|
|
327
|
-
3. Use **Detailed evidence** only for source, Session, branch, or lineage facts.
|
|
328
|
-
4. Answer the current request using this evidence.
|
|
348
|
+
## Files
|
|
329
349
|
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
**Conversation** — \`rarebit_conversation/v1\`
|
|
350
|
+
**Conversation** — \`rarebit_conversation/v2\`. Read this first.
|
|
333
351
|
|
|
334
352
|
\`\`\`text
|
|
335
353
|
${recall.conversationPath}
|
|
336
354
|
\`\`\`
|
|
337
355
|
|
|
338
|
-
**Detailed evidence** — \`rarebit_message_recall/v1\`
|
|
356
|
+
**Detailed evidence** — \`rarebit_message_recall/v1\`. Use it only for source, Session, branch, or lineage facts. Join on \`seq\`.
|
|
339
357
|
|
|
340
358
|
~~~~text
|
|
341
359
|
${recall.detailedPath}
|
|
342
360
|
~~~~
|
|
343
361
|
|
|
362
|
+
## Conversation schema
|
|
363
|
+
|
|
364
|
+
\`\`\`text
|
|
365
|
+
{ messageCount, lastRound, messages: [{ seq, round, role, kind, time, text }] }
|
|
366
|
+
seq 1-based, oldest first
|
|
367
|
+
round increments at each user message; agent messages before the first user message have round 0
|
|
368
|
+
role "user" | "agent"
|
|
369
|
+
kind "request" (user) | "progress" (agent text before a tool call) | "reply" (agent final answer)
|
|
370
|
+
time UTC "YYYY-MM-DDTHH:MM:SSZ", or null
|
|
371
|
+
\`\`\`
|
|
372
|
+
|
|
373
|
+
Messages contain user and agent text only. Tool calls and tool results are excluded.
|
|
374
|
+
|
|
375
|
+
## Example queries
|
|
376
|
+
|
|
377
|
+
Set \`F\` to the Conversation path. Read the smallest slice that answers the request.
|
|
378
|
+
|
|
379
|
+
\`\`\`sh
|
|
380
|
+
jq '.messages[-20:]' "$F" # last 20 messages
|
|
381
|
+
jq '[.messages[] | select(.role == "user")][-3:]' "$F" # last 3 user messages
|
|
382
|
+
jq '[.messages[] | select(.kind == "reply")][-10:]' "$F" # last 10 agent replies
|
|
383
|
+
jq '.lastRound as $r | [.messages[] | select(.round > $r - 5)]' "$F" # last 5 rounds
|
|
384
|
+
jq '(now - 8*3600 | todate) as $t | [.messages[] | select(.time >= $t)]' "$F" # last 8 hours
|
|
385
|
+
\`\`\`
|
|
386
|
+
|
|
344
387
|
## Current request
|
|
345
388
|
|
|
346
389
|
\`\`\`\`text
|
|
@@ -390,7 +433,7 @@ test("long delimiter runs ending at value boundaries use the shortest safe fence
|
|
|
390
433
|
assert.equal(harness.sent.length, 1);
|
|
391
434
|
const message = harness.sent[0].args[0];
|
|
392
435
|
const conversationBlock = message.match(
|
|
393
|
-
/\*\*Conversation\*\* — `rarebit_conversation\/
|
|
436
|
+
/\*\*Conversation\*\* — `rarebit_conversation\/v2`\. Read this first\.\n\n([`~]+)text\n([^\n]*)\n\1\n\n\*\*Detailed evidence\*\*/,
|
|
394
437
|
);
|
|
395
438
|
assert.ok(conversationBlock);
|
|
396
439
|
assert.equal(conversationBlock[2], conversationPath);
|
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import { createRequire } from "node:module";
|
|
4
|
+
import { dirname, join } from "node:path";
|
|
5
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
6
|
+
import { createRarebitViewController } from "../src/rarebit-view.mjs";
|
|
7
|
+
import registerRarebit from "../src/extension.mjs";
|
|
8
|
+
import { rarebitPaletteCommand } from "../src/rarebit-settings-ui.mjs";
|
|
9
|
+
|
|
10
|
+
// A host boundary records command/UI effects. The reader uses the installed Pi
|
|
11
|
+
// Markdown and keyboard implementations; no view helper is replaced.
|
|
12
|
+
function host({ mode = "tui", failOverlay = false } = {}) {
|
|
13
|
+
const commands = new Map(), shortcuts = new Map(), events = new Map(), entryRenderers = new Map();
|
|
14
|
+
const notices = [], statuses = new Map(), overlays = [];
|
|
15
|
+
let settingsReads = 0, picker, renderRequests = 0;
|
|
16
|
+
const tui = { children: [], terminal: { rows: 25, columns: 100 }, requestRender() { renderRequests++; }, showOverlay() {} };
|
|
17
|
+
const theme = { fg: (_color, text) => text, bg: (_color, text) => text, bold: (text) => text, inverse: (text) => text };
|
|
18
|
+
const entries = [
|
|
19
|
+
{ type: "message", id: "old", timestamp: "2026-01-01T10:00:00Z", message: { role: "user", content: "Older Needle" } },
|
|
20
|
+
{ type: "compaction", id: "cut1", firstKeptEntryId: "now", timestamp: "2026-01-01T11:00:00Z" },
|
|
21
|
+
{ type: "compaction", id: "cut2", firstKeptEntryId: "now", timestamp: "2026-01-01T12:00:00Z" },
|
|
22
|
+
{ type: "message", id: "now", timestamp: "2026-01-01T12:00:01Z", message: { role: "assistant", stopReason: "stop", content: [{ type: "text", text: "Newer needle" }, { type: "thinking", thinking: "Hidden reasoning" }] } },
|
|
23
|
+
{ type: "message", id: "tool", message: { role: "toolResult", content: [{ type: "text", text: "Hidden tool" }] } },
|
|
24
|
+
];
|
|
25
|
+
const ctx = {
|
|
26
|
+
mode, hasUI: true,
|
|
27
|
+
sessionManager: { getBranch: () => entries, getHeader: () => ({ id: "mock" }) },
|
|
28
|
+
ui: {
|
|
29
|
+
notify: (text) => notices.push(text),
|
|
30
|
+
setStatus(key, value) { if (value === undefined) statuses.delete(key); else statuses.set(key, value); },
|
|
31
|
+
setWidget() {},
|
|
32
|
+
async select() { assert.equal(statuses.size, 0); return picker; },
|
|
33
|
+
custom(factory, options) {
|
|
34
|
+
if (failOverlay && options?.overlay) return Promise.reject(new Error("mock unavailable"));
|
|
35
|
+
return new Promise((resolve, reject) => {
|
|
36
|
+
let finished = false;
|
|
37
|
+
const done = () => { finished = true; resolve(); };
|
|
38
|
+
try {
|
|
39
|
+
const component = factory(tui, theme, {}, done);
|
|
40
|
+
if (!finished && options?.overlay) {
|
|
41
|
+
overlays.push({ component, options, done });
|
|
42
|
+
options.onHandle?.({ isFocused: () => !options.overlayOptions.nonCapturing });
|
|
43
|
+
}
|
|
44
|
+
} catch (error) { reject(error); }
|
|
45
|
+
});
|
|
46
|
+
},
|
|
47
|
+
},
|
|
48
|
+
};
|
|
49
|
+
const pi = {
|
|
50
|
+
on(name, handler) { const handlers = events.get(name) ?? []; handlers.push(handler); events.set(name, handlers); },
|
|
51
|
+
registerEntryRenderer: (name, renderer) => entryRenderers.set(name, renderer),
|
|
52
|
+
registerCommand: (name, definition) => commands.set(name, definition),
|
|
53
|
+
registerShortcut: (name, definition) => shortcuts.set(name, definition),
|
|
54
|
+
};
|
|
55
|
+
registerRarebit(pi, { settingsLoader: async () => { settingsReads++; return {}; }, activityReporter: { start() {}, update() {}, stop() {} } });
|
|
56
|
+
return { ctx, commands, shortcuts, notices, statuses, overlays, tui, entries, entryRenderers,
|
|
57
|
+
command: (args) => commands.get("rarebit").handler(args, ctx),
|
|
58
|
+
event: async (name, detail = {}) => { for (const handler of events.get(name) ?? []) await handler(detail, ctx); },
|
|
59
|
+
setPicker(value) { picker = value; }, get settingsReads() { return settingsReads; }, get renderRequests() { return renderRequests; } };
|
|
60
|
+
}
|
|
61
|
+
const tick = () => new Promise((resolve) => setImmediate(resolve));
|
|
62
|
+
|
|
63
|
+
test("view command projects all branch prose and each compaction without reading settings", async () => {
|
|
64
|
+
const h = host();
|
|
65
|
+
await h.command("view all");
|
|
66
|
+
const pane = h.overlays.at(-1).component;
|
|
67
|
+
const output = pane.render(100).join("\n");
|
|
68
|
+
assert.match(output, /Older Needle/);
|
|
69
|
+
assert.match(output, /Newer needle/);
|
|
70
|
+
assert.doesNotMatch(output, /Hidden reasoning|Hidden tool/);
|
|
71
|
+
assert.match(output, /□ user message/);
|
|
72
|
+
assert.match(output, /● agent stops/);
|
|
73
|
+
assert.equal(output.match(/compaction /g).length, 2);
|
|
74
|
+
assert.equal(h.settingsReads, 0);
|
|
75
|
+
await h.command("view off");
|
|
76
|
+
assert.equal(h.statuses.size, 0);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test("focused overlay accepts Kitty search, smartcase, repeat and two-stage Escape", async () => {
|
|
80
|
+
const h = host();
|
|
81
|
+
await h.command("view all");
|
|
82
|
+
const pane = h.overlays.at(-1).component;
|
|
83
|
+
pane.render(100);
|
|
84
|
+
pane.handleInput("\x1b[47u");
|
|
85
|
+
pane.handleInput("needle");
|
|
86
|
+
pane.handleInput("\r");
|
|
87
|
+
assert.match(pane.render(100).at(-1), /\/needle · 1\/2/);
|
|
88
|
+
pane.handleInput("\x1b[110u");
|
|
89
|
+
assert.match(pane.render(100).at(-1), /\/needle · 2\/2/);
|
|
90
|
+
pane.handleInput("N");
|
|
91
|
+
assert.match(pane.render(100).at(-1), /\/needle · 1\/2/);
|
|
92
|
+
pane.handleInput("?"); pane.handleInput("Needle"); pane.handleInput("\r");
|
|
93
|
+
assert.match(pane.render(100).at(-1), /\?Needle · 1\/1/);
|
|
94
|
+
pane.handleInput("\x1b");
|
|
95
|
+
assert.equal(h.statuses.size, 1);
|
|
96
|
+
assert.doesNotMatch(pane.render(100).at(-1), /Needle ·/);
|
|
97
|
+
pane.handleInput("\x1b");
|
|
98
|
+
assert.equal(h.statuses.size, 0);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test("toggle defaults to all; overlay cycle and toggle use the controller and preserve preference", async () => {
|
|
102
|
+
const h = host();
|
|
103
|
+
await h.shortcuts.get("ctrl+alt+r").handler(h.ctx);
|
|
104
|
+
assert.match(h.statuses.get("rarebit-view"), /view: all/);
|
|
105
|
+
h.overlays.at(-1).component.handleInput("\x1b[110;13u");
|
|
106
|
+
await tick();
|
|
107
|
+
assert.match(h.statuses.get("rarebit-view"), /view: peek/);
|
|
108
|
+
assert.equal(h.overlays.at(-1).options.overlayOptions.nonCapturing, true);
|
|
109
|
+
await h.shortcuts.get("ctrl+super+r").handler(h.ctx);
|
|
110
|
+
assert.equal(h.statuses.size, 0);
|
|
111
|
+
await h.shortcuts.get("ctrl+alt+r").handler(h.ctx);
|
|
112
|
+
assert.match(h.statuses.get("rarebit-view"), /view: peek/);
|
|
113
|
+
await h.command("view off");
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
test("context fails closed and falls back to all on an unsupported transcript", async () => {
|
|
117
|
+
const h = host();
|
|
118
|
+
await h.command("view context");
|
|
119
|
+
await tick();
|
|
120
|
+
assert.match(h.notices.join("\n"), /Context view is unavailable on this Pi version; showing all\./);
|
|
121
|
+
assert.match(h.statuses.get("rarebit-view"), /view: all/);
|
|
122
|
+
await h.command("view off");
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test("picker closes the current view; shutdown clears it and invalidates pending activation", async () => {
|
|
126
|
+
const h = host();
|
|
127
|
+
assert.equal(rarebitPaletteCommand({ id: "view" }), "view");
|
|
128
|
+
await h.command("view all");
|
|
129
|
+
h.setPicker(undefined);
|
|
130
|
+
await h.command("view");
|
|
131
|
+
assert.equal(h.statuses.size, 0);
|
|
132
|
+
const pending = h.command("view all");
|
|
133
|
+
await h.event("session_shutdown");
|
|
134
|
+
await pending;
|
|
135
|
+
assert.equal(h.statuses.size, 0);
|
|
136
|
+
await h.command("view peek");
|
|
137
|
+
const pane = h.overlays.at(-1).component;
|
|
138
|
+
assert.equal(pane.render(100).length, 18);
|
|
139
|
+
h.tui.terminal.rows = 40;
|
|
140
|
+
assert.equal(pane.render(100).length, 33);
|
|
141
|
+
await h.event("session_shutdown");
|
|
142
|
+
assert.equal(h.statuses.size, 0);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
test("search wrap notice survives renders and clears on the next key", async () => {
|
|
146
|
+
const h = host();
|
|
147
|
+
await h.command("view all");
|
|
148
|
+
const pane = h.overlays.at(-1).component;
|
|
149
|
+
assert.match(pane.render(120).at(-1), /ctrl\+alt\+n next/);
|
|
150
|
+
pane.handleInput("/"); pane.handleInput("needle"); pane.handleInput("\r");
|
|
151
|
+
pane.handleInput("n"); pane.handleInput("n");
|
|
152
|
+
assert.match(pane.render(120).at(-1), /search hit BOTTOM, continuing at TOP/);
|
|
153
|
+
assert.match(pane.render(120).at(-1), /search hit BOTTOM, continuing at TOP/);
|
|
154
|
+
pane.handleInput("N");
|
|
155
|
+
assert.match(pane.render(120).at(-1), /search hit TOP, continuing at BOTTOM/);
|
|
156
|
+
pane.handleInput("j");
|
|
157
|
+
assert.doesNotMatch(pane.render(120).at(-1), /search hit/);
|
|
158
|
+
assert.match(pane.render(120).at(-1), /\/needle · 2\/2/);
|
|
159
|
+
await h.command("view off");
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
// Override only for a second installed Pi version. No personal Session is read.
|
|
163
|
+
const piRoot = process.env.RAREBIT_TEST_PI_ROOT ?? dirname(dirname(fileURLToPath(import.meta.resolve("@earendil-works/pi-coding-agent"))));
|
|
164
|
+
const piRequire = createRequire(pathToFileURL(join(piRoot, "package.json")));
|
|
165
|
+
const realTui = await import(pathToFileURL(piRequire.resolve("@earendil-works/pi-tui")));
|
|
166
|
+
const { AssistantMessageComponent } = await import(pathToFileURL(join(piRoot, "dist/modes/interactive/components/assistant-message.js")));
|
|
167
|
+
const { initTheme } = await import(pathToFileURL(join(piRoot, "dist/modes/interactive/theme/theme.js")));
|
|
168
|
+
initTheme("dark", false);
|
|
169
|
+
let ThemedText;
|
|
170
|
+
try {
|
|
171
|
+
({ ThemedText } = await import(pathToFileURL(join(piRoot, "dist/modes/interactive/components/themed-text.js"))));
|
|
172
|
+
} catch (error) {
|
|
173
|
+
if (error.code !== "ERR_MODULE_NOT_FOUND") throw error;
|
|
174
|
+
}
|
|
175
|
+
const notice = (text) => ThemedText ? new ThemedText(() => text, 1, 0) : new realTui.Text(text, 1, 0);
|
|
176
|
+
|
|
177
|
+
function nativeHost(components) {
|
|
178
|
+
const document = new realTui.Container(), chat = new realTui.Container();
|
|
179
|
+
for (const component of components) chat.addChild(component);
|
|
180
|
+
document.addChild(chat);
|
|
181
|
+
const tui = { children: [document], terminal: { rows: 30, columns: 100 }, requestRender() {} };
|
|
182
|
+
const ctx = { mode: "tui", hasUI: true, sessionManager: { getBranch: () => [] }, ui: {
|
|
183
|
+
setStatus() {}, notify(text) { assert.fail(text); },
|
|
184
|
+
async custom(factory) { return factory(tui, {}, {}, () => {}); },
|
|
185
|
+
} };
|
|
186
|
+
return { chat, ctx, text: () => realTui.stripTerminalSequences(chat.render(100).join("\n")) };
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
test("context shows only newly appended real Pi notice components and restores earlier notices on off", async () => {
|
|
190
|
+
const h = nativeHost([notice("EARLIER_NOTICE")]);
|
|
191
|
+
const controller = createRarebitViewController();
|
|
192
|
+
await controller.command(h.ctx, "context");
|
|
193
|
+
try {
|
|
194
|
+
h.chat.addChild(new realTui.Spacer(5));
|
|
195
|
+
h.chat.addChild(notice("NEW_NOTICE"));
|
|
196
|
+
assert.equal(h.text().trim(), "NEW_NOTICE");
|
|
197
|
+
} finally { controller.dispose(); }
|
|
198
|
+
assert.match(h.text(), /EARLIER_NOTICE/);
|
|
199
|
+
assert.match(h.text(), /NEW_NOTICE/);
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
test("context shows a new status when Pi reuses an earlier notice component", async () => {
|
|
203
|
+
let message = "EARLIER_NOTICE";
|
|
204
|
+
const previousNotice = ThemedText ? new ThemedText(() => message, 1, 0) : new realTui.Text(message, 1, 0);
|
|
205
|
+
const h = nativeHost([previousNotice]);
|
|
206
|
+
const controller = createRarebitViewController();
|
|
207
|
+
await controller.command(h.ctx, "context");
|
|
208
|
+
try {
|
|
209
|
+
assert.doesNotMatch(h.text(), /EARLIER_NOTICE/);
|
|
210
|
+
previousNotice.invalidate();
|
|
211
|
+
assert.doesNotMatch(realTui.stripTerminalSequences(h.chat.render(40).join("\n")), /EARLIER_NOTICE/);
|
|
212
|
+
message = "UPDATED_NOTICE";
|
|
213
|
+
if (ThemedText) previousNotice.invalidate();
|
|
214
|
+
else previousNotice.setText(message);
|
|
215
|
+
assert.match(h.text(), /UPDATED_NOTICE/);
|
|
216
|
+
assert.doesNotMatch(h.text(), /EARLIER_NOTICE/);
|
|
217
|
+
} finally { controller.dispose(); }
|
|
218
|
+
assert.match(h.text(), /UPDATED_NOTICE/);
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
test("Session shutdown detaches Recap renderers from the outgoing context", async () => {
|
|
222
|
+
const h = host();
|
|
223
|
+
await h.event("session_start");
|
|
224
|
+
const component = h.entryRenderers.get("rarebit-recap")({
|
|
225
|
+
type: "custom", id: "recap", customType: "rarebit-recap",
|
|
226
|
+
data: { version: 1, heading: "Mock Recap", summary: "Generic recap prose" },
|
|
227
|
+
}, {}, { fg: (_color, text) => text });
|
|
228
|
+
for (let i = 0; i < 20 && component.render(100).join("\n").includes("Recap loading"); i++) await tick();
|
|
229
|
+
assert.match(component.render(100).join("\n"), /Generic recap prose/);
|
|
230
|
+
await h.event("session_shutdown");
|
|
231
|
+
h.ctx.sessionManager.getBranch = () => { throw new Error("outgoing Pi context is stale"); };
|
|
232
|
+
assert.doesNotThrow(() => component.render(100));
|
|
233
|
+
assert.match(component.render(100).join("\n"), /Generic recap prose/);
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
test("context preserves literal thinking-label prose with real Pi assistant components", async () => {
|
|
237
|
+
for (const withThinking of [false, true]) for (const hiddenBefore of [false, true]) {
|
|
238
|
+
const message = { role: "assistant", stopReason: "stop", content: [
|
|
239
|
+
...(withThinking ? [{ type: "thinking", thinking: "REASONING_MUST_STAY_HIDDEN" }] : []),
|
|
240
|
+
{ type: "text", text: "Thinking..." },
|
|
241
|
+
] };
|
|
242
|
+
const assistant = new AssistantMessageComponent(message, hiddenBefore);
|
|
243
|
+
const h = nativeHost([assistant]);
|
|
244
|
+
const original = h.text();
|
|
245
|
+
const controller = createRarebitViewController();
|
|
246
|
+
await controller.command(h.ctx, "context");
|
|
247
|
+
try {
|
|
248
|
+
assert.equal(controller.activeMode, "context");
|
|
249
|
+
const during = h.text();
|
|
250
|
+
assert.equal(during.match(/Thinking\.\.\./g)?.length, 1, "selected literal prose must survive once");
|
|
251
|
+
assert.doesNotMatch(during, /REASONING_MUST_STAY_HIDDEN/);
|
|
252
|
+
// A Pi invalidation rebuilds the content tree. It must not revive chrome
|
|
253
|
+
// or change the selected prose on the next native render.
|
|
254
|
+
assistant.invalidate();
|
|
255
|
+
assert.equal(h.text().match(/Thinking\.\.\./g)?.length, 1);
|
|
256
|
+
} finally { controller.dispose(); }
|
|
257
|
+
assert.equal(h.text(), original, "off restores the real component rendering");
|
|
258
|
+
assert.equal(assistant.hideThinkingBlock, hiddenBefore);
|
|
259
|
+
}
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
test("non-TUI and rejected overlay operations stay off without unhandled rejections", async () => {
|
|
263
|
+
for (const options of [{ mode: "rpc" }, { failOverlay: true }]) {
|
|
264
|
+
const h = host(options);
|
|
265
|
+
await h.command("view all");
|
|
266
|
+
assert.equal(h.statuses.size, 0);
|
|
267
|
+
assert.match(h.notices.join("\n"), /unavailable on this Pi version/);
|
|
268
|
+
assert.equal(h.settingsReads, 0);
|
|
269
|
+
}
|
|
270
|
+
});
|