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/README.md +115 -43
- package/README.zh-CN.md +227 -0
- package/figures/review-tui.png +0 -0
- package/figures/review-webui.png +0 -0
- package/figures/transaction-lifecycle.svg +1 -1
- package/package.json +2 -1
- package/skills/midcompact/SKILL.md +91 -31
- package/src/index.ts +96 -33
- package/src/review-ui.ts +190 -147
- package/src/review-webui.html +846 -0
- package/src/review-webui.ts +212 -0
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
|
|
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
|
-
|
|
168
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
233
|
-
const
|
|
234
|
-
const
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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>(
|
|
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
|
}
|