loom-agent 1.2.33 → 1.2.37

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 (39) hide show
  1. package/CHANGELOG.md +118 -0
  2. package/README.md +14 -30
  3. package/bin/loom-bun.js +95 -67
  4. package/bin/loom-tui.js +21 -1
  5. package/bin/loom.js +0 -10
  6. package/package.json +14 -9
  7. package/scripts/postinstall.js +57 -5
  8. package/src/connectors/convex.js +356 -0
  9. package/src/connectors/deploy.js +390 -0
  10. package/src/connectors/index.js +23 -0
  11. package/src/connectors/supabase.js +279 -0
  12. package/src/core/agents.js +32 -2
  13. package/src/core/cli.js +56 -7
  14. package/src/core/permissions.js +12 -1
  15. package/src/core/plugin-cmd.js +1 -0
  16. package/src/core/session.js +87 -6
  17. package/src/mcp/mcp-client.js +42 -2
  18. package/src/skills/skills-manager.js +25 -2
  19. package/src/tools/browser.js +204 -0
  20. package/src/tools/index.js +116 -5
  21. package/src/tui/App.tsx +19 -4
  22. package/src/tui/components/BreadcrumbBar.tsx +10 -9
  23. package/src/tui/components/ChatArea.tsx +3 -3
  24. package/src/tui/components/InputBar.tsx +22 -11
  25. package/src/tui/components/MdText.tsx +2 -1
  26. package/src/tui/components/Modals.tsx +31 -17
  27. package/src/tui/components/PermissionPopup.tsx +15 -12
  28. package/src/tui/components/Sidebar.tsx +6 -2
  29. package/src/tui/components/SplashScreen.tsx +17 -24
  30. package/src/tui/components/SubagentPanel.tsx +1 -1
  31. package/src/tui/components/ToastOverlay.tsx +23 -12
  32. package/src/tui/store.ts +4 -2
  33. package/src/tui/theme.ts +45 -30
  34. package/src/tui/tool-display.ts +13 -2
  35. package/docs/web.md +0 -205
  36. package/src/web/attach.js +0 -242
  37. package/src/web/graph-view.html +0 -262
  38. package/src/web/index.html +0 -824
  39. package/src/web/web-server.js +0 -470
@@ -35,12 +35,16 @@ export function ModalFrame(props: { title: string; subtitle?: string; children:
35
35
  // Floating panel: centered over the terminal but NOT a full-screen takeover —
36
36
  // no backdrop fill, so the chat and any running agent stay visible behind it
37
37
  // ("same window, hovering"). Keys still belong to the modal while open.
38
+ // Border is subtle (ui.border) — the title carries the accent, no divider
39
+ // rules inside (they read as a grid).
38
40
  return (
39
41
  <box position="absolute" top={0} left={0} right={0} bottom={0}
40
42
  alignItems="center" justifyContent="center" flexDirection="column">
41
- <box border borderStyle="rounded" borderColor={ui.primary} backgroundColor={ui.bgPanel}
42
- paddingX={3} paddingY={2} flexDirection="column" minWidth={52} maxWidth={72}>
43
- <text fg={ui.primary}>{props.title}</text>
43
+ <box border borderStyle="rounded" borderColor={ui.border} backgroundColor={ui.bgPanel}
44
+ paddingX={3} paddingY={1} flexDirection="column" minWidth={52} maxWidth={72}>
45
+ <box flexDirection="row" alignItems="center">
46
+ <text fg={ui.primary}>{"◆ " + props.title}</text>
47
+ </box>
44
48
  {props.subtitle ? <text fg={ui.fgMuted} marginTop={0}>{props.subtitle}</text> : null}
45
49
  <box flexDirection="column" marginTop={1}>{props.children}</box>
46
50
  {props.footer ? <text fg={ui.fgMuted} marginTop={1}>{props.footer}</text> : null}
@@ -298,6 +302,7 @@ export function SelectModal(props: {
298
302
  return (
299
303
  <box
300
304
  flexDirection="row" paddingLeft={2}
305
+ backgroundColor={active ? ui.bgPanelAlt : undefined}
301
306
  // Hover moves the selection (live theme preview via onPreview) —
302
307
  // but only for genuine pointer movement, never while a
303
308
  // keyboard/wheel scroll is settling (see hoverLockUntil).
@@ -306,10 +311,10 @@ export function SelectModal(props: {
306
311
  onMouseUp={() => clickRow(abs)}
307
312
  >
308
313
  <text fg={active ? ui.primary : ui.fgDim}>
309
- {(active ? " > " : " ") + fit(opt.label, LABEL_MAX)}
314
+ {(active ? " ▸ " : " ") + fit(opt.label, LABEL_MAX)}
310
315
  </text>
311
- {opt.recent ? <text fg={ui.fgMuted}> {" \u2713 recent"}</text> : null}
312
- {opt.sub ? <text fg={ui.fgDim}> {"(" + fit(opt.sub, SUB_MAX) + ")"}</text> : null}
316
+ {opt.recent ? <text fg={ui.fgMuted}> {" ✓ recent"}</text> : null}
317
+ {opt.sub ? <text fg={active ? ui.fg : ui.fgDim}> {"(" + fit(opt.sub, SUB_MAX) + ")"}</text> : null}
313
318
  </box>
314
319
  );
315
320
  })}
@@ -615,25 +620,31 @@ function ServerBrowser(props: { kind: "mcp" | "connector" }) {
615
620
  // capped so the command column keeps ~28 chars. Without an explicit width the
616
621
  // name text yoga-shrinks to zero width when the command is long.
617
622
  const nameW = () => Math.min(30, Math.max(18, ...servers().map(s => String(s.name).length + 9)));
618
- // Command column budget: modal inner ~64 chars minus name column minus the
619
- // "→ " arrow — any longer and the text pixel-punches past the right border.
620
- const cmdW = () => Math.max(24, 62 - nameW());
623
+ // Command column budget: modal inner ~62 chars (64 minus the row's left
624
+ // padding) minus name column — any longer and the text pixel-punches past
625
+ // the right border.
626
+ const cmdW = () => Math.max(22, 60 - nameW());
621
627
 
622
628
  return (
623
- <ModalFrame title={title} subtitle={"Enter toggles a server on/off" + rangeSub()} footer={kbNav().submit + " toggle | A add " + (kind === "connector" ? "connector" : "server") + " | wheel scroll | " + kbNav().cancel + " close"}>
629
+ <ModalFrame title={title} subtitle={"Enter toggles a server" + rangeSub()} footer={kbNav().submit + " toggle · A add · " + kbNav().cancel + " close"}>
624
630
  <box onMouseScroll={scrollBy} flexDirection="column" flexShrink={0}>
625
631
  {win().items.map((s, i) => {
626
632
  const abs = win().start + i;
633
+ const active = abs === sel();
627
634
  return (
628
635
  <box
629
- flexDirection="row" paddingY={0} height={1} flexShrink={0}
636
+ flexDirection="row" paddingY={0} height={1} flexShrink={0}
637
+ paddingLeft={1}
638
+ backgroundColor={active ? ui.bgPanelAlt : undefined}
630
639
  onMouseDown={() => setSel(abs)}
631
640
  onMouseUp={() => { if (abs === sel()) { const r = toggleServer(s.name); if (!r || !r.error) refresh(); } }}
632
641
  >
633
- <text fg={abs === sel() ? ui.primary : ui.fgDim} width={nameW()} height={1} flexShrink={0}>
634
- {" " + (s.enabled ? "[on] " : "[off] ") + s.name}
642
+ <text fg={active ? ui.primary : ui.fgDim} width={nameW()} height={1} flexShrink={0}>
643
+ {(active ? "▸ " : " ") + "[" + (s.enabled ? "on" : "off") + "] " + s.name}
644
+ </text>
645
+ <text fg={s.enabled ? ui.success : ui.fgMuted} height={1} flexGrow={1}>
646
+ {mcpFit(s.command + " " + (s.args || []).join(" "), cmdW())}
635
647
  </text>
636
- <text fg={ui.fgMuted} height={1} flexGrow={1}>{"\u2192 " + mcpFit(s.command + " " + (s.args || []).join(" "), cmdW())}</text>
637
648
  </box>
638
649
  );
639
650
  })}
@@ -705,15 +716,18 @@ export function PaletteModal(props: { onPick: (cmd: string) => void }) {
705
716
  <box onMouseScroll={scrollBy}>
706
717
  {win().items.map((it, i) => {
707
718
  const abs = win().start + i;
719
+ const active = abs === sel();
708
720
  return (
709
721
  <box
710
- flexDirection="row" paddingY={0}
722
+ flexDirection="row" paddingY={0}
723
+ backgroundColor={active ? ui.bgPanelAlt : undefined}
711
724
  onMouseDown={() => setSel(abs)}
712
725
  onMouseUp={() => clickRow(abs)}
713
726
  >
714
- <text fg={abs === sel() ? ui.primary : ui.fgDim}>
715
- {abs === sel() ? "> " : " "}{it.label.split("/")[1] || it.label}
727
+ <text fg={active ? ui.primary : ui.fgDim}>
728
+ {active ? "▸ " : " "}{it.label.split("/")[1] || it.label}
716
729
  </text>
730
+ {it.sub ? <text fg={active ? ui.fg : ui.fgMuted}>{" " + String(it.sub).slice(0, 36)}</text> : null}
717
731
  </box>
718
732
  );
719
733
  })}
@@ -200,44 +200,47 @@ function PermissionPrompt() {
200
200
  <box flexDirection="column" marginTop={1} gap={0}>
201
201
  {isQ ? (
202
202
  opts.map((o, i) => (
203
- <box {...row(i)} flexDirection="row" paddingY={0}>
203
+ <box {...row(i)} flexDirection="row" paddingY={0}
204
+ backgroundColor={sel() === i ? ui.bgPanelAlt : ui.bgPanel}>
204
205
  <text fg={sel() === i ? ui.primary : ui.fgDim} paddingRight={1}>
205
- {(sel() === i ? "\u25B6 " : " ") + fit(o, 40)}
206
+ {(sel() === i ? "▸ " : " ") + fit(o, 40)}
206
207
  </text>
207
208
  </box>
208
209
  ))
209
210
  ) : (
210
211
  <>
211
- <box {...row(0)} flexDirection="row" paddingY={0}>
212
+ <box {...row(0)} flexDirection="row" paddingY={0}
213
+ backgroundColor={sel() === 0 ? ui.bgPanelAlt : ui.bgPanel}>
212
214
  <text fg={sel() === 0 ? ui.primary : ui.fgDim} paddingRight={1}>
213
- {(sel() === 0 ? "\u25B6 " : " ") + "Allow"}
215
+ {(sel() === 0 ? "▸ " : " ") + "Allow"}
214
216
  </text>
215
217
  <text fg={ui.success}>{"(recommended)"}</text>
216
218
  </box>
217
- <box {...row(1)} flexDirection="row" paddingY={0}>
219
+ <box {...row(1)} flexDirection="row" paddingY={0}
220
+ backgroundColor={sel() === 1 ? ui.bgPanelAlt : ui.bgPanel}>
218
221
  <text fg={sel() === 1 ? ui.primary : ui.fgDim} paddingRight={1}>
219
- {(sel() === 1 ? "\u25B6 " : " ") + "Always allow"}
222
+ {(sel() === 1 ? "▸ " : " ") + "Always allow"}
220
223
  </text>
221
224
  <text fg={ui.fgMuted}>{"remember for this command"}</text>
222
225
  </box>
223
- <box {...row(2)}>
226
+ <box {...row(2)} backgroundColor={sel() === 2 ? ui.bgPanelAlt : ui.bgPanel}>
224
227
  <text fg={sel() === 2 ? ui.primary : ui.fgDim} paddingY={0}>
225
- {(sel() === 2 ? "\u25B6 " : " ") + "Deny"}
228
+ {(sel() === 2 ? "▸ " : " ") + "Deny"}
226
229
  </text>
227
230
  </box>
228
231
  {pr.tool === "bash" ? (
229
- <box {...row(3)}>
232
+ <box {...row(3)} backgroundColor={sel() === 3 ? ui.bgPanelAlt : ui.bgPanel}>
230
233
  <text fg={sel() === 3 ? ui.primary : ui.fgDim} paddingY={0}>
231
- {(sel() === 3 ? "\u25B6 " : " ") + "Allow all commands in this session"}
234
+ {(sel() === 3 ? "▸ " : " ") + "Allow all commands in this session"}
232
235
  </text>
233
236
  </box>
234
237
  ) : null}
235
238
  </>
236
239
  )}
237
240
  {isQ && !isStart ? (
238
- <box {...row(opts.length)}>
241
+ <box {...row(opts.length)} backgroundColor={sel() === opts.length ? ui.bgPanelAlt : ui.bgPanel}>
239
242
  <text fg={sel() === opts.length ? ui.primary : ui.fgDim} paddingY={0}>
240
- {(sel() === opts.length ? "\u25B6 " : " ") + "Type your answer\u2026"}
243
+ {(sel() === opts.length ? "▸ " : " ") + "Type your answer…"}
241
244
  </text>
242
245
  </box>
243
246
  ) : null}
@@ -71,7 +71,7 @@ function speedInfo(sp: any): { label: string; color: string } {
71
71
  if (tps == null) color = ui.fgMuted;
72
72
  else if (tps >= 25 && (latency == null || latency <= 2500)) color = ui.success;
73
73
  else if (tps >= 8 || (latency != null && latency <= 6000)) color = ui.warning;
74
- else color = "#ff5555";
74
+ else color = ui.error;
75
75
  return { label, color };
76
76
  }
77
77
 
@@ -86,6 +86,8 @@ export function Sidebar(props: { show: boolean }) {
86
86
  <text fg={ui.fgMuted} height={1} flexShrink={0}>{"v" + VERSION}</text>
87
87
  </box>
88
88
 
89
+ <text fg={ui.border} height={1} flexShrink={0}>{"─".repeat(36)}</text>
90
+
89
91
  <box flexDirection="column" height={skillActive().length > 0 ? 8 : 7} flexShrink={0}>
90
92
  <box flexDirection="row" height={1} flexShrink={0}><text fg={ui.fgMuted}>{"Provider: "}</text><text fg={ui.primary}>{providerName()}</text></box>
91
93
  <box flexDirection="row" height={1} flexShrink={0}><text fg={ui.fgMuted}>{"Model: "}</text><text fg={ui.fg}>{modelName()}</text></box>
@@ -106,11 +108,13 @@ export function Sidebar(props: { show: boolean }) {
106
108
  ) : null}
107
109
  </box>
108
110
 
111
+ <text fg={ui.border} height={1} flexShrink={0}>{"─".repeat(36)}</text>
112
+
109
113
  <box flexDirection="row" gap={1} flexShrink={0}>
110
114
  {TAB_NAMES.map((name, i) => (
111
115
  <text fg={sidebarTab() === i ? ui.primary : ui.fgMuted} height={1} flexShrink={0}
112
116
  onMouseUp={() => setSidebarTab(i)}>
113
- {" " + name + " "}
117
+ {(sidebarTab() === i ? "▌" : " ") + name + " "}
114
118
  </text>
115
119
  ))}
116
120
  </box>
@@ -1,21 +1,8 @@
1
1
  // Splash screen -- logo + embedded InputBar when no messages yet. Signal reads in JSX.
2
- import fs from "fs";
3
- import path from "path";
4
- import { palette, LOOM_LOGO } from "../theme.ts";
2
+ import { palette, LOOM_LOGO, VERSION } from "../theme.ts";
5
3
  import { providerName, modelName, providerKeyOk } from "../store.ts";
6
4
  import { InputBar } from "./InputBar.tsx";
7
5
 
8
- // Read the real version from package.json (same source as `loom --version`),
9
- // never a hardcoded label that drifts out of sync with releases.
10
- const LOOM_VERSION = (() => {
11
- try {
12
- const pkg = fs.readFileSync(path.join(import.meta.dir, "..", "..", "package.json"), "utf8");
13
- return "v" + pkg.match(/"version":\s*"([^"]+)"/)[1];
14
- } catch {
15
- return "";
16
- }
17
- })();
18
-
19
6
  export function SplashScreen() {
20
7
  const ui = palette("loom");
21
8
 
@@ -24,23 +11,29 @@ export function SplashScreen() {
24
11
  <box flexGrow={1} />
25
12
 
26
13
  <box flexDirection="column" alignItems="center" marginBottom={1}>
27
- {LOOM_LOGO.map((line, i) => (
14
+ {LOOM_LOGO.map((line) => (
28
15
  <text fg={ui.primary}>{line}</text>
29
16
  ))}
30
17
  </box>
31
18
 
32
- <box marginTop={2} flexDirection="row">
33
- <text fg={ui.primary}>{"Build"}</text>
34
- <text fg={ui.fgDim}>{" "}</text>
35
- <text fg={ui.fg}>{providerName()}</text>
36
- <text fg={ui.fgDim}>{" "}</text>
19
+ <text fg={ui.border}>{"─".repeat(60)}</text>
20
+
21
+ <box marginTop={1} flexDirection="row" alignItems="center">
22
+ {/* OpenTUI text nodes take fg/bg via the style prop — a direct
23
+ backgroundColor prop is silently ignored (the pill vanished). */}
24
+ {/* @ts-ignore styled span for the build chip */}
25
+ <text><span style={{ fg: ui.bgPanel, bg: ui.primary }}>{" build "}</span></text>
26
+ <text fg={ui.fgMuted}>{" " + providerName()}</text>
27
+ <text fg={ui.fgMuted}>{" / "}</text>
37
28
  <text fg={ui.fg}>{modelName()}</text>
38
- <text fg={ui.fgDim}>{" "}</text>
39
- <text fg={providerKeyOk() ? "green" : "yellow"}>{providerKeyOk() ? "connected" : "no key"}</text>
29
+ <text fg={ui.fgMuted}>{" "}</text>
30
+ <text fg={providerKeyOk() ? ui.success : ui.warning}>
31
+ {"● " + (providerKeyOk() ? "connected" : "no key")}
32
+ </text>
40
33
  </box>
41
34
 
42
35
  <box marginTop={0}>
43
- <text fg={ui.fgMuted}>{LOOM_VERSION}</text>
36
+ <text fg={ui.fgMuted}>{"v" + VERSION}</text>
44
37
  </box>
45
38
 
46
39
  <box marginY={1} width={74}>
@@ -58,4 +51,4 @@ export function SplashScreen() {
58
51
  <box flexGrow={1} />
59
52
  </box>
60
53
  );
61
- }
54
+ }
@@ -25,7 +25,7 @@ function statusColor(s: SubagentEntry["status"]): string {
25
25
  if (s === "running") return ui.warning || ui.primary;
26
26
  if (s === "done") return ui.success || ui.primary;
27
27
  if (s === "cancelled") return ui.fgMuted;
28
- return "#ff5555";
28
+ return ui.error || "#ff5555";
29
29
  }
30
30
 
31
31
  function fmtCost(c: number): string {
@@ -15,18 +15,29 @@ export function ToastOverlay() {
15
15
  flexDirection="column" paddingX={1} paddingY={0}
16
16
  >
17
17
  <For each={list()}>
18
- {(t) => (
19
- <box
20
- border borderStyle="rounded"
21
- borderColor={t.kind === "error" ? ui.error : t.kind === "ok" ? ui.success : ui.primary}
22
- paddingX={1} paddingY={0} marginTop={0}
23
- backgroundColor={ui.bgPanel}
24
- >
25
- <text fg={t.kind === "error" ? ui.error : t.kind === "ok" ? ui.success : ui.fg}>
26
- {" " + t.text}
27
- </text>
28
- </box>
29
- )}
18
+ {(t) => {
19
+ const c = t.kind === "error" ? ui.error : t.kind === "ok" ? ui.success : ui.primary;
20
+ const dot = t.kind === "error" ? "✖" : t.kind === "ok" ? "●" : "◆";
21
+ // Cap the rendered width: a wide toast overlaps the chat's last
22
+ // rows (the box is opaque) — the store keeps the full text, only
23
+ // the floating chip clips with an ellipsis.
24
+ const body = t.text.length > 40 ? t.text.slice(0, 39) + "\u2026" : t.text;
25
+ return (
26
+ <box
27
+ border borderStyle="rounded"
28
+ borderColor={c}
29
+ paddingX={1} paddingY={0} marginTop={0}
30
+ backgroundColor={ui.bgPanel}
31
+ >
32
+ <text fg={c}>
33
+ {" " + dot + " "}
34
+ </text>
35
+ <text fg={t.kind === "error" ? ui.error : ui.fg}>
36
+ {body}
37
+ </text>
38
+ </box>
39
+ );
40
+ }}
30
41
  </For>
31
42
  </box>
32
43
  </Show>
package/src/tui/store.ts CHANGED
@@ -324,7 +324,9 @@ export function allModelOptions() {
324
324
  if (!m) continue;
325
325
  const key = r.provider + "/" + r.model;
326
326
  seen.add(key);
327
- out.push({ label: m.name, value: m.id, sub: m.id, provider: r.provider, tags: m.tags, recent: true });
327
+ // No sub: the full id repeated under every name was pure clutter — the
328
+ // id is still searchable (filtered() matches value + tags).
329
+ out.push({ label: m.name, value: m.id, provider: r.provider, tags: m.tags, recent: true });
328
330
  }
329
331
  }
330
332
  for (const p of PROVIDER_ORDER) {
@@ -337,7 +339,7 @@ export function allModelOptions() {
337
339
  for (const m of mods) {
338
340
  const key = p + "/" + m.id;
339
341
  if (seen.has(key)) continue;
340
- out.push({ label: m.name, value: m.id, sub: m.id, provider: p, tags: m.tags });
342
+ out.push({ label: m.name, value: m.id, provider: p, tags: m.tags });
341
343
  }
342
344
  }
343
345
  out.push({ label: "(custom model ID)", value: "__custom__" });
package/src/tui/theme.ts CHANGED
@@ -2,6 +2,8 @@
2
2
  // palette() returns a live Proxy: each property is read from the currently
3
3
  // selected theme at render time, so switching themes re-renders instantly.
4
4
  import { createSignal } from "solid-js";
5
+ import fs from "fs";
6
+ import path from "path";
5
7
  import { loadTuiJson, saveTuiJson } from "./tui-config.ts";
6
8
 
7
9
  export type Palette = {
@@ -22,49 +24,50 @@ export type Palette = {
22
24
  export type Theme = { id: string; label: string; desc: string; palette: Palette };
23
25
 
24
26
  const LOOM_DARK: Palette = {
25
- // Deep warm charcoal — terracotta accents
26
- bg: "#191817",
27
- bgMsg: "#131211",
28
- bgPanel: "#211f1e",
29
- bgPanelAlt: "#2a2725",
30
- bgInput: "#25221f",
31
- bgHover: "#322e2a",
32
- border: "#4a4440",
33
- borderFocus: "#e07856",
34
- fg: "#f0ebe4",
35
- fgDim: "#a89e93",
36
- fgMuted: "#6e655c",
37
- primary: "#e07856",
38
- primarySoft: "#f4a27f",
39
- secondary: "#b8d0a8",
40
- accent: "#e8b06f",
27
+ // Deep warm charcoal — terracotta accents (ember layers: every surface
28
+ // steps clearly toward bgPanelAlt so panels read without any borders)
29
+ bg: "#171512",
30
+ bgMsg: "#121009",
31
+ bgPanel: "#1f1c18",
32
+ bgPanelAlt: "#282420",
33
+ bgInput: "#201d18",
34
+ bgHover: "#322c24",
35
+ border: "#463f36",
36
+ borderFocus: "#e8845c",
37
+ fg: "#f2ede4",
38
+ fgDim: "#a89d8d",
39
+ fgMuted: "#6b6252",
40
+ primary: "#e8845c",
41
+ primarySoft: "#f5a883",
42
+ secondary: "#a8c496",
43
+ accent: "#edb56f",
41
44
  success: "#a3c084",
42
45
  warning: "#e0b356",
43
- error: "#e06c5a",
46
+ error: "#e06858",
44
47
  thinking: "#d9a35f",
45
48
  syntax: {
46
- kw: "#e07856",
49
+ kw: "#e8845c",
47
50
  str: "#a3c084",
48
- com: "#6e655c",
51
+ com: "#6b6252",
49
52
  num: "#e0b356",
50
- call: "#e8b06f",
51
- plain: "#f0ebe4",
53
+ call: "#edb56f",
54
+ plain: "#f2ede4",
52
55
  },
53
56
  };
54
57
 
55
58
  const LIGHT: Palette = {
56
59
  // Paper light — warm parchment with terracotta
57
- bg: "#faf7f2",
58
- bgMsg: "#efe9df",
59
- bgPanel: "#f3eee6",
60
- bgPanelAlt: "#eae3d8",
60
+ bg: "#faf7f0",
61
+ bgMsg: "#efe8dc",
62
+ bgPanel: "#f2ece1",
63
+ bgPanelAlt: "#e8e0d1",
61
64
  bgInput: "#ffffff",
62
- bgHover: "#e0d6c8",
63
- border: "#c9bfae",
65
+ bgHover: "#ddd3c2",
66
+ border: "#c4b9a5",
64
67
  borderFocus: "#c2593a",
65
68
  fg: "#2b2620",
66
69
  fgDim: "#6f675c",
67
- fgMuted: "#9a9082",
70
+ fgMuted: "#968c7c",
68
71
  primary: "#c2593a",
69
72
  primarySoft: "#a34a2f",
70
73
  secondary: "#5c7a4a",
@@ -76,7 +79,7 @@ const LIGHT: Palette = {
76
79
  syntax: {
77
80
  kw: "#c2593a",
78
81
  str: "#5f8a3d",
79
- com: "#9a9082",
82
+ com: "#968c7c",
80
83
  num: "#a97a1a",
81
84
  call: "#b0742f",
82
85
  plain: "#2b2620",
@@ -301,8 +304,20 @@ export function palette(_name?: string): Palette {
301
304
  }) as Palette;
302
305
  }
303
306
 
304
- export const VERSION = "1.2.0";
307
+ // Version read from package.json — the single source of truth for `loom
308
+ // --version`, the splash, and the sidebar (a hardcoded const drifted to 1.2.0
309
+ // while the package moved on).
310
+ export const VERSION = (() => {
311
+ try {
312
+ const pkg = JSON.parse(fs.readFileSync(path.join(import.meta.dir, "..", "..", "package.json"), "utf8"));
313
+ return String(pkg.version || "0.0.0");
314
+ } catch {
315
+ return "0.0.0";
316
+ }
317
+ })();
305
318
 
319
+ // Full "LOOM CODE" block wordmark for the splash, rendered in the theme's
320
+ // primary (terracotta on the default theme).
306
321
  export const LOOM_LOGO = [
307
322
  " ██╗ ██████╗ ██████╗ ███╗ ███╗ ██████╗ ██████╗ ██████╗ ███████╗",
308
323
  " ██║ ██╔═══██╗██╔═══██╗████╗ ████║ ██╔════╝██╔═══██╗██╔══██╗██╔════╝",
@@ -87,9 +87,20 @@ const TOOL_DISPLAY: Record<string, ToolDisplay> = Object.assign(Object.create(nu
87
87
  diffs: null,
88
88
  },
89
89
  websearch: {
90
- icon: "\u25C8",
90
+ icon: "◈",
91
91
  pending: "Searching web...",
92
- label: (i) => "WebSearch \u0022" + String(i?.query || "") + "\u0022",
92
+ label: (i) => "WebSearch \"" + String(i?.query || "") + "\"",
93
+ diffs: null,
94
+ },
95
+ browser: {
96
+ icon: "◉",
97
+ pending: "Browsing...",
98
+ spinner: true,
99
+ label: (i) => {
100
+ const a = String(i?.action || "");
101
+ const target = String(i?.url || i?.selector || i?.key || "");
102
+ return "Browser " + a + (target ? " " + target.slice(0, 60) : "");
103
+ },
93
104
  diffs: null,
94
105
  },
95
106
  todowrite: {