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