yarn-spinner-runner-ts 0.1.4 → 0.1.5-a

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 (54) hide show
  1. package/README.md +44 -16
  2. package/dist/compile/compiler.js +2 -2
  3. package/dist/compile/compiler.js.map +1 -1
  4. package/dist/compile/ir.d.ts +1 -0
  5. package/dist/markup/parser.js +12 -2
  6. package/dist/markup/parser.js.map +1 -1
  7. package/dist/model/ast.d.ts +1 -0
  8. package/dist/parse/lexer.js +5 -5
  9. package/dist/parse/lexer.js.map +1 -1
  10. package/dist/parse/parser.js +12 -2
  11. package/dist/parse/parser.js.map +1 -1
  12. package/dist/react/DialogueExample.js +12 -10
  13. package/dist/react/DialogueExample.js.map +1 -1
  14. package/dist/react/DialogueView.d.ts +10 -4
  15. package/dist/react/DialogueView.js +38 -12
  16. package/dist/react/DialogueView.js.map +1 -1
  17. package/dist/react/MarkupRenderer.js +1 -1
  18. package/dist/react/MarkupRenderer.js.map +1 -1
  19. package/dist/react/useYarnRunner.js +63 -10
  20. package/dist/react/useYarnRunner.js.map +1 -1
  21. package/dist/runtime/evaluator.d.ts +3 -0
  22. package/dist/runtime/evaluator.js +165 -3
  23. package/dist/runtime/evaluator.js.map +1 -1
  24. package/dist/runtime/runner.d.ts +2 -0
  25. package/dist/runtime/runner.js +66 -10
  26. package/dist/runtime/runner.js.map +1 -1
  27. package/dist/tests/dialogue_view.test.d.ts +1 -0
  28. package/dist/tests/dialogue_view.test.js +18 -0
  29. package/dist/tests/dialogue_view.test.js.map +1 -0
  30. package/dist/tests/markup.test.js +7 -0
  31. package/dist/tests/markup.test.js.map +1 -1
  32. package/dist/tests/options.test.js +164 -9
  33. package/dist/tests/options.test.js.map +1 -1
  34. package/dist/tests/variables_flow_cmds.test.js +117 -10
  35. package/dist/tests/variables_flow_cmds.test.js.map +1 -1
  36. package/docs/markup.md +33 -33
  37. package/eslint.config.cjs +39 -39
  38. package/package.json +6 -6
  39. package/src/compile/compiler.ts +2 -2
  40. package/src/compile/ir.ts +1 -1
  41. package/src/markup/parser.ts +53 -43
  42. package/src/model/ast.ts +1 -0
  43. package/src/parse/lexer.ts +18 -18
  44. package/src/parse/parser.ts +33 -22
  45. package/src/react/DialogueExample.tsx +16 -14
  46. package/src/react/DialogueView.tsx +312 -275
  47. package/src/react/MarkupRenderer.tsx +1 -2
  48. package/src/react/useYarnRunner.tsx +101 -34
  49. package/src/runtime/evaluator.ts +224 -47
  50. package/src/runtime/runner.ts +102 -37
  51. package/src/tests/dialogue_view.test.tsx +26 -0
  52. package/src/tests/markup.test.ts +17 -1
  53. package/src/tests/options.test.ts +206 -36
  54. package/src/tests/variables_flow_cmds.test.ts +139 -28
@@ -1,275 +1,312 @@
1
- import React, { useRef, useEffect, useState } from "react";
2
- import type { RuntimeResult } from "../runtime/results.js";
3
- import { DialogueScene } from "./DialogueScene.js";
4
- import type { SceneCollection } from "../scene/types.js";
5
- import { TypingText } from "./TypingText.js";
6
- import { MarkupRenderer } from "./MarkupRenderer.js";
7
- // Note: CSS is imported in the browser demo entry point (examples/browser/main.tsx)
8
- // This prevents Node.js from trying to resolve CSS imports during tests
9
-
10
- export interface DialogueViewProps {
11
- result: RuntimeResult | null;
12
- onAdvance: (optionIndex?: number) => void;
13
- className?: string;
14
- scenes?: SceneCollection;
15
- actorTransitionDuration?: number;
16
- // Typing animation options
17
- enableTypingAnimation?: boolean;
18
- typingSpeed?: number;
19
- showTypingCursor?: boolean;
20
- cursorCharacter?: string;
21
- // Auto-advance after typing completes
22
- autoAdvanceAfterTyping?: boolean;
23
- autoAdvanceDelay?: number; // Delay in ms after typing completes before auto-advancing
24
- // Pause before advance
25
- pauseBeforeAdvance?: number; // Delay in ms before advancing when clicking (0 = no pause)
26
- }
27
-
28
- // Helper to parse CSS string into object
29
- function parseCss(cssStr: string | undefined): React.CSSProperties {
30
- if (!cssStr) return {};
31
- const styles: React.CSSProperties = {};
32
- // Improved parser: handles quoted values and commas
33
- // Split by semicolon, but preserve quoted strings
34
- const rules: string[] = [];
35
- let currentRule = "";
36
- let inQuotes = false;
37
- let quoteChar = "";
38
-
39
- for (let i = 0; i < cssStr.length; i++) {
40
- const char = cssStr[i];
41
- if ((char === '"' || char === "'") && !inQuotes) {
42
- inQuotes = true;
43
- quoteChar = char;
44
- currentRule += char;
45
- } else if (char === quoteChar && inQuotes) {
46
- inQuotes = false;
47
- quoteChar = "";
48
- currentRule += char;
49
- } else if (char === ";" && !inQuotes) {
50
- rules.push(currentRule.trim());
51
- currentRule = "";
52
- } else {
53
- currentRule += char;
54
- }
55
- }
56
- if (currentRule.trim()) {
57
- rules.push(currentRule.trim());
58
- }
59
-
60
- rules.forEach((rule) => {
61
- if (!rule) return;
62
- const colonIndex = rule.indexOf(":");
63
- if (colonIndex === -1) return;
64
- const prop = rule.slice(0, colonIndex).trim();
65
- const value = rule.slice(colonIndex + 1).trim();
66
- if (prop && value) {
67
- // Convert kebab-case to camelCase
68
- const camelProp = prop.replace(/-([a-z])/g, (g) => g[1].toUpperCase());
69
- // Remove quotes from value if present, and strip !important (React doesn't support it)
70
- let cleanValue = value.trim();
71
- if (cleanValue.endsWith("!important")) {
72
- cleanValue = cleanValue.slice(0, -10).trim();
73
- }
74
- if (cleanValue.startsWith('"') && cleanValue.endsWith('"')) {
75
- cleanValue = cleanValue.slice(1, -1);
76
- } else if (cleanValue.startsWith("'") && cleanValue.endsWith("'")) {
77
- cleanValue = cleanValue.slice(1, -1);
78
- }
79
- (styles as any)[camelProp] = cleanValue;
80
- }
81
- });
82
- return styles;
83
- }
84
-
85
- export function DialogueView({
86
- result,
87
- onAdvance,
88
- className,
89
- scenes,
90
- actorTransitionDuration = 350,
91
- enableTypingAnimation = false,
92
- typingSpeed = 50, // Characters per second (50 cps = ~20ms per character)
93
- showTypingCursor = true,
94
- cursorCharacter = "|",
95
- autoAdvanceAfterTyping = false,
96
- autoAdvanceDelay = 500,
97
- pauseBeforeAdvance = 0,
98
- }: DialogueViewProps) {
99
- const sceneName = result?.type === "text" || result?.type === "options" ? result.scene : undefined;
100
- const speaker = result?.type === "text" ? result.speaker : undefined;
101
- const sceneCollection = scenes || { scenes: {} };
102
- const [typingComplete, setTypingComplete] = useState(false);
103
- const [currentTextKey, setCurrentTextKey] = useState(0);
104
- const [skipTyping, setSkipTyping] = useState(false);
105
- const advanceTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
106
-
107
- // Reset typing completion when text changes
108
- useEffect(() => {
109
- if (result?.type === "text") {
110
- setTypingComplete(false);
111
- setSkipTyping(false);
112
- setCurrentTextKey((prev) => prev + 1); // Force re-render of TypingText
113
- }
114
- // Cleanup any pending advance timeouts when text changes
115
- return () => {
116
- if (advanceTimeoutRef.current) {
117
- clearTimeout(advanceTimeoutRef.current);
118
- advanceTimeoutRef.current = null;
119
- }
120
- };
121
- }, [result?.type === "text" ? result.text : null]);
122
-
123
- // Handle auto-advance after typing completes
124
- useEffect(() => {
125
- if (
126
- autoAdvanceAfterTyping &&
127
- typingComplete &&
128
- result?.type === "text" &&
129
- !result.isDialogueEnd
130
- ) {
131
- const timer = setTimeout(() => {
132
- onAdvance();
133
- }, autoAdvanceDelay);
134
- return () => clearTimeout(timer);
135
- }
136
- }, [autoAdvanceAfterTyping, typingComplete, result, onAdvance, autoAdvanceDelay]);
137
-
138
- if (!result) {
139
- return (
140
- <div className={`yd-empty ${className || ""}`}>
141
- <p>Dialogue ended or not started.</p>
142
- </div>
143
- );
144
- }
145
-
146
- if (result.type === "text") {
147
- const nodeStyles = parseCss(result.nodeCss);
148
- const displayText = result.text || "\u00A0";
149
- const shouldShowContinue = !result.isDialogueEnd && !enableTypingAnimation;
150
-
151
- const handleClick = () => {
152
- if (result.isDialogueEnd) return;
153
-
154
- // If typing is in progress, skip it; otherwise advance
155
- if (enableTypingAnimation && !typingComplete) {
156
- // Skip typing animation
157
- setSkipTyping(true);
158
- setTypingComplete(true);
159
- } else {
160
- // Clear any pending timeout
161
- if (advanceTimeoutRef.current) {
162
- clearTimeout(advanceTimeoutRef.current);
163
- advanceTimeoutRef.current = null;
164
- }
165
-
166
- // Apply pause before advance if configured
167
- if (pauseBeforeAdvance > 0) {
168
- advanceTimeoutRef.current = setTimeout(() => {
169
- onAdvance();
170
- advanceTimeoutRef.current = null;
171
- }, pauseBeforeAdvance);
172
- } else {
173
- onAdvance();
174
- }
175
- }
176
- };
177
-
178
- return (
179
- <div className="yd-container">
180
- <DialogueScene
181
- sceneName={sceneName}
182
- speaker={speaker}
183
- scenes={sceneCollection}
184
- actorTransitionDuration={actorTransitionDuration}
185
- />
186
- <div
187
- className={`yd-dialogue-box ${result.isDialogueEnd ? "yd-text-box-end" : ""} ${className || ""}`}
188
- style={nodeStyles} // Only apply dynamic node CSS
189
- onClick={handleClick}
190
- >
191
- <div className="yd-text-box">
192
- {result.speaker && (
193
- <div className="yd-speaker">
194
- {result.speaker}
195
- </div>
196
- )}
197
- <p className={`yd-text ${result.speaker ? "yd-text-with-speaker" : ""}`}>
198
- {enableTypingAnimation ? (
199
- <TypingText
200
- key={currentTextKey}
201
- text={displayText}
202
- markup={result.markup}
203
- typingSpeed={typingSpeed}
204
- showCursor={showTypingCursor}
205
- cursorCharacter={cursorCharacter}
206
- disabled={skipTyping}
207
- onComplete={() => setTypingComplete(true)}
208
- />
209
- ) : (
210
- <MarkupRenderer text={displayText} markup={result.markup} />
211
- )}
212
- </p>
213
- {shouldShowContinue && (
214
- <div className="yd-continue">
215
-
216
- </div>
217
- )}
218
- </div>
219
- </div>
220
- </div>
221
- );
222
- }
223
-
224
- if (result.type === "options") {
225
- const nodeStyles = parseCss(result.nodeCss);
226
- return (
227
- <div className="yd-container">
228
- <DialogueScene
229
- sceneName={sceneName}
230
- speaker={speaker}
231
- scenes={sceneCollection}
232
- actorTransitionDuration={actorTransitionDuration}
233
- />
234
- <div className={`yd-options-container ${className || ""}`}>
235
- <div className="yd-options-box" style={nodeStyles}>
236
- <div className="yd-options-title">Choose an option:</div>
237
- <div className="yd-options-list">
238
- {result.options.map((option, index) => {
239
- const optionStyles = parseCss(option.css);
240
- return (
241
- <button
242
- key={index}
243
- className="yd-option-button"
244
- onClick={() => onAdvance(index)}
245
- style={optionStyles} // Only apply dynamic option CSS
246
- >
247
- <MarkupRenderer text={option.text} markup={option.markup} />
248
- </button>
249
- );
250
- })}
251
- </div>
252
- </div>
253
- </div>
254
- </div>
255
- );
256
- }
257
-
258
- // Command result - auto-advance
259
- if (result.type === "command") {
260
- // Auto-advance commands after a brief moment
261
- React.useEffect(() => {
262
- const timer = setTimeout(() => onAdvance(), 50);
263
- return () => clearTimeout(timer);
264
- }, [result.command, onAdvance]);
265
-
266
- return (
267
- <div className={`yd-command ${className || ""}`}>
268
- <p>Executing: {result.command}</p>
269
- </div>
270
- );
271
- }
272
-
273
- return null;
274
- }
275
-
1
+ import React, { useRef, useEffect, useState } from "react";
2
+ import { DialogueScene } from "./DialogueScene.js";
3
+ import type { SceneCollection } from "../scene/types.js";
4
+ import { TypingText } from "./TypingText.js";
5
+ import { useYarnRunner } from "./useYarnRunner.js";
6
+ import { MarkupRenderer } from "./MarkupRenderer.js";
7
+ // Note: CSS is imported in the browser demo entry point (examples/browser/main.tsx)
8
+ // This prevents Node.js from trying to resolve CSS imports during tests
9
+
10
+ import type { IRProgram } from "../compile/ir.js";
11
+
12
+ export interface DialogueViewProps {
13
+ program: IRProgram;
14
+ startNode?: string;
15
+ className?: string;
16
+ scenes?: SceneCollection;
17
+ actorTransitionDuration?: number;
18
+ // Custom functions and callbacks
19
+ functions?: Record<string, (...args: unknown[]) => unknown>;
20
+ variables?: Record<string, unknown>;
21
+ onStoryEnd?: (info: { variables: Readonly<Record<string, unknown>>; storyEnd: true }) => void;
22
+ // Typing animation options
23
+ enableTypingAnimation?: boolean;
24
+ typingSpeed?: number;
25
+ showTypingCursor?: boolean;
26
+ cursorCharacter?: string;
27
+ // Auto-advance after typing completes
28
+ autoAdvanceAfterTyping?: boolean;
29
+ autoAdvanceDelay?: number; // Delay in ms after typing completes before auto-advancing
30
+ // Pause before advance
31
+ pauseBeforeAdvance?: number; // Delay in ms before advancing when clicking (0 = no pause)
32
+ }
33
+
34
+ // Helper to parse CSS string into object
35
+ function parseCss(cssStr: string | undefined): React.CSSProperties {
36
+ if (!cssStr) return {};
37
+ const styles: React.CSSProperties = {};
38
+ // Improved parser: handles quoted values and commas
39
+ // Split by semicolon, but preserve quoted strings
40
+ const rules: string[] = [];
41
+ let currentRule = "";
42
+ let inQuotes = false;
43
+ let quoteChar = "";
44
+
45
+ for (let i = 0; i < cssStr.length; i++) {
46
+ const char = cssStr[i];
47
+ if ((char === '"' || char === "'") && !inQuotes) {
48
+ inQuotes = true;
49
+ quoteChar = char;
50
+ currentRule += char;
51
+ } else if (char === quoteChar && inQuotes) {
52
+ inQuotes = false;
53
+ quoteChar = "";
54
+ currentRule += char;
55
+ } else if (char === ";" && !inQuotes) {
56
+ rules.push(currentRule.trim());
57
+ currentRule = "";
58
+ } else {
59
+ currentRule += char;
60
+ }
61
+ }
62
+ if (currentRule.trim()) {
63
+ rules.push(currentRule.trim());
64
+ }
65
+
66
+ rules.forEach((rule) => {
67
+ if (!rule) return;
68
+ const colonIndex = rule.indexOf(":");
69
+ if (colonIndex === -1) return;
70
+ const prop = rule.slice(0, colonIndex).trim();
71
+ const value = rule.slice(colonIndex + 1).trim();
72
+ if (prop && value) {
73
+ // Convert kebab-case to camelCase
74
+ const camelProp = prop.replace(/-([a-z])/g, (g) => g[1].toUpperCase());
75
+ // Remove quotes from value if present, and strip !important (React doesn't support it)
76
+ let cleanValue = value.trim();
77
+ if (cleanValue.endsWith("!important")) {
78
+ cleanValue = cleanValue.slice(0, -10).trim();
79
+ }
80
+ if (cleanValue.startsWith('"') && cleanValue.endsWith('"')) {
81
+ cleanValue = cleanValue.slice(1, -1);
82
+ } else if (cleanValue.startsWith("'") && cleanValue.endsWith("'")) {
83
+ cleanValue = cleanValue.slice(1, -1);
84
+ }
85
+ (styles as any)[camelProp] = cleanValue;
86
+ }
87
+ });
88
+ return styles;
89
+ }
90
+
91
+ export function DialogueView({
92
+ program,
93
+ startNode = "Start",
94
+ className,
95
+ scenes,
96
+ actorTransitionDuration = 350,
97
+ functions,
98
+ variables,
99
+ onStoryEnd,
100
+ enableTypingAnimation = false,
101
+ typingSpeed = 50, // Characters per second (50 cps = ~20ms per character)
102
+ showTypingCursor = true,
103
+ cursorCharacter = "|",
104
+ autoAdvanceAfterTyping = false,
105
+ autoAdvanceDelay = 500,
106
+ pauseBeforeAdvance = 0,
107
+ }: DialogueViewProps) {
108
+ const { result, advance, runner } = useYarnRunner(program, {
109
+ startAt: startNode,
110
+ functions,
111
+ variables,
112
+ });
113
+ const sceneName = result?.type === "text" || result?.type === "options" ? result.scene : undefined;
114
+ const speaker = result?.type === "text" ? result.speaker : undefined;
115
+ const sceneCollection = scenes || { scenes: {} };
116
+
117
+ const [typingComplete, setTypingComplete] = useState(false);
118
+ const [currentTextKey, setCurrentTextKey] = useState(0);
119
+ const [skipTyping, setSkipTyping] = useState(false);
120
+ const advanceTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
121
+ const storyEndTriggeredRef = useRef(false);
122
+
123
+ useEffect(() => {
124
+ storyEndTriggeredRef.current = false;
125
+ }, [program, startNode]);
126
+
127
+ useEffect(() => {
128
+ if (!result || result.type !== "command") {
129
+ return;
130
+ }
131
+ const timer = setTimeout(() => advance(), 50);
132
+ return () => clearTimeout(timer);
133
+ }, [result, advance]);
134
+
135
+ useEffect(() => {
136
+ if (!onStoryEnd || !result || storyEndTriggeredRef.current) {
137
+ return;
138
+ }
139
+ if (!result.isDialogueEnd) {
140
+ return;
141
+ }
142
+ if (result.type === "options") {
143
+ return;
144
+ }
145
+
146
+ storyEndTriggeredRef.current = true;
147
+ const variablesSnapshot = Object.freeze({ ...(runner?.getVariables?.() ?? {}) });
148
+ onStoryEnd({ storyEnd: true, variables: variablesSnapshot });
149
+ }, [result, onStoryEnd, runner]);
150
+
151
+ // Reset typing completion when text changes
152
+ useEffect(() => {
153
+ if (result?.type === "text") {
154
+ setTypingComplete(false);
155
+ setSkipTyping(false);
156
+ setCurrentTextKey((prev) => prev + 1); // Force re-render of TypingText
157
+ }
158
+ // Cleanup any pending advance timeouts when text changes
159
+ return () => {
160
+ if (advanceTimeoutRef.current) {
161
+ clearTimeout(advanceTimeoutRef.current);
162
+ advanceTimeoutRef.current = null;
163
+ }
164
+ };
165
+ }, [result?.type === "text" ? result.text : null]);
166
+
167
+ // Handle auto-advance after typing completes
168
+ useEffect(() => {
169
+ if (
170
+ autoAdvanceAfterTyping &&
171
+ typingComplete &&
172
+ result?.type === "text" &&
173
+ !result.isDialogueEnd
174
+ ) {
175
+ const timer = setTimeout(() => {
176
+ advance();
177
+ }, autoAdvanceDelay);
178
+ return () => clearTimeout(timer);
179
+ }
180
+ }, [autoAdvanceAfterTyping, typingComplete, result, advance, autoAdvanceDelay]);
181
+
182
+ if (!result) {
183
+ return (
184
+ <div className={`yd-empty ${className || ""}`}>
185
+ <p>Dialogue ended or not started.</p>
186
+ </div>
187
+ );
188
+ }
189
+
190
+ if (result.type === "text") {
191
+ const nodeStyles = parseCss(result.nodeCss);
192
+ const displayText = result.text || "\u00A0";
193
+ const shouldShowContinue = !result.isDialogueEnd && !enableTypingAnimation;
194
+
195
+ const handleClick = () => {
196
+ if (result.isDialogueEnd) return;
197
+
198
+ // If typing is in progress, skip it; otherwise advance
199
+ if (enableTypingAnimation && !typingComplete) {
200
+ // Skip typing animation
201
+ setSkipTyping(true);
202
+ setTypingComplete(true);
203
+ } else {
204
+ // Clear any pending timeout
205
+ if (advanceTimeoutRef.current) {
206
+ clearTimeout(advanceTimeoutRef.current);
207
+ advanceTimeoutRef.current = null;
208
+ }
209
+
210
+ // Apply pause before advance if configured
211
+ if (pauseBeforeAdvance > 0) {
212
+ advanceTimeoutRef.current = setTimeout(() => {
213
+ advance();
214
+ advanceTimeoutRef.current = null;
215
+ }, pauseBeforeAdvance);
216
+ } else {
217
+ advance();
218
+ }
219
+ }
220
+ };
221
+
222
+ return (
223
+ <div className="yd-container">
224
+ <DialogueScene
225
+ sceneName={sceneName}
226
+ speaker={speaker}
227
+ scenes={sceneCollection}
228
+ actorTransitionDuration={actorTransitionDuration}
229
+ />
230
+ <div
231
+ className={`yd-dialogue-box ${result.isDialogueEnd ? "yd-text-box-end" : ""} ${className || ""}`}
232
+ style={nodeStyles} // Only apply dynamic node CSS
233
+ onClick={handleClick}
234
+ >
235
+ <div className="yd-text-box">
236
+ {result.speaker && (
237
+ <div className="yd-speaker">
238
+ {result.speaker}
239
+ </div>
240
+ )}
241
+ <p className={`yd-text ${result.speaker ? "yd-text-with-speaker" : ""}`}>
242
+ {enableTypingAnimation ? (
243
+ <TypingText
244
+ key={currentTextKey}
245
+ text={displayText}
246
+ markup={result.markup}
247
+ typingSpeed={typingSpeed}
248
+ showCursor={showTypingCursor}
249
+ cursorCharacter={cursorCharacter}
250
+ disabled={skipTyping}
251
+ onComplete={() => setTypingComplete(true)}
252
+ />
253
+ ) : (
254
+ <MarkupRenderer text={displayText} markup={result.markup} />
255
+ )}
256
+ </p>
257
+ {shouldShowContinue && (
258
+ <div className="yd-continue">
259
+
260
+ </div>
261
+ )}
262
+ </div>
263
+ </div>
264
+ </div>
265
+ );
266
+ }
267
+
268
+ if (result.type === "options") {
269
+ const nodeStyles = parseCss(result.nodeCss);
270
+ return (
271
+ <div className="yd-container">
272
+ <DialogueScene
273
+ sceneName={sceneName}
274
+ speaker={speaker}
275
+ scenes={sceneCollection}
276
+ actorTransitionDuration={actorTransitionDuration}
277
+ />
278
+ <div className={`yd-options-container ${className || ""}`}>
279
+ <div className="yd-options-box" style={nodeStyles}>
280
+ <div className="yd-options-title">Choose an option:</div>
281
+ <div className="yd-options-list">
282
+ {result.options.map((option, index) => {
283
+ const optionStyles = parseCss(option.css);
284
+ return (
285
+ <button
286
+ key={index}
287
+ className="yd-option-button"
288
+ onClick={() => advance(index)}
289
+ style={optionStyles} // Only apply dynamic option CSS
290
+ >
291
+ <MarkupRenderer text={option.text} markup={option.markup} />
292
+ </button>
293
+ );
294
+ })}
295
+ </div>
296
+ </div>
297
+ </div>
298
+ </div>
299
+ );
300
+ }
301
+
302
+ // Command result - auto-advance
303
+ if (result.type === "command") {
304
+ return (
305
+ <div className={`yd-command ${className || ""}`}>
306
+ <p>Executing: {result.command}</p>
307
+ </div>
308
+ );
309
+ }
310
+
311
+ return null;
312
+ }
@@ -1,7 +1,7 @@
1
1
  import React from "react";
2
2
  import type { MarkupParseResult, MarkupWrapper } from "../markup/types.js";
3
3
 
4
- const DEFAULT_HTML_TAGS = new Set(["b", "em", "small", "strong", "sub", "sup", "ins", "del", "mark"]);
4
+ const DEFAULT_HTML_TAGS = new Set(["b", "em", "small", "strong", "sub", "sup", "ins", "del", "mark", "br"]);
5
5
 
6
6
  interface RenderPiece {
7
7
  text: string;
@@ -107,4 +107,3 @@ function createWrapperElement(
107
107
  function sanitizeClassName(name: string): string {
108
108
  return name.toLowerCase().replace(/[^a-z0-9_-]/g, "-");
109
109
  }
110
-