pi-midcompact 0.3.0 → 0.5.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 +117 -42
- package/README.zh-CN.md +236 -0
- package/figures/review-tui.png +0 -0
- package/figures/review-webui.png +0 -0
- package/package.json +3 -2
- package/skills/midcompact/SKILL.md +87 -72
- package/skills/midcompact/references/tool-interface.md +83 -0
- package/src/atoms.ts +31 -7
- package/src/content-metrics.ts +185 -0
- package/src/index.ts +526 -207
- package/src/inventory.ts +295 -0
- package/src/messages.ts +56 -1
- package/src/plan.ts +176 -20
- package/src/planning-lock.ts +42 -0
- package/src/projection.ts +43 -1
- package/src/renderers.ts +16 -19
- package/src/review-ui.ts +199 -155
- package/src/review-webui.html +1063 -0
- package/src/review-webui.ts +317 -0
- package/src/selection-ui.ts +220 -0
- package/src/selection.ts +95 -0
- package/src/start-ui.ts +57 -0
- package/src/state.ts +39 -2
- package/src/telemetry.ts +48 -18
- package/src/types.ts +141 -2
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
import http from "node:http";
|
|
2
|
+
import { exec } from "node:child_process";
|
|
3
|
+
import { randomBytes } from "node:crypto";
|
|
4
|
+
import { readFileSync } from "node:fs";
|
|
5
|
+
|
|
6
|
+
import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
7
|
+
|
|
8
|
+
import type { Atom, DraftPlan, DraftTelemetry, SelectionSpan } from "./types.js";
|
|
9
|
+
|
|
10
|
+
const HTML_TEMPLATE = readFileSync(new URL("review-webui.html", import.meta.url), "utf8");
|
|
11
|
+
|
|
12
|
+
export type ReviewWebUiView = "review" | "selection";
|
|
13
|
+
|
|
14
|
+
export interface ReviewState {
|
|
15
|
+
view: ReviewWebUiView;
|
|
16
|
+
atoms: Array<{
|
|
17
|
+
ref: string;
|
|
18
|
+
index: number;
|
|
19
|
+
groupRef: string;
|
|
20
|
+
groupLabel: string;
|
|
21
|
+
kind: string;
|
|
22
|
+
preview: string;
|
|
23
|
+
contentChars: number;
|
|
24
|
+
imageCount: number;
|
|
25
|
+
imagePayloadBytes: number;
|
|
26
|
+
compressible: boolean;
|
|
27
|
+
protocolClosed: boolean;
|
|
28
|
+
toolNames: string[];
|
|
29
|
+
roles: string[];
|
|
30
|
+
compressedBlockId?: string;
|
|
31
|
+
owningRangeId?: string;
|
|
32
|
+
isRangeStart?: boolean;
|
|
33
|
+
isRangeEnd?: boolean;
|
|
34
|
+
}>;
|
|
35
|
+
draft: {
|
|
36
|
+
revision: number;
|
|
37
|
+
ranges: Array<{
|
|
38
|
+
id: string;
|
|
39
|
+
startRef: string;
|
|
40
|
+
endRef: string;
|
|
41
|
+
topic?: string;
|
|
42
|
+
summary: string;
|
|
43
|
+
originalContentChars: number;
|
|
44
|
+
originalImageCount: number;
|
|
45
|
+
originalImagePayloadBytes: number;
|
|
46
|
+
replacementContentChars: number;
|
|
47
|
+
atomCount: number;
|
|
48
|
+
}>;
|
|
49
|
+
};
|
|
50
|
+
telemetry: DraftTelemetry;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface ReviewWebUiCallbacks {
|
|
54
|
+
applySelection?(spans: SelectionSpan[], keepRefs: string[]): void;
|
|
55
|
+
editSummary(draftId: string, summary: string): void;
|
|
56
|
+
editTopic(draftId: string, topic: string): void;
|
|
57
|
+
remove(draftId: string): void;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export interface ReviewWebUiRuntimeOptions {
|
|
61
|
+
/** Test seam; production opens the system browser. */
|
|
62
|
+
openBrowser?: (url: string) => void;
|
|
63
|
+
/** Release the UI when no page establishes its liveness stream. */
|
|
64
|
+
livenessConnectTimeoutMs?: number;
|
|
65
|
+
/** Server-originated keepalive interval for detecting a disappeared page. */
|
|
66
|
+
livenessPingIntervalMs?: number;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function owningRange(atomIndex: number, ranges: DraftPlan["ranges"]) {
|
|
70
|
+
return ranges.find((range) => atomIndex >= range.startIndex && atomIndex <= range.endIndex);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function groupMeta(atoms: readonly Atom[]): Map<number, { ref: string; label: string }> {
|
|
74
|
+
let groupNumber = 0;
|
|
75
|
+
let seenUser = false;
|
|
76
|
+
const result = new Map<number, { ref: string; label: string }>();
|
|
77
|
+
atoms.forEach((atom, index) => {
|
|
78
|
+
if (atom.kind === "user") {
|
|
79
|
+
groupNumber += 1;
|
|
80
|
+
seenUser = true;
|
|
81
|
+
}
|
|
82
|
+
const ref = seenUser ? `g${String(groupNumber).padStart(4, "0")}` : "g0000";
|
|
83
|
+
const label = seenUser && atom.kind === "user" ? firstLine(atom.preview, 72) : seenUser ? `group ${ref}` : "context before first user message";
|
|
84
|
+
result.set(index, { ref, label });
|
|
85
|
+
});
|
|
86
|
+
return result;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function firstLine(text: string, limit: number): string {
|
|
90
|
+
const normalized = text.replace(/\s+/g, " ").trim();
|
|
91
|
+
return normalized.length <= limit ? normalized : `${normalized.slice(0, limit - 1)}...`;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function serializeReviewState(
|
|
95
|
+
atoms: Atom[],
|
|
96
|
+
draft: DraftPlan,
|
|
97
|
+
telemetry: DraftTelemetry,
|
|
98
|
+
view: ReviewWebUiView = "review",
|
|
99
|
+
): ReviewState {
|
|
100
|
+
const groups = groupMeta(atoms);
|
|
101
|
+
return {
|
|
102
|
+
view,
|
|
103
|
+
atoms: atoms.map((atom) => {
|
|
104
|
+
const owner = owningRange(atom.index, draft.ranges);
|
|
105
|
+
const group = groups.get(atom.index)!;
|
|
106
|
+
return {
|
|
107
|
+
ref: atom.ref,
|
|
108
|
+
index: atom.index,
|
|
109
|
+
groupRef: group.ref,
|
|
110
|
+
groupLabel: group.label,
|
|
111
|
+
kind: atom.kind,
|
|
112
|
+
preview: atom.preview,
|
|
113
|
+
contentChars: atom.metrics.contentChars,
|
|
114
|
+
imageCount: atom.metrics.imageCount,
|
|
115
|
+
imagePayloadBytes: atom.metrics.images.reduce((sum, image) => sum + image.payloadBytes, 0),
|
|
116
|
+
compressible: atom.compressible,
|
|
117
|
+
protocolClosed: atom.protocolClosed,
|
|
118
|
+
toolNames: atom.toolNames,
|
|
119
|
+
roles: atom.roles,
|
|
120
|
+
compressedBlockId: atom.compressedBlockId,
|
|
121
|
+
owningRangeId: owner?.id,
|
|
122
|
+
isRangeStart: owner?.startIndex === atom.index,
|
|
123
|
+
isRangeEnd: owner?.endIndex === atom.index,
|
|
124
|
+
};
|
|
125
|
+
}),
|
|
126
|
+
draft: {
|
|
127
|
+
revision: draft.revision,
|
|
128
|
+
ranges: draft.ranges.map((range) => ({
|
|
129
|
+
id: range.id,
|
|
130
|
+
startRef: range.startRef,
|
|
131
|
+
endRef: range.endRef,
|
|
132
|
+
topic: range.topic,
|
|
133
|
+
summary: range.summary,
|
|
134
|
+
originalContentChars: range.originalContentChars,
|
|
135
|
+
originalImageCount: range.originalImageCount,
|
|
136
|
+
originalImagePayloadBytes: range.originalImagePayloadBytes,
|
|
137
|
+
replacementContentChars: range.replacementContentChars,
|
|
138
|
+
atomCount: range.endIndex - range.startIndex + 1,
|
|
139
|
+
})),
|
|
140
|
+
},
|
|
141
|
+
telemetry,
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** Serve the shared Review/Selection workbench over loopback HTTP. */
|
|
146
|
+
export async function showReviewWebUi(
|
|
147
|
+
ctx: ExtensionCommandContext,
|
|
148
|
+
atoms: Atom[],
|
|
149
|
+
getLatest: () => { draft: DraftPlan; telemetry: DraftTelemetry },
|
|
150
|
+
callbacks: ReviewWebUiCallbacks,
|
|
151
|
+
view: ReviewWebUiView = "review",
|
|
152
|
+
runtime: ReviewWebUiRuntimeOptions = {},
|
|
153
|
+
): Promise<void> {
|
|
154
|
+
return new Promise<void>((resolve, reject) => {
|
|
155
|
+
const connectTimeoutMs = runtime.livenessConnectTimeoutMs ?? 30_000;
|
|
156
|
+
const pingIntervalMs = runtime.livenessPingIntervalMs ?? 10_000;
|
|
157
|
+
let settled = false;
|
|
158
|
+
let closing = false;
|
|
159
|
+
let livenessResponse: http.ServerResponse | undefined;
|
|
160
|
+
let connectTimer: NodeJS.Timeout | undefined;
|
|
161
|
+
let pingTimer: NodeJS.Timeout | undefined;
|
|
162
|
+
|
|
163
|
+
const clearTimers = () => {
|
|
164
|
+
if (connectTimer) clearTimeout(connectTimer);
|
|
165
|
+
if (pingTimer) clearInterval(pingTimer);
|
|
166
|
+
connectTimer = undefined;
|
|
167
|
+
pingTimer = undefined;
|
|
168
|
+
};
|
|
169
|
+
const finish = () => {
|
|
170
|
+
if (settled) return;
|
|
171
|
+
settled = true;
|
|
172
|
+
clearTimers();
|
|
173
|
+
resolve();
|
|
174
|
+
};
|
|
175
|
+
const fail = (error: Error) => {
|
|
176
|
+
if (settled) return;
|
|
177
|
+
settled = true;
|
|
178
|
+
clearTimers();
|
|
179
|
+
reject(error);
|
|
180
|
+
};
|
|
181
|
+
const server = http.createServer(async (req, res) => {
|
|
182
|
+
const url = new URL(req.url ?? "/", "http://127.0.0.1");
|
|
183
|
+
const path = url.pathname;
|
|
184
|
+
const sendJson = (code: number, body: unknown) => {
|
|
185
|
+
const payload = JSON.stringify(body);
|
|
186
|
+
res.writeHead(code, { "content-type": "application/json; charset=utf-8", "content-length": Buffer.byteLength(payload) });
|
|
187
|
+
res.end(payload);
|
|
188
|
+
};
|
|
189
|
+
const sendHtml = (html: string) => {
|
|
190
|
+
res.writeHead(200, { "content-type": "text/html; charset=utf-8", "content-length": Buffer.byteLength(html) });
|
|
191
|
+
res.end(html);
|
|
192
|
+
};
|
|
193
|
+
const readBody = async (): Promise<string> => {
|
|
194
|
+
const chunks: Buffer[] = [];
|
|
195
|
+
for await (const chunk of req) chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
|
196
|
+
return Buffer.concat(chunks).toString("utf8");
|
|
197
|
+
};
|
|
198
|
+
const currentState = () => {
|
|
199
|
+
const latest = getLatest();
|
|
200
|
+
return serializeReviewState(atoms, latest.draft, latest.telemetry, view);
|
|
201
|
+
};
|
|
202
|
+
|
|
203
|
+
try {
|
|
204
|
+
if (req.method === "GET" && path === "/api/liveness") {
|
|
205
|
+
if (livenessResponse && !livenessResponse.writableEnded) livenessResponse.end();
|
|
206
|
+
livenessResponse = res;
|
|
207
|
+
if (connectTimer) clearTimeout(connectTimer);
|
|
208
|
+
connectTimer = undefined;
|
|
209
|
+
res.writeHead(200, {
|
|
210
|
+
"content-type": "text/event-stream; charset=utf-8",
|
|
211
|
+
"cache-control": "no-cache",
|
|
212
|
+
connection: "keep-alive",
|
|
213
|
+
});
|
|
214
|
+
res.write(": connected\n\n");
|
|
215
|
+
if (pingTimer) clearInterval(pingTimer);
|
|
216
|
+
pingTimer = setInterval(() => {
|
|
217
|
+
if (!res.writableEnded) res.write(": keepalive\n\n");
|
|
218
|
+
}, pingIntervalMs);
|
|
219
|
+
req.on("close", () => {
|
|
220
|
+
if (livenessResponse === res) {
|
|
221
|
+
livenessResponse = undefined;
|
|
222
|
+
closeServer();
|
|
223
|
+
}
|
|
224
|
+
});
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
if (req.method === "GET" && path === "/") {
|
|
228
|
+
const stateJson = JSON.stringify(currentState()).replace(/</g, "\\u003c");
|
|
229
|
+
sendHtml(HTML_TEMPLATE.replace("<!--MIDCOMPACT_STATE-->", () => stateJson));
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
232
|
+
if (req.method === "GET" && path === "/api/state") {
|
|
233
|
+
sendJson(200, currentState());
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
if (req.method === "POST" && path === "/api/close") {
|
|
237
|
+
sendJson(200, { ok: true });
|
|
238
|
+
closeServer();
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
if (req.method === "POST" && path === "/api/selection") {
|
|
242
|
+
if (view !== "selection" || !callbacks.applySelection) {
|
|
243
|
+
sendJson(409, { error: "Selection is not available in this view." });
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
const parsed = JSON.parse(await readBody()) as { spans?: SelectionSpan[]; keepRefs?: string[] };
|
|
247
|
+
if (!Array.isArray(parsed.spans) || !Array.isArray(parsed.keepRefs)) {
|
|
248
|
+
sendJson(400, { error: "Selection requires spans and keepRefs arrays." });
|
|
249
|
+
return;
|
|
250
|
+
}
|
|
251
|
+
callbacks.applySelection(parsed.spans, parsed.keepRefs);
|
|
252
|
+
sendJson(200, currentState());
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
const editMatch = path.match(/^\/api\/range\/([^/]+)\/(edit-summary|edit-topic|remove)$/);
|
|
256
|
+
if (req.method === "POST" && editMatch) {
|
|
257
|
+
const [, draftId, operation] = editMatch;
|
|
258
|
+
if (view === "selection" && operation !== "remove") {
|
|
259
|
+
sendJson(409, { error: "Summary and topic editing belong to Review." });
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
262
|
+
const current = getLatest().draft;
|
|
263
|
+
if (!current.ranges.some((range) => range.id === draftId)) {
|
|
264
|
+
sendJson(404, { error: `Unknown range ${draftId}` });
|
|
265
|
+
return;
|
|
266
|
+
}
|
|
267
|
+
if (operation === "remove") {
|
|
268
|
+
callbacks.remove(draftId);
|
|
269
|
+
sendJson(200, currentState());
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
272
|
+
const parsed = JSON.parse(await readBody()) as { value?: string };
|
|
273
|
+
const value = typeof parsed.value === "string" ? parsed.value.trim() : "";
|
|
274
|
+
if (operation === "edit-summary") {
|
|
275
|
+
if (!value) {
|
|
276
|
+
sendJson(400, { error: "summary must not be empty" });
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
279
|
+
callbacks.editSummary(draftId, value);
|
|
280
|
+
} else callbacks.editTopic(draftId, value);
|
|
281
|
+
sendJson(200, currentState());
|
|
282
|
+
return;
|
|
283
|
+
}
|
|
284
|
+
sendJson(404, { error: "not found" });
|
|
285
|
+
} catch (error) {
|
|
286
|
+
sendJson(400, { error: error instanceof Error ? error.message : String(error) });
|
|
287
|
+
}
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
const closeServer = () => {
|
|
291
|
+
if (closing || settled) return;
|
|
292
|
+
closing = true;
|
|
293
|
+
clearTimers();
|
|
294
|
+
if (livenessResponse && !livenessResponse.writableEnded) livenessResponse.end();
|
|
295
|
+
livenessResponse = undefined;
|
|
296
|
+
server.close(finish);
|
|
297
|
+
};
|
|
298
|
+
|
|
299
|
+
server.on("error", fail);
|
|
300
|
+
server.listen(0, "127.0.0.1", () => {
|
|
301
|
+
const address = server.address();
|
|
302
|
+
const port = typeof address === "object" && address ? address.port : 0;
|
|
303
|
+
const url = `http://127.0.0.1:${port}/?view=${view}`;
|
|
304
|
+
const token = randomBytes(3).toString("hex");
|
|
305
|
+
ctx.ui.notify(`Midcompact ${view} webui ready: ${url} (token ${token})`, "info");
|
|
306
|
+
connectTimer = setTimeout(closeServer, connectTimeoutMs);
|
|
307
|
+
(runtime.openBrowser ?? tryOpenBrowser)(url);
|
|
308
|
+
});
|
|
309
|
+
});
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
function tryOpenBrowser(url: string): void {
|
|
313
|
+
const command = process.platform === "win32" ? `start "" "${url}"`
|
|
314
|
+
: process.platform === "darwin" ? `open "${url}"`
|
|
315
|
+
: `xdg-open "${url}"`;
|
|
316
|
+
exec(command, () => { /* best-effort */ });
|
|
317
|
+
}
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { Key, matchesKey, truncateToWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
|
|
3
|
+
|
|
4
|
+
import type { Atom, DraftPlan, DraftTelemetry, SelectionSpan } from "./types.js";
|
|
5
|
+
|
|
6
|
+
export interface SelectionUiAction {
|
|
7
|
+
action: "save" | "close";
|
|
8
|
+
spans?: SelectionSpan[];
|
|
9
|
+
keepRefs?: string[];
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Atom-level Selection surface. It edits a requested selection only; the
|
|
14
|
+
* caller normalizes it into ordinary DraftRanges through the shared core.
|
|
15
|
+
*/
|
|
16
|
+
export async function showSelectionUi(
|
|
17
|
+
ctx: ExtensionCommandContext,
|
|
18
|
+
atoms: Atom[],
|
|
19
|
+
draft: DraftPlan,
|
|
20
|
+
telemetry: DraftTelemetry,
|
|
21
|
+
): Promise<SelectionUiAction> {
|
|
22
|
+
if (ctx.mode !== "tui") return { action: "close" };
|
|
23
|
+
|
|
24
|
+
return ctx.ui.custom(
|
|
25
|
+
(tui: { terminal: { rows: number }; requestRender: () => void }, theme: any, _keybindings: unknown, done: (action: SelectionUiAction) => void) => {
|
|
26
|
+
const selected = new Set<number>();
|
|
27
|
+
const keep = new Set<string>();
|
|
28
|
+
let cursor = 0;
|
|
29
|
+
let scrollOffset = 0;
|
|
30
|
+
let dirty = false;
|
|
31
|
+
|
|
32
|
+
for (const range of draft.ranges) {
|
|
33
|
+
for (let index = range.startIndex; index <= range.endIndex; index += 1) selected.add(index);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const dim = (text: string) => theme.fg("dim", text);
|
|
37
|
+
const accent = (text: string) => theme.fg("accent", text);
|
|
38
|
+
const success = (text: string) => theme.fg("success", text);
|
|
39
|
+
const warning = (text: string) => theme.fg("warning", text);
|
|
40
|
+
const border = (text: string) => theme.fg("border", text);
|
|
41
|
+
const widthOf = (width: number) => Math.max(40, width);
|
|
42
|
+
const frame = (text: string, width: number) => `${border("|")} ${truncateToWidth(text, Math.max(20, width - 4), "...", true)} ${border("|")}`;
|
|
43
|
+
const rule = (width: number, left: string, right: string) => border(`${left}${"-".repeat(Math.max(0, width - 2))}${right}`);
|
|
44
|
+
const short = (text: string, limit: number) => {
|
|
45
|
+
const normalized = text.replace(/\s+/g, " ").trim();
|
|
46
|
+
return normalized.length <= limit ? normalized : `${normalized.slice(0, limit - 1)}...`;
|
|
47
|
+
};
|
|
48
|
+
const groupBounds = (index: number): { start: number; end: number } => {
|
|
49
|
+
let start = index;
|
|
50
|
+
while (start > 0 && atoms[start]?.kind !== "user") start -= 1;
|
|
51
|
+
if (atoms[start]?.kind !== "user" && start === 0) start = 0;
|
|
52
|
+
let end = index;
|
|
53
|
+
while (end + 1 < atoms.length && atoms[end + 1]?.kind !== "user") end += 1;
|
|
54
|
+
return { start, end };
|
|
55
|
+
};
|
|
56
|
+
const spans = (): SelectionSpan[] => {
|
|
57
|
+
const indices = [...selected].sort((a, b) => a - b);
|
|
58
|
+
const result: SelectionSpan[] = [];
|
|
59
|
+
for (const index of indices) {
|
|
60
|
+
const previous = result[result.length - 1];
|
|
61
|
+
const previousIndex = previous ? atoms.findIndex((atom) => atom.ref === previous.endRef) : -2;
|
|
62
|
+
if (previous && previousIndex + 1 === index) previous.endRef = atoms[index]!.ref;
|
|
63
|
+
else result.push({ startRef: atoms[index]!.ref, endRef: atoms[index]!.ref });
|
|
64
|
+
}
|
|
65
|
+
return result;
|
|
66
|
+
};
|
|
67
|
+
const toggleSelection = () => {
|
|
68
|
+
const atom = atoms[cursor];
|
|
69
|
+
if (!atom) return;
|
|
70
|
+
if (!atom.compressible && !selected.has(cursor)) {
|
|
71
|
+
ctx.ui.notify(`Protected atom ${atom.ref} stays KEEP. Select its surrounding group instead.`, "warning");
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
if (selected.has(cursor)) {
|
|
75
|
+
selected.delete(cursor);
|
|
76
|
+
keep.delete(atom.ref);
|
|
77
|
+
} else selected.add(cursor);
|
|
78
|
+
dirty = true;
|
|
79
|
+
tui.requestRender();
|
|
80
|
+
};
|
|
81
|
+
const toggleKeep = () => {
|
|
82
|
+
const atom = atoms[cursor];
|
|
83
|
+
if (!atom) return;
|
|
84
|
+
if (!atom.compressible) {
|
|
85
|
+
ctx.ui.notify(`Protected atom ${atom.ref} already stays KEEP.`, "info");
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
if (!selected.has(cursor)) {
|
|
89
|
+
selected.add(cursor);
|
|
90
|
+
keep.add(atom.ref);
|
|
91
|
+
dirty = true;
|
|
92
|
+
tui.requestRender();
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
if (keep.has(atom.ref)) keep.delete(atom.ref);
|
|
96
|
+
else keep.add(atom.ref);
|
|
97
|
+
dirty = true;
|
|
98
|
+
tui.requestRender();
|
|
99
|
+
};
|
|
100
|
+
const addGroup = () => {
|
|
101
|
+
const bounds = groupBounds(cursor);
|
|
102
|
+
for (let index = bounds.start; index <= bounds.end; index += 1) selected.add(index);
|
|
103
|
+
dirty = true;
|
|
104
|
+
tui.requestRender();
|
|
105
|
+
};
|
|
106
|
+
const clearRange = () => {
|
|
107
|
+
const current = atoms[cursor];
|
|
108
|
+
if (!current?.compressible || keep.has(current.ref) || !selected.has(cursor)) return;
|
|
109
|
+
let start = cursor;
|
|
110
|
+
let end = cursor;
|
|
111
|
+
while (start > 0 && atoms[start - 1]!.compressible && selected.has(start - 1) && !keep.has(atoms[start - 1]!.ref)) start -= 1;
|
|
112
|
+
while (end + 1 < atoms.length && atoms[end + 1]!.compressible && selected.has(end + 1) && !keep.has(atoms[end + 1]!.ref)) end += 1;
|
|
113
|
+
for (let index = start; index <= end; index += 1) {
|
|
114
|
+
selected.delete(index);
|
|
115
|
+
keep.delete(atoms[index]!.ref);
|
|
116
|
+
}
|
|
117
|
+
dirty = true;
|
|
118
|
+
tui.requestRender();
|
|
119
|
+
};
|
|
120
|
+
const move = (delta: number) => {
|
|
121
|
+
cursor = Math.max(0, Math.min(atoms.length - 1, cursor + delta));
|
|
122
|
+
tui.requestRender();
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
const totalChars = atoms.reduce((sum, atom) => sum + atom.metrics.contentChars, 0);
|
|
126
|
+
const component = {
|
|
127
|
+
render(width: number): string[] {
|
|
128
|
+
const w = widthOf(width);
|
|
129
|
+
const budget = Math.max(18, Math.floor(tui.terminal.rows * 0.82));
|
|
130
|
+
const selectedAtoms = [...selected]
|
|
131
|
+
.map((index) => atoms[index])
|
|
132
|
+
.filter((atom): atom is Atom => Boolean(atom?.compressible && !keep.has(atom.ref)));
|
|
133
|
+
const chars = selectedAtoms.reduce((sum, atom) => sum + atom.metrics.contentChars, 0);
|
|
134
|
+
const charShare = percentage(chars, totalChars);
|
|
135
|
+
const images = selectedAtoms.reduce((sum, atom) => sum + atom.metrics.imageCount, 0);
|
|
136
|
+
const body: string[] = [];
|
|
137
|
+
const lineStarts = new Map<number, number>();
|
|
138
|
+
let groupNumber = 0;
|
|
139
|
+
|
|
140
|
+
atoms.forEach((atom, index) => {
|
|
141
|
+
if (atom.kind === "user") groupNumber += 1;
|
|
142
|
+
if (index === 0 || atom.kind === "user") {
|
|
143
|
+
const groupRef = atom.kind === "user" ? `g${String(groupNumber).padStart(4, "0")}` : "g0000";
|
|
144
|
+
const label = atom.kind === "user" ? short(atom.preview, Math.max(24, w - 24)) : "context before first user message";
|
|
145
|
+
body.push(frame(accent(`-- ${groupRef} | ${label}`), w));
|
|
146
|
+
}
|
|
147
|
+
lineStarts.set(index, body.length);
|
|
148
|
+
const isCursor = index === cursor;
|
|
149
|
+
const isSelected = selected.has(index);
|
|
150
|
+
const isKeep = keep.has(atom.ref) || !atom.compressible;
|
|
151
|
+
const marker = isKeep ? "K" : isSelected ? "*" : " ";
|
|
152
|
+
const state = isKeep ? success("KEEP") : isSelected ? warning("PLAN") : dim("....");
|
|
153
|
+
const tools = atom.toolNames.length ? ` tools:${atom.toolNames.join(",")}` : "";
|
|
154
|
+
const line = `${isCursor ? ">" : " "} [${marker}] ${atom.ref} ${atom.kind.padEnd(15)} ${String(atom.metrics.contentChars).padStart(6)} chars ${String(atom.metrics.imageCount).padStart(2)} img ${tools}`;
|
|
155
|
+
body.push(frame(isCursor ? accent(line) : state + " " + line.slice(2), w));
|
|
156
|
+
const preview = short(atom.preview, Math.max(30, w - 24));
|
|
157
|
+
for (const wrapped of wrapTextWithAnsi(` ${preview}`, Math.max(20, w - 8)).slice(0, 2)) body.push(frame(dim(wrapped), w));
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
const header = [
|
|
161
|
+
rule(w, "+", "+"),
|
|
162
|
+
frame(accent(theme.bold(`Midcompact Selection | Draft v${draft.revision}`)), w),
|
|
163
|
+
frame(dim(`Anchor Pi usage: ${usageLine(telemetry)}`), w),
|
|
164
|
+
frame(dim(`Selected ${selectedAtoms.length}/${atoms.length} atoms | ${chars}/${totalChars} chars (${charShare} of anchor) | up to ${charShare} fewer anchor chars | ${images} images`), w),
|
|
165
|
+
rule(w, "+", "+"),
|
|
166
|
+
];
|
|
167
|
+
const footer = [
|
|
168
|
+
rule(w, "+", "+"),
|
|
169
|
+
frame(dim("j/k move | space select | K KEEP | G add group | D remove range | S save | Esc save & close"), w),
|
|
170
|
+
frame(dim(`requested spans ${spans().length} | keep ${keep.size} | ${selectedAtoms.length} planned`), w),
|
|
171
|
+
rule(w, "+", "+"),
|
|
172
|
+
];
|
|
173
|
+
const viewport = Math.max(4, budget - header.length - footer.length);
|
|
174
|
+
const cursorLine = lineStarts.get(cursor) ?? 0;
|
|
175
|
+
if (cursorLine < scrollOffset) scrollOffset = cursorLine;
|
|
176
|
+
else if (cursorLine >= scrollOffset + viewport) scrollOffset = cursorLine - viewport + 1;
|
|
177
|
+
scrollOffset = Math.max(0, Math.min(scrollOffset, Math.max(0, body.length - viewport)));
|
|
178
|
+
const visible = body.slice(scrollOffset, scrollOffset + viewport);
|
|
179
|
+
while (visible.length < viewport) visible.push(frame("", w));
|
|
180
|
+
return [...header, ...visible, ...footer];
|
|
181
|
+
},
|
|
182
|
+
invalidate(): void {},
|
|
183
|
+
handleInput(data: string): void {
|
|
184
|
+
if (matchesKey(data, Key.escape) || data === "q") {
|
|
185
|
+
done(dirty ? { action: "save", spans: spans(), keepRefs: [...keep] } : { action: "close" });
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
if (data === "s" || data === "S" || matchesKey(data, Key.enter)) {
|
|
189
|
+
done({ action: "save", spans: spans(), keepRefs: [...keep] });
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
if (data === "j" || matchesKey(data, Key.down)) move(1);
|
|
193
|
+
else if (data === "k" || matchesKey(data, Key.up)) move(-1);
|
|
194
|
+
else if (data === " " || data === "x") toggleSelection();
|
|
195
|
+
else if (data === "K") toggleKeep();
|
|
196
|
+
else if (data === "g" || data === "G") addGroup();
|
|
197
|
+
else if (data === "d" || data === "D") clearRange();
|
|
198
|
+
else if (matchesKey(data, Key.pageDown)) { scrollOffset += 10; tui.requestRender(); }
|
|
199
|
+
else if (matchesKey(data, Key.pageUp)) { scrollOffset = Math.max(0, scrollOffset - 10); tui.requestRender(); }
|
|
200
|
+
},
|
|
201
|
+
};
|
|
202
|
+
return component;
|
|
203
|
+
},
|
|
204
|
+
{ overlay: true, overlayOptions: { width: "94%", maxHeight: "88%", anchor: "center" } },
|
|
205
|
+
) as Promise<SelectionUiAction>;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function percentage(part: number, total: number): string {
|
|
209
|
+
if (total <= 0) return "0%";
|
|
210
|
+
const value = Math.round((part / total) * 1_000) / 10;
|
|
211
|
+
return `${Number.isInteger(value) ? value : value.toFixed(1)}%`;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function usageLine(telemetry: DraftTelemetry): string {
|
|
215
|
+
const usage = telemetry.anchorUsage;
|
|
216
|
+
if (!usage) return "unavailable";
|
|
217
|
+
const tokens = usage.tokens === null ? "unavailable" : String(usage.tokens);
|
|
218
|
+
const percent = usage.percent === null ? "unavailable" : `${usage.percent}%`;
|
|
219
|
+
return `${tokens}/${usage.contextWindow} tokens (${percent}, Pi reported)`;
|
|
220
|
+
}
|
package/src/selection.ts
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
// Pure selection core. A requested span may cross KEEP/protected atoms; this
|
|
2
|
+
// module subtracts those nodes and returns sorted, non-overlapping ordinary
|
|
3
|
+
// spans that contain no protected atom. It does not create summaries, append
|
|
4
|
+
// session entries, or know whether the caller is TUI, Web, or Agent.
|
|
5
|
+
|
|
6
|
+
import { isProtectedAtom } from "./atoms.js";
|
|
7
|
+
import type { Atom, OrdinarySpan, SelectionSpan } from "./types.js";
|
|
8
|
+
|
|
9
|
+
export interface SelectionInput {
|
|
10
|
+
/** Requested spans. May cross KEEP/protected atoms. */
|
|
11
|
+
spans: SelectionSpan[];
|
|
12
|
+
/** Atom refs marked KEEP inside the spans. */
|
|
13
|
+
keepRefs: string[];
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface SelectionResult {
|
|
17
|
+
spans: OrdinarySpan[];
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export class SelectionError extends Error {}
|
|
21
|
+
|
|
22
|
+
function refIndex(atoms: readonly Atom[], ref: string): number {
|
|
23
|
+
const idx = atoms.findIndex((atom) => atom.ref === ref);
|
|
24
|
+
if (idx < 0) throw new SelectionError(`Unknown atom ref ${ref}.`);
|
|
25
|
+
return idx;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Expand a requested selection into ordinary spans.
|
|
30
|
+
*
|
|
31
|
+
* - Rejects unknown refs, reversed or empty spans.
|
|
32
|
+
* - Rejects a requested span that consists entirely of protected atoms
|
|
33
|
+
* (compressing a protected atom directly is not allowed).
|
|
34
|
+
* - Subtracts KEEP and protected atoms from each span, splitting it into
|
|
35
|
+
* contiguous ordinary fragments.
|
|
36
|
+
* - Merges adjacent fragments across spans and drops empty ones.
|
|
37
|
+
* - Output is sorted by start index, non-overlapping, and contains no
|
|
38
|
+
* protected atom.
|
|
39
|
+
*/
|
|
40
|
+
export function expandSelection(atoms: readonly Atom[], input: SelectionInput): SelectionResult {
|
|
41
|
+
if (input.spans.length === 0) return { spans: [] };
|
|
42
|
+
|
|
43
|
+
const keepSet = new Set(input.keepRefs);
|
|
44
|
+
|
|
45
|
+
// Resolve and normalize requested spans to [start,end] inclusive index ranges.
|
|
46
|
+
const requested: Array<{ start: number; end: number }> = [];
|
|
47
|
+
for (const span of input.spans) {
|
|
48
|
+
const start = refIndex(atoms, span.startRef);
|
|
49
|
+
const end = refIndex(atoms, span.endRef);
|
|
50
|
+
if (start > end) throw new SelectionError(`Span ${span.startRef}→${span.endRef} is reversed.`);
|
|
51
|
+
requested.push({ start, end });
|
|
52
|
+
}
|
|
53
|
+
requested.sort((a, b) => a.start - b.start);
|
|
54
|
+
|
|
55
|
+
const ordinaryIndices: number[] = [];
|
|
56
|
+
for (const range of requested) {
|
|
57
|
+
let anyProtected = false;
|
|
58
|
+
let anyOrdinary = false;
|
|
59
|
+
for (let i = range.start; i <= range.end; i += 1) {
|
|
60
|
+
const atom = atoms[i];
|
|
61
|
+
if (!atom) continue;
|
|
62
|
+
if (isProtectedAtom(atom)) {
|
|
63
|
+
anyProtected = true;
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
if (keepSet.has(atom.ref)) continue;
|
|
67
|
+
ordinaryIndices.push(i);
|
|
68
|
+
anyOrdinary = true;
|
|
69
|
+
}
|
|
70
|
+
if (!anyOrdinary && anyProtected) {
|
|
71
|
+
const ref = atoms[range.start]!.ref;
|
|
72
|
+
throw new SelectionError(`Cannot compress protected atom ${ref}; split around it or drop it.`);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Group ordinary indices into contiguous runs.
|
|
77
|
+
ordinaryIndices.sort((a, b) => a - b);
|
|
78
|
+
const runs: Array<{ start: number; end: number }> = [];
|
|
79
|
+
for (const idx of ordinaryIndices) {
|
|
80
|
+
const last = runs[runs.length - 1];
|
|
81
|
+
if (last && idx === last.end + 1) {
|
|
82
|
+
last.end = idx;
|
|
83
|
+
} else {
|
|
84
|
+
runs.push({ start: idx, end: idx });
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const spans: OrdinarySpan[] = runs.map((run) => ({
|
|
89
|
+
startRef: atoms[run.start]!.ref,
|
|
90
|
+
endRef: atoms[run.end]!.ref,
|
|
91
|
+
startIndex: run.start,
|
|
92
|
+
endIndex: run.end,
|
|
93
|
+
}));
|
|
94
|
+
return { spans };
|
|
95
|
+
}
|
package/src/start-ui.ts
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { Key, matchesKey, truncateToWidth } from "@earendil-works/pi-tui";
|
|
3
|
+
|
|
4
|
+
import type { StartMode } from "./types.js";
|
|
5
|
+
|
|
6
|
+
export type StartChoice = StartMode | "cancelled";
|
|
7
|
+
|
|
8
|
+
export async function showStartChoiceUi(ctx: ExtensionCommandContext): Promise<StartChoice> {
|
|
9
|
+
if (ctx.mode !== "tui") return "agent";
|
|
10
|
+
return ctx.ui.custom<StartChoice>(
|
|
11
|
+
(tui, theme, _keybindings, done) => {
|
|
12
|
+
const choices: Array<{ value: StartChoice; title: string; detail: string }> = [
|
|
13
|
+
{ value: "cancelled", title: "Drop", detail: "Leave the session unchanged" },
|
|
14
|
+
{ value: "agent", title: "Agent direct", detail: "Inspect and draft with Agent" },
|
|
15
|
+
{ value: "user", title: "User manual", detail: "Select the initial DraftPlan yourself" },
|
|
16
|
+
];
|
|
17
|
+
let selected = 1;
|
|
18
|
+
const component = {
|
|
19
|
+
render(width: number): string[] {
|
|
20
|
+
const w = Math.max(48, Math.min(width, 94));
|
|
21
|
+
const line = (text: string) => theme.fg("border", `| ${truncateToWidth(text, w - 4, "...", true).padEnd(w - 4)} |`);
|
|
22
|
+
const rule = theme.fg("border", `+${"-".repeat(w - 2)}+`);
|
|
23
|
+
const rows = choices.map((choice, index) => {
|
|
24
|
+
const marker = index === selected ? theme.fg("accent", ">") : " ";
|
|
25
|
+
const rawTitle = choice.title.padEnd(18);
|
|
26
|
+
const title = index === selected ? theme.fg("accent", theme.bold(rawTitle)) : rawTitle;
|
|
27
|
+
return line(`${marker} ${String(index + 1)} ${title} ${theme.fg("dim", choice.detail)}`);
|
|
28
|
+
});
|
|
29
|
+
return [
|
|
30
|
+
rule,
|
|
31
|
+
line(theme.fg("accent", theme.bold("Midcompact | Freeze current context"))),
|
|
32
|
+
line(theme.fg("dim", "Choose how to enter. Only Agent direct starts a model turn.")),
|
|
33
|
+
rule,
|
|
34
|
+
...rows,
|
|
35
|
+
rule,
|
|
36
|
+
line(theme.fg("dim", "left/right or j/k move | Enter choose | Esc drop")),
|
|
37
|
+
rule,
|
|
38
|
+
];
|
|
39
|
+
},
|
|
40
|
+
invalidate(): void {},
|
|
41
|
+
handleInput(data: string): void {
|
|
42
|
+
if (matchesKey(data, Key.escape) || data === "q") { done("cancelled"); return; }
|
|
43
|
+
if (matchesKey(data, Key.enter)) { done(choices[selected]!.value); return; }
|
|
44
|
+
if (data === "1") { done("cancelled"); return; }
|
|
45
|
+
if (data === "2") { done("agent"); return; }
|
|
46
|
+
if (data === "3") { done("user"); return; }
|
|
47
|
+
if (matchesKey(data, Key.left) || matchesKey(data, Key.up) || data === "k") selected = (selected + choices.length - 1) % choices.length;
|
|
48
|
+
else if (matchesKey(data, Key.right) || matchesKey(data, Key.down) || data === "j") selected = (selected + 1) % choices.length;
|
|
49
|
+
else return;
|
|
50
|
+
tui.requestRender();
|
|
51
|
+
},
|
|
52
|
+
};
|
|
53
|
+
return component;
|
|
54
|
+
},
|
|
55
|
+
{ overlay: true, overlayOptions: { width: "76%", maxHeight: 13, anchor: "center" } },
|
|
56
|
+
);
|
|
57
|
+
}
|