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.
@@ -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
- approxTokens: number;
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
- originalApproxTokens: number;
37
- compressedApproxTokens: number;
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((r) => atomIndex >= r.startIndex && atomIndex <= r.endIndex);
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(atoms: Atom[], draft: DraftPlan, telemetry: DraftTelemetry): ReviewState {
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
- approxTokens: atom.approxTokens,
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
- originalApproxTokens: range.originalApproxTokens,
85
- compressedApproxTokens: range.compressedApproxTokens,
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 server = http.createServer((req, res) => {
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 { draft, telemetry } = getLatest();
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
- const { draft, telemetry } = getLatest();
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
- server.close();
149
- resolve();
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, op] = 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
+ }
155
262
  const current = getLatest().draft;
156
- const range = current.ranges.find((r) => r.id === draftId);
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 (op === "remove") {
267
+ if (operation === "remove") {
162
268
  callbacks.remove(draftId);
163
- const { draft, telemetry } = getLatest();
164
- sendJson(200, serializeReviewState(atoms, draft, telemetry));
269
+ sendJson(200, currentState());
165
270
  return;
166
271
  }
167
- readBody().then((raw) => {
168
- try {
169
- const parsed = JSON.parse(raw) as { value?: string };
170
- const value = typeof parsed.value === "string" ? parsed.value : "";
171
- if (op === "edit-summary") {
172
- if (!value.trim()) {
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 (err) {
190
- sendJson(500, { error: err instanceof Error ? err.message : String(err) });
285
+ } catch (error) {
286
+ sendJson(400, { error: error instanceof Error ? error.message : String(error) });
191
287
  }
192
288
  });
193
289
 
194
- server.on("error", reject);
195
- // Bind loopback only; random port assigned by the OS.
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 addr = server.address();
198
- const port = typeof addr === "object" && addr ? addr.port : 0;
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 review-webui ready: ${url} (token ${token})`, "info");
202
- tryOpenBrowser(url);
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 cmd = process.platform === "win32" ? `start "" "${url}"`
313
+ const command = process.platform === "win32" ? `start "" "${url}"`
209
314
  : process.platform === "darwin" ? `open "${url}"`
210
- : `xdg-open "${url}"`;
211
- exec(cmd, () => { /* best-effort; ignore failures */ });
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
+ }
@@ -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
+ }