pi-midcompact 0.4.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 +16 -7
- package/README.zh-CN.md +16 -7
- package/package.json +2 -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 +487 -196
- 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 +17 -16
- package/src/review-webui.html +290 -73
- package/src/review-webui.ts +176 -71
- 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
package/src/review-webui.ts
CHANGED
|
@@ -5,17 +5,24 @@ import { readFileSync } from "node:fs";
|
|
|
5
5
|
|
|
6
6
|
import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
7
7
|
|
|
8
|
-
import type { Atom, DraftPlan, DraftTelemetry } from "./types.js";
|
|
8
|
+
import type { Atom, DraftPlan, DraftTelemetry, SelectionSpan } from "./types.js";
|
|
9
9
|
|
|
10
10
|
const HTML_TEMPLATE = readFileSync(new URL("review-webui.html", import.meta.url), "utf8");
|
|
11
11
|
|
|
12
|
+
export type ReviewWebUiView = "review" | "selection";
|
|
13
|
+
|
|
12
14
|
export interface ReviewState {
|
|
15
|
+
view: ReviewWebUiView;
|
|
13
16
|
atoms: Array<{
|
|
14
17
|
ref: string;
|
|
15
18
|
index: number;
|
|
19
|
+
groupRef: string;
|
|
20
|
+
groupLabel: string;
|
|
16
21
|
kind: string;
|
|
17
22
|
preview: string;
|
|
18
|
-
|
|
23
|
+
contentChars: number;
|
|
24
|
+
imageCount: number;
|
|
25
|
+
imagePayloadBytes: number;
|
|
19
26
|
compressible: boolean;
|
|
20
27
|
protocolClosed: boolean;
|
|
21
28
|
toolNames: string[];
|
|
@@ -33,8 +40,10 @@ export interface ReviewState {
|
|
|
33
40
|
endRef: string;
|
|
34
41
|
topic?: string;
|
|
35
42
|
summary: string;
|
|
36
|
-
|
|
37
|
-
|
|
43
|
+
originalContentChars: number;
|
|
44
|
+
originalImageCount: number;
|
|
45
|
+
originalImagePayloadBytes: number;
|
|
46
|
+
replacementContentChars: number;
|
|
38
47
|
atomCount: number;
|
|
39
48
|
}>;
|
|
40
49
|
};
|
|
@@ -42,27 +51,68 @@ export interface ReviewState {
|
|
|
42
51
|
}
|
|
43
52
|
|
|
44
53
|
export interface ReviewWebUiCallbacks {
|
|
54
|
+
applySelection?(spans: SelectionSpan[], keepRefs: string[]): void;
|
|
45
55
|
editSummary(draftId: string, summary: string): void;
|
|
46
56
|
editTopic(draftId: string, topic: string): void;
|
|
47
57
|
remove(draftId: string): void;
|
|
48
58
|
}
|
|
49
59
|
|
|
50
|
-
|
|
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
|
+
}
|
|
51
68
|
|
|
52
69
|
function owningRange(atomIndex: number, ranges: DraftPlan["ranges"]) {
|
|
53
|
-
return ranges.find((
|
|
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)}...`;
|
|
54
92
|
}
|
|
55
93
|
|
|
56
|
-
export function serializeReviewState(
|
|
94
|
+
export function serializeReviewState(
|
|
95
|
+
atoms: Atom[],
|
|
96
|
+
draft: DraftPlan,
|
|
97
|
+
telemetry: DraftTelemetry,
|
|
98
|
+
view: ReviewWebUiView = "review",
|
|
99
|
+
): ReviewState {
|
|
100
|
+
const groups = groupMeta(atoms);
|
|
57
101
|
return {
|
|
102
|
+
view,
|
|
58
103
|
atoms: atoms.map((atom) => {
|
|
59
104
|
const owner = owningRange(atom.index, draft.ranges);
|
|
105
|
+
const group = groups.get(atom.index)!;
|
|
60
106
|
return {
|
|
61
107
|
ref: atom.ref,
|
|
62
108
|
index: atom.index,
|
|
109
|
+
groupRef: group.ref,
|
|
110
|
+
groupLabel: group.label,
|
|
63
111
|
kind: atom.kind,
|
|
64
112
|
preview: atom.preview,
|
|
65
|
-
|
|
113
|
+
contentChars: atom.metrics.contentChars,
|
|
114
|
+
imageCount: atom.metrics.imageCount,
|
|
115
|
+
imagePayloadBytes: atom.metrics.images.reduce((sum, image) => sum + image.payloadBytes, 0),
|
|
66
116
|
compressible: atom.compressible,
|
|
67
117
|
protocolClosed: atom.protocolClosed,
|
|
68
118
|
toolNames: atom.toolNames,
|
|
@@ -81,8 +131,10 @@ export function serializeReviewState(atoms: Atom[], draft: DraftPlan, telemetry:
|
|
|
81
131
|
endRef: range.endRef,
|
|
82
132
|
topic: range.topic,
|
|
83
133
|
summary: range.summary,
|
|
84
|
-
|
|
85
|
-
|
|
134
|
+
originalContentChars: range.originalContentChars,
|
|
135
|
+
originalImageCount: range.originalImageCount,
|
|
136
|
+
originalImagePayloadBytes: range.originalImagePayloadBytes,
|
|
137
|
+
replacementContentChars: range.replacementContentChars,
|
|
86
138
|
atomCount: range.endIndex - range.startIndex + 1,
|
|
87
139
|
})),
|
|
88
140
|
},
|
|
@@ -90,123 +142,176 @@ export function serializeReviewState(atoms: Atom[], draft: DraftPlan, telemetry:
|
|
|
90
142
|
};
|
|
91
143
|
}
|
|
92
144
|
|
|
93
|
-
/**
|
|
94
|
-
* Start a local HTTP server hosting the midcompact review page. Resolves when
|
|
95
|
-
* the user closes the review (POST /api/close) or the server errors out.
|
|
96
|
-
*
|
|
97
|
-
* Edits are applied via callbacks that the caller wires to the same draft
|
|
98
|
-
* mutation + append-entry path used by the TUI, so both surfaces stay consistent.
|
|
99
|
-
*/
|
|
145
|
+
/** Serve the shared Review/Selection workbench over loopback HTTP. */
|
|
100
146
|
export async function showReviewWebUi(
|
|
101
147
|
ctx: ExtensionCommandContext,
|
|
102
148
|
atoms: Atom[],
|
|
103
149
|
getLatest: () => { draft: DraftPlan; telemetry: DraftTelemetry },
|
|
104
150
|
callbacks: ReviewWebUiCallbacks,
|
|
151
|
+
view: ReviewWebUiView = "review",
|
|
152
|
+
runtime: ReviewWebUiRuntimeOptions = {},
|
|
105
153
|
): Promise<void> {
|
|
106
154
|
return new Promise<void>((resolve, reject) => {
|
|
107
|
-
const
|
|
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) => {
|
|
108
182
|
const url = new URL(req.url ?? "/", "http://127.0.0.1");
|
|
109
183
|
const path = url.pathname;
|
|
110
|
-
|
|
111
184
|
const sendJson = (code: number, body: unknown) => {
|
|
112
185
|
const payload = JSON.stringify(body);
|
|
113
|
-
res.writeHead(code, {
|
|
114
|
-
"content-type": "application/json; charset=utf-8",
|
|
115
|
-
"content-length": Buffer.byteLength(payload),
|
|
116
|
-
});
|
|
186
|
+
res.writeHead(code, { "content-type": "application/json; charset=utf-8", "content-length": Buffer.byteLength(payload) });
|
|
117
187
|
res.end(payload);
|
|
118
188
|
};
|
|
119
189
|
const sendHtml = (html: string) => {
|
|
120
|
-
res.writeHead(200, {
|
|
121
|
-
"content-type": "text/html; charset=utf-8",
|
|
122
|
-
"content-length": Buffer.byteLength(html),
|
|
123
|
-
});
|
|
190
|
+
res.writeHead(200, { "content-type": "text/html; charset=utf-8", "content-length": Buffer.byteLength(html) });
|
|
124
191
|
res.end(html);
|
|
125
192
|
};
|
|
126
|
-
|
|
127
193
|
const readBody = async (): Promise<string> => {
|
|
128
194
|
const chunks: Buffer[] = [];
|
|
129
195
|
for await (const chunk of req) chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
|
130
196
|
return Buffer.concat(chunks).toString("utf8");
|
|
131
197
|
};
|
|
198
|
+
const currentState = () => {
|
|
199
|
+
const latest = getLatest();
|
|
200
|
+
return serializeReviewState(atoms, latest.draft, latest.telemetry, view);
|
|
201
|
+
};
|
|
132
202
|
|
|
133
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
|
+
}
|
|
134
227
|
if (req.method === "GET" && path === "/") {
|
|
135
|
-
const
|
|
136
|
-
const stateJson = JSON.stringify(serializeReviewState(atoms, draft, telemetry)).replace(/</g, "\\u003c");
|
|
137
|
-
// NOTE: function replacer — a string replacement would interpret `$&` / `$'` in the payload.
|
|
228
|
+
const stateJson = JSON.stringify(currentState()).replace(/</g, "\\u003c");
|
|
138
229
|
sendHtml(HTML_TEMPLATE.replace("<!--MIDCOMPACT_STATE-->", () => stateJson));
|
|
139
230
|
return;
|
|
140
231
|
}
|
|
141
232
|
if (req.method === "GET" && path === "/api/state") {
|
|
142
|
-
|
|
143
|
-
sendJson(200, serializeReviewState(atoms, draft, telemetry));
|
|
233
|
+
sendJson(200, currentState());
|
|
144
234
|
return;
|
|
145
235
|
}
|
|
146
236
|
if (req.method === "POST" && path === "/api/close") {
|
|
147
237
|
sendJson(200, { ok: true });
|
|
148
|
-
|
|
149
|
-
|
|
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());
|
|
150
253
|
return;
|
|
151
254
|
}
|
|
152
255
|
const editMatch = path.match(/^\/api\/range\/([^/]+)\/(edit-summary|edit-topic|remove)$/);
|
|
153
256
|
if (req.method === "POST" && editMatch) {
|
|
154
|
-
const [, draftId,
|
|
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
|
+
}
|
|
155
262
|
const current = getLatest().draft;
|
|
156
|
-
|
|
157
|
-
if (!range) {
|
|
263
|
+
if (!current.ranges.some((range) => range.id === draftId)) {
|
|
158
264
|
sendJson(404, { error: `Unknown range ${draftId}` });
|
|
159
265
|
return;
|
|
160
266
|
}
|
|
161
|
-
if (
|
|
267
|
+
if (operation === "remove") {
|
|
162
268
|
callbacks.remove(draftId);
|
|
163
|
-
|
|
164
|
-
sendJson(200, serializeReviewState(atoms, draft, telemetry));
|
|
269
|
+
sendJson(200, currentState());
|
|
165
270
|
return;
|
|
166
271
|
}
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
sendJson(400, { error: "summary must not be empty" });
|
|
174
|
-
return;
|
|
175
|
-
}
|
|
176
|
-
callbacks.editSummary(draftId, value.trim());
|
|
177
|
-
} else {
|
|
178
|
-
callbacks.editTopic(draftId, value.trim());
|
|
179
|
-
}
|
|
180
|
-
const { draft, telemetry } = getLatest();
|
|
181
|
-
sendJson(200, serializeReviewState(atoms, draft, telemetry));
|
|
182
|
-
} catch (err) {
|
|
183
|
-
sendJson(400, { error: err instanceof Error ? err.message : String(err) });
|
|
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;
|
|
184
278
|
}
|
|
185
|
-
|
|
279
|
+
callbacks.editSummary(draftId, value);
|
|
280
|
+
} else callbacks.editTopic(draftId, value);
|
|
281
|
+
sendJson(200, currentState());
|
|
186
282
|
return;
|
|
187
283
|
}
|
|
188
284
|
sendJson(404, { error: "not found" });
|
|
189
|
-
} catch (
|
|
190
|
-
sendJson(
|
|
285
|
+
} catch (error) {
|
|
286
|
+
sendJson(400, { error: error instanceof Error ? error.message : String(error) });
|
|
191
287
|
}
|
|
192
288
|
});
|
|
193
289
|
|
|
194
|
-
|
|
195
|
-
|
|
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);
|
|
196
300
|
server.listen(0, "127.0.0.1", () => {
|
|
197
|
-
const
|
|
198
|
-
const port = typeof
|
|
199
|
-
const url = `http://127.0.0.1:${port}`;
|
|
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}`;
|
|
200
304
|
const token = randomBytes(3).toString("hex");
|
|
201
|
-
ctx.ui.notify(`Midcompact
|
|
202
|
-
|
|
305
|
+
ctx.ui.notify(`Midcompact ${view} webui ready: ${url} (token ${token})`, "info");
|
|
306
|
+
connectTimer = setTimeout(closeServer, connectTimeoutMs);
|
|
307
|
+
(runtime.openBrowser ?? tryOpenBrowser)(url);
|
|
203
308
|
});
|
|
204
309
|
});
|
|
205
310
|
}
|
|
206
311
|
|
|
207
312
|
function tryOpenBrowser(url: string): void {
|
|
208
|
-
const
|
|
313
|
+
const command = process.platform === "win32" ? `start "" "${url}"`
|
|
209
314
|
: process.platform === "darwin" ? `open "${url}"`
|
|
210
|
-
|
|
211
|
-
exec(
|
|
315
|
+
: `xdg-open "${url}"`;
|
|
316
|
+
exec(command, () => { /* best-effort */ });
|
|
212
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
|
+
}
|