@runtypelabs/persona 3.37.0 → 4.1.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 (55) hide show
  1. package/README.md +4 -5
  2. package/dist/animations/glyph-cycle.d.cts +1 -1
  3. package/dist/animations/glyph-cycle.d.ts +1 -1
  4. package/dist/animations/{types-DgYsuwXL.d.cts → types-B_xbfvR0.d.cts} +263 -181
  5. package/dist/animations/{types-DgYsuwXL.d.ts → types-B_xbfvR0.d.ts} +263 -181
  6. package/dist/animations/wipe.d.cts +1 -1
  7. package/dist/animations/wipe.d.ts +1 -1
  8. package/dist/codegen.cjs +1 -1
  9. package/dist/codegen.js +1 -1
  10. package/dist/index.cjs +54 -52
  11. package/dist/index.cjs.map +1 -1
  12. package/dist/index.d.cts +328 -839
  13. package/dist/index.d.ts +328 -839
  14. package/dist/index.global.js +41 -39
  15. package/dist/index.global.js.map +1 -1
  16. package/dist/index.js +54 -52
  17. package/dist/index.js.map +1 -1
  18. package/dist/install.global.js +1 -1
  19. package/dist/install.global.js.map +1 -1
  20. package/dist/smart-dom-reader.d.cts +269 -192
  21. package/dist/smart-dom-reader.d.ts +269 -192
  22. package/dist/theme-editor-preview.cjs +55 -53
  23. package/dist/theme-editor-preview.d.cts +269 -192
  24. package/dist/theme-editor-preview.d.ts +269 -192
  25. package/dist/theme-editor-preview.js +57 -55
  26. package/dist/theme-editor.d.cts +269 -192
  27. package/dist/theme-editor.d.ts +269 -192
  28. package/dist/widget.css +14 -0
  29. package/package.json +1 -1
  30. package/src/client.test.ts +446 -1041
  31. package/src/client.ts +684 -967
  32. package/src/components/message-bubble.ts +7 -0
  33. package/src/components/tool-bubble.ts +46 -33
  34. package/src/generated/runtype-openapi-contract.ts +210 -714
  35. package/src/index-core.ts +2 -3
  36. package/src/install.test.ts +1 -34
  37. package/src/install.ts +1 -26
  38. package/src/runtime/init.test.ts +8 -67
  39. package/src/runtime/init.ts +2 -17
  40. package/src/session.test.ts +8 -8
  41. package/src/session.ts +1 -1
  42. package/src/styles/widget.css +14 -0
  43. package/src/types.ts +12 -13
  44. package/src/ui.postprocess.test.ts +107 -0
  45. package/src/ui.ts +45 -5
  46. package/src/utils/__fixtures__/unified-translator.oracle.ts +62 -14
  47. package/src/utils/copy-selection.test.ts +37 -0
  48. package/src/utils/copy-selection.ts +19 -0
  49. package/src/utils/event-stream-capture.test.ts +9 -64
  50. package/src/utils/streaming-table.test.ts +100 -0
  51. package/src/utils/streaming-table.ts +103 -0
  52. package/src/utils/sequence-buffer.test.ts +0 -256
  53. package/src/utils/sequence-buffer.ts +0 -130
  54. package/src/utils/unified-event-bridge.test.ts +0 -263
  55. package/src/utils/unified-event-bridge.ts +0 -431
@@ -36,7 +36,9 @@ class UnifiedEventTranslator {
36
36
  private kind: ExecutionKind = "flow";
37
37
  private blockCounter = 0;
38
38
  private openText: string | null = null;
39
+ private openTextBuffer = "";
39
40
  private openReasoning: string | null = null;
41
+ private openReasoningBuffer = "";
40
42
 
41
43
  constructor(
42
44
  private readonly sink: (chunk: string) => void,
@@ -56,40 +58,78 @@ class UnifiedEventTranslator {
56
58
  this.sink(`event: ${type}\ndata: ${JSON.stringify(frame)}\n\n`);
57
59
  }
58
60
 
59
- private emitTextDelta(delta: unknown): void {
61
+ /**
62
+ * Parent model tool-call id stamped by the `tool_nested` FilteredStream as
63
+ * `toolContext.toolId` (a flow running as a tool enriches its frames this way).
64
+ * Surfaced on the text/reasoning channel as `parentToolCallId` so consumers can
65
+ * route nested streamed output into the parent tool's row (PR #4602). Undefined
66
+ * for top-level output.
67
+ */
68
+ private parentToolCallId(data: Json): string | undefined {
69
+ const toolContext = data.toolContext;
70
+ if (toolContext && typeof toolContext === "object") {
71
+ const toolId = (toolContext as Json).toolId;
72
+ if (typeof toolId === "string" && toolId) return toolId;
73
+ }
74
+ return undefined;
75
+ }
76
+
77
+ private emitTextDelta(delta: unknown, parentToolCallId?: string): void {
60
78
  if (delta == null || delta === "") return;
61
79
  if (!this.openText) {
62
80
  this.openText = this.mint("text");
81
+ this.openTextBuffer = "";
63
82
  this.out("text_start", {
64
83
  id: this.openText,
65
84
  ...(this.kind === "agent" ? { role: "assistant" } : {}),
85
+ ...(parentToolCallId ? { parentToolCallId } : {}),
66
86
  });
67
87
  }
68
- this.out("text_delta", { id: this.openText, delta: String(delta) });
88
+ const text = String(delta);
89
+ this.openTextBuffer += text;
90
+ this.out("text_delta", { id: this.openText, delta: text });
69
91
  }
70
92
 
71
93
  private closeText(): void {
72
94
  if (!this.openText) return;
73
- this.out("text_complete", { id: this.openText });
95
+ // U2: carry the assembled text so a non-streaming consumer can read the
96
+ // finished message off `text_complete` instead of re-concatenating deltas.
97
+ this.out("text_complete", {
98
+ id: this.openText,
99
+ ...(this.openTextBuffer ? { text: this.openTextBuffer } : {}),
100
+ });
74
101
  this.openText = null;
102
+ this.openTextBuffer = "";
75
103
  }
76
104
 
77
- private ensureReasoningOpen(scope?: "turn" | "loop"): void {
105
+ private ensureReasoningOpen(scope?: "turn" | "loop", parentToolCallId?: string): void {
78
106
  if (this.openReasoning) return;
79
107
  this.openReasoning = this.mint("reason");
80
- this.out("reasoning_start", { id: this.openReasoning, ...(scope ? { scope } : {}) });
108
+ this.openReasoningBuffer = "";
109
+ this.out("reasoning_start", {
110
+ id: this.openReasoning,
111
+ ...(scope ? { scope } : {}),
112
+ ...(parentToolCallId ? { parentToolCallId } : {}),
113
+ });
81
114
  }
82
115
 
83
- private emitReasoningDelta(delta: unknown): void {
116
+ private emitReasoningDelta(delta: unknown, parentToolCallId?: string): void {
84
117
  if (delta == null || delta === "") return;
85
- this.ensureReasoningOpen();
86
- this.out("reasoning_delta", { id: this.openReasoning, delta: String(delta) });
118
+ this.ensureReasoningOpen(undefined, parentToolCallId);
119
+ const text = String(delta);
120
+ this.openReasoningBuffer += text;
121
+ this.out("reasoning_delta", { id: this.openReasoning, delta: text });
87
122
  }
88
123
 
89
124
  private closeReasoning(): void {
90
125
  if (!this.openReasoning) return;
91
- this.out("reasoning_complete", { id: this.openReasoning });
126
+ // U2: carry the assembled reasoning text (parity with text_complete).
127
+ this.out("reasoning_complete", {
128
+ id: this.openReasoning,
129
+ ...(this.openReasoningBuffer ? { text: this.openReasoningBuffer } : {}),
130
+ });
92
131
  this.openReasoning = null;
132
+ this.openReasoningBuffer = "";
93
133
  }
94
134
 
95
135
  private closeChannels(): void {
@@ -391,7 +431,7 @@ class UnifiedEventTranslator {
391
431
  });
392
432
  break;
393
433
  case "step_delta":
394
- this.emitTextDelta(data.text ?? data.delta);
434
+ this.emitTextDelta(data.text ?? data.delta, this.parentToolCallId(data));
395
435
  break;
396
436
  case "step_complete":
397
437
  this.closeChannels();
@@ -492,23 +532,31 @@ class UnifiedEventTranslator {
492
532
  });
493
533
  break;
494
534
  case "chunk":
495
- this.emitTextDelta(data.text);
535
+ this.emitTextDelta(data.text, this.parentToolCallId(data));
496
536
  break;
497
537
 
498
538
  case "text_start":
499
539
  if (!this.openText) {
500
540
  this.openText = (data.id as string) ?? this.mint("text");
501
- this.out("text_start", { id: this.openText });
541
+ this.openTextBuffer = "";
542
+ const parentToolCallId = this.parentToolCallId(data);
543
+ this.out("text_start", {
544
+ id: this.openText,
545
+ ...(parentToolCallId ? { parentToolCallId } : {}),
546
+ });
502
547
  }
503
548
  break;
504
549
  case "text_end":
505
550
  this.closeText();
506
551
  break;
507
552
  case "reason_start":
508
- this.ensureReasoningOpen();
553
+ this.ensureReasoningOpen(undefined, this.parentToolCallId(data));
509
554
  break;
510
555
  case "reason_delta":
511
- this.emitReasoningDelta(data.reasoningText ?? data.delta ?? data.text);
556
+ this.emitReasoningDelta(
557
+ data.reasoningText ?? data.delta ?? data.text,
558
+ this.parentToolCallId(data)
559
+ );
512
560
  break;
513
561
  case "reason_complete":
514
562
  this.closeReasoning();
@@ -0,0 +1,37 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { normalizeCopiedSelectionText } from "./copy-selection";
3
+
4
+ describe("normalizeCopiedSelectionText", () => {
5
+ it("strips a single trailing newline left by block-element serialization", () => {
6
+ const raw = "Create a markdown document titled \"Bridge Test Report\".\n";
7
+ expect(normalizeCopiedSelectionText(raw)).toBe(
8
+ "Create a markdown document titled \"Bridge Test Report\"."
9
+ );
10
+ });
11
+
12
+ it("strips multiple trailing blank lines and whitespace", () => {
13
+ expect(normalizeCopiedSelectionText("hello\n\n \n")).toBe("hello");
14
+ });
15
+
16
+ it("strips leading blank lines", () => {
17
+ expect(normalizeCopiedSelectionText("\n\nhello")).toBe("hello");
18
+ });
19
+
20
+ it("preserves interior newlines (multi-paragraph / multi-message selection)", () => {
21
+ expect(normalizeCopiedSelectionText("first\n\nsecond\n")).toBe("first\n\nsecond");
22
+ });
23
+
24
+ it("preserves leading indentation on the first line (copied code)", () => {
25
+ expect(normalizeCopiedSelectionText(" indented line\nnext\n")).toBe(
26
+ " indented line\nnext"
27
+ );
28
+ });
29
+
30
+ it("returns an unchanged string when there is nothing to trim", () => {
31
+ expect(normalizeCopiedSelectionText("clean text")).toBe("clean text");
32
+ });
33
+
34
+ it("returns an empty string for whitespace-only input", () => {
35
+ expect(normalizeCopiedSelectionText("\n\n \n")).toBe("");
36
+ });
37
+ });
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Normalize text pulled from a native browser selection before it is written to
3
+ * the clipboard.
4
+ *
5
+ * When a user triple-clicks a chat bubble and presses Ctrl/Cmd-C, the browser
6
+ * serializes the DOM selection itself. Markdown is rendered into block-level
7
+ * elements (`<p>`, `<li>`, `<pre>`, …), and browsers emit surrounding newlines
8
+ * for those blocks — so copying a single message drags along stray leading
9
+ * blank lines and a trailing newline that the user never visually selected.
10
+ *
11
+ * This trims the outer whitespace so the clipboard matches the visible
12
+ * selection, while preserving:
13
+ * - interior newlines (a multi-paragraph or multi-message selection keeps its
14
+ * line breaks), and
15
+ * - leading indentation on the first line (e.g. copied code keeps its indent;
16
+ * only fully-blank leading lines are dropped).
17
+ */
18
+ export const normalizeCopiedSelectionText = (text: string): string =>
19
+ text.replace(/^\n+/, "").replace(/\s+$/, "");
@@ -304,28 +304,9 @@ describe("Event Capture Pipeline - no interference with message processing", ()
304
304
 
305
305
  it("should still create assistant message correctly when event capture is active", async () => {
306
306
  global.fetch = createMockFetch([
307
- sseData({
308
- type: "step_chunk",
309
- id: "step_1",
310
- name: "Prompt 1",
311
- executionType: "prompt",
312
- index: 1,
313
- text: "Hello"
314
- }),
315
- sseData({
316
- type: "step_chunk",
317
- id: "step_1",
318
- name: "Prompt 1",
319
- executionType: "prompt",
320
- index: 2,
321
- text: " there"
322
- }),
323
- sseData({
324
- type: "flow_complete",
325
- flowId: "flow_1",
326
- success: true,
327
- duration: 100
328
- })
307
+ sseData({ type: "text_delta", id: "text_1", delta: "Hello" }),
308
+ sseData({ type: "text_delta", id: "text_1", delta: " there" }),
309
+ sseData({ type: "execution_complete", kind: "flow", success: true })
329
310
  ]);
330
311
 
331
312
  await client.dispatch(
@@ -374,20 +355,8 @@ describe("Event Capture Pipeline - no interference with message processing", ()
374
355
  duration: 250,
375
356
  completedAt: "2025-01-01T00:00:01.000Z"
376
357
  }),
377
- sseData({
378
- type: "step_chunk",
379
- id: "step_1",
380
- name: "Prompt 1",
381
- executionType: "prompt",
382
- index: 1,
383
- text: "Found results"
384
- }),
385
- sseData({
386
- type: "flow_complete",
387
- flowId: "flow_1",
388
- success: true,
389
- duration: 500
390
- })
358
+ sseData({ type: "text_delta", id: "text_1", delta: "Found results" }),
359
+ sseData({ type: "execution_complete", kind: "flow", success: true })
391
360
  ]);
392
361
 
393
362
  await client.dispatch(
@@ -442,20 +411,8 @@ describe("Event Capture Pipeline - no interference with message processing", ()
442
411
  });
443
412
 
444
413
  global.fetch = createMockFetch([
445
- sseData({
446
- type: "step_chunk",
447
- id: "step_1",
448
- name: "Prompt 1",
449
- executionType: "prompt",
450
- index: 1,
451
- text: "Works fine"
452
- }),
453
- sseData({
454
- type: "flow_complete",
455
- flowId: "flow_1",
456
- success: true,
457
- duration: 50
458
- })
414
+ sseData({ type: "text_delta", id: "text_1", delta: "Works fine" }),
415
+ sseData({ type: "execution_complete", kind: "flow", success: true })
459
416
  ]);
460
417
 
461
418
  const events: AgentWidgetEvent[] = [];
@@ -496,20 +453,8 @@ describe("Event Capture Pipeline - no interference with message processing", ()
496
453
  });
497
454
 
498
455
  global.fetch = createMockFetch([
499
- sseData({
500
- type: "step_chunk",
501
- id: "step_1",
502
- name: "Prompt 1",
503
- executionType: "prompt",
504
- index: 1,
505
- text: "Still works"
506
- }),
507
- sseData({
508
- type: "flow_complete",
509
- flowId: "flow_1",
510
- success: true,
511
- duration: 50
512
- })
456
+ sseData({ type: "text_delta", id: "text_1", delta: "Still works" }),
457
+ sseData({ type: "execution_complete", kind: "flow", success: true })
513
458
  ]);
514
459
 
515
460
  const events: AgentWidgetEvent[] = [];
@@ -0,0 +1,100 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { stabilizeStreamingTables } from "./streaming-table";
3
+ import { createMarkdownProcessor } from "../postprocessors";
4
+
5
+ describe("stabilizeStreamingTables", () => {
6
+ it("leaves text without pipes untouched", () => {
7
+ const md = "# Heading\n\nA paragraph with no tables.";
8
+ expect(stabilizeStreamingTables(md)).toBe(md);
9
+ });
10
+
11
+ it("does not treat a header-only line as a table (no delimiter yet)", () => {
12
+ // Ambiguous: could just be inline pipe text. Leave it alone until a
13
+ // delimiter row starts streaming in.
14
+ const md = "| Name | Price |";
15
+ expect(stabilizeStreamingTables(md)).toBe(md);
16
+ });
17
+
18
+ it("completes a partial delimiter row so the table renders immediately", () => {
19
+ const md = "| Name | Price |\n| --";
20
+ expect(stabilizeStreamingTables(md)).toBe("| Name | Price |\n| --- | --- |");
21
+ });
22
+
23
+ it("completes a delimiter that is just a leading pipe with one dash", () => {
24
+ const md = "| A | B | C |\n|-";
25
+ expect(stabilizeStreamingTables(md)).toBe("| A | B | C |\n| --- | --- | --- |");
26
+ });
27
+
28
+ it("pads a partial trailing row to the header column count", () => {
29
+ const md = "| Name | Price |\n| --- | --- |\n| Apple";
30
+ expect(stabilizeStreamingTables(md)).toBe(
31
+ "| Name | Price |\n| --- | --- |\n| Apple | |"
32
+ );
33
+ });
34
+
35
+ it("keeps a complete table stable (idempotent)", () => {
36
+ const md = "| Name | Price |\n| --- | --- |\n| Apple | $1 |";
37
+ expect(stabilizeStreamingTables(md)).toBe(md);
38
+ expect(stabilizeStreamingTables(stabilizeStreamingTables(md))).toBe(md);
39
+ });
40
+
41
+ it("counts columns from a header without outer pipes", () => {
42
+ const md = "Name | Price\n---";
43
+ expect(stabilizeStreamingTables(md)).toBe("Name | Price\n| --- | --- |");
44
+ });
45
+
46
+ it("normalizes rows with extra cells down to the header column count", () => {
47
+ const md = "| A | B |\n| --- | --- |\n| 1 | 2 | 3 |";
48
+ expect(stabilizeStreamingTables(md)).toBe("| A | B |\n| --- | --- |\n| 1 | 2 |");
49
+ });
50
+
51
+ it("stops the table region at a blank line and leaves following text alone", () => {
52
+ const md = "| A | B |\n| --- | --- |\n| 1 | 2 |\n\nSome | trailing | text";
53
+ expect(stabilizeStreamingTables(md)).toBe(md);
54
+ });
55
+
56
+ it("preserves leading prose before a streaming table", () => {
57
+ const md = "Here you go:\n\n| A | B |\n| -";
58
+ expect(stabilizeStreamingTables(md)).toBe("Here you go:\n\n| A | B |\n| --- | --- |");
59
+ });
60
+
61
+ it("handles two separate tables in one stream", () => {
62
+ const md =
63
+ "| A | B |\n| --- | --- |\n| 1 | 2 |\n\nmiddle\n\n| C | D | E |\n| --";
64
+ expect(stabilizeStreamingTables(md)).toBe(
65
+ "| A | B |\n| --- | --- |\n| 1 | 2 |\n\nmiddle\n\n| C | D | E |\n| --- | --- | --- |"
66
+ );
67
+ });
68
+
69
+ it("preserves alignment-colon delimiters as a valid table start", () => {
70
+ const md = "| A | B |\n| :-";
71
+ // Detection accepts colons; the synthesized delimiter normalizes to dashes
72
+ // (alignment reappears in the untouched final render).
73
+ expect(stabilizeStreamingTables(md)).toBe("| A | B |\n| --- | --- |");
74
+ });
75
+ });
76
+
77
+ describe("stabilizeStreamingTables → marked integration", () => {
78
+ const md = createMarkdownProcessor();
79
+
80
+ it("renders a <table> from a header + partial delimiter that marked alone would not", () => {
81
+ const partial = "| Name | Price |\n| --";
82
+
83
+ // Without stabilization marked sees no complete delimiter → no table.
84
+ expect(md(partial)).not.toContain("<table");
85
+
86
+ const html = md(stabilizeStreamingTables(partial));
87
+ expect(html).toContain("<table");
88
+ expect(html).toContain("<th>Name</th>");
89
+ expect(html).toContain("<th>Price</th>");
90
+ });
91
+
92
+ it("renders a body row for a partial trailing row", () => {
93
+ const partial = "| Name | Price |\n| --- | --- |\n| Apple";
94
+ const html = md(stabilizeStreamingTables(partial));
95
+ expect(html).toContain("<table");
96
+ expect(html).toContain("<td>Apple</td>");
97
+ // The padded empty cell keeps the column count stable.
98
+ expect(html.match(/<td/g)?.length).toBe(2);
99
+ });
100
+ });
@@ -0,0 +1,103 @@
1
+ /**
2
+ * Streaming markdown table stabilizer (Telegram-style space reservation).
3
+ *
4
+ * During SSE streaming the full accumulated markdown is re-parsed by `marked`
5
+ * on every chunk. For GFM tables that produces two jarring jolts:
6
+ *
7
+ * 1. The paragraph→table flip. GFM only recognizes a table once the *delimiter*
8
+ * row (`| --- | --- |`) has arrived, so a freshly-streamed header line first
9
+ * renders as a plain paragraph and then snaps into a `<table>`.
10
+ * 2. Partial-row flicker. The in-flight last row grows cell-by-cell.
11
+ *
12
+ * This module rewrites table-in-progress regions so a real `<table>` renders
13
+ * from the first row onward with a stable column count: it completes the
14
+ * delimiter row as soon as it starts streaming and pads the trailing partial
15
+ * row to the header's column count. Combined with `table-layout: fixed` while
16
+ * streaming (see `.persona-content-streaming table` in widget.css), columns lock
17
+ * to even widths so rows append vertically without horizontal reflow.
18
+ *
19
+ * It runs ONLY while a message is streaming; the final render uses the real,
20
+ * untouched `marked` output, so correctness is never affected.
21
+ */
22
+
23
+ /**
24
+ * A GFM delimiter row, full or still streaming in: only delimiter characters
25
+ * (`-`, `:`, `|`, whitespace) and at least one dash. Matches `|`-led partials
26
+ * like `| -`, `|--`, `| :--`, and complete rows like `| --- | :--: |`.
27
+ */
28
+ const DELIMITER_RE = /^\s*\|?[\s:|-]*-[\s:|-]*$/;
29
+
30
+ /** A candidate table row contains at least one pipe. */
31
+ const hasPipe = (line: string): boolean => line.includes("|");
32
+
33
+ /** Split a markdown table row into trimmed cell strings, ignoring outer pipes. */
34
+ const splitCells = (line: string): string[] => {
35
+ let s = line.trim();
36
+ if (s.startsWith("|")) s = s.slice(1);
37
+ if (s.endsWith("|")) s = s.slice(0, -1);
38
+ return s.split("|").map((cell) => cell.trim());
39
+ };
40
+
41
+ /** Render cells back into a normalized, pipe-delimited row. */
42
+ const buildRow = (cells: string[]): string => `| ${cells.join(" | ")} |`;
43
+
44
+ /** Build a complete delimiter row with the given column count. */
45
+ const buildDelimiter = (cols: number): string =>
46
+ `| ${Array.from({ length: cols }, () => "---").join(" | ")} |`;
47
+
48
+ /** Pad (or trim) a row's cells to exactly `cols` columns. */
49
+ const fitCells = (cells: string[], cols: number): string[] => {
50
+ if (cells.length >= cols) return cells.slice(0, cols);
51
+ return cells.concat(Array.from({ length: cols - cells.length }, () => ""));
52
+ };
53
+
54
+ /**
55
+ * Normalize any streaming-in-progress GFM tables in `markdown` so they render as
56
+ * complete tables with a stable column count. Returns the input unchanged when
57
+ * there is nothing to stabilize (cheap fast-path for the common no-table case).
58
+ */
59
+ export const stabilizeStreamingTables = (markdown: string): string => {
60
+ if (!markdown || !markdown.includes("|")) return markdown;
61
+
62
+ const lines = markdown.split("\n");
63
+ let changed = false;
64
+
65
+ for (let i = 0; i < lines.length - 1; i++) {
66
+ const header = lines[i];
67
+ const delimiter = lines[i + 1];
68
+
69
+ // A table starts at a header line (has a pipe, is not itself a delimiter)
70
+ // immediately followed by a delimiter row that is full or still streaming.
71
+ if (!hasPipe(header) || DELIMITER_RE.test(header)) continue;
72
+ if (!DELIMITER_RE.test(delimiter)) continue;
73
+
74
+ const cols = splitCells(header).length;
75
+ if (cols < 1) continue;
76
+
77
+ // Complete the delimiter to match the header's column count so `marked`
78
+ // recognizes the table immediately instead of waiting for it to finish.
79
+ const fullDelimiter = buildDelimiter(cols);
80
+ if (lines[i + 1] !== fullDelimiter) {
81
+ lines[i + 1] = fullDelimiter;
82
+ changed = true;
83
+ }
84
+
85
+ // Normalize body rows (including a partial trailing one) to `cols` columns
86
+ // so each row occupies its slot instead of growing cell-by-cell. The region
87
+ // ends at the first blank or pipe-less line.
88
+ let j = i + 2;
89
+ for (; j < lines.length; j++) {
90
+ const row = lines[j];
91
+ if (row.trim() === "" || !hasPipe(row)) break;
92
+ const normalized = buildRow(fitCells(splitCells(row), cols));
93
+ if (lines[j] !== normalized) {
94
+ lines[j] = normalized;
95
+ changed = true;
96
+ }
97
+ }
98
+
99
+ i = j - 1; // resume scanning after this table region
100
+ }
101
+
102
+ return changed ? lines.join("\n") : markdown;
103
+ };