@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/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: (ctx, generation) => {
483
- recapController.invalidate(ctx, generation);
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;
@@ -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",
@@ -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 ",