pi-midcompact 0.2.1 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/index.ts CHANGED
@@ -6,6 +6,7 @@ import {
6
6
  type ExtensionContext,
7
7
  type SessionEntry,
8
8
  } from "@earendil-works/pi-coding-agent";
9
+ import type { AutocompleteItem } from "@earendil-works/pi-tui";
9
10
 
10
11
  import { buildAtoms, formatLocatedAtom, locateAtoms } from "./atoms.js";
11
12
  import { messageText } from "./messages.js";
@@ -13,6 +14,7 @@ import { addDraftRange, emptyDraft, formatDraft, removeDraftRange, updateDraftRa
13
14
  import { projectMessages } from "./projection.js";
14
15
  import { registerStateRenderer, stateTreeLabel } from "./renderers.js";
15
16
  import { showReviewUi } from "./review-ui.js";
17
+ import { showReviewWebUi } from "./review-webui.js";
16
18
  import {
17
19
  DRAFT_ENTRY,
18
20
  STATE_ENTRY,
@@ -28,6 +30,7 @@ import type {
28
30
  CompressionBlock,
29
31
  CompressionState,
30
32
  DraftPlan,
33
+ DraftTelemetry,
31
34
  MessageLike,
32
35
  TransactionState,
33
36
  } from "./types.js";
@@ -89,9 +92,24 @@ export default function (pi: ExtensionAPI) {
89
92
 
90
93
  pi.registerCommand("midcompact", {
91
94
  description: "Start, review, commit, inspect, or abort a branch-isolated mid-context compression transaction",
95
+ getArgumentCompletions(prefix: string): AutocompleteItem[] | null {
96
+ const query = prefix.trimStart().toLowerCase();
97
+ if (/\s/.test(query)) return null;
98
+ const items: AutocompleteItem[] = [
99
+ { value: "start", label: "start — Start a new midcompact transaction at the current anchor" },
100
+ { value: "abort", label: "abort — Abort the active transaction and return to anchor" },
101
+ { value: "commit", label: "commit — Commit the current draft to the branch state" },
102
+ { value: "review", label: "review — Open interactive TUI to inspect and edit the draft" },
103
+ { value: "review-webui", label: "review-webui — Open a local web page to inspect and edit the draft (works without TUI)" },
104
+ { value: "status", label: "status — Show current transaction and draft status" },
105
+ ];
106
+ const filtered = items.filter((item) => item.value.startsWith(query));
107
+ return filtered.length > 0 ? filtered : null;
108
+ },
92
109
  handler: async (args: string, ctx: ExtensionCommandContext) => {
93
110
  await ctx.waitForIdle();
94
- const sub = args.trim().toLowerCase();
111
+ const rawArgs = args.trim();
112
+ const sub = rawArgs.toLowerCase();
95
113
 
96
114
  if (sub === "abort") {
97
115
  const restored = restoreTransaction(ctx.sessionManager.getBranch() as SessionEntry[]);
@@ -144,7 +162,12 @@ export default function (pi: ExtensionAPI) {
144
162
  }
145
163
 
146
164
  if (sub === "review") {
147
- await reviewTransaction(ctx);
165
+ await reviewTransaction(ctx, "tui");
166
+ return;
167
+ }
168
+
169
+ if (sub === "review-webui") {
170
+ await reviewTransaction(ctx, "web");
148
171
  return;
149
172
  }
150
173
 
@@ -164,8 +187,12 @@ export default function (pi: ExtensionAPI) {
164
187
  return;
165
188
  }
166
189
 
167
- if (sub) {
168
- ctx.ui.notify("Usage: /midcompact | /midcompact review | /midcompact commit | /midcompact status | /midcompact abort", "warning");
190
+ const startMatch = rawArgs.match(/^start\s+/i);
191
+ const isStart = sub === "start" || !!startMatch;
192
+ const customInstructions = startMatch ? rawArgs.slice(startMatch[0].length).trim() : undefined;
193
+
194
+ if (!isStart) {
195
+ ctx.ui.notify("Usage: /midcompact start [instructions] | /midcompact review | /midcompact commit | /midcompact status | /midcompact abort", "warning");
169
196
  return;
170
197
  }
171
198
 
@@ -179,6 +206,16 @@ export default function (pi: ExtensionAPI) {
179
206
  ctx.ui.notify("Cannot start midcompact without a session leaf.", "error");
180
207
  return;
181
208
  }
209
+ if (ctx.hasUI) {
210
+ const ok = await ctx.ui.confirm(
211
+ "Start midcompact transaction?",
212
+ "The current context snapshot will be frozen as an anchor. You will need to review and explicitly commit (/midcompact commit) or abort (/midcompact abort) later.",
213
+ );
214
+ if (!ok) {
215
+ ctx.ui.notify("Midcompact start cancelled.", "info");
216
+ return;
217
+ }
218
+ }
182
219
  transaction = {
183
220
  version: 1,
184
221
  id: `tx-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 7)}`,
@@ -192,13 +229,19 @@ export default function (pi: ExtensionAPI) {
192
229
  updateStatus(ctx, transaction, draft);
193
230
  const awareness = formatTelemetry(draftTelemetry(transaction, draft));
194
231
  ctx.ui.notify(`Midcompact started at anchor ${anchorEntryId}. ${compactUsage(transaction)}`, "info");
195
- pi.sendUserMessage([
232
+ const promptLines = [
196
233
  "A mid-compaction transaction is active on a frozen anchor snapshot.",
197
234
  awareness,
198
235
  "These numbers are context awareness, not a target or optimization constraint. Use them to judge the scale of proposed compression while preserving semantic value.",
236
+ ];
237
+ if (customInstructions) {
238
+ promptLines.push(`User focus: ${customInstructions}`);
239
+ }
240
+ promptLines.push(
199
241
  "Load the `midcompact` skill, use the `midcompact` tool to locate and draft compression ranges, and present the draft for user review.",
200
242
  "The Agent cannot commit. The user can inspect `/midcompact review`, then explicitly run `/midcompact commit` when satisfied.",
201
- ].join("\n"));
243
+ );
244
+ pi.sendUserMessage(promptLines.join("\n"));
202
245
  },
203
246
  });
204
247
 
@@ -210,7 +253,7 @@ export default function (pi: ExtensionAPI) {
210
253
  async execute(_id: string, params: ParamsType, _signal: AbortSignal | undefined, _onUpdate: unknown, ctx: ExtensionContext) {
211
254
  try {
212
255
  if (params.action === "recall") return toolResult(handleRecall(params, ctx));
213
- if (!transaction) return toolResult("No active midcompact transaction. Ask the user to run `/midcompact` first.");
256
+ if (!transaction) return toolResult("No active midcompact transaction. Ask the user to run `/midcompact start` first.");
214
257
  const snapshot = buildAnchorSnapshot(ctx.sessionManager, transaction);
215
258
  if (params.action === "locate") return toolResult(handleLocate(params, snapshot.atoms));
216
259
  if (params.action === "plan") {
@@ -227,49 +270,69 @@ export default function (pi: ExtensionAPI) {
227
270
  },
228
271
  });
229
272
 
230
- async function reviewTransaction(ctx: ExtensionCommandContext): Promise<void> {
273
+ async function reviewTransaction(ctx: ExtensionCommandContext, mode: "tui" | "web" = "tui"): Promise<void> {
274
+ const restored = restoreTransaction(ctx.sessionManager.getBranch() as SessionEntry[]);
275
+ const currentTx = restored.transaction ?? transaction;
276
+ const currentDraft = restored.draft ?? draft;
277
+ if (!currentTx) {
278
+ ctx.ui.notify("No active midcompact transaction.", "warning");
279
+ return;
280
+ }
281
+ const currentPlan = currentDraft ?? emptyDraft(currentTx.id);
282
+ const snapshot = buildAnchorSnapshot(ctx.sessionManager, currentTx);
283
+
284
+ const commitMutation = (next: DraftPlan): void => {
285
+ draft = next;
286
+ pi.appendEntry(DRAFT_ENTRY, next);
287
+ updateStatus(ctx, currentTx, next);
288
+ };
289
+
290
+ if (mode === "web") {
291
+ const getLatest = (): { draft: DraftPlan; telemetry: DraftTelemetry } => ({
292
+ draft: draft ?? emptyDraft(currentTx.id),
293
+ telemetry: draftTelemetry(currentTx, draft),
294
+ });
295
+ await showReviewWebUi(ctx, snapshot.atoms, getLatest, {
296
+ editSummary: (id, summary) => commitMutation(updateDraftRange(draft ?? emptyDraft(currentTx.id), id, { summary })),
297
+ editTopic: (id, topic) => commitMutation(updateDraftRange(draft ?? emptyDraft(currentTx.id), id, { topic: topic || undefined })),
298
+ remove: (id) => commitMutation(removeDraftRange(draft ?? emptyDraft(currentTx.id), id)),
299
+ });
300
+ ctx.ui.notify("Midcompact review-webui closed.", "info");
301
+ return;
302
+ }
303
+
304
+ if (ctx.mode !== "tui") {
305
+ ctx.ui.notify(
306
+ "Interactive TUI review is only available in interactive (tui) mode. Use /midcompact review-webui to open a local web page instead.",
307
+ "warning",
308
+ );
309
+ return;
310
+ }
311
+
231
312
  while (true) {
232
- const restored = restoreTransaction(ctx.sessionManager.getBranch() as SessionEntry[]);
233
- const currentTx = restored.transaction ?? transaction;
234
- const currentDraft = restored.draft ?? draft;
235
- if (!currentTx) {
236
- ctx.ui.notify("No active midcompact transaction.", "warning");
237
- return;
238
- }
239
- const currentPlan = currentDraft ?? emptyDraft(currentTx.id);
240
- const snapshot = buildAnchorSnapshot(ctx.sessionManager, currentTx);
241
- const telemetry = draftTelemetry(currentTx, currentPlan);
242
- if (ctx.mode !== "tui") {
243
- ctx.ui.notify(formatDraft(currentPlan, telemetry), "info");
244
- return;
245
- }
246
- const action = await showReviewUi(ctx, snapshot.atoms, currentPlan, telemetry);
313
+ const plan = draft ?? emptyDraft(currentTx.id);
314
+ const telemetry = draftTelemetry(currentTx, plan);
315
+ const action = await showReviewUi(ctx, snapshot.atoms, plan, telemetry);
247
316
  if (action.action === "close") return;
248
- const range = currentPlan.ranges.find((candidate) => candidate.id === action.draftId);
317
+ const range = plan.ranges.find((candidate) => candidate.id === action.draftId);
249
318
  if (!range) continue;
250
319
 
251
320
  if (action.action === "edit-summary") {
252
321
  const value = await ctx.ui.editor(`Edit ${range.id} summary`, range.summary);
253
322
  if (value === undefined || !value.trim()) continue;
254
- draft = updateDraftRange(currentPlan, range.id, { summary: value.trim() });
255
- pi.appendEntry(DRAFT_ENTRY, draft);
256
- updateStatus(ctx, currentTx, draft);
323
+ commitMutation(updateDraftRange(plan, range.id, { summary: value.trim() }));
257
324
  continue;
258
325
  }
259
326
  if (action.action === "edit-topic") {
260
327
  const value = await ctx.ui.input(`Edit ${range.id} topic`, range.topic ?? "");
261
328
  if (value === undefined) continue;
262
- draft = updateDraftRange(currentPlan, range.id, { topic: value.trim() || undefined });
263
- pi.appendEntry(DRAFT_ENTRY, draft);
264
- updateStatus(ctx, currentTx, draft);
329
+ commitMutation(updateDraftRange(plan, range.id, { topic: value.trim() || undefined }));
265
330
  continue;
266
331
  }
267
332
  if (action.action === "remove") {
268
333
  const approved = await ctx.ui.confirm("Remove compression range?", `${range.id}: ${range.startRef} → ${range.endRef}`);
269
334
  if (!approved) continue;
270
- draft = removeDraftRange(currentPlan, range.id);
271
- pi.appendEntry(DRAFT_ENTRY, draft);
272
- updateStatus(ctx, currentTx, draft);
335
+ commitMutation(removeDraftRange(plan, range.id));
273
336
  }
274
337
  }
275
338
  }
package/src/review-ui.ts CHANGED
@@ -4,6 +4,12 @@ import { Key, matchesKey, truncateToWidth, wrapTextWithAnsi } from "@earendil-wo
4
4
  import { formatPercent, formatTokenCount } from "./telemetry.js";
5
5
  import type { Atom, DraftPlan, DraftRange, DraftTelemetry, ReviewAction } from "./types.js";
6
6
 
7
+ /**
8
+ * Interactive TUI review. Rendered as a centered overlay (not a full-height
9
+ * editor replacement) so it never flattens the chat transcript. Ranges fold
10
+ * into single header lines; the selected range expands inline with its detail
11
+ * and edit affordances, removing the duplicate top "Selected" block.
12
+ */
7
13
  export async function showReviewUi(
8
14
  ctx: ExtensionCommandContext,
9
15
  atoms: Atom[],
@@ -12,148 +18,180 @@ export async function showReviewUi(
12
18
  ): Promise<ReviewAction> {
13
19
  if (ctx.mode !== "tui") return { action: "close" };
14
20
 
15
- return ctx.ui.custom<ReviewAction>((tui, theme, _keybindings, done) => {
16
- let scrollOffset = 0;
17
- let selectedRange = draft.ranges.length ? 0 : -1;
18
- let expandSelected = false;
19
- let jumpToSelected = true;
20
-
21
- const selected = (): DraftRange | undefined => selectedRange >= 0 ? draft.ranges[selectedRange] : undefined;
22
-
23
- const component = {
24
- render(width: number): string[] {
25
- const w = Math.max(30, width);
26
- const rows = Math.max(12, tui.terminal.rows);
27
- const border = (text: string) => theme.fg("border", text);
28
- const dim = (text: string) => theme.fg("dim", text);
29
- const accent = (text: string) => theme.fg("accent", text);
30
- const success = (text: string) => theme.fg("success", text);
31
- const warning = (text: string) => theme.fg("warning", text);
32
- const bodyWidth = Math.max(20, w - 4);
33
- const framed = (text: string) => `${border("")} ${truncateToWidth(text, bodyWidth, "…", true)} ${border("│")}`;
34
- const horizontal = (left: string, mid = "─", right = "─") => border(`${left}${mid.repeat(Math.max(0, w - 2))}${right}`);
35
-
36
- const header: string[] = [];
37
- header.push(horizontal("", "", ""));
38
- header.push(framed(accent(theme.bold(`Midcompact Review · Draft v${draft.revision}`))));
39
- header.push(framed(formatUsageLine(telemetry, theme)));
40
- header.push(framed(dim("This is awareness, not a target. Review the linear anchor snapshot before /midcompact commit.")));
41
- header.push(horizontal("├", "─", "┤"));
42
-
43
- const range = selected();
44
- const selectedInfo: string[] = [];
45
- if (range) {
46
- selectedInfo.push(framed(`${accent("Selected")} ${range.id} ${range.startRef} → ${range.endRef}${range.topic ? ` ${range.topic}` : ""}`));
47
- selectedInfo.push(framed(dim(`~${formatTokenCount(range.originalApproxTokens)} → ~${formatTokenCount(range.compressedApproxTokens)} tokens`)));
48
- for (const line of wrapTextWithAnsi(`${accent("Summary:")} ${range.summary}`, bodyWidth)) selectedInfo.push(framed(line));
49
- selectedInfo.push(horizontal("├", "─", "┤"));
50
- }
51
-
52
- const body: string[] = [];
53
- const atomLineStarts = new Map<number, number>();
54
- for (const atom of atoms) {
55
- atomLineStarts.set(atom.index, body.length);
56
- const owner = owningRange(atom.index, draft.ranges);
57
- const isSelected = Boolean(range && owner?.id === range.id);
58
- const isRangeStart = owner?.startIndex === atom.index;
59
- const isRangeEnd = owner?.endIndex === atom.index;
60
- const rangeMark = owner ? (isRangeStart ? "┌" : isRangeEnd ? "└" : "│") : " ";
61
- const selectionMark = isSelected ? "▶" : " ";
62
- const policy = owner ? warning(owner.id) : success("KEEP");
63
- const token = dim(`~${formatTokenCount(atom.approxTokens)}`);
64
- const oneLine = firstLine(atom.preview);
65
- body.push(framed(`${selectionMark}${rangeMark} ${policy} ${atom.ref} [${atom.kind}] ${token} ${oneLine}`));
66
- if (expandSelected && isSelected) {
67
- const detail = wrapTextWithAnsi(atom.preview, Math.max(10, bodyWidth - 4));
68
- for (const detailLine of detail.slice(1, 8)) body.push(framed(dim(` ${detailLine}`)));
21
+ return ctx.ui.custom<ReviewAction>(
22
+ (tui, theme, _keybindings, done) => {
23
+ let scrollOffset = 0;
24
+ let selectedRange = draft.ranges.length ? 0 : -1;
25
+ let expandSelected = true;
26
+ let jumpToSelected = true;
27
+
28
+ const selected = (): DraftRange | undefined =>
29
+ selectedRange >= 0 ? draft.ranges[selectedRange] : undefined;
30
+
31
+ const component = {
32
+ render(width: number): string[] {
33
+ const w = Math.max(40, width);
34
+ // Conservative height budget: stay under the overlay's maxHeight so
35
+ // the overlay never clips and the chat transcript is not crushed.
36
+ const totalBudget = Math.max(18, Math.floor(tui.terminal.rows * 0.8));
37
+ const border = (text: string) => theme.fg("border", text);
38
+ const dim = (text: string) => theme.fg("dim", text);
39
+ const accent = (text: string) => theme.fg("accent", text);
40
+ const success = (text: string) => theme.fg("success", text);
41
+ const warning = (text: string) => theme.fg("warning", text);
42
+ const inner = Math.max(20, w - 4);
43
+ const framed = (text: string) => `${border("")} ${truncateToWidth(text, inner, "", true)} ${border("")}`;
44
+ const h = (l: string, m = "─", r = "─") => border(`${l}${m.repeat(Math.max(0, w - 2))}${r}`);
45
+
46
+ const range = selected();
47
+
48
+ const header: string[] = [
49
+ h("╭"),
50
+ framed(accent(theme.bold(`Midcompact Review · Draft v${draft.revision} · ${draft.ranges.length} range(s)`))),
51
+ framed(usageLine(telemetry, theme)),
52
+ h(""),
53
+ ];
54
+
55
+ // Timeline: KEEP atoms always shown; ranges fold to a header line,
56
+ // except the selected range which expands with detail + its atoms.
57
+ const body: string[] = [];
58
+ const rangeStarts = new Map(draft.ranges.map((r) => [r.startIndex, r]));
59
+ const rangeLineStarts = new Map<string, number>();
60
+
61
+ for (const atom of atoms) {
62
+ const head = rangeStarts.get(atom.index);
63
+ if (head) {
64
+ rangeLineStarts.set(head.id, body.length);
65
+ const isSel = Boolean(range && range.id === head.id);
66
+ const save = Math.max(0, head.originalApproxTokens - head.compressedApproxTokens);
67
+ const atomCount = head.endIndex - head.startIndex + 1;
68
+ const caret = isSel ? (expandSelected ? "▾" : "▸") : "";
69
+ const caretCol = isSel ? accent(caret) : dim(caret);
70
+ const idCol = isSel ? theme.bold(warning(head.id)) : warning(head.id);
71
+ const span = dim(`${head.startRef}→${head.endRef} · ${atomCount} atoms`);
72
+ const tok = dim(`~${formatTokenCount(head.originalApproxTokens)}→~${formatTokenCount(head.compressedApproxTokens)}`);
73
+ const saveCol = success(`save ~${formatTokenCount(save)}`);
74
+ const topicCol = head.topic ? `${accent(head.topic)} ` : "";
75
+ const sumCol = dim(firstLine(head.summary, 48));
76
+ const headText = `${caretCol} ${idCol} ${span} ${tok} ${saveCol} ${topicCol}${sumCol}`;
77
+ body.push(framed(isSel ? accent(headText) : headText));
78
+
79
+ if (isSel) {
80
+ body.push(framed(dim(` topic: ${head.topic ?? "—"}`)));
81
+ body.push(framed(dim(` tokens: ~${formatTokenCount(head.originalApproxTokens)} → ~${formatTokenCount(head.compressedApproxTokens)} (save ~${formatTokenCount(save)})`)));
82
+ const wrapWidth = Math.max(10, inner - 6);
83
+ for (const line of wrapTextWithAnsi(`${accent("summary:")} ${head.summary}`, wrapWidth)) {
84
+ body.push(framed(` ${line}`));
85
+ }
86
+ body.push(framed(dim(` [e] edit summary · [t] edit topic · [d] remove · [x] ${expandSelected ? "collapse" : "expand"} atoms`)));
87
+ }
88
+ }
89
+
90
+ const owner = owningRange(atom.index, draft.ranges);
91
+ const showAtom = !owner || (Boolean(range) && owner.id === range!.id && expandSelected);
92
+ if (!showAtom) continue;
93
+
94
+ const mark = owner
95
+ ? atom.index === owner.startIndex
96
+ ? "┌"
97
+ : atom.index === owner.endIndex
98
+ ? "└"
99
+ : "│"
100
+ : " ";
101
+ const policy = owner ? warning(owner.id) : success("KEEP");
102
+ const tok = dim(`~${formatTokenCount(atom.approxTokens)}`);
103
+ const oneLine = dim(firstLine(atom.preview, 80));
104
+ body.push(framed(`${mark} ${policy} ${atom.ref} [${atom.kind}] ${tok} ${oneLine}`));
105
+ }
106
+ if (!body.length) body.push(framed(dim("(No atoms in anchor snapshot.)")));
107
+
108
+ const footer: string[] = [
109
+ h("├"),
110
+ framed(dim(`ranges ${hint("n/p", theme)} · scroll ${hint("↑↓ PgUp PgDn", theme)} · ${hint("Esc", theme)} close`)),
111
+ framed(dim(`selected: ${hint("e", theme)} summary · ${hint("t", theme)} topic · ${hint("d", theme)} remove · ${hint("x", theme)} expand`)),
112
+ h("╰"),
113
+ ];
114
+
115
+ const viewportHeight = Math.max(5, totalBudget - header.length - footer.length);
116
+ if (jumpToSelected && range) {
117
+ const target = rangeLineStarts.get(range.id) ?? 0;
118
+ scrollOffset = Math.max(0, target - 2);
119
+ jumpToSelected = false;
69
120
  }
70
- }
71
- if (!body.length) body.push(framed(dim("(No atoms in anchor snapshot.)")));
72
-
73
- const footerRows = 4;
74
- const viewportHeight = Math.max(5, rows - header.length - selectedInfo.length - footerRows - 2);
75
- if (jumpToSelected && range) {
76
- scrollOffset = atomLineStarts.get(range.startIndex) ?? scrollOffset;
77
- jumpToSelected = false;
78
- }
79
- const maxOffset = Math.max(0, body.length - viewportHeight);
80
- scrollOffset = Math.max(0, Math.min(scrollOffset, maxOffset));
81
- const visible = body.slice(scrollOffset, scrollOffset + viewportHeight);
82
- while (visible.length < viewportHeight) visible.push(framed(""));
83
-
84
- const top = body.length ? scrollOffset + 1 : 0;
85
- const bottom = Math.min(body.length, scrollOffset + viewportHeight);
86
- const footer = [
87
- horizontal("├", "─", "┤"),
88
- framed(dim(`Lines ${top}-${bottom} of ${body.length} · n/p select range · ↑↓/PgUp/PgDn scroll · x expand selected`)),
89
- framed(dim("e edit summary · t edit topic · d remove selected range · Enter/Esc close")),
90
- horizontal("", "─", "╯"),
91
- ];
92
- return [...header, ...selectedInfo, ...visible, ...footer];
93
- },
94
- invalidate(): void {},
95
- handleInput(data: string): void {
96
- if (matchesKey(data, Key.escape) || matchesKey(data, Key.enter) || data === "q") {
97
- done({ action: "close" });
98
- return;
99
- }
100
- if ((data === "n" || matchesKey(data, Key.right)) && draft.ranges.length) {
101
- selectedRange = (selectedRange + 1 + draft.ranges.length) % draft.ranges.length;
102
- jumpToSelected = true;
103
- tui.requestRender();
104
- return;
105
- }
106
- if ((data === "p" || matchesKey(data, Key.left)) && draft.ranges.length) {
107
- selectedRange = (selectedRange - 1 + draft.ranges.length) % draft.ranges.length;
108
- jumpToSelected = true;
109
- tui.requestRender();
110
- return;
111
- }
112
- if (matchesKey(data, Key.up) || data === "k") {
113
- scrollOffset = Math.max(0, scrollOffset - 1);
114
- tui.requestRender();
115
- return;
116
- }
117
- if (matchesKey(data, Key.down) || data === "j") {
118
- scrollOffset += 1;
119
- tui.requestRender();
120
- return;
121
- }
122
- if (matchesKey(data, Key.pageUp)) {
123
- scrollOffset = Math.max(0, scrollOffset - 12);
124
- tui.requestRender();
125
- return;
126
- }
127
- if (matchesKey(data, Key.pageDown)) {
128
- scrollOffset += 12;
129
- tui.requestRender();
130
- return;
131
- }
132
- if (matchesKey(data, Key.home)) {
133
- scrollOffset = 0;
134
- tui.requestRender();
135
- return;
136
- }
137
- if (matchesKey(data, Key.end)) {
138
- scrollOffset = Number.MAX_SAFE_INTEGER;
139
- tui.requestRender();
140
- return;
141
- }
142
- if (data === "x") {
143
- expandSelected = !expandSelected;
144
- jumpToSelected = true;
145
- tui.requestRender();
146
- return;
147
- }
148
- const range = selected();
149
- if (!range) return;
150
- if (data === "e") done({ action: "edit-summary", draftId: range.id });
151
- else if (data === "t") done({ action: "edit-topic", draftId: range.id });
152
- else if (data === "d") done({ action: "remove", draftId: range.id });
153
- },
154
- };
155
- return component;
156
- });
121
+ const maxOffset = Math.max(0, body.length - viewportHeight);
122
+ scrollOffset = Math.max(0, Math.min(scrollOffset, maxOffset));
123
+ const visible = body.slice(scrollOffset, scrollOffset + viewportHeight);
124
+ while (visible.length < viewportHeight) visible.push(framed(""));
125
+
126
+ return [...header, ...visible, ...footer];
127
+ },
128
+ invalidate(): void {},
129
+ handleInput(data: string): void {
130
+ if (matchesKey(data, Key.escape) || matchesKey(data, Key.enter) || data === "q") {
131
+ done({ action: "close" });
132
+ return;
133
+ }
134
+ if ((data === "n" || matchesKey(data, Key.right)) && draft.ranges.length) {
135
+ selectedRange = (selectedRange + 1 + draft.ranges.length) % draft.ranges.length;
136
+ expandSelected = true;
137
+ jumpToSelected = true;
138
+ tui.requestRender();
139
+ return;
140
+ }
141
+ if ((data === "p" || matchesKey(data, Key.left)) && draft.ranges.length) {
142
+ selectedRange = (selectedRange - 1 + draft.ranges.length) % draft.ranges.length;
143
+ expandSelected = true;
144
+ jumpToSelected = true;
145
+ tui.requestRender();
146
+ return;
147
+ }
148
+ if (matchesKey(data, Key.up) || data === "k") {
149
+ scrollOffset = Math.max(0, scrollOffset - 1);
150
+ tui.requestRender();
151
+ return;
152
+ }
153
+ if (matchesKey(data, Key.down) || data === "j") {
154
+ scrollOffset += 1;
155
+ tui.requestRender();
156
+ return;
157
+ }
158
+ if (matchesKey(data, Key.pageUp)) {
159
+ scrollOffset = Math.max(0, scrollOffset - 12);
160
+ tui.requestRender();
161
+ return;
162
+ }
163
+ if (matchesKey(data, Key.pageDown)) {
164
+ scrollOffset += 12;
165
+ tui.requestRender();
166
+ return;
167
+ }
168
+ if (matchesKey(data, Key.home)) {
169
+ scrollOffset = 0;
170
+ tui.requestRender();
171
+ return;
172
+ }
173
+ if (matchesKey(data, Key.end)) {
174
+ scrollOffset = Number.MAX_SAFE_INTEGER;
175
+ tui.requestRender();
176
+ return;
177
+ }
178
+ if (data === "x") {
179
+ expandSelected = !expandSelected;
180
+ jumpToSelected = true;
181
+ tui.requestRender();
182
+ return;
183
+ }
184
+ const range = selected();
185
+ if (!range) return;
186
+ if (data === "e") done({ action: "edit-summary", draftId: range.id });
187
+ else if (data === "t") done({ action: "edit-topic", draftId: range.id });
188
+ else if (data === "d") done({ action: "remove", draftId: range.id });
189
+ },
190
+ };
191
+ return component;
192
+ },
193
+ { overlay: true, overlayOptions: { width: "92%", maxHeight: "86%", anchor: "center" } },
194
+ );
157
195
  }
158
196
 
159
197
  export function buildReviewText(atoms: Atom[], draft: DraftPlan, telemetry: DraftTelemetry): string {
@@ -165,7 +203,7 @@ export function buildReviewText(atoms: Atom[], draft: DraftPlan, telemetry: Draf
165
203
  ];
166
204
  for (const atom of atoms) {
167
205
  const owner = owningRange(atom.index, draft.ranges);
168
- lines.push(`${owner ? owner.id : "KEEP"} ${atom.ref} [${atom.kind}] ~${formatTokenCount(atom.approxTokens)} ${firstLine(atom.preview)}`);
206
+ lines.push(`${owner ? owner.id : "KEEP"} ${atom.ref} [${atom.kind}] ~${formatTokenCount(atom.approxTokens)} ${firstLine(atom.preview, 120)}`);
169
207
  }
170
208
  if (draft.ranges.length) {
171
209
  lines.push("", "Proposed summaries:");
@@ -178,8 +216,14 @@ function owningRange(atomIndex: number, ranges: DraftRange[]): DraftRange | unde
178
216
  return ranges.find((range) => atomIndex >= range.startIndex && atomIndex <= range.endIndex);
179
217
  }
180
218
 
181
- function firstLine(text: string): string {
182
- return text.replace(/\s+/g, " ").trim();
219
+ function firstLine(text: string, limit = 120): string {
220
+ const normalized = text.replace(/\s+/g, " ").trim();
221
+ if (normalized.length <= limit) return normalized;
222
+ return `${normalized.slice(0, Math.max(0, limit - 1))}…`;
223
+ }
224
+
225
+ function hint(text: string, theme: ExtensionCommandContext["ui"]["theme"]): string {
226
+ return theme.fg("accent", text);
183
227
  }
184
228
 
185
229
  function plainUsageLine(telemetry: DraftTelemetry): string {
@@ -192,7 +236,6 @@ function plainUsageLine(telemetry: DraftTelemetry): string {
192
236
  return `Anchor ${anchor} · Draft selected ~${formatTokenCount(telemetry.selectedOriginalApproxTokens)}→~${formatTokenCount(telemetry.selectedCompressedApproxTokens)} · Projected ${projected}`;
193
237
  }
194
238
 
195
- function formatUsageLine(telemetry: DraftTelemetry, theme: ExtensionCommandContext["ui"]["theme"]): string {
196
- const label = theme.fg("accent", "Context");
197
- return `${label}: ${plainUsageLine(telemetry)}`;
239
+ function usageLine(telemetry: DraftTelemetry, theme: ExtensionCommandContext["ui"]["theme"]): string {
240
+ return `${theme.fg("accent", "Context")}: ${plainUsageLine(telemetry)}`;
198
241
  }