pi-midcompact 0.3.0 → 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/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
  }