drexler 0.2.12 → 0.2.14

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,7 +1,13 @@
1
1
  import { Box, Text } from "ink";
2
2
  import { memo, useMemo } from "react";
3
3
  import { renderMarkdown } from "../renderer.ts";
4
+ import {
5
+ firstDisplayLine,
6
+ normalizeAssistantDisplayContent,
7
+ normalizeAssistantMarkdownRenderContent,
8
+ } from "./displayContent.ts";
4
9
  import { fitDisplayText } from "./graphemes.ts";
10
+ import { MarkdownBody } from "./MarkdownBody.tsx";
5
11
  import { useTheme } from "./ThemeContext.tsx";
6
12
 
7
13
  interface MessageItem {
@@ -30,12 +36,16 @@ function Separator() {
30
36
 
31
37
  function MessageInner({ role, content }: MessageItem) {
32
38
  const t = useTheme();
39
+ const displayContent =
40
+ role === "assistant"
41
+ ? normalizeAssistantMarkdownRenderContent(content)
42
+ : content;
33
43
  const assistantLines = useMemo(
34
44
  () =>
35
45
  role === "assistant"
36
- ? renderMarkdown(content).trimEnd().split("\n")
46
+ ? renderMarkdown(displayContent).trimEnd().split("\n")
37
47
  : [],
38
- [content, role],
48
+ [displayContent, role],
39
49
  );
40
50
 
41
51
  if (role === "user") {
@@ -111,12 +121,16 @@ interface StreamingProps {
111
121
 
112
122
  function StreamingMessageInner({ content, width = 80 }: StreamingProps) {
113
123
  const t = useTheme();
114
- const lines = useMemo(() => content.split("\n"), [content]);
115
124
  const safeWidth = Math.max(1, Math.floor(width));
116
- const contentWidth = Math.max(1, safeWidth - 3);
125
+ const innerWidth = Math.max(1, safeWidth - 2);
126
+ const compactDisplayContent = normalizeAssistantDisplayContent(content);
127
+ const markdownDisplayContent = normalizeAssistantMarkdownRenderContent(content);
117
128
 
118
129
  if (safeWidth < 18) {
119
- const compactLine = fitDisplayText(content.replace(/\s+/g, " "), safeWidth);
130
+ const compactLine = fitDisplayText(
131
+ firstDisplayLine(compactDisplayContent).replace(/\s+/g, " "),
132
+ safeWidth,
133
+ );
120
134
  return (
121
135
  <Box width={safeWidth} flexShrink={1}>
122
136
  <Text color={t.primaryLight} wrap="truncate">
@@ -135,16 +149,15 @@ function StreamingMessageInner({ content, width = 80 }: StreamingProps) {
135
149
  <Text color={t.primaryDim}> ─ </Text>
136
150
  <Text color={t.dim}>drafting live</Text>
137
151
  </Box>
138
- {lines.map((ln, i) => (
139
- <Box key={i} paddingLeft={1}>
140
- <Text color={i === lines.length - 1 ? t.primaryLight : t.primary}>
141
- {" "}
142
- </Text>
143
- <Text color={t.text} wrap="truncate">
144
- {fitDisplayText(ln, contentWidth)}
145
- </Text>
146
- </Box>
147
- ))}
152
+ <MarkdownBody
153
+ content={markdownDisplayContent}
154
+ baseColor={t.text}
155
+ accentColor={t.primaryLight}
156
+ dimColor={t.dim}
157
+ codeColor={t.primaryDim}
158
+ width={innerWidth}
159
+ paddingLeft={1}
160
+ />
148
161
  </Box>
149
162
  );
150
163
  }
@@ -1,6 +1,6 @@
1
1
  import { Box, Text } from "ink";
2
2
  import { useEffect, useState } from "react";
3
- import { fitDisplayText } from "./graphemes.ts";
3
+ import { displayWidth, fitDisplayText } from "./graphemes.ts";
4
4
  import { useTheme } from "./ThemeContext.tsx";
5
5
 
6
6
  const FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
@@ -47,8 +47,15 @@ export function Spinner({ label, width = 80 }: Props) {
47
47
  );
48
48
  }
49
49
 
50
- const labelBudget = Math.max(1, safeWidth - 22);
50
+ const innerWidth = Math.max(1, safeWidth - 4);
51
51
  const showStage = safeWidth >= 42;
52
+ const stageLabel = showStage ? ` · ${stage}` : "";
53
+ const secondsLabel = seconds > 0 && safeWidth >= 34 ? ` · ${seconds}s` : "";
54
+ const fixedWidth =
55
+ displayWidth(`${FRAMES[i]} WORKING ─ `) +
56
+ displayWidth(stageLabel) +
57
+ displayWidth(secondsLabel);
58
+ const labelBudget = Math.max(1, innerWidth - fixedWidth);
52
59
 
53
60
  return (
54
61
  <Box
@@ -66,13 +73,8 @@ export function Spinner({ label, width = 80 }: Props) {
66
73
  <Text color={t.text} wrap="truncate">
67
74
  {fitDisplayText(label, labelBudget)}
68
75
  </Text>
69
- {showStage ? <Text color={t.dim}> · {stage}</Text> : null}
70
- {seconds > 0 && safeWidth >= 34 ? (
71
- <>
72
- <Text color={t.primaryDim}> · </Text>
73
- <Text color={t.dim}>{seconds}s</Text>
74
- </>
75
- ) : null}
76
+ {stageLabel ? <Text color={t.dim}>{stageLabel}</Text> : null}
77
+ {secondsLabel ? <Text color={t.dim}>{secondsLabel}</Text> : null}
76
78
  </Box>
77
79
  );
78
80
  }
@@ -16,13 +16,6 @@ interface Props {
16
16
 
17
17
  const MAX_WITTICISM_LEN = 60;
18
18
 
19
- function clampText(input: string, max: number): string {
20
- if (input.length <= max) return input;
21
- if (max <= 0) return "";
22
- if (max === 1) return "…";
23
- return input.slice(0, max - 1) + "…";
24
- }
25
-
26
19
  function StatusBarInner({
27
20
  messageCount,
28
21
  witticism,
@@ -40,52 +33,27 @@ function StatusBarInner({
40
33
  }),
41
34
  [t.primaryLight, t.warning, t.error],
42
35
  );
36
+ const safeWidth =
37
+ typeof maxWidth === "number" ? Math.max(1, Math.floor(maxWidth)) : undefined;
43
38
  const countLabel = `${messageCount} message${messageCount === 1 ? "" : "s"}`;
44
- const hintLabel = scrollHint ? `${scrollHint}` : "";
45
- const quoteWidth =
46
- typeof maxWidth === "number"
47
- ? Math.max(
48
- 0,
49
- maxWidth -
50
- "● ".length -
51
- countLabel.length -
52
- hintLabel.length -
53
- " │ ".length -
54
- 2,
55
- )
56
- : MAX_WITTICISM_LEN;
57
- const safe = clampText(witticism, Math.min(MAX_WITTICISM_LEN, quoteWidth));
39
+ const quote = `"${fitDisplayText(witticism, MAX_WITTICISM_LEN)}"`;
40
+ const line = compact
41
+ ? `${countLabel}${scrollHint ? ` │ ${scrollHint}` : ""}`
42
+ : `${countLabel}${scrollHint ? ` │ ${scrollHint}` : ""} │ ${quote}`;
43
+ const body = fitDisplayText(line, Math.max(1, (safeWidth ?? 80) - 2));
58
44
  const box = compact ? (
59
45
  <Box>
60
46
  <Text color={dotColor[status]}>● </Text>
61
- <Text color={t.dim}>{countLabel}</Text>
62
- {scrollHint ? (
63
- <>
64
- <Text color={t.primaryDim}>{" │ "}</Text>
65
- <Text color={t.primaryLight}>
66
- {fitDisplayText(scrollHint, Math.max(1, maxWidth ?? 24))}
67
- </Text>
68
- </>
69
- ) : null}
47
+ <Text color={t.dim} wrap="truncate">{body}</Text>
70
48
  </Box>
71
49
  ) : (
72
50
  <Box>
73
51
  <Text color={dotColor[status]}>● </Text>
74
- <Text color={t.dim}>{countLabel}</Text>
75
- {scrollHint ? (
76
- <>
77
- <Text color={t.primaryDim}>{" │ "}</Text>
78
- <Text color={t.primaryLight}>{scrollHint}</Text>
79
- </>
80
- ) : null}
81
- <Text color={t.primaryDim}>{" │ "}</Text>
82
- <Text color={t.dim} italic>
83
- "{safe}"
84
- </Text>
52
+ <Text color={t.dim} italic wrap="truncate">{body}</Text>
85
53
  </Box>
86
54
  );
87
- if (typeof maxWidth === "number") {
88
- return <Box width={maxWidth}>{box}</Box>;
55
+ if (typeof safeWidth === "number") {
56
+ return <Box width={safeWidth}>{box}</Box>;
89
57
  }
90
58
  return box;
91
59
  }
@@ -16,8 +16,9 @@ export interface SynergyEventDefinition {
16
16
 
17
17
  export const SYNERGY_EVENT_FRAMES = 28;
18
18
  const FULL_EVENT_WIDTH = 88;
19
- const FULL_EVENT_ROWS = 12;
20
- const FULL_EVENT_ART_ROWS = 4;
19
+ const FULL_EVENT_ART_ROWS = 6;
20
+ const FULL_EVENT_CHROME_ROWS = 7;
21
+ const FULL_EVENT_ROWS = FULL_EVENT_CHROME_ROWS + FULL_EVENT_ART_ROWS;
21
22
 
22
23
  export const SYNERGY_EVENTS: readonly SynergyEventDefinition[] = [
23
24
  {
@@ -1,5 +1,11 @@
1
1
  import { Box, Text } from "ink";
2
2
  import { Children, memo, useMemo, type ReactNode } from "react";
3
+ import {
4
+ assistantDisplayLines,
5
+ firstDisplayLine,
6
+ normalizeAssistantDisplayContent,
7
+ type AssistantDisplayLine,
8
+ } from "./displayContent.ts";
3
9
  import { displayWidth, fitDisplayText, splitGraphemes } from "./graphemes.ts";
4
10
  import { useTheme } from "./ThemeContext.tsx";
5
11
 
@@ -41,6 +47,105 @@ const ROLE_MARKERS: Record<TranscriptViewportItem["role"], string> = {
41
47
  system: "!",
42
48
  };
43
49
 
50
+ const BODY_SUFFIX = " │";
51
+ const CONTINUATION_PREFIX = "│ ";
52
+ const CODE_GUTTER = "┃ ";
53
+ const DRACULA_CODE = {
54
+ text: "#f8f8f2",
55
+ keyword: "#ff79c6",
56
+ function: "#50fa7b",
57
+ string: "#f1fa8c",
58
+ number: "#bd93f9",
59
+ comment: "#6272a4",
60
+ operator: "#8be9fd",
61
+ gutter: "#6272a4",
62
+ };
63
+
64
+ interface WrappedTranscriptLine {
65
+ kind: AssistantDisplayLine["kind"];
66
+ text: string;
67
+ language?: string;
68
+ }
69
+
70
+ interface CodeToken {
71
+ kind:
72
+ | "plain"
73
+ | "keyword"
74
+ | "function"
75
+ | "string"
76
+ | "number"
77
+ | "comment"
78
+ | "operator";
79
+ text: string;
80
+ }
81
+
82
+ const CODE_KEYWORDS = new Set([
83
+ "and",
84
+ "as",
85
+ "async",
86
+ "await",
87
+ "break",
88
+ "case",
89
+ "catch",
90
+ "class",
91
+ "const",
92
+ "continue",
93
+ "def",
94
+ "default",
95
+ "do",
96
+ "elif",
97
+ "else",
98
+ "except",
99
+ "export",
100
+ "extends",
101
+ "false",
102
+ "finally",
103
+ "for",
104
+ "from",
105
+ "function",
106
+ "if",
107
+ "import",
108
+ "in",
109
+ "interface",
110
+ "let",
111
+ "new",
112
+ "none",
113
+ "not",
114
+ "null",
115
+ "or",
116
+ "pass",
117
+ "return",
118
+ "switch",
119
+ "throw",
120
+ "true",
121
+ "try",
122
+ "type",
123
+ "var",
124
+ "while",
125
+ "with",
126
+ "yield",
127
+ ]);
128
+
129
+ const CODE_OPERATOR_RE = /^[()[\]{}.,:;+\-*/%=<>!&|^~?]+/u;
130
+ const CODE_NUMBER_RE = /^\b(?:0x[\da-f]+|\d+(?:\.\d+)?)\b/iu;
131
+ const CODE_IDENTIFIER_RE = /^[A-Za-z_$][\w$]*/u;
132
+
133
+ function bodyPrefixForRole(role: TranscriptViewportItem["role"]): string {
134
+ if (role === "user") return "│ › ";
135
+ if (role === "assistant") return "│ ◆ ";
136
+ return CONTINUATION_PREFIX;
137
+ }
138
+
139
+ function transcriptContentWidth(
140
+ role: TranscriptViewportItem["role"],
141
+ cols: number,
142
+ ): number {
143
+ return Math.max(
144
+ 1,
145
+ cols - displayWidth(bodyPrefixForRole(role)) - displayWidth(BODY_SUFFIX),
146
+ );
147
+ }
148
+
44
149
  function wrapDisplayLine(input: string, maxWidth: number): string[] {
45
150
  const width = Math.max(1, maxWidth);
46
151
  if (input.length === 0) return [""];
@@ -91,10 +196,99 @@ function wrapDisplayLine(input: string, maxWidth: number): string[] {
91
196
  return rows.filter((row, index) => row.length > 0 || index === 0);
92
197
  }
93
198
 
94
- function wrappedContentRows(content: string, width: number): string[] {
95
- return content
96
- .split("\n")
97
- .flatMap((line) => wrapDisplayLine(line, width));
199
+ function displayContentForItem(item: TranscriptViewportItem): string {
200
+ if (item.role !== "assistant") return item.content;
201
+ return normalizeAssistantDisplayContent(item.content);
202
+ }
203
+
204
+ function displayLinesForItem(item: TranscriptViewportItem): AssistantDisplayLine[] {
205
+ if (item.role === "assistant") return assistantDisplayLines(item.content);
206
+ return item.content.split("\n").map((text) => ({ kind: "text", text }));
207
+ }
208
+
209
+ function wrappedTranscriptLines(
210
+ item: TranscriptViewportItem,
211
+ contentWidth: number,
212
+ ): WrappedTranscriptLine[] {
213
+ return displayLinesForItem(item).flatMap((line) => {
214
+ const width =
215
+ line.kind === "code"
216
+ ? Math.max(1, contentWidth - displayWidth(CODE_GUTTER))
217
+ : contentWidth;
218
+ return wrapDisplayLine(line.text, width).map((text) => ({
219
+ kind: line.kind,
220
+ text,
221
+ language: line.language,
222
+ }));
223
+ });
224
+ }
225
+
226
+ function tokenizeCodeLine(line: string): CodeToken[] {
227
+ const tokens: CodeToken[] = [];
228
+ let rest = line;
229
+
230
+ while (rest.length > 0) {
231
+ const comment =
232
+ rest.startsWith("#") || rest.startsWith("//") ? rest : undefined;
233
+ if (comment !== undefined) {
234
+ tokens.push({ kind: "comment", text: comment });
235
+ break;
236
+ }
237
+
238
+ const stringQuote = rest[0];
239
+ if (stringQuote === "\"" || stringQuote === "'" || stringQuote === "`") {
240
+ let end = 1;
241
+ let escaped = false;
242
+ while (end < rest.length) {
243
+ const char = rest[end];
244
+ if (escaped) {
245
+ escaped = false;
246
+ } else if (char === "\\") {
247
+ escaped = true;
248
+ } else if (char === stringQuote) {
249
+ end += 1;
250
+ break;
251
+ }
252
+ end += 1;
253
+ }
254
+ tokens.push({ kind: "string", text: rest.slice(0, end) });
255
+ rest = rest.slice(end);
256
+ continue;
257
+ }
258
+
259
+ const number = CODE_NUMBER_RE.exec(rest)?.[0];
260
+ if (number) {
261
+ tokens.push({ kind: "number", text: number });
262
+ rest = rest.slice(number.length);
263
+ continue;
264
+ }
265
+
266
+ const identifier = CODE_IDENTIFIER_RE.exec(rest)?.[0];
267
+ if (identifier) {
268
+ const after = rest.slice(identifier.length);
269
+ const kind =
270
+ CODE_KEYWORDS.has(identifier.toLowerCase())
271
+ ? "keyword"
272
+ : /^\s*\(/u.test(after)
273
+ ? "function"
274
+ : "plain";
275
+ tokens.push({ kind, text: identifier });
276
+ rest = rest.slice(identifier.length);
277
+ continue;
278
+ }
279
+
280
+ const operator = CODE_OPERATOR_RE.exec(rest)?.[0];
281
+ if (operator) {
282
+ tokens.push({ kind: "operator", text: operator });
283
+ rest = rest.slice(operator.length);
284
+ continue;
285
+ }
286
+
287
+ tokens.push({ kind: "plain", text: rest[0]! });
288
+ rest = rest.slice(1);
289
+ }
290
+
291
+ return tokens;
98
292
  }
99
293
 
100
294
  function itemRows(
@@ -103,13 +297,8 @@ function itemRows(
103
297
  cols: number,
104
298
  ): number {
105
299
  if (compact) return 1;
106
- const bodyPrefix = "│ › ";
107
- const bodySuffix = " │";
108
- const contentWidth = Math.max(
109
- 1,
110
- cols - displayWidth(bodyPrefix) - displayWidth(bodySuffix),
111
- );
112
- return 2 + wrappedContentRows(item.content, contentWidth).length;
300
+ const contentWidth = transcriptContentWidth(item.role, cols);
301
+ return 2 + wrappedTranscriptLines(item, contentWidth).length;
113
302
  }
114
303
 
115
304
  function roleAccentColor(
@@ -145,12 +334,39 @@ function DefaultTranscriptItem({
145
334
  const t = useTheme();
146
335
  const label = ROLE_LABELS[item.role];
147
336
  const accent = roleAccentColor(item.role, t);
337
+ const renderCodeLine = (line: string) =>
338
+ tokenizeCodeLine(line).map((token, tokenIndex) => {
339
+ const color =
340
+ token.kind === "keyword"
341
+ ? DRACULA_CODE.keyword
342
+ : token.kind === "function"
343
+ ? DRACULA_CODE.function
344
+ : token.kind === "string"
345
+ ? DRACULA_CODE.string
346
+ : token.kind === "number" || token.kind === "operator"
347
+ ? token.kind === "number"
348
+ ? DRACULA_CODE.number
349
+ : DRACULA_CODE.operator
350
+ : token.kind === "comment"
351
+ ? DRACULA_CODE.comment
352
+ : DRACULA_CODE.text;
353
+ return (
354
+ <Text
355
+ key={tokenIndex}
356
+ color={color}
357
+ bold={token.kind === "keyword" || token.kind === "function"}
358
+ italic={token.kind === "comment"}
359
+ >
360
+ {token.text}
361
+ </Text>
362
+ );
363
+ });
148
364
 
149
365
  if (compact) {
150
366
  const marker = item.role === "assistant" ? "◆" : ROLE_MARKERS[item.role];
151
367
  const prefix = `${label} ${marker} `;
152
368
  const budget = Math.max(1, cols - displayWidth(prefix));
153
- const firstLine = item.content.split("\n")[0] ?? "";
369
+ const firstLine = firstDisplayLine(displayContentForItem(item));
154
370
  return (
155
371
  <Box width={cols} flexShrink={1}>
156
372
  <Text color={accent} bold>
@@ -168,13 +384,9 @@ function DefaultTranscriptItem({
168
384
  const headerPrefix = `╭─ ${label} `;
169
385
  const headerRuleWidth = Math.max(0, cols - displayWidth(headerPrefix) - 1);
170
386
  const footerWidth = Math.max(0, cols - 2);
171
- const bodyPrefix = item.role === "user" ? "│ › " : "│ ";
172
- const continuationPrefix = "│ ";
173
- const bodySuffix = " │";
174
- const contentWidth = Math.max(
175
- 1,
176
- cols - displayWidth(bodyPrefix) - displayWidth(bodySuffix),
177
- );
387
+ const bodyPrefix = bodyPrefixForRole(item.role);
388
+ const contentWidth = transcriptContentWidth(item.role, cols);
389
+ const displayLines = wrappedTranscriptLines(item, contentWidth);
178
390
 
179
391
  return (
180
392
  <Box flexDirection="column" width={cols} flexShrink={1}>
@@ -184,18 +396,39 @@ function DefaultTranscriptItem({
184
396
  cols,
185
397
  )}
186
398
  </Text>
187
- {wrappedContentRows(item.content, contentWidth).map((line, index) => (
399
+ {displayLines.map((line, index) => (
188
400
  <Box key={index} width={cols} flexShrink={1}>
189
401
  <Text color={accent} bold={item.role === "user"}>
190
- {index === 0 ? bodyPrefix : continuationPrefix}
402
+ {index === 0 ? bodyPrefix : CONTINUATION_PREFIX}
191
403
  </Text>
192
- <Text color={roleBodyColor(item.role, t)}>
193
- {fitDisplayText(line, contentWidth)}
404
+ {line.kind === "code" ? (
405
+ <Text color={DRACULA_CODE.gutter}>{CODE_GUTTER}</Text>
406
+ ) : null}
407
+ <Text
408
+ color={
409
+ line.kind === "code"
410
+ ? DRACULA_CODE.text
411
+ : roleBodyColor(item.role, t)
412
+ }
413
+ >
414
+ {line.kind === "code"
415
+ ? renderCodeLine(
416
+ fitDisplayText(
417
+ line.text,
418
+ Math.max(1, contentWidth - displayWidth(CODE_GUTTER)),
419
+ ),
420
+ )
421
+ : fitDisplayText(line.text, contentWidth)}
194
422
  </Text>
195
423
  <Text color={accent} bold={item.role === "user"}>
196
424
  {`${" ".repeat(
197
- Math.max(0, contentWidth - displayWidth(line)),
198
- )}${bodySuffix}`}
425
+ Math.max(
426
+ 0,
427
+ contentWidth -
428
+ displayWidth(line.text) -
429
+ (line.kind === "code" ? displayWidth(CODE_GUTTER) : 0),
430
+ ),
431
+ )}${BODY_SUFFIX}`}
199
432
  </Text>
200
433
  </Box>
201
434
  ))}
@@ -275,11 +508,19 @@ function selectWindow(
275
508
  reserveTop = nextReserveTop;
276
509
  }
277
510
 
278
- return {
279
- visible: entries.slice(start, end),
280
- hiddenBefore: start,
281
- hiddenAfter: entries.length - end,
282
- };
511
+ const visible = entries.slice(start, end);
512
+ const visibleRows = visible.reduce(
513
+ (sum, entry) => sum + Math.max(1, entry.estimatedRows),
514
+ 0,
515
+ );
516
+ let hiddenBefore = start;
517
+ let hiddenAfter = entries.length - end;
518
+ if (visibleRows + (hiddenBefore > 0 ? 1 : 0) + (hiddenAfter > 0 ? 1 : 0) > safeRows) {
519
+ if (hiddenBefore > 0) hiddenBefore = 0;
520
+ if (visibleRows + (hiddenAfter > 0 ? 1 : 0) > safeRows) hiddenAfter = 0;
521
+ }
522
+
523
+ return { visible, hiddenBefore, hiddenAfter };
283
524
  }
284
525
 
285
526
  function ScrollIndicator({
@@ -0,0 +1,114 @@
1
+ const FENCE_OPEN_RE = /^[ \t]*(`{3,}|~{3,})([^`~\n\r]*)$/u;
2
+ const TAB_DISPLAY = " ";
3
+
4
+ export interface AssistantDisplayLine {
5
+ kind: "text" | "code";
6
+ text: string;
7
+ language?: string;
8
+ }
9
+
10
+ function isFenceClose(line: string, fenceChar: string, minLength: number): boolean {
11
+ const trimmed = line.trim();
12
+ if (trimmed.length < minLength) return false;
13
+ for (const char of trimmed) {
14
+ if (char !== fenceChar) return false;
15
+ }
16
+ return true;
17
+ }
18
+
19
+ function isMarkdownFence(info: string): boolean {
20
+ const lang = info.trim().split(/\s+/u)[0]?.toLowerCase() ?? "";
21
+ return lang === "markdown" || lang === "md" || lang === "mdown";
22
+ }
23
+
24
+ function fenceLanguage(info: string): string | undefined {
25
+ const lang = info.trim().split(/\s+/u)[0]?.toLowerCase();
26
+ return lang && !isMarkdownFence(lang) ? lang : undefined;
27
+ }
28
+
29
+ export function assistantDisplayLines(content: string): AssistantDisplayLine[] {
30
+ const lines = content.replace(/\r\n?/gu, "\n").split("\n");
31
+ const output: AssistantDisplayLine[] = [];
32
+ let fenceChar = "";
33
+ let fenceLength = 0;
34
+ let fenceKind: AssistantDisplayLine["kind"] = "text";
35
+ let fenceLang: string | undefined;
36
+
37
+ for (const rawLine of lines) {
38
+ const line = rawLine.replace(/\t/gu, TAB_DISPLAY);
39
+ if (fenceChar.length > 0) {
40
+ if (isFenceClose(line, fenceChar, fenceLength)) {
41
+ fenceChar = "";
42
+ fenceLength = 0;
43
+ fenceKind = "text";
44
+ fenceLang = undefined;
45
+ } else {
46
+ output.push({ kind: fenceKind, text: line, language: fenceLang });
47
+ }
48
+ continue;
49
+ }
50
+
51
+ const openingFence = FENCE_OPEN_RE.exec(line);
52
+ if (openingFence) {
53
+ const marker = openingFence[1]!;
54
+ const info = openingFence[2] ?? "";
55
+ fenceChar = marker[0]!;
56
+ fenceLength = marker.length;
57
+ fenceKind = isMarkdownFence(info) ? "text" : "code";
58
+ fenceLang = fenceKind === "code" ? fenceLanguage(info) : undefined;
59
+ continue;
60
+ }
61
+
62
+ output.push({ kind: "text", text: line });
63
+ }
64
+
65
+ return output;
66
+ }
67
+
68
+ export function normalizeAssistantDisplayContent(content: string): string {
69
+ return assistantDisplayLines(content)
70
+ .map((line) => line.text)
71
+ .join("\n");
72
+ }
73
+
74
+ export function normalizeAssistantMarkdownRenderContent(content: string): string {
75
+ const lines = content.replace(/\r\n?/gu, "\n").split("\n");
76
+ const output: string[] = [];
77
+ let fenceChar = "";
78
+ let fenceLength = 0;
79
+ let markdownFence = false;
80
+
81
+ for (const rawLine of lines) {
82
+ const line = rawLine.replace(/\t/gu, TAB_DISPLAY);
83
+ if (fenceChar.length > 0) {
84
+ if (isFenceClose(line, fenceChar, fenceLength)) {
85
+ if (!markdownFence) output.push("```");
86
+ fenceChar = "";
87
+ fenceLength = 0;
88
+ markdownFence = false;
89
+ } else {
90
+ output.push(line);
91
+ }
92
+ continue;
93
+ }
94
+
95
+ const openingFence = FENCE_OPEN_RE.exec(line);
96
+ if (openingFence) {
97
+ const marker = openingFence[1]!;
98
+ const info = openingFence[2] ?? "";
99
+ fenceChar = marker[0]!;
100
+ fenceLength = marker.length;
101
+ markdownFence = isMarkdownFence(info);
102
+ if (!markdownFence) output.push("```");
103
+ continue;
104
+ }
105
+
106
+ output.push(line);
107
+ }
108
+
109
+ return output.join("\n");
110
+ }
111
+
112
+ export function firstDisplayLine(content: string): string {
113
+ return content.split("\n").find((line) => line.trim().length > 0) ?? "";
114
+ }
@@ -35,6 +35,7 @@ function isWideCodePoint(codePoint: number): boolean {
35
35
  function graphemeWidth(input: string): number {
36
36
  if (input.length === 0) return 0;
37
37
  if (/^\p{Mark}+$/u.test(input)) return 0;
38
+ if (/^[©®™]$/u.test(input)) return 1;
38
39
  if (/\p{Extended_Pictographic}/u.test(input)) return 2;
39
40
  let width = 0;
40
41
  for (const char of input) {