@gavin-hu/my-pi-agent 0.15.1 → 0.16.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.
Files changed (87) hide show
  1. package/README.md +10 -7
  2. package/README.zh-CN.md +8 -5
  3. package/extensions/bytefly/README.md +98 -116
  4. package/extensions/bytefly/notice/component.ts +6 -4
  5. package/extensions/bytefly/notice/config.ts +16 -2
  6. package/extensions/bytefly/panel/format.ts +3 -8
  7. package/extensions/bytefly/panel/join.ts +1 -6
  8. package/extensions/bytefly/panel/settings.ts +74 -377
  9. package/extensions/bytefly/panel/tui/entries.ts +16 -14
  10. package/extensions/bytefly/panel/tui/extensions-view.ts +3 -0
  11. package/extensions/bytefly/panel/tui/frame.ts +2 -2
  12. package/extensions/bytefly/panel/tui/settings-view.ts +8 -12
  13. package/extensions/bytefly/panel/tui.ts +4 -6
  14. package/extensions/bytefly/picker/resources.ts +7 -3
  15. package/extensions/bytefly/working/register.ts +2 -8
  16. package/extensions/check/tools.ts +2 -11
  17. package/extensions/context/README.md +162 -0
  18. package/extensions/context/collect.ts +53 -0
  19. package/extensions/context/commands.ts +100 -0
  20. package/extensions/context/format.ts +82 -0
  21. package/extensions/context/index.ts +16 -0
  22. package/extensions/context/report.ts +319 -0
  23. package/extensions/context/tui.ts +268 -0
  24. package/extensions/context/types.ts +122 -0
  25. package/extensions/file-browse/README.md +38 -18
  26. package/extensions/file-browse/config.ts +8 -0
  27. package/extensions/file-browse/html.ts +2 -2
  28. package/extensions/file-browse/index.ts +17 -2
  29. package/extensions/file-browse/live-client.ts +13 -13
  30. package/extensions/file-browse/live.ts +44 -10
  31. package/extensions/file-browse/markdown.ts +3 -10
  32. package/extensions/file-browse/raw-html.ts +3 -10
  33. package/extensions/file-browse/server.ts +13 -2
  34. package/extensions/file-open/README.md +1 -2
  35. package/extensions/{document → file-read}/README.md +72 -56
  36. package/extensions/{document → file-read}/config.ts +12 -9
  37. package/extensions/{document → file-read}/extract/doc.ts +2 -2
  38. package/extensions/{document → file-read}/extract/docx.ts +3 -3
  39. package/extensions/{document → file-read}/extract/file.ts +9 -9
  40. package/extensions/{document → file-read}/extract/odt.ts +3 -3
  41. package/extensions/{document → file-read}/extract/pdf.ts +3 -3
  42. package/extensions/{document → file-read}/extract/rtf.ts +3 -3
  43. package/extensions/{document → file-read}/extract/xlsx.ts +4 -4
  44. package/extensions/{document → file-read}/formats.ts +11 -11
  45. package/extensions/file-read/index.ts +17 -0
  46. package/extensions/{document → file-read}/paging.ts +15 -13
  47. package/extensions/{document → file-read}/render.ts +17 -13
  48. package/extensions/{document → file-read}/schema.ts +7 -7
  49. package/extensions/{document → file-read}/tool.ts +36 -28
  50. package/extensions/goal/tools.ts +10 -14
  51. package/extensions/job/commands.ts +32 -46
  52. package/extensions/job/runtime.ts +6 -8
  53. package/extensions/job/tools.ts +7 -26
  54. package/extensions/memory/commands.ts +30 -44
  55. package/extensions/memory/tools.ts +6 -14
  56. package/extensions/plan/tools/enter.ts +6 -7
  57. package/extensions/plan/tools/exit.ts +6 -7
  58. package/extensions/plan/tools/steps.ts +6 -7
  59. package/extensions/plan/tools/write.ts +8 -9
  60. package/extensions/plan/web.ts +21 -12
  61. package/extensions/question/tools.ts +5 -4
  62. package/extensions/subagent/background.ts +1 -7
  63. package/extensions/todo/tools.ts +10 -14
  64. package/extensions/watch/README.md +5 -5
  65. package/extensions/watch/commands.ts +33 -47
  66. package/extensions/watch/runtime.ts +5 -7
  67. package/extensions/watch/tools.ts +7 -18
  68. package/extensions/web-access/README.md +3 -3
  69. package/extensions/web-access/search/search.ts +9 -18
  70. package/extensions/wechat/timer.ts +9 -30
  71. package/extensions/wechat/tools.ts +4 -7
  72. package/lib/README.md +9 -6
  73. package/lib/escape.ts +15 -2
  74. package/lib/pdf.ts +2 -2
  75. package/lib/requirements.ts +2 -2
  76. package/lib/screen-session.ts +85 -0
  77. package/lib/timer.ts +61 -0
  78. package/lib/tui/README.md +3 -3
  79. package/lib/tui/columns.ts +33 -8
  80. package/lib/tui/rows.ts +15 -6
  81. package/lib/tui/rules.ts +4 -8
  82. package/lib/tui/tool.ts +55 -2
  83. package/package.json +8 -4
  84. package/skills/changelog/SKILL.md +136 -0
  85. package/extensions/document/index.ts +0 -17
  86. /package/extensions/{document → file-read}/deadline.ts +0 -0
  87. /package/extensions/{document → file-read}/extract/vendor.d.ts +0 -0
@@ -0,0 +1,319 @@
1
+ /**
2
+ * The pure half of the report: turn collected inputs into the numbers a reader
3
+ * sees, with no host access and no theme.
4
+ *
5
+ * Two rules keep the report honest. First, the headline is the host's number
6
+ * (`ctx.getContextUsage()`), and the composition is this module's estimate, so
7
+ * the difference is reported as `drift` instead of being hidden by forcing the
8
+ * parts to add up. Second, the compaction threshold is resolved the way the host
9
+ * resolves it — per-model override, then the shared setting, then Pi's default —
10
+ * because the point of the report is to predict when Pi will compact.
11
+ */
12
+
13
+ import type { AgentMessage } from "@earendil-works/pi-agent-core";
14
+ import { DEFAULT_COMPACTION_SETTINGS, estimateTokens, getLatestCompactionEntry } from "@earendil-works/pi-coding-agent";
15
+ import { sanitizeLine } from "../../lib/format.ts";
16
+ import type {
17
+ CompactionHistory,
18
+ CompactionSummary,
19
+ ContextBucket,
20
+ ContextInputs,
21
+ ContextReport,
22
+ ContextSettings,
23
+ LargestBlock,
24
+ } from "./types.ts";
25
+
26
+ /** The host's chars/4 heuristic, for text that is not a `message` (system prompt, schemas). */
27
+ export function estimateText(text: string): number {
28
+ return estimateChars(text.length);
29
+ }
30
+
31
+ /** {@link estimateText} when only a character count is known, without building the string. */
32
+ export function estimateChars(chars: number): number {
33
+ return Math.ceil(chars / 4);
34
+ }
35
+
36
+ /** The compaction numbers in effect for one model. */
37
+ export interface ResolvedCompaction {
38
+ enabled: boolean;
39
+ reserve: number;
40
+ keepRecentTokens: number;
41
+ }
42
+
43
+ /** A token setting as the host reads it: a finite, non-negative whole number. */
44
+ function tokenSetting(value: number | undefined, fallback: number): number {
45
+ if (typeof value !== "number" || !Number.isFinite(value) || value < 0) return fallback;
46
+ return Math.floor(value);
47
+ }
48
+
49
+ /**
50
+ * Resolve the compaction settings for `model` exactly as Pi does:
51
+ * `compaction.modelOverrides["provider/id"]`, then `compaction`, then the default.
52
+ */
53
+ export function resolveCompactionSettings(
54
+ settings: ContextSettings,
55
+ model: { provider: string; id: string } | null,
56
+ ): ResolvedCompaction {
57
+ const compaction = settings.compaction;
58
+ const override = model ? compaction?.modelOverrides?.[`${model.provider}/${model.id}`] : undefined;
59
+ return {
60
+ enabled: compaction?.enabled ?? DEFAULT_COMPACTION_SETTINGS.enabled,
61
+ reserve: tokenSetting(
62
+ override?.reserveTokens,
63
+ tokenSetting(compaction?.reserveTokens, DEFAULT_COMPACTION_SETTINGS.reserveTokens),
64
+ ),
65
+ keepRecentTokens: tokenSetting(
66
+ override?.keepRecentTokens,
67
+ tokenSetting(compaction?.keepRecentTokens, DEFAULT_COMPACTION_SETTINGS.keepRecentTokens),
68
+ ),
69
+ };
70
+ }
71
+
72
+ /** A message's role; custom roles are matched by name, never by a host type. */
73
+ function roleOf(message: AgentMessage): string {
74
+ const role = (message as { role?: unknown }).role;
75
+ return typeof role === "string" ? role : "";
76
+ }
77
+
78
+ /** The `customType` an injected message carries; the pack tags every injection. */
79
+ function customTypeOf(message: AgentMessage): string {
80
+ const customType = (message as { customType?: unknown }).customType;
81
+ return typeof customType === "string" && customType.trim() ? sanitizeLine(customType) : "injected";
82
+ }
83
+
84
+ /** The text a message's content starts with, for a descriptor. */
85
+ function firstText(content: unknown): string {
86
+ if (typeof content === "string") return content;
87
+ if (!Array.isArray(content)) return "";
88
+ for (const block of content) {
89
+ if (typeof block !== "object" || block === null) continue;
90
+ if ((block as { type?: unknown }).type !== "text") continue;
91
+ const text = (block as { text?: unknown }).text;
92
+ if (typeof text === "string" && text.trim()) return text;
93
+ }
94
+ return "";
95
+ }
96
+
97
+ /** A one-line, bounded descriptor for the "largest blocks" list. */
98
+ function boundedLabel(text: string, maxLength = 40): string {
99
+ const line = sanitizeLine(text);
100
+ return line.length > maxLength ? `${line.slice(0, maxLength - 1)}…` : line;
101
+ }
102
+
103
+ /** What kind of message a block is, in the report's vocabulary. */
104
+ const ROLE_LABELS: Readonly<Record<string, string>> = {
105
+ toolResult: "tool result",
106
+ assistant: "assistant",
107
+ user: "user",
108
+ custom: "injected",
109
+ bashExecution: "shell",
110
+ branchSummary: "summary",
111
+ compactionSummary: "summary",
112
+ system: "system",
113
+ };
114
+
115
+ function roleLabel(message: AgentMessage): string {
116
+ return ROLE_LABELS[roleOf(message)] ?? "message";
117
+ }
118
+
119
+ /** The descriptor under a block's role: a tool name, a first line, a custom type. */
120
+ function descriptorOf(message: AgentMessage): string {
121
+ switch (roleOf(message)) {
122
+ case "toolResult": {
123
+ const name = (message as { toolName?: unknown }).toolName;
124
+ return typeof name === "string" && name ? sanitizeLine(name) : "result";
125
+ }
126
+ case "custom":
127
+ return customTypeOf(message);
128
+ case "bashExecution": {
129
+ const command = (message as { command?: unknown }).command;
130
+ return boundedLabel(typeof command === "string" ? command : "command");
131
+ }
132
+ case "branchSummary":
133
+ case "compactionSummary": {
134
+ const summary = (message as { summary?: unknown }).summary;
135
+ return boundedLabel(typeof summary === "string" ? summary : "summary");
136
+ }
137
+ default: {
138
+ const text = firstText((message as { content?: unknown }).content);
139
+ return text ? boundedLabel(text) : roleLabel(message);
140
+ }
141
+ }
142
+ }
143
+
144
+ /** `YYYY-MM-DD HH:MMZ` from an entry's ISO timestamp, without locale formatting. */
145
+ export function compactTimestamp(timestamp: string): string {
146
+ const match = /^(\d{4}-\d{2}-\d{2})T(\d{2}:\d{2})/.exec(timestamp);
147
+ return match ? `${match[1]} ${match[2]}Z` : boundedLabel(timestamp, 24);
148
+ }
149
+
150
+ /** The branch's compaction history, newest last in the entry list. */
151
+ function compactionHistory(inputs: ContextInputs): CompactionHistory {
152
+ const count = inputs.branch.filter((entry) => entry.type === "compaction").length;
153
+ const latest = getLatestCompactionEntry(inputs.branch);
154
+ if (!latest) return { count, last: null };
155
+ const summaryTokens = estimateText(latest.summary);
156
+ const last: CompactionSummary = {
157
+ at: compactTimestamp(latest.timestamp),
158
+ tokensBefore: latest.tokensBefore,
159
+ summaryTokens,
160
+ freed: Math.max(0, latest.tokensBefore - summaryTokens),
161
+ };
162
+ return { count, last };
163
+ }
164
+
165
+ /** A top-level row before shares and nesting are applied. */
166
+ interface BucketDraft {
167
+ id: string;
168
+ label: string;
169
+ tokens: number;
170
+ note?: string;
171
+ }
172
+
173
+ /** Round a share so the text and the screen cannot disagree about precision. */
174
+ function shareOf(tokens: number, total: number): number {
175
+ if (total <= 0) return 0;
176
+ return Math.round((tokens / total) * 1000) / 10;
177
+ }
178
+
179
+ /** Build the report from collected inputs. Pure: the same inputs give the same numbers. */
180
+ export function buildReport(inputs: ContextInputs): ContextReport {
181
+ const resolved = resolveCompactionSettings(inputs.settings, inputs.model);
182
+ const window = inputs.window > 0 ? inputs.window : (inputs.usage?.contextWindow ?? 0);
183
+ const tokens = inputs.usage?.tokens ?? null;
184
+ const percent = inputs.usage?.percent ?? (tokens !== null && window > 0 ? (tokens / window) * 100 : null);
185
+ const free = tokens !== null && window > 0 ? window - tokens : null;
186
+ const autoCompactAt = window > 0 ? Math.max(0, window - resolved.reserve) : 0;
187
+ const headroom = tokens !== null && free !== null && window > 0 ? autoCompactAt - tokens : null;
188
+
189
+ const messageCounts = new Map<string, number>();
190
+ const messageTokens = new Map<string, number>();
191
+ const injectedTokens = new Map<string, number>();
192
+ const injectedCounts = new Map<string, number>();
193
+ let systemTokens = 0;
194
+ const largestCandidates: Array<{ message: AgentMessage; tokens: number }> = [];
195
+
196
+ const add = (id: string, estimate: number): void => {
197
+ messageTokens.set(id, (messageTokens.get(id) ?? 0) + estimate);
198
+ messageCounts.set(id, (messageCounts.get(id) ?? 0) + 1);
199
+ };
200
+
201
+ for (const message of inputs.messages) {
202
+ const estimate = estimateTokens(message);
203
+ if (estimate > 0) largestCandidates.push({ message, tokens: estimate });
204
+ switch (roleOf(message)) {
205
+ case "system":
206
+ systemTokens += estimate;
207
+ break;
208
+ case "user":
209
+ add("user", estimate);
210
+ break;
211
+ case "assistant":
212
+ add("assistant", estimate);
213
+ break;
214
+ case "toolResult":
215
+ add("toolResults", estimate);
216
+ break;
217
+ case "custom": {
218
+ const customType = customTypeOf(message);
219
+ injectedTokens.set(customType, (injectedTokens.get(customType) ?? 0) + estimate);
220
+ injectedCounts.set(customType, (injectedCounts.get(customType) ?? 0) + 1);
221
+ break;
222
+ }
223
+ case "branchSummary":
224
+ case "compactionSummary":
225
+ add("summaries", estimate);
226
+ break;
227
+ case "bashExecution":
228
+ add("bash", estimate);
229
+ break;
230
+ default:
231
+ // Pi's estimator returns 0 for a role it does not know, so there is
232
+ // nothing to attribute and no row to open.
233
+ break;
234
+ }
235
+ }
236
+
237
+ // The host counts the system prompt once. Prefer a system message when the
238
+ // projection carries one, so it is never counted twice.
239
+ const systemEstimate = systemTokens > 0 ? systemTokens : estimateText(inputs.systemPrompt);
240
+
241
+ const top: BucketDraft[] = [];
242
+ const push = (id: string, label: string, estimate: number, note?: string): void => {
243
+ if (estimate <= 0) return;
244
+ top.push({ id, label, tokens: estimate, ...(note ? { note } : {}) });
245
+ };
246
+ const countNote = (count: number): string | undefined =>
247
+ count > 0 ? `${count} ${count === 1 ? "message" : "messages"}` : undefined;
248
+ const messageNote = (id: string): string | undefined => countNote(messageCounts.get(id) ?? 0);
249
+
250
+ push("system", "System prompt", systemEstimate);
251
+ push("tools", "Tool definitions", inputs.tools.tokens, `${inputs.tools.active} active of ${inputs.tools.total}`);
252
+ push("user", "User messages", messageTokens.get("user") ?? 0, messageNote("user"));
253
+ push("assistant", "Assistant", messageTokens.get("assistant") ?? 0, messageNote("assistant"));
254
+ push("toolResults", "Tool results", messageTokens.get("toolResults") ?? 0, messageNote("toolResults"));
255
+
256
+ const injectedTotal = [...injectedTokens.values()].reduce((sum, value) => sum + value, 0);
257
+ const injectedCount = [...injectedCounts.values()].reduce((sum, value) => sum + value, 0);
258
+ push("injected", "Injected context", injectedTotal, countNote(injectedCount));
259
+
260
+ push("summaries", "Summaries", messageTokens.get("summaries") ?? 0, messageNote("summaries"));
261
+ push("bash", "Shell output", messageTokens.get("bash") ?? 0, messageNote("bash"));
262
+
263
+ const estimate = top.reduce((sum, bucket) => sum + bucket.tokens, 0);
264
+
265
+ // Injected children: per custom type, biggest first, then one "other" row so
266
+ // the child list stays short without dropping a type's share.
267
+ const children = [...injectedTokens.entries()].sort((a, b) => b[1] - a[1]);
268
+ const shown = children.slice(0, 5);
269
+ const rest = children.slice(5);
270
+ const childDrafts: BucketDraft[] = shown.map(([customType, estimateForType]) => ({
271
+ id: `injected:${customType}`,
272
+ label: customType,
273
+ tokens: estimateForType,
274
+ note: countNote(injectedCounts.get(customType) ?? 0),
275
+ }));
276
+ if (rest.length > 0) {
277
+ childDrafts.push({
278
+ id: "injected:other",
279
+ label: `${rest.length} more`,
280
+ tokens: rest.reduce((sum, [, value]) => sum + value, 0),
281
+ note: countNote(rest.reduce((sum, [type]) => sum + (injectedCounts.get(type) ?? 0), 0)),
282
+ });
283
+ }
284
+
285
+ const buckets: ContextBucket[] = [];
286
+ for (const draft of top) {
287
+ buckets.push({ ...draft, share: shareOf(draft.tokens, estimate), depth: 0 });
288
+ if (draft.id !== "injected") continue;
289
+ for (const child of childDrafts) {
290
+ buckets.push({ ...child, share: shareOf(child.tokens, estimate), depth: 1 });
291
+ }
292
+ }
293
+
294
+ const largest: LargestBlock[] = largestCandidates
295
+ .sort((a, b) => b.tokens - a.tokens)
296
+ .slice(0, 5)
297
+ .map((candidate) => ({
298
+ tokens: candidate.tokens,
299
+ role: roleLabel(candidate.message),
300
+ label: descriptorOf(candidate.message),
301
+ }));
302
+
303
+ return {
304
+ window,
305
+ tokens,
306
+ percent,
307
+ free,
308
+ headroom,
309
+ reserve: resolved.reserve,
310
+ keepRecentTokens: resolved.keepRecentTokens,
311
+ enabled: resolved.enabled,
312
+ autoCompactAt,
313
+ buckets,
314
+ estimate,
315
+ drift: tokens !== null ? tokens - estimate : null,
316
+ compaction: compactionHistory(inputs),
317
+ largest,
318
+ };
319
+ }
@@ -0,0 +1,268 @@
1
+ /**
2
+ * The `/context` screen: one docked report, with the one action the report
3
+ * justifies.
4
+ *
5
+ * The component owns the scroll, the confirm layer, and the feedback line; it
6
+ * owns no host access. The command hands it a report and two callbacks (`read`
7
+ * and `compact`), which keeps the screen testable with a plain object and keeps
8
+ * the host boundary in `collect.ts` / `commands.ts`.
9
+ *
10
+ * The report is re-read on open, on `r`, and after a compaction — never per
11
+ * frame. Arrow keys only move a slice of a body that is already built, so a
12
+ * long session does not re-walk its entries on every keypress.
13
+ */
14
+
15
+ import type { CompactionResult, Theme } from "@earendil-works/pi-coding-agent";
16
+ import { truncateToWidth, visibleWidth, type Component } from "@earendil-works/pi-tui";
17
+ import { messageOf } from "../../lib/errors.ts";
18
+ import { formatTokens } from "../../lib/format.ts";
19
+ import { screenHint, type ViewportRowsSource } from "../../lib/tui/chrome.ts";
20
+ import { ConfirmState } from "../../lib/tui/confirm.ts";
21
+ import { clampScroll, fitRows, formatRange, isCloseKey, navigateScroll } from "../../lib/tui/cursor.ts";
22
+ import { gaugeBar, gaugeRole } from "../../lib/tui/gauge.ts";
23
+ import { BODY_INDENT } from "../../lib/tui/indent.ts";
24
+ import { titleRule } from "../../lib/tui/rules.ts";
25
+ import { paint, type TuiRole } from "../../lib/tui/tokens.ts";
26
+ import { formatBudget, formatCompaction, formatDrift } from "./format.ts";
27
+ import type { ContextBucket, ContextReport } from "./types.ts";
28
+
29
+ /** Fixed rows around the scrollable body: title, gauge, budget, blank, blank, footer, blank. */
30
+ const CHROME_ROWS = 7;
31
+
32
+ /** Cells in the composition bar. */
33
+ const BUCKET_BLOCKS = 8;
34
+
35
+ /** Body rows when the terminal height is unknown. */
36
+ const DEFAULT_BODY_ROWS = 12;
37
+
38
+ /** What the compact action needs from the screen. */
39
+ export interface ContextCompactCallbacks {
40
+ done(result: CompactionResult): void;
41
+ fail(error: unknown): void;
42
+ }
43
+
44
+ /** Everything the screen needs; the command supplies the host work. */
45
+ export interface ContextScreenOptions {
46
+ theme: Theme;
47
+ /** The report as opened. */
48
+ report: ContextReport;
49
+ /** Re-collect and rebuild the report. */
50
+ read(): ContextReport;
51
+ /** Whether the agent is busy (a turn or a compaction); compaction is refused while it is. */
52
+ isIdle(): boolean;
53
+ /** Start the host's compaction with custom instructions, or the plain one. */
54
+ compact(instructions: string | undefined, callbacks: ContextCompactCallbacks): void;
55
+ /** Close the screen. */
56
+ close(): void;
57
+ /** Ask the host to repaint. */
58
+ requestRender(): void;
59
+ /** Terminal height, read fresh so a resize is picked up. */
60
+ viewportRowsSource?: ViewportRowsSource;
61
+ }
62
+
63
+ /** A feedback message and the role it is painted in. */
64
+ interface Feedback {
65
+ text: string;
66
+ role: TuiRole;
67
+ }
68
+
69
+ /** A right-aligned cell in a composition row. */
70
+ function shareCell(share: number): string {
71
+ return `${share.toFixed(1)}%`.padStart(6);
72
+ }
73
+
74
+ /** Pad a label to its column with spaces, measured in terminal columns. */
75
+ function padCell(text: string, width: number): string {
76
+ const clipped = truncateToWidth(text, width, "…");
77
+ return `${clipped}${" ".repeat(Math.max(0, width - visibleWidth(clipped)))}`;
78
+ }
79
+
80
+ /** The scrollable, read-only report. */
81
+ export class ContextComponent implements Component {
82
+ private report: ContextReport;
83
+ private scroll = 0;
84
+ private visible = DEFAULT_BODY_ROWS;
85
+ private body: string[] = [];
86
+ private feedback: Feedback | undefined;
87
+ private readonly confirm = new ConfirmState();
88
+
89
+ constructor(private readonly options: ContextScreenOptions) {
90
+ this.report = options.report;
91
+ }
92
+
93
+ private get theme(): Theme {
94
+ return this.options.theme;
95
+ }
96
+
97
+ /** Replace the report from the host and repaint. */
98
+ private refresh(): void {
99
+ this.report = this.options.read();
100
+ this.requestRender();
101
+ }
102
+
103
+ private requestRender(): void {
104
+ this.options.requestRender();
105
+ }
106
+
107
+ private setFeedback(text: string, role: TuiRole): void {
108
+ this.feedback = { text, role };
109
+ this.requestRender();
110
+ }
111
+
112
+ /** Ask before compacting, then run the action only once the turn is idle. */
113
+ private askCompact(): void {
114
+ this.confirm.ask("Compact the conversation now? y/N", () => {
115
+ if (!this.options.isIdle()) {
116
+ this.setFeedback("The agent is busy (a turn or a compaction is running); wait for it to finish.", "warn");
117
+ return;
118
+ }
119
+ this.feedback = { text: "Compacting…", role: "meta" };
120
+ this.requestRender();
121
+ this.options.compact(undefined, {
122
+ done: (result) => {
123
+ this.report = this.options.read();
124
+ const after = result.estimatedTokensAfter === undefined ? "?" : formatTokens(result.estimatedTokensAfter);
125
+ this.setFeedback(`Compacted ${formatTokens(result.tokensBefore)} → ${after}`, "ok");
126
+ },
127
+ fail: (error) => {
128
+ this.setFeedback(`Compaction failed: ${messageOf(error)}`, "bad");
129
+ },
130
+ });
131
+ });
132
+ this.requestRender();
133
+ }
134
+
135
+ /** The gauge line: the host's number, its bar, and its percentage. */
136
+ private gaugeLine(width: number): string {
137
+ const { percent, tokens, window } = this.report;
138
+ if (window <= 0) return truncateToWidth(` ${paint(this.theme, "meta", "no model context window")}`, width);
139
+ const role = gaugeRole(percent);
140
+ const value = tokens === null ? `? / ${formatTokens(window)}` : `${formatTokens(tokens)} / ${formatTokens(window)}`;
141
+ const bar = gaugeBar(this.theme, { percent, blocks: 10 });
142
+ const label = percent === null ? "unknown" : `${Math.round(percent)}%`;
143
+ return truncateToWidth(` ${paint(this.theme, role, value)} ${bar} ${paint(this.theme, role, label)}`, width);
144
+ }
145
+
146
+ /** One composition row; optional columns drop as the terminal narrows. */
147
+ private bucketLine(bucket: ContextBucket, width: number): string {
148
+ const indent = " ".repeat(bucket.depth > 0 ? 4 : 2);
149
+ const labelWidth = bucket.depth > 0 ? 16 : 18;
150
+ const label = padCell(bucket.label, labelWidth);
151
+ const tokens = formatTokens(bucket.tokens).padStart(6);
152
+ const share = shareCell(bucket.share);
153
+ const note = bucket.note ? ` ${bucket.note}` : "";
154
+ const bar = gaugeBar(this.theme, {
155
+ percent: bucket.share,
156
+ blocks: BUCKET_BLOCKS,
157
+ role: "meta",
158
+ emptyRole: "faint",
159
+ });
160
+ const head = `${indent}${paint(this.theme, "label", label)}${paint(this.theme, "meta", tokens)}`;
161
+ // Keep the tokens and the label above a trailing note, then the percentage,
162
+ // then the bar, so a narrow terminal still reads "what" and "how much".
163
+ const candidates = [`${head} ${share} ${bar}${note}`, `${head} ${share}${note}`, `${head}${note}`, head];
164
+ for (const candidate of candidates) {
165
+ if (visibleWidth(candidate) <= width) return candidate;
166
+ }
167
+ return truncateToWidth(head, width);
168
+ }
169
+
170
+ /** The scrollable body: composition, drift, compaction, and the largest blocks. */
171
+ private buildBody(width: number): string[] {
172
+ const report = this.report;
173
+ const lines: string[] = report.buckets.map((bucket) => this.bucketLine(bucket, width));
174
+
175
+ const drift = formatDrift(report);
176
+ if (drift) lines.push("", truncateToWidth(` ${paint(this.theme, "warn", drift)}`, width));
177
+
178
+ lines.push(
179
+ "",
180
+ truncateToWidth(
181
+ ` ${paint(this.theme, "label", "Compaction")} ${paint(this.theme, "meta", formatCompaction(report))}`,
182
+ width,
183
+ ),
184
+ );
185
+
186
+ if (report.largest.length > 0) {
187
+ lines.push("", ` ${paint(this.theme, "label", "Largest blocks")}`);
188
+ for (const block of report.largest) {
189
+ const tokens = formatTokens(block.tokens).padStart(6);
190
+ const role = padCell(block.role, 11);
191
+ const candidate = ` ${paint(this.theme, "meta", tokens)} ${paint(this.theme, "label", role)}${block.label}`;
192
+ lines.push(truncateToWidth(candidate, width));
193
+ }
194
+ }
195
+ return lines;
196
+ }
197
+
198
+ /** The footer: a feedback line, the pending confirmation, or the key hints. */
199
+ private footerLine(width: number): string {
200
+ const pending = this.confirm.pending;
201
+ if (pending) return truncateToWidth(` ${paint(this.theme, "warn", pending.prompt)}`, width);
202
+ if (this.feedback) return truncateToWidth(` ${paint(this.theme, this.feedback.role, this.feedback.text)}`, width);
203
+ return screenHint(
204
+ this.theme,
205
+ width,
206
+ [
207
+ { text: "c compact", color: "accent" },
208
+ { text: "r refresh", color: "dim" },
209
+ { text: "↑↓ scroll", color: "dim" },
210
+ { text: "Esc close", color: "dim" },
211
+ ],
212
+ BODY_INDENT,
213
+ );
214
+ }
215
+
216
+ render(width: number): string[] {
217
+ const w = Math.max(1, width);
218
+ this.body = this.buildBody(w);
219
+ this.visible = fitRows(this.options.viewportRowsSource, this.body.length, CHROME_ROWS, DEFAULT_BODY_ROWS);
220
+ this.scroll = clampScroll(this.scroll, this.body.length, this.visible);
221
+
222
+ const end = Math.min(this.body.length, this.scroll + this.visible);
223
+ const lines = [
224
+ titleRule(this.theme, w, "Context"),
225
+ this.gaugeLine(w),
226
+ truncateToWidth(` ${paint(this.theme, "meta", formatBudget(this.report))}`, w),
227
+ "",
228
+ ...this.body.slice(this.scroll, end),
229
+ ];
230
+ if (this.body.length > this.visible) {
231
+ lines.push(formatRange(this.theme, w, { start: this.scroll, end, total: this.body.length }));
232
+ }
233
+ lines.push("", this.footerLine(w), "");
234
+ return lines.map((line) => truncateToWidth(line, w));
235
+ }
236
+
237
+ handleInput(data: string): void {
238
+ const outcome = this.confirm.resolve(data);
239
+ if (outcome) {
240
+ this.requestRender();
241
+ return;
242
+ }
243
+ if (data === "g" || data === "G") {
244
+ this.scroll = data === "g" ? 0 : Math.max(0, this.body.length - this.visible);
245
+ this.requestRender();
246
+ return;
247
+ }
248
+ const next = navigateScroll(data, this.scroll, this.body.length, this.visible);
249
+ if (next !== undefined) {
250
+ this.scroll = next;
251
+ this.requestRender();
252
+ return;
253
+ }
254
+ if (data === "c") {
255
+ this.askCompact();
256
+ return;
257
+ }
258
+ if (data === "r") {
259
+ this.feedback = { text: "Refreshed.", role: "meta" };
260
+ this.refresh();
261
+ return;
262
+ }
263
+ if (isCloseKey(data) || data === "q") this.options.close();
264
+ }
265
+
266
+ /** A read-only report caches nothing, so a theme change has nothing to drop. */
267
+ invalidate(): void {}
268
+ }