pi-cc-extensions 0.9.8 → 0.9.10

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.
@@ -5,8 +5,13 @@ import { config } from "../../config/config.ts";
5
5
  import { oneLine } from "../../utils/format.ts";
6
6
  import { headTruncateToWidth } from "./result.ts";
7
7
 
8
+ /** inputClip=0 时不设字符上限,交给渲染时的实际宽度截断。 */
9
+ function clipLimit(): number {
10
+ return config.inputClip > 0 ? config.inputClip : Number.POSITIVE_INFINITY;
11
+ }
12
+
8
13
  function clip(value: unknown): string {
9
- return oneLine(value, config.inputClip);
14
+ return oneLine(value, clipLimit());
10
15
  }
11
16
 
12
17
  /** 载荷(入参 JSON / 脚本代码)至少留出这么多宽度才显示,否则整段省略(避免只剩一个孤零零的 " {…")。 */
@@ -125,7 +130,7 @@ export function truncatePathToWidth(path: string, width: number): string {
125
130
 
126
131
  /** 路径展示的统一入口:相对化后按配置和当前可用宽度截断。 */
127
132
  export function formatDisplayPath(value: unknown, cwd: string | undefined, width: number): string {
128
- return truncatePathToWidth(displayPath(value, cwd), Math.min(width, config.inputClip));
133
+ return truncatePathToWidth(displayPath(value, cwd), Math.min(width, clipLimit()));
129
134
  }
130
135
 
131
136
  /** 按最终终端宽度渲染摘要;路径摘要不会再被整行头部截断。 */
@@ -134,7 +139,7 @@ export function fitToolCallSummary(summary: ToolCallSummary, width: number): str
134
139
  const prefix = summary.path.prefix;
135
140
  const pathWidth = Math.max(0, width - visibleWidth(prefix) - 1);
136
141
  if (pathWidth <= 0) return headToWidth(prefix, width, "…");
137
- return `${prefix} ${truncatePathToWidth(summary.path.value, Math.min(pathWidth, config.inputClip))}`;
142
+ return `${prefix} ${truncatePathToWidth(summary.path.value, Math.min(pathWidth, clipLimit()))}`;
138
143
  }
139
144
 
140
145
  function pathSummary(
@@ -145,7 +150,7 @@ function pathSummary(
145
150
  ): ToolCallSummary {
146
151
  const path = displayPath(value, cwd);
147
152
  return {
148
- main: `${prefix} ${truncatePathToWidth(path, config.inputClip)}`,
153
+ main: `${prefix} ${truncatePathToWidth(path, clipLimit())}`,
149
154
  detail,
150
155
  path: { prefix, value: path },
151
156
  };
@@ -250,6 +255,9 @@ export function toolCallSummary(
250
255
  const code = typeof args.code === "string" && args.code ? clip(args.code) : "";
251
256
  return code ? { main: title, detail: "", payload: code } : { main: title, detail: "" };
252
257
  }
258
+ // 内置 codemode:标题只报工具名。脚本很长,塞进行首既挤又读不出重点;
259
+ // 要看代码展开 Input,要看跑了什么看子调用行。
260
+ if (name === "codemode") return { main: title, detail: "" };
253
261
 
254
262
  const value = (fallback: string, ...keys: string[]) => {
255
263
  const found = keys.map((key) => args[key]).find((item) => typeof item === "string" && item);
@@ -1,15 +1,22 @@
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";
7
8
  import { sanitizeToolResultText } from "../../utils/tool-result-sanitize.ts";
8
9
 
9
10
  const TOOL_VIEWPORT_WIDTH_RATIO = 0.8;
11
+ /** 宽屏右侧留白上限:比例留白超过这么多列时改用固定留白。 */
12
+ const TOOL_VIEWPORT_MAX_GUTTER = 24;
10
13
 
11
14
  export function toolViewportWidth(width: number): number {
12
- return Math.max(1, Math.floor(width * TOOL_VIEWPORT_WIDTH_RATIO));
15
+ return Math.max(
16
+ 1,
17
+ Math.floor(width * TOOL_VIEWPORT_WIDTH_RATIO),
18
+ Math.floor(width) - TOOL_VIEWPORT_MAX_GUTTER,
19
+ );
13
20
  }
14
21
 
15
22
  /** 与默认工具结果相同的一级缩进包装。子组件只扣始终加上的 1 列;↳ 行多出的 2 列由 truncateToWidth 吃掉。 */
@@ -225,7 +232,7 @@ export class ExpandedToolResultText {
225
232
  }
226
233
 
227
234
  /** 截断体末行 `… +N more lines` 旁的展开提示,点击打开全量预览。 */
228
- export const SHOW_MORE_LABEL = "• click to show more";
235
+ export const SHOW_MORE_LABEL = "· click to show more";
229
236
 
230
237
  export type ToolIoSection = "input" | "output";
231
238
 
@@ -262,6 +269,17 @@ export class ExpandedToolIoView {
262
269
  private truncated: { input: boolean; output: boolean } = { input: false, output: false };
263
270
  /** 0-based header line indexes that carry show-more after last render. */
264
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;
265
283
 
266
284
  /** flushLeft:贴左渲染(mode=on 展开卡);默认 false 保留前导空格(compact 等共用路径)。 */
267
285
  private flushLeft: boolean;
@@ -319,6 +337,57 @@ export class ExpandedToolIoView {
319
337
  return this.inputBody;
320
338
  }
321
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
+
322
391
  getOutputBody(): string {
323
392
  return this.outputBody.trim() ? this.outputBody : "Done";
324
393
  }
@@ -336,7 +405,7 @@ export class ExpandedToolIoView {
336
405
  /** True when the plain truncation footer carries show-more. Input 续行带 │,Output 不带。 */
337
406
  matchShowMoreLine(plainLine: string): ToolIoSection | null {
338
407
  const line = plainLine.replace(/\x1b\[[0-9;]*m/g, "");
339
- if (!line.includes(` • ${showMoreHintText()}`) || !/\+\d+ more lines/.test(line)) return null;
408
+ if (!line.includes(` · ${showMoreHintText()}`) || !/\+\d+ more lines/.test(line)) return null;
340
409
  if (this.truncated.input && line.includes("│")) return "input";
341
410
  if (this.truncated.output) return "output";
342
411
  return null;
@@ -357,7 +426,7 @@ export class ExpandedToolIoView {
357
426
  /** Column range (1-based, visible cells) of show-more on a rendered header, if present. */
358
427
  showMoreHitbox(plainLine: string): { startCol: number; endCol: number } | null {
359
428
  const line = plainLine.replace(/\x1b\[[0-9;]*m/g, "");
360
- const label = ` • ${showMoreHintText()}`;
429
+ const label = ` · ${showMoreHintText()}`;
361
430
  const idx = line.lastIndexOf(label);
362
431
  if (idx < 0) return null;
363
432
  const before = line.slice(0, idx);
@@ -403,14 +472,6 @@ export class ExpandedToolIoView {
403
472
  lines.push(truncateToWidth(theme.fg("dim", `${lead}│`), safeWidth, ""));
404
473
  };
405
474
 
406
- /** Style `key: value` input rows — dim keys, readable values. */
407
- const styleInputLine = (rawLine: string): string => {
408
- const match = rawLine.match(/^([A-Za-z_][\w.-]*)(:\s*)(.*)$/);
409
- if (!match) return theme.fg("muted", rawLine);
410
- const [, key, sep, rest] = match;
411
- return theme.fg("dim", key + sep) + theme.fg("muted", rest ?? "");
412
- };
413
-
414
475
  const pushBody = (
415
476
  body: string,
416
477
  opts: { input?: boolean; limit: number; continued?: boolean; section: ToolIoSection },
@@ -422,8 +483,13 @@ export class ExpandedToolIoView {
422
483
  }
423
484
  const sourceLines = raw.split("\n");
424
485
  const wrapped: string[] = [];
425
- for (const source of sourceLines) {
426
- 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);
427
493
  const parts = wrapTextWithAnsi(styled, contentWidth);
428
494
  if (parts.length === 0) wrapped.push(styled);
429
495
  else wrapped.push(...parts);
@@ -438,7 +504,7 @@ export class ExpandedToolIoView {
438
504
  if (hidden > 0) {
439
505
  // hover 只高亮文字,圆点保持 dim(与 group hint 一致)。
440
506
  const more =
441
- theme.fg("dim", " •") +
507
+ theme.fg("dim", " ·") +
442
508
  theme.fg(
443
509
  this.hoveredSection === opts.section ? "text" : "dim",
444
510
  ` ${showMoreHintText()}`,
@@ -456,7 +522,15 @@ export class ExpandedToolIoView {
456
522
  // Decide show-more from the same truncation rules as pushBody.
457
523
  const inputWouldTruncate =
458
524
  hasInput &&
459
- 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
+ );
460
534
  const outputWouldTruncate = bodyExceedsLineLimit(
461
535
  outputText,
462
536
  this.maxOutputLines,
@@ -516,6 +590,40 @@ export function isExpandedToolIoView(value: unknown): value is ExpandedToolIoVie
516
590
  );
517
591
  }
518
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
+
519
627
  /** True when body needs truncation at the given line limit (source lines or wrapped rows). */
520
628
  function bodyExceedsLineLimit(
521
629
  body: string,
@@ -524,23 +632,18 @@ function bodyExceedsLineLimit(
524
632
  asInput: boolean,
525
633
  theme: any,
526
634
  bodyColor = "toolOutput",
635
+ /** 代码块的源行取着色内容,行数判断与实际渲染保持同一套规则。 */
636
+ codeLine?: (sourceIndex: number) => string | undefined,
527
637
  ): boolean {
528
638
  const raw = body.replace(/\t/g, " ").replace(/\n+$/, "");
529
639
  if (!raw.trim()) return false;
530
640
  const sourceLines = raw.split("\n");
531
641
  if (sourceLines.length > limit) return true;
532
642
  let total = 0;
533
- for (const source of sourceLines) {
534
- let styled: string;
535
- if (asInput) {
536
- const match = source.match(/^([A-Za-z_][\w.-]*)(:\s*)(.*)$/);
537
- styled = match
538
- ? theme.fg("dim", match[1] + match[2]) + theme.fg("muted", match[3] ?? "")
539
- : theme.fg("muted", source);
540
- } else {
541
- styled = theme.fg(bodyColor, source);
542
- }
543
- 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);
544
647
  total += Math.max(1, parts.length);
545
648
  if (total > limit) return true;
546
649
  }
@@ -597,26 +700,42 @@ export function middleTruncateToWidth(text: string, width: number): string {
597
700
  return `${left}…${right}`;
598
701
  }
599
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
+
600
719
  /** Pretty-print tool call args for the expanded Input section. */
601
- export function formatToolInputArgs(args: unknown, maxChars = 8_000): string {
602
- if (args === undefined || args === null) return "";
720
+ export function formatToolInputBody(args: unknown, maxChars = 8_000): ToolInputBody {
721
+ if (args === undefined || args === null) return { text: "" };
603
722
  if (typeof args !== "object") {
604
723
  const text = sanitizeToolResultText(String(args));
605
- return text.length > maxChars ? `${text.slice(0, maxChars)}…` : text;
724
+ return { text: text.length > maxChars ? `${text.slice(0, maxChars)}…` : text };
606
725
  }
607
726
  if (Array.isArray(args)) {
608
727
  try {
609
728
  const json = JSON.stringify(args, null, 2);
610
- return json.length > maxChars ? `${json.slice(0, maxChars)}…` : json;
729
+ return { text: json.length > maxChars ? `${json.slice(0, maxChars)}…` : json };
611
730
  } catch {
612
- return sanitizeToolResultText(String(args));
731
+ return { text: sanitizeToolResultText(String(args)) };
613
732
  }
614
733
  }
615
734
 
616
735
  const entries = Object.entries(args as Record<string, unknown>).filter(
617
736
  ([, value]) => value !== undefined,
618
737
  );
619
- if (entries.length === 0) return "";
738
+ if (entries.length === 0) return { text: "" };
620
739
 
621
740
  // Stable, human-first field order for common tools.
622
741
  const preferred = [
@@ -642,13 +761,30 @@ export function formatToolInputArgs(args: unknown, maxChars = 8_000): string {
642
761
  });
643
762
 
644
763
  const lines: string[] = [];
764
+ let codeBlock: InputCodeBlock | undefined;
645
765
  for (const [rawKey, value] of entries) {
646
766
  const key = sanitizeToolResultText(rawKey);
647
767
  if (typeof value === "string") {
648
768
  const safeValue = sanitizeToolResultText(value);
649
769
  if (safeValue.includes("\n")) {
650
- lines.push(`${key}:`);
651
- 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")) {
652
788
  lines.push(` ${line}`);
653
789
  }
654
790
  } else {
@@ -673,7 +809,13 @@ export function formatToolInputArgs(args: unknown, maxChars = 8_000): string {
673
809
  }
674
810
  }
675
811
  const text = lines.join("\n");
676
- 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;
677
819
  }
678
820
 
679
821
  export function hasExpandableDetail(outputText: string, args: unknown): boolean {
@@ -691,7 +833,7 @@ export function renderExpandedToolResult(
691
833
  /** mode=on 展开卡贴左;compact 等保持默认前导空格 */
692
834
  flushLeft = false,
693
835
  ): ExpandedToolIoView | ExpandedToolResultText | Text {
694
- const inputBody = formatToolInputArgs(args);
836
+ const { text: inputBody, codeBlock } = formatToolInputBody(args);
695
837
  const outputBody = body;
696
838
  const maxOutputLines = config.expandedOutputMaxLines;
697
839
  const maxInputLines = config.expandedInputMaxLines;
@@ -720,6 +862,7 @@ export function renderExpandedToolResult(
720
862
  flushLeft,
721
863
  );
722
864
  }
865
+ view.setInputCode(codeBlock);
723
866
  if (context) rememberIoView(context, view);
724
867
  return view;
725
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
  );
@@ -49,6 +49,8 @@ export const TOOL_MOUSE_TUI_SLOT = Symbol.for("pi.ccstyle.tool-mouse-tui");
49
49
  export const SCROLL_BUTTON_STATE_SLOT = Symbol.for("pi.ccstyle.scroll-button-state");
50
50
  export const OFFICIAL_SCROLL_TO_END_KEY = Symbol.for("pi.ccstyle.official-scroll-to-end");
51
51
  export const FLUSH_DOCKED_BASH_PATCH = Symbol.for("pi.ccstyle.flush-docked-bash-patch");
52
+ export const EARLY_STARTUP_HEADER_PATCH = Symbol.for("pi.ccstyle.early-startup-header-patch");
53
+ export const EARLY_STARTUP_HEADER_SWAP_KEY = Symbol.for("pi.ccstyle.early-startup-header-swap");
52
54
  export const TOOL_HOVER_STATE_KEY = Symbol.for("pi.ccstyle.tool-hover-state");
53
55
 
54
56
  // ── rich diff 组件标记 ──
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-cc-extensions",
3
- "version": "0.9.8",
3
+ "version": "0.9.10",
4
4
  "license": "MIT",
5
5
  "description": "Claude Code-style TUI output with some personal touches and handy utilities.",
6
6
  "type": "module",
@@ -56,15 +56,18 @@
56
56
  },
57
57
  "bundledDependencies": [],
58
58
  "peerDependencies": {
59
- "@earendil-works/pi-ai": "^0.84.0",
60
- "@earendil-works/pi-coding-agent": "^0.84.0",
61
- "@earendil-works/pi-tui": "^0.84.0"
59
+ "@earendil-works/pi-ai": "*",
60
+ "@earendil-works/pi-coding-agent": "*",
61
+ "@earendil-works/pi-tui": "*"
62
62
  },
63
63
  "engines": {
64
64
  "node": ">=22.19.0"
65
65
  },
66
66
  "devDependencies": {
67
67
  "@biomejs/biome": "2.5.5",
68
+ "@earendil-works/pi-ai": "0.99.1",
69
+ "@earendil-works/pi-coding-agent": "0.99.1",
70
+ "@earendil-works/pi-tui": "0.99.1",
68
71
  "@types/node": "^26.1.1",
69
72
  "typescript": "^5.9.3"
70
73
  }