pi-one-ui 0.1.1

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 (101) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +106 -0
  3. package/extensions/app/config/renderer.ts +452 -0
  4. package/extensions/app/config/shell.ts +2484 -0
  5. package/extensions/app/ownership.ts +18 -0
  6. package/extensions/app/panel.ts +533 -0
  7. package/extensions/app/presets.ts +36 -0
  8. package/extensions/features/agent-summary/core.ts +194 -0
  9. package/extensions/features/agent-summary/index.ts +37 -0
  10. package/extensions/features/compact-thinking.ts +1262 -0
  11. package/extensions/features/context.ts +857 -0
  12. package/extensions/features/reference/index.ts +643 -0
  13. package/extensions/features/reference/session.ts +278 -0
  14. package/extensions/features/reference/subagent.ts +218 -0
  15. package/extensions/features/shell/aliases.ts +28 -0
  16. package/extensions/features/shell/flush-docked-bash.ts +39 -0
  17. package/extensions/features/shell/startup-header.ts +357 -0
  18. package/extensions/features/shell/working-message.ts +226 -0
  19. package/extensions/index.ts +52 -0
  20. package/extensions/shell/accent-rail-editor.ts +112 -0
  21. package/extensions/shell/accent-rail-layout-patch.ts +530 -0
  22. package/extensions/shell/completion-menu.ts +186 -0
  23. package/extensions/shell/editor-metadata-format.ts +287 -0
  24. package/extensions/shell/editor-transfer.ts +73 -0
  25. package/extensions/shell/extension-status.ts +102 -0
  26. package/extensions/shell/footer-format.ts +388 -0
  27. package/extensions/shell/footer-layout.ts +144 -0
  28. package/extensions/shell/footer.ts +1035 -0
  29. package/extensions/shell/format.ts +577 -0
  30. package/extensions/shell/git.ts +434 -0
  31. package/extensions/shell/icons.ts +261 -0
  32. package/extensions/shell/index.ts +1659 -0
  33. package/extensions/shell/interaction-summary.ts +1194 -0
  34. package/extensions/shell/live-context.ts +82 -0
  35. package/extensions/shell/minimalist-editor.ts +548 -0
  36. package/extensions/shell/package-version.ts +675 -0
  37. package/extensions/shell/project-refresh.ts +130 -0
  38. package/extensions/shell/project-state.ts +60 -0
  39. package/extensions/shell/prototype-patch-registry.ts +183 -0
  40. package/extensions/shell/runtime.ts +915 -0
  41. package/extensions/shell/selector-border.ts +109 -0
  42. package/extensions/shell/session-lifecycle.ts +60 -0
  43. package/extensions/shell/settings-command.ts +2144 -0
  44. package/extensions/shell/settings-previews.ts +137 -0
  45. package/extensions/shell/state.ts +83 -0
  46. package/extensions/shell/style.ts +480 -0
  47. package/extensions/shell/telemetry.ts +90 -0
  48. package/extensions/shell/ui.ts +1326 -0
  49. package/extensions/shell/user-message-osc.ts +279 -0
  50. package/extensions/shell/user-message-styles.ts +266 -0
  51. package/extensions/shell/user-message.ts +226 -0
  52. package/extensions/shell/working-line-messages.ts +24 -0
  53. package/extensions/shell/working-line-spinners.ts +54 -0
  54. package/extensions/shell/working-line.ts +1603 -0
  55. package/extensions/tools/ansi-color.ts +44 -0
  56. package/extensions/tools/ansi-text.ts +52 -0
  57. package/extensions/tools/component-tree.ts +37 -0
  58. package/extensions/tools/format.ts +28 -0
  59. package/extensions/tools/fullscreen-detect.ts +11 -0
  60. package/extensions/tools/patch-keys.ts +158 -0
  61. package/extensions/tools/sgr-mouse.ts +59 -0
  62. package/extensions/tools/tool-loading-icon.ts +20 -0
  63. package/extensions/tools/tool-result-sanitize.ts +27 -0
  64. package/extensions/transcript/index.ts +65 -0
  65. package/extensions/transcript/renderer/compact-mode.ts +1443 -0
  66. package/extensions/transcript/renderer/default-mode.ts +735 -0
  67. package/extensions/transcript/renderer/index.ts +286 -0
  68. package/extensions/transcript/renderer/markdown-enhance.ts +325 -0
  69. package/extensions/transcript/renderer/mouse/hover.ts +175 -0
  70. package/extensions/transcript/renderer/mouse/interaction.ts +1016 -0
  71. package/extensions/transcript/renderer/mouse/layout.ts +207 -0
  72. package/extensions/transcript/renderer/mouse/packets.ts +96 -0
  73. package/extensions/transcript/renderer/mouse/scroll.ts +211 -0
  74. package/extensions/transcript/renderer/tool/diff/ATTRIBUTION.md +28 -0
  75. package/extensions/transcript/renderer/tool/diff/ansi-utils.ts +128 -0
  76. package/extensions/transcript/renderer/tool/diff/diff-component.ts +112 -0
  77. package/extensions/transcript/renderer/tool/diff/diff-edit-render.ts +255 -0
  78. package/extensions/transcript/renderer/tool/diff/diff-header.ts +184 -0
  79. package/extensions/transcript/renderer/tool/diff/diff-highlight.ts +131 -0
  80. package/extensions/transcript/renderer/tool/diff/diff-inline.ts +350 -0
  81. package/extensions/transcript/renderer/tool/diff/diff-layout.ts +870 -0
  82. package/extensions/transcript/renderer/tool/diff/diff-limits.ts +189 -0
  83. package/extensions/transcript/renderer/tool/diff/diff-palette.ts +436 -0
  84. package/extensions/transcript/renderer/tool/diff/diff-parse.ts +536 -0
  85. package/extensions/transcript/renderer/tool/diff/diff-presentation.ts +79 -0
  86. package/extensions/transcript/renderer/tool/diff/diff-renderer.ts +29 -0
  87. package/extensions/transcript/renderer/tool/diff/diff-text.ts +57 -0
  88. package/extensions/transcript/renderer/tool/diff/diff-write-render.ts +629 -0
  89. package/extensions/transcript/renderer/tool/diff/index.ts +147 -0
  90. package/extensions/transcript/renderer/tool/diff/line-width-safety.ts +100 -0
  91. package/extensions/transcript/renderer/tool/diff/shiki-highlight.ts +104 -0
  92. package/extensions/transcript/renderer/tool/diff/write-execution.ts +124 -0
  93. package/extensions/transcript/renderer/tool/grouping.ts +619 -0
  94. package/extensions/transcript/renderer/tool/message-display.ts +183 -0
  95. package/extensions/transcript/renderer/tool/names.ts +195 -0
  96. package/extensions/transcript/renderer/tool/result.ts +842 -0
  97. package/extensions/transcript/renderer/tool/show-more-hint.ts +42 -0
  98. package/extensions/transcript/renderer/transcript-refresh.ts +79 -0
  99. package/package.json +58 -0
  100. package/themes/cc-dark.json +91 -0
  101. package/themes/cc-light.json +91 -0
@@ -0,0 +1,857 @@
1
+ import {
2
+ type BuildSystemPromptOptions,
3
+ type ExtensionAPI,
4
+ type ExtensionCommandContext,
5
+ type ToolInfo,
6
+ estimateTokens,
7
+ formatSkillsForPrompt,
8
+ getMarkdownTheme,
9
+ } from "@earendil-works/pi-coding-agent";
10
+ import {
11
+ Key,
12
+ Markdown,
13
+ matchesKey,
14
+ visibleWidth,
15
+ } from "@earendil-works/pi-tui";
16
+ import { mouseBaseButton, parseSgrMousePacket } from "../tools/sgr-mouse.ts";
17
+ import { padLine } from "../tools/format.ts";
18
+
19
+ export type ContextPart = {
20
+ label: string;
21
+ tokens: number;
22
+ color:
23
+ | "accent"
24
+ | "success"
25
+ | "warning"
26
+ | "customMessageLabel"
27
+ | "muted"
28
+ | "dim"
29
+ | "error";
30
+ };
31
+
32
+ type PreviewKey =
33
+ | "systemPrompt"
34
+ | "memoryFiles"
35
+ | "skills"
36
+ | "tools"
37
+ | "toolResults"
38
+ | "contextFiles";
39
+
40
+ type ContextPreview = {
41
+ key: PreviewKey;
42
+ label: string;
43
+ title: string;
44
+ content: string;
45
+ };
46
+
47
+ function normalizePreviewText(text: string): string {
48
+ return text
49
+ .replace(/\r\n?/g, "\n")
50
+ .replace(/\t/g, " ")
51
+ .replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/g, "");
52
+ }
53
+
54
+ export type DialogBounds = { left: number; top: number; width: number };
55
+
56
+ /** 1-based terminal hitbox of the [esc] close button on the dialog title row (row 2 of the box). */
57
+ export function escCloseHitbox(bounds: DialogBounds): {
58
+ row: number;
59
+ startCol: number;
60
+ endCol: number;
61
+ } {
62
+ return {
63
+ row: bounds.top + 2,
64
+ startCol: bounds.left + bounds.width - 5,
65
+ endCol: bounds.left + bounds.width - 1,
66
+ };
67
+ }
68
+
69
+ let activeContextOverlays = 0;
70
+
71
+ /** fullscreen 输入包装用于把鼠标事件继续传给当前 context 主弹框或文本预览 overlay。 */
72
+ export function hasActiveTextPreview(): boolean {
73
+ return activeContextOverlays > 0;
74
+ }
75
+
76
+ /** 官方 fullscreen 打开 overlay 时会退回 1002;重新启用 1003 才能收到无按键 hover。 */
77
+ function ensureFullscreenMouseMotion(tui: any): void {
78
+ if (tui.mode === "fullscreen")
79
+ tui.terminal?.write?.("\x1b[?1003h\x1b[?1006h");
80
+ }
81
+
82
+ export async function showTextPreview(
83
+ ctx: Pick<ExtensionCommandContext, "ui">,
84
+ title: string,
85
+ rawContent: string,
86
+ ): Promise<void> {
87
+ const content = normalizePreviewText(rawContent);
88
+ activeContextOverlays++;
89
+ try {
90
+ await ctx.ui.custom(
91
+ (tui, theme, _keybindings, done) => {
92
+ ensureFullscreenMouseMotion(tui);
93
+ let scrollOffset = 0;
94
+ let pageSize = 1;
95
+ let totalLines = 1;
96
+ let escHovered = false;
97
+ let escHitbox:
98
+ | { row: number; startCol: number; endCol: number }
99
+ | undefined;
100
+ let scrollbarHitbox:
101
+ | {
102
+ col: number;
103
+ startRow: number;
104
+ endRow: number;
105
+ thumbStart: number;
106
+ thumbSize: number;
107
+ maxOffset: number;
108
+ }
109
+ | undefined;
110
+ let scrollbarDragOffset: number | null = null;
111
+ const markdownView = new Markdown(content, 0, 0, getMarkdownTheme());
112
+
113
+ const scrollTo = (nextOffset: number): void => {
114
+ const next = Math.max(
115
+ 0,
116
+ Math.min(nextOffset, Math.max(0, totalLines - pageSize)),
117
+ );
118
+ if (next === scrollOffset) return;
119
+ scrollOffset = next;
120
+ tui.requestRender();
121
+ };
122
+
123
+ const setEscHovered = (hovered: boolean): void => {
124
+ if (hovered === escHovered) return;
125
+ escHovered = hovered;
126
+ tui.requestRender();
127
+ };
128
+
129
+ const dragScrollbarTo = (mouseRow: number): void => {
130
+ if (!scrollbarHitbox || scrollbarDragOffset === null) return;
131
+ const trackSize =
132
+ scrollbarHitbox.endRow - scrollbarHitbox.startRow + 1;
133
+ const maxThumbStart = Math.max(
134
+ 0,
135
+ trackSize - scrollbarHitbox.thumbSize,
136
+ );
137
+ const thumbStart = Math.max(
138
+ 0,
139
+ Math.min(
140
+ mouseRow - scrollbarHitbox.startRow - scrollbarDragOffset,
141
+ maxThumbStart,
142
+ ),
143
+ );
144
+ const nextOffset =
145
+ maxThumbStart > 0
146
+ ? Math.round(
147
+ (thumbStart / maxThumbStart) * scrollbarHitbox.maxOffset,
148
+ )
149
+ : 0;
150
+ scrollTo(nextOffset);
151
+ };
152
+
153
+ return {
154
+ invalidate() {
155
+ markdownView.invalidate();
156
+ },
157
+ handleInput(data: string) {
158
+ if (
159
+ matchesKey(data, Key.escape) ||
160
+ matchesKey(data, Key.ctrl("c"))
161
+ ) {
162
+ done(undefined);
163
+ return;
164
+ }
165
+ if (matchesKey(data, Key.up)) scrollTo(scrollOffset - 1);
166
+ else if (matchesKey(data, Key.down)) scrollTo(scrollOffset + 1);
167
+ else if (matchesKey(data, "pageUp"))
168
+ scrollTo(scrollOffset - pageSize);
169
+ else if (matchesKey(data, "pageDown"))
170
+ scrollTo(scrollOffset + pageSize);
171
+ else if (matchesKey(data, Key.home)) scrollTo(0);
172
+ else if (matchesKey(data, Key.end)) scrollTo(totalLines - pageSize);
173
+ else {
174
+ const mouse = parseSgrMousePacket(data);
175
+ if (!mouse) return;
176
+ if (mouse.final === "m") {
177
+ scrollbarDragOffset = null;
178
+ return;
179
+ }
180
+ const overEsc = Boolean(
181
+ escHitbox &&
182
+ mouse.row === escHitbox.row &&
183
+ mouse.col >= escHitbox.startCol &&
184
+ mouse.col <= escHitbox.endCol,
185
+ );
186
+ setEscHovered(overEsc);
187
+ const button = mouseBaseButton(mouse.code);
188
+ const motion = (mouse.code & 32) !== 0;
189
+ if (button === 0 && !motion) {
190
+ if (overEsc) {
191
+ done(undefined);
192
+ return;
193
+ }
194
+ if (
195
+ scrollbarHitbox &&
196
+ mouse.col === scrollbarHitbox.col &&
197
+ mouse.row >= scrollbarHitbox.startRow &&
198
+ mouse.row <= scrollbarHitbox.endRow
199
+ ) {
200
+ const trackRow = mouse.row - scrollbarHitbox.startRow;
201
+ const inThumb =
202
+ trackRow >= scrollbarHitbox.thumbStart &&
203
+ trackRow <
204
+ scrollbarHitbox.thumbStart + scrollbarHitbox.thumbSize;
205
+ scrollbarDragOffset = inThumb
206
+ ? trackRow - scrollbarHitbox.thumbStart
207
+ : Math.floor(scrollbarHitbox.thumbSize / 2);
208
+ dragScrollbarTo(mouse.row);
209
+ return;
210
+ }
211
+ }
212
+ if (motion && scrollbarDragOffset !== null) {
213
+ dragScrollbarTo(mouse.row);
214
+ return;
215
+ }
216
+ if (button === 64) scrollTo(scrollOffset - 3);
217
+ else if (button === 65) scrollTo(scrollOffset + 3);
218
+ }
219
+ },
220
+ render(width: number) {
221
+ const inner = Math.max(1, width - 2);
222
+ const escWidth = visibleWidth("[esc]");
223
+ const bodyInner = Math.max(1, inner - 1);
224
+ const bodyWidth = Math.max(1, bodyInner - 1);
225
+ const terminalHeight = Math.max(1, tui.terminal.rows);
226
+ const availableHeight = Math.max(1, terminalHeight - 4);
227
+ const viewportHeight = Math.min(
228
+ 30,
229
+ Math.max(1, Math.floor(terminalHeight * 0.8)),
230
+ availableHeight,
231
+ );
232
+ pageSize = Math.max(1, viewportHeight - 6);
233
+ const wrapped = markdownView.render(bodyWidth);
234
+ totalLines = wrapped.length;
235
+ scrollOffset = Math.min(
236
+ scrollOffset,
237
+ Math.max(0, totalLines - pageSize),
238
+ );
239
+ // Centered overlay with margin 2: mirror TUI resolveOverlayLayout for anchor "center".
240
+ const overlayTop =
241
+ 2 + Math.floor((availableHeight - viewportHeight) / 2);
242
+ const overlayLeft = Math.floor(
243
+ (Math.max(1, tui.terminal.columns) - width) / 2,
244
+ );
245
+ escHitbox = escCloseHitbox({
246
+ left: overlayLeft,
247
+ top: overlayTop,
248
+ width,
249
+ });
250
+ const visible = wrapped.slice(
251
+ scrollOffset,
252
+ scrollOffset + pageSize,
253
+ );
254
+ const border = (text: string) => theme.fg("border", text);
255
+ const scrollable = totalLines > pageSize;
256
+ const thumbSize = scrollable
257
+ ? Math.max(1, Math.floor((pageSize * pageSize) / totalLines))
258
+ : 0;
259
+ const maxScrollOffset = Math.max(0, totalLines - pageSize);
260
+ const thumbStart =
261
+ scrollable && maxScrollOffset > 0
262
+ ? Math.round(
263
+ (scrollOffset / maxScrollOffset) * (pageSize - thumbSize),
264
+ )
265
+ : 0;
266
+ scrollbarHitbox = scrollable
267
+ ? {
268
+ col: overlayLeft + width - 1,
269
+ startRow: overlayTop + 4,
270
+ endRow: overlayTop + 3 + pageSize,
271
+ thumbStart,
272
+ thumbSize,
273
+ maxOffset: maxScrollOffset,
274
+ }
275
+ : undefined;
276
+ const scrollbar = (row: number): string => {
277
+ if (!scrollable) return " ";
278
+ const inThumb = row >= thumbStart && row < thumbStart + thumbSize;
279
+ return theme.fg(
280
+ inThumb ? "accent" : "borderMuted",
281
+ inThumb ? "█" : "│",
282
+ );
283
+ };
284
+ const bodyRows = Array.from({ length: pageSize }, (_, row) => {
285
+ const line = visible[row] ?? "";
286
+ return `${border("│")}${padLine(` ${line}`, bodyInner)}${scrollbar(row)}${border("│")}`;
287
+ });
288
+ const start = totalLines === 0 ? 0 : scrollOffset + 1;
289
+ const end = Math.min(totalLines, scrollOffset + pageSize);
290
+ const status = `${start}-${end} / ${totalLines} lines · ↑↓ PgUp/PgDn Home/End · [esc] close`;
291
+
292
+ return [
293
+ border(`╭${"─".repeat(inner)}╮`),
294
+ `${border("│")}${padLine(` ${theme.bold(theme.fg("accent", title))}`, inner - escWidth)}${theme.fg(escHovered ? "text" : "muted", "[esc]")}${border("│")}`,
295
+ `${border("├")}${border("─".repeat(inner))}${border("┤")}`,
296
+ ...bodyRows,
297
+ `${border("├")}${border("─".repeat(inner))}${border("┤")}`,
298
+ `${border("│")}${padLine(theme.fg("dim", ` ${status}`), inner)}${border("│")}`,
299
+ border(`╰${"─".repeat(inner)}╯`),
300
+ ];
301
+ },
302
+ };
303
+ },
304
+ {
305
+ overlay: true,
306
+ overlayOptions: {
307
+ anchor: "center",
308
+ width: "85%",
309
+ minWidth: 50,
310
+ maxHeight: "80%",
311
+ margin: 2,
312
+ },
313
+ },
314
+ );
315
+ } finally {
316
+ activeContextOverlays--;
317
+ }
318
+ }
319
+
320
+ const tokenEstimate = (value: unknown): number => {
321
+ if (!value) return 0;
322
+ const text = typeof value === "string" ? value : JSON.stringify(value);
323
+ return Math.max(0, Math.ceil(text.length / 4));
324
+ };
325
+
326
+ /** 只统计确实嵌进 system prompt 的片段,避免源文件预览把占用加两遍。 */
327
+ function embeddedTokens(prompt: string, chunk: string): number {
328
+ if (!chunk || !prompt.includes(chunk)) return 0;
329
+ return tokenEstimate(chunk);
330
+ }
331
+
332
+ export function capParts(
333
+ parts: ContextPart[],
334
+ target: number,
335
+ fixedPrefix = 0,
336
+ ): ContextPart[] {
337
+ const fixed = parts.slice(0, fixedPrefix);
338
+ const variable = parts.slice(fixedPrefix);
339
+ const fixedTokens = fixed.reduce((sum, part) => sum + part.tokens, 0);
340
+ const variableTarget = Math.max(0, target - fixedTokens);
341
+ const estimated = variable.reduce((sum, part) => sum + part.tokens, 0);
342
+ if (estimated <= variableTarget || estimated === 0) return parts;
343
+ if (variableTarget === 0) {
344
+ return [...fixed, ...variable.map((part) => ({ ...part, tokens: 0 }))];
345
+ }
346
+
347
+ let previous = 0;
348
+ let cumulative = 0;
349
+ const capped = variable.map((part, index) => {
350
+ cumulative += part.tokens;
351
+ const next =
352
+ index === variable.length - 1
353
+ ? variableTarget
354
+ : Math.round((cumulative / estimated) * variableTarget);
355
+ const tokens = next - previous;
356
+ previous = next;
357
+ return { ...part, tokens };
358
+ });
359
+ return [...fixed, ...capped];
360
+ }
361
+
362
+ export function formatTokens(tokens: number): string {
363
+ if (tokens < 1_000) return String(tokens);
364
+ if (tokens < 100_000) return `${(tokens / 1_000).toFixed(1)}k`;
365
+ return `${Math.round(tokens / 1_000)}k`;
366
+ }
367
+
368
+ export function resolveUsedTokens(
369
+ usage: { tokens: number | null; percent: number | null } | undefined,
370
+ estimated: number,
371
+ contextWindow: number,
372
+ ): number {
373
+ const reported = usage?.tokens;
374
+ const fromPercent =
375
+ usage?.percent !== null && usage?.percent !== undefined && contextWindow > 0
376
+ ? Math.round((usage.percent / 100) * contextWindow)
377
+ : undefined;
378
+ let resolved = reported ?? fromPercent ?? estimated;
379
+ if (
380
+ reported !== null &&
381
+ reported !== undefined &&
382
+ fromPercent !== undefined
383
+ ) {
384
+ // 某些 Provider 会返回异常 totalTokens;百分比与底部状态栏不一致时优先采用百分比。
385
+ const tolerance = Math.max(32, Math.round(contextWindow * 0.001));
386
+ if (Math.abs(reported - fromPercent) > tolerance) resolved = fromPercent;
387
+ }
388
+ // tokens 与 percent 可能同时源自异常 usage;数量级明显偏小时回退到实际内容估算。
389
+ if (estimated > 0 && resolved < estimated * 0.25) return estimated;
390
+ return resolved;
391
+ }
392
+
393
+ type ContextBreakdown = {
394
+ parts: ContextPart[];
395
+ previews: Record<PreviewKey, string>;
396
+ };
397
+
398
+ function previewValue(value: unknown): string {
399
+ if (typeof value === "string") return value;
400
+ return `\`\`\`json\n${JSON.stringify(value, null, 2)}\n\`\`\``;
401
+ }
402
+
403
+ /** 按真实请求的 systemPrompt、tools、messages 三部分同步组装计数与预览。 */
404
+ export function collectContextBreakdown(
405
+ ctx: ExtensionCommandContext,
406
+ allTools: ToolInfo[],
407
+ ): ContextBreakdown {
408
+ const options = (ctx.getSystemPromptOptions?.() ??
409
+ {}) as BuildSystemPromptOptions;
410
+ const systemPrompt =
411
+ typeof ctx.getSystemPrompt === "function" ? ctx.getSystemPrompt() : "";
412
+ const selectedTools = new Set(
413
+ options.selectedTools ?? ["read", "bash", "edit", "write"],
414
+ );
415
+ const toolDefinitionPreview: string[] = [];
416
+ const toolResultPreview: string[] = [];
417
+ const contextPreview: string[] = [];
418
+ let toolDefinitionTokens = 0;
419
+ let toolResultTokens = 0;
420
+ let contextTokens = 0;
421
+
422
+ const memoryPreview: string[] = [];
423
+ let memoryTokens = 0;
424
+ for (const file of options.contextFiles ?? []) {
425
+ memoryTokens += embeddedTokens(systemPrompt, file.content);
426
+ memoryPreview.push(`## ${file.path}\n\n${previewValue(file.content)}`);
427
+ }
428
+
429
+ const skillsText = formatSkillsForPrompt(options.skills ?? []).trim();
430
+ const skillsTokens = embeddedTokens(systemPrompt, skillsText);
431
+
432
+ for (const tool of allTools) {
433
+ if (!selectedTools.has(tool.name)) continue;
434
+ const definition = {
435
+ name: tool.name,
436
+ description: tool.description,
437
+ parameters: tool.parameters,
438
+ };
439
+ toolDefinitionTokens += tokenEstimate(definition);
440
+ toolDefinitionPreview.push(
441
+ `## Definition: ${tool.name}\n\n${previewValue(definition)}`,
442
+ );
443
+ }
444
+
445
+ for (const entry of ctx.sessionManager.buildContextEntries()) {
446
+ if (entry.type === "message") {
447
+ const message = entry.message;
448
+ if (message.role === "assistant") {
449
+ for (const block of message.content) {
450
+ if (block.type === "toolCall") {
451
+ contextTokens +=
452
+ tokenEstimate(block.name) + tokenEstimate(block.arguments);
453
+ contextPreview.push(
454
+ `## Assistant tool call: ${block.name}\n\n${previewValue(block.arguments)}`,
455
+ );
456
+ } else if (block.type === "text") {
457
+ contextTokens += tokenEstimate(block.text);
458
+ contextPreview.push(`## Assistant\n\n${block.text}`);
459
+ } else if (block.type === "thinking") {
460
+ contextTokens += tokenEstimate(block.thinking);
461
+ contextPreview.push(`## Assistant thinking\n\n${block.thinking}`);
462
+ }
463
+ }
464
+ } else if (message.role === "toolResult") {
465
+ toolResultTokens += estimateTokens(message);
466
+ toolResultPreview.push(
467
+ `## Result: ${message.toolName}\n\n${previewValue(message.content)}`,
468
+ );
469
+ } else if (message.role === "bashExecution") {
470
+ toolResultTokens += estimateTokens(message);
471
+ toolResultPreview.push(
472
+ `## Bash\n\nCommand:\n\n${previewValue(message.command)}\n\nOutput:\n\n${previewValue(message.output)}`,
473
+ );
474
+ } else if (
475
+ message.role === "branchSummary" ||
476
+ message.role === "compactionSummary"
477
+ ) {
478
+ contextTokens += estimateTokens(message);
479
+ contextPreview.push(`## ${message.role}\n\n${message.summary}`);
480
+ } else {
481
+ contextTokens += estimateTokens(message);
482
+ contextPreview.push(
483
+ `## ${message.role}\n\n${previewValue(message.content)}`,
484
+ );
485
+ }
486
+ } else if (entry.type === "compaction" || entry.type === "branch_summary") {
487
+ contextTokens += tokenEstimate(entry.summary);
488
+ contextPreview.push(
489
+ `## ${entry.type === "compaction" ? "Compaction" : "Branch summary"}\n\n${entry.summary}`,
490
+ );
491
+ } else if (entry.type === "custom_message") {
492
+ contextTokens += tokenEstimate(entry.content);
493
+ contextPreview.push(
494
+ `## Custom: ${entry.customType}\n\n${previewValue(entry.content)}`,
495
+ );
496
+ }
497
+ }
498
+
499
+ const systemTokens = Math.max(
500
+ 0,
501
+ tokenEstimate(systemPrompt) - memoryTokens - skillsTokens,
502
+ );
503
+ return {
504
+ parts: [
505
+ { label: "System prompt", tokens: systemTokens, color: "accent" },
506
+ { label: "Memory", tokens: memoryTokens, color: "error" },
507
+ { label: "Skills", tokens: skillsTokens, color: "warning" },
508
+ {
509
+ label: "Tools definition",
510
+ tokens: toolDefinitionTokens,
511
+ color: "success",
512
+ },
513
+ {
514
+ label: "Tool results",
515
+ tokens: toolResultTokens,
516
+ color: "customMessageLabel",
517
+ },
518
+ { label: "Context", tokens: contextTokens, color: "warning" },
519
+ ] satisfies ContextPart[],
520
+ previews: {
521
+ systemPrompt: systemPrompt || "No system prompt.",
522
+ memoryFiles: memoryPreview.join("\n\n") || "No memory files in context.",
523
+ skills: skillsText || "No skills in context.",
524
+ tools:
525
+ toolDefinitionPreview.join("\n\n") || "No active tool definitions.",
526
+ toolResults:
527
+ toolResultPreview.join("\n\n") ||
528
+ "No tool results in the current context.",
529
+ contextFiles: contextPreview.join("\n\n") || "No conversation context.",
530
+ },
531
+ };
532
+ }
533
+
534
+ export default function contextUsageExtension(pi: ExtensionAPI) {
535
+ pi.registerCommand("context", {
536
+ description: "Show the current context-window distribution",
537
+ handler: async (_args, ctx) => {
538
+ const usage = ctx.getContextUsage();
539
+ const contextWindow =
540
+ usage?.contextWindow ?? ctx.model?.contextWindow ?? 0;
541
+ const tools = pi.getAllTools();
542
+ const breakdown = collectContextBreakdown(ctx, tools);
543
+ const estimated = breakdown.parts.reduce(
544
+ (sum, part) => sum + part.tokens,
545
+ 0,
546
+ );
547
+ // System / Memory / Skills / Tools definition 为固定项,capParts 时保持原值不压缩。
548
+ const fixedTokens = breakdown.parts
549
+ .slice(0, 4)
550
+ .reduce((sum, part) => sum + part.tokens, 0);
551
+ const used = Math.max(
552
+ resolveUsedTokens(usage, estimated, contextWindow),
553
+ fixedTokens,
554
+ );
555
+ const parts = capParts(breakdown.parts, used, 4);
556
+ const attributed = parts.reduce((sum, part) => sum + part.tokens, 0);
557
+ const other = Math.max(0, used - attributed);
558
+ const free = Math.max(0, contextWindow - used);
559
+ const allParts = [
560
+ ...parts,
561
+ { label: "Other", tokens: other, color: "muted" as const },
562
+ { label: "Free space", tokens: free, color: "dim" as const },
563
+ ];
564
+
565
+ if (ctx.mode !== "tui") {
566
+ const lines = allParts.map(
567
+ (part) => `${part.label}: ${formatTokens(part.tokens)} tokens`,
568
+ );
569
+ ctx.ui.notify(lines.join("\n"), "info");
570
+ return;
571
+ }
572
+
573
+ const rawPreviews: ContextPreview[] = [
574
+ {
575
+ key: "systemPrompt",
576
+ label: "System prompt",
577
+ title: "System Prompt",
578
+ content: breakdown.previews.systemPrompt,
579
+ },
580
+ {
581
+ key: "memoryFiles",
582
+ label: "Memory",
583
+ title: "Memory Files",
584
+ content: breakdown.previews.memoryFiles,
585
+ },
586
+ {
587
+ key: "skills",
588
+ label: "Skills",
589
+ title: "Skills",
590
+ content: breakdown.previews.skills,
591
+ },
592
+ {
593
+ key: "tools",
594
+ label: "Tools definition",
595
+ title: "Tools definition",
596
+ content: breakdown.previews.tools,
597
+ },
598
+ {
599
+ key: "toolResults",
600
+ label: "Tool results",
601
+ title: "Tool Results",
602
+ content: breakdown.previews.toolResults,
603
+ },
604
+ {
605
+ key: "contextFiles",
606
+ label: "Context",
607
+ title: "Context",
608
+ content: breakdown.previews.contextFiles,
609
+ },
610
+ ];
611
+ const previews = rawPreviews.map((preview) => ({
612
+ ...preview,
613
+ content: normalizePreviewText(preview.content),
614
+ }));
615
+ const previewByKey = new Map(
616
+ previews.map((preview) => [preview.key, preview]),
617
+ );
618
+ const visiblePreviews = previews.filter((preview) =>
619
+ allParts.some((part) => part.label === preview.label),
620
+ );
621
+ let selectedPreviewIndex = 0;
622
+
623
+ while (true) {
624
+ // 主弹框也计入活动 overlay,fullscreen 下官方输入链才会把鼠标包放行给行点击。
625
+ activeContextOverlays++;
626
+ let action;
627
+ try {
628
+ action = await ctx.ui.custom(
629
+ (tui, theme, _keybindings, done) => {
630
+ ensureFullscreenMouseMotion(tui);
631
+ let previewHitboxes: Array<{
632
+ key: PreviewKey;
633
+ row: number;
634
+ startCol: number;
635
+ endCol: number;
636
+ }> = [];
637
+ let escHitbox:
638
+ | { row: number; startCol: number; endCol: number }
639
+ | undefined;
640
+ let escHovered = false;
641
+ let hoveredKey: PreviewKey | undefined;
642
+
643
+ return {
644
+ invalidate() {},
645
+ handleInput(data: string) {
646
+ if (
647
+ matchesKey(data, Key.escape) ||
648
+ matchesKey(data, Key.ctrl("c"))
649
+ ) {
650
+ done(undefined);
651
+ return;
652
+ }
653
+ if (matchesKey(data, Key.up) && visiblePreviews.length > 0) {
654
+ selectedPreviewIndex =
655
+ (selectedPreviewIndex - 1 + visiblePreviews.length) %
656
+ visiblePreviews.length;
657
+ tui.requestRender();
658
+ return;
659
+ }
660
+ if (
661
+ matchesKey(data, Key.down) &&
662
+ visiblePreviews.length > 0
663
+ ) {
664
+ selectedPreviewIndex =
665
+ (selectedPreviewIndex + 1) % visiblePreviews.length;
666
+ tui.requestRender();
667
+ return;
668
+ }
669
+ if (matchesKey(data, Key.enter)) {
670
+ done(visiblePreviews[selectedPreviewIndex]?.key);
671
+ return;
672
+ }
673
+
674
+ const mouse = parseSgrMousePacket(data);
675
+ if (!mouse || mouse.final !== "M") return;
676
+ if ((mouse.code & 32) !== 0) {
677
+ // SGR 1003 的无按键 hover code 为 35,不能按左键事件过滤。
678
+ const overEsc = Boolean(
679
+ escHitbox &&
680
+ mouse.row === escHitbox.row &&
681
+ mouse.col >= escHitbox.startCol &&
682
+ mouse.col <= escHitbox.endCol,
683
+ );
684
+ const hovered = previewHitboxes.find(
685
+ (candidate) =>
686
+ mouse.row === candidate.row &&
687
+ mouse.col >= candidate.startCol &&
688
+ mouse.col <= candidate.endCol,
689
+ );
690
+ if (overEsc !== escHovered || hovered?.key !== hoveredKey) {
691
+ escHovered = overEsc;
692
+ hoveredKey = hovered?.key;
693
+ tui.requestRender();
694
+ }
695
+ return;
696
+ }
697
+ if (mouseBaseButton(mouse.code) !== 0) return;
698
+ if (
699
+ escHitbox &&
700
+ mouse.row === escHitbox.row &&
701
+ mouse.col >= escHitbox.startCol &&
702
+ mouse.col <= escHitbox.endCol
703
+ ) {
704
+ done(undefined);
705
+ return;
706
+ }
707
+ const hitbox = previewHitboxes.find(
708
+ (candidate) =>
709
+ mouse.row === candidate.row &&
710
+ mouse.col >= candidate.startCol &&
711
+ mouse.col <= candidate.endCol,
712
+ );
713
+ if (hitbox) {
714
+ selectedPreviewIndex = Math.max(
715
+ 0,
716
+ visiblePreviews.findIndex(
717
+ (preview) => preview.key === hitbox.key,
718
+ ),
719
+ );
720
+ done(hitbox.key);
721
+ }
722
+ },
723
+ render(width: number) {
724
+ const inner = Math.max(1, width - 2);
725
+ const escWidth = visibleWidth("[esc]");
726
+ const percent =
727
+ contextWindow > 0 ? (used / contextWindow) * 100 : 0;
728
+ const title = theme.bold(theme.fg("accent", "Context Usage"));
729
+ const subtitle = `${formatTokens(used)} / ${formatTokens(contextWindow)} tokens (${percent.toFixed(1)}%)`;
730
+ const barWidth = Math.max(1, Math.min(60, inner - 2));
731
+ let remaining = barWidth;
732
+ const segments = allParts
733
+ .map((part, index) => {
734
+ const cells =
735
+ index === allParts.length - 1
736
+ ? remaining
737
+ : Math.min(
738
+ remaining,
739
+ Math.round(
740
+ (part.tokens / Math.max(1, contextWindow)) *
741
+ barWidth,
742
+ ),
743
+ );
744
+ remaining -= cells;
745
+ return theme.fg(
746
+ part.color,
747
+ "█".repeat(Math.max(0, cells)),
748
+ );
749
+ })
750
+ .join("");
751
+ const labelWidth = Math.min(
752
+ 24,
753
+ Math.max(...allParts.map((part) => part.label.length)),
754
+ );
755
+ const selectedLabel =
756
+ visiblePreviews[selectedPreviewIndex]?.label;
757
+ const partRows = allParts.map((part) => {
758
+ const pct =
759
+ contextWindow > 0
760
+ ? (part.tokens / contextWindow) * 100
761
+ : 0;
762
+ const swatch = theme.fg(part.color, "■");
763
+ const label = part.label.padEnd(labelWidth);
764
+ const amount = `${formatTokens(part.tokens).padStart(7)} ${pct.toFixed(1).padStart(5)}%`;
765
+ const selected = part.label === selectedLabel;
766
+ const hoverLabel = visiblePreviews.find(
767
+ (p) => p.key === hoveredKey,
768
+ )?.label;
769
+ const prefix = selected ? "› " : " ";
770
+ const row = padLine(
771
+ `${prefix}${swatch} ${label} ${amount}`,
772
+ inner,
773
+ );
774
+ if (selected) return theme.bg("selectedBg", row);
775
+ return part.label === hoverLabel
776
+ ? theme.bg("customMessageBg", row)
777
+ : row;
778
+ });
779
+ const border = (text: string) => theme.fg("border", text);
780
+ const lines = [
781
+ border(`╭${"─".repeat(inner)}╮`),
782
+ `${border("│")}${padLine(` ${title} ${theme.fg("muted", subtitle)}`, inner - escWidth)}${theme.fg(escHovered ? "text" : "muted", "[esc]")}${border("│")}`,
783
+ `${border("├")}${border("─".repeat(inner))}${border("┤")}`,
784
+ `${border("│")}${padLine(` ${segments}`, inner)}${border("│")}`,
785
+ `${border("│")}${" ".repeat(inner)}${border("│")}`,
786
+ ...partRows.map(
787
+ (row) => `${border("│")}${row}${border("│")}`,
788
+ ),
789
+ `${border("├")}${border("─".repeat(inner))}${border("┤")}`,
790
+ `${border("│")}${padLine(theme.fg("dim", " ↑↓ select · Click / Enter to preview · [esc] close"), inner)}${border("│")}`,
791
+ border(`╰${"─".repeat(inner)}╯`),
792
+ ];
793
+
794
+ const terminalHeight = Math.max(1, tui.terminal.rows);
795
+ const maxHeight = Math.min(
796
+ Math.max(1, Math.floor(terminalHeight * 0.9)),
797
+ Math.max(1, terminalHeight - 2),
798
+ );
799
+ const visibleHeight = Math.min(lines.length, maxHeight);
800
+ const overlayTop =
801
+ 1 +
802
+ Math.floor(
803
+ (Math.max(1, terminalHeight - 2) - visibleHeight) / 2,
804
+ );
805
+ const overlayLeft = Math.floor(
806
+ (Math.max(1, tui.terminal.columns) - width) / 2,
807
+ );
808
+ escHitbox = escCloseHitbox({
809
+ left: overlayLeft,
810
+ top: overlayTop,
811
+ width,
812
+ });
813
+ previewHitboxes = visiblePreviews.flatMap((preview) => {
814
+ const partIndex = allParts.findIndex(
815
+ (part) => part.label === preview.label,
816
+ );
817
+ const line = 5 + partIndex;
818
+ return partIndex >= 0 && line < visibleHeight
819
+ ? [
820
+ {
821
+ key: preview.key,
822
+ row: overlayTop + line + 1,
823
+ startCol: overlayLeft + 1,
824
+ endCol: overlayLeft + width,
825
+ },
826
+ ]
827
+ : [];
828
+ });
829
+
830
+ return lines;
831
+ },
832
+ };
833
+ },
834
+ {
835
+ overlay: true,
836
+ overlayOptions: {
837
+ anchor: "center",
838
+ width: 64,
839
+ minWidth: 44,
840
+ maxHeight: "90%",
841
+ margin: 1,
842
+ },
843
+ },
844
+ );
845
+ } finally {
846
+ activeContextOverlays--;
847
+ }
848
+
849
+ if (!action) break;
850
+ const preview = previewByKey.get(action as PreviewKey);
851
+ if (!preview) continue;
852
+
853
+ await showTextPreview(ctx, preview.title, preview.content);
854
+ }
855
+ },
856
+ });
857
+ }