opencode-sidechat 1.0.0 → 1.1.1

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,348 +1,428 @@
1
- /** @jsxImportSource @opentui/solid */
2
- import { createMemo, For } from "solid-js";
3
- import type { InputRenderable } from "@opentui/core";
4
- import { THINKING_TEXT } from "../constants";
5
- import type { OverlayState } from "../types";
6
-
7
- const MAX_VISIBLE_MESSAGES = 20;
8
-
9
- function renderInlineMarkdown(text: string) {
10
- const parts: Array<{ type: string; text: string }> = [];
11
- let remaining = text;
12
-
13
- while (remaining.length > 0) {
14
- if (remaining.startsWith("`")) {
15
- const end = remaining.indexOf("`", 1);
16
- if (end === -1) {
17
- parts.push({ type: "text", text: remaining });
18
- break;
19
- }
20
- parts.push({ type: "code", text: remaining.slice(1, end) });
21
- remaining = remaining.slice(end + 1);
22
- } else if (remaining.startsWith("**")) {
23
- const end = remaining.indexOf("**", 2);
24
- if (end === -1) {
25
- parts.push({ type: "text", text: remaining });
26
- break;
27
- }
28
- parts.push({ type: "bold", text: remaining.slice(2, end) });
29
- remaining = remaining.slice(end + 2);
30
- } else if (remaining.startsWith("*") && !remaining.startsWith("**")) {
31
- const end = remaining.indexOf("*", 1);
32
- if (end === -1) {
33
- parts.push({ type: "text", text: remaining });
34
- break;
35
- }
36
- parts.push({ type: "italic", text: remaining.slice(1, end) });
37
- remaining = remaining.slice(end + 1);
38
- } else if (remaining.startsWith("[") && remaining.includes("](")) {
39
- const closeBracket = remaining.indexOf("](");
40
- const closeParen = remaining.indexOf(")", closeBracket);
41
- if (closeParen === -1) {
42
- parts.push({ type: "text", text: remaining });
43
- break;
44
- }
45
- parts.push({ type: "text", text: remaining.slice(1, closeBracket) });
46
- remaining = remaining.slice(closeParen + 1);
47
- } else {
48
- const nextSpecial = searchSpecialChar(remaining);
49
- if (nextSpecial === -1) {
50
- parts.push({ type: "text", text: remaining });
51
- break;
52
- }
53
- if (nextSpecial > 0) {
54
- parts.push({ type: "text", text: remaining.slice(0, nextSpecial) });
55
- }
56
- remaining = remaining.slice(nextSpecial);
57
- }
58
- }
59
-
60
- return parts;
61
- }
62
-
63
- function searchSpecialChar(text: string): number {
64
- for (let i = 0; i < text.length; i++) {
65
- const c = text[i];
66
- if (c === "`" || c === "*" || (c === "[" && text.includes("](", i))) {
67
- return i;
68
- }
69
- }
70
- return -1;
71
- }
72
-
73
- export function SideChat(props: OverlayState & { width: number; transcriptHeight: number; tokenLimit: number }) {
74
- const theme = props.api.theme.current;
75
- let input: InputRenderable | undefined;
76
- let inputValue = "";
77
-
78
- const panelWidth = props.width;
79
- const contentWidth = props.width - 4;
80
-
81
- const msgs = createMemo(() => {
82
- const messages = props.state.entries
83
- .map((entry) => {
84
- const textParts: string[] = [];
85
- const reasoning: Array<{ id: string; text: string }> = [];
86
-
87
- for (const p of entry.parts) {
88
- if (p.type === "text") {
89
- if (p.text.trim()) textParts.push(p.text.trim());
90
- } else if (p.type === "reasoning") {
91
- if (p.text) reasoning.push({ id: p.id, text: p.text });
92
- }
93
- }
94
-
95
- if (textParts.length === 0 && reasoning.length === 0) return null;
96
-
97
- return {
98
- id: entry.info.id,
99
- role: entry.info.role as "user" | "assistant",
100
- text: textParts.join("\n"),
101
- reasoning,
102
- };
103
- })
104
- .filter((m): m is NonNullable<typeof m> => m !== null)
105
- .slice(-MAX_VISIBLE_MESSAGES);
106
-
107
- if (props.state.loading && props.state.streamingAnswer) {
108
- const streaming = props.state.streamingAnswer.trim();
109
- const last = messages[messages.length - 1];
110
- const lastText = last?.role === "assistant" ? last.text : "";
111
- if (streaming && streaming !== lastText) {
112
- messages.push({
113
- id: "__streaming__",
114
- role: "assistant",
115
- text: streaming,
116
- reasoning: [],
117
- });
118
- }
119
- }
120
-
121
- return messages;
122
- });
123
-
124
- const ctxLabel = createMemo(() => {
125
- const n = props.state.tokenCount ?? 0;
126
- if (n <= 0) return "";
127
- const current = n >= 1000 ? (n / 1000).toFixed(1) + "k" : String(n);
128
- const limit = props.tokenLimit >= 1000 ? (props.tokenLimit / 1000).toFixed(0) + "k" : String(props.tokenLimit);
129
- return current + "/" + limit + " ctx";
130
- });
131
-
132
- const shortModelName = createMemo(() => {
133
- const name = props.modelName;
134
- const parts = name.split("/");
135
- return parts.length >= 2 ? parts[parts.length - 1] : name;
136
- });
137
-
138
- const renderThinking = (r: { id: string; text: string }) => {
139
- if (!props.thinkCollapsed) {
140
- return (
141
- <box flexDirection="column">
142
- <text fg={theme.textMuted}>
143
- {" thinking:"}
144
- </text>
145
- <text fg={theme.textMuted}>{r.text}</text>
146
- </box>
147
- );
148
- }
149
-
150
- const label = props.thinkConfig.showSummary
151
- ? "▶ thinking: " + r.text.slice(0, 60).replace(/\n/g, " ") + (r.text.length > 60 ? "..." : "")
152
- : "▶ thinking (" + r.text.length + " chars)";
153
-
154
- return <text fg={theme.textMuted}>{label}</text>;
155
- };
156
-
157
- return (
158
- <box
159
- position="absolute"
160
- bottom={0}
161
- right={0}
162
- >
163
- <box
164
- width={panelWidth}
165
- flexDirection="column"
166
- border={true}
167
- borderColor={theme.borderActive}
168
- backgroundColor={theme.backgroundPanel}
169
- >
170
- <box
171
- flexDirection="row"
172
- justifyContent="space-between"
173
- alignItems="center"
174
- paddingTop={1}
175
- paddingLeft={1}
176
- paddingRight={1}
177
- >
178
- <box flexDirection="row" gap={1} alignItems="center">
179
- <box paddingLeft={1} paddingRight={1} backgroundColor={theme.accent}>
180
- <text fg={theme.background}>
181
- <b>{"SideChat"}</b>
182
- </text>
183
- </box>
184
- <text fg={theme.success}>
185
- <b>{"[f]"}</b>
186
- </text>
187
- </box>
188
- <box flexDirection="row" gap={1} alignItems="center">
189
- <text fg={theme.textMuted}>{shortModelName()}</text>
190
- {ctxLabel() ? (
191
- <text fg={theme.textMuted}>{ctxLabel()}</text>
192
- ) : (
193
- <text>{""}</text>
194
- )}
195
- </box>
196
- </box>
197
-
198
- <box paddingLeft={1} paddingRight={1}>
199
- <scrollbox
200
- scrollY={true}
201
- stickyScroll={true}
202
- stickyStart="bottom"
203
- height={props.transcriptHeight}
204
- width={contentWidth}
205
- >
206
- <box flexDirection="column" gap={1} paddingTop={1} paddingBottom={1} width={contentWidth - 2}>
207
- {msgs().length > 0 ? (
208
- msgs().map((msg) => (
209
- <box flexDirection="column" gap={0}>
210
- <text fg={msg.role === "assistant" ? theme.secondary : theme.text}>
211
- <b>{msg.role === "assistant" ? "A:" : "You:"}</b>
212
- </text>
213
- {msg.reasoning.map((r) => renderThinking(r))}
214
- {msg.text ? (
215
- <box flexDirection="column">
216
- <RenderMarkdown text={msg.text} theme={theme} />
217
- </box>
218
- ) : (
219
- <text>{""}</text>
220
- )}
221
- </box>
222
- ))
223
- ) : props.state.loading ? (
224
- <text fg={theme.textMuted}>{THINKING_TEXT}</text>
225
- ) : (
226
- <text>{""}</text>
227
- )}
228
- {props.state.error ? (
229
- <text fg={theme.error}>{"Error: " + String(props.state.error)}</text>
230
- ) : (
231
- <text>{""}</text>
232
- )}
233
- </box>
234
- </scrollbox>
235
- </box>
236
-
237
- <box paddingLeft={1} paddingRight={1}>
238
- <input
239
- ref={(node) => { input = node; props.onInput?.(node); }}
240
- width={contentWidth}
241
- placeholder={props.state.loading ? "..." : ">"}
242
- textColor={theme.text}
243
- placeholderColor={theme.textMuted}
244
- backgroundColor={theme.backgroundElement}
245
- focusedTextColor={theme.text}
246
- cursorColor={theme.primary}
247
- focusedBackgroundColor={theme.backgroundElement}
248
- onInput={(value) => {
249
- inputValue = value;
250
- }}
251
- onSubmit={() => {
252
- const submitted = (input?.value ?? inputValue).trim();
253
- if (!submitted || props.state.loading) return;
254
- if (!props.onSubmit(submitted)) return;
255
- inputValue = "";
256
- if (input) input.value = "";
257
- }}
258
- />
259
- </box>
260
-
261
- <box
262
- flexDirection="row"
263
- gap={1}
264
- paddingTop={0}
265
- paddingBottom={1}
266
- paddingLeft={1}
267
- paddingRight={1}
268
- alignItems="center"
269
- >
270
- <text fg={theme.secondary}><b>{"Alt+C"}</b></text>
271
- <text fg={theme.primary}>{"Clear"}</text>
272
- <text fg={theme.textMuted}>{"·"}</text>
273
- <text fg={theme.secondary}><b>{"Alt+T"}</b></text>
274
- <text fg={theme.primary}>{"Thinking"}</text>
275
- <text fg={theme.textMuted}>{props.thinkCollapsed ? "" : "(on)"}</text>
276
- <text fg={theme.textMuted}>{"·"}</text>
277
- <text fg={theme.secondary}><b>{"Tab"}</b></text>
278
- <text fg={theme.primary}>{"Model"}</text>
279
- </box>
280
- </box>
281
- </box>
282
- );
283
- }
284
-
285
- function RenderMarkdown(props: { text: string; theme: import("@opencode-ai/plugin/tui").TuiThemeCurrent }) {
286
- const lines = createMemo(() => {
287
- const text = props.text;
288
- const result: Array<{ type: "line" | "codeblock"; parts: Array<{ type: string; text: string }>; codeText?: string }> = [];
289
- let inCodeBlock = false;
290
- let codeBuffer = "";
291
-
292
- for (const line of text.split("\n")) {
293
- if (line.startsWith("```")) {
294
- if (inCodeBlock) {
295
- result.push({ type: "codeblock", parts: [], codeText: codeBuffer });
296
- codeBuffer = "";
297
- }
298
- inCodeBlock = !inCodeBlock;
299
- continue;
300
- }
301
- if (inCodeBlock) {
302
- codeBuffer += (codeBuffer ? "\n" : "") + line;
303
- continue;
304
- }
305
-
306
- const parts = renderInlineMarkdown(line);
307
- result.push({ type: "line", parts });
308
- }
309
-
310
- if (inCodeBlock && codeBuffer) {
311
- result.push({ type: "codeblock", parts: [], codeText: codeBuffer });
312
- }
313
-
314
- return result;
315
- });
316
-
317
- const t = props.theme;
318
-
319
- return (
320
- <For each={lines()}>
321
- {(block) => {
322
- if (block.type === "codeblock") {
323
- return (
324
- <box
325
- backgroundColor={t.backgroundElement}
326
- paddingLeft={1}
327
- paddingRight={1}
328
- >
329
- <text fg={t.markdownCode}>{block.codeText}</text>
330
- </box>
331
- );
332
- }
333
- return (
334
- <box flexDirection="row" flexWrap="wrap" gap={0}>
335
- <For each={block.parts}>
336
- {(part) => {
337
- if (part.type === "bold") return <text><b>{part.text}</b></text>;
338
- if (part.type === "italic") return <text><i>{part.text}</i></text>;
339
- if (part.type === "code") return <text fg={t.markdownCode}>{part.text}</text>;
340
- return <text>{part.text}</text>;
341
- }}
342
- </For>
343
- </box>
344
- );
345
- }}
346
- </For>
347
- );
348
- }
1
+ /** @jsxImportSource @opentui/solid */
2
+ import { createMemo, For } from "solid-js";
3
+ import type { InputRenderable } from "@opentui/core";
4
+ import { THINKING_TEXT } from "../constants";
5
+ import type { OverlayState } from "../types";
6
+
7
+ const MAX_VISIBLE_MESSAGES = 20;
8
+
9
+ type InlinePart =
10
+ | { type: "text" | "code" | "bold" | "italic"; text: string }
11
+ | { type: "link"; text: string; url: string };
12
+
13
+ function renderInlineMarkdown(text: string) {
14
+ const parts: InlinePart[] = [];
15
+ let remaining = text;
16
+
17
+ while (remaining.length > 0) {
18
+ if (remaining.startsWith("`")) {
19
+ const end = remaining.indexOf("`", 1);
20
+ if (end === -1) {
21
+ parts.push({ type: "text", text: remaining });
22
+ break;
23
+ }
24
+ parts.push({ type: "code", text: remaining.slice(1, end) });
25
+ remaining = remaining.slice(end + 1);
26
+ } else if (remaining.startsWith("**")) {
27
+ const end = remaining.indexOf("**", 2);
28
+ if (end === -1) {
29
+ parts.push({ type: "text", text: remaining });
30
+ break;
31
+ }
32
+ parts.push({ type: "bold", text: remaining.slice(2, end) });
33
+ remaining = remaining.slice(end + 2);
34
+ } else if (remaining.startsWith("*") && !remaining.startsWith("**")) {
35
+ const end = remaining.indexOf("*", 1);
36
+ if (end === -1) {
37
+ parts.push({ type: "text", text: remaining });
38
+ break;
39
+ }
40
+ parts.push({ type: "italic", text: remaining.slice(1, end) });
41
+ remaining = remaining.slice(end + 1);
42
+ } else if (remaining.startsWith("[") && remaining.includes("](")) {
43
+ const closeBracket = remaining.indexOf("](");
44
+ const closeParen = remaining.indexOf(")", closeBracket);
45
+ if (closeParen === -1) {
46
+ parts.push({ type: "text", text: remaining });
47
+ break;
48
+ }
49
+ const linkText = remaining.slice(1, closeBracket);
50
+ const linkUrl = remaining.slice(closeBracket + 2, closeParen);
51
+ parts.push({ type: "link", text: linkText, url: linkUrl });
52
+ remaining = remaining.slice(closeParen + 1);
53
+ } else {
54
+ const nextSpecial = searchSpecialChar(remaining);
55
+ if (nextSpecial === -1) {
56
+ parts.push({ type: "text", text: remaining });
57
+ break;
58
+ }
59
+ if (nextSpecial > 0) {
60
+ parts.push({ type: "text", text: remaining.slice(0, nextSpecial) });
61
+ }
62
+ remaining = remaining.slice(nextSpecial);
63
+ }
64
+ }
65
+
66
+ return parts;
67
+ }
68
+
69
+ function searchSpecialChar(text: string): number {
70
+ for (let i = 0; i < text.length; i++) {
71
+ const c = text[i];
72
+ if (c === "`" || c === "*" || (c === "[" && text.includes("](", i))) {
73
+ return i;
74
+ }
75
+ }
76
+ return -1;
77
+ }
78
+
79
+ function formatKeybind(kb: string | false): string | false {
80
+ if (kb === false) return false;
81
+ return kb
82
+ .split(/[+]/)
83
+ .map(p => p.charAt(0).toUpperCase() + p.slice(1))
84
+ .join("+");
85
+ }
86
+
87
+ export function SideChat(props: OverlayState & { width: number; transcriptHeight: number; tokenLimit: number }) {
88
+ const theme = props.api.theme.current;
89
+ let input: InputRenderable | undefined;
90
+ let inputValue = "";
91
+
92
+ const panelWidth = props.width;
93
+ const contentWidth = props.width - 4;
94
+
95
+ const msgs = createMemo(() => {
96
+ const messages = props.state.entries
97
+ .map((entry) => {
98
+ const textParts: string[] = [];
99
+ const reasoning: Array<{ id: string; text: string }> = [];
100
+
101
+ for (const p of entry.parts) {
102
+ if (p.type === "text") {
103
+ if (p.text.trim()) textParts.push(p.text.trim());
104
+ } else if (p.type === "reasoning") {
105
+ if (p.text) reasoning.push({ id: p.id, text: p.text });
106
+ }
107
+ }
108
+
109
+ if (textParts.length === 0 && reasoning.length === 0) return null;
110
+
111
+ return {
112
+ id: entry.info.id,
113
+ role: entry.info.role as "user" | "assistant",
114
+ text: textParts.join("\n"),
115
+ reasoning,
116
+ };
117
+ })
118
+ .filter((m): m is NonNullable<typeof m> => m !== null)
119
+ .slice(-MAX_VISIBLE_MESSAGES);
120
+
121
+ if (props.state.loading && props.streamingAnswer) {
122
+ const streaming = props.streamingAnswer.trim();
123
+ const last = messages[messages.length - 1];
124
+ const lastText = last?.role === "assistant" ? last.text : "";
125
+ if (streaming && streaming !== lastText) {
126
+ messages.push({
127
+ id: "__streaming__",
128
+ role: "assistant",
129
+ text: streaming,
130
+ reasoning: [],
131
+ });
132
+ }
133
+ }
134
+
135
+ return messages;
136
+ });
137
+
138
+ const ctxLabel = createMemo(() => {
139
+ const n = props.state.tokenCount ?? 0;
140
+ if (n <= 0) return "";
141
+ const current = n >= 1000 ? (n / 1000).toFixed(1) + "k" : String(n);
142
+ const limit = props.tokenLimit >= 1000 ? (props.tokenLimit / 1000).toFixed(0) + "k" : String(props.tokenLimit);
143
+ return current + "/" + limit + " ctx";
144
+ });
145
+
146
+ const shortModelName = createMemo(() => {
147
+ const name = props.modelName;
148
+ const parts = name.split("/");
149
+ return parts.length >= 2 ? parts[parts.length - 1] : name;
150
+ });
151
+
152
+ const renderThinking = (r: { id: string; text: string }) => {
153
+ if (!props.thinkCollapsed) {
154
+ return (
155
+ <box flexDirection="column">
156
+ <text fg={theme.textMuted}>
157
+ {"▼ thinking:"}
158
+ </text>
159
+ <text fg={theme.textMuted}>{r.text}</text>
160
+ </box>
161
+ );
162
+ }
163
+
164
+ const label = props.thinkConfig.showSummary
165
+ ? "▶ thinking: " + r.text.slice(0, 60).replace(/\n/g, " ") + (r.text.length > 60 ? "..." : "")
166
+ : "▶ thinking (" + r.text.length + " chars)";
167
+
168
+ return <text fg={theme.textMuted}>{label}</text>;
169
+ };
170
+
171
+ return (
172
+ <box
173
+ position="absolute"
174
+ bottom={0}
175
+ right={0}
176
+ onMouseDown={() => input?.focus()}
177
+ >
178
+ <box
179
+ width={panelWidth}
180
+ flexDirection="column"
181
+ border={true}
182
+ borderColor={theme.borderActive}
183
+ backgroundColor={theme.backgroundPanel}
184
+ >
185
+ <box
186
+ flexDirection="row"
187
+ justifyContent="space-between"
188
+ alignItems="center"
189
+ paddingTop={1}
190
+ paddingLeft={1}
191
+ paddingRight={1}
192
+ >
193
+ <box flexDirection="row" gap={1} alignItems="center">
194
+ <box paddingLeft={1} paddingRight={1} backgroundColor={theme.accent}>
195
+ <text fg={theme.background}>
196
+ <b>{"OpenCode-SideChat"}</b>
197
+ </text>
198
+ </box>
199
+
200
+ </box>
201
+ <box flexDirection="row" gap={1} alignItems="center">
202
+ <text fg={theme.textMuted}>{shortModelName()}</text>
203
+ {ctxLabel() ? (
204
+ <text fg={theme.textMuted}>{ctxLabel()}</text>
205
+ ) : (
206
+ <text>{""}</text>
207
+ )}
208
+ </box>
209
+ </box>
210
+
211
+ <box paddingLeft={1} paddingRight={1}>
212
+ <scrollbox
213
+ scrollY={true}
214
+ stickyScroll={true}
215
+ stickyStart="bottom"
216
+ height={props.transcriptHeight}
217
+ width={contentWidth}
218
+ >
219
+ <box flexDirection="column" gap={1} paddingTop={1} paddingBottom={1} width={contentWidth - 2}>
220
+ {msgs().length > 0 ? (
221
+ <For each={msgs()}>
222
+ {(msg) => (
223
+ <box flexDirection="column" gap={0}>
224
+ <text fg={msg.role === "assistant" ? theme.secondary : theme.text}>
225
+ <b>{msg.role === "assistant" ? "Agent:" : "You:"}</b>
226
+ </text>
227
+ {msg.reasoning.map((r) => renderThinking(r))}
228
+ {msg.text ? (
229
+ <box flexDirection="column">
230
+ <RenderMarkdown text={msg.text} theme={theme} />
231
+ </box>
232
+ ) : (
233
+ <text>{""}</text>
234
+ )}
235
+ </box>
236
+ )}
237
+ </For>
238
+ ) : props.state.loading ? (
239
+ <text fg={theme.textMuted}>{THINKING_TEXT}</text>
240
+ ) : (
241
+ <text>{""}</text>
242
+ )}
243
+ {props.state.error ? (
244
+ <text fg={theme.error}>{"Error: " + String(props.state.error)}</text>
245
+ ) : (
246
+ <text>{""}</text>
247
+ )}
248
+ </box>
249
+ </scrollbox>
250
+ </box>
251
+
252
+ <box paddingLeft={1} paddingRight={1}>
253
+ <input
254
+ ref={(node) => { input = node; props.onInput?.(node); }}
255
+ width={contentWidth}
256
+ placeholder={props.state.loading ? "..." : ">"}
257
+ textColor={theme.text}
258
+ placeholderColor={theme.textMuted}
259
+ backgroundColor={theme.backgroundElement}
260
+ focusedTextColor={theme.text}
261
+ cursorColor={theme.primary}
262
+ focusedBackgroundColor={theme.backgroundElement}
263
+ onInput={(value) => {
264
+ inputValue = value;
265
+ }}
266
+ onSubmit={() => {
267
+ const submitted = (input?.value ?? inputValue).trim();
268
+ if (!submitted || props.state.loading) return;
269
+ if (!props.onSubmit(submitted)) return;
270
+ inputValue = "";
271
+ if (input) input.value = "";
272
+ }}
273
+ />
274
+ </box>
275
+
276
+ <box
277
+ flexDirection="row"
278
+ gap={1}
279
+ paddingTop={0}
280
+ paddingBottom={1}
281
+ paddingLeft={1}
282
+ paddingRight={1}
283
+ alignItems="center"
284
+ >
285
+ {formatKeybind(props.clearKeybind) && (
286
+ <box flexDirection="row" gap={1} alignItems="center">
287
+ <text fg={theme.secondary}><b>{formatKeybind(props.clearKeybind)}</b></text>
288
+ <text fg={theme.primary}>{"Clear"}</text>
289
+ <text fg={theme.textMuted}>{"·"}</text>
290
+ </box>
291
+ )}
292
+ {formatKeybind(props.thinkToggleKeybind) && (
293
+ <box flexDirection="row" gap={1} alignItems="center">
294
+ <text fg={theme.secondary}><b>{formatKeybind(props.thinkToggleKeybind)}</b></text>
295
+ <text fg={theme.primary}>{"Thinking"}</text>
296
+ <text fg={theme.textMuted}>{props.thinkCollapsed ? "" : "(on)"}</text>
297
+ <text fg={theme.textMuted}>{"·"}</text>
298
+ </box>
299
+ )}
300
+ <text fg={theme.secondary}><b>{"Tab"}</b></text>
301
+ <text fg={theme.primary}>{"Model"}</text>
302
+ </box>
303
+ </box>
304
+ </box>
305
+ );
306
+ }
307
+
308
+ type RenderBlock =
309
+ | { type: "line"; style?: "heading" | "blockquote" | "listitem" | "hr"; parts: InlinePart[] }
310
+ | { type: "codeblock"; codeText: string };
311
+
312
+ function RenderMarkdown(props: { text: string; theme: import("@opencode-ai/plugin/tui").TuiThemeCurrent }) {
313
+ const t = props.theme;
314
+
315
+ const lines = createMemo((): RenderBlock[] => {
316
+ const text = props.text;
317
+ const result: RenderBlock[] = [];
318
+
319
+ let inCodeBlock = false;
320
+ let codeBuffer = "";
321
+
322
+ for (const rawLine of text.split("\n")) {
323
+ if (rawLine.startsWith("```")) {
324
+ if (inCodeBlock) {
325
+ result.push({ type: "codeblock", codeText: codeBuffer });
326
+ codeBuffer = "";
327
+ }
328
+ inCodeBlock = !inCodeBlock;
329
+ continue;
330
+ }
331
+ if (inCodeBlock) {
332
+ codeBuffer += (codeBuffer ? "\n" : "") + rawLine;
333
+ continue;
334
+ }
335
+
336
+ // Detect block-level styles
337
+ let line = rawLine;
338
+ let style: "heading" | "blockquote" | "listitem" | "hr" | undefined;
339
+
340
+ if (/^#{1,6}\s/.test(line)) {
341
+ style = "heading";
342
+ line = line.replace(/^#{1,6}\s+/, "");
343
+ } else if (line.startsWith("> ")) {
344
+ style = "blockquote";
345
+ line = line.slice(2);
346
+ } else if (/^[-*]\s/.test(line)) {
347
+ style = "listitem";
348
+ line = line.replace(/^[-*]\s/, "");
349
+ } else if (/^\d+\.\s/.test(line)) {
350
+ style = "listitem";
351
+ // Keep number prefix (1., 2. etc.) visible
352
+ } else if (/^[-*=_]{3,}$/.test(line)) {
353
+ style = "hr";
354
+ line = "";
355
+ }
356
+
357
+ result.push({ type: "line", style, parts: renderInlineMarkdown(line) });
358
+ }
359
+
360
+ if (inCodeBlock && codeBuffer) {
361
+ result.push({ type: "codeblock", codeText: codeBuffer });
362
+ }
363
+
364
+ return result;
365
+ });
366
+
367
+ return (
368
+ <For each={lines()}>
369
+ {(block) => {
370
+ if (block.type === "codeblock") {
371
+ return (
372
+ <box
373
+ backgroundColor={t.backgroundElement}
374
+ paddingLeft={1}
375
+ paddingRight={1}
376
+ >
377
+ <text fg={t.markdownCodeBlock}>{block.codeText}</text>
378
+ </box>
379
+ );
380
+ }
381
+
382
+ const s = block.style;
383
+ let textColor: import("@opentui/core").RGBA;
384
+ if (s === "heading") {
385
+ textColor = t.markdownHeading;
386
+ } else if (s === "blockquote") {
387
+ textColor = t.markdownBlockQuote;
388
+ } else if (s === "listitem") {
389
+ textColor = t.markdownListItem;
390
+ } else if (s === "hr") {
391
+ textColor = t.markdownHorizontalRule;
392
+ } else {
393
+ textColor = t.markdownText;
394
+ }
395
+
396
+ if (s === "hr") {
397
+ return <text fg={t.markdownHorizontalRule}>{"―".repeat(8)}</text>;
398
+ }
399
+
400
+ // Render inline parts with appropriate colors
401
+ return (
402
+ <box flexDirection="row" flexWrap="wrap" gap={0}>
403
+ <For each={block.parts}>
404
+ {(part) => {
405
+ switch (part.type) {
406
+ case "bold":
407
+ return <text fg={t.markdownStrong}><b>{part.text}</b></text>;
408
+ case "italic":
409
+ return <text fg={t.markdownEmph}><i>{part.text}</i></text>;
410
+ case "code":
411
+ return <text fg={t.markdownCode}>{part.text}</text>;
412
+ case "link":
413
+ return (
414
+ <text fg={t.markdownLinkText}>
415
+ {part.text}<text fg={t.markdownLink}>{"(" + part.url + ")"}</text>
416
+ </text>
417
+ );
418
+ default:
419
+ return <text fg={textColor}>{part.text}</text>;
420
+ }
421
+ }}
422
+ </For>
423
+ </box>
424
+ );
425
+ }}
426
+ </For>
427
+ );
428
+ }