@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/src/extension.mjs
CHANGED
|
@@ -42,6 +42,7 @@ import {
|
|
|
42
42
|
registerRarebitLifecycle,
|
|
43
43
|
} from "./lifecycle.mjs";
|
|
44
44
|
import { createRarebitRecapController } from "./rarebit-recap.mjs";
|
|
45
|
+
import { createRarebitViewController } from "./rarebit-view.mjs";
|
|
45
46
|
import { readRarebitCurrent } from "./rarebit-store.mjs";
|
|
46
47
|
import {
|
|
47
48
|
openRarebitPalette,
|
|
@@ -140,6 +141,13 @@ export default function registerPiRarebit(pi, config = {}) {
|
|
|
140
141
|
delayMs: explicit.recap?.delayMs,
|
|
141
142
|
timezone: explicit.recap?.timezone,
|
|
142
143
|
});
|
|
144
|
+
const viewController = createRarebitViewController();
|
|
145
|
+
for (const key of ["ctrl+alt+r", "ctrl+super+r"])
|
|
146
|
+
pi.registerShortcut?.(key, { description: "Toggle Rarebit view", handler: (ctx) => viewController.toggle(ctx) });
|
|
147
|
+
for (const key of ["ctrl+alt+n", "ctrl+super+n"])
|
|
148
|
+
pi.registerShortcut?.(key, { description: "Next Rarebit view", handler: (ctx) => viewController.cycle(ctx) });
|
|
149
|
+
pi.on("session_compact", () => viewController.refresh());
|
|
150
|
+
pi.on("message_end", () => viewController.refresh());
|
|
143
151
|
pi.registerEntryRenderer?.("rarebit-recap", recapController.renderEntry);
|
|
144
152
|
const acknowledgeRecap = (ctx) => {
|
|
145
153
|
const result = recapController.acknowledge(ctx);
|
|
@@ -455,6 +463,7 @@ export default function registerPiRarebit(pi, config = {}) {
|
|
|
455
463
|
|
|
456
464
|
registerRarebitLifecycle(pi, schedule, {
|
|
457
465
|
onSessionStart: (ctx, generation) => {
|
|
466
|
+
viewController.dispose();
|
|
458
467
|
recapController.invalidate(ctx, generation);
|
|
459
468
|
activeSession = { ...identityFrom(ctx), generation };
|
|
460
469
|
activityReporter.start(projectActivity(ctx));
|
|
@@ -469,6 +478,7 @@ export default function registerPiRarebit(pi, config = {}) {
|
|
|
469
478
|
recapController.invalidate(ctx, generation);
|
|
470
479
|
},
|
|
471
480
|
onSessionTree: (ctx, _event, generation) => {
|
|
481
|
+
viewController.refresh();
|
|
472
482
|
recapController.invalidate(ctx, generation);
|
|
473
483
|
activityReporter.update(projectActivity(ctx));
|
|
474
484
|
},
|
|
@@ -476,11 +486,13 @@ export default function registerPiRarebit(pi, config = {}) {
|
|
|
476
486
|
activityReporter.update(projectActivity(ctx));
|
|
477
487
|
},
|
|
478
488
|
onAgentSettled: (ctx) => {
|
|
489
|
+
viewController.refresh();
|
|
479
490
|
recapController.updateContext(ctx);
|
|
480
491
|
activityReporter.update(projectActivity(ctx));
|
|
481
492
|
},
|
|
482
|
-
onSessionShutdown: (
|
|
483
|
-
|
|
493
|
+
onSessionShutdown: () => {
|
|
494
|
+
viewController.dispose();
|
|
495
|
+
recapController.dispose();
|
|
484
496
|
activeSession = undefined;
|
|
485
497
|
activityReporter.stop();
|
|
486
498
|
},
|
|
@@ -561,6 +573,7 @@ ${fence(requestText)}`;
|
|
|
561
573
|
let commandInput = String(args ?? "");
|
|
562
574
|
const trimmedCommandInput = commandInput.trim();
|
|
563
575
|
if (!trimmedCommandInput || trimmedCommandInput === "menu") {
|
|
576
|
+
viewController.dispose();
|
|
564
577
|
const currentUiState = () => ({
|
|
565
578
|
...identityFrom(ctx),
|
|
566
579
|
branchLeafId: ctx?.sessionManager?.getBranch?.()?.at(-1)?.id ?? null,
|
|
@@ -611,7 +624,12 @@ ${fence(requestText)}`;
|
|
|
611
624
|
);
|
|
612
625
|
return;
|
|
613
626
|
}
|
|
627
|
+
if (subcommand === "view") {
|
|
628
|
+
await viewController.command(ctx, rest[0]);
|
|
629
|
+
return;
|
|
630
|
+
}
|
|
614
631
|
if (subcommand === "settings") {
|
|
632
|
+
viewController.dispose();
|
|
615
633
|
const currentUiState = () => ({
|
|
616
634
|
...identityFrom(ctx),
|
|
617
635
|
branchLeafId: ctx?.sessionManager?.getBranch?.()?.at(-1)?.id ?? null,
|
|
@@ -776,7 +794,7 @@ ${fence(requestText)}`;
|
|
|
776
794
|
: "settings_or_default";
|
|
777
795
|
notify(
|
|
778
796
|
ctx,
|
|
779
|
-
`Rarebit ${subcommand}: auto-title=${autoTitleOverride ?? effective.autoTitle ?? true}; recap=${effective.recap?.enabled !== false ? "on" : "off"} after ${effective.recap?.delayMs ?? 60000}ms (${effective.recap?.timezone ?? "host"}); diagnostics=triggered:${effective.diagnostics?.summaryTriggered === true ? "on" : "off"},updated:${effective.diagnostics?.summaryUpdated === true ? "on" : "off"}; max_input_tokens=${effective.maxInputTokens ?? 64000}; max_rarebit_ratio=${policy.maxRarebitRatio} (${ratioSource}); min_total_length=${policy.minTotalLength} estimated tokens via ceil(chars/4) (${lengthSource}); measurement=${policy.measurementVersion}; model=${modelLabel(effective.model)} from ${effective.modelProvenance?.settingsKey ?? "explicit config"}${policyStatus}`,
|
|
797
|
+
`Rarebit ${subcommand}: view=${viewController.activeMode}; auto-title=${autoTitleOverride ?? effective.autoTitle ?? true}; recap=${effective.recap?.enabled !== false ? "on" : "off"} after ${effective.recap?.delayMs ?? 60000}ms (${effective.recap?.timezone ?? "host"}); diagnostics=triggered:${effective.diagnostics?.summaryTriggered === true ? "on" : "off"},updated:${effective.diagnostics?.summaryUpdated === true ? "on" : "off"}; max_input_tokens=${effective.maxInputTokens ?? 64000}; max_rarebit_ratio=${policy.maxRarebitRatio} (${ratioSource}); min_total_length=${policy.minTotalLength} estimated tokens via ceil(chars/4) (${lengthSource}); measurement=${policy.measurementVersion}; model=${modelLabel(effective.model)} from ${effective.modelProvenance?.settingsKey ?? "explicit config"}${policyStatus}`,
|
|
780
798
|
"info",
|
|
781
799
|
);
|
|
782
800
|
return;
|
package/src/rarebit-command.mjs
CHANGED
|
@@ -29,6 +29,11 @@ export const RAREBIT_COMMAND_GRAMMAR = Object.freeze({
|
|
|
29
29
|
description: "Open interactive Rarebit settings",
|
|
30
30
|
forms: [[], [literal("global", "Edit global Pi settings")], [literal("project", "Edit trusted project settings")]],
|
|
31
31
|
},
|
|
32
|
+
{
|
|
33
|
+
name: "view",
|
|
34
|
+
description: "Read Rarebits in the TUI",
|
|
35
|
+
forms: [[], [literal("context", "Current context only (experimental)")], [literal("all", "All Rarebits across compactions")], [literal("peek", "All Rarebits with the editor live")], [literal("off", "Normal transcript")]],
|
|
36
|
+
},
|
|
32
37
|
{
|
|
33
38
|
name: "help",
|
|
34
39
|
description: "Show Rarebit command help",
|
package/src/rarebit-recap.mjs
CHANGED
|
@@ -50,7 +50,8 @@ function receiptCoverage(receipt, ctx) {
|
|
|
50
50
|
return { state: current, coverage: null };
|
|
51
51
|
}
|
|
52
52
|
|
|
53
|
-
function observedAtLabel(observedAt, timezone) {
|
|
53
|
+
export function observedAtLabel(observedAt, timezone = DEFAULT_RAREBIT_RECAP_TIMEZONE) {
|
|
54
|
+
if (observedAt === null || observedAt === undefined) return "unknown";
|
|
54
55
|
const date = new Date(observedAt);
|
|
55
56
|
if (Number.isNaN(date.valueOf())) return "unknown";
|
|
56
57
|
const usesHostTimezone = timezone === DEFAULT_RAREBIT_RECAP_TIMEZONE;
|
|
@@ -200,6 +200,7 @@ export const RAREBIT_SETTINGS_FIELDS = Object.freeze([
|
|
|
200
200
|
]);
|
|
201
201
|
|
|
202
202
|
const paletteItems = [
|
|
203
|
+
{ id: "view", tab: "Actions", label: "View", description: "Read Rarebits in the TUI." },
|
|
203
204
|
{ id: "settings", tab: "Actions", label: "Settings", description: "Open the interactive Rarebit settings editor." },
|
|
204
205
|
{ id: "status", tab: "Actions", label: "Status", description: "Show effective Rarebit configuration." },
|
|
205
206
|
{ id: "help", tab: "Actions", label: "Command help", description: "Show direct /rarebit commands." },
|
|
@@ -466,6 +467,7 @@ export async function openRarebitSettings(ctx, {
|
|
|
466
467
|
|
|
467
468
|
export function rarebitPaletteCommand(selection) {
|
|
468
469
|
switch (selection?.id) {
|
|
470
|
+
case "view": return "view";
|
|
469
471
|
case "settings": case "summary_settings": case "recap_settings": case "session_settings": return "settings";
|
|
470
472
|
case "summary_status": return "status";
|
|
471
473
|
case "recap": return "recap";
|
|
@@ -0,0 +1,426 @@
|
|
|
1
|
+
import { messageTextBlocks, rarebitMetadata } from "./rarebit-core.mjs";
|
|
2
|
+
import { rarebitOccurrencePresentation } from "./rarebit-visual-language.mjs";
|
|
3
|
+
import { RECAP_ENTRY_TYPE } from "./rarebit-read-checkpoint.mjs";
|
|
4
|
+
import { observedAtLabel } from "./rarebit-recap.mjs";
|
|
5
|
+
|
|
6
|
+
const MODES = ["context", "all", "peek"];
|
|
7
|
+
const TOGGLE_KEYS = ["ctrl+alt+r", "ctrl+super+r"];
|
|
8
|
+
const CYCLE_KEYS = ["ctrl+alt+n", "ctrl+super+n"];
|
|
9
|
+
const DOCK_ROWS = 7;
|
|
10
|
+
let peers;
|
|
11
|
+
async function loadPeers() {
|
|
12
|
+
peers ??= Promise.all([import("@earendil-works/pi-tui"), import("@earendil-works/pi-coding-agent")])
|
|
13
|
+
.then(([tui, agent]) => ({ ...tui, getMarkdownTheme: agent.getMarkdownTheme }));
|
|
14
|
+
return peers;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function branchItems(ctx) {
|
|
18
|
+
const branch = ctx.sessionManager.getBranch();
|
|
19
|
+
const cutoffs = new Map();
|
|
20
|
+
for (const entry of branch) {
|
|
21
|
+
if (entry.type !== "compaction") continue;
|
|
22
|
+
const cutoff = entry.firstKeptEntryId ?? entry.id;
|
|
23
|
+
const markers = cutoffs.get(cutoff) ?? [];
|
|
24
|
+
markers.push(entry);
|
|
25
|
+
cutoffs.set(cutoff, markers);
|
|
26
|
+
}
|
|
27
|
+
const items = [];
|
|
28
|
+
branch.forEach((entry, order) => {
|
|
29
|
+
for (const marker of cutoffs.get(entry.id) ?? [])
|
|
30
|
+
items.push({ kind: "compaction", time: observedAtLabel(marker.timestamp, "host") });
|
|
31
|
+
const meta = rarebitMetadata(entry, order);
|
|
32
|
+
if (meta) items.push({ ...meta, kind: "rarebit", text: messageTextBlocks(entry.message).join("\n\n"), time: observedAtLabel(meta.timestamp, "host") });
|
|
33
|
+
});
|
|
34
|
+
return items;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** One disposable reader. Selection stays in the semantic backend. */
|
|
38
|
+
class RarebitPane {
|
|
39
|
+
constructor(tui, theme, ctx, mode, primitives, transitions) {
|
|
40
|
+
Object.assign(this, { tui, theme, ctx, mode, primitives, transitions });
|
|
41
|
+
this.scrollTop = 0;
|
|
42
|
+
this.follow = true;
|
|
43
|
+
this.bodyHeight = 1;
|
|
44
|
+
this.body = [];
|
|
45
|
+
this.cache = new Map();
|
|
46
|
+
}
|
|
47
|
+
lines(items, width) {
|
|
48
|
+
const { theme: t, primitives: p } = this;
|
|
49
|
+
const lines = [];
|
|
50
|
+
const texts = new Set();
|
|
51
|
+
for (const item of items) {
|
|
52
|
+
if (lines.length) lines.push("");
|
|
53
|
+
if (item.kind === "compaction") {
|
|
54
|
+
lines.push(t.fg("muted", `── compaction ${item.time} · context restarts here ──`));
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
const presentation = rarebitOccurrencePresentation(item);
|
|
58
|
+
const color = item.role === "user" ? "success" : item.outcome === "continuation" ? "accent" : "muted";
|
|
59
|
+
lines.push(` ${t.fg(color, `${presentation.mark} ${presentation.label}`)} ${t.fg("dim", item.time)}`);
|
|
60
|
+
texts.add(item.text);
|
|
61
|
+
let md = this.cache.get(item.text);
|
|
62
|
+
if (!md) this.cache.set(item.text, md = new p.Markdown(item.text, 3, 0, p.getMarkdownTheme()));
|
|
63
|
+
lines.push(...md.render(width));
|
|
64
|
+
}
|
|
65
|
+
for (const text of this.cache.keys()) if (!texts.has(text)) this.cache.delete(text);
|
|
66
|
+
return lines;
|
|
67
|
+
}
|
|
68
|
+
matches() {
|
|
69
|
+
if (!this.search?.query) return [];
|
|
70
|
+
const { stripTerminalSequences, visibleWidth } = this.primitives;
|
|
71
|
+
const sensitive = /[A-Z]/.test(this.search.query);
|
|
72
|
+
const needle = sensitive ? this.search.query : this.search.query.toLowerCase();
|
|
73
|
+
const matches = [];
|
|
74
|
+
this.body.forEach((line, index) => {
|
|
75
|
+
const plain = stripTerminalSequences(line);
|
|
76
|
+
const hay = sensitive ? plain : plain.toLowerCase();
|
|
77
|
+
for (let at = hay.indexOf(needle); at >= 0; at = hay.indexOf(needle, at + Math.max(1, needle.length)))
|
|
78
|
+
matches.push({ line: index, col: visibleWidth(plain.slice(0, at)), width: visibleWidth(plain.slice(at, at + needle.length)) });
|
|
79
|
+
});
|
|
80
|
+
return matches;
|
|
81
|
+
}
|
|
82
|
+
highlight(line, matches, current) {
|
|
83
|
+
const { sliceByColumn, stripTerminalSequences, visibleWidth } = this.primitives;
|
|
84
|
+
let out = "", cursor = 0;
|
|
85
|
+
for (const m of matches) {
|
|
86
|
+
const text = stripTerminalSequences(sliceByColumn(line, m.col, m.width));
|
|
87
|
+
out += sliceByColumn(line, cursor, m.col - cursor) +
|
|
88
|
+
(m === current ? this.theme.inverse(text) : this.theme.bg("searchMatchBg", text));
|
|
89
|
+
cursor = m.col + m.width;
|
|
90
|
+
}
|
|
91
|
+
return out + sliceByColumn(line, cursor, Math.max(0, visibleWidth(line) - cursor));
|
|
92
|
+
}
|
|
93
|
+
render(width) {
|
|
94
|
+
const { theme: t, primitives: p } = this;
|
|
95
|
+
const items = branchItems(this.ctx);
|
|
96
|
+
const rows = Math.max(3, this.tui.terminal.rows);
|
|
97
|
+
const height = this.mode === "peek" ? Math.max(3, rows - Math.min(DOCK_ROWS, rows - 3)) : rows;
|
|
98
|
+
this.body = this.lines(items, width);
|
|
99
|
+
this.bodyHeight = height - 2;
|
|
100
|
+
const maxTop = Math.max(0, this.body.length - this.bodyHeight);
|
|
101
|
+
if (this.follow) this.scrollTop = maxTop;
|
|
102
|
+
this.scrollTop = Math.min(Math.max(0, this.scrollTop), maxTop);
|
|
103
|
+
const title = ` Rarebits · ${items.filter((item) => item.kind === "rarebit").length} on this branch `;
|
|
104
|
+
const top = t.fg("accent", t.bold(title)) + t.fg("dim", "─".repeat(Math.max(0, width - p.visibleWidth(title))));
|
|
105
|
+
const matches = this.matches();
|
|
106
|
+
const current = matches[this.search?.index];
|
|
107
|
+
const view = [];
|
|
108
|
+
for (let i = this.scrollTop; i < Math.min(this.body.length, this.scrollTop + this.bodyHeight); i++) {
|
|
109
|
+
const onLine = matches.filter((m) => m.line === i);
|
|
110
|
+
view.push(onLine.length ? this.highlight(this.body[i], onLine, current) : this.body[i]);
|
|
111
|
+
}
|
|
112
|
+
while (view.length < this.bodyHeight) view.push("");
|
|
113
|
+
let left;
|
|
114
|
+
if (this.prompt) left = t.fg("accent", ` ${this.prompt.forward ? "/" : "?"}${this.prompt.text}`) + "█";
|
|
115
|
+
else if (this.message) left = t.fg("warning", ` ${this.message}`);
|
|
116
|
+
else if (this.search && matches.length) left = t.fg("muted", ` ${this.search.forward ? "/" : "?"}${this.search.query} · ${this.search.index + 1}/${matches.length} · n/N`);
|
|
117
|
+
else left = t.fg("dim", this.mode === "all"
|
|
118
|
+
? ` j/k · u/d · b/space · g/G · / ? n N search · esc/q close · ${CYCLE_KEYS[0]} next `
|
|
119
|
+
: ` keys go to the editor · wheel scrolls · ${TOGGLE_KEYS[0]} closes · replies appear when finished `);
|
|
120
|
+
const pos = this.body.length > this.bodyHeight ? `${this.scrollTop + 1}-${Math.min(this.body.length, this.scrollTop + this.bodyHeight)}/${this.body.length}` : "";
|
|
121
|
+
const bottom = left + " ".repeat(Math.max(0, width - p.visibleWidth(left) - pos.length)) + t.fg("muted", pos);
|
|
122
|
+
return [top, ...view, bottom].map((line) => {
|
|
123
|
+
const cut = p.truncateToWidth(line, width, "");
|
|
124
|
+
return t.bg("customMessageBg", cut + " ".repeat(Math.max(0, width - p.visibleWidth(cut))));
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
scrollBy(delta) {
|
|
128
|
+
const maxTop = Math.max(0, this.body.length - this.bodyHeight);
|
|
129
|
+
this.scrollTop = Math.min(Math.max(0, this.scrollTop + delta), maxTop);
|
|
130
|
+
this.follow = this.scrollTop >= maxTop;
|
|
131
|
+
this.tui.requestRender();
|
|
132
|
+
}
|
|
133
|
+
jump(forward, fromCurrent) {
|
|
134
|
+
const matches = this.matches();
|
|
135
|
+
if (!matches.length) {
|
|
136
|
+
this.message = `Pattern not found: ${this.search.query}`;
|
|
137
|
+
return this.tui.requestRender();
|
|
138
|
+
}
|
|
139
|
+
const current = matches[this.search.index];
|
|
140
|
+
const anchor = fromCurrent && current ? current : { line: forward ? this.scrollTop - 1 : this.scrollTop + this.bodyHeight, col: 0 };
|
|
141
|
+
let index = forward
|
|
142
|
+
? matches.findIndex((m) => m.line > anchor.line || (m.line === anchor.line && m.col > anchor.col))
|
|
143
|
+
: matches.findLastIndex((m) => m.line < anchor.line || (m.line === anchor.line && m.col < anchor.col));
|
|
144
|
+
if (index < 0) {
|
|
145
|
+
index = forward ? 0 : matches.length - 1;
|
|
146
|
+
this.message = forward ? "search hit BOTTOM, continuing at TOP" : "search hit TOP, continuing at BOTTOM";
|
|
147
|
+
}
|
|
148
|
+
this.search.index = index;
|
|
149
|
+
const line = matches[index].line;
|
|
150
|
+
if (line < this.scrollTop || line >= this.scrollTop + this.bodyHeight)
|
|
151
|
+
this.scrollTop = Math.max(0, line - Math.floor(this.bodyHeight / 3));
|
|
152
|
+
this.follow = false;
|
|
153
|
+
this.tui.requestRender();
|
|
154
|
+
}
|
|
155
|
+
handleInput(data) {
|
|
156
|
+
const p = this.primitives;
|
|
157
|
+
const key = (expected) => p.matchesKey(data, expected);
|
|
158
|
+
if (TOGGLE_KEYS.some(key)) return this.transitions.toggle();
|
|
159
|
+
if (CYCLE_KEYS.some(key)) return this.transitions.cycle();
|
|
160
|
+
const text = p.decodeKittyPrintable(data) ?? (data.startsWith("\x1b") ? undefined : data);
|
|
161
|
+
this.message = undefined;
|
|
162
|
+
if (this.prompt) {
|
|
163
|
+
if (key("escape")) this.prompt = undefined;
|
|
164
|
+
else if (key("enter")) {
|
|
165
|
+
const { text: queryText, forward } = this.prompt;
|
|
166
|
+
this.prompt = undefined;
|
|
167
|
+
const query = queryText || this.search?.query;
|
|
168
|
+
if (query) { this.search = { query, forward, index: -1 }; return this.jump(forward, false); }
|
|
169
|
+
} else if (key("backspace")) {
|
|
170
|
+
if (!this.prompt.text) this.prompt = undefined;
|
|
171
|
+
else this.prompt.text = [...this.prompt.text].slice(0, -1).join("");
|
|
172
|
+
} else if (text && !/[\x00-\x1f\x7f]/.test(text)) this.prompt.text += text;
|
|
173
|
+
return this.tui.requestRender();
|
|
174
|
+
}
|
|
175
|
+
if (text === "/" || text === "?") { this.prompt = { text: "", forward: text === "/" }; return this.tui.requestRender(); }
|
|
176
|
+
if (text === "n" || text === "N") {
|
|
177
|
+
if (this.search) this.jump(text === "n" ? this.search.forward : !this.search.forward, true);
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
if (key("escape") && this.search) { this.search = undefined; return this.tui.requestRender(); }
|
|
181
|
+
if (key("escape") || text === "q") return this.transitions.close();
|
|
182
|
+
if (key("up") || text === "k") return this.scrollBy(-1);
|
|
183
|
+
if (key("down") || text === "j") return this.scrollBy(1);
|
|
184
|
+
const page = Math.max(1, this.bodyHeight - 2), half = Math.max(1, Math.floor(this.bodyHeight / 2));
|
|
185
|
+
if (key("pageUp") || text === "b") return this.scrollBy(-page);
|
|
186
|
+
if (key("pageDown") || text === " ") return this.scrollBy(page);
|
|
187
|
+
if (key("ctrl+u") || text === "u") return this.scrollBy(-half);
|
|
188
|
+
if (key("ctrl+d") || text === "d") return this.scrollBy(half);
|
|
189
|
+
if (key("home") || text === "g") return this.scrollBy(-Infinity);
|
|
190
|
+
if (key("end") || text === "G") return this.scrollBy(Infinity);
|
|
191
|
+
}
|
|
192
|
+
handleMouse(event) {
|
|
193
|
+
if (event.type !== "wheel" || !event.wheelDelta) return undefined;
|
|
194
|
+
this.scrollBy(event.wheelDelta);
|
|
195
|
+
return { handled: true };
|
|
196
|
+
}
|
|
197
|
+
invalidate() { this.cache.clear(); }
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const typeName = (component) => component?.constructor?.name;
|
|
201
|
+
/**
|
|
202
|
+
* Unsupported by Pi and can break on any release. This adapter relies on:
|
|
203
|
+
* tui.children[0] being the document container and its last child being chat;
|
|
204
|
+
* mutable Container.render/children and mouseLayout; built-in constructor names;
|
|
205
|
+
* AssistantMessageComponent.lastMessage, hideThinkingBlock, hiddenThinkingLabel,
|
|
206
|
+
* setHideThinkingBlock, contentContainer.children; hidden thinking chrome as Text
|
|
207
|
+
* or MouseRegion.child Text; ThemedText (or older Text) notice classes and text;
|
|
208
|
+
* CustomEntryComponent.entry.customType. It changes display
|
|
209
|
+
* only. Pi still owns context, compaction, transcript layout, and Session data.
|
|
210
|
+
*/
|
|
211
|
+
function tryAttach(tui, p, unavailable) {
|
|
212
|
+
const document = tui.children?.[0];
|
|
213
|
+
const chat = document?.children?.at(-1);
|
|
214
|
+
const validAssistant = (child) => typeName(child) !== "AssistantMessageComponent" ||
|
|
215
|
+
(child.lastMessage?.role === "assistant" && typeof child.hideThinkingBlock === "boolean" &&
|
|
216
|
+
typeof child.hiddenThinkingLabel === "string" && typeof child.setHideThinkingBlock === "function" &&
|
|
217
|
+
typeName(child.contentContainer) === "Container" && Array.isArray(child.contentContainer.children));
|
|
218
|
+
if (typeName(document) !== "Container" || typeName(chat) !== "Container" || !Array.isArray(chat.children) ||
|
|
219
|
+
typeof chat.render !== "function" || !chat.children.every((child) => typeof child.render === "function" && validAssistant(child))) return;
|
|
220
|
+
const original = chat.render;
|
|
221
|
+
try {
|
|
222
|
+
const lines = original.call(chat, Math.max(1, tui.terminal.columns));
|
|
223
|
+
if (!Array.isArray(lines) || !lines.every((line) => typeof line === "string")) return;
|
|
224
|
+
} catch { return; }
|
|
225
|
+
const isNotice = (child) => ["ThemedText", "Text"].includes(typeName(child));
|
|
226
|
+
const noticeText = (child) => p.stripTerminalSequences(String(child.text ?? ""));
|
|
227
|
+
const initialNotices = new WeakMap(chat.children.filter(isNotice).map((child) => [child, noticeText(child)]));
|
|
228
|
+
const previousMouseLayout = chat.mouseLayout;
|
|
229
|
+
let ownedMouseLayout;
|
|
230
|
+
const thinking = new Map();
|
|
231
|
+
let disposed = false;
|
|
232
|
+
const restore = (child, previous) => {
|
|
233
|
+
if (child.hideThinkingBlock === true) child.setHideThinkingBlock(previous);
|
|
234
|
+
};
|
|
235
|
+
const dispose = () => {
|
|
236
|
+
if (disposed) return;
|
|
237
|
+
disposed = true;
|
|
238
|
+
if (chat.render === wrapper) {
|
|
239
|
+
chat.render = original;
|
|
240
|
+
if (ownedMouseLayout && chat.mouseLayout === ownedMouseLayout) chat.mouseLayout = previousMouseLayout;
|
|
241
|
+
for (const [child, previous] of thinking) if (chat.children.includes(child)) restore(child, previous);
|
|
242
|
+
}
|
|
243
|
+
thinking.clear();
|
|
244
|
+
tui.requestRender(true);
|
|
245
|
+
};
|
|
246
|
+
const renderFiltered = function (width) {
|
|
247
|
+
if (disposed) return original.call(this, width);
|
|
248
|
+
for (const child of thinking.keys()) if (!this.children.includes(child)) thinking.delete(child);
|
|
249
|
+
if (!this.children.every((child) => typeof child.render === "function" && validAssistant(child))) {
|
|
250
|
+
dispose();
|
|
251
|
+
queueMicrotask(unavailable);
|
|
252
|
+
return original.call(this, width);
|
|
253
|
+
}
|
|
254
|
+
const lines = [], mouseChildren = [];
|
|
255
|
+
for (const child of this.children) {
|
|
256
|
+
const kind = typeName(child);
|
|
257
|
+
// Pi reuses its last status component for consecutive notices. Rendering
|
|
258
|
+
// hydrates ThemedText; compare unwrapped prose so theme/width changes do
|
|
259
|
+
// not reveal an unchanged earlier notice.
|
|
260
|
+
const noticeLines = isNotice(child) ? child.render(width) : undefined;
|
|
261
|
+
const freshNotice = noticeLines && (!initialNotices.has(child) || initialNotices.get(child) !== noticeText(child));
|
|
262
|
+
const keep = kind === "UserMessageComponent" || kind === "CompactionSummaryMessageComponent" ||
|
|
263
|
+
freshNotice ||
|
|
264
|
+
(kind === "CustomEntryComponent" && child.entry?.customType === RECAP_ENTRY_TYPE) ||
|
|
265
|
+
(kind === "AssistantMessageComponent" && rarebitMetadata({ type: "message", message: child.lastMessage }) !== null);
|
|
266
|
+
if (keep && kind === "AssistantMessageComponent" && !thinking.has(child)) {
|
|
267
|
+
thinking.set(child, child.hideThinkingBlock);
|
|
268
|
+
child.setHideThinkingBlock(true);
|
|
269
|
+
}
|
|
270
|
+
let childLines = [];
|
|
271
|
+
if (keep && kind === "AssistantMessageComponent") {
|
|
272
|
+
// Pi renders selected prose as Markdown. Thinking placeholders are
|
|
273
|
+
// separate Text children (wrapped by MouseRegion on newer Pi). Exclude
|
|
274
|
+
// those components only while rendering; never compare prose to a label.
|
|
275
|
+
const content = child.contentContainer;
|
|
276
|
+
const originalChildren = content.children;
|
|
277
|
+
const blocks = child.lastMessage.content;
|
|
278
|
+
const hasThinking = blocks.some((block) => block.type === "thinking" && block.thinking.trim());
|
|
279
|
+
const thinkingChrome = (part) => typeName(part) === "Text" ||
|
|
280
|
+
(typeName(part) === "MouseRegion" && typeName(part.child) === "Text");
|
|
281
|
+
const filtered = hasThinking ? originalChildren.filter((part) => !thinkingChrome(part)) : originalChildren;
|
|
282
|
+
const proseBlocks = blocks.filter((block) => block.type === "text" && block.text.trim()).length;
|
|
283
|
+
if (filtered.some((part) => !["Markdown", "Spacer"].includes(typeName(part))) ||
|
|
284
|
+
filtered.filter((part) => typeName(part) === "Markdown").length !== proseBlocks)
|
|
285
|
+
throw new Error("Pi assistant content structure changed");
|
|
286
|
+
content.children = filtered;
|
|
287
|
+
try { childLines = child.render(width); }
|
|
288
|
+
finally { if (content.children === filtered) content.children = originalChildren; }
|
|
289
|
+
} else if (keep) childLines = noticeLines ?? child.render(width);
|
|
290
|
+
if (!Array.isArray(childLines) || !childLines.every((line) => typeof line === "string")) throw new Error("Pi transcript render shape changed");
|
|
291
|
+
if (keep && kind === "AssistantMessageComponent") {
|
|
292
|
+
const plain = (line) => p.stripTerminalSequences(line).trim();
|
|
293
|
+
while (childLines.length && !plain(childLines[0])) childLines.shift();
|
|
294
|
+
while (childLines.length && !plain(childLines.at(-1))) childLines.pop();
|
|
295
|
+
}
|
|
296
|
+
if (childLines.length && lines.length) childLines = ["", ...childLines];
|
|
297
|
+
mouseChildren.push({ component: child, height: childLines.length });
|
|
298
|
+
lines.push(...childLines);
|
|
299
|
+
}
|
|
300
|
+
if ("mouseLayout" in this) this.mouseLayout = ownedMouseLayout = { width, children: mouseChildren };
|
|
301
|
+
return lines;
|
|
302
|
+
};
|
|
303
|
+
const wrapper = function (width) {
|
|
304
|
+
try { return renderFiltered.call(this, width); }
|
|
305
|
+
catch {
|
|
306
|
+
dispose();
|
|
307
|
+
queueMicrotask(unavailable);
|
|
308
|
+
return original.call(this, width);
|
|
309
|
+
}
|
|
310
|
+
};
|
|
311
|
+
chat.render = wrapper;
|
|
312
|
+
return dispose;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/** Session-scoped display controller. No Session writes or model operations. */
|
|
316
|
+
export function createRarebitViewController() {
|
|
317
|
+
let preferred = "all", active, activation, epoch = 0;
|
|
318
|
+
const notify = (ctx, message) => ctx?.hasUI && ctx.ui.notify?.(message, "warning");
|
|
319
|
+
const stop = () => {
|
|
320
|
+
epoch++;
|
|
321
|
+
const old = activation;
|
|
322
|
+
activation = undefined;
|
|
323
|
+
active = undefined;
|
|
324
|
+
try { old?.dispose?.(); }
|
|
325
|
+
finally {
|
|
326
|
+
old?.ctx.ui.setStatus?.("rarebit-view", undefined);
|
|
327
|
+
old?.tui?.requestRender(true);
|
|
328
|
+
}
|
|
329
|
+
};
|
|
330
|
+
const activate = async (ctx, mode) => {
|
|
331
|
+
stop();
|
|
332
|
+
if (ctx?.mode !== "tui" || !ctx.hasUI || typeof ctx.ui?.custom !== "function") {
|
|
333
|
+
notify(ctx, "Rarebit view is unavailable on this Pi version");
|
|
334
|
+
return;
|
|
335
|
+
}
|
|
336
|
+
preferred = mode;
|
|
337
|
+
const token = epoch;
|
|
338
|
+
const record = { ctx, dispose: () => {} };
|
|
339
|
+
activation = record;
|
|
340
|
+
const current = () => token === epoch && activation === record;
|
|
341
|
+
try {
|
|
342
|
+
const p = await loadPeers();
|
|
343
|
+
if (!current()) return;
|
|
344
|
+
for (const name of ["Markdown", "matchesKey", "decodeKittyPrintable", "sliceByColumn", "stripTerminalSequences", "truncateToWidth", "visibleWidth", "getMarkdownTheme"])
|
|
345
|
+
if (typeof p[name] !== "function") throw new Error("unavailable");
|
|
346
|
+
if (typeof ctx.sessionManager?.getBranch !== "function") throw new Error("unavailable");
|
|
347
|
+
const markActive = (tui) => {
|
|
348
|
+
if (!current()) return;
|
|
349
|
+
record.tui = tui;
|
|
350
|
+
active = mode;
|
|
351
|
+
ctx.ui.setStatus?.("rarebit-view", `Rarebit view: ${mode} · next ${CYCLE_KEYS[0]} · off ${TOGGLE_KEYS[0]}`);
|
|
352
|
+
tui.requestRender(true);
|
|
353
|
+
};
|
|
354
|
+
const fallback = () => {
|
|
355
|
+
if (!current()) return;
|
|
356
|
+
notify(ctx, "Context view is unavailable on this Pi version; showing all.");
|
|
357
|
+
void activate(ctx, "all");
|
|
358
|
+
};
|
|
359
|
+
if (mode === "context") {
|
|
360
|
+
let tui;
|
|
361
|
+
await ctx.ui.custom((host, _theme, _kb, done) => {
|
|
362
|
+
tui = host;
|
|
363
|
+
done();
|
|
364
|
+
return { render: () => [], invalidate() {} };
|
|
365
|
+
});
|
|
366
|
+
if (!current()) return;
|
|
367
|
+
const dispose = tui && tryAttach(tui, p, fallback);
|
|
368
|
+
if (!dispose) return fallback();
|
|
369
|
+
record.dispose = dispose;
|
|
370
|
+
markActive(tui);
|
|
371
|
+
return;
|
|
372
|
+
}
|
|
373
|
+
let close, timer;
|
|
374
|
+
const ready = new Promise((resolve, reject) => {
|
|
375
|
+
timer = setTimeout(() => reject(new Error("unavailable")), 1000);
|
|
376
|
+
record.dispose = () => { clearTimeout(timer); close?.(); resolve(); };
|
|
377
|
+
const pending = ctx.ui.custom((tui, theme, _kb, done) => {
|
|
378
|
+
close = done;
|
|
379
|
+
if (!current()) done();
|
|
380
|
+
if (typeof tui.showOverlay !== "function" || typeof theme.inverse !== "function") throw new Error("unavailable");
|
|
381
|
+
record.tui = tui;
|
|
382
|
+
return new RarebitPane(tui, theme, ctx, mode, p, {
|
|
383
|
+
close: stop, toggle: () => void controller.toggle(ctx), cycle: () => void controller.cycle(ctx),
|
|
384
|
+
});
|
|
385
|
+
}, {
|
|
386
|
+
overlay: true,
|
|
387
|
+
overlayOptions: { row: 0, col: 0, width: "100%", maxHeight: "100%", nonCapturing: mode === "peek" },
|
|
388
|
+
onHandle: (handle) => {
|
|
389
|
+
if (!current()) { close?.(); return resolve(); }
|
|
390
|
+
if (typeof handle.isFocused !== "function" || (mode === "peek" && handle.isFocused())) return reject(new Error("unavailable"));
|
|
391
|
+
clearTimeout(timer);
|
|
392
|
+
markActive(record.tui);
|
|
393
|
+
resolve();
|
|
394
|
+
},
|
|
395
|
+
});
|
|
396
|
+
Promise.resolve(pending).then(() => { if (current()) stop(); resolve(); }, reject);
|
|
397
|
+
});
|
|
398
|
+
await ready;
|
|
399
|
+
} catch {
|
|
400
|
+
if (current()) { stop(); notify(ctx, "Rarebit view is unavailable on this Pi version"); }
|
|
401
|
+
}
|
|
402
|
+
};
|
|
403
|
+
const controller = {
|
|
404
|
+
get activeMode() { return active ?? "off"; },
|
|
405
|
+
async command(ctx, mode) {
|
|
406
|
+
if (mode === "off") return stop();
|
|
407
|
+
if (!mode) {
|
|
408
|
+
stop();
|
|
409
|
+
if (ctx?.mode !== "tui" || !ctx.hasUI) return;
|
|
410
|
+
const token = epoch;
|
|
411
|
+
const picked = await ctx.ui.select?.("Rarebit view", [
|
|
412
|
+
"all — All Rarebits across compactions", "peek — All Rarebits with the editor live",
|
|
413
|
+
"context — Current context only (experimental)", "off — Normal transcript",
|
|
414
|
+
]);
|
|
415
|
+
if (token !== epoch || !picked) return;
|
|
416
|
+
mode = picked.split(" ")[0];
|
|
417
|
+
}
|
|
418
|
+
if (MODES.includes(mode)) await activate(ctx, mode);
|
|
419
|
+
},
|
|
420
|
+
async toggle(ctx) { if (activation) stop(); else await activate(ctx, preferred); },
|
|
421
|
+
async cycle(ctx) { await activate(ctx, MODES[(MODES.indexOf(preferred) + 1) % MODES.length]); },
|
|
422
|
+
refresh() { activation?.tui?.requestRender(); },
|
|
423
|
+
dispose: stop,
|
|
424
|
+
};
|
|
425
|
+
return controller;
|
|
426
|
+
}
|
|
@@ -14,11 +14,11 @@ const values = (prefix) =>
|
|
|
14
14
|
test("one grammar derives command description and usage", () => {
|
|
15
15
|
assert.equal(
|
|
16
16
|
rarebitCommandDescription(),
|
|
17
|
-
"Rarebit menu/settings/help/status/recall/fork/config/auto-title/title/recap/got-it/summarize",
|
|
17
|
+
"Rarebit menu/settings/view/help/status/recall/fork/config/auto-title/title/recap/got-it/summarize",
|
|
18
18
|
);
|
|
19
19
|
assert.equal(
|
|
20
20
|
rarebitCommandUsage(),
|
|
21
|
-
"Usage: /rarebit [menu|settings|help|status|recall|fork|config|auto-title|title|recap|got-it|summarize]",
|
|
21
|
+
"Usage: /rarebit [menu|settings|view|help|status|recall|fork|config|auto-title|title|recap|got-it|summarize]",
|
|
22
22
|
);
|
|
23
23
|
assert.equal(
|
|
24
24
|
rarebitCommandUsage("config"),
|
|
@@ -44,6 +44,10 @@ test("parser accepts exactly the command grammar", () => {
|
|
|
44
44
|
subcommand: "menu",
|
|
45
45
|
arguments: [],
|
|
46
46
|
});
|
|
47
|
+
for (const input of ["view", "view context", "view all", "view peek", "view off"])
|
|
48
|
+
assert.equal(parseRarebitCommand(input).ok, true);
|
|
49
|
+
for (const input of ["view keys", "view native-hide", "view all extra"])
|
|
50
|
+
assert.equal(parseRarebitCommand(input).ok, false);
|
|
47
51
|
assert.equal(parseRarebitCommand("fork").ok, true);
|
|
48
52
|
assert.equal(parseRarebitCommand("fork --max-token-length 64000").ok, true);
|
|
49
53
|
assert.equal(parseRarebitCommand("config").ok, true);
|
|
@@ -97,6 +101,7 @@ test("autocomplete covers subcommands and discrete nested arguments", () => {
|
|
|
97
101
|
assert.deepEqual(values(""), [
|
|
98
102
|
"menu",
|
|
99
103
|
"settings",
|
|
104
|
+
"view",
|
|
100
105
|
"help",
|
|
101
106
|
"status",
|
|
102
107
|
"recall",
|
|
@@ -108,6 +113,9 @@ test("autocomplete covers subcommands and discrete nested arguments", () => {
|
|
|
108
113
|
"got-it",
|
|
109
114
|
"summarize",
|
|
110
115
|
]);
|
|
116
|
+
assert.deepEqual(values("view "), ["view context", "view all", "view peek", "view off"]);
|
|
117
|
+
assert.deepEqual(values("view p"), ["view peek"]);
|
|
118
|
+
assert.equal(values("view all "), null);
|
|
111
119
|
assert.deepEqual(values("s"), ["settings", "status", "summarize"]);
|
|
112
120
|
assert.deepEqual(values("config "), [
|
|
113
121
|
"config max_rarebit_ratio ",
|