pi-cc-extensions 1.0.2 → 1.0.4

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.
@@ -3,18 +3,20 @@ import {
3
3
  type ExtensionAPI,
4
4
  type ExtensionCommandContext,
5
5
  type ToolInfo,
6
+ copyToClipboard,
6
7
  estimateTokens,
7
8
  formatSkillsForPrompt,
8
9
  getMarkdownTheme,
9
10
  } from "@earendil-works/pi-coding-agent";
10
11
  import { Key, Markdown, matchesKey, visibleWidth } from "@earendil-works/pi-tui";
11
- import { mouseBaseButton, parseSgrMousePacket } from "../utils/sgr-mouse.ts";
12
+ import { type Color, rgbColor } from "@earendil-works/pi-tui";
12
13
  import { padLine } from "../utils/format.ts";
13
14
 
14
15
  export type ContextPart = {
15
16
  label: string;
16
17
  tokens: number;
17
- color: "accent" | "success" | "warning" | "customMessageLabel" | "muted" | "dim" | "error";
18
+ /** 固定 RGB 色值,不走主题 token——避免不同主题撞色。渲染用 theme.style({fg: color})。 */
19
+ color: Color;
18
20
  };
19
21
 
20
22
  type PreviewKey =
@@ -54,6 +56,30 @@ export function escCloseHitbox(bounds: DialogBounds): {
54
56
  };
55
57
  }
56
58
 
59
+ const COPY_LABEL = "[copy]";
60
+ const ESC_LABEL = "[esc]";
61
+
62
+ /** 测试可替换;生产使用 pi 的剪贴板实现。 */
63
+ let copyTextImpl: (text: string) => Promise<void> = (text) => copyToClipboard(text);
64
+
65
+ export function setPreviewCopyImpl(impl: ((text: string) => Promise<void>) | null): void {
66
+ copyTextImpl = impl ?? ((text) => copyToClipboard(text));
67
+ }
68
+
69
+ /** 1-based terminal hitbox of the [copy] button, one space left of [esc] on the same title row. */
70
+ export function copyButtonHitbox(bounds: DialogBounds): {
71
+ row: number;
72
+ startCol: number;
73
+ endCol: number;
74
+ } {
75
+ const esc = escCloseHitbox(bounds);
76
+ return {
77
+ row: esc.row,
78
+ startCol: esc.startCol - visibleWidth(COPY_LABEL) - 1,
79
+ endCol: esc.startCol - 2,
80
+ };
81
+ }
82
+
57
83
  let activeContextOverlays = 0;
58
84
 
59
85
  /** fullscreen 输入包装用于把鼠标事件继续传给当前 context 主弹框或文本预览 overlay。 */
@@ -81,7 +107,10 @@ export async function showTextPreview(
81
107
  let pageSize = 1;
82
108
  let totalLines = 1;
83
109
  let escHovered = false;
110
+ let copyHovered = false;
111
+ let scrollbarHovered = false;
84
112
  let escHitbox: { row: number; startCol: number; endCol: number } | undefined;
113
+ let copyHitbox: { row: number; startCol: number; endCol: number } | undefined;
85
114
  let scrollbarHitbox:
86
115
  | {
87
116
  col: number;
@@ -108,6 +137,30 @@ export async function showTextPreview(
108
137
  tui.requestRender();
109
138
  };
110
139
 
140
+ const setCopyHovered = (hovered: boolean): void => {
141
+ if (hovered === copyHovered) return;
142
+ copyHovered = hovered;
143
+ tui.requestRender();
144
+ };
145
+
146
+ const setScrollbarHovered = (hovered: boolean): void => {
147
+ if (hovered === scrollbarHovered) return;
148
+ scrollbarHovered = hovered;
149
+ tui.requestRender();
150
+ };
151
+
152
+ const copyPreview = (): void => {
153
+ void copyTextImpl(content)
154
+ .then(() => {
155
+ const flash = (tui as { flash?: (message: string) => void }).flash;
156
+ if (typeof flash === "function") flash("Copied!");
157
+ else ctx.ui.notify("Copied preview to clipboard", "info");
158
+ })
159
+ .catch((error: unknown) => {
160
+ ctx.ui.notify(error instanceof Error ? error.message : "Copy failed", "error");
161
+ });
162
+ };
163
+
111
164
  const dragScrollbarTo = (mouseRow: number): void => {
112
165
  if (!scrollbarHitbox || scrollbarDragOffset === null) return;
113
166
  const trackSize = scrollbarHitbox.endRow - scrollbarHitbox.startRow + 1;
@@ -123,6 +176,92 @@ export async function showTextPreview(
123
176
  scrollTo(nextOffset);
124
177
  };
125
178
 
179
+ /**
180
+ * 预览鼠标共用处:1-based 屏幕 col/row + 已解码的按键/动作。
181
+ * handleInput(regular 主屏 raw SGR)与 handleMouse(overlay 事件对象)
182
+ * 都收在这里,两条链同一套 esc/copy/滚动条/滚轮行为。
183
+ * 返回 true 表示这次输入被预览消费。
184
+ */
185
+ const handlePreviewMouse = (args: {
186
+ col: number;
187
+ row: number;
188
+ release: boolean;
189
+ motion: boolean;
190
+ /** 基础按键码:0 左键、64/65 滚轮上/下。 */
191
+ baseButton: number;
192
+ }): boolean => {
193
+ const { col, row, release, motion, baseButton } = args;
194
+ if (release) {
195
+ scrollbarDragOffset = null;
196
+ setScrollbarHovered(false);
197
+ return true;
198
+ }
199
+ const overEsc = Boolean(
200
+ escHitbox &&
201
+ row === escHitbox.row &&
202
+ col >= escHitbox.startCol &&
203
+ col <= escHitbox.endCol,
204
+ );
205
+ const overCopy = Boolean(
206
+ copyHitbox &&
207
+ row === copyHitbox.row &&
208
+ col >= copyHitbox.startCol &&
209
+ col <= copyHitbox.endCol,
210
+ );
211
+ setEscHovered(overEsc);
212
+ setCopyHovered(overCopy);
213
+ // 拖动中保持 hover:thumb 一直亮,官方 scrollbarDrag 同理。
214
+ setScrollbarHovered(
215
+ Boolean(
216
+ scrollbarHitbox &&
217
+ col === scrollbarHitbox.col &&
218
+ row >= scrollbarHitbox.startRow &&
219
+ row <= scrollbarHitbox.endRow,
220
+ ) || scrollbarDragOffset !== null,
221
+ );
222
+ if (baseButton === 0 && !motion) {
223
+ if (overEsc) {
224
+ done(undefined);
225
+ return true;
226
+ }
227
+ if (overCopy) {
228
+ copyPreview();
229
+ return true;
230
+ }
231
+ if (
232
+ scrollbarHitbox &&
233
+ col === scrollbarHitbox.col &&
234
+ row >= scrollbarHitbox.startRow &&
235
+ row <= scrollbarHitbox.endRow
236
+ ) {
237
+ const trackRow = row - scrollbarHitbox.startRow;
238
+ const inThumb =
239
+ trackRow >= scrollbarHitbox.thumbStart &&
240
+ trackRow < scrollbarHitbox.thumbStart + scrollbarHitbox.thumbSize;
241
+ scrollbarDragOffset = inThumb
242
+ ? trackRow - scrollbarHitbox.thumbStart
243
+ : Math.floor(scrollbarHitbox.thumbSize / 2);
244
+ dragScrollbarTo(row);
245
+ return true;
246
+ }
247
+ }
248
+ if (motion && scrollbarDragOffset !== null) {
249
+ dragScrollbarTo(row);
250
+ return true;
251
+ }
252
+ if (baseButton === 64) {
253
+ scrollTo(scrollOffset - 3);
254
+ return true;
255
+ }
256
+ if (baseButton === 65) {
257
+ scrollTo(scrollOffset + 3);
258
+ return true;
259
+ }
260
+ // 预览矩形内但未命中任何控件的包也算预览的:返回 true 让 overlay
261
+ // 命中它(hit 成立),官方不再往底下的 transcript 分发。
262
+ return true;
263
+ };
264
+
126
265
  return {
127
266
  invalidate() {
128
267
  markdownView.invalidate();
@@ -138,56 +277,29 @@ export async function showTextPreview(
138
277
  else if (matchesKey(data, "pageDown")) scrollTo(scrollOffset + pageSize);
139
278
  else if (matchesKey(data, Key.home)) scrollTo(0);
140
279
  else if (matchesKey(data, Key.end)) scrollTo(totalLines - pageSize);
141
- else {
142
- const mouse = parseSgrMousePacket(data);
143
- if (!mouse) return;
144
- if (mouse.final === "m") {
145
- scrollbarDragOffset = null;
146
- return;
147
- }
148
- const overEsc = Boolean(
149
- escHitbox &&
150
- mouse.row === escHitbox.row &&
151
- mouse.col >= escHitbox.startCol &&
152
- mouse.col <= escHitbox.endCol,
153
- );
154
- setEscHovered(overEsc);
155
- const button = mouseBaseButton(mouse.code);
156
- const motion = (mouse.code & 32) !== 0;
157
- if (button === 0 && !motion) {
158
- if (overEsc) {
159
- done(undefined);
160
- return;
161
- }
162
- if (
163
- scrollbarHitbox &&
164
- mouse.col === scrollbarHitbox.col &&
165
- mouse.row >= scrollbarHitbox.startRow &&
166
- mouse.row <= scrollbarHitbox.endRow
167
- ) {
168
- const trackRow = mouse.row - scrollbarHitbox.startRow;
169
- const inThumb =
170
- trackRow >= scrollbarHitbox.thumbStart &&
171
- trackRow < scrollbarHitbox.thumbStart + scrollbarHitbox.thumbSize;
172
- scrollbarDragOffset = inThumb
173
- ? trackRow - scrollbarHitbox.thumbStart
174
- : Math.floor(scrollbarHitbox.thumbSize / 2);
175
- dragScrollbarTo(mouse.row);
176
- return;
177
- }
178
- }
179
- if (motion && scrollbarDragOffset !== null) {
180
- dragScrollbarTo(mouse.row);
181
- return;
182
- }
183
- if (button === 64) scrollTo(scrollOffset - 3);
184
- else if (button === 65) scrollTo(scrollOffset + 3);
185
- }
280
+ },
281
+ // overlay 事件对象路径:官方 dispatchMouseToOverlay 把鼠标派到
282
+ // handleMouse,event.x/y 是相对弹框左上角的 0-based 局部坐标,
283
+ // 与 hitbox 同基准,天然对齐官方放置位置。
284
+ handleMouse(event: any) {
285
+ // 一次单击 = press + release + 合成 click。动作已在 press 执行,
286
+ // click 只消费不重复触发(否则 copy/esc 会跑两次)。
287
+ if (event.type === "click") return { handled: true };
288
+ const wheelUp = event.type === "wheel" && Number(event.wheelDelta) < 0;
289
+ const wheelDown = event.type === "wheel" && Number(event.wheelDelta) > 0;
290
+ handlePreviewMouse({
291
+ col: Number(event.x),
292
+ row: Number(event.y),
293
+ release: event.type === "release",
294
+ motion: event.type === "move" || event.type === "drag",
295
+ baseButton: wheelUp ? 64 : wheelDown ? 65 : event.button === "left" ? 0 : -1,
296
+ });
297
+ return { handled: true };
186
298
  },
187
299
 
188
300
  render(width: number) {
189
301
  const inner = Math.max(1, width - 2);
190
- const escWidth = visibleWidth("[esc]");
302
+ const actionsWidth = visibleWidth(`${COPY_LABEL} ${ESC_LABEL}`);
191
303
  const bodyInner = Math.max(1, inner - 1);
192
304
  const bodyWidth = Math.max(1, bodyInner - 1);
193
305
  const terminalHeight = Math.max(1, tui.terminal.rows);
@@ -201,15 +313,24 @@ export async function showTextPreview(
201
313
  const wrapped = markdownView.render(bodyWidth);
202
314
  totalLines = wrapped.length;
203
315
  scrollOffset = Math.min(scrollOffset, Math.max(0, totalLines - pageSize));
204
- // Centered overlay with margin 2: mirror TUI resolveOverlayLayout for anchor "center".
205
- const overlayTop = 2 + Math.floor((availableHeight - viewportHeight) / 2);
206
- const overlayLeft = Math.floor((Math.max(1, tui.terminal.columns) - width) / 2);
207
- escHitbox = escCloseHitbox({ left: overlayLeft, top: overlayTop, width });
316
+ // hitbox 用弹框局部坐标(0-based):title 固定第 1 行,esc/copy
317
+ // 在行尾,滚动条字符在 body 行右 border 内侧(width-2 列)。
318
+ escHitbox = { row: 1, startCol: width - 6, endCol: width - 2 };
319
+ copyHitbox = {
320
+ row: 1,
321
+ startCol: width - 6 - 1 - visibleWidth(COPY_LABEL),
322
+ endCol: width - 8,
323
+ };
208
324
  const visible = wrapped.slice(scrollOffset, scrollOffset + pageSize);
209
325
  const border = (text: string) => theme.fg("border", text);
210
326
  const scrollable = totalLines > pageSize;
327
+ // thumb 几何对齐官方 getScrollbarGeometry:trackHeight=pageSize、
328
+ // contentHeight=totalLines,minThumb=min(2,track),thumb=round(track²/content)。
211
329
  const thumbSize = scrollable
212
- ? Math.max(1, Math.floor((pageSize * pageSize) / totalLines))
330
+ ? Math.max(
331
+ Math.min(2, pageSize),
332
+ Math.min(pageSize, Math.round((pageSize * pageSize) / totalLines)),
333
+ )
213
334
  : 0;
214
335
  const maxScrollOffset = Math.max(0, totalLines - pageSize);
215
336
  const thumbStart =
@@ -218,9 +339,9 @@ export async function showTextPreview(
218
339
  : 0;
219
340
  scrollbarHitbox = scrollable
220
341
  ? {
221
- col: overlayLeft + width - 1,
222
- startRow: overlayTop + 4,
223
- endRow: overlayTop + 3 + pageSize,
342
+ col: width - 2,
343
+ startRow: 3,
344
+ endRow: 2 + pageSize,
224
345
  thumbStart,
225
346
  thumbSize,
226
347
  maxOffset: maxScrollOffset,
@@ -229,7 +350,10 @@ export async function showTextPreview(
229
350
  const scrollbar = (row: number): string => {
230
351
  if (!scrollable) return " ";
231
352
  const inThumb = row >= thumbStart && row < thumbStart + thumbSize;
232
- return theme.fg(inThumb ? "accent" : "borderMuted", inThumb ? "█" : "│");
353
+ // 对齐官方:thumb 常态 ┃、hover/拖动中 █;track 始终 │。
354
+ const thumbActive = scrollbarHovered || scrollbarDragOffset !== null;
355
+ if (inThumb) return theme.fg("accent", thumbActive ? "█" : "┃");
356
+ return theme.fg("borderMuted", "│");
233
357
  };
234
358
  const bodyRows = Array.from({ length: pageSize }, (_, row) => {
235
359
  const line = visible[row] ?? "";
@@ -237,11 +361,11 @@ export async function showTextPreview(
237
361
  });
238
362
  const start = totalLines === 0 ? 0 : scrollOffset + 1;
239
363
  const end = Math.min(totalLines, scrollOffset + pageSize);
240
- const status = `${start}-${end} / ${totalLines} lines · ↑↓ PgUp/PgDn Home/End · [esc] close`;
364
+ const status = `${start}-${end} / ${totalLines} lines · ↑↓ PgUp/PgDn Home/End`;
241
365
 
242
366
  return [
243
367
  border(`╭${"─".repeat(inner)}╮`),
244
- `${border("│")}${padLine(` ${theme.bold(theme.fg("accent", title))}`, inner - escWidth)}${theme.fg(escHovered ? "text" : "muted", "[esc]")}${border("│")}`,
368
+ `${border("│")}${padLine(` ${theme.bold(theme.fg("accent", title))}`, inner - actionsWidth)}${theme.fg(copyHovered ? "text" : "muted", COPY_LABEL)} ${theme.fg(escHovered ? "text" : "muted", ESC_LABEL)}${border("│")}`,
245
369
  `${border("├")}${border("─".repeat(inner))}${border("┤")}`,
246
370
  ...bodyRows,
247
371
  `${border("├")}${border("─".repeat(inner))}${border("┤")}`,
@@ -255,7 +379,7 @@ export async function showTextPreview(
255
379
  overlay: true,
256
380
  overlayOptions: {
257
381
  anchor: "center",
258
- width: "85%",
382
+ width: "80%",
259
383
  minWidth: 50,
260
384
  maxHeight: "80%",
261
385
  margin: 2,
@@ -273,6 +397,23 @@ const tokenEstimate = (value: unknown): number => {
273
397
  return Math.max(0, Math.ceil(text.length / 4));
274
398
  };
275
399
 
400
+ /** 只算会进请求正文的 content 和 sections。工具 schema 走 tools 参数,由 Tools definition 单独计一次。 */
401
+ function systemMessageTokens(message: {
402
+ content?: unknown;
403
+ sections?: Record<string, string | null> | null;
404
+ }): number {
405
+ let chars = typeof message.content === "string" ? message.content.length : 0;
406
+ for (const section of Object.values(message.sections ?? {})) {
407
+ if (typeof section === "string") chars += section.length;
408
+ }
409
+ return Math.ceil(chars / 4);
410
+ }
411
+
412
+ /** prompt 组装会把 CRLF 归一成 LF,匹配前对齐行尾。 */
413
+ function normalizeLineEndings(text: string): string {
414
+ return text.replace(/\r\n?/g, "\n");
415
+ }
416
+
276
417
  /** 只统计确实嵌进 system prompt 的片段,避免源文件预览把占用加两遍。 */
277
418
  function embeddedTokens(prompt: string, chunk: string): number {
278
419
  if (!chunk || !prompt.includes(chunk)) return 0;
@@ -348,24 +489,24 @@ export function collectContextBreakdown(
348
489
  allTools: ToolInfo[],
349
490
  ): ContextBreakdown {
350
491
  const options = (ctx.getSystemPromptOptions?.() ?? {}) as BuildSystemPromptOptions;
351
- const systemPrompt = typeof ctx.getSystemPrompt === "function" ? ctx.getSystemPrompt() : "";
352
492
  const selectedTools = new Set(options.selectedTools ?? ["read", "bash", "edit", "write"]);
353
493
  const toolDefinitionPreview: string[] = [];
354
494
  const toolResultPreview: string[] = [];
355
495
  const contextPreview: string[] = [];
496
+ const systemPromptPreview: string[] = [];
356
497
  let toolDefinitionTokens = 0;
357
498
  let toolResultTokens = 0;
358
499
  let contextTokens = 0;
500
+ let systemTokens = 0;
359
501
 
360
502
  const memoryPreview: string[] = [];
361
- let memoryTokens = 0;
362
- for (const file of options.contextFiles ?? []) {
363
- memoryTokens += embeddedTokens(systemPrompt, file.content);
503
+ const contextFiles = options.contextFiles ?? [];
504
+ for (const file of contextFiles) {
364
505
  memoryPreview.push(`## ${file.path}\n\n${previewValue(file.content)}`);
365
506
  }
366
507
 
367
- const skillsText = formatSkillsForPrompt(options.skills ?? []).trim();
368
- const skillsTokens = embeddedTokens(systemPrompt, skillsText);
508
+ const skills = options.skills ?? [];
509
+ const skillsText = formatSkillsForPrompt(skills).trim();
369
510
 
370
511
  for (const tool of allTools) {
371
512
  if (!selectedTools.has(tool.name)) continue;
@@ -378,21 +519,51 @@ export function collectContextBreakdown(
378
519
  toolDefinitionPreview.push(`## Definition: ${tool.name}\n\n${previewValue(definition)}`);
379
520
  }
380
521
 
381
- for (const entry of ctx.sessionManager.buildContextEntries()) {
382
- if (entry.type === "message") {
383
- const message = entry.message;
384
- if (message.role === "assistant") {
522
+ let memoryTokens = 0;
523
+ let skillsTokens = 0;
524
+ for (const projected of ctx.sessionManager.buildSessionProjection().entries) {
525
+ const source = projected.sourceEntry;
526
+ for (const message of projected.messages) {
527
+ if (message.role === "system") {
528
+ systemTokens += systemMessageTokens(message);
529
+ // Memory / skills 按投影中的实际 section 文本切分,与 system prompt 增量互补、不重不漏。
530
+ const sections = (message.sections ?? {}) as Record<string, string | null>;
531
+ const projectContext =
532
+ typeof sections.project_context === "string"
533
+ ? normalizeLineEndings(sections.project_context)
534
+ : "";
535
+ for (const file of contextFiles) {
536
+ const chunk = `<project_instructions path="${file.path}">\n${normalizeLineEndings(file.content)}\n</project_instructions>`;
537
+ memoryTokens += embeddedTokens(projectContext, chunk);
538
+ systemTokens -= embeddedTokens(projectContext, chunk);
539
+ }
540
+ const skillsSection =
541
+ typeof sections.skills === "string" ? normalizeLineEndings(sections.skills) : "";
542
+ skillsTokens += embeddedTokens(skillsSection, skillsText);
543
+ systemTokens -= embeddedTokens(skillsSection, skillsText);
544
+ systemPromptPreview.push(
545
+ (source.type === "compaction"
546
+ ? "## System prompt snapshot at compaction\n\n"
547
+ : "## System prompt update\n\n") +
548
+ previewValue({
549
+ content: message.content,
550
+ sections: message.sections,
551
+ toolsAdded: (message.toolsAdded ?? []).map((tool: { name?: string }) => tool?.name),
552
+ toolsRemoved: (message.toolsRemoved ?? []).map(
553
+ (tool: { name?: string }) => tool?.name,
554
+ ),
555
+ }),
556
+ );
557
+ } else if (message.role === "assistant") {
558
+ contextTokens += estimateTokens(message);
385
559
  for (const block of message.content) {
386
560
  if (block.type === "toolCall") {
387
- contextTokens += tokenEstimate(block.name) + tokenEstimate(block.arguments);
388
561
  contextPreview.push(
389
562
  `## Assistant tool call: ${block.name}\n\n${previewValue(block.arguments)}`,
390
563
  );
391
564
  } else if (block.type === "text") {
392
- contextTokens += tokenEstimate(block.text);
393
565
  contextPreview.push(`## Assistant\n\n${block.text}`);
394
566
  } else if (block.type === "thinking") {
395
- contextTokens += tokenEstimate(block.thinking);
396
567
  contextPreview.push(`## Assistant thinking\n\n${block.thinking}`);
397
568
  }
398
569
  }
@@ -402,8 +573,8 @@ export function collectContextBreakdown(
402
573
  `## Result: ${message.toolName}\n\n${previewValue(message.content)}`,
403
574
  );
404
575
  } else if (message.role === "bashExecution") {
405
- toolResultTokens += estimateTokens(message);
406
- toolResultPreview.push(
576
+ contextTokens += estimateTokens(message);
577
+ contextPreview.push(
407
578
  `## Bash\n\nCommand:\n\n${previewValue(message.command)}\n\nOutput:\n\n${previewValue(message.output)}`,
408
579
  );
409
580
  } else if (message.role === "branchSummary" || message.role === "compactionSummary") {
@@ -413,29 +584,28 @@ export function collectContextBreakdown(
413
584
  contextTokens += estimateTokens(message);
414
585
  contextPreview.push(`## ${message.role}\n\n${previewValue(message.content)}`);
415
586
  }
416
- } else if (entry.type === "compaction" || entry.type === "branch_summary") {
417
- contextTokens += tokenEstimate(entry.summary);
418
- contextPreview.push(
419
- `## ${entry.type === "compaction" ? "Compaction" : "Branch summary"}\n\n${entry.summary}`,
420
- );
421
- } else if (entry.type === "custom_message") {
422
- contextTokens += tokenEstimate(entry.content);
423
- contextPreview.push(`## Custom: ${entry.customType}\n\n${previewValue(entry.content)}`);
424
587
  }
425
588
  }
426
589
 
427
- const systemTokens = Math.max(0, tokenEstimate(systemPrompt) - memoryTokens - skillsTokens);
590
+ systemTokens = Math.max(0, systemTokens);
428
591
  return {
429
592
  parts: [
430
- { label: "System prompt", tokens: systemTokens, color: "accent" },
431
- { label: "Memory", tokens: memoryTokens, color: "error" },
432
- { label: "Skills", tokens: skillsTokens, color: "warning" },
433
- { label: "Tools definition", tokens: toolDefinitionTokens, color: "success" },
434
- { label: "Tool results", tokens: toolResultTokens, color: "customMessageLabel" },
435
- { label: "Context", tokens: contextTokens, color: "warning" },
593
+ { label: "System prompt", tokens: systemTokens, color: rgbColor(0x79, 0xc0, 0xff) },
594
+ { label: "Memory", tokens: memoryTokens, color: rgbColor(0xff, 0x7b, 0x72) },
595
+ { label: "Skills", tokens: skillsTokens, color: rgbColor(0xcb, 0x92, 0x14) },
596
+ {
597
+ label: "Tools definition",
598
+ tokens: toolDefinitionTokens,
599
+ color: rgbColor(0x37, 0xb3, 0x4a),
600
+ },
601
+ { label: "Tool results", tokens: toolResultTokens, color: rgbColor(0xf7, 0x78, 0xba) },
602
+ { label: "Context", tokens: contextTokens, color: rgbColor(0xf0, 0x88, 0x3e) },
436
603
  ] satisfies ContextPart[],
437
604
  previews: {
438
- systemPrompt: systemPrompt || "No system prompt.",
605
+ systemPrompt:
606
+ systemPromptPreview.join("\n\n") ||
607
+ (typeof ctx.getSystemPrompt === "function" ? ctx.getSystemPrompt() : "") ||
608
+ "No system prompt.",
439
609
  memoryFiles: memoryPreview.join("\n\n") || "No memory files in context.",
440
610
  skills: skillsText || "No skills in context.",
441
611
  tools: toolDefinitionPreview.join("\n\n") || "No active tool definitions.",
@@ -463,8 +633,8 @@ export default function contextUsageExtension(pi: ExtensionAPI) {
463
633
  const free = Math.max(0, contextWindow - used);
464
634
  const allParts = [
465
635
  ...parts,
466
- { label: "Other", tokens: other, color: "muted" as const },
467
- { label: "Free space", tokens: free, color: "dim" as const },
636
+ { label: "Other", tokens: other, color: rgbColor(0x92, 0x9e, 0xab) },
637
+ { label: "Free space", tokens: free, color: rgbColor(0x76, 0x84, 0x94) },
468
638
  ];
469
639
 
470
640
  if (ctx.mode !== "tui") {
@@ -539,6 +709,59 @@ export default function contextUsageExtension(pi: ExtensionAPI) {
539
709
  let escHovered = false;
540
710
  let hoveredKey: PreviewKey | undefined;
541
711
 
712
+ /** context 主弹框鼠标共用处:handleInput(raw SGR)与 handleMouse(overlay
713
+ * 事件对象)同一套 hover/选中/关闭行为。返回 true 表示被预览消费。 */
714
+ const handleDialogMouse = (args: {
715
+ col: number;
716
+ row: number;
717
+ release: boolean;
718
+ motion: boolean;
719
+ baseButton: number;
720
+ }): boolean => {
721
+ const { col, row, release, motion, baseButton } = args;
722
+ if (release) return true;
723
+ if (motion) {
724
+ const overEsc = Boolean(
725
+ escHitbox &&
726
+ row === escHitbox.row &&
727
+ col >= escHitbox.startCol &&
728
+ col <= escHitbox.endCol,
729
+ );
730
+ const hovered = previewHitboxes.find(
731
+ (candidate) =>
732
+ row === candidate.row && col >= candidate.startCol && col <= candidate.endCol,
733
+ );
734
+ if (overEsc !== escHovered || hovered?.key !== hoveredKey) {
735
+ escHovered = overEsc;
736
+ hoveredKey = hovered?.key;
737
+ tui.requestRender();
738
+ }
739
+ return true;
740
+ }
741
+ if (baseButton !== 0) return true;
742
+ if (
743
+ escHitbox &&
744
+ row === escHitbox.row &&
745
+ col >= escHitbox.startCol &&
746
+ col <= escHitbox.endCol
747
+ ) {
748
+ done(undefined);
749
+ return true;
750
+ }
751
+ const hitbox = previewHitboxes.find(
752
+ (candidate) =>
753
+ row === candidate.row && col >= candidate.startCol && col <= candidate.endCol,
754
+ );
755
+ if (hitbox) {
756
+ selectedPreviewIndex = Math.max(
757
+ 0,
758
+ visiblePreviews.findIndex((preview) => preview.key === hitbox.key),
759
+ );
760
+ done(hitbox.key);
761
+ }
762
+ return true;
763
+ };
764
+
542
765
  return {
543
766
  invalidate() {},
544
767
  handleInput(data: string) {
@@ -561,53 +784,20 @@ export default function contextUsageExtension(pi: ExtensionAPI) {
561
784
  done(visiblePreviews[selectedPreviewIndex]?.key);
562
785
  return;
563
786
  }
564
-
565
- const mouse = parseSgrMousePacket(data);
566
- if (!mouse || mouse.final !== "M") return;
567
- if ((mouse.code & 32) !== 0) {
568
- // SGR 1003 的无按键 hover code 为 35,不能按左键事件过滤。
569
- const overEsc = Boolean(
570
- escHitbox &&
571
- mouse.row === escHitbox.row &&
572
- mouse.col >= escHitbox.startCol &&
573
- mouse.col <= escHitbox.endCol,
574
- );
575
- const hovered = previewHitboxes.find(
576
- (candidate) =>
577
- mouse.row === candidate.row &&
578
- mouse.col >= candidate.startCol &&
579
- mouse.col <= candidate.endCol,
580
- );
581
- if (overEsc !== escHovered || hovered?.key !== hoveredKey) {
582
- escHovered = overEsc;
583
- hoveredKey = hovered?.key;
584
- tui.requestRender();
585
- }
586
- return;
587
- }
588
- if (mouseBaseButton(mouse.code) !== 0) return;
589
- if (
590
- escHitbox &&
591
- mouse.row === escHitbox.row &&
592
- mouse.col >= escHitbox.startCol &&
593
- mouse.col <= escHitbox.endCol
594
- ) {
595
- done(undefined);
596
- return;
597
- }
598
- const hitbox = previewHitboxes.find(
599
- (candidate) =>
600
- mouse.row === candidate.row &&
601
- mouse.col >= candidate.startCol &&
602
- mouse.col <= candidate.endCol,
603
- );
604
- if (hitbox) {
605
- selectedPreviewIndex = Math.max(
606
- 0,
607
- visiblePreviews.findIndex((preview) => preview.key === hitbox.key),
608
- );
609
- done(hitbox.key);
610
- }
787
+ },
788
+ // overlay 事件对象路径:event.x/y 是弹框局部 0-based 坐标,
789
+ // 与 hitbox 同基准。click 是 press+release 的合成,动作已在
790
+ // press 做过,只消费。
791
+ handleMouse(event: any) {
792
+ if (event.type === "click") return { handled: true };
793
+ handleDialogMouse({
794
+ col: Number(event.x),
795
+ row: Number(event.y),
796
+ release: event.type === "release",
797
+ motion: event.type === "move" || event.type === "drag",
798
+ baseButton: event.button === "left" ? 0 : -1,
799
+ });
800
+ return { handled: true };
611
801
  },
612
802
  render(width: number) {
613
803
  const inner = Math.max(1, width - 2);
@@ -627,7 +817,7 @@ export default function contextUsageExtension(pi: ExtensionAPI) {
627
817
  Math.round((part.tokens / Math.max(1, contextWindow)) * barWidth),
628
818
  );
629
819
  remaining -= cells;
630
- return theme.fg(part.color, "█".repeat(Math.max(0, cells)));
820
+ return theme.style("█".repeat(Math.max(0, cells)), { fg: part.color });
631
821
  })
632
822
  .join("");
633
823
  const labelWidth = Math.min(
@@ -637,7 +827,7 @@ export default function contextUsageExtension(pi: ExtensionAPI) {
637
827
  const selectedLabel = visiblePreviews[selectedPreviewIndex]?.label;
638
828
  const partRows = allParts.map((part) => {
639
829
  const pct = contextWindow > 0 ? (part.tokens / contextWindow) * 100 : 0;
640
- const swatch = theme.fg(part.color, "■");
830
+ const swatch = theme.style("■", { fg: part.color });
641
831
  const label = part.label.padEnd(labelWidth);
642
832
  const amount = `${formatTokens(part.tokens).padStart(7)} ${pct.toFixed(1).padStart(5)}%`;
643
833
  const selected = part.label === selectedLabel;
@@ -666,10 +856,10 @@ export default function contextUsageExtension(pi: ExtensionAPI) {
666
856
  Math.max(1, terminalHeight - 2),
667
857
  );
668
858
  const visibleHeight = Math.min(lines.length, maxHeight);
669
- const overlayTop =
670
- 1 + Math.floor((Math.max(1, terminalHeight - 2) - visibleHeight) / 2);
671
- const overlayLeft = Math.floor((Math.max(1, tui.terminal.columns) - width) / 2);
672
- escHitbox = escCloseHitbox({ left: overlayLeft, top: overlayTop, width });
859
+ // hitbox 用弹框局部坐标(0-based):esc 在 title 行(第 1 行)
860
+ // 尾部 │ 前 5 列,preview 行对应 partRows(顶 border=0、title=1、
861
+ // ├=2、segments=3、空行=4,part 从 5 起)。不依赖官方放置行列。
862
+ escHitbox = { row: 1, startCol: width - 6, endCol: width - 2 };
673
863
  previewHitboxes = visiblePreviews.flatMap((preview) => {
674
864
  const partIndex = allParts.findIndex((part) => part.label === preview.label);
675
865
  const line = 5 + partIndex;
@@ -677,9 +867,9 @@ export default function contextUsageExtension(pi: ExtensionAPI) {
677
867
  ? [
678
868
  {
679
869
  key: preview.key,
680
- row: overlayTop + line + 1,
681
- startCol: overlayLeft + 1,
682
- endCol: overlayLeft + width,
870
+ row: line,
871
+ startCol: 0,
872
+ endCol: width - 1,
683
873
  },
684
874
  ]
685
875
  : [];