loom-agent 1.2.27 → 1.2.33

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/CHANGELOG.md CHANGED
@@ -4,6 +4,31 @@ All notable changes to **Loom Code** are documented here.
4
4
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
5
5
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
+ ## [1.2.33] — fix frozen splash on global installs + stable model picker
8
+
9
+ ### Fixed
10
+ - **Global npm installs froze on the splash screen** ("Build … no key", no
11
+ keyboard input) while repo checkouts worked. Root cause: the launch chain
12
+ started Bun at the package root and later ran `process.chdir()` back to the
13
+ user's project inside `tui-open.tsx`. That mid-flight chdir killed OpenTUI's
14
+ repaint/input pipeline after the first frame — signals kept updating and
15
+ Solid effects kept firing (verified with runtime instrumentation), but no
16
+ frame ever reached the terminal again.
17
+ - The launcher now registers the Solid JSX preloader with **absolute
18
+ `--preload` paths** and starts Bun **directly in the user's project
19
+ directory**, so no chdir ever happens: `bin/loom-bun.js`, `bin/loom-tui.js`,
20
+ the postinstall-rewritten shims, and the core-CLI TUI spawn
21
+ (`src/core/cli.js`) were all switched to that scheme.
22
+ - **Model picker jitter** — scrolling `/models` (and every `SelectModal`:
23
+ `/connect`, theme pickers, MCP preset picker) bounced the whole modal.
24
+ Section headers added an extra margin row, so the centered frame's height
25
+ flipped between 12/13/14 rows on every page of a header-heavy list. The
26
+ list window is now a fixed 12 rows (headers are plain one-row entries), the
27
+ window is computed once per change instead of mutating during render, and
28
+ mouse-hover no longer yanks the selection while a keyboard/wheel scroll is
29
+ settling. Regression test added (29b: modal title row must never move while
30
+ scrolling).
31
+
7
32
  ## [Unreleased]
8
33
 
9
34
  ### Added
package/bin/loom-bun.js CHANGED
@@ -5,21 +5,28 @@ const path = require("path");
5
5
  const fs = require("fs");
6
6
  const { spawnSync } = require("child_process");
7
7
 
8
- // Bun discovers bunfig.toml ONLY from its starting cwd, and the Solid JSX
9
- // plugin ONLY works when loaded through that bunfig preload phase runtime
10
- // registration from another cwd silently no-ops (proven). So unless bun is
11
- // ALREADY sitting in the package root, respawn it there once. The user's
12
- // real directory rides along in LOOM_START_CWD and is restored by
13
- // tui-open.tsx after its imports finish loading.
8
+ // The Solid JSX transform must register in Bun's preload phase, so the
9
+ // preloads are passed as ABSOLUTE paths. That lets Bun start directly in the
10
+ // user's project directory the old "spawn at the package root, then chdir
11
+ // to the project inside tui-open.tsx" dance froze the TUI after the first
12
+ // frame (splash paints once, then no repaints and no keyboard input; proven
13
+ // by A/B-launching the identical entry with and without the mid-flight
14
+ // process.chdir). No chdir ever happens now: LOOM_START_CWD always equals
15
+ // the starting directory, so the restore in tui-open.tsx is a no-op kept
16
+ // only as a safety net.
14
17
  const pkgRoot = path.join(__dirname, "..");
15
18
  const underBun = typeof Bun !== "undefined" && !!process.versions.bun;
16
19
  if (!underBun) {
17
20
  const result = spawnSync(
18
21
  process.platform === "win32" ? "bun.exe" : "bun",
19
- [__filename, ...process.argv.slice(2)],
22
+ [
23
+ "--preload", path.join(pkgRoot, "src", "tui-preload.js"),
24
+ __filename,
25
+ ...process.argv.slice(2),
26
+ ],
20
27
  {
21
28
  stdio: "inherit",
22
- cwd: pkgRoot,
29
+ cwd: process.env.LOOM_START_CWD || process.cwd(),
23
30
  env: { ...process.env, LOOM_START_CWD: process.env.LOOM_START_CWD || process.cwd() },
24
31
  windowsHide: false,
25
32
  }
@@ -58,4 +65,4 @@ process.title = "loom-code";
58
65
  console.error(err && err.message ? err.message : String(err));
59
66
  process.exit(1);
60
67
  }
61
- })();
68
+ })();
package/bin/loom-tui.js CHANGED
@@ -4,30 +4,35 @@
4
4
  const path = require("path");
5
5
  const { spawnSync } = require("child_process");
6
6
 
7
- // Respawn from the package root so bunfig.toml's preload chain (Solid JSX
8
- // transform) loads natively — see bin/loom-bun.js.
7
+ // Preloads are ABSOLUTE paths so Bun starts directly in the user's project
8
+ // directory — see bin/loom-bun.js for why the package-root + chdir dance is
9
+ // gone (it froze the TUI after the first frame).
9
10
  const pkgRoot = path.join(__dirname, "..");
10
11
  const underBun = typeof Bun !== "undefined" && !!process.versions.bun;
11
12
  if (!underBun) {
12
- const result = spawnSync(
13
- process.platform === "win32" ? "bun.exe" : "bun",
14
- [__filename, ...process.argv.slice(2)],
15
- {
16
- stdio: "inherit",
17
- cwd: pkgRoot,
18
- env: { ...process.env, LOOM_START_CWD: process.env.LOOM_START_CWD || process.cwd() },
19
- windowsHide: false,
20
- }
21
- );
22
- if (result.error) {
23
- console.error("[loom] Bun is required for the TUI. Install it from https://bun.sh/");
24
- process.exit(1);
25
- }
26
- process.exit(result.status == null ? 1 : result.status);
13
+ const result = spawnSync(
14
+ process.platform === "win32" ? "bun.exe" : "bun",
15
+ [
16
+ "--preload", path.join(pkgRoot, "src", "tui-preload.js"),
17
+ __filename,
18
+ ...process.argv.slice(2),
19
+ ],
20
+ {
21
+ stdio: "inherit",
22
+ cwd: process.env.LOOM_START_CWD || process.cwd(),
23
+ env: { ...process.env, LOOM_START_CWD: process.env.LOOM_START_CWD || process.cwd() },
24
+ windowsHide: false,
25
+ }
26
+ );
27
+ if (result.error) {
28
+ console.error("[loom] Bun is required for the TUI. Install it from https://bun.sh/");
29
+ process.exit(1);
30
+ }
31
+ process.exit(result.status == null ? 1 : result.status);
27
32
  }
28
33
 
29
34
  (async () => {
30
- await import("../src/tui-preload.js");
31
- await import("@opentui/solid/preload");
32
- await import("../src/tui-open.tsx");
33
- })();
35
+ await import("../src/tui-preload.js");
36
+ await import("@opentui/solid/preload");
37
+ await import("../src/tui-open.tsx");
38
+ })();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "loom-agent",
3
- "version": "1.2.27",
3
+ "version": "1.2.33",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/toshalkumbhar8979-design/loomcode.git"
@@ -9,7 +9,7 @@
9
9
  "bugs": {
10
10
  "url": "https://github.com/toshalkumbhar8979-design/loomcode/issues"
11
11
  },
12
- "description": "Loom Code — AI-powered coding agent for the terminal. Multi-provider support including NVIDIA. OpenTUI interface.",
12
+ "description": "Loom Code — AI-powered coding agent for the terminal. Multi-provider support including NVIDIA. OpenTUI interface.",
13
13
  "main": "src/index.js",
14
14
  "publishConfig": {
15
15
  "access": "public",
@@ -30,6 +30,7 @@
30
30
  "tui": "bun bin/loom-tui.js",
31
31
  "tui:win": "\"%USERPROFILE%\\..\\bun\\bin\\bun.exe\" run src/tui-open.tsx",
32
32
  "prepublishOnly": "npm test",
33
+ "postinstall": "node scripts/postinstall.js",
33
34
  "lint": "tsc --noEmit"
34
35
  },
35
36
  "dependencies": {
@@ -90,6 +91,7 @@
90
91
  "LOOM.md",
91
92
  "docs/acp.md",
92
93
  "scripts/acp-smoke.js",
94
+ "scripts/postinstall.js",
93
95
  "docs/web.md",
94
96
  "src/web/index.html",
95
97
  "src/web/graph-view.html"
@@ -0,0 +1,88 @@
1
+ // Postinstall: rewrite the npm-generated bin shims so `loom` starts ONE bun
2
+ // process directly IN THE USER'S PROJECT DIRECTORY. The Solid JSX preloader
3
+ // is registered with absolute --preload paths (the package-root cwd trick is
4
+ // gone — starting at the package root forced a later process.chdir back to
5
+ // the project inside tui-open.tsx, which froze the TUI after the first frame:
6
+ // splash painted once, then no repaints and no keyboard input).
7
+ const fs = require("fs");
8
+ const path = require("path");
9
+
10
+ const pkgRoot = path.resolve(__dirname, "..");
11
+
12
+ function shimDirs() {
13
+ const dirs = new Set();
14
+ if (path.basename(path.resolve(pkgRoot, "..")) !== "node_modules") return [];
15
+ dirs.add(path.resolve(pkgRoot, "..", ".."));
16
+ dirs.add(path.join(path.resolve(pkgRoot, ".."), ".bin"));
17
+ return [...dirs].filter(function(d) {
18
+ try {
19
+ return fs.existsSync(path.join(d, "loom.cmd")) || fs.existsSync(path.join(d, "loom"));
20
+ } catch {
21
+ return false;
22
+ }
23
+ });
24
+ }
25
+
26
+ function cmdShim(pkg, script) {
27
+ var pkgEscaped = pkg.replace(/\//g, "\\");
28
+ return [
29
+ "@ECHO off",
30
+ "SETLOCAL",
31
+ 'SET "LOOM_START_CWD=%CD%"',
32
+ 'IF EXIST "%~dp0bun.exe" (SET "_prog=%~dp0bun.exe") ELSE (SET "_prog=bun")',
33
+ '"%_prog%" --preload "%~dp0' + pkgEscaped + '\\src\\tui-preload.js" "%~dp0' + pkgEscaped + '\\bin\\' + script + '" %*',
34
+ "EXIT /b %ERRORLEVEL%",
35
+ ""
36
+ ].join("\r\n");
37
+ }
38
+
39
+ function psShim(pkg, script) {
40
+ return [
41
+ "#!/usr/bin/env pwsh",
42
+ "# rewritten by loom-agent postinstall (no-cwd-change TUI launch)",
43
+ "$env:LOOM_START_CWD = (Get-Location).Path",
44
+ "$pkg = Join-Path $PSScriptRoot '" + pkg + "'",
45
+ "& bun --preload (Join-Path $pkg 'src/tui-preload.js') (Join-Path $pkg 'bin/" + script + "') @args",
46
+ "exit $LASTEXITCODE",
47
+ ""
48
+ ].join("\n");
49
+ }
50
+
51
+ function shShim(pkg, script) {
52
+ return [
53
+ "#!/bin/sh",
54
+ "# rewritten by loom-agent postinstall (no-cwd-change TUI launch)",
55
+ 'LOOM_START_CWD="$(pwd)"',
56
+ "export LOOM_START_CWD",
57
+ 'PKG="$(dirname "$0")/' + pkg + '"',
58
+ 'exec bun --preload "$PKG/src/tui-preload.js" "$PKG/bin/' + script + '" "$@"',
59
+ ""
60
+ ].join("\n");
61
+ }
62
+
63
+ try {
64
+ for (var i = 0; i < shimDirs().length; i++) {
65
+ var dir = shimDirs()[i];
66
+ var relPkg = path.relative(dir, pkgRoot).split(path.sep).join("/");
67
+ if (!relPkg || relPkg.startsWith("..")) continue;
68
+ for (var j = 0; j < 2; j++) {
69
+ var name = j === 0 ? "loom" : "loom-tui";
70
+ var script = name === "loom" ? "loom-bun.js" : "loom-tui.js";
71
+ var targets = [
72
+ [name + ".cmd", cmdShim(relPkg, script)],
73
+ [name + ".ps1", psShim(relPkg, script)],
74
+ [name, shShim(relPkg, script)]
75
+ ];
76
+ for (var k = 0; k < targets.length; k++) {
77
+ var file = targets[k][0];
78
+ var content = targets[k][1];
79
+ var p = path.join(dir, file);
80
+ if (!fs.existsSync(p)) continue;
81
+ fs.writeFileSync(p, content, { mode: 0o755 });
82
+ console.log("[loom-agent] rewrote shim " + p);
83
+ }
84
+ }
85
+ }
86
+ } catch (err) {
87
+ console.warn("[loom-agent] shim rewrite skipped: " + (err && err.message));
88
+ }
package/src/core/cli.js CHANGED
@@ -38,7 +38,7 @@ class LoomCLI {
38
38
  terminal: true,
39
39
  });
40
40
  console.log(LOOM_BASE);
41
- console.log(`\n Loom Code v1.2.27 — AI Coding Agent for the terminal`);
41
+ console.log(`\n Loom Code v1.2.28 — AI Coding Agent for the terminal`);
42
42
  console.log(` Press Ctrl+C or ESC to interrupt | /help for commands\n`);
43
43
 
44
44
  process.stdin.on('keypress', (str, key) => {
@@ -575,19 +575,22 @@ if (args.includes('--help') || args.includes('-h')) {
575
575
  const tuiEntry = path.join(__dirname, '..', 'tui-open.tsx');
576
576
  if (bunPath && fs.existsSync(tuiEntry)) {
577
577
  const { spawnSync } = require('child_process');
578
- // Start bun from the package root so it discovers bunfig.toml /
579
- // tsconfig.json (Solid JSX preloader) even for global installs;
580
- // LOOM_START_CWD restores the user's project dir in tui-bootstrap.js.
578
+ // The Solid JSX preloader is registered inside src/tui-preload.js
579
+ // (passed as an ABSOLUTE --preload path), so bun starts directly in
580
+ // the user's project directory. The old "start at the package root,
581
+ // chdir to the project inside tui-open" dance froze the TUI after
582
+ // the first frame (no repaints, no keyboard input) — see
583
+ // bin/loom-bun.js.
581
584
  const pkgRoot = path.join(__dirname, '..', '..');
585
+ const tuiPreload = path.join(pkgRoot, 'src', 'tui-preload.js');
582
586
  process.env.LOOM_START_CWD = process.cwd();
583
587
  process.env.LOOM_BIN_NAME = "loom";
584
- process.env.BUN_CONFIG = path.join(pkgRoot, "bunfig.toml");
585
- const tuiArgs = [tuiEntry];
588
+ const tuiArgs = ['--preload', tuiPreload, tuiEntry];
586
589
  if (sessionId) tuiArgs.push('-s', sessionId);
587
590
  if (autoMode) tuiArgs.push('--auto');
588
591
  const prompt = promptArgs.join(' ');
589
592
  if (prompt) tuiArgs.push(...prompt.split(/\s+/));
590
- process.exit(spawnSync(bunPath, tuiArgs, { stdio: 'inherit', cwd: pkgRoot, env: process.env }).status ?? 0);
593
+ process.exit(spawnSync(bunPath, tuiArgs, { stdio: 'inherit', cwd: process.cwd(), env: process.env }).status ?? 0);
591
594
  }
592
595
  console.error('[loom] bun not found — full TUI requires bun (https://bun.sh/). Falling back to line-mode REPL.');
593
596
  console.error('[loom] Use --basic to skip this warning.\n');
@@ -1,5 +1,5 @@
1
1
  // Modals -- provider picker, model picker, key input, base URL, settings, palette.
2
- import { createSignal, onMount } from "solid-js";
2
+ import { createSignal, createMemo, onMount } from "solid-js";
3
3
  import { useKeyboard, usePaste } from "@opentui/solid";
4
4
  import { palette } from "../theme.ts";
5
5
  import * as kbs from "../keybinds.ts";
@@ -206,17 +206,25 @@ export function SelectModal(props: {
206
206
  // Start on the first real row (not a header).
207
207
  setIndex(firstSelectable());
208
208
 
209
+ // Hover must not fight scrolling: any keyboard/wheel/search index change
210
+ // locks hover-selection briefly, so the list sliding under a stationary
211
+ // cursor cannot yank the selection back (this feedback loop read as
212
+ // "jitter" while scrolling the model picker).
213
+ let hoverLockUntil = 0;
214
+ const lockHover = () => { hoverLockUntil = Date.now() + 250; };
215
+ const setIndexByKey = (fn: (i: number) => number) => { setIndex(fn); lockHover(); };
216
+
209
217
  const nav = kbNav();
210
218
 
211
219
  useKeyboard(key => {
212
220
  const ks = kbs.keyString(key);
213
221
  if (kbs.is("modal_cancel", ks)) { closeModal(); if (props.onCancel) props.onCancel(); return; }
214
- if (kbs.dialogIs("dialog_select_prev", ks)) { setIndex(i => stepSelectable(i, -1)); firePreview(); return; }
215
- if (kbs.dialogIs("dialog_select_next", ks)) { setIndex(i => stepSelectable(i, 1)); firePreview(); return; }
216
- if (kbs.dialogIs("dialog_select_page_up", ks)) { setIndex(i => pageJump(i, -1)); firePreview(); return; }
217
- if (kbs.dialogIs("dialog_select_page_down", ks)) { setIndex(i => pageJump(i, 1)); firePreview(); return; }
218
- if (kbs.dialogIs("dialog_select_home", ks)) { setIndex(firstSelectable()); firePreview(); return; }
219
- if (kbs.dialogIs("dialog_select_end", ks)) { setIndex(lastSelectable()); firePreview(); return; }
222
+ if (kbs.dialogIs("dialog_select_prev", ks)) { setIndexByKey(i => stepSelectable(i, -1)); firePreview(); return; }
223
+ if (kbs.dialogIs("dialog_select_next", ks)) { setIndexByKey(i => stepSelectable(i, 1)); firePreview(); return; }
224
+ if (kbs.dialogIs("dialog_select_page_up", ks)) { setIndexByKey(i => pageJump(i, -1)); firePreview(); return; }
225
+ if (kbs.dialogIs("dialog_select_page_down", ks)) { setIndexByKey(i => pageJump(i, 1)); firePreview(); return; }
226
+ if (kbs.dialogIs("dialog_select_home", ks)) { setIndexByKey(firstSelectable); firePreview(); return; }
227
+ if (kbs.dialogIs("dialog_select_end", ks)) { setIndexByKey(lastSelectable); firePreview(); return; }
220
228
  if (kbs.dialogIs("dialog_select_submit", ks)) {
221
229
  const opt = filtered()[index()];
222
230
  if (!opt || opt.isHeader) return;
@@ -226,11 +234,11 @@ export function SelectModal(props: {
226
234
  if (props.searchable) {
227
235
  // Reset to 0, not firstSelectable(): setQ is batched, so firstSelectable()
228
236
  // would read the STALE list and land past its end (dead arrows/blank row).
229
- if (key.name === "backspace") { setQ(v => v.slice(0, -1)); setIndex(0); firePreview(); return; }
237
+ if (key.name === "backspace") { setQ(v => v.slice(0, -1)); setIndexByKey(() => 0); firePreview(); return; }
230
238
  const s = key.sequence;
231
239
  if (!key.ctrl && !key.meta && s && s.length <= 10 && s !== "\r" && s !== "\n") {
232
240
  setQ(v => v + s);
233
- setIndex(0);
241
+ setIndexByKey(() => 0);
234
242
  firePreview();
235
243
  return;
236
244
  }
@@ -246,7 +254,7 @@ export function SelectModal(props: {
246
254
  if (j === i) break;
247
255
  i = j;
248
256
  }
249
- setIndex(i);
257
+ setIndexByKey(() => i);
250
258
  };
251
259
  const clickRow = (i: number) => {
252
260
  if (i !== index()) {
@@ -257,12 +265,14 @@ export function SelectModal(props: {
257
265
  if (o?.isHeader) return;
258
266
  props.onPick(o?.value, o);
259
267
  };
260
- let winStart = 0;
261
- const win = () => {
268
+ // Window of 12 rows, computed ONCE per reactive change (the old version
269
+ // mutated `winStart` from inside the JSX — called three times per render).
270
+ let lastStart = 0;
271
+ const win = createMemo(() => {
262
272
  const total = filtered().length;
263
- winStart = windowFor(index(), total, 12, winStart);
264
- return { total, start: winStart, items: filtered().slice(winStart, winStart + 12) };
265
- };
273
+ lastStart = windowFor(index(), total, 12, lastStart);
274
+ return { total, start: lastStart, items: filtered().slice(lastStart, lastStart + 12) };
275
+ });
266
276
  const rangeSub = () => {
267
277
  const w = win();
268
278
  if (w.total <= 12) return "";
@@ -271,21 +281,27 @@ export function SelectModal(props: {
271
281
 
272
282
  return (
273
283
  <ModalFrame title={props.title} subtitle={(props.searchable ? "search: " + (q() || "_") + rangeSub() : rangeSub())} footer={nav.prev + "/" + nav.next + " navigate | " + nav.submit + " select | wheel scroll | " + nav.cancel + " cancel" + (props.searchable ? " | type to search" : "")}>
274
- <box onMouseScroll={scrollBy}>
284
+ {/* Fixed height: the modal frame must not resize while scrolling.
285
+ Headers used to add an extra margin row, so the centered modal
286
+ bounced between 12/13/14 rows on every page of a header-heavy list
287
+ (the model picker) — the "jitter". Every item is now exactly one
288
+ row and the window is always 12 rows tall. */}
289
+ <box onMouseScroll={scrollBy} height={12} flexShrink={0}>
275
290
  {win().items.map((opt, i) => {
276
291
  const abs = win().start + i;
277
292
  if (opt.isHeader) return (
278
- <text fg={ui.secondary} marginTop={i === 0 ? 0 : 1}>
293
+ <text fg={ui.secondary}>
279
294
  {opt.header + ":"}
280
295
  </text>
281
296
  );
282
297
  const active = abs === index();
283
298
  return (
284
299
  <box
285
- flexDirection="row" paddingLeft={2}
286
- // Hover moves the selection (live theme preview via onPreview);
287
- // a click on the hovered row still selects+submits.
288
- onMouseOver={() => { if (abs !== index()) { setIndex(abs); firePreview(); } }}
300
+ flexDirection="row" paddingLeft={2}
301
+ // Hover moves the selection (live theme preview via onPreview)
302
+ // but only for genuine pointer movement, never while a
303
+ // keyboard/wheel scroll is settling (see hoverLockUntil).
304
+ onMouseOver={() => { if (Date.now() >= hoverLockUntil && abs !== index()) { setIndex(abs); firePreview(); } }}
289
305
  onMouseDown={() => setIndex(abs)}
290
306
  onMouseUp={() => clickRow(abs)}
291
307
  >
@@ -51,6 +51,61 @@ globalThis.__loomTrace = record;
51
51
  process.on("uncaughtException", (e) => record("uncaughtException", e));
52
52
  process.on("unhandledRejection", (r) => record("unhandledRejection", r));
53
53
 
54
+ // Global npm installs live INSIDE node_modules, and @opentui/solid's loader
55
+ // filter deliberately skips every node_modules path — so for installs the
56
+ // app's own TSX would fall through to Bun's default React JSX transform and
57
+ // crash at startup ("Cannot find module 'react/jsx-dev-runtime'").
58
+ //
59
+ // This preload is THE single registration point for the TUI launch chain
60
+ // (shims and respawns pass ONLY this file via --preload, with an absolute
61
+ // path), so it registers both plugins itself:
62
+ // 1. The Solid JSX plugin — via the bare "@opentui/solid/bun-plugin"
63
+ // specifier, which resolves by walking up from this file and therefore
64
+ // works whether the dependency is nested inside the package or hoisted
65
+ // to the install root. Idempotent (symbol-guarded upstream).
66
+ // 2. A supplemental loader scoped to THIS package's src directory only —
67
+ // real dependencies are never touched. It is a no-op in repo checkouts,
68
+ // where the solid plugin (non-node_modules paths) already handles these
69
+ // files first.
70
+ if (typeof Bun !== "undefined" && Bun.plugin) {
71
+ try {
72
+ require("@opentui/solid/bun-plugin").ensureSolidTransformPlugin();
73
+ } catch {}
74
+ if (!globalThis.__loomAppTsxPlugin) {
75
+ try {
76
+ globalThis.__loomAppTsxPlugin = true;
77
+ const pkgSrc = __dirname; // tui-preload.js lives in src/
78
+ const pkgRoot = path.join(pkgSrc, "..");
79
+ const esc = pkgSrc.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
80
+ // Layout-proof transform lookup: nested (npm i -g default observed)
81
+ // and hoisted (top-level install node_modules) candidates.
82
+ const candidates = [
83
+ path.join(pkgRoot, "node_modules", "@opentui", "solid", "scripts", "solid-transform.js"),
84
+ path.join(pkgRoot, "..", "..", "@opentui", "solid", "scripts", "solid-transform.js"),
85
+ ];
86
+ let transformSolidSource = null;
87
+ for (const c of candidates) {
88
+ try { transformSolidSource = require(c).transformSolidSource; break; } catch {}
89
+ }
90
+ if (transformSolidSource) {
91
+ Bun.plugin({
92
+ name: "loom-app-solid-tsx",
93
+ setup(build) {
94
+ build.onLoad({ filter: new RegExp("^" + esc + "[\\\\/].+\\.tsx$") }, async (args) => {
95
+ const code = await Bun.file(args.path).text();
96
+ const contents = await transformSolidSource(code, {
97
+ filename: args.path,
98
+ moduleName: "@opentui/solid",
99
+ });
100
+ return { contents, loader: "js" };
101
+ });
102
+ },
103
+ });
104
+ }
105
+ } catch {}
106
+ }
107
+ }
108
+
54
109
  // stdout byte-counter: frames flowing = counter climbs. This splits the two
55
110
  // remaining frozen-splash suspects with certainty — if the counter climbs but
56
111
  // the screen is frozen, the console is dropping VT repaints (mode flags); if