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/src/projection.ts CHANGED
@@ -1,4 +1,10 @@
1
- import type { CompressionBlock, CompressionState, MessageLike } from "./types.js";
1
+ // Projection: replaces an exact persisted messageKeys subsequence with a
2
+ // midcompact summary message and fails open when a sequence cannot be resolved.
3
+ // Owns factual replacement-size calculation based on the actual summary message
4
+ // wrapper text, not a fixed token heuristic.
5
+
6
+ import type { CompressionBlock, CompressionState, ContentMetrics, MessageLike } from "./types.js";
7
+ import { aggregateMetrics, measureMessage } from "./content-metrics.js";
2
8
  import { approxTokens, messageKey } from "./messages.js";
3
9
 
4
10
  export function summaryMessage(block: CompressionBlock, timestamp = Date.now()): MessageLike {
@@ -56,6 +62,42 @@ function findSubsequence(haystack: string[], needle: string[]): { start: number;
56
62
  return undefined;
57
63
  }
58
64
 
65
+ /**
66
+ * Measure the actual replacement message produced for a block, so callers can
67
+ * report factual replacement content chars (midcompact wrapper + summary).
68
+ */
69
+ export function replacementMetrics(block: CompressionBlock): ContentMetrics {
70
+ return measureMessage(summaryMessage(block));
71
+ }
72
+
73
+ /**
74
+ * Factual replacement content chars for a range, given its topic and summary.
75
+ * Computed from the actual summary message wrapper text that projection emits.
76
+ */
77
+ export function replacementContentChars(summary: string, topic?: string): number {
78
+ const block: CompressionBlock = {
79
+ id: "draft",
80
+ summary,
81
+ topic,
82
+ entryIds: [],
83
+ messageKeys: [],
84
+ createdAt: new Date().toISOString(),
85
+ originalContentChars: 0,
86
+ originalImageCount: 0,
87
+ originalImagePayloadBytes: 0,
88
+ replacementContentChars: 0,
89
+ originalApproxTokens: 0,
90
+ compressedApproxTokens: 0,
91
+ };
92
+ return replacementMetrics(block).contentChars;
93
+ }
94
+
95
+ /** @deprecated legacy heuristic; kept only for old approximate-token field compat. */
59
96
  export function estimateCompressedTokens(summary: string, topic?: string): number {
60
97
  return approxTokens(`${topic ?? ""}\n${summary}`) + 40;
61
98
  }
99
+
100
+ /** Factual metrics for a set of atoms that a range/block would replace. */
101
+ export function rangeMetricsForAtoms(atoms: readonly { metrics: ContentMetrics }[]): ContentMetrics {
102
+ return aggregateMetrics(atoms.map((atom) => atom.metrics));
103
+ }
package/src/renderers.ts CHANGED
@@ -14,28 +14,27 @@ export function registerStateRenderer(pi: ExtensionAPI): void {
14
14
  return box;
15
15
  }
16
16
  const commit = state.lastCommit;
17
- const saved = state.blocks.reduce(
18
- (sum, block) => sum + Math.max(0, block.originalApproxTokens - block.compressedApproxTokens),
19
- 0,
20
- );
17
+ const originalChars = state.blocks.reduce((sum, block) => sum + (block.originalContentChars ?? 0), 0);
18
+ const replacementChars = state.blocks.reduce((sum, block) => sum + (block.replacementContentChars ?? 0), 0);
19
+ const imageCount = state.blocks.reduce((sum, block) => sum + (block.originalImageCount ?? 0), 0);
21
20
  const added = commit?.addedRangeCount ?? 0;
22
21
  const headline = [
23
22
  theme.fg("success", "✓ MIDCOMPACT"),
24
23
  added ? `+${added} range${added === 1 ? "" : "s"}` : `${state.blocks.length} active block${state.blocks.length === 1 ? "" : "s"}`,
25
24
  `${state.blocks.length} active`,
26
- `~${formatTokenCount(saved)} saved`,
27
- ].join(" · ");
25
+ `${originalChars} → ${replacementChars} content chars`,
26
+ imageCount ? `${imageCount} images` : "",
27
+ ].filter(Boolean).join(" · ");
28
28
  box.addChild(new Text(headline, 0, 0));
29
29
 
30
- if (commit?.anchorUsage?.contextWindow && commit.projectedTokens !== null) {
30
+ if (commit?.anchorUsage?.contextWindow) {
31
+ // Pi-reported awareness only; no local projected token percentage claim.
31
32
  box.addChild(new Text(
32
33
  theme.fg(
33
34
  "dim",
34
- `anchor ${formatPercent(commit.anchorUsage.percent)} projected ${formatPercent(commit.projectedPercent, true)} ` +
35
- `(~${formatTokenCount(commit.projectedTokens)}/${formatTokenCount(commit.anchorUsage.contextWindow)})`,
35
+ `anchor ${formatPercent(commit.anchorUsage.percent)} [Pi reported] · ${commit.selectedOriginalContentChars} → ${commit.selectedReplacementContentChars} chars · ${commit.selectedImageCount} images`,
36
36
  ),
37
- 0,
38
- 0,
37
+ 0, 0,
39
38
  ));
40
39
  }
41
40
 
@@ -43,7 +42,7 @@ export function registerStateRenderer(pi: ExtensionAPI): void {
43
42
  const addedSet = new Set(commit?.addedBlockIds ?? []);
44
43
  const blocks = addedSet.size ? state.blocks.filter((block) => addedSet.has(block.id)) : state.blocks;
45
44
  for (const block of blocks) {
46
- const title = `${block.id}${block.topic ? ` · ${block.topic}` : ""} · ~${formatTokenCount(block.originalApproxTokens)} → ~${formatTokenCount(block.compressedApproxTokens)}`;
45
+ const title = `${block.id}${block.topic ? ` · ${block.topic}` : ""} · ${block.originalContentChars ?? 0} → ${block.replacementContentChars ?? 0} chars${block.originalImageCount ? ` · ${block.originalImageCount} images` : ""}`;
47
46
  box.addChild(new Text(theme.fg("accent", title), 0, 0));
48
47
  box.addChild(new Text(theme.fg("dim", block.summary), 1, 0));
49
48
  }
@@ -56,12 +55,10 @@ export function registerStateRenderer(pi: ExtensionAPI): void {
56
55
  export function stateTreeLabel(state: CompressionState): string {
57
56
  const commit = state.lastCommit;
58
57
  const added = commit?.addedRangeCount ?? 0;
59
- const saved = commit?.estimatedSavedTokens ?? state.blocks.reduce(
60
- (sum, block) => sum + Math.max(0, block.originalApproxTokens - block.compressedApproxTokens),
61
- 0,
62
- );
63
- const projection = commit?.projectedPercent === null || commit?.projectedPercent === undefined
58
+ const replacementChars = state.blocks.reduce((sum, block) => sum + (block.replacementContentChars ?? 0), 0);
59
+ const originalChars = state.blocks.reduce((sum, block) => sum + (block.originalContentChars ?? 0), 0);
60
+ const anchor = commit?.anchorUsage?.percent === null || commit?.anchorUsage?.percent === undefined
64
61
  ? ""
65
- : ` · →~${formatPercent(commit.projectedPercent, false)}`;
66
- return `midcompact${added ? ` +${added}` : ""} · ~${formatTokenCount(saved)} saved${projection}`;
62
+ : ` · anchor ${formatPercent(commit.anchorUsage.percent)} [Pi]`;
63
+ return `midcompact${added ? ` +${added}` : ""} · ${originalChars} ${replacementChars} chars${anchor}`;
67
64
  }
package/src/review-ui.ts CHANGED
@@ -1,9 +1,14 @@
1
1
  import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
2
2
  import { Key, matchesKey, truncateToWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
3
3
 
4
- import { formatPercent, formatTokenCount } from "./telemetry.js";
5
4
  import type { Atom, DraftPlan, DraftRange, DraftTelemetry, ReviewAction } from "./types.js";
6
5
 
6
+ /**
7
+ * Interactive TUI review. Rendered as a centered overlay (not a full-height
8
+ * editor replacement) so it never flattens the chat transcript. Ranges fold
9
+ * into single header lines; the selected range expands inline with its detail
10
+ * and edit affordances, removing the duplicate top "Selected" block.
11
+ */
7
12
  export async function showReviewUi(
8
13
  ctx: ExtensionCommandContext,
9
14
  atoms: Atom[],
@@ -12,148 +17,180 @@ export async function showReviewUi(
12
17
  ): Promise<ReviewAction> {
13
18
  if (ctx.mode !== "tui") return { action: "close" };
14
19
 
15
- return ctx.ui.custom<ReviewAction>((tui, theme, _keybindings, done) => {
16
- let scrollOffset = 0;
17
- let selectedRange = draft.ranges.length ? 0 : -1;
18
- let expandSelected = false;
19
- let jumpToSelected = true;
20
-
21
- const selected = (): DraftRange | undefined => selectedRange >= 0 ? draft.ranges[selectedRange] : undefined;
22
-
23
- const component = {
24
- render(width: number): string[] {
25
- const w = Math.max(30, width);
26
- const rows = Math.max(12, tui.terminal.rows);
27
- const border = (text: string) => theme.fg("border", text);
28
- const dim = (text: string) => theme.fg("dim", text);
29
- const accent = (text: string) => theme.fg("accent", text);
30
- const success = (text: string) => theme.fg("success", text);
31
- const warning = (text: string) => theme.fg("warning", text);
32
- const bodyWidth = Math.max(20, w - 4);
33
- const framed = (text: string) => `${border("")} ${truncateToWidth(text, bodyWidth, "…", true)} ${border("│")}`;
34
- const horizontal = (left: string, mid = "─", right = "─") => border(`${left}${mid.repeat(Math.max(0, w - 2))}${right}`);
35
-
36
- const header: string[] = [];
37
- header.push(horizontal("", "", ""));
38
- header.push(framed(accent(theme.bold(`Midcompact Review · Draft v${draft.revision}`))));
39
- header.push(framed(formatUsageLine(telemetry, theme)));
40
- header.push(framed(dim("This is awareness, not a target. Review the linear anchor snapshot before /midcompact commit.")));
41
- header.push(horizontal("├", "─", "┤"));
42
-
43
- const range = selected();
44
- const selectedInfo: string[] = [];
45
- if (range) {
46
- selectedInfo.push(framed(`${accent("Selected")} ${range.id} ${range.startRef} → ${range.endRef}${range.topic ? ` ${range.topic}` : ""}`));
47
- selectedInfo.push(framed(dim(`~${formatTokenCount(range.originalApproxTokens)} → ~${formatTokenCount(range.compressedApproxTokens)} tokens`)));
48
- for (const line of wrapTextWithAnsi(`${accent("Summary:")} ${range.summary}`, bodyWidth)) selectedInfo.push(framed(line));
49
- selectedInfo.push(horizontal("├", "─", "┤"));
50
- }
51
-
52
- const body: string[] = [];
53
- const atomLineStarts = new Map<number, number>();
54
- for (const atom of atoms) {
55
- atomLineStarts.set(atom.index, body.length);
56
- const owner = owningRange(atom.index, draft.ranges);
57
- const isSelected = Boolean(range && owner?.id === range.id);
58
- const isRangeStart = owner?.startIndex === atom.index;
59
- const isRangeEnd = owner?.endIndex === atom.index;
60
- const rangeMark = owner ? (isRangeStart ? "┌" : isRangeEnd ? "└" : "│") : " ";
61
- const selectionMark = isSelected ? "▶" : " ";
62
- const policy = owner ? warning(owner.id) : success("KEEP");
63
- const token = dim(`~${formatTokenCount(atom.approxTokens)}`);
64
- const oneLine = firstLine(atom.preview);
65
- body.push(framed(`${selectionMark}${rangeMark} ${policy} ${atom.ref} [${atom.kind}] ${token} ${oneLine}`));
66
- if (expandSelected && isSelected) {
67
- const detail = wrapTextWithAnsi(atom.preview, Math.max(10, bodyWidth - 4));
68
- for (const detailLine of detail.slice(1, 8)) body.push(framed(dim(` ${detailLine}`)));
20
+ return ctx.ui.custom<ReviewAction>(
21
+ (tui, theme, _keybindings, done) => {
22
+ let scrollOffset = 0;
23
+ let selectedRange = draft.ranges.length ? 0 : -1;
24
+ let expandSelected = true;
25
+ let jumpToSelected = true;
26
+
27
+ const selected = (): DraftRange | undefined =>
28
+ selectedRange >= 0 ? draft.ranges[selectedRange] : undefined;
29
+
30
+ const component = {
31
+ render(width: number): string[] {
32
+ const w = Math.max(40, width);
33
+ // Conservative height budget: stay under the overlay's maxHeight so
34
+ // the overlay never clips and the chat transcript is not crushed.
35
+ const totalBudget = Math.max(18, Math.floor(tui.terminal.rows * 0.8));
36
+ const border = (text: string) => theme.fg("border", text);
37
+ const dim = (text: string) => theme.fg("dim", text);
38
+ const accent = (text: string) => theme.fg("accent", text);
39
+ const success = (text: string) => theme.fg("success", text);
40
+ const warning = (text: string) => theme.fg("warning", text);
41
+ const inner = Math.max(20, w - 4);
42
+ const framed = (text: string) => `${border("")} ${truncateToWidth(text, inner, "", true)} ${border("")}`;
43
+ const h = (l: string, m = "─", r = "─") => border(`${l}${m.repeat(Math.max(0, w - 2))}${r}`);
44
+
45
+ const range = selected();
46
+
47
+ const header: string[] = [
48
+ h("╭"),
49
+ framed(accent(theme.bold(`Midcompact Review · Draft v${draft.revision} · ${draft.ranges.length} range(s)`))),
50
+ framed(usageLine(telemetry, theme)),
51
+ h(""),
52
+ ];
53
+
54
+ // Timeline: KEEP atoms always shown; ranges fold to a header line,
55
+ // except the selected range which expands with detail + its atoms.
56
+ const body: string[] = [];
57
+ const rangeStarts = new Map(draft.ranges.map((r) => [r.startIndex, r]));
58
+ const rangeLineStarts = new Map<string, number>();
59
+
60
+ for (const atom of atoms) {
61
+ const head = rangeStarts.get(atom.index);
62
+ if (head) {
63
+ rangeLineStarts.set(head.id, body.length);
64
+ const isSel = Boolean(range && range.id === head.id);
65
+ const savedChars = Math.max(0, head.originalContentChars - head.replacementContentChars);
66
+ const atomCount = head.endIndex - head.startIndex + 1;
67
+ const caret = isSel ? (expandSelected ? "▾" : "▸") : "";
68
+ const caretCol = isSel ? accent(caret) : dim(caret);
69
+ const idCol = isSel ? theme.bold(warning(head.id)) : warning(head.id);
70
+ const span = dim(`${head.startRef}→${head.endRef} · ${atomCount} atoms`);
71
+ const chars = dim(`${formatCount(head.originalContentChars)}→${formatCount(head.replacementContentChars)} chars`);
72
+ const saved = success(`−${formatCount(savedChars)}`);
73
+ const topicCol = head.topic ? `${accent(head.topic)} ` : "";
74
+ const sumCol = dim(firstLine(head.summary, 48));
75
+ const headText = `${caretCol} ${idCol} ${span} ${chars} ${saved} ${topicCol}${sumCol}`;
76
+ body.push(framed(isSel ? accent(headText) : headText));
77
+
78
+ if (isSel) {
79
+ body.push(framed(dim(` topic: ${head.topic ?? "—"}`)));
80
+ body.push(framed(dim(` chars: ${formatCount(head.originalContentChars)} → ${formatCount(head.replacementContentChars)} (−${formatCount(savedChars)}) · ${head.originalImageCount} images`)));
81
+ const wrapWidth = Math.max(10, inner - 6);
82
+ for (const line of wrapTextWithAnsi(`${accent("summary:")} ${head.summary}`, wrapWidth)) {
83
+ body.push(framed(` ${line}`));
84
+ }
85
+ body.push(framed(dim(` [e] edit summary · [t] edit topic · [d] remove · [x] ${expandSelected ? "collapse" : "expand"} atoms`)));
86
+ }
87
+ }
88
+
89
+ const owner = owningRange(atom.index, draft.ranges);
90
+ const showAtom = !owner || (Boolean(range) && owner.id === range!.id && expandSelected);
91
+ if (!showAtom) continue;
92
+
93
+ const mark = owner
94
+ ? atom.index === owner.startIndex
95
+ ? "┌"
96
+ : atom.index === owner.endIndex
97
+ ? "└"
98
+ : "│"
99
+ : " ";
100
+ const policy = owner ? warning(owner.id) : success("KEEP");
101
+ const facts = dim(`${formatCount(atom.metrics.contentChars)} chars${atom.metrics.imageCount ? ` · ${atom.metrics.imageCount} img` : ""}`);
102
+ const oneLine = dim(firstLine(atom.preview, 80));
103
+ body.push(framed(`${mark} ${policy} ${atom.ref} [${atom.kind}] ${facts} ${oneLine}`));
104
+ }
105
+ if (!body.length) body.push(framed(dim("(No atoms in anchor snapshot.)")));
106
+
107
+ const footer: string[] = [
108
+ h("├"),
109
+ framed(dim(`ranges ${hint("n/p", theme)} · scroll ${hint("↑↓ PgUp PgDn", theme)} · ${hint("Esc", theme)} close`)),
110
+ framed(dim(`selected: ${hint("e", theme)} summary · ${hint("t", theme)} topic · ${hint("d", theme)} remove · ${hint("x", theme)} expand`)),
111
+ h("╰"),
112
+ ];
113
+
114
+ const viewportHeight = Math.max(5, totalBudget - header.length - footer.length);
115
+ if (jumpToSelected && range) {
116
+ const target = rangeLineStarts.get(range.id) ?? 0;
117
+ scrollOffset = Math.max(0, target - 2);
118
+ jumpToSelected = false;
119
+ }
120
+ const maxOffset = Math.max(0, body.length - viewportHeight);
121
+ scrollOffset = Math.max(0, Math.min(scrollOffset, maxOffset));
122
+ const visible = body.slice(scrollOffset, scrollOffset + viewportHeight);
123
+ while (visible.length < viewportHeight) visible.push(framed(""));
124
+
125
+ return [...header, ...visible, ...footer];
126
+ },
127
+ invalidate(): void {},
128
+ handleInput(data: string): void {
129
+ if (matchesKey(data, Key.escape) || matchesKey(data, Key.enter) || data === "q") {
130
+ done({ action: "close" });
131
+ return;
69
132
  }
70
- }
71
- if (!body.length) body.push(framed(dim("(No atoms in anchor snapshot.)")));
72
-
73
- const footerRows = 4;
74
- const viewportHeight = Math.max(5, rows - header.length - selectedInfo.length - footerRows - 2);
75
- if (jumpToSelected && range) {
76
- scrollOffset = atomLineStarts.get(range.startIndex) ?? scrollOffset;
77
- jumpToSelected = false;
78
- }
79
- const maxOffset = Math.max(0, body.length - viewportHeight);
80
- scrollOffset = Math.max(0, Math.min(scrollOffset, maxOffset));
81
- const visible = body.slice(scrollOffset, scrollOffset + viewportHeight);
82
- while (visible.length < viewportHeight) visible.push(framed(""));
83
-
84
- const top = body.length ? scrollOffset + 1 : 0;
85
- const bottom = Math.min(body.length, scrollOffset + viewportHeight);
86
- const footer = [
87
- horizontal("├", "─", "┤"),
88
- framed(dim(`Lines ${top}-${bottom} of ${body.length} · n/p select range · ↑↓/PgUp/PgDn scroll · x expand selected`)),
89
- framed(dim("e edit summary · t edit topic · d remove selected range · Enter/Esc close")),
90
- horizontal("╰", "─", "╯"),
91
- ];
92
- return [...header, ...selectedInfo, ...visible, ...footer];
93
- },
94
- invalidate(): void {},
95
- handleInput(data: string): void {
96
- if (matchesKey(data, Key.escape) || matchesKey(data, Key.enter) || data === "q") {
97
- done({ action: "close" });
98
- return;
99
- }
100
- if ((data === "n" || matchesKey(data, Key.right)) && draft.ranges.length) {
101
- selectedRange = (selectedRange + 1 + draft.ranges.length) % draft.ranges.length;
102
- jumpToSelected = true;
103
- tui.requestRender();
104
- return;
105
- }
106
- if ((data === "p" || matchesKey(data, Key.left)) && draft.ranges.length) {
107
- selectedRange = (selectedRange - 1 + draft.ranges.length) % draft.ranges.length;
108
- jumpToSelected = true;
109
- tui.requestRender();
110
- return;
111
- }
112
- if (matchesKey(data, Key.up) || data === "k") {
113
- scrollOffset = Math.max(0, scrollOffset - 1);
114
- tui.requestRender();
115
- return;
116
- }
117
- if (matchesKey(data, Key.down) || data === "j") {
118
- scrollOffset += 1;
119
- tui.requestRender();
120
- return;
121
- }
122
- if (matchesKey(data, Key.pageUp)) {
123
- scrollOffset = Math.max(0, scrollOffset - 12);
124
- tui.requestRender();
125
- return;
126
- }
127
- if (matchesKey(data, Key.pageDown)) {
128
- scrollOffset += 12;
129
- tui.requestRender();
130
- return;
131
- }
132
- if (matchesKey(data, Key.home)) {
133
- scrollOffset = 0;
134
- tui.requestRender();
135
- return;
136
- }
137
- if (matchesKey(data, Key.end)) {
138
- scrollOffset = Number.MAX_SAFE_INTEGER;
139
- tui.requestRender();
140
- return;
141
- }
142
- if (data === "x") {
143
- expandSelected = !expandSelected;
144
- jumpToSelected = true;
145
- tui.requestRender();
146
- return;
147
- }
148
- const range = selected();
149
- if (!range) return;
150
- if (data === "e") done({ action: "edit-summary", draftId: range.id });
151
- else if (data === "t") done({ action: "edit-topic", draftId: range.id });
152
- else if (data === "d") done({ action: "remove", draftId: range.id });
153
- },
154
- };
155
- return component;
156
- });
133
+ if ((data === "n" || matchesKey(data, Key.right)) && draft.ranges.length) {
134
+ selectedRange = (selectedRange + 1 + draft.ranges.length) % draft.ranges.length;
135
+ expandSelected = true;
136
+ jumpToSelected = true;
137
+ tui.requestRender();
138
+ return;
139
+ }
140
+ if ((data === "p" || matchesKey(data, Key.left)) && draft.ranges.length) {
141
+ selectedRange = (selectedRange - 1 + draft.ranges.length) % draft.ranges.length;
142
+ expandSelected = true;
143
+ jumpToSelected = true;
144
+ tui.requestRender();
145
+ return;
146
+ }
147
+ if (matchesKey(data, Key.up) || data === "k") {
148
+ scrollOffset = Math.max(0, scrollOffset - 1);
149
+ tui.requestRender();
150
+ return;
151
+ }
152
+ if (matchesKey(data, Key.down) || data === "j") {
153
+ scrollOffset += 1;
154
+ tui.requestRender();
155
+ return;
156
+ }
157
+ if (matchesKey(data, Key.pageUp)) {
158
+ scrollOffset = Math.max(0, scrollOffset - 12);
159
+ tui.requestRender();
160
+ return;
161
+ }
162
+ if (matchesKey(data, Key.pageDown)) {
163
+ scrollOffset += 12;
164
+ tui.requestRender();
165
+ return;
166
+ }
167
+ if (matchesKey(data, Key.home)) {
168
+ scrollOffset = 0;
169
+ tui.requestRender();
170
+ return;
171
+ }
172
+ if (matchesKey(data, Key.end)) {
173
+ scrollOffset = Number.MAX_SAFE_INTEGER;
174
+ tui.requestRender();
175
+ return;
176
+ }
177
+ if (data === "x") {
178
+ expandSelected = !expandSelected;
179
+ jumpToSelected = true;
180
+ tui.requestRender();
181
+ return;
182
+ }
183
+ const range = selected();
184
+ if (!range) return;
185
+ if (data === "e") done({ action: "edit-summary", draftId: range.id });
186
+ else if (data === "t") done({ action: "edit-topic", draftId: range.id });
187
+ else if (data === "d") done({ action: "remove", draftId: range.id });
188
+ },
189
+ };
190
+ return component;
191
+ },
192
+ { overlay: true, overlayOptions: { width: "92%", maxHeight: "86%", anchor: "center" } },
193
+ );
157
194
  }
158
195
 
159
196
  export function buildReviewText(atoms: Atom[], draft: DraftPlan, telemetry: DraftTelemetry): string {
@@ -165,7 +202,7 @@ export function buildReviewText(atoms: Atom[], draft: DraftPlan, telemetry: Draf
165
202
  ];
166
203
  for (const atom of atoms) {
167
204
  const owner = owningRange(atom.index, draft.ranges);
168
- lines.push(`${owner ? owner.id : "KEEP"} ${atom.ref} [${atom.kind}] ~${formatTokenCount(atom.approxTokens)} ${firstLine(atom.preview)}`);
205
+ lines.push(`${owner ? owner.id : "KEEP"} ${atom.ref} [${atom.kind}] ${formatCount(atom.metrics.contentChars)} chars${atom.metrics.imageCount ? ` · ${atom.metrics.imageCount} images` : ""} ${firstLine(atom.preview, 120)}`);
169
206
  }
170
207
  if (draft.ranges.length) {
171
208
  lines.push("", "Proposed summaries:");
@@ -178,21 +215,28 @@ function owningRange(atomIndex: number, ranges: DraftRange[]): DraftRange | unde
178
215
  return ranges.find((range) => atomIndex >= range.startIndex && atomIndex <= range.endIndex);
179
216
  }
180
217
 
181
- function firstLine(text: string): string {
182
- return text.replace(/\s+/g, " ").trim();
218
+ function firstLine(text: string, limit = 120): string {
219
+ const normalized = text.replace(/\s+/g, " ").trim();
220
+ if (normalized.length <= limit) return normalized;
221
+ return `${normalized.slice(0, Math.max(0, limit - 1))}…`;
222
+ }
223
+
224
+ function hint(text: string, theme: ExtensionCommandContext["ui"]["theme"]): string {
225
+ return theme.fg("accent", text);
183
226
  }
184
227
 
185
228
  function plainUsageLine(telemetry: DraftTelemetry): string {
186
- const anchor = telemetry.contextWindow === null
187
- ? "anchor usage unavailable"
188
- : `${formatTokenCount(telemetry.anchorTokens)}/${formatTokenCount(telemetry.contextWindow)} (${formatPercent(telemetry.anchorPercent)})`;
189
- const projected = telemetry.projectedTokens === null || telemetry.contextWindow === null
190
- ? "projected unavailable"
191
- : `~${formatTokenCount(telemetry.projectedTokens)}/${formatTokenCount(telemetry.contextWindow)} (${formatPercent(telemetry.projectedPercent, true)})`;
192
- return `Anchor ${anchor} · Draft selected ~${formatTokenCount(telemetry.selectedOriginalApproxTokens)}→~${formatTokenCount(telemetry.selectedCompressedApproxTokens)} · Projected ${projected}`;
229
+ const usage = telemetry.anchorUsage;
230
+ const anchor = usage
231
+ ? `${usage.tokens === null ? "unavailable" : formatCount(usage.tokens)}/${formatCount(usage.contextWindow)} tokens (${usage.percent === null ? "unavailable" : `${usage.percent}%`}, Pi reported)`
232
+ : "Pi reported anchor usage unavailable";
233
+ return `Anchor ${anchor} · Draft ${telemetry.rangeCount} ranges · ${formatCount(telemetry.selectedOriginalContentChars)}→${formatCount(telemetry.selectedReplacementContentChars)} chars · ${telemetry.selectedImageCount} images · ${telemetry.pendingSummaryCount} pending`;
234
+ }
235
+
236
+ function formatCount(value: number): string {
237
+ return value.toLocaleString("en-US");
193
238
  }
194
239
 
195
- function formatUsageLine(telemetry: DraftTelemetry, theme: ExtensionCommandContext["ui"]["theme"]): string {
196
- const label = theme.fg("accent", "Context");
197
- return `${label}: ${plainUsageLine(telemetry)}`;
240
+ function usageLine(telemetry: DraftTelemetry, theme: ExtensionCommandContext["ui"]["theme"]): string {
241
+ return `${theme.fg("accent", "Context")}: ${plainUsageLine(telemetry)}`;
198
242
  }