@iinm/plain-agent 1.10.2 → 1.10.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.
@@ -1,19 +1,19 @@
1
1
  /**
2
- * @import { Message, MessageContentToolUse, MessageContentToolResult, ProviderTokenUsage } from "./model"
3
- * @import { CompactContextInput } from "./tools/compactContext"
4
- * @import { ExecCommandInput } from "./tools/execCommand"
5
- * @import { PatchBlock, PatchFileInput } from "./tools/patchFile"
6
- * @import { ReadFileInput } from "./tools/readFile"
7
- * @import { WriteFileInput } from "./tools/writeFile"
8
- * @import { TmuxCommandInput } from "./tools/tmuxCommand"
9
- * @import { SwitchToSubagentInput } from "./tools/switchToSubagent"
2
+ * @import { Message, MessageContentToolUse, MessageContentToolResult, ProviderTokenUsage } from "../model"
3
+ * @import { CompactContextInput } from "../tools/compactContext"
4
+ * @import { ExecCommandInput } from "../tools/execCommand"
5
+ * @import { PatchBlock, PatchFileInput } from "../tools/patchFile"
6
+ * @import { ReadFileInput } from "../tools/readFile"
7
+ * @import { WriteFileInput } from "../tools/writeFile"
8
+ * @import { TmuxCommandInput } from "../tools/tmuxCommand"
9
+ * @import { SwitchToSubagentInput } from "../tools/switchToSubagent"
10
10
  */
11
11
 
12
12
  import fs from "node:fs/promises";
13
13
  import { styleText } from "node:util";
14
- import { parseBlocks } from "./tools/patchFile.mjs";
15
- import { diffLines } from "./utils/diffLines.mjs";
16
- import { noThrow } from "./utils/noThrow.mjs";
14
+ import { parseBlocks } from "../tools/patchFile.mjs";
15
+ import { diffLines } from "../utils/diffLines.mjs";
16
+ import { noThrow } from "../utils/noThrow.mjs";
17
17
 
18
18
  /** Length above which a single-line arg forces block-form rendering. */
19
19
  const ARG_BLOCK_LENGTH_THRESHOLD = 60;
@@ -144,7 +144,7 @@ export async function formatToolUse(toolUse) {
144
144
  }
145
145
 
146
146
  if (toolName === "switch_to_main_agent") {
147
- /** @type {Partial<import("./tools/switchToMainAgent").SwitchToMainAgentInput>} */
147
+ /** @type {Partial<import("../tools/switchToMainAgent").SwitchToMainAgentInput>} */
148
148
  const switchToMainAgentInput = input;
149
149
  return [
150
150
  `tool: ${toolName}`,
@@ -153,7 +153,7 @@ export async function formatToolUse(toolUse) {
153
153
  }
154
154
 
155
155
  if (toolName === "web_search") {
156
- /** @type {Partial<import("./tools/webSearch.mjs").WebSearchInput>} */
156
+ /** @type {Partial<import("../tools/webSearch.mjs").WebSearchInput>} */
157
157
  const webSearchInput = input;
158
158
  const searchesLine = webSearchInput.searches
159
159
  ? webSearchInput.searches.map((s) => s.keywords.join(" ")).join(" | ")
@@ -166,7 +166,7 @@ export async function formatToolUse(toolUse) {
166
166
  }
167
167
 
168
168
  if (toolName === "web_fetch") {
169
- /** @type {Partial<import("./tools/webFetch.mjs").WebFetchInput>} */
169
+ /** @type {Partial<import("../tools/webFetch.mjs").WebFetchInput>} */
170
170
  const webFetchInput = input;
171
171
  return [
172
172
  `tool: ${toolName}`,
@@ -296,7 +296,7 @@ export function formatProviderTokenUsage(usage) {
296
296
 
297
297
  /**
298
298
  * Format cost summary for interactive display
299
- * @param {import("./costTracker.mjs").CostSummary} summary
299
+ * @param {import("../costTracker.mjs").CostSummary} summary
300
300
  * @returns {string}
301
301
  */
302
302
  export function formatCostSummary(summary) {
@@ -334,7 +334,7 @@ export function formatCostSummary(summary) {
334
334
 
335
335
  /**
336
336
  * Format cost for batch mode JSON output
337
- * @param {import("./costTracker.mjs").CostSummary} summary
337
+ * @param {import("../costTracker.mjs").CostSummary} summary
338
338
  */
339
339
  export function formatCostForBatch(summary) {
340
340
  if (!summary || Object.keys(summary.breakdown).length === 0) {
@@ -428,6 +428,348 @@ export async function printMessage(message) {
428
428
  }
429
429
  }
430
430
  }
431
+ /**
432
+ * Format markdown table lines with aligned columns.
433
+ * Input lines may have leading/trailing pipes.
434
+ * Output always has leading and trailing pipes with padded cells.
435
+ * When the table would exceed `maxWidth`, long cells are wrapped onto
436
+ * additional visual lines so the table stays within the terminal width.
437
+ * @param {string[]} lines - Raw table lines (including alignment row)
438
+ * @param {number} [maxWidth=Infinity] - Maximum terminal display width
439
+ * @returns {string} - Formatted table string with aligned columns
440
+ */
441
+ export function formatMarkdownTable(
442
+ lines,
443
+ maxWidth = Number.POSITIVE_INFINITY,
444
+ ) {
445
+ if (lines.length === 0) return "";
446
+
447
+ const rows = lines.map(splitTableRow);
448
+
449
+ // Calculate max display width for each column (natural width)
450
+ const colCount = Math.max(...rows.map((r) => r.length));
451
+ /** @type {number[]} */
452
+ const naturalWidths = new Array(colCount).fill(0);
453
+ for (const row of rows) {
454
+ for (let i = 0; i < row.length; i++) {
455
+ const width = charDisplayWidth(row[i]);
456
+ if (width > naturalWidths[i]) {
457
+ naturalWidths[i] = width;
458
+ }
459
+ }
460
+ }
461
+
462
+ // Determine column widths that fit within maxWidth
463
+ const colWidths = fitColumns(naturalWidths, colCount, maxWidth);
464
+
465
+ // Check if wrapping is needed (any column was shrunk)
466
+ const needsWrapping = colWidths.some((w, i) => w < naturalWidths[i]);
467
+
468
+ if (!needsWrapping) {
469
+ // Original path: no wrapping, just pad and join
470
+ return rows
471
+ .map((row) => {
472
+ const fullRow = row.concat(new Array(colCount - row.length).fill(""));
473
+ const padded = fullRow.map((cell, i) =>
474
+ padCell(cell, colWidths[i] ?? 0),
475
+ );
476
+ return `| ${padded.join(" | ")} |`;
477
+ })
478
+ .join("\n");
479
+ }
480
+
481
+ // Wrapped path: wrap cells and render multi-line rows
482
+ const wrappedRows = rows.map((row) => {
483
+ const fullRow = row.concat(new Array(colCount - row.length).fill(""));
484
+ const isSeparator = isSeparatorRow(fullRow);
485
+ return fullRow.map((cell, i) => {
486
+ if (isSeparator) {
487
+ // Regenerate separator dashes to fit the column width (no wrapping)
488
+ return ["-".repeat(colWidths[i])];
489
+ }
490
+ return wrapCell(cell, colWidths[i]);
491
+ });
492
+ });
493
+
494
+ return wrappedRows
495
+ .map((wrappedCells) => renderWrappedRow(wrappedCells, colWidths))
496
+ .join("\n");
497
+ }
498
+
499
+ /**
500
+ * Check if a row is a markdown table separator row.
501
+ * A separator row contains only dashes, colons, and spaces
502
+ * (e.g., "------", ":----:", "-----:", ":-----").
503
+ * @param {string[]} cells
504
+ * @returns {boolean}
505
+ */
506
+ function isSeparatorRow(cells) {
507
+ return cells.length > 0 && cells.every((cell) => /^[-: ]+$/.test(cell));
508
+ }
509
+
510
+ /**
511
+ * Determine column widths that fit within maxWidth.
512
+ * If the natural total width fits, returns natural widths unchanged.
513
+ * Otherwise, shrinks columns proportionally (minimum 3 chars each).
514
+ * Returns null in any entry if the table cannot fit at all (fallback signal).
515
+ * @param {number[]} naturalWidths - Natural (max content) width per column
516
+ * @param {number} colCount - Number of columns
517
+ * @param {number} maxWidth - Available terminal width
518
+ * @returns {number[]} - Target width per column
519
+ */
520
+ function fitColumns(naturalWidths, colCount, maxWidth) {
521
+ const gutter = 4 + (colCount - 1) * 3; // "| " + " |" + inter-column " | "
522
+ const available = maxWidth - gutter;
523
+
524
+ // If natural widths fit, use them as-is
525
+ const totalNatural = naturalWidths.reduce((s, w) => s + w, 0);
526
+ if (totalNatural <= available || maxWidth === Number.POSITIVE_INFINITY) {
527
+ return naturalWidths;
528
+ }
529
+
530
+ // Shrink: allocate minimum width first, then distribute remainder proportionally
531
+ const minWidth = 3;
532
+ const minTotal = minWidth * colCount;
533
+
534
+ if (minTotal > available) {
535
+ // Cannot fit even at minimum — return natural widths (will overflow)
536
+ return naturalWidths;
537
+ }
538
+
539
+ const result = naturalWidths.map(() => minWidth);
540
+ const remaining = available - minTotal;
541
+
542
+ // Distribute remaining space proportionally to natural widths
543
+ const naturalTotalAboveMin = naturalWidths.reduce(
544
+ (s, w) => s + Math.max(0, w - minWidth),
545
+ 0,
546
+ );
547
+
548
+ if (naturalTotalAboveMin > 0) {
549
+ for (let i = 0; i < colCount; i++) {
550
+ const aboveMin = Math.max(0, naturalWidths[i] - minWidth);
551
+ const share = Math.round((aboveMin / naturalTotalAboveMin) * remaining);
552
+ result[i] = minWidth + share;
553
+ }
554
+
555
+ // Adjust for rounding: distribute leftover pixels to widest columns
556
+ const currentTotal = result.reduce((s, w) => s + w, 0);
557
+ let diff = available - currentTotal;
558
+ // Sort column indices by natural width descending for fair distribution
559
+ const sortedIndices = naturalWidths
560
+ .map((w, i) => /** @type {[number, number]} */ ([i, w]))
561
+ .sort((a, b) => b[1] - a[1])
562
+ .map(([i]) => i);
563
+ let idx = 0;
564
+ while (diff > 0) {
565
+ result[sortedIndices[idx % colCount]]++;
566
+ diff--;
567
+ idx++;
568
+ }
569
+ while (diff < 0) {
570
+ result[sortedIndices[idx % colCount]]--;
571
+ diff++;
572
+ idx++;
573
+ }
574
+ }
575
+
576
+ return result;
577
+ }
578
+
579
+ /**
580
+ * Wrap a cell's content to fit within the given display width.
581
+ * Respects ANSI escape codes (does not break them) and CJK wide characters.
582
+ * @param {string} text - Cell content (may contain ANSI codes)
583
+ * @param {number} width - Maximum display width per line
584
+ * @returns {string[]} - Array of visual lines for this cell
585
+ */
586
+ function wrapCell(text, width) {
587
+ if (width <= 0) return [text];
588
+ const textWidth = charDisplayWidth(text);
589
+ if (textWidth <= width) return [text];
590
+
591
+ // Build segments: each segment is either an ANSI escape code or a visible character
592
+ /** @type {{ text: string, displayWidth: number }[]} */
593
+ const segments = [];
594
+ let i = 0;
595
+ while (i < text.length) {
596
+ // Check for ANSI escape sequence
597
+ // biome-ignore lint/suspicious/noControlCharactersInRegex: ANSI escape code pattern
598
+ const ansiMatch = text.slice(i).match(/^\u001b\[[0-9;]*m/);
599
+ if (ansiMatch) {
600
+ segments.push({ text: ansiMatch[0], displayWidth: 0 });
601
+ i += ansiMatch[0].length;
602
+ } else {
603
+ const ch = text[i];
604
+ const code = /** @type {number} */ (ch.codePointAt(0));
605
+ const isWide = isWideChar(code);
606
+ segments.push({ text: ch, displayWidth: isWide ? 2 : 1 });
607
+ i++;
608
+ }
609
+ }
610
+
611
+ // Group segments into lines
612
+ /** @type {string[]} */
613
+ const lines = [];
614
+ /** @type {string} */
615
+ let currentLine = "";
616
+ let currentWidth = 0;
617
+
618
+ for (const seg of segments) {
619
+ if (seg.displayWidth === 0) {
620
+ // ANSI code: attach to current line without increasing width
621
+ currentLine += seg.text;
622
+ continue;
623
+ }
624
+
625
+ if (currentWidth + seg.displayWidth > width) {
626
+ // This character would overflow — start a new line
627
+ lines.push(currentLine);
628
+ currentLine = seg.text;
629
+ currentWidth = seg.displayWidth;
630
+ } else {
631
+ currentLine += seg.text;
632
+ currentWidth += seg.displayWidth;
633
+ }
634
+ }
635
+
636
+ if (currentLine.length > 0 || lines.length === 0) {
637
+ lines.push(currentLine);
638
+ }
639
+
640
+ return lines;
641
+ }
642
+
643
+ /**
644
+ * Sorted, merged [start, end] ranges of Unicode code points with
645
+ * East_Asian_Width property "W" (Wide) or "F" (Fullwidth).
646
+ *
647
+ * Generated by: node scripts/fetchEastAsianWideRanges.mjs
648
+ * Source: https://www.unicode.org/Public/16.0.0/ucd/EastAsianWidth.txt
649
+ */
650
+ import WIDE_RANGES from "./eastAsianWideRanges.json" with { type: "json" };
651
+
652
+ /**
653
+ * Check if a Unicode code point is a wide (double-width) character.
654
+ * Uses binary search over the sorted WIDE_RANGES for efficiency.
655
+ * @param {number} code
656
+ * @returns {boolean}
657
+ */
658
+ function isWideChar(code) {
659
+ let lo = 0;
660
+ let hi = WIDE_RANGES.length - 1;
661
+ while (lo <= hi) {
662
+ const mid = (lo + hi) >>> 1;
663
+ const [start, end] = WIDE_RANGES[mid];
664
+ if (code < start) hi = mid - 1;
665
+ else if (code > end) lo = mid + 1;
666
+ else return true;
667
+ }
668
+ return false;
669
+ }
670
+
671
+ /**
672
+ * Render a wrapped row (where cells may span multiple visual lines).
673
+ * Each cell's visual lines are padded to the column width, and cells
674
+ * are aligned horizontally across visual lines.
675
+ * @param {string[][]} wrappedCells - Array of visual-line arrays per cell
676
+ * @param {number[]} colWidths - Target display width per column
677
+ * @returns {string} - Rendered row (may contain embedded newlines)
678
+ */
679
+ function renderWrappedRow(wrappedCells, colWidths) {
680
+ const maxLines = Math.max(...wrappedCells.map((c) => c.length));
681
+ const visualLines = [];
682
+ for (let lineIdx = 0; lineIdx < maxLines; lineIdx++) {
683
+ const parts = wrappedCells.map((cell, colIdx) => {
684
+ const text = cell[lineIdx] ?? "";
685
+ return padCell(text, colWidths[colIdx]);
686
+ });
687
+ visualLines.push(`| ${parts.join(" | ")} |`);
688
+ }
689
+ return visualLines.join("\n");
690
+ }
691
+
692
+ /** @type {RegExp} - ANSI escape code pattern */
693
+ // biome-ignore lint/suspicious/noControlCharactersInRegex: ANSI escape code pattern
694
+ const ANSI_RE = /\u001b\[[0-9;]*m/g;
695
+
696
+ /**
697
+ * Strip ANSI escape codes for display width calculation.
698
+ * @param {string} str
699
+ * @returns {string}
700
+ */
701
+ function stripAnsiCodes(str) {
702
+ return str.replace(ANSI_RE, "");
703
+ }
704
+
705
+ /**
706
+ * Calculate the terminal display width of a string.
707
+ * CJK full-width characters and emoji count as 2 columns; ASCII as 1.
708
+ * ANSI escape codes are stripped before measurement.
709
+ * @param {string} str
710
+ * @returns {number}
711
+ */
712
+ function charDisplayWidth(str) {
713
+ const plain = stripAnsiCodes(str);
714
+ let width = 0;
715
+ for (const ch of plain) {
716
+ const code = /** @type {number} */ (ch.codePointAt(0));
717
+ width += isWideChar(code) ? 2 : 1;
718
+ }
719
+ return width;
720
+ }
721
+
722
+ /**
723
+ * Split a markdown table row into cells.
724
+ * Removes leading/trailing pipes, splits by `|`.
725
+ * Respects escaped pipes (`\|`).
726
+ * @param {string} line
727
+ * @returns {string[]}
728
+ */
729
+ function splitTableRow(line) {
730
+ const trimmed = line.trim();
731
+ // Remove leading and trailing pipes
732
+ let inner;
733
+ if (trimmed.startsWith("|") && trimmed.endsWith("|")) {
734
+ inner = trimmed.slice(1, -1);
735
+ } else if (trimmed.startsWith("|")) {
736
+ inner = trimmed.slice(1);
737
+ } else if (trimmed.endsWith("|")) {
738
+ inner = trimmed.slice(0, -1);
739
+ } else {
740
+ inner = trimmed;
741
+ }
742
+
743
+ // Split by pipe, respecting escaped pipes
744
+ /** @type {string[]} */
745
+ const cells = [];
746
+ let current = "";
747
+ for (let i = 0; i < inner.length; i++) {
748
+ if (inner[i] === "\\" && i + 1 < inner.length && inner[i + 1] === "|") {
749
+ current += "|";
750
+ i++;
751
+ } else if (inner[i] === "|") {
752
+ cells.push(current);
753
+ current = "";
754
+ } else {
755
+ current += inner[i];
756
+ }
757
+ }
758
+ cells.push(current);
759
+ return cells.map((c) => c.trim());
760
+ }
761
+
762
+ /**
763
+ * Pad a cell string with trailing spaces to the given display width.
764
+ * @param {string} cell - Original cell content (may contain ANSI codes)
765
+ * @param {number} targetWidth - Target display width
766
+ * @returns {string}
767
+ */
768
+ function padCell(cell, targetWidth) {
769
+ const currentWidth = charDisplayWidth(cell);
770
+ if (currentWidth >= targetWidth) return cell;
771
+ return cell + " ".repeat(targetWidth - currentWidth);
772
+ }
431
773
 
432
774
  /**
433
775
  * Render a patch_file `patch` string for terminal display.
@@ -455,7 +797,11 @@ async function renderPatch(filePath, patch) {
455
797
  let blocks;
456
798
  try {
457
799
  blocks = parseBlocks(patch, nonce);
458
- } catch {
800
+ } catch (err) {
801
+ const message = err instanceof Error ? err.message : String(err);
802
+ console.error(
803
+ styleText("yellow", `Warning: Patch parsing failed: ${message}`),
804
+ );
459
805
  return fallback;
460
806
  }
461
807
 
@@ -485,7 +831,7 @@ function renderPatchBlock(block, originalLines, nonce) {
485
831
  out.push(
486
832
  styleText(
487
833
  "cyan",
488
- `@@@ ${nonce} ${block.start}:${block.startHash}-${block.end}:${block.endHash}`,
834
+ `>>> ${nonce} ${block.start}:${block.startHash}-${block.end}:${block.endHash}`,
489
835
  ),
490
836
  );
491
837
  if (originalLines) {
@@ -513,12 +859,12 @@ function renderPatchBlock(block, originalLines, nonce) {
513
859
  }
514
860
  } else {
515
861
  const afterSuffix = block.afterHash ? `:${block.afterHash}` : "";
516
- out.push(styleText("cyan", `@@@ ${nonce} ${block.after}${afterSuffix}+`));
862
+ out.push(styleText("cyan", `>>> ${nonce} ${block.after}${afterSuffix}+`));
517
863
  for (const line of block.body) {
518
864
  out.push(styleText("green", `+ ${line}`));
519
865
  }
520
866
  }
521
- out.push(styleText("cyan", `@@@ ${nonce}`));
867
+ out.push(styleText("cyan", `<<< ${nonce}`));
522
868
  return out.join("\n");
523
869
  }
524
870
 
@@ -532,8 +878,8 @@ function highlightPatchPlain(patch) {
532
878
  if (!patch) {
533
879
  return "";
534
880
  }
535
- // Patch headers/closes look like "@@@ <nonce> ..." or "@@@ <nonce>".
536
- const headerRegex = /^@@@\s+\S+(\s.*)?$/;
881
+ // Patch open/close markers look like ">>> <nonce> ..." or "<<< <nonce>".
882
+ const headerRegex = /^(>>>|<<<)\s+\S+(\s.*)?$/;
537
883
  return patch
538
884
  .split("\n")
539
885
  .map((line) => {
@@ -554,7 +900,7 @@ function highlightPatchPlain(patch) {
554
900
  * @returns {string | null}
555
901
  */
556
902
  function extractPatchNonce(patch) {
557
- const match = patch.match(/^@@@\s+(\S+)/m);
903
+ const match = patch.match(/^>>>\s+(\S+)/m);
558
904
  return match ? match[1] : null;
559
905
  }
560
906
 
@@ -1,25 +1,28 @@
1
1
  /**
2
- * @import { UserEventEmitter, AgentEventEmitter, AgentCommands } from "./agent"
3
- * @import { ClaudeCodePlugin } from "./claudeCodePlugin.mjs"
4
- * @import { VoiceInputConfig, VoiceSession } from "./voiceInput.mjs"
2
+ * @import { UserEventEmitter, AgentEventEmitter, AgentCommands } from "../agent"
3
+ * @import { ClaudeCodePlugin } from "../claudeCodePlugin.mjs"
4
+ * @import { VoiceInputConfig } from "../voice/input.mjs"
5
+ * @import { VoiceSession } from "../voice/session.mjs"
5
6
  */
6
7
 
7
8
  import readline from "node:readline";
8
9
  import { styleText } from "node:util";
9
- import { createCommandHandler } from "./cliCommands.mjs";
10
- import { createCompleter, SLASH_COMMANDS } from "./cliCompleter.mjs";
10
+ import { appendUsageRecord, buildUsageRecord } from "../usageStore.mjs";
11
+ import { createSequentialExecutor } from "../utils/createSequentialExecutor.mjs";
12
+ import { notify } from "../utils/notify.mjs";
13
+ import { startVoiceSession } from "../voice/input.mjs";
14
+ import { parseVoiceToggleKey } from "../voice/toggleKey.mjs";
15
+ import { createCommandHandler } from "./commands.mjs";
16
+ import { createCompleter, SLASH_COMMANDS } from "./completer.mjs";
11
17
  import {
12
18
  formatCostSummary,
13
19
  formatProviderTokenUsage,
14
20
  printMessage,
15
- } from "./cliFormatter.mjs";
16
- import { createInterruptTransform } from "./cliInterruptTransform.mjs";
17
- import { createMuteTransform } from "./cliMuteTransform.mjs";
18
- import { createPasteHandler } from "./cliPasteTransform.mjs";
19
- import { appendUsageRecord, buildUsageRecord } from "./usageStore.mjs";
20
- import { createSequentialExecutor } from "./utils/createSequentialExecutor.mjs";
21
- import { notify } from "./utils/notify.mjs";
22
- import { parseVoiceToggleKey, startVoiceSession } from "./voiceInput.mjs";
21
+ } from "./formatter.mjs";
22
+ import { createInterruptTransform } from "./interruptTransform.mjs";
23
+ import { createMuteTransform } from "./muteTransform.mjs";
24
+ import { createPasteHandler } from "./pasteTransform.mjs";
25
+ import { createTableDetector } from "./tableDetector.mjs";
23
26
 
24
27
  const HELP_MESSAGE = [
25
28
  "Commands:",
@@ -70,7 +73,7 @@ const HELP_MESSAGE = [
70
73
  * Persist the session's cost summary to the usage log.
71
74
  * Failures are logged but never thrown so exit is not blocked.
72
75
  *
73
- * @param {import("./costTracker.mjs").CostSummary} summary
76
+ * @param {import("../costTracker.mjs").CostSummary} summary
74
77
  * @param {{ sessionId: string, modelName: string, startTime: Date }} meta
75
78
  */
76
79
  async function persistUsage(summary, { sessionId, modelName, startTime }) {
@@ -122,6 +125,9 @@ export function startInteractiveSession({
122
125
  */
123
126
  let voice = null;
124
127
 
128
+ // Create the table buffer instance for this session
129
+ const tableBuffer = createTableBuffer();
130
+
125
131
  // Parse the voice toggle key once at startup so misconfiguration fails
126
132
  // loudly instead of silently falling back.
127
133
  const voiceToggle = parseVoiceToggleKey(voiceInput?.toggleKey);
@@ -465,6 +471,8 @@ export function startInteractiveSession({
465
471
  if (partialContent.content) {
466
472
  if (partialContent.type === "tool_use") {
467
473
  process.stdout.write(styleText("gray", partialContent.content));
474
+ } else if (partialContent.type === "text") {
475
+ tableBuffer.feed(partialContent.content);
468
476
  } else {
469
477
  process.stdout.write(partialContent.content);
470
478
  }
@@ -520,6 +528,9 @@ export function startInteractiveSession({
520
528
  });
521
529
 
522
530
  agentEventEmitter.on("turnEnd", async () => {
531
+ // Flush any remaining table buffer content
532
+ tableBuffer.forceFlush();
533
+
523
534
  const err = notify(notifyCmd);
524
535
  if (err) {
525
536
  console.error(
@@ -543,3 +554,26 @@ export function startInteractiveSession({
543
554
  process.on("exit", cleanup);
544
555
  process.on("SIGTERM", cleanup);
545
556
  }
557
+
558
+ /**
559
+ * Creates a table buffer for detecting and formatting markdown tables
560
+ * in streaming text output.
561
+ * Thin shell: delegates pure logic to createTableDetector and handles I/O.
562
+ */
563
+ function createTableBuffer() {
564
+ const detector = createTableDetector();
565
+
566
+ function feed(/** @type {string} */ chunk) {
567
+ const { output, warnings } = detector.feed(chunk);
568
+ for (const s of output) process.stdout.write(s);
569
+ for (const w of warnings) console.error(styleText("yellow", w));
570
+ }
571
+
572
+ function forceFlush() {
573
+ const { output, warnings } = detector.forceFlush();
574
+ for (const s of output) process.stdout.write(s);
575
+ for (const w of warnings) console.error(styleText("yellow", w));
576
+ }
577
+
578
+ return { feed, forceFlush };
579
+ }