@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.
@@ -6,7 +6,7 @@ import { isAbsolute, join, resolve } from "node:path";
6
6
  import { selectRarebits } from "./rarebit-core.mjs";
7
7
 
8
8
  export const RAREBIT_RECALL_SCHEMA_VERSION = 1;
9
- export const RAREBIT_CONVERSATION_SCHEMA_VERSION = 1;
9
+ export const RAREBIT_CONVERSATION_SCHEMA_VERSION = 2;
10
10
  export const RAREBIT_RECALL_DETAILED_FILENAME = "rarebit-evidence.json";
11
11
  export const RAREBIT_RECALL_CONVERSATION_FILENAME = "rarebit-conversation.json";
12
12
 
@@ -52,16 +52,20 @@ function recallDocument({ sessionId, sessionFile, branch, selection, now }) {
52
52
  occurrenceCount: selection.occurrences.length,
53
53
  uniquePayloadCount: selection.payloads.length,
54
54
  occurrences: selection.occurrences.map(
55
- ({
56
- occurrenceId,
55
+ (
56
+ {
57
+ occurrenceId,
57
58
  sourceEntryId,
58
59
  order,
59
60
  timestamp,
60
61
  role,
61
62
  outcome,
62
- contentHash,
63
- text,
64
- }) => ({
63
+ contentHash,
64
+ text,
65
+ },
66
+ index,
67
+ ) => ({
68
+ seq: index + 1,
65
69
  occurrenceId,
66
70
  sourceEntryId,
67
71
  order,
@@ -76,37 +80,45 @@ function recallDocument({ sessionId, sessionFile, branch, selection, now }) {
76
80
  };
77
81
  }
78
82
 
79
- function conversationHour(timestamp) {
83
+ // Second precision matches jq `todate`, so `.time >= (now - N | todate)` and
84
+ // `fromdate` work without parsing fractional seconds.
85
+ function conversationTime(timestamp) {
80
86
  if (typeof timestamp !== "string") return null;
81
87
  const value = new Date(timestamp);
82
88
  if (Number.isNaN(value.getTime())) return null;
83
- return `${value.toISOString().slice(0, 13)}:00Z`;
89
+ return `${value.toISOString().slice(0, 19)}Z`;
84
90
  }
85
91
 
92
+ const CONVERSATION_KIND = Object.freeze({
93
+ user: "request",
94
+ continuation: "progress",
95
+ stop: "reply",
96
+ });
97
+
98
+ /**
99
+ * One flat, branch-ordered message list. `seq` matches the detailed
100
+ * occurrence `seq`. A round starts at each user message, so tail slices by
101
+ * message, role, kind, round, or time are single jq filters.
102
+ */
86
103
  function conversationDocument(selection) {
87
- const byHour = new Map();
88
- for (const occurrence of selection.occurrences) {
89
- const hour = conversationHour(occurrence.timestamp);
90
- let bucket = byHour.get(hour);
91
- if (!bucket) {
92
- bucket = { hour, messages: [] };
93
- byHour.set(hour, bucket);
94
- }
95
- bucket.messages.push({
96
- role: occurrence.role === "user" ? "user" : "agent",
97
- content: occurrence.text,
98
- });
99
- }
100
- const hours = [...byHour.values()].sort((left, right) => {
101
- if (left.hour === null) return right.hour === null ? 0 : 1;
102
- if (right.hour === null) return -1;
103
- return left.hour.localeCompare(right.hour);
104
+ let round = 0;
105
+ const messages = selection.occurrences.map((occurrence, index) => {
106
+ if (occurrence.outcome === "user") round += 1;
107
+ return {
108
+ seq: index + 1,
109
+ round,
110
+ role: occurrence.outcome === "user" ? "user" : "agent",
111
+ kind: CONVERSATION_KIND[occurrence.outcome],
112
+ time: conversationTime(occurrence.timestamp),
113
+ text: occurrence.text,
114
+ };
104
115
  });
105
116
  return {
106
117
  schemaVersion: RAREBIT_CONVERSATION_SCHEMA_VERSION,
107
118
  type: "rarebit_conversation",
108
- timeZone: "UTC",
109
- hours,
119
+ messageCount: messages.length,
120
+ lastRound: round,
121
+ messages,
110
122
  };
111
123
  }
112
124
 
@@ -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 ",