@owlburtoe/pi-cc-tools 1.0.1 → 1.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +18 -0
- package/config/config.example.json +2 -0
- package/extensions/index.ts +267 -9
- package/package.json +2 -1
package/README.md
CHANGED
|
@@ -5,6 +5,7 @@ Claude Code inspired tool rendering for Pi — Shiki-powered diffs, status dots,
|
|
|
5
5
|
## Features
|
|
6
6
|
|
|
7
7
|
- **Compact built-in tool rendering** for `read`, `bash`, `grep`, `find`, `ls`, `edit`, and `write`
|
|
8
|
+
- **Semantic bash display** that renders common read-only shell one-liners like `nl -ba file | sed -n '1,200p'` as Claude Code-style `Read file (lines 1-200)` rows
|
|
8
9
|
- **Claude-style OpenAI tool rendering** for `apply_patch` plus common Pi/OpenAI-style tools like `webfetch`, `web_search`, `fetch_content`, task tools, and context tools
|
|
9
10
|
- **`apply_patch` diff previews** that render parsed file patches in the call phase, similar to `edit`/`write`
|
|
10
11
|
- **Adaptive edit/write diffs** with split or unified layouts, syntax highlighting, and inline word-level emphasis
|
|
@@ -32,6 +33,8 @@ Set in `.pi/settings.json` or `~/.pi/settings.json`:
|
|
|
32
33
|
"previewLines": 8,
|
|
33
34
|
"bashOutputMode": "opencode",
|
|
34
35
|
"bashCollapsedLines": 10,
|
|
36
|
+
"bashStackConsecutive": true,
|
|
37
|
+
"bashSemanticDisplay": true,
|
|
35
38
|
"diffCollapsedLines": 24,
|
|
36
39
|
"themeAdaptive": true,
|
|
37
40
|
"diffTheme": "github-dark",
|
|
@@ -135,6 +138,21 @@ Color selections are persisted as `spinnerColor` / `spinnerStatusColor` in `~/.p
|
|
|
135
138
|
| `mcpOutputMode` | `hidden`, `summary`, `preview` | `preview` |
|
|
136
139
|
| `bashOutputMode` | `opencode`, `summary`, `preview` | `opencode` |
|
|
137
140
|
|
|
141
|
+
`bashOutputMode` behavior:
|
|
142
|
+
|
|
143
|
+
| Value | Behavior |
|
|
144
|
+
|-------|----------|
|
|
145
|
+
| `opencode` | Compact status while collapsed, with `Ctrl+O` hint for output preview on raw shell commands. Semantic read-only bash rows omit the repeated hint to stay closer to Claude Code. |
|
|
146
|
+
| `summary` | Status only; no output preview or expansion hint |
|
|
147
|
+
| `preview` | Show a small output preview even while collapsed |
|
|
148
|
+
|
|
149
|
+
### Boolean settings
|
|
150
|
+
|
|
151
|
+
| Setting | Default | Description |
|
|
152
|
+
|---------|---------|-------------|
|
|
153
|
+
| `bashStackConsecutive` | `true` | Remove the extra synthetic spacer between adjacent bash tool rows so command bursts render as a tight stack |
|
|
154
|
+
| `bashSemanticDisplay` | `true` | Render common read-only shell file-inspection commands as semantic `Read` rows instead of raw `Bash` rows |
|
|
155
|
+
|
|
138
156
|
### Numeric settings
|
|
139
157
|
|
|
140
158
|
| Setting | Default | Description |
|
package/extensions/index.ts
CHANGED
|
@@ -84,6 +84,10 @@ interface SettingsFile {
|
|
|
84
84
|
expandedPreviewMaxLines?: number;
|
|
85
85
|
bashOutputMode?: "opencode" | "summary" | "preview";
|
|
86
86
|
bashCollapsedLines?: number;
|
|
87
|
+
/** When true (default), consecutive bash tool rows render without the extra inter-tool spacer. */
|
|
88
|
+
bashStackConsecutive?: boolean;
|
|
89
|
+
/** When true (default), read-only shell file-inspection one-liners render as semantic Read rows instead of raw Bash rows. */
|
|
90
|
+
bashSemanticDisplay?: boolean;
|
|
87
91
|
showTruncationHints?: boolean;
|
|
88
92
|
diffCollapsedLines?: number;
|
|
89
93
|
diffTheme?: string;
|
|
@@ -329,6 +333,44 @@ function isToolExecutionLike(value: unknown): value is { toolName: string; toolC
|
|
|
329
333
|
return typeof candidate.toolName === "string" && typeof candidate.toolCallId === "string";
|
|
330
334
|
}
|
|
331
335
|
|
|
336
|
+
function isBashToolExecution(value: unknown): boolean {
|
|
337
|
+
return isToolExecutionLike(value) && (value as any).toolName === "bash";
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
function shouldStackConsecutiveBash(): boolean {
|
|
341
|
+
return readSettings().bashStackConsecutive !== false;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
function hasConsecutiveBashToolChildren(children: unknown[]): boolean {
|
|
345
|
+
let previousWasBash = false;
|
|
346
|
+
for (const child of children) {
|
|
347
|
+
const currentIsBash = isBashToolExecution(child);
|
|
348
|
+
if (currentIsBash && previousWasBash) return true;
|
|
349
|
+
previousWasBash = currentIsBash;
|
|
350
|
+
}
|
|
351
|
+
return false;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
function dropLeadingSpacerLine(lines: string[]): string[] {
|
|
355
|
+
return lines.length > 0 && isBlankLine(lines[0]) ? lines.slice(1) : lines;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
function renderWithStackedConsecutiveBash(container: any, width: number): string[] | null {
|
|
359
|
+
if (!shouldStackConsecutiveBash()) return null;
|
|
360
|
+
const children = Array.isArray(container?.children) ? container.children : null;
|
|
361
|
+
if (!children || !hasConsecutiveBashToolChildren(children)) return null;
|
|
362
|
+
|
|
363
|
+
const rendered: string[] = [];
|
|
364
|
+
let previousWasBash = false;
|
|
365
|
+
for (const child of children) {
|
|
366
|
+
const currentIsBash = isBashToolExecution(child);
|
|
367
|
+
const childLines = typeof child?.render === "function" ? child.render(width) : [];
|
|
368
|
+
rendered.push(...(currentIsBash && previousWasBash ? dropLeadingSpacerLine(childLines) : childLines));
|
|
369
|
+
previousWasBash = currentIsBash;
|
|
370
|
+
}
|
|
371
|
+
return rendered;
|
|
372
|
+
}
|
|
373
|
+
|
|
332
374
|
function isTerminalImageLine(line: string): boolean {
|
|
333
375
|
return line.includes(KITTY_IMAGE_PREFIX) || line.includes(ITERM2_IMAGE_PREFIX);
|
|
334
376
|
}
|
|
@@ -359,6 +401,11 @@ function patchGlobalToolBorders(): void {
|
|
|
359
401
|
|
|
360
402
|
const originalRender = proto.render;
|
|
361
403
|
proto.render = function patchedContainerRender(width: number): string[] {
|
|
404
|
+
if (!isToolExecutionLike(this)) {
|
|
405
|
+
const stacked = renderWithStackedConsecutiveBash(this, width);
|
|
406
|
+
if (stacked) return stacked;
|
|
407
|
+
}
|
|
408
|
+
|
|
362
409
|
if (isToolExecutionLike(this)) {
|
|
363
410
|
const cached = (this as any)[TOOL_RENDER_CACHE];
|
|
364
411
|
if (cached?.width === width && cached?.mode === toolBackgroundMode) {
|
|
@@ -1324,6 +1371,195 @@ function bashCollapsedLimit(): number {
|
|
|
1324
1371
|
return typeof value === "number" && Number.isFinite(value) && value >= 0 ? Math.floor(value) : 10;
|
|
1325
1372
|
}
|
|
1326
1373
|
|
|
1374
|
+
function bashOutputMode(): "opencode" | "summary" | "preview" {
|
|
1375
|
+
return getMode(readSettings().bashOutputMode, ["opencode", "summary", "preview"] as const, "opencode");
|
|
1376
|
+
}
|
|
1377
|
+
|
|
1378
|
+
function bashSemanticDisplayEnabled(): boolean {
|
|
1379
|
+
return readSettings().bashSemanticDisplay !== false;
|
|
1380
|
+
}
|
|
1381
|
+
|
|
1382
|
+
export interface BashDisplayInfo {
|
|
1383
|
+
kind: "read";
|
|
1384
|
+
label: "Read";
|
|
1385
|
+
path: string;
|
|
1386
|
+
rangeLabel?: string;
|
|
1387
|
+
suppressCollapsedHint: boolean;
|
|
1388
|
+
}
|
|
1389
|
+
|
|
1390
|
+
function tokenizeShellCommand(command: string): string[] | null {
|
|
1391
|
+
const tokens: string[] = [];
|
|
1392
|
+
let current = "";
|
|
1393
|
+
let quote: "'" | '"' | null = null;
|
|
1394
|
+
let escaping = false;
|
|
1395
|
+
|
|
1396
|
+
const push = () => {
|
|
1397
|
+
if (current.length > 0) {
|
|
1398
|
+
tokens.push(current);
|
|
1399
|
+
current = "";
|
|
1400
|
+
}
|
|
1401
|
+
};
|
|
1402
|
+
|
|
1403
|
+
for (let i = 0; i < command.length; i++) {
|
|
1404
|
+
const char = command[i];
|
|
1405
|
+
if (escaping) {
|
|
1406
|
+
current += char;
|
|
1407
|
+
escaping = false;
|
|
1408
|
+
continue;
|
|
1409
|
+
}
|
|
1410
|
+
if (quote) {
|
|
1411
|
+
if (char === quote) {
|
|
1412
|
+
quote = null;
|
|
1413
|
+
continue;
|
|
1414
|
+
}
|
|
1415
|
+
if (quote === '"' && char === "\\") {
|
|
1416
|
+
escaping = true;
|
|
1417
|
+
continue;
|
|
1418
|
+
}
|
|
1419
|
+
current += char;
|
|
1420
|
+
continue;
|
|
1421
|
+
}
|
|
1422
|
+
if (char === "\\") {
|
|
1423
|
+
escaping = true;
|
|
1424
|
+
continue;
|
|
1425
|
+
}
|
|
1426
|
+
if (char === "'" || char === '"') {
|
|
1427
|
+
quote = char;
|
|
1428
|
+
continue;
|
|
1429
|
+
}
|
|
1430
|
+
if (/\s/.test(char)) {
|
|
1431
|
+
push();
|
|
1432
|
+
continue;
|
|
1433
|
+
}
|
|
1434
|
+
if (char === "|" || char === ";") {
|
|
1435
|
+
push();
|
|
1436
|
+
if (char === "|" && command[i + 1] === "|") {
|
|
1437
|
+
tokens.push("||");
|
|
1438
|
+
i++;
|
|
1439
|
+
} else {
|
|
1440
|
+
tokens.push(char);
|
|
1441
|
+
}
|
|
1442
|
+
continue;
|
|
1443
|
+
}
|
|
1444
|
+
if (char === "&" && command[i + 1] === "&") {
|
|
1445
|
+
push();
|
|
1446
|
+
tokens.push("&&");
|
|
1447
|
+
i++;
|
|
1448
|
+
continue;
|
|
1449
|
+
}
|
|
1450
|
+
current += char;
|
|
1451
|
+
}
|
|
1452
|
+
if (escaping) current += "\\";
|
|
1453
|
+
if (quote) return null;
|
|
1454
|
+
push();
|
|
1455
|
+
return tokens;
|
|
1456
|
+
}
|
|
1457
|
+
|
|
1458
|
+
function isControlToken(token: string): boolean {
|
|
1459
|
+
return token === "&&" || token === "||" || token === ";";
|
|
1460
|
+
}
|
|
1461
|
+
|
|
1462
|
+
function isOptionToken(token: string): boolean {
|
|
1463
|
+
return token.startsWith("-") && token !== "-" && token !== "--";
|
|
1464
|
+
}
|
|
1465
|
+
|
|
1466
|
+
function singlePathArg(tokens: readonly string[]): string | null {
|
|
1467
|
+
const paths = tokens.filter((token) => token !== "--" && token !== "-" && !isOptionToken(token));
|
|
1468
|
+
return paths.length === 1 ? paths[0] : null;
|
|
1469
|
+
}
|
|
1470
|
+
|
|
1471
|
+
function formatSedRangeLabel(script: string | undefined): string | undefined {
|
|
1472
|
+
if (!script) return undefined;
|
|
1473
|
+
const match = script.match(/^(\d+|\$)(?:,(\d+|\$))?p$/);
|
|
1474
|
+
if (!match) return undefined;
|
|
1475
|
+
const start = match[1];
|
|
1476
|
+
const end = match[2];
|
|
1477
|
+
if (!end || end === start) return `line ${start}`;
|
|
1478
|
+
return `lines ${start}-${end}`;
|
|
1479
|
+
}
|
|
1480
|
+
|
|
1481
|
+
function parseSedPrintRange(tokens: readonly string[]): string | undefined {
|
|
1482
|
+
for (let i = 0; i < tokens.length; i++) {
|
|
1483
|
+
if (tokens[i] === "-n" && tokens[i + 1]) {
|
|
1484
|
+
const label = formatSedRangeLabel(tokens[i + 1]);
|
|
1485
|
+
if (label) return label;
|
|
1486
|
+
}
|
|
1487
|
+
const direct = formatSedRangeLabel(tokens[i]);
|
|
1488
|
+
if (direct) return direct;
|
|
1489
|
+
}
|
|
1490
|
+
return undefined;
|
|
1491
|
+
}
|
|
1492
|
+
|
|
1493
|
+
function classifyNlRead(tokens: readonly string[]): BashDisplayInfo | null {
|
|
1494
|
+
const pipeIndex = tokens.indexOf("|");
|
|
1495
|
+
const left = pipeIndex === -1 ? tokens : tokens.slice(0, pipeIndex);
|
|
1496
|
+
if (left[0] !== "nl") return null;
|
|
1497
|
+
const path = singlePathArg(left.slice(1));
|
|
1498
|
+
if (!path) return null;
|
|
1499
|
+
let rangeLabel: string | undefined;
|
|
1500
|
+
if (pipeIndex !== -1) {
|
|
1501
|
+
const right = tokens.slice(pipeIndex + 1);
|
|
1502
|
+
if (right[0] !== "sed" || right.includes("|")) return null;
|
|
1503
|
+
rangeLabel = parseSedPrintRange(right);
|
|
1504
|
+
}
|
|
1505
|
+
return { kind: "read", label: "Read", path, rangeLabel, suppressCollapsedHint: true };
|
|
1506
|
+
}
|
|
1507
|
+
|
|
1508
|
+
function classifySedRead(tokens: readonly string[]): BashDisplayInfo | null {
|
|
1509
|
+
if (tokens[0] !== "sed") return null;
|
|
1510
|
+
const rangeLabel = parseSedPrintRange(tokens);
|
|
1511
|
+
if (!rangeLabel) return null;
|
|
1512
|
+
const scriptIndex = tokens.findIndex((token) => formatSedRangeLabel(token) !== undefined);
|
|
1513
|
+
const path = singlePathArg(tokens.slice(scriptIndex + 1));
|
|
1514
|
+
if (!path) return null;
|
|
1515
|
+
return { kind: "read", label: "Read", path, rangeLabel, suppressCollapsedHint: true };
|
|
1516
|
+
}
|
|
1517
|
+
|
|
1518
|
+
function classifyCatRead(tokens: readonly string[]): BashDisplayInfo | null {
|
|
1519
|
+
if (tokens[0] !== "cat") return null;
|
|
1520
|
+
const path = singlePathArg(tokens.slice(1));
|
|
1521
|
+
if (!path) return null;
|
|
1522
|
+
return { kind: "read", label: "Read", path, suppressCollapsedHint: true };
|
|
1523
|
+
}
|
|
1524
|
+
|
|
1525
|
+
function classifyHeadTailRead(tokens: readonly string[]): BashDisplayInfo | null {
|
|
1526
|
+
const command = tokens[0];
|
|
1527
|
+
if (command !== "head" && command !== "tail") return null;
|
|
1528
|
+
const args = tokens.slice(1);
|
|
1529
|
+
let count: string | undefined;
|
|
1530
|
+
const pathCandidates: string[] = [];
|
|
1531
|
+
for (let i = 0; i < args.length; i++) {
|
|
1532
|
+
const token = args[i];
|
|
1533
|
+
if (token === "--") continue;
|
|
1534
|
+
if (token === "-n" && args[i + 1]) {
|
|
1535
|
+
count = args[i + 1];
|
|
1536
|
+
i++;
|
|
1537
|
+
continue;
|
|
1538
|
+
}
|
|
1539
|
+
const compactCount = token.match(/^-([0-9]+)$/)?.[1];
|
|
1540
|
+
if (compactCount) {
|
|
1541
|
+
count = compactCount;
|
|
1542
|
+
continue;
|
|
1543
|
+
}
|
|
1544
|
+
if (isOptionToken(token)) continue;
|
|
1545
|
+
pathCandidates.push(token);
|
|
1546
|
+
}
|
|
1547
|
+
if (pathCandidates.length !== 1) return null;
|
|
1548
|
+
const amount = count && /^\d+$/.test(count) ? count : "10";
|
|
1549
|
+
const rangeLabel = `${command === "head" ? "first" : "last"} ${amount} lines`;
|
|
1550
|
+
return { kind: "read", label: "Read", path: pathCandidates[0], rangeLabel, suppressCollapsedHint: true };
|
|
1551
|
+
}
|
|
1552
|
+
|
|
1553
|
+
export function classifyBashCommandForDisplay(command: string): BashDisplayInfo | null {
|
|
1554
|
+
const tokens = tokenizeShellCommand(command.trim());
|
|
1555
|
+
if (!tokens || tokens.length === 0) return null;
|
|
1556
|
+
if (tokens.some(isControlToken)) return null;
|
|
1557
|
+
return classifyNlRead(tokens)
|
|
1558
|
+
?? classifySedRead(tokens)
|
|
1559
|
+
?? classifyCatRead(tokens)
|
|
1560
|
+
?? classifyHeadTailRead(tokens);
|
|
1561
|
+
}
|
|
1562
|
+
|
|
1327
1563
|
function diffCollapsedLimit(): number {
|
|
1328
1564
|
const value = readSettings().diffCollapsedLines;
|
|
1329
1565
|
return typeof value === "number" && Number.isFinite(value) && value > 0 ? Math.floor(value) : 24;
|
|
@@ -4232,28 +4468,50 @@ export default function (pi: ExtensionAPI) {
|
|
|
4232
4468
|
},
|
|
4233
4469
|
renderCall(args, theme, ctx) {
|
|
4234
4470
|
syncToolCallStatus(ctx);
|
|
4235
|
-
const
|
|
4236
|
-
|
|
4471
|
+
const semantic = bashSemanticDisplayEnabled() ? classifyBashCommandForDisplay(args.command ?? "") : null;
|
|
4472
|
+
const summary = stableCallSummary(ctx, "_callSummary", () => {
|
|
4473
|
+
if (!semantic) return summarizeText(args.command, 72);
|
|
4474
|
+
const path = sp(semantic.path);
|
|
4475
|
+
return semantic.rangeLabel ? `${path} ${theme.fg("muted", `(${semantic.rangeLabel})`)}` : path;
|
|
4476
|
+
});
|
|
4477
|
+
return makeText(ctx.lastComponent, toolHeader(semantic?.label ?? "Bash", summary, theme, toolStatusDot(ctx, theme)));
|
|
4237
4478
|
},
|
|
4238
4479
|
renderResult(result, { expanded, isPartial }, theme, ctx) {
|
|
4239
4480
|
const details = result.details as BashToolDetails | undefined;
|
|
4240
4481
|
const output = result.content[0]?.type === "text" ? result.content[0].text : "";
|
|
4241
4482
|
const nonEmpty = output.split("\n").filter((line) => line.trim().length > 0);
|
|
4483
|
+
const semantic = bashSemanticDisplayEnabled() ? classifyBashCommandForDisplay(ctx.args?.command ?? "") : null;
|
|
4242
4484
|
if (isPartial) {
|
|
4243
4485
|
setupBlinkTimer(ctx);
|
|
4244
|
-
|
|
4486
|
+
const running = semantic?.kind === "read" ? "Reading" : "Running";
|
|
4487
|
+
return makeText(ctx.lastComponent, withBranch(theme.fg("warning", `${running}... (${nonEmpty.length} lines)`), theme));
|
|
4245
4488
|
}
|
|
4246
4489
|
clearBlinkTimer(ctx);
|
|
4247
4490
|
setToolStatus(ctx, ctx.isError ? "error" : "success");
|
|
4248
|
-
const exitMatch = output.match(/exit code
|
|
4491
|
+
const exitMatch = output.match(/(?:exit code:|exited with code)\s+(\d+)/i);
|
|
4249
4492
|
const exitCode = exitMatch ? Number.parseInt(exitMatch[1], 10) : null;
|
|
4250
|
-
|
|
4251
|
-
text
|
|
4493
|
+
const isError = ctx.isError || (exitCode !== null && exitCode !== 0);
|
|
4494
|
+
let text = isError
|
|
4495
|
+
? theme.fg("error", exitCode !== null ? `Exit ${exitCode}` : "Failed")
|
|
4496
|
+
: semantic?.kind === "read"
|
|
4497
|
+
? `${theme.fg("success", "Read")} ${theme.fg("muted", `${nonEmpty.length} line${nonEmpty.length === 1 ? "" : "s"}`)}`
|
|
4498
|
+
: `${theme.fg("success", "Done")}${theme.fg("muted", ` (${nonEmpty.length} lines)`)}`;
|
|
4252
4499
|
if (details?.truncation?.truncated) text += theme.fg("warning", " [truncated]");
|
|
4253
|
-
|
|
4500
|
+
|
|
4501
|
+
const mode = bashOutputMode();
|
|
4502
|
+
if (mode === "summary") return makeText(ctx.lastComponent, withBranch(text, theme));
|
|
4503
|
+
if (mode === "preview") {
|
|
4504
|
+
if (nonEmpty.length === 0) return makeText(ctx.lastComponent, withBranch(text, theme));
|
|
4505
|
+
const preview = buildPreviewText(nonEmpty.map((line) => theme.fg(isError ? "error" : "dim", line)), expanded, theme, bashCollapsedLimit());
|
|
4506
|
+
return makeText(ctx.lastComponent, withBranch(`${text}\n${preview}`, theme));
|
|
4507
|
+
}
|
|
4508
|
+
|
|
4509
|
+
if (!expanded && nonEmpty.length > 0) {
|
|
4510
|
+
const hint = semantic?.suppressCollapsedHint ? "" : theme.fg("muted", " • Ctrl+O to expand");
|
|
4511
|
+
return makeText(ctx.lastComponent, withBranch(`${text}${hint}`, theme));
|
|
4512
|
+
}
|
|
4254
4513
|
if (!expanded) return makeText(ctx.lastComponent, withBranch(text, theme));
|
|
4255
|
-
|
|
4256
|
-
text += `\n${buildPreviewText(nonEmpty.map((line) => theme.fg("dim", line)), false, theme, collapsed)}`;
|
|
4514
|
+
text += `\n${buildPreviewText(nonEmpty.map((line) => theme.fg(isError ? "error" : "dim", line)), true, theme, bashCollapsedLimit())}`;
|
|
4257
4515
|
return makeText(ctx.lastComponent, withBranch(text, theme));
|
|
4258
4516
|
},
|
|
4259
4517
|
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@owlburtoe/pi-cc-tools",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.1.1",
|
|
4
4
|
"description": "Claude Code-style tool rows for pi with Ctrl+O image previews and consistent built-in, MCP, and custom tool rendering",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"pi-package",
|
|
@@ -32,6 +32,7 @@
|
|
|
32
32
|
],
|
|
33
33
|
"scripts": {
|
|
34
34
|
"bench:tools": "bun scripts/benchmark-tools.ts",
|
|
35
|
+
"test:bash-display": "bun scripts/test-bash-display.ts",
|
|
35
36
|
"test:message-chrome": "bun scripts/test-message-chrome.ts"
|
|
36
37
|
},
|
|
37
38
|
"dependencies": {
|