loom-agent 1.2.36 → 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.
@@ -1,6 +1,7 @@
1
- // Top breadcrumb bar — project path, session info, model.
1
+ // Top breadcrumb bar — project path, session info, model. bgPanel surface
2
+ // plus a dim right-hand cluster; no frame (the TUI stays grid-line-free).
2
3
  import { palette } from "../theme.ts";
3
- import { providerName, modelName, sessionId, cwdShort, username, inputMode } from "../store.ts";
4
+ import { providerName, modelName, sessionId, cwdShort, inputMode } from "../store.ts";
4
5
 
5
6
  const MODE_LABELS: Record<string, string> = { build: "B", plan: "P", chat: "C" };
6
7
 
@@ -15,19 +16,19 @@ export function BreadcrumbBar() {
15
16
  paddingX={1}
16
17
  backgroundColor={ui.bgPanel}
17
18
  >
18
- <box flexDirection="row">
19
+ <box flexDirection="row" alignItems="center">
19
20
  <text fg={ui.primary}>{"loom"}</text>
20
- <text fg={ui.fgMuted}>{" / "}</text>
21
+ <text fg={ui.fgMuted}>{" · "}</text>
21
22
  <text fg={ui.fg}>{cwdShort() || "~"}</text>
22
- <text fg={ui.fgMuted}>{" · "}</text>
23
- <text fg={ui.fgDim}>{sessionId().slice(0, 8)}</text>
24
- <text fg={ui.fgMuted}>{" · "}</text>
23
+ <text fg={ui.fgMuted}>{" · "}</text>
24
+ <text fg={ui.fgMuted}>{sessionId().slice(0, 8)}</text>
25
+ <text fg={ui.fgMuted}>{" "}</text>
25
26
  <text fg={modeColors[inputMode()] || ui.primary}>{"[" + (MODE_LABELS[inputMode()] || "B") + "]"}</text>
26
27
  </box>
27
- <box flexDirection="row">
28
+ <box flexDirection="row" alignItems="center">
28
29
  <text fg={ui.secondary}>{providerName()}</text>
29
30
  <text fg={ui.fgMuted}>{" / "}</text>
30
- <text fg={ui.fg}>{modelName()}</text>
31
+ <text fg={ui.fgDim}>{modelName()}</text>
31
32
  </box>
32
33
  </box>
33
34
  );
@@ -441,8 +441,8 @@ function MsgBubble(props: { m: any; idx: number }) {
441
441
 
442
442
  if (m.role === "system") {
443
443
  return (
444
- <box marginBottom={1} paddingX={2}>
445
- <text fg={ui.fgMuted}>{m.content}</text>
444
+ <box marginBottom={1} paddingX={2} backgroundColor={ui.bgMsg}>
445
+ <text fg={ui.fgMuted}>{"· " + m.content}</text>
446
446
  </box>
447
447
  );
448
448
  }
@@ -486,7 +486,7 @@ function MsgBubble(props: { m: any; idx: number }) {
486
486
  {!todoShown && m.todos?.length ? <TodosBlock todos={m.todos} /> : null}
487
487
  {!isThink ? (
488
488
  <box flexDirection="row" paddingLeft={3} marginTop={1}>
489
- <text fg={isErr ? ui.error : ui.secondary}>{"\u25A3 " + (agentTag || "Loom")}</text>
489
+ <text fg={isErr ? ui.error : ui.primary}>{"\u25A3 " + (agentTag || "Loom")}</text>
490
490
  <text fg={ui.fgMuted}>
491
491
  {(m.thinkTime ? " \u00B7 " + fmtMs(m.thinkTime) : "") + (isStop ? " \u00B7 interrupted" : "")}
492
492
  </text>
@@ -201,17 +201,19 @@ export function InputBar() {
201
201
  </text>
202
202
  {slashWin().items.map((item, i) => {
203
203
  const abs = slashWin().start + i;
204
+ const act = abs === autoIndex();
204
205
  return (
205
206
  <box
206
- flexDirection="row" paddingY={0}
207
+ flexDirection="row" paddingY={0}
208
+ backgroundColor={act ? ui.bgPanelAlt : undefined}
207
209
  onMouseDown={() => selectSuggestionAt(abs)}
208
210
  onMouseUp={() => pickSuggestionAt(abs)}
209
211
  >
210
- <text fg={abs === autoIndex() ? ui.primary : ui.fgDim}>
211
- {(abs === autoIndex() ? "\u25B6 " : " ") + item.label.split(" ")[0]}
212
+ <text fg={act ? ui.primary : ui.fgDim}>
213
+ {(act ? "▸ " : " ") + item.label.split(" ")[0]}
212
214
  </text>
213
215
  <Show when={!!item.desc}>
214
- <text fg={abs === autoIndex() ? ui.fg : ui.fgMuted}>
216
+ <text fg={act ? ui.fg : ui.fgMuted}>
215
217
  {" " + item.desc}
216
218
  </text>
217
219
  </Show>
@@ -230,15 +232,19 @@ export function InputBar() {
230
232
  >
231
233
  {fileWin().items.map((item, i) => {
232
234
  const abs = fileWin().start + i;
235
+ const act = abs === autoIndex();
233
236
  return (
234
- <text
235
-
236
- fg={abs === autoIndex() ? ui.primary : ui.fgDim}
237
+ // Box wrapper: text nodes ignore a direct backgroundColor prop.
238
+ <box
239
+ flexDirection="row"
240
+ backgroundColor={act ? ui.bgPanelAlt : undefined}
237
241
  onMouseDown={() => selectSuggestionAt(abs)}
238
242
  onMouseUp={() => pickSuggestionAt(abs)}
239
243
  >
240
- {(abs === autoIndex() ? "> " : " ") + item.label}
241
- </text>
244
+ <text fg={act ? ui.primary : ui.fgDim}>
245
+ {(act ? "▸ " : " ") + item.label}
246
+ </text>
247
+ </box>
242
248
  );
243
249
  })}
244
250
  <text fg={ui.fgMuted}>{" UP/DN TAB/ENTER pick wheel/click ESC close" + rangeHint(fileWin().start, fileWin().total)}</text>
@@ -271,8 +277,13 @@ export function InputBar() {
271
277
  <box flexGrow={1} flexDirection="column" justifyContent="center">
272
278
  <text>
273
279
  {/* @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>
280
+ <span style={{ fg: showCursor() ? ui.fg : ui.bgInput }}>{"\u2588"}</span>
281
+ <span style={{ fg: ui.fgMuted }}>{" Ask anything... "}</span>
282
+ <span style={{ fg: ui.primary }}>{"/commands"}</span>
283
+ <span style={{ fg: ui.fgMuted }}>{" "}</span>
284
+ <span style={{ fg: ui.accent }}>{"@file"}</span>
285
+ <span style={{ fg: ui.fgMuted }}>{" "}</span>
286
+ <span style={{ fg: ui.warning }}>{"!shell"}</span>
276
287
  </text>
277
288
  </box>
278
289
  }
@@ -57,7 +57,8 @@ function spansToNodes(spans: any[], keyPrefix: string) {
57
57
 
58
58
  function HeadingText(props: { level: 1 | 2 | 3; spans: any[]; keyPrefix: string }) {
59
59
  const marker = props.level === 1 ? "# " : props.level === 2 ? "## " : "### ";
60
- return <text fg={ui.primary}>{marker}{spansToNodes(props.spans, props.keyPrefix)}</text>;
60
+ const color = props.level === 1 ? ui.primary : props.level === 2 ? ui.primarySoft : ui.secondary;
61
+ return <text fg={color}>{marker}{spansToNodes(props.spans, props.keyPrefix)}</text>;
61
62
  }
62
63
 
63
64
  export function MdText(props: { md: string; wrap?: boolean }) {
@@ -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: {