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/README.md +106 -40
- package/README.zh-CN.md +227 -0
- package/figures/review-tui.png +0 -0
- package/figures/review-webui.png +0 -0
- package/package.json +2 -1
- package/skills/midcompact/SKILL.md +1 -1
- package/src/index.ts +56 -28
- package/src/review-ui.ts +190 -147
- package/src/review-webui.html +846 -0
- package/src/review-webui.ts +212 -0
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>(
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
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
|
-
|
|
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
|
|
196
|
-
|
|
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
|
}
|