pi-cc-extensions 0.9.9 → 0.9.11

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.
@@ -1,6 +1,7 @@
1
1
  import { Text, truncateToWidth, visibleWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
2
2
  import { inspect } from "node:util";
3
3
  import { config } from "../../config/config.ts";
4
+ import { createCodeBlockHighlighter } from "./diff/diff-highlight.ts";
4
5
  import { showMoreHintText } from "./show-more-hint.ts";
5
6
  import { TOOL_LOADING_INTERVAL_MS, toolLoadingIcon } from "../../utils/tool-loading-icon.ts";
6
7
  import { getToolMouseTui } from "../mouse/scroll.ts";
@@ -231,7 +232,7 @@ export class ExpandedToolResultText {
231
232
  }
232
233
 
233
234
  /** 截断体末行 `… +N more lines` 旁的展开提示,点击打开全量预览。 */
234
- export const SHOW_MORE_LABEL = "• click to show more";
235
+ export const SHOW_MORE_LABEL = "· click to show more";
235
236
 
236
237
  export type ToolIoSection = "input" | "output";
237
238
 
@@ -268,6 +269,17 @@ export class ExpandedToolIoView {
268
269
  private truncated: { input: boolean; output: boolean } = { input: false, output: false };
269
270
  /** 0-based header line indexes that carry show-more after last render. */
270
271
  private showMoreHeaderRows: { input?: number; output?: number } = {};
272
+ /** Input 里的代码块(codemode 的 `code`):着色行覆盖默认 dim/muted 样式。 */
273
+ private inputCode?:
274
+ | {
275
+ startLine: number;
276
+ language: string;
277
+ standalone: boolean;
278
+ code: string;
279
+ theme: unknown;
280
+ rows: () => string[];
281
+ }
282
+ | undefined;
271
283
 
272
284
  /** flushLeft:贴左渲染(mode=on 展开卡);默认 false 保留前导空格(compact 等共用路径)。 */
273
285
  private flushLeft: boolean;
@@ -325,6 +337,57 @@ export class ExpandedToolIoView {
325
337
  return this.inputBody;
326
338
  }
327
339
 
340
+ /**
341
+ * 登记 Input 里的代码块:同步 cli-highlight 打底,shiki 异步升级,落地后
342
+ * 经 invalidate 重画。同一份代码与主题只建一次(视图按宽度缓存行)。
343
+ */
344
+ setInputCode(block: InputCodeBlock | undefined): void {
345
+ if (!block) {
346
+ if (this.inputCode) {
347
+ this.inputCode = undefined;
348
+ this.invalidate();
349
+ }
350
+ return;
351
+ }
352
+ if (this.inputCode?.code === block.code && this.inputCode.theme === this.theme) {
353
+ this.inputCode.startLine = block.startLine;
354
+ this.inputCode.standalone = block.standalone;
355
+ return;
356
+ }
357
+ this.inputCode = {
358
+ startLine: block.startLine,
359
+ language: block.language,
360
+ standalone: block.standalone,
361
+ code: block.code,
362
+ theme: this.theme,
363
+ rows: createCodeBlockHighlighter(block.code, block.language, this.theme, () => {
364
+ // 视图自己的行缓存 + 外层工具卡(清 paint 缓存并 requestRender)
365
+ this.invalidate();
366
+ invalidateIoView(this);
367
+ }),
368
+ };
369
+ this.invalidate();
370
+ }
371
+
372
+ /**
373
+ * 整段 Input 就是代码块时给出语言与代码,供全量预览包一层围栏(弹框按 Markdown
374
+ * 渲染,围栏代码块由 pi 的 highlightCode 着色)。混了别的字段时返回 undefined。
375
+ */
376
+ getInputCodeFence(): { language: string; code: string } | undefined {
377
+ const block = this.inputCode;
378
+ return block?.standalone ? { language: block.language, code: block.code } : undefined;
379
+ }
380
+
381
+ /** 代码块里某一源行的着色内容(已剥掉行首缩进);不在块内时为 undefined。 */
382
+ private inputCodeLine(sourceIndex: number): string | undefined {
383
+ const block = this.inputCode;
384
+ if (!block) return undefined;
385
+ const offset = sourceIndex - block.startLine;
386
+ if (offset < 0) return undefined;
387
+ const rows = block.rows();
388
+ return offset < rows.length ? rows[offset] : undefined;
389
+ }
390
+
328
391
  getOutputBody(): string {
329
392
  return this.outputBody.trim() ? this.outputBody : "Done";
330
393
  }
@@ -342,7 +405,7 @@ export class ExpandedToolIoView {
342
405
  /** True when the plain truncation footer carries show-more. Input 续行带 │,Output 不带。 */
343
406
  matchShowMoreLine(plainLine: string): ToolIoSection | null {
344
407
  const line = plainLine.replace(/\x1b\[[0-9;]*m/g, "");
345
- if (!line.includes(` • ${showMoreHintText()}`) || !/\+\d+ more lines/.test(line)) return null;
408
+ if (!line.includes(` · ${showMoreHintText()}`) || !/\+\d+ more lines/.test(line)) return null;
346
409
  if (this.truncated.input && line.includes("│")) return "input";
347
410
  if (this.truncated.output) return "output";
348
411
  return null;
@@ -363,7 +426,7 @@ export class ExpandedToolIoView {
363
426
  /** Column range (1-based, visible cells) of show-more on a rendered header, if present. */
364
427
  showMoreHitbox(plainLine: string): { startCol: number; endCol: number } | null {
365
428
  const line = plainLine.replace(/\x1b\[[0-9;]*m/g, "");
366
- const label = ` • ${showMoreHintText()}`;
429
+ const label = ` · ${showMoreHintText()}`;
367
430
  const idx = line.lastIndexOf(label);
368
431
  if (idx < 0) return null;
369
432
  const before = line.slice(0, idx);
@@ -409,14 +472,6 @@ export class ExpandedToolIoView {
409
472
  lines.push(truncateToWidth(theme.fg("dim", `${lead}│`), safeWidth, ""));
410
473
  };
411
474
 
412
- /** Style `key: value` input rows — dim keys, readable values. */
413
- const styleInputLine = (rawLine: string): string => {
414
- const match = rawLine.match(/^([A-Za-z_][\w.-]*)(:\s*)(.*)$/);
415
- if (!match) return theme.fg("muted", rawLine);
416
- const [, key, sep, rest] = match;
417
- return theme.fg("dim", key + sep) + theme.fg("muted", rest ?? "");
418
- };
419
-
420
475
  const pushBody = (
421
476
  body: string,
422
477
  opts: { input?: boolean; limit: number; continued?: boolean; section: ToolIoSection },
@@ -428,8 +483,13 @@ export class ExpandedToolIoView {
428
483
  }
429
484
  const sourceLines = raw.split("\n");
430
485
  const wrapped: string[] = [];
431
- for (const source of sourceLines) {
432
- const styled = opts.input ? styleInputLine(source) : theme.fg(bodyColor, source);
486
+ for (const [index, source] of sourceLines.entries()) {
487
+ if (opts.input) {
488
+ // 代码块的行用着色版本(已剥缩进),其余走默认 dim/muted
489
+ wrapped.push(...wrapInputLine(source, contentWidth, theme, this.inputCodeLine(index)));
490
+ continue;
491
+ }
492
+ const styled = theme.fg(bodyColor, source);
433
493
  const parts = wrapTextWithAnsi(styled, contentWidth);
434
494
  if (parts.length === 0) wrapped.push(styled);
435
495
  else wrapped.push(...parts);
@@ -444,7 +504,7 @@ export class ExpandedToolIoView {
444
504
  if (hidden > 0) {
445
505
  // hover 只高亮文字,圆点保持 dim(与 group hint 一致)。
446
506
  const more =
447
- theme.fg("dim", " •") +
507
+ theme.fg("dim", " ·") +
448
508
  theme.fg(
449
509
  this.hoveredSection === opts.section ? "text" : "dim",
450
510
  ` ${showMoreHintText()}`,
@@ -462,7 +522,15 @@ export class ExpandedToolIoView {
462
522
  // Decide show-more from the same truncation rules as pushBody.
463
523
  const inputWouldTruncate =
464
524
  hasInput &&
465
- bodyExceedsLineLimit(this.inputBody, this.maxInputLines, contentWidth, true, theme);
525
+ bodyExceedsLineLimit(
526
+ this.inputBody,
527
+ this.maxInputLines,
528
+ contentWidth,
529
+ true,
530
+ theme,
531
+ bodyColor,
532
+ (inputIndex: number) => this.inputCodeLine(inputIndex),
533
+ );
466
534
  const outputWouldTruncate = bodyExceedsLineLimit(
467
535
  outputText,
468
536
  this.maxOutputLines,
@@ -522,6 +590,40 @@ export function isExpandedToolIoView(value: unknown): value is ExpandedToolIoVie
522
590
  );
523
591
  }
524
592
 
593
+ /** Input 行着色:`key: value` 拆成 dim 键 + muted 值,其余整行 muted。 */
594
+ function styleInputSourceLine(line: string, theme: any): string {
595
+ const match = line.match(/^([A-Za-z_][\w.-]*)(:\s*)(.*)$/);
596
+ if (!match) return theme.fg("muted", line);
597
+ const [, key, sep, rest] = match;
598
+ return theme.fg("dim", key + sep) + theme.fg("muted", rest ?? "");
599
+ }
600
+
601
+ /**
602
+ * 折一行 Input 源码。不带 styledBody 时与改动前完全一致(其他工具不受影响);
603
+ * 代码块(styledBody = 已着色且已剥缩进的行)续行跟着源码前导缩进:顶到 rail
604
+ * 会被读成新的一行,所以缩进先剥掉、折行宽度相应扣减,再给每一行加回去。
605
+ * 行数判断(bodyExceedsLineLimit)与实际渲染共用这一套,展开提示才对得上。
606
+ */
607
+ function wrapInputLine(
608
+ source: string,
609
+ contentWidth: number,
610
+ theme: any,
611
+ styledBody?: string,
612
+ ): string[] {
613
+ if (styledBody === undefined) {
614
+ const styled = styleInputSourceLine(source, theme);
615
+ const parts = wrapTextWithAnsi(styled, contentWidth);
616
+ return parts.length ? parts : [styled];
617
+ }
618
+ const lead = /^[ \t]*/.exec(source)?.[0] ?? "";
619
+ const leadWidth = visibleWidth(lead);
620
+ const parts = wrapTextWithAnsi(styledBody, Math.max(1, contentWidth - leadWidth));
621
+ const rows = parts.length ? parts : [styledBody];
622
+ if (!leadWidth) return rows;
623
+ const prefix = theme.fg("muted", lead);
624
+ return rows.map((row) => prefix + row);
625
+ }
626
+
525
627
  /** True when body needs truncation at the given line limit (source lines or wrapped rows). */
526
628
  function bodyExceedsLineLimit(
527
629
  body: string,
@@ -530,23 +632,18 @@ function bodyExceedsLineLimit(
530
632
  asInput: boolean,
531
633
  theme: any,
532
634
  bodyColor = "toolOutput",
635
+ /** 代码块的源行取着色内容,行数判断与实际渲染保持同一套规则。 */
636
+ codeLine?: (sourceIndex: number) => string | undefined,
533
637
  ): boolean {
534
638
  const raw = body.replace(/\t/g, " ").replace(/\n+$/, "");
535
639
  if (!raw.trim()) return false;
536
640
  const sourceLines = raw.split("\n");
537
641
  if (sourceLines.length > limit) return true;
538
642
  let total = 0;
539
- for (const source of sourceLines) {
540
- let styled: string;
541
- if (asInput) {
542
- const match = source.match(/^([A-Za-z_][\w.-]*)(:\s*)(.*)$/);
543
- styled = match
544
- ? theme.fg("dim", match[1] + match[2]) + theme.fg("muted", match[3] ?? "")
545
- : theme.fg("muted", source);
546
- } else {
547
- styled = theme.fg(bodyColor, source);
548
- }
549
- const parts = wrapTextWithAnsi(styled, contentWidth);
643
+ for (const [index, source] of sourceLines.entries()) {
644
+ const parts = asInput
645
+ ? wrapInputLine(source, contentWidth, theme, codeLine?.(index))
646
+ : wrapTextWithAnsi(theme.fg(bodyColor, source), contentWidth);
550
647
  total += Math.max(1, parts.length);
551
648
  if (total > limit) return true;
552
649
  }
@@ -603,26 +700,42 @@ export function middleTruncateToWidth(text: string, width: number): string {
603
700
  return `${left}…${right}`;
604
701
  }
605
702
 
703
+ /**
704
+ * 展开卡 Input 的代码块:源行起始下标(相对 text)+ 语言 + 剥掉行首缩进的代码。
705
+ * standalone = 整段 Input 就是这块代码(没有 `code:` 标签),全量预览可以整段包围栏。
706
+ */
707
+ export type InputCodeBlock = {
708
+ startLine: number;
709
+ language: string;
710
+ code: string;
711
+ standalone: boolean;
712
+ };
713
+
714
+ export type ToolInputBody = { text: string; codeBlock?: InputCodeBlock };
715
+
716
+ /** 按字段名判定可着色的入参:codemode / mcpscript 的 `code` 都是 JS 脚本。 */
717
+ const INPUT_CODE_LANGUAGES: Record<string, string> = { code: "javascript" };
718
+
606
719
  /** Pretty-print tool call args for the expanded Input section. */
607
- export function formatToolInputArgs(args: unknown, maxChars = 8_000): string {
608
- if (args === undefined || args === null) return "";
720
+ export function formatToolInputBody(args: unknown, maxChars = 8_000): ToolInputBody {
721
+ if (args === undefined || args === null) return { text: "" };
609
722
  if (typeof args !== "object") {
610
723
  const text = sanitizeToolResultText(String(args));
611
- return text.length > maxChars ? `${text.slice(0, maxChars)}…` : text;
724
+ return { text: text.length > maxChars ? `${text.slice(0, maxChars)}…` : text };
612
725
  }
613
726
  if (Array.isArray(args)) {
614
727
  try {
615
728
  const json = JSON.stringify(args, null, 2);
616
- return json.length > maxChars ? `${json.slice(0, maxChars)}…` : json;
729
+ return { text: json.length > maxChars ? `${json.slice(0, maxChars)}…` : json };
617
730
  } catch {
618
- return sanitizeToolResultText(String(args));
731
+ return { text: sanitizeToolResultText(String(args)) };
619
732
  }
620
733
  }
621
734
 
622
735
  const entries = Object.entries(args as Record<string, unknown>).filter(
623
736
  ([, value]) => value !== undefined,
624
737
  );
625
- if (entries.length === 0) return "";
738
+ if (entries.length === 0) return { text: "" };
626
739
 
627
740
  // Stable, human-first field order for common tools.
628
741
  const preferred = [
@@ -648,13 +761,30 @@ export function formatToolInputArgs(args: unknown, maxChars = 8_000): string {
648
761
  });
649
762
 
650
763
  const lines: string[] = [];
764
+ let codeBlock: InputCodeBlock | undefined;
651
765
  for (const [rawKey, value] of entries) {
652
766
  const key = sanitizeToolResultText(rawKey);
653
767
  if (typeof value === "string") {
654
768
  const safeValue = sanitizeToolResultText(value);
655
769
  if (safeValue.includes("\n")) {
656
- lines.push(`${key}:`);
657
- for (const line of safeValue.replace(/\t/g, " ").split("\n")) {
770
+ const expanded = safeValue.replace(/\t/g, " ");
771
+ const language = INPUT_CODE_LANGUAGES[key];
772
+ // 只有一个 code 字段时(codemode 等)整块 Input 就是脚本,`code:` 标签是噪音
773
+ const standaloneCode = Boolean(language) && entries.length === 1;
774
+ if (!standaloneCode) lines.push(`${key}:`);
775
+ if (language) {
776
+ // 缩进由 wrapInputLine 负责,着色只喂剥掉行首缩进的正文
777
+ codeBlock = {
778
+ startLine: lines.length,
779
+ language,
780
+ code: expanded
781
+ .split("\n")
782
+ .map((line) => line.replace(/^[ \t]*/, ""))
783
+ .join("\n"),
784
+ standalone: standaloneCode,
785
+ };
786
+ }
787
+ for (const line of expanded.split("\n")) {
658
788
  lines.push(` ${line}`);
659
789
  }
660
790
  } else {
@@ -679,7 +809,13 @@ export function formatToolInputArgs(args: unknown, maxChars = 8_000): string {
679
809
  }
680
810
  }
681
811
  const text = lines.join("\n");
682
- return text.length > maxChars ? `${text.slice(0, maxChars)}…` : text;
812
+ // 截断后的行号与着色行不再对齐,这时只给纯文本
813
+ if (text.length > maxChars) return { text: `${text.slice(0, maxChars)}…` };
814
+ return codeBlock ? { text, codeBlock } : { text };
815
+ }
816
+
817
+ export function formatToolInputArgs(args: unknown, maxChars = 8_000): string {
818
+ return formatToolInputBody(args, maxChars).text;
683
819
  }
684
820
 
685
821
  export function hasExpandableDetail(outputText: string, args: unknown): boolean {
@@ -697,7 +833,7 @@ export function renderExpandedToolResult(
697
833
  /** mode=on 展开卡贴左;compact 等保持默认前导空格 */
698
834
  flushLeft = false,
699
835
  ): ExpandedToolIoView | ExpandedToolResultText | Text {
700
- const inputBody = formatToolInputArgs(args);
836
+ const { text: inputBody, codeBlock } = formatToolInputBody(args);
701
837
  const outputBody = body;
702
838
  const maxOutputLines = config.expandedOutputMaxLines;
703
839
  const maxInputLines = config.expandedInputMaxLines;
@@ -726,6 +862,7 @@ export function renderExpandedToolResult(
726
862
  flushLeft,
727
863
  );
728
864
  }
865
+ view.setInputCode(codeBlock);
729
866
  if (context) rememberIoView(context, view);
730
867
  return view;
731
868
  }
@@ -41,6 +41,7 @@ export function stripBackgroundAnsi(line: string): string {
41
41
  /** 剥离行首状态图标(展开组内工具首行复用)。 */
42
42
  export function stripLeadingStatusIcon(line: string): string {
43
43
  return line.replace(
44
+ // 行首图标:ccstyle 用 `·`,pi 原生输出里还有 `•`,两种都要剥
44
45
  /^((?:\x1b\[[0-9;]*m|[ \t]|[├└│─])*)(?:\x1b\[[0-9;]*m)*(?:[✓✗●○■⬤•·])(?:\x1b\[[0-9;]*m)*\s+/,
45
46
  "$1",
46
47
  );
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-cc-extensions",
3
- "version": "0.9.9",
3
+ "version": "0.9.11",
4
4
  "license": "MIT",
5
5
  "description": "Claude Code-style TUI output with some personal touches and handy utilities.",
6
6
  "type": "module",