@oh-my-pi/pi-coding-agent 3.20.0 → 3.21.0

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 (95) hide show
  1. package/CHANGELOG.md +78 -8
  2. package/docs/custom-tools.md +3 -3
  3. package/docs/extensions.md +226 -220
  4. package/docs/hooks.md +2 -2
  5. package/docs/sdk.md +3 -3
  6. package/examples/custom-tools/README.md +2 -2
  7. package/examples/custom-tools/subagent/index.ts +1 -1
  8. package/examples/extensions/README.md +76 -74
  9. package/examples/extensions/todo.ts +2 -5
  10. package/examples/hooks/custom-compaction.ts +1 -1
  11. package/examples/hooks/handoff.ts +1 -1
  12. package/examples/hooks/qna.ts +1 -1
  13. package/examples/sdk/02-custom-model.ts +1 -1
  14. package/examples/sdk/12-full-control.ts +1 -1
  15. package/examples/sdk/README.md +1 -1
  16. package/package.json +5 -5
  17. package/src/cli/file-processor.ts +1 -1
  18. package/src/cli/list-models.ts +1 -1
  19. package/src/core/agent-session.ts +13 -2
  20. package/src/core/auth-storage.ts +1 -1
  21. package/src/core/compaction/branch-summarization.ts +2 -2
  22. package/src/core/compaction/compaction.ts +2 -2
  23. package/src/core/compaction/utils.ts +1 -1
  24. package/src/core/custom-tools/types.ts +1 -1
  25. package/src/core/extensions/runner.ts +1 -1
  26. package/src/core/extensions/types.ts +1 -1
  27. package/src/core/extensions/wrapper.ts +1 -1
  28. package/src/core/hooks/runner.ts +2 -2
  29. package/src/core/hooks/types.ts +1 -1
  30. package/src/core/messages.ts +1 -1
  31. package/src/core/model-registry.ts +1 -1
  32. package/src/core/model-resolver.ts +1 -1
  33. package/src/core/sdk.ts +33 -4
  34. package/src/core/session-manager.ts +11 -22
  35. package/src/core/settings-manager.ts +66 -1
  36. package/src/core/slash-commands.ts +12 -5
  37. package/src/core/system-prompt.ts +27 -3
  38. package/src/core/title-generator.ts +2 -2
  39. package/src/core/tools/ask.ts +88 -1
  40. package/src/core/tools/bash-interceptor.ts +7 -0
  41. package/src/core/tools/bash.ts +106 -0
  42. package/src/core/tools/edit-diff.ts +73 -24
  43. package/src/core/tools/edit.ts +214 -20
  44. package/src/core/tools/find.ts +162 -1
  45. package/src/core/tools/gemini-image.ts +279 -56
  46. package/src/core/tools/git.ts +4 -0
  47. package/src/core/tools/grep.ts +191 -0
  48. package/src/core/tools/index.ts +3 -6
  49. package/src/core/tools/ls.ts +142 -2
  50. package/src/core/tools/lsp/render.ts +34 -14
  51. package/src/core/tools/notebook.ts +110 -0
  52. package/src/core/tools/output.ts +179 -7
  53. package/src/core/tools/read.ts +122 -9
  54. package/src/core/tools/render-utils.ts +241 -0
  55. package/src/core/tools/renderers.ts +40 -828
  56. package/src/core/tools/review.ts +26 -7
  57. package/src/core/tools/rulebook.ts +3 -1
  58. package/src/core/tools/task/index.ts +18 -3
  59. package/src/core/tools/task/render.ts +7 -2
  60. package/src/core/tools/task/types.ts +1 -1
  61. package/src/core/tools/truncate.ts +27 -1
  62. package/src/core/tools/web-fetch.ts +23 -15
  63. package/src/core/tools/web-search/index.ts +130 -45
  64. package/src/core/tools/web-search/providers/anthropic.ts +7 -2
  65. package/src/core/tools/web-search/providers/exa.ts +2 -1
  66. package/src/core/tools/web-search/providers/perplexity.ts +6 -1
  67. package/src/core/tools/web-search/render.ts +5 -0
  68. package/src/core/tools/web-search/types.ts +13 -0
  69. package/src/core/tools/write.ts +90 -0
  70. package/src/core/voice.ts +1 -1
  71. package/src/lib/worktree/constants.ts +6 -6
  72. package/src/main.ts +1 -1
  73. package/src/modes/interactive/components/assistant-message.ts +1 -1
  74. package/src/modes/interactive/components/custom-message.ts +1 -1
  75. package/src/modes/interactive/components/extensions/inspector-panel.ts +25 -22
  76. package/src/modes/interactive/components/extensions/state-manager.ts +12 -0
  77. package/src/modes/interactive/components/footer.ts +1 -1
  78. package/src/modes/interactive/components/hook-message.ts +1 -1
  79. package/src/modes/interactive/components/model-selector.ts +1 -1
  80. package/src/modes/interactive/components/oauth-selector.ts +1 -1
  81. package/src/modes/interactive/components/settings-defs.ts +49 -0
  82. package/src/modes/interactive/components/status-line.ts +1 -1
  83. package/src/modes/interactive/components/tool-execution.ts +93 -538
  84. package/src/modes/interactive/interactive-mode.ts +19 -7
  85. package/src/modes/print-mode.ts +1 -1
  86. package/src/modes/rpc/rpc-client.ts +1 -1
  87. package/src/modes/rpc/rpc-types.ts +1 -1
  88. package/src/prompts/system-prompt.md +4 -0
  89. package/src/prompts/tools/gemini-image.md +5 -1
  90. package/src/prompts/tools/output.md +4 -0
  91. package/src/prompts/tools/web-fetch.md +1 -0
  92. package/src/prompts/tools/web-search.md +2 -0
  93. package/src/utils/image-convert.ts +8 -2
  94. package/src/utils/image-magick.ts +247 -0
  95. package/src/utils/image-resize.ts +53 -13
@@ -1,17 +1,29 @@
1
1
  import { existsSync } from "node:fs";
2
2
  import path from "node:path";
3
+ import type { ImageContent, TextContent } from "@mariozechner/pi-ai";
3
4
  import type { AgentTool } from "@oh-my-pi/pi-agent-core";
4
- import type { ImageContent, TextContent } from "@oh-my-pi/pi-ai";
5
+ import type { Component } from "@oh-my-pi/pi-tui";
6
+ import { Text } from "@oh-my-pi/pi-tui";
5
7
  import { Type } from "@sinclair/typebox";
6
8
  import { globSync } from "glob";
9
+ import { getLanguageFromPath, highlightCode, type Theme } from "../../modes/interactive/theme/theme";
7
10
  import readDescription from "../../prompts/tools/read.md" with { type: "text" };
8
11
  import { formatDimensionNote, resizeImage } from "../../utils/image-resize";
9
12
  import { detectSupportedImageMimeTypeFromFile } from "../../utils/mime";
10
13
  import { ensureTool } from "../../utils/tools-manager";
14
+ import type { RenderResultOptions } from "../custom-tools/types";
11
15
  import { untilAborted } from "../utils";
12
16
  import { createLsTool } from "./ls";
13
17
  import { resolveReadPath, resolveToCwd } from "./path-utils";
14
- import { DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, formatSize, type TruncationResult, truncateHead } from "./truncate";
18
+ import { replaceTabs, shortenPath, wrapBrackets } from "./render-utils";
19
+ import {
20
+ DEFAULT_MAX_BYTES,
21
+ DEFAULT_MAX_LINES,
22
+ formatSize,
23
+ type TruncationResult,
24
+ truncateHead,
25
+ truncateStringToBytesFromStart,
26
+ } from "./truncate";
15
27
 
16
28
  // Document types convertible via markitdown
17
29
  const CONVERTIBLE_EXTENSIONS = new Set([".pdf", ".doc", ".docx", ".ppt", ".pptx", ".xls", ".xlsx", ".rtf", ".epub"]);
@@ -450,9 +462,9 @@ export function createReadTool(cwd: string, options?: ReadToolOptions): AgentToo
450
462
  let outputText = truncation.content;
451
463
 
452
464
  if (truncation.truncated) {
453
- outputText += `\n\n[Document converted via markitdown. Output truncated to $formatSize(
465
+ outputText += `\n\n[Document converted via markitdown. Output truncated to ${formatSize(
454
466
  DEFAULT_MAX_BYTES,
455
- )]`;
467
+ )}]`;
456
468
  details = { truncation };
457
469
  }
458
470
 
@@ -498,11 +510,21 @@ export function createReadTool(cwd: string, options?: ReadToolOptions): AgentToo
498
510
  let outputText: string;
499
511
 
500
512
  if (truncation.firstLineExceedsLimit) {
501
- // First line at offset exceeds 30KB - tell model to use bash
502
- const firstLineSize = formatSize(Buffer.byteLength(allLines[startLine], "utf-8"));
503
- outputText = `[Line ${startLineDisplay} is ${firstLineSize}, exceeds ${formatSize(
504
- DEFAULT_MAX_BYTES,
505
- )} limit. Use bash: sed -n '${startLineDisplay}p' ${readPath} | head -c ${DEFAULT_MAX_BYTES}]`;
513
+ const firstLine = allLines[startLine] ?? "";
514
+ const firstLineBytes = Buffer.byteLength(firstLine, "utf-8");
515
+ const snippet = truncateStringToBytesFromStart(firstLine, DEFAULT_MAX_BYTES);
516
+ const shownSize = formatSize(snippet.bytes);
517
+
518
+ outputText = snippet.text;
519
+ if (outputText.length > 0) {
520
+ outputText += `\n\n[Line ${startLineDisplay} is ${formatSize(
521
+ firstLineBytes,
522
+ )}, exceeds ${formatSize(DEFAULT_MAX_BYTES)} limit. Showing first ${shownSize} of the line.]`;
523
+ } else {
524
+ outputText = `[Line ${startLineDisplay} is ${formatSize(
525
+ firstLineBytes,
526
+ )}, exceeds ${formatSize(DEFAULT_MAX_BYTES)} limit. Unable to display a valid UTF-8 snippet.]`;
527
+ }
506
528
  details = { truncation };
507
529
  } else if (truncation.truncated) {
508
530
  // Truncation occurred - build actionable notice
@@ -542,3 +564,94 @@ export function createReadTool(cwd: string, options?: ReadToolOptions): AgentToo
542
564
 
543
565
  /** Default read tool using process.cwd() - for backwards compatibility */
544
566
  export const readTool = createReadTool(process.cwd());
567
+
568
+ // =============================================================================
569
+ // TUI Renderer
570
+ // =============================================================================
571
+
572
+ interface ReadRenderArgs {
573
+ path?: string;
574
+ file_path?: string;
575
+ offset?: number;
576
+ limit?: number;
577
+ }
578
+
579
+ const IMAGE_EXTENSIONS = new Set(["png", "jpg", "jpeg", "gif", "webp", "svg", "ico", "bmp", "tiff"]);
580
+ const BINARY_EXTENSIONS = new Set(["pdf", "zip", "tar", "gz", "exe", "dll", "so", "dylib", "wasm"]);
581
+
582
+ function getFileType(filePath: string): "image" | "binary" | "text" {
583
+ const ext = filePath.split(".").pop()?.toLowerCase();
584
+ if (!ext) return "text";
585
+ if (IMAGE_EXTENSIONS.has(ext)) return "image";
586
+ if (BINARY_EXTENSIONS.has(ext)) return "binary";
587
+ return "text";
588
+ }
589
+
590
+ export const readToolRenderer = {
591
+ renderCall(args: ReadRenderArgs, uiTheme: Theme): Component {
592
+ const rawPath = args.file_path || args.path || "";
593
+ const filePath = shortenPath(rawPath);
594
+ const offset = args.offset;
595
+ const limit = args.limit;
596
+
597
+ let pathDisplay = filePath ? uiTheme.fg("accent", filePath) : uiTheme.fg("toolOutput", uiTheme.format.ellipsis);
598
+ if (offset !== undefined || limit !== undefined) {
599
+ const startLine = offset ?? 1;
600
+ const endLine = limit !== undefined ? startLine + limit - 1 : "";
601
+ pathDisplay += uiTheme.fg("warning", `:${startLine}${endLine ? `-${endLine}` : ""}`);
602
+ }
603
+
604
+ const text = `${uiTheme.fg("toolTitle", uiTheme.bold("Read"))} ${pathDisplay}`;
605
+ return new Text(text, 0, 0);
606
+ },
607
+
608
+ renderResult(
609
+ result: { content: Array<{ type: string; text?: string }>; details?: ReadToolDetails },
610
+ { expanded }: RenderResultOptions,
611
+ uiTheme: Theme,
612
+ args?: ReadRenderArgs,
613
+ ): Component {
614
+ const rawPath = args?.file_path || args?.path || "";
615
+ const fileType = getFileType(rawPath);
616
+ const details = result.details;
617
+ const lines: string[] = [];
618
+
619
+ const output = result.content?.find((c) => c.type === "text")?.text ?? "";
620
+
621
+ if (fileType === "image") {
622
+ lines.push(uiTheme.fg("muted", "Image rendered below"));
623
+ } else if (fileType === "binary") {
624
+ // Binary files just show the header from renderCall
625
+ } else {
626
+ // Text file
627
+ const lang = getLanguageFromPath(rawPath);
628
+ const contentLines = lang ? highlightCode(replaceTabs(output), lang) : output.split("\n");
629
+
630
+ if (expanded) {
631
+ lines.push(
632
+ ...contentLines.map((line: string) =>
633
+ lang ? replaceTabs(line) : uiTheme.fg("toolOutput", replaceTabs(line)),
634
+ ),
635
+ );
636
+ } else {
637
+ lines.push(uiTheme.fg("dim", `${uiTheme.nav.expand} Ctrl+O to show content`));
638
+ }
639
+
640
+ // Truncation warning
641
+ const truncation = details?.truncation;
642
+ if (truncation?.truncated) {
643
+ let warning: string;
644
+ if (truncation.firstLineExceedsLimit) {
645
+ warning = `First line exceeds ${formatSize(truncation.maxBytes ?? DEFAULT_MAX_BYTES)} limit`;
646
+ } else if (truncation.truncatedBy === "lines") {
647
+ warning = `Truncated: ${truncation.outputLines} of ${truncation.totalLines} lines (${truncation.maxLines ?? DEFAULT_MAX_LINES} line limit)`;
648
+ } else {
649
+ warning = `Truncated: ${truncation.outputLines} lines (${formatSize(truncation.maxBytes ?? DEFAULT_MAX_BYTES)} limit)`;
650
+ }
651
+ lines.push(uiTheme.fg("warning", wrapBrackets(warning, uiTheme)));
652
+ }
653
+ }
654
+
655
+ return new Text(lines.join("\n"), 0, 0);
656
+ },
657
+ };
@@ -204,6 +204,247 @@ export function formatMoreItems(remaining: number, itemType: string, theme: Them
204
204
  return `${theme.format.ellipsis} ${safeRemaining} more ${pluralize(itemType, safeRemaining)}`;
205
205
  }
206
206
 
207
+ export function formatMeta(meta: string[], theme: Theme): string {
208
+ return meta.length > 0 ? ` ${theme.fg("muted", meta.join(theme.sep.dot))}` : "";
209
+ }
210
+
211
+ export function formatScope(scopePath: string | undefined, theme: Theme): string {
212
+ return scopePath ? ` ${theme.fg("muted", `in ${scopePath}`)}` : "";
213
+ }
214
+
215
+ export function formatTruncationSuffix(truncated: boolean, theme: Theme): string {
216
+ return truncated ? theme.fg("warning", " (truncated)") : "";
217
+ }
218
+
219
+ export function formatErrorMessage(message: string | undefined, theme: Theme): string {
220
+ const clean = (message ?? "").replace(/^Error:\s*/, "").trim();
221
+ return `${theme.styledSymbol("status.error", "error")} ${theme.fg("error", `Error: ${clean || "Unknown error"}`)}`;
222
+ }
223
+
224
+ export function formatEmptyMessage(message: string, theme: Theme): string {
225
+ return `${theme.styledSymbol("status.warning", "warning")} ${theme.fg("muted", message)}`;
226
+ }
227
+
228
+ // =============================================================================
229
+ // Diagnostic Formatting
230
+ // =============================================================================
231
+
232
+ interface ParsedDiagnostic {
233
+ filePath: string;
234
+ line: number;
235
+ col: number;
236
+ severity: "error" | "warning" | "info" | "hint";
237
+ source?: string;
238
+ message: string;
239
+ code?: string;
240
+ }
241
+
242
+ function parseDiagnosticMessage(msg: string): ParsedDiagnostic | null {
243
+ const match = msg.match(/^(.+?):(\d+):(\d+)\s+\[(\w+)\]\s+(?:\[([^\]]+)\]\s+)?(.+?)(?:\s+\(([^)]+)\))?$/);
244
+ if (!match) return null;
245
+ return {
246
+ filePath: match[1],
247
+ line: parseInt(match[2], 10),
248
+ col: parseInt(match[3], 10),
249
+ severity: match[4] as ParsedDiagnostic["severity"],
250
+ source: match[5],
251
+ message: match[6],
252
+ code: match[7],
253
+ };
254
+ }
255
+
256
+ export function formatDiagnostics(
257
+ diag: { errored: boolean; summary: string; messages: string[] },
258
+ expanded: boolean,
259
+ theme: Theme,
260
+ getLangIcon: (filePath: string) => string,
261
+ ): string {
262
+ if (diag.messages.length === 0) return "";
263
+
264
+ const byFile = new Map<string, ParsedDiagnostic[]>();
265
+ const unparsed: string[] = [];
266
+
267
+ for (const msg of diag.messages) {
268
+ const parsed = parseDiagnosticMessage(msg);
269
+ if (parsed) {
270
+ const existing = byFile.get(parsed.filePath) ?? [];
271
+ existing.push(parsed);
272
+ byFile.set(parsed.filePath, existing);
273
+ } else {
274
+ unparsed.push(msg);
275
+ }
276
+ }
277
+
278
+ const headerIcon = diag.errored
279
+ ? theme.styledSymbol("status.error", "error")
280
+ : theme.styledSymbol("status.warning", "warning");
281
+ let output = `\n\n${headerIcon} ${theme.fg("toolTitle", "Diagnostics")} ${theme.fg("dim", `(${diag.summary})`)}`;
282
+
283
+ const maxDiags = expanded ? diag.messages.length : 5;
284
+ let shown = 0;
285
+
286
+ const files = Array.from(byFile.entries());
287
+ for (let fi = 0; fi < files.length && shown < maxDiags; fi++) {
288
+ const [filePath, diagnostics] = files[fi];
289
+ const isLastFile = fi === files.length - 1 && unparsed.length === 0;
290
+ const fileBranch = isLastFile ? theme.tree.last : theme.tree.branch;
291
+
292
+ const fileIcon = theme.fg("muted", getLangIcon(filePath));
293
+ output += `\n ${theme.fg("dim", fileBranch)} ${fileIcon} ${theme.fg("accent", filePath)}`;
294
+ shown++;
295
+
296
+ for (let di = 0; di < diagnostics.length && shown < maxDiags; di++) {
297
+ const d = diagnostics[di];
298
+ const isLastDiag = di === diagnostics.length - 1;
299
+ const diagBranch = isLastFile
300
+ ? isLastDiag
301
+ ? ` ${theme.tree.last}`
302
+ : ` ${theme.tree.branch}`
303
+ : isLastDiag
304
+ ? ` ${theme.tree.vertical} ${theme.tree.last}`
305
+ : ` ${theme.tree.vertical} ${theme.tree.branch}`;
306
+
307
+ const sevIcon =
308
+ d.severity === "error"
309
+ ? theme.styledSymbol("status.error", "error")
310
+ : d.severity === "warning"
311
+ ? theme.styledSymbol("status.warning", "warning")
312
+ : theme.styledSymbol("status.info", "muted");
313
+ const location = theme.fg("dim", `:${d.line}:${d.col}`);
314
+ const codeTag = d.code ? theme.fg("dim", ` (${d.code})`) : "";
315
+ const msgColor = d.severity === "error" ? "error" : d.severity === "warning" ? "warning" : "toolOutput";
316
+
317
+ output += `\n ${theme.fg("dim", diagBranch)} ${sevIcon}${location} ${theme.fg(msgColor, d.message)}${codeTag}`;
318
+ shown++;
319
+ }
320
+ }
321
+
322
+ for (const msg of unparsed) {
323
+ if (shown >= maxDiags) break;
324
+ const color = msg.includes("[error]") ? "error" : msg.includes("[warning]") ? "warning" : "dim";
325
+ output += `\n ${theme.fg("dim", theme.tree.branch)} ${theme.fg(color, msg)}`;
326
+ shown++;
327
+ }
328
+
329
+ if (diag.messages.length > shown) {
330
+ const remaining = diag.messages.length - shown;
331
+ output += `\n ${theme.fg("dim", theme.tree.last)} ${theme.fg("muted", `${theme.format.ellipsis} ${remaining} more`)} ${theme.fg("dim", "(Ctrl+O to expand)")}`;
332
+ }
333
+
334
+ return output;
335
+ }
336
+
337
+ // =============================================================================
338
+ // Diff Utilities
339
+ // =============================================================================
340
+
341
+ export interface DiffStats {
342
+ added: number;
343
+ removed: number;
344
+ hunks: number;
345
+ lines: number;
346
+ }
347
+
348
+ export function getDiffStats(diffText: string): DiffStats {
349
+ const lines = diffText ? diffText.split("\n") : [];
350
+ let added = 0;
351
+ let removed = 0;
352
+ let hunks = 0;
353
+ let inHunk = false;
354
+
355
+ for (const line of lines) {
356
+ const isAdded = line.startsWith("+");
357
+ const isRemoved = line.startsWith("-");
358
+ const isChange = isAdded || isRemoved;
359
+
360
+ if (isAdded) added++;
361
+ if (isRemoved) removed++;
362
+
363
+ if (isChange && !inHunk) {
364
+ hunks++;
365
+ inHunk = true;
366
+ } else if (!isChange) {
367
+ inHunk = false;
368
+ }
369
+ }
370
+
371
+ return { added, removed, hunks, lines: lines.length };
372
+ }
373
+
374
+ export function formatDiffStats(added: number, removed: number, hunks: number, theme: Theme): string {
375
+ const parts: string[] = [];
376
+ if (added > 0) parts.push(theme.fg("success", `+${added}`));
377
+ if (removed > 0) parts.push(theme.fg("error", `-${removed}`));
378
+ if (hunks > 0) parts.push(theme.fg("dim", `${hunks} hunk${hunks !== 1 ? "s" : ""}`));
379
+ return parts.join(theme.fg("dim", " / "));
380
+ }
381
+
382
+ export function truncateDiffByHunk(
383
+ diffText: string,
384
+ maxHunks: number,
385
+ maxLines: number,
386
+ ): { text: string; hiddenHunks: number; hiddenLines: number } {
387
+ const lines = diffText ? diffText.split("\n") : [];
388
+ const totalStats = getDiffStats(diffText);
389
+ const kept: string[] = [];
390
+ let inHunk = false;
391
+ let currentHunks = 0;
392
+ let reachedLimit = false;
393
+
394
+ for (const line of lines) {
395
+ const isChange = line.startsWith("+") || line.startsWith("-");
396
+ if (isChange && !inHunk) {
397
+ currentHunks++;
398
+ inHunk = true;
399
+ }
400
+ if (!isChange) {
401
+ inHunk = false;
402
+ }
403
+
404
+ if (currentHunks > maxHunks) {
405
+ reachedLimit = true;
406
+ break;
407
+ }
408
+
409
+ kept.push(line);
410
+ if (kept.length >= maxLines) {
411
+ reachedLimit = true;
412
+ break;
413
+ }
414
+ }
415
+
416
+ if (!reachedLimit) {
417
+ return { text: diffText, hiddenHunks: 0, hiddenLines: 0 };
418
+ }
419
+
420
+ const keptStats = getDiffStats(kept.join("\n"));
421
+ return {
422
+ text: kept.join("\n"),
423
+ hiddenHunks: Math.max(0, totalStats.hunks - keptStats.hunks),
424
+ hiddenLines: Math.max(0, totalStats.lines - kept.length),
425
+ };
426
+ }
427
+
428
+ // =============================================================================
429
+ // Path Utilities
430
+ // =============================================================================
431
+
432
+ export function shortenPath(filePath: string, homeDir?: string): string {
433
+ const home = homeDir ?? process.env.HOME ?? process.env.USERPROFILE;
434
+ if (home && filePath.startsWith(home)) {
435
+ return `~${filePath.slice(home.length)}`;
436
+ }
437
+ return filePath;
438
+ }
439
+
440
+ export function wrapBrackets(text: string, theme: Theme): string {
441
+ return `${theme.format.bracketLeft}${text}${theme.format.bracketRight}`;
442
+ }
443
+
444
+ export function replaceTabs(text: string): string {
445
+ return text.replace(/\t/g, " ");
446
+ }
447
+
207
448
  function pluralize(label: string, count: number): string {
208
449
  if (count === 1) return label;
209
450
  if (/(?:ch|sh|s|x|z)$/i.test(label)) return `${label}es`;