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.
- package/.env.example +25 -0
- package/CHANGELOG.md +402 -0
- package/LICENSE +21 -0
- package/LOOM.md +235 -0
- package/README.md +433 -0
- package/bin/loom-tui.js +43 -0
- package/bin/loom.js +44 -0
- package/docs/acp.md +151 -0
- package/docs/web.md +205 -0
- package/package.json +97 -0
- package/scripts/acp-smoke.js +146 -0
- package/src/acp/acp-server.js +287 -0
- package/src/config/provider-cmd.js +37 -0
- package/src/config/settings.js +164 -0
- package/src/core/agents.js +361 -0
- package/src/core/background-tasks.js +103 -0
- package/src/core/cli.js +579 -0
- package/src/core/custom-commands.js +70 -0
- package/src/core/errors.js +29 -0
- package/src/core/events.js +24 -0
- package/src/core/file-diffs.js +282 -0
- package/src/core/format.js +206 -0
- package/src/core/graph.js +257 -0
- package/src/core/hooks.js +82 -0
- package/src/core/lsp.js +385 -0
- package/src/core/memory.js +87 -0
- package/src/core/model-router.js +87 -0
- package/src/core/permissions.js +327 -0
- package/src/core/platform.js +33 -0
- package/src/core/plugin-cmd.js +380 -0
- package/src/core/restore.js +207 -0
- package/src/core/session-store.js +167 -0
- package/src/core/session.js +910 -0
- package/src/core/subagent-log.js +134 -0
- package/src/core/tokens.js +31 -0
- package/src/core/update.js +6 -0
- package/src/core/usage.js +166 -0
- package/src/index.js +41 -0
- package/src/mcp/mcp-client.js +201 -0
- package/src/mcp/mcp-manager.js +193 -0
- package/src/providers/anthropic.js +243 -0
- package/src/providers/google.js +29 -0
- package/src/providers/index.js +175 -0
- package/src/providers/local.js +27 -0
- package/src/providers/nvidia.js +85 -0
- package/src/providers/openai-compat.js +269 -0
- package/src/providers/openai.js +35 -0
- package/src/providers/openrouter.js +43 -0
- package/src/providers/registry.js +196 -0
- package/src/providers/tokenrouter.js +19 -0
- package/src/skills/skill-matcher.js +133 -0
- package/src/skills/skills-manager.js +213 -0
- package/src/tools/index.js +543 -0
- package/src/tui/App.tsx +1578 -0
- package/src/tui/components/BreadcrumbBar.tsx +34 -0
- package/src/tui/components/ChatArea.tsx +518 -0
- package/src/tui/components/InputBar.tsx +354 -0
- package/src/tui/components/MdText.tsx +105 -0
- package/src/tui/components/Modals.tsx +851 -0
- package/src/tui/components/PermissionPopup.tsx +264 -0
- package/src/tui/components/Sidebar.tsx +182 -0
- package/src/tui/components/SplashScreen.tsx +51 -0
- package/src/tui/components/SubagentPanel.tsx +217 -0
- package/src/tui/components/ToastOverlay.tsx +34 -0
- package/src/tui/keybinds.ts +318 -0
- package/src/tui/mcp-presets.ts +189 -0
- package/src/tui/md-render.ts +228 -0
- package/src/tui/store.ts +714 -0
- package/src/tui/suite-home.ts +20 -0
- package/src/tui/theme.ts +313 -0
- package/src/tui/themes.generated.ts +968 -0
- package/src/tui/tool-display.ts +176 -0
- package/src/tui/toolname.ts +60 -0
- package/src/tui/tui-config.ts +28 -0
- package/src/tui-open.tsx +51 -0
- package/src/web/attach.js +242 -0
- package/src/web/graph-view.html +262 -0
- package/src/web/index.html +824 -0
- 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
|
+
}
|