loom-agent 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (79) hide show
  1. package/.env.example +25 -0
  2. package/CHANGELOG.md +402 -0
  3. package/LICENSE +21 -0
  4. package/LOOM.md +235 -0
  5. package/README.md +433 -0
  6. package/bin/loom-tui.js +43 -0
  7. package/bin/loom.js +44 -0
  8. package/docs/acp.md +151 -0
  9. package/docs/web.md +205 -0
  10. package/package.json +97 -0
  11. package/scripts/acp-smoke.js +146 -0
  12. package/src/acp/acp-server.js +287 -0
  13. package/src/config/provider-cmd.js +37 -0
  14. package/src/config/settings.js +164 -0
  15. package/src/core/agents.js +361 -0
  16. package/src/core/background-tasks.js +103 -0
  17. package/src/core/cli.js +579 -0
  18. package/src/core/custom-commands.js +70 -0
  19. package/src/core/errors.js +29 -0
  20. package/src/core/events.js +24 -0
  21. package/src/core/file-diffs.js +282 -0
  22. package/src/core/format.js +206 -0
  23. package/src/core/graph.js +257 -0
  24. package/src/core/hooks.js +82 -0
  25. package/src/core/lsp.js +385 -0
  26. package/src/core/memory.js +87 -0
  27. package/src/core/model-router.js +87 -0
  28. package/src/core/permissions.js +327 -0
  29. package/src/core/platform.js +33 -0
  30. package/src/core/plugin-cmd.js +380 -0
  31. package/src/core/restore.js +207 -0
  32. package/src/core/session-store.js +167 -0
  33. package/src/core/session.js +910 -0
  34. package/src/core/subagent-log.js +134 -0
  35. package/src/core/tokens.js +31 -0
  36. package/src/core/update.js +6 -0
  37. package/src/core/usage.js +166 -0
  38. package/src/index.js +41 -0
  39. package/src/mcp/mcp-client.js +201 -0
  40. package/src/mcp/mcp-manager.js +193 -0
  41. package/src/providers/anthropic.js +243 -0
  42. package/src/providers/google.js +29 -0
  43. package/src/providers/index.js +175 -0
  44. package/src/providers/local.js +27 -0
  45. package/src/providers/nvidia.js +85 -0
  46. package/src/providers/openai-compat.js +269 -0
  47. package/src/providers/openai.js +35 -0
  48. package/src/providers/openrouter.js +43 -0
  49. package/src/providers/registry.js +196 -0
  50. package/src/providers/tokenrouter.js +19 -0
  51. package/src/skills/skill-matcher.js +133 -0
  52. package/src/skills/skills-manager.js +213 -0
  53. package/src/tools/index.js +543 -0
  54. package/src/tui/App.tsx +1578 -0
  55. package/src/tui/components/BreadcrumbBar.tsx +34 -0
  56. package/src/tui/components/ChatArea.tsx +518 -0
  57. package/src/tui/components/InputBar.tsx +354 -0
  58. package/src/tui/components/MdText.tsx +105 -0
  59. package/src/tui/components/Modals.tsx +851 -0
  60. package/src/tui/components/PermissionPopup.tsx +264 -0
  61. package/src/tui/components/Sidebar.tsx +182 -0
  62. package/src/tui/components/SplashScreen.tsx +51 -0
  63. package/src/tui/components/SubagentPanel.tsx +217 -0
  64. package/src/tui/components/ToastOverlay.tsx +34 -0
  65. package/src/tui/keybinds.ts +318 -0
  66. package/src/tui/mcp-presets.ts +189 -0
  67. package/src/tui/md-render.ts +228 -0
  68. package/src/tui/store.ts +714 -0
  69. package/src/tui/suite-home.ts +20 -0
  70. package/src/tui/theme.ts +313 -0
  71. package/src/tui/themes.generated.ts +968 -0
  72. package/src/tui/tool-display.ts +176 -0
  73. package/src/tui/toolname.ts +60 -0
  74. package/src/tui/tui-config.ts +28 -0
  75. package/src/tui-open.tsx +51 -0
  76. package/src/web/attach.js +242 -0
  77. package/src/web/graph-view.html +262 -0
  78. package/src/web/index.html +824 -0
  79. package/src/web/web-server.js +470 -0
@@ -0,0 +1,354 @@
1
+ // InputBar -- input line + autocomplete popup. All signal reads inside JSX for reactivity.
2
+ import { onMount, onCleanup, createSignal, createMemo, createEffect, Show } from "solid-js";
3
+ import { useTerminalDimensions } from "@opentui/solid";
4
+ import { palette } from "../theme.ts";
5
+ import { PermissionPopup } from "./PermissionPopup.tsx";
6
+ import {
7
+ input, cursor, setCursor, thinking, suggestions, autoIndex, autoKind,
8
+ inputMode, sessionUsage, modelName,
9
+ selectSuggestionAt, moveSuggestionIndex, pickSuggestionAt, windowFor,
10
+ selStart, selEnd, clearSelection, pastedAt, autoPerm,
11
+ vimMode, vimNormal,
12
+ } from "../store.ts";
13
+ import { formatTokens, formatUsd } from "../../core/usage.js";
14
+ import { loadConfig } from "../../config/settings.js";
15
+
16
+ const ui = palette("loom");
17
+ const MODE_NAMES: Record<string, string> = { build: "Build", plan: "Plan", chat: "Chat" };
18
+ const MODE_HINTS: Record<string, string> = { build: "all tools", plan: "read-only", chat: "no tools" };
19
+ const MODE_COLORS: Record<string, string> = { build: ui.primary, plan: ui.primarySoft, chat: ui.warning };
20
+ const POPUP_ROWS = 10;
21
+
22
+ function onSuggestionWheel(e: any) {
23
+ const dir = e?.scroll?.direction;
24
+ if (dir === "up") moveSuggestionIndex(-1);
25
+ else if (dir === "down") moveSuggestionIndex(1);
26
+ }
27
+
28
+ function rangeHint(start: number, total: number) {
29
+ if (total <= POPUP_ROWS) return "";
30
+ const end = Math.min(start + POPUP_ROWS, total);
31
+ return " (" + (start + 1) + "-" + end + "/" + total + ")";
32
+ }
33
+
34
+ // The chatbox grows with its content: header row (badges) + scrollable content
35
+ // rows + a mode row ("Build") at the bottom. No border — borderless panels
36
+ // keep the UI grid-line-free (box-drawing chars also snag text selection).
37
+ const MIN_INPUT_HEIGHT = 5; // roomy resting box (splash included)
38
+ const MAX_INPUT_HEIGHT = 16; // grows line-by-line, then scrolls
39
+
40
+ // Wrap width used for counting: the text's real content width is the box
41
+ // width minus paddingX={1} on each side, snapped DOWN to an integer so the
42
+ // count never underestimates (the box never clips text and the height never
43
+ // oscillates — no vibration). The measured width is integer-stable too.
44
+ function wrapWidth(boxWidth: number): number {
45
+ return Math.max(20, Math.floor(boxWidth) - 2);
46
+ }
47
+
48
+ function visualLines(text: string, boxWidth: number): number {
49
+ if (!text) return 1;
50
+ const w = wrapWidth(boxWidth);
51
+ let n = 0;
52
+ for (const ln of text.split("\n")) {
53
+ n += Math.max(1, Math.ceil((ln.length + 1) / w));
54
+ }
55
+ return n;
56
+ }
57
+
58
+ // Map a click position inside the wrapped text to a character index. The
59
+ // click's (x, y) are terminal coords; the text's screenX/screenY mark its
60
+ // first row. Wrap math matches visualLines() so the caret lands where the
61
+ // user pointed even for multi-line, wrapped drafts.
62
+ function caretFromMouse(ev: any, text: string, boxWidth: number): number {
63
+ const t: any = ev.target;
64
+ if (!t) return text.length;
65
+ // Same wrap width as visualLines() so the caret lands where the user
66
+ // pointed even for multi-line, wrapped drafts.
67
+ const w = wrapWidth(boxWidth);
68
+ const row = Math.max(0, Number(ev.y) - Number(t.screenY ?? ev.y));
69
+ const col = Math.max(0, Number(ev.x) - Number(t.screenX ?? ev.x));
70
+ // Walk the draft line-by-line, consuming wrapped segments, and find the row
71
+ // the user clicked in.
72
+ let idx = 0;
73
+ let visualRow = 0;
74
+ const lines = text.split("\n");
75
+ for (let li = 0; li < lines.length; li++) {
76
+ const line = lines[li];
77
+ const segs = Math.max(1, Math.ceil((line.length + 1) / w));
78
+ for (let s = 0; s < segs; s++) {
79
+ if (visualRow === row) {
80
+ const start = s * w;
81
+ return Math.min(text.length, idx + Math.min(line.length - start, col));
82
+ }
83
+ visualRow++;
84
+ idx += Math.min(w, line.length - s * w) <= 0 ? 0 : Math.min(w, line.length - s * w);
85
+ }
86
+ idx += 1; // the "\n"
87
+ }
88
+ return text.length;
89
+ }
90
+
91
+ export function InputBar() {
92
+ const [showCursor, setShowCursor] = createSignal(true);
93
+ const dims = useTerminalDimensions();
94
+ // The chatbox's real width differs from the terminal width on the splash
95
+ // screen (where it sits inside a fixed 74-col container). visualLines()
96
+ // must use the rendered box width — measured post-layout and re-measured
97
+ // when the terminal resizes (never per frame, so the height can't
98
+ // oscillate; a per-frame measurement was the source of the whole-screen
99
+ // vibration).
100
+ const [measuredW, setMeasuredW] = createSignal(0);
101
+ let boxEl: any = null;
102
+ let blink: any;
103
+ const measureW = () => { if (boxEl && typeof boxEl.width === "number") setMeasuredW(Math.floor(boxEl.width)); };
104
+
105
+ onMount(() => { blink = setInterval(() => setShowCursor(v => !v), 530); });
106
+ onCleanup(() => { if (blink) clearInterval(blink); });
107
+ // The box width follows the terminal: re-measure whenever the terminal
108
+ // dimensions change so visualLines()/status truncation track the live width.
109
+ // (Only on resize events — never per frame, so no height oscillation.)
110
+ createEffect(() => { dims(); measureW(); });
111
+
112
+ let winStart = 0;
113
+ const slashWin = createMemo(() => {
114
+ const total = suggestions().length;
115
+ winStart = windowFor(autoIndex(), total, POPUP_ROWS, winStart);
116
+ return { total, start: winStart, items: suggestions().slice(winStart, winStart + POPUP_ROWS) };
117
+ });
118
+ const fileWin = createMemo(() => {
119
+ const total = suggestions().length;
120
+ winStart = windowFor(autoIndex(), total, POPUP_ROWS, winStart);
121
+ return { total, start: winStart, items: suggestions().slice(winStart, winStart + POPUP_ROWS) };
122
+ });
123
+
124
+ // Chatbox height: header row + clamped content rows + mode row. A pasted
125
+ // draft past 10 lines renders COMPRESSED (first lines + "pasted ~N lines"),
126
+ // so the box stays compact instead of hitting the scroll cap.
127
+ const effectiveW = () => measuredW() || dims().width || 100;
128
+ const inputLines = createMemo(() => visualLines(input(), effectiveW()));
129
+ const pasteCompressed = createMemo(() => pastedAt() > 0 && inputLines() > 10);
130
+ // OpenCode-style growth: height tracks LOGICAL newlines, not soft-wrapped
131
+ // rows. Typing mid-line never changes the height (zero jitter); the box
132
+ // gains one row per Enter until the cap, and only then does text wrap
133
+ // inside the fixed-height box.
134
+ const logicalLines = createMemo(() => {
135
+ if (pasteCompressed()) return 11;
136
+ const v = input();
137
+ let n = 1;
138
+ for (let i = 0; i < v.length; i++) if (v.charCodeAt(i) === 10) n++;
139
+ return n;
140
+ });
141
+ const inputHeight = createMemo(() => {
142
+ const l = logicalLines();
143
+ // OpenCode-style: the editor claims what it needs up to ~60% of the
144
+ // terminal; the chat area above absorbs the rest. No inner scrollbars.
145
+ const capH = Math.max(MIN_INPUT_HEIGHT + 2, Math.floor(((dims().height || 30) * 6) / 10));
146
+ return Math.max(MIN_INPUT_HEIGHT, Math.min(capH, 2 + l));
147
+ });
148
+
149
+ // One-line status under the chatbox, opencode-style: cwd · usage · hint.
150
+ // The row is as wide as the chatbox itself (measured box width), so the
151
+ // cwd truncates instead of the line wrapping.
152
+ const statusLine = () => {
153
+ const sess = sessionUsage();
154
+ const usage = formatTokens(sess.tokens) + " (" + Math.round(sess.pct) + "%) \u00B7 " + formatUsd(sess.cost);
155
+ const held = thinking() && input().trim() ? " \u00B7 \u23F3 held" : "";
156
+ const autoTag = autoPerm() ? " \u00B7 auto" : "";
157
+ const vimTag = vimMode() ? (vimNormal() ? " \u00B7 -- NORMAL --" : " \u00B7 INSERT") : "";
158
+ // Optional template override (config.statusLine): {model} {cost} {tokens}
159
+ // {mode} {cwd} placeholders. Config is cached (1.5s TTL) — reading it on
160
+ // every keystroke stalls the renderer and desyncs mouse hit-testing.
161
+ let tmpl: string = "";
162
+ try {
163
+ const nowMs = Date.now();
164
+ const g = globalThis as any;
165
+ if (!g.__loomStatusTmpl || nowMs - g.__loomStatusAt > 1500) {
166
+ g.__loomStatusTmpl = String(loadConfig().statusLine || "");
167
+ g.__loomStatusAt = nowMs;
168
+ }
169
+ tmpl = g.__loomStatusTmpl;
170
+ } catch {}
171
+ let right: string;
172
+ if (tmpl.trim()) {
173
+ right = tmpl
174
+ .replace(/\{model\}/g, modelName())
175
+ .replace(/\{cost\}/g, formatUsd(sess.cost))
176
+ .replace(/\{tokens\}/g, formatTokens(sess.tokens))
177
+ .replace(/\{cwd\}/g, process.cwd())
178
+ .replace(/\{mode\}/g, inputMode());
179
+ } else {
180
+ right = usage + held + autoTag + vimTag + " \u00B7 ctrl+p commands";
181
+ }
182
+ const avail = Math.max(20, effectiveW() - 2);
183
+ const maxCwd = Math.max(8, avail - right.length - 2);
184
+ const cwd = process.cwd();
185
+ const cwdStr = cwd.length > maxCwd ? "\u2026" + cwd.slice(-(maxCwd - 1)) : cwd;
186
+ return { cwd: cwdStr, right };
187
+ };
188
+
189
+ return (
190
+ <box flexDirection="column" flexShrink={0}>
191
+ <Show when={suggestions().length > 0 && autoKind() === "slash"}>
192
+ <box
193
+ border borderStyle="rounded" borderColor={ui.accent}
194
+ paddingX={1} paddingY={0}
195
+ flexDirection="column" marginBottom={0}
196
+ backgroundColor={ui.bgPanel}
197
+ onMouseScroll={onSuggestionWheel}
198
+ >
199
+ <text fg={ui.fgMuted}>
200
+ {" \u2191\u2193 nav TAB/ENTER pick wheel/click ESC close" + rangeHint(slashWin().start, slashWin().total)}
201
+ </text>
202
+ {slashWin().items.map((item, i) => {
203
+ const abs = slashWin().start + i;
204
+ return (
205
+ <box
206
+ flexDirection="row" paddingY={0}
207
+ onMouseDown={() => selectSuggestionAt(abs)}
208
+ onMouseUp={() => pickSuggestionAt(abs)}
209
+ >
210
+ <text fg={abs === autoIndex() ? ui.primary : ui.fgDim}>
211
+ {(abs === autoIndex() ? "\u25B6 " : " ") + item.label.split(" ")[0]}
212
+ </text>
213
+ <Show when={!!item.desc}>
214
+ <text fg={abs === autoIndex() ? ui.fg : ui.fgMuted}>
215
+ {" " + item.desc}
216
+ </text>
217
+ </Show>
218
+ </box>
219
+ );
220
+ })}
221
+ </box>
222
+ </Show>
223
+
224
+ <Show when={suggestions().length > 0 && autoKind() !== "slash"}>
225
+ <box
226
+ border borderStyle="rounded" borderColor={ui.accent}
227
+ paddingX={1} flexDirection="column" marginBottom={0}
228
+ backgroundColor={ui.bgInput}
229
+ onMouseScroll={onSuggestionWheel}
230
+ >
231
+ {fileWin().items.map((item, i) => {
232
+ const abs = fileWin().start + i;
233
+ return (
234
+ <text
235
+
236
+ fg={abs === autoIndex() ? ui.primary : ui.fgDim}
237
+ onMouseDown={() => selectSuggestionAt(abs)}
238
+ onMouseUp={() => pickSuggestionAt(abs)}
239
+ >
240
+ {(abs === autoIndex() ? "> " : " ") + item.label}
241
+ </text>
242
+ );
243
+ })}
244
+ <text fg={ui.fgMuted}>{" UP/DN TAB/ENTER pick wheel/click ESC close" + rangeHint(fileWin().start, fileWin().total)}</text>
245
+ </box>
246
+ </Show>
247
+
248
+ <PermissionPopup />
249
+
250
+ <box
251
+ ref={(r: any) => { boxEl = r; setTimeout(measureW, 0); }}
252
+ paddingX={1} paddingY={0}
253
+ flexDirection="column"
254
+ backgroundColor={ui.bgInput}
255
+ height={inputHeight()}
256
+ >
257
+ <box flexDirection="row" alignItems="center" gap={1} flexShrink={0} height={1}>
258
+ <Show when={pasteCompressed()}>
259
+ <text fg={ui.warning} flexGrow={1}>{"pasted ~" + inputLines() + " lines \u00B7 Shift+Enter newline"}</text>
260
+ </Show>
261
+ <Show when={!pasteCompressed() && inputLines() > 1}>
262
+ <text fg={ui.fgMuted} flexGrow={1}>{"~" + inputLines() + " lines \u00B7 Shift+Enter newline"}</text>
263
+ </Show>
264
+ <Show when={thinking()}>
265
+ <text fg={ui.warning}>{"\u25C6"}</text>
266
+ </Show>
267
+ </box>
268
+ <Show
269
+ when={input().length > 0}
270
+ fallback={
271
+ <box flexGrow={1} flexDirection="column" justifyContent="center">
272
+ <text>
273
+ {/* @ts-ignore OpenTUI text nodes take fg/bg via the style prop (TextNodeOptions), not as direct props */}
274
+ <span style={{ fg: showCursor() ? ui.fgMuted : ui.bgInput }}>{"\u2588"}</span>
275
+ <span style={{ fg: ui.fgMuted }}>{" Ask anything... /commands @file !shell"}</span>
276
+ </text>
277
+ </box>
278
+ }
279
+ >
280
+ {(() => {
281
+ // A freshly-pasted draft past 10 lines renders as a compact
282
+ // preview (first 10 lines + a "pasted ~N lines" note) — the full
283
+ // text is still in input() and sends untouched. Any edit expands.
284
+ const v = input();
285
+ if (pasteCompressed()) {
286
+ return (
287
+ <box flexDirection="column">
288
+ <text fg={ui.fg}>{v.split("\n").slice(0, 10).join("\n")}</text>
289
+ <text fg={ui.fgMuted}>{"\u2026 (pasted ~" + inputLines() + " lines \u2014 keep typing to expand)"}</text>
290
+ </box>
291
+ );
292
+ }
293
+ // Caret-aware render: the block sits where the caret is, over the
294
+ // character it covers (or after the last char when at the end).
295
+ // Ctrl+A highlights the selection with the accent background.
296
+ const p = Math.max(0, Math.min(cursor(), v.length));
297
+ const ss = selStart();
298
+ const se = selEnd();
299
+ const hasSel = ss >= 0 && se > ss;
300
+ // Segment the draft at the caret char (p..p+1) and the selection
301
+ // edges so ordinary text, highlighted text and the caret block
302
+ // each render as their own span.
303
+ const pts = Array.from(new Set(
304
+ [0, p, Math.min(p + 1, v.length), hasSel ? ss : -1, hasSel ? se : -1, v.length]
305
+ .filter(x => x >= 0 && x <= v.length)
306
+ )).sort((a, b) => a - b);
307
+ const segs: Array<{ text: string; sel: boolean }> = [];
308
+ for (let i = 0; i < pts.length - 1; i++) {
309
+ const a = pts[i], b = pts[i + 1];
310
+ if (b <= a) continue;
311
+ segs.push({ text: v.slice(a, b), sel: hasSel && a >= ss && b <= se });
312
+ }
313
+ const cursorInSel = hasSel && p >= ss && p < se;
314
+ return (
315
+ <box flexDirection="column" flexGrow={1}
316
+ onMouseDown={(ev: any) => { clearSelection(); setCursor(caretFromMouse(ev, input(), effectiveW())); }}
317
+ >
318
+ <text fg={ui.fg}>
319
+ {segs.map((g, i) => g.sel ? (
320
+ // @ts-ignore OpenTUI text nodes take fg/bg via the style prop (TextNodeOptions), not as direct props
321
+ <span style={{ fg: ui.bgPanel, bg: ui.primary }}>{g.text}</span>
322
+ ) : (
323
+ <span>{g.text}</span>
324
+ ))}
325
+ {/* Caret blink swaps COLOR, never the character. The cell
326
+ always renders a full block ("\u2588"), so the text length
327
+ and wrap are identical in both blink phases — swapping the
328
+ char for a space lets TUI renderers that trim trailing
329
+ whitespace shorten the last wrapped line and vibrate the
330
+ box height at blink rate while typing. Instead we hide the
331
+ block by giving it the same fg as the background behind it
332
+ (bgInput, or primary inside a selection). */}
333
+ {/* @ts-ignore OpenTUI text nodes take fg/bg via the style prop (TextNodeOptions), not as direct props */}
334
+ <span style={{ fg: cursorInSel ? (showCursor() ? ui.bgPanel : ui.primary) : (showCursor() ? ui.fg : ui.bgInput), bg: cursorInSel ? ui.primary : undefined }}>
335
+ {"\u2588"}
336
+ </span>
337
+ </text>
338
+ </box>
339
+ );
340
+ })()}
341
+ </Show>
342
+ <box flexDirection="row" alignItems="center" flexShrink={0}>
343
+ <text fg={MODE_COLORS[inputMode()] || ui.primary}>{MODE_NAMES[inputMode()] || "Build"}</text>
344
+ <text fg={ui.fgDim}>{" " + (MODE_HINTS[inputMode()] || "")}</text>
345
+ </box>
346
+ </box>
347
+
348
+ <box paddingX={1} flexDirection="row" justifyContent="space-between">
349
+ <text fg={ui.fgMuted}>{statusLine().cwd}</text>
350
+ <text fg={ui.fgMuted}>{statusLine().right}</text>
351
+ </box>
352
+ </box>
353
+ );
354
+ }
@@ -0,0 +1,105 @@
1
+ // MdText — render a markdown string into chat-friendly terminal JSX.
2
+ // Line-level parsing comes from md-render.ts; this file is the JSX mapping.
3
+ import { palette } from "../theme.ts";
4
+ import { parseMarkdown, highlightCode, CodeTok } from "../md-render.ts";
5
+
6
+ const ui = palette("loom");
7
+
8
+ // Code colors come straight from the active theme's TextMate syntax scopes
9
+ // (keyword/string/comment/number/call) — same source VS Code uses, so fenced
10
+ // blocks look right for whichever theme is picked.
11
+ const CODE_COLORS: Record<CodeTok["style"], () => string> = {
12
+ kw: () => ui.syntax.kw,
13
+ str: () => ui.syntax.str,
14
+ com: () => ui.syntax.com,
15
+ num: () => ui.syntax.num,
16
+ call: () => ui.syntax.call,
17
+ plain: () => ui.syntax.plain,
18
+ };
19
+
20
+ function CodeLine(props: { toks: CodeTok[]; k: string }) {
21
+ return (
22
+ <text>
23
+ {props.toks.map((t, j) => (
24
+ // @ts-ignore OpenTUI text nodes take fg via the style prop (TextNodeOptions), not as a direct prop
25
+ <span style={{ fg: CODE_COLORS[t.style]() }}>
26
+ {t.text}
27
+ </span>
28
+ ))}
29
+ </text>
30
+ );
31
+ }
32
+
33
+ function spansToNodes(spans: any[], keyPrefix: string) {
34
+ if (!spans.length) return " ";
35
+ return spans.map((s, i) => {
36
+ const key = keyPrefix + "-" + i;
37
+ if (s.code) {
38
+ // Inline code: accent color + panel background to read as a chip.
39
+ // @ts-ignore OpenTUI text nodes take fg/bg via the style prop (TextNodeOptions), not as direct props
40
+ return <span style={{ fg: ui.accent, bg: ui.bgPanel }}>{s.text}</span>;
41
+ }
42
+ if (s.link) {
43
+ // @ts-ignore OpenTUI text nodes take fg/underline via the style prop; href is a direct prop
44
+ return <a href={s.link} style={{ fg: ui.accent, underline: true }}>{s.text}</a>;
45
+ }
46
+ if (s.bold && s.italic) {
47
+ // @ts-ignore OpenTUI text nodes take fg via the style prop (TextNodeOptions)
48
+ return <span style={{ fg: ui.fg }}><b><i>{s.text}</i></b></span>;
49
+ }
50
+ // @ts-ignore OpenTUI text nodes take fg via the style prop (TextNodeOptions)
51
+ if (s.bold) return <b><span style={{ fg: ui.fg }}>{s.text}</span></b>;
52
+ // @ts-ignore OpenTUI text nodes take fg via the style prop (TextNodeOptions)
53
+ if (s.italic) return <i><span style={{ fg: ui.fg }}>{s.text}</span></i>;
54
+ return <span>{s.text}</span>;
55
+ });
56
+ }
57
+
58
+ function HeadingText(props: { level: 1 | 2 | 3; spans: any[]; keyPrefix: string }) {
59
+ const marker = props.level === 1 ? "# " : props.level === 2 ? "## " : "### ";
60
+ return <text fg={ui.primary}>{marker}{spansToNodes(props.spans, props.keyPrefix)}</text>;
61
+ }
62
+
63
+ export function MdText(props: { md: string; wrap?: boolean }) {
64
+ const lines = parseMarkdown(props.md);
65
+ return (
66
+ <box flexDirection="column">
67
+ {lines.map((ln, i) => {
68
+ const kp = "mdl-" + i;
69
+ switch (ln.kind) {
70
+ case "heading": return <HeadingText level={ln.level} spans={ln.spans} keyPrefix={kp} />;
71
+ case "bullet":
72
+ return (
73
+ <text paddingLeft={ln.indent}>
74
+ {// @ts-ignore OpenTUI text nodes take fg via the style prop (TextNodeOptions)
75
+ <span style={{ fg: ui.secondary }}>{"• "}</span>}
76
+ {spansToNodes(ln.spans, kp)}
77
+ </text>
78
+ );
79
+ case "quote":
80
+ return (
81
+ <text>
82
+ {// @ts-ignore OpenTUI text nodes take fg via the style prop (TextNodeOptions)
83
+ <span style={{ fg: ui.accent }}>{"│ "}</span>}
84
+ {spansToNodes(ln.spans, kp)}
85
+ </text>
86
+ );
87
+ case "rule":
88
+ return <text fg={ui.fgMuted}>{"────────"}</text>;
89
+ case "code": {
90
+ const lines = highlightCode(ln.code, ln.lang);
91
+ return (
92
+ <box backgroundColor={ui.bgPanel} paddingX={1} flexDirection="column">
93
+ {lines.map((toks, j) => (
94
+ <CodeLine toks={toks} k={kp + "-c" + j} />
95
+ ))}
96
+ </box>
97
+ );
98
+ }
99
+ default:
100
+ return <text>{spansToNodes(ln.spans, kp)}</text>;
101
+ }
102
+ })}
103
+ </box>
104
+ );
105
+ }