@pi-kaush/pi-tool-call-markers 0.3.5 → 0.3.7

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/CHANGELOG.md CHANGED
@@ -2,6 +2,86 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ - Render an asked question as a user-input moment instead of a raw tool row.
6
+ `ask_user_question` used to collapse to `│ * {"questions":…`, which is a tool
7
+ call marker doing the work of a prompt. An asked question now takes the
8
+ submitted-prompt shell: `▎ > <question>` followed by `▎ User: <answer>`, one
9
+ pair per question, with multi-select labels joined, a decline reading
10
+ `User declined to answer questions`, and a live row showing
11
+ `awaiting your answer…`. Recognition is shape-first off
12
+ `details.answers`, so another author's question tool renders the same without
13
+ a change here; the tool name is the fallback for calls that never answered.
14
+ The block reuses pi-content-layout's submitted-prompt geometry — rail,
15
+ surface, inset, one-column left and two-column right body padding, and
16
+ background padding rows — and italicizes the question while the answer stays
17
+ upright. Question blocks never join a tool group, and `Ctrl+O` still restores
18
+ Pi's native expanded rendering.
19
+
20
+ - Make the railed shell's body padding explicit: `renderRailedBlockLines` takes
21
+ a `{ left, right }` padding, so the submitted-prompt shape (1/2) and the
22
+ existing `!`-block shape (1/0) come from one code path instead of the body
23
+ width being hardcoded.
24
+
25
+ - Extract the railed prompt shell into `src/prompt-shell.ts`. User `!` blocks
26
+ and asked-question blocks share one implementation of the inset, rail,
27
+ surface background, and background repaint instead of two.
28
+
29
+ - Read colors from the live palette instead of caching them against the
30
+ theme object. Pi swaps the palette behind a stable theme Proxy, so every
31
+ identity-keyed cache survived a theme switch: collapsed rows, the `+ Thought`
32
+ label, and user `!` blocks kept the old colors until their content changed.
33
+ Probes are map lookups, so the render caches now key on the resolved colors
34
+ (`paletteSample`, including the bash-block line cache) and the label
35
+ resolves its style per call.
36
+
37
+ - Stop grouping from resurrecting a tool that opted out of the transcript. A
38
+ self-rendered tool that draws no lines stays hidden whether it renders alone
39
+ or beside another call: the singleton and grouped paths share one
40
+ draws-nothing rule.
41
+
42
+ - Go inert at the final owner's shutdown. Every prototype wrapper — tool
43
+ presentation, container grouping, user bash blocks, thinking — delegates to
44
+ the original when another extension's wrapper has buried it and removal is
45
+ impossible, so collapsed rows and restyled labels cannot outlive their owner.
46
+ A later install re-enables the same wrappers.
47
+
48
+ - Roll back a container hook that mutates the child list and then throws.
49
+ Its edit used to stick, dropping those rows from every later render. Both
50
+ copies of the shared hook contract changed together.
51
+
52
+ - Own and release `/toggle-info` with the rest of the extension: the
53
+ process-global filter hook and the hidden flag now leave with the last owner
54
+ instead of filtering a transcript this package no longer renders.
55
+
56
+ - Track thinking rows for the `/toggle-info` replay instead of every assistant
57
+ row, capped at 400 entries, so a long session or an abandoned branch cannot
58
+ pin every message the transcript ever showed.
59
+
60
+ - Delete state nothing reads (the row→group map) and the duplicate
61
+ `session_start` handler that applied the collapsed default twice.
62
+
63
+ - Drop the colon after every collapsed-row anchor: glyph rows read
64
+ `│ ● src/a.ts` and bash rows `│ $ npm test`. The anchor runs bold with a
65
+ single space before the call text, so the anchor alone separates the two.
66
+ The whole seam — glyph map, `$` prompt, subagent fallback heading, and the
67
+ tool-token drop — now lives in one function that both the singleton and
68
+ grouped render paths call, and self-rendered labels no longer leak their
69
+ tool name into the row under themes that tag instead of color.
70
+
71
+ - Drop the italics Pi puts on thinking. The hidden `+ Thought` label renders
72
+ plain in every theme — the live spinner keeps its level tint, the settled
73
+ row its muted tone, and a theme that resolves no color still loses the
74
+ italics — and the visible trace stays italic only while it streams, then
75
+ renders as ordinary transcript text once the row settles. The live/settled
76
+ rule is shared with the label, so un-flagged row rebuilds (resize, theme
77
+ switch) keep a live trace italic and a finished one plain.
78
+ `PI_TOOL_CALL_MARKERS_THOUGHT_COLOR=inherit` remains the only opt-out that
79
+ keeps Pi's native italic styling.
80
+
81
+ - Fail open when a theme rejects a thinking token. Pi's Theme throws on
82
+ tokens it does not know, so the label probe now falls back to the plain
83
+ treatment instead of throwing out of the row's update pass.
84
+
5
85
  - Restore Pi 0.85's click-to-expand under the custom collapsed rows. Pi routes
6
86
  mouse clicks by per-child rendered line heights, and the grouping render
7
87
  bypassed the native render that refreshes that cache, so clicks on anything
package/README.md CHANGED
@@ -15,13 +15,13 @@ Restart Pi or run `/reload`.
15
15
  Collapsed tool rows use semantic theme colors with no gear, background fill, box padding, or filled blank rows:
16
16
 
17
17
  ```text
18
- │ ●: src/a.ts 42 lines
19
- │ ●: src/b.ts 18 lines
20
- │ $: npm test → done
18
+ │ ● src/a.ts 42 lines
19
+ │ ● src/b.ts 18 lines
20
+ │ $ npm test → done
21
21
  ```
22
22
 
23
23
  - **Two-column outer inset.** Tool markers and image output align with an inset conversation surface. Very narrow terminals reduce the decoration before useful content.
24
- - **`│` tool-call rail.** Every collapsed call anchors on a glyph instead of its tool name: `●` read, `+` write, `±` edit, `○` local search, `≡` ls, `↗` web search and fetch, and `$` bash; unmapped tools fall back to `*`. Grouped rows flow directly across tool types.
24
+ - **`│` tool-call rail.** Every collapsed call anchors on a bold glyph instead of its tool name — `●` read, `+` write, `±` edit, `○` local search, `≡` ls, `↗` web search and fetch, and `$` bash; unmapped tools fall back to `*` — with a single space between the anchor and the call text, no joining colon. Grouped rows flow directly across tool types.
25
25
  - **Semantic, low-contrast status.** The anchor and the call content share the collapsed mute, pending state is warning-colored, and failures remain error-colored. Ordinary tool states have no background.
26
26
  - **Width-safe outcome tails.** Long summaries truncate before useful tails such as `→ done`, `→ 42 lines`, `→ +2/-1`, or a `bash` duration.
27
27
  - **Stable running groups.** Adjacent calls group as they appear, including settled failures, which stay error-colored inside the group. Pending state and elapsed `bash` time settle into the final outcome without changing the row count.
@@ -47,6 +47,27 @@ While a subagent runs, the plan headline's tail shows live progress from the str
47
47
 
48
48
  Malformed, ambiguous, future, or too-narrow shapes fall back to the generic `↪ subagent …` collapsed row rather than dropping information, and `Ctrl+O` still exposes the native subagent renderer.
49
49
 
50
+ ## Asked questions
51
+
52
+ A call that asks the user something is a user-input moment, not an execution row, so it renders in the submitted-prompt shell instead of the tool-call rail:
53
+
54
+ ```text
55
+ ▎
56
+ ▎ > Which call-site shape should `notifications:` use?
57
+ ▎ User: Nested within subagent (Recommended)
58
+ ▎ > Which tier should the flag read at runtime?
59
+ ▎ User: Client only, Client and server
60
+ ▎
61
+ ```
62
+
63
+ The block reuses `pi-content-layout`'s submitted-prompt geometry — the same rail, surface, inset, one-column left padding, two-column right padding, and background padding rows — so an asked question and a submitted prompt are the same shape in the transcript.
64
+
65
+ - **Shape-first recognition.** Any tool whose result carries an `answers` array of `{ question, answer | selected }` entries renders as a question block, so another author's question tool works without a change here. The tool name (`ask_user_question` and close variants) is the fallback for a call that failed before answering.
66
+ - **Answers, not options.** Each question shows its answer: an option label, typed custom text, or multi-select labels joined with commas. A question left blank reads `User: (no answer)`, a decline reads `User declined to answer questions`, and a validation failure keeps Pi's native failure row.
67
+ - **Quoted questions.** The question is the model's text read inside a user-input shell, so it renders italic under a `>` marker and the answer stays upright: the answer is the user's own words. A theme without italics renders the question plainly.
68
+ - **Prompt-shell tones.** The rail uses `borderAccent` and the body paints on `userMessageBg`, with the question in `text`, the `User:` label in `muted`, the answer in `userMessageText`, and a live row's `awaiting your answer…` in `warning`.
69
+ - **No grouping.** Question blocks never join an adjacent tool group and carry no tool glyph. `Ctrl+O` still restores Pi's native expanded rendering.
70
+
50
71
  ## Edit diffs
51
72
 
52
73
  Settled `edit` calls collapse like every other row: the call line keeps its path and a `+added/-removed` outcome stat, and the hunk itself stays out of the collapsed row. `Ctrl+O` restores Pi's full native diff, including line numbers and intra-line word highlights. To keep edits expanded without pressing `Ctrl+O`, list `edit` in `PI_ALWAYS_EXPANDED_TOOL_CALL_MARKERS` (see Configuration).
@@ -55,9 +76,9 @@ Files and paths in collapsed rows are preserved as displayed by Pi: hyperlink-wr
55
76
 
56
77
  ## Scope boundary
57
78
 
58
- This package owns every **execution row** in the transcript: collapsed tool calls, tool grouping, subagent plans, thinking labels, and user-run `!` bash blocks (reshaped into the railed prompt shell). Transcript _surface_ layout — message insets, system-text and status-rule alignment, the editor surface, and the submitted-prompt shell for user messages — belongs to `@pi-kaush/pi-content-layout`, which never renders tool rows.
79
+ This package owns every **execution row** in the transcript: collapsed tool calls, tool grouping, subagent plans, asked-question blocks, thinking labels, and user-run `!` bash blocks (reshaped into the railed prompt shell). Transcript _surface_ layout — message insets, system-text and status-rule alignment, the editor surface, and the submitted-prompt shell for user messages — belongs to `@pi-kaush/pi-content-layout`, which never renders tool rows.
59
80
 
60
- One shared visual contract crosses the line: `!` blocks align with message text, so their inset mirrors `pi-content-layout`'s message `contentInset` (see `src/bash-block.ts`). Change indentation in both packages together.
81
+ One shared visual contract crosses the line: `!` blocks and asked-question blocks align with message text, so their inset mirrors `pi-content-layout`'s message `contentInset` (see `src/prompt-shell.ts`, which owns the railed shell both use). Change indentation in both packages together.
61
82
 
62
83
  ## Hiding info entirely: /toggle-info
63
84
 
@@ -78,7 +99,9 @@ When Pi exposes its per-row hidden-thinking and streaming fields, hidden reasoni
78
99
  + Thought · 2.5s
79
100
  ```
80
101
 
81
- The live label samples Pi's native braille spinner sequence from the content updates Pi already renders; it does not add a timer. The adapter stores the first local streaming timestamp per assistant row in a `WeakMap`. A restored message or an older runtime with no streaming argument uses `+ Thought`. Visible-thinking mode remains native. There is no interval, timeout, render request, model call, or network work.
102
+ The live label samples Pi's native braille spinner sequence from the content updates Pi already renders; it does not add a timer. The adapter stores the first local streaming timestamp per assistant row in a `WeakMap`. A restored message or an older runtime with no streaming argument uses `+ Thought`. There is no interval, timeout, render request, model call, or network work.
103
+
104
+ Pi renders thinking labels and traces italic. This package drops those italics: both labels read as plain collapsed rows (the live spinner keeps its thinking-level tint, the settled row the muted tone, and a theme that resolves no color still loses the italics), and a visible trace stays italic only while it streams, then settles into ordinary transcript text. `PI_TOOL_CALL_MARKERS_THOUGHT_COLOR=inherit` is the opt-out that keeps Pi's native italic `thinkingText` styling.
82
105
 
83
106
  ## Local development
84
107
 
@@ -131,10 +154,14 @@ Pi has no public hook for native tool rows, transcript grouping, or per-message
131
154
 
132
155
  - `ToolExecutionComponent` for collapsed presentation;
133
156
  - `Container` for adjacent grouping; and
134
- - `AssistantMessageComponent.updateContent` for display-only thinking merging and lifecycle labels.
157
+ - `AssistantMessageComponent.updateContent` for display-only thinking merging, lifecycle labels, and the settled trace's italics.
135
158
 
136
159
  Each adapter feature-detects the fields and methods it needs, keeps the original method, uses an idempotency symbol, catches cosmetic failures, and restores the original on `session_shutdown` when it still owns the patch. Unsupported shapes fail open to Pi's native rendering. The thinking adapter continues adjacent merging even when the private label shape is unavailable.
137
160
 
161
+ Teardown is owner-counted and inert: when another extension's wrapper sits above one of these, removal is impossible, so the buried wrapper delegates to the original instead of leaving collapsed rows or restyled labels behind after its owner shuts down. A later install re-enables it. The shared chat-container hook contract applies the same fail-open rule to hooks: a hook that throws after editing the child list has its edit rolled back, so a failed hook cannot drop rows from the transcript.
162
+
163
+ Colors are read live from the theme, so a mid-session theme switch repaints collapsed rows, labels, and `!` blocks; cached render output keys on the resolved palette rather than on the theme object, which Pi keeps stable across a switch.
164
+
138
165
  Expanded tools always use Pi's native renderer. The collapsed tool shell owns its two-column inset directly; transcript layout extensions should leave tool rows unchanged, preventing load-order-dependent double padding.
139
166
 
140
167
  > TODO: migrate these adapters to public Pi transcript and tool-rendering APIs when available.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-kaush/pi-tool-call-markers",
3
- "version": "0.3.5",
3
+ "version": "0.3.7",
4
4
  "description": "Compact and group Pi tool calls, with a bundled extension for adjacent thinking blocks.",
5
5
  "license": "MIT",
6
6
  "author": "Kaushik Gopal",
package/src/bash-block.ts CHANGED
@@ -1,141 +1,21 @@
1
1
  import { BashExecutionComponent } from "@earendil-works/pi-coding-agent";
2
- import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
3
-
4
- type ThemeLike = {
5
- bold(text: string): string;
2
+ import { visibleWidth } from "@earendil-works/pi-tui";
3
+ import { paletteSample } from "./muted.ts";
4
+ import {
5
+ PROMPT_RAIL,
6
+ fitLine,
7
+ promptBlockInset,
8
+ renderRailedBlockLines,
9
+ stripDisplayAnsi,
10
+ type PromptShellTheme,
11
+ } from "./prompt-shell.ts";
12
+
13
+ type ThemeLike = PromptShellTheme & {
6
14
  fg(color: string, text: string): string;
7
- getBgAnsi?(color: string): string;
8
15
  };
9
16
 
10
17
  const BASH_BLOCK_PATCHED = Symbol.for("kg.pi.bashBlock.v1");
11
18
 
12
- // The prompt surface paints with the theme's userMessageBg token, matching
13
- // pi-content-layout's surfaces. userMessageBg is a required pi theme token;
14
- // the legacy hex remains only as a fallback for theme lookalikes without
15
- // getBgAnsi.
16
- const PROMPT_SURFACE_BG_FALLBACK = "\x1b[48;2;7;19;18m"; // #071312
17
-
18
- function promptSurfaceBg(theme: ThemeLike): string {
19
- try {
20
- return theme.getBgAnsi?.("userMessageBg") ?? PROMPT_SURFACE_BG_FALLBACK;
21
- } catch {
22
- return PROMPT_SURFACE_BG_FALLBACK;
23
- }
24
- }
25
- const PROMPT_RAIL = "▎";
26
-
27
- // User-run bash blocks align with the message inset pi-content-layout
28
- // applies to transcript lines. Keep this in sync with that package's
29
- // contentInset (2 columns outside the rail for any width above 4); the
30
- // conversation surface has one shared visual contract even though the two
31
- // packages otherwise own disjoint components.
32
- function bashBlockInset(width: number): number {
33
- return width > BASH_BLOCK_INSET * 2 ? BASH_BLOCK_INSET : 0;
34
- }
35
- const BASH_BLOCK_INSET = 2;
36
-
37
- const SGR_PATTERN = /\x1b\[([0-9;]*)m/g;
38
- const BACKGROUND_PATTERN =
39
- /\x1b\[(?:4[0-8]|10[0-7]|48;5;\d{1,3}|48;2;\d{1,3};\d{1,3};\d{1,3})m/g;
40
- const DISPLAY_ANSI_PATTERN = /\x1b(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])/g;
41
-
42
- function semanticControlEnd(text: string, offset: number): number | undefined {
43
- if (text[offset] !== "\x1b") return undefined;
44
- const kind = text[offset + 1];
45
- if (kind !== "]" && kind !== "_" && kind !== "P" && kind !== "^") {
46
- return undefined;
47
- }
48
- for (let index = offset + 2; index < text.length; index++) {
49
- if (text[index] === "\x07") return index + 1;
50
- if (text[index] === "\x1b" && text[index + 1] === "\\") {
51
- return index + 2;
52
- }
53
- }
54
- return undefined;
55
- }
56
-
57
- function splitLeadingSemanticControls(line: string): {
58
- controls: string;
59
- content: string;
60
- } {
61
- let offset = 0;
62
- while (offset < line.length) {
63
- const end = semanticControlEnd(line, offset);
64
- if (end === undefined) break;
65
- offset = end;
66
- }
67
- return { controls: line.slice(0, offset), content: line.slice(offset) };
68
- }
69
-
70
- function fitLine(line: string, width: number): string {
71
- if (width <= 0) return "";
72
- const clipped = truncateToWidth(line, width, "");
73
- return clipped + " ".repeat(Math.max(0, width - visibleWidth(clipped)));
74
- }
75
-
76
- function reapplyBackgroundAfterReset(
77
- text: string,
78
- backgroundAnsi: string,
79
- ): string {
80
- return text.replace(SGR_PATTERN, (sequence, rawParameters: string) => {
81
- const parameters =
82
- rawParameters === "" ? [0] : rawParameters.split(";").map(Number);
83
- return parameters.includes(0) || parameters.includes(49)
84
- ? `${sequence}${backgroundAnsi}`
85
- : sequence;
86
- });
87
- }
88
-
89
- function paintBackground(
90
- line: string,
91
- width: number,
92
- backgroundAnsi: string,
93
- ): string {
94
- const fitted = fitLine(line, width);
95
- // Mirrors Theme.bg: background, content, then a background-only reset.
96
- return `${backgroundAnsi}${reapplyBackgroundAfterReset(fitted, backgroundAnsi)}\x1b[49m`;
97
- }
98
-
99
- function stripDisplayAnsi(text: string): string {
100
- return text.replace(DISPLAY_ANSI_PATTERN, "");
101
- }
102
-
103
- function replaceBackground(text: string, backgroundAnsi: string): string {
104
- return text.replace(BACKGROUND_PATTERN, backgroundAnsi);
105
- }
106
-
107
- // Renders lines inside a railed, dark-surfaced block (the submitted-prompt
108
- // shell): optional leading semantic controls stay outside the inset, the rail
109
- // is recolored, and content backgrounds are normalized to the prompt surface.
110
- function renderRailedBlockLines(
111
- lines: string[],
112
- width: number,
113
- theme: ThemeLike,
114
- inset: number,
115
- railColor: string,
116
- ): string[] {
117
- const blockWidth = width - inset * 2;
118
- if (inset === 0 || blockWidth <= visibleWidth(PROMPT_RAIL)) {
119
- return lines.map((line) => fitLine(line, width));
120
- }
121
- const bodyWidth = blockWidth - visibleWidth(PROMPT_RAIL);
122
- const margin = " ".repeat(inset);
123
- const surfaceBg = promptSurfaceBg(theme);
124
- return lines.map((line) => {
125
- const { controls, content } = splitLeadingSemanticControls(line);
126
- const recolored = replaceBackground(content, surfaceBg);
127
- const rail = theme.fg(railColor, PROMPT_RAIL);
128
- // One extra leading space inside the body, so submitted text sits two
129
- // columns right of the rail instead of one.
130
- const body = paintBackground(
131
- ` ${fitLine(recolored, Math.max(1, bodyWidth - 1))}`,
132
- bodyWidth,
133
- surfaceBg,
134
- );
135
- return `${controls}${margin}${rail}${body}${margin}`;
136
- });
137
- }
138
-
139
19
  const RULE_LINE_RE = /^─+$/;
140
20
 
141
21
  function isRuleLine(line: string | undefined): line is string {
@@ -178,7 +58,7 @@ function renderBashBlockLines(
178
58
  width: number,
179
59
  theme: ThemeLike,
180
60
  status: string | undefined,
181
- inset = bashBlockInset(width),
61
+ inset = promptBlockInset(width),
182
62
  ): string[] {
183
63
  const [spacer, ...rest] = lines;
184
64
  const topRule = isRuleLine(rest[0]) ? rest[0] : undefined;
@@ -199,8 +79,15 @@ type BashRow = { status?: unknown };
199
79
 
200
80
  type BashBlockPatchState = {
201
81
  owners: number;
82
+ // Set at the final owner's shutdown. A wrapper another extension has buried
83
+ // cannot be uninstalled, so it delegates instead of outliving its owner; a
84
+ // later install re-enables it.
85
+ disabled?: boolean;
202
86
  theme?: ThemeLike;
203
- cache: WeakMap<object, { width: number; src: string[]; lines: string[] }>;
87
+ cache: WeakMap<
88
+ object,
89
+ { width: number; src: string[]; lines: string[]; palette: string }
90
+ >;
204
91
  originalRender: (width: number) => string[];
205
92
  patchedRender?: (width: number) => string[];
206
93
  };
@@ -222,6 +109,7 @@ function installBashBlockPatch(): BashBlockPatchState | undefined {
222
109
  const existing = proto[BASH_BLOCK_PATCHED];
223
110
  if (existing) {
224
111
  existing.owners++;
112
+ existing.disabled = false;
225
113
  return existing;
226
114
  }
227
115
 
@@ -235,17 +123,24 @@ function installBashBlockPatch(): BashBlockPatchState | undefined {
235
123
  width: number,
236
124
  ): string[] {
237
125
  const theme = state.theme;
238
- if (!theme) return state.originalRender.call(this, width);
239
- const inset = bashBlockInset(width);
126
+ if (state.disabled || !theme) {
127
+ return state.originalRender.call(this, width);
128
+ }
129
+ const inset = promptBlockInset(width);
240
130
  const bodyWidth = width - inset * 2 - visibleWidth(PROMPT_RAIL);
241
131
  if (inset === 0 || bodyWidth < 8) {
242
132
  return state.originalRender.call(this, width);
243
133
  }
244
134
  const lines = state.originalRender.call(this, Math.max(1, bodyWidth));
135
+ // Decorated lines carry resolved colors, so the cache keys on the
136
+ // palette too: Pi swaps the colors behind a stable theme Proxy, which a
137
+ // theme-identity check would never notice.
138
+ const palette = paletteSample(theme);
245
139
  const entry = state.cache.get(this);
246
140
  if (
247
141
  entry &&
248
142
  entry.width === width &&
143
+ entry.palette === palette &&
249
144
  entry.src.length === lines.length &&
250
145
  entry.src.every((line, index) => line === lines[index])
251
146
  ) {
@@ -259,7 +154,7 @@ function installBashBlockPatch(): BashBlockPatchState | undefined {
259
154
  typeof status === "string" ? status : undefined,
260
155
  inset,
261
156
  );
262
- state.cache.set(this, { width, src: lines, lines: decorated });
157
+ state.cache.set(this, { width, src: lines, lines: decorated, palette });
263
158
  return decorated;
264
159
  };
265
160
 
@@ -285,6 +180,7 @@ function uninstallBashBlockPatch(state: BashBlockPatchState | undefined): void {
285
180
  if (!state || state.owners <= 0) return;
286
181
  state.owners--;
287
182
  if (state.owners > 0) return;
183
+ state.disabled = true;
288
184
  const proto = BashExecutionComponent?.prototype as unknown as BashRow & {
289
185
  [BASH_BLOCK_PATCHED]?: BashBlockPatchState;
290
186
  render?: (width: number) => string[];
@@ -37,11 +37,17 @@ export function runChatContainerHooks(
37
37
  const restores: Array<() => void> = [];
38
38
  if (Array.isArray(children)) {
39
39
  for (const hook of chatContainerHooks()) {
40
+ // A hook rewrites `children` and hands back its own undo. Snapshot first:
41
+ // a hook that throws after splicing rows out would otherwise leave them
42
+ // missing from every later render, with nothing left to restore them.
43
+ const before = [...children];
40
44
  try {
41
45
  const restore = hook(container, children, width);
42
46
  if (typeof restore === "function") restores.push(restore);
43
47
  } catch {
44
48
  // Hooks are cosmetic; one failing hook must not break rendering.
49
+ children.length = 0;
50
+ children.push(...before);
45
51
  }
46
52
  }
47
53
  }