loom-agent 1.2.9 → 1.2.12

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/README.md CHANGED
@@ -1,8 +1,8 @@
1
1
  # Loom Code
2
2
 
3
- [![npm version](https://img.shields.io/npm/v/loom-code?style=flat&color=blue)](https://www.npmjs.com/package/loom-agent)
4
- [![License: MIT](https://img.shields.io/npm/l/loom-code?style=flat&color=green)](LICENSE)
5
- [![Platform: Win/Mac/Linux](https://img.shields.io/badge/terminal-Win%20%7C%20Mac%20%7C%20Linux-orange)](#)
3
+ [![npm version](https://img.shields.io/npm/v/loom-agent?style=flat&color=blue)](https://www.npmjs.com/package/loom-agent)
4
+ [![License: MIT](https://img.shields.io/npm/l/loom-agent?style=flat&color=green)](LICENSE)
5
+ [![Platform: Win/Mac/Linux](https://img.shields.io/badge/platform-Win--Mac--Linux-orange)](#)
6
6
 
7
7
  An AI-powered coding agent for the terminal with multi-provider support and a full terminal UI.
8
8
 
@@ -0,0 +1,31 @@
1
+ #!/usr/bin/env bun
2
+ // npm invokes bin targets through Node on Windows, so re-launch under Bun
3
+ // when this file was not started by Bun itself.
4
+ const path = require("path");
5
+ const { spawnSync } = require("child_process");
6
+
7
+ if (!(typeof Bun !== "undefined" && process.versions.bun)) {
8
+ const result = spawnSync(process.platform === "win32" ? "bun.exe" : "bun", ["--conditions=browser", __filename, ...process.argv.slice(2)], {
9
+ stdio: "inherit",
10
+ cwd: process.cwd(),
11
+ env: process.env,
12
+ windowsHide: false,
13
+ });
14
+ if (result.error) {
15
+ console.error("[loom] Bun is required for the full CLI. Install it from https://bun.sh/");
16
+ process.exit(1);
17
+ }
18
+ process.exit(result.status == null ? 1 : result.status);
19
+ }
20
+
21
+ process.env.BUN_CONFIG = path.join(__dirname, "..", "bunfig.toml");
22
+ process.title = "loom-code";
23
+ (async () => {
24
+ try {
25
+ const { main } = require("../src/core/cli.js");
26
+ await main();
27
+ } catch (err) {
28
+ console.error(err && err.message ? err.message : String(err));
29
+ process.exit(1);
30
+ }
31
+ })();
package/bin/loom-tui.js CHANGED
@@ -1,52 +1,23 @@
1
- #!/usr/bin/env node
2
- // Loom Code TUI launcher. Prefers bun for the TSX/JSX pipeline; falls back to
3
- // plain node for environments without bun (the TUI will tell the user to install
4
- // bun first).
5
- const { spawnSync } = require('child_process');
6
- const path = require('path');
7
- const fs = require('fs');
8
- const os = require('os');
1
+ #!/usr/bin/env bun
2
+ // npm invokes bin targets through Node on Windows, so re-launch under Bun
3
+ // when this file was not started by Bun itself.
4
+ const path = require("path");
5
+ const { spawnSync } = require("child_process");
9
6
 
10
- function findBun() {
11
- const candidates = [
12
- // Common install locations
13
- path.join(os.homedir(), '.bun', 'bin', 'bun.exe'),
14
- path.join(os.homedir(), '.bun', 'bin', 'bun'),
15
- '/usr/local/bin/bun',
16
- '/opt/homebrew/bin/bun',
17
- path.join(os.homedir(), 'bin', 'bun'),
18
- path.join(os.homedir(), '.local', 'bin', 'bun'),
19
- ];
20
- for (const p of candidates) { try { if (fs.existsSync(p)) return p; } catch {} }
21
- // PATH lookup (fast)
22
- try {
23
- const out = require('child_process').execSync('bun --version', { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'], windowsHide: true }).trim();
24
- if (out) return 'bun';
25
- } catch {}
26
- return null;
7
+ if (!(typeof Bun !== "undefined" && process.versions.bun)) {
8
+ const result = spawnSync(process.platform === "win32" ? "bun.exe" : "bun", ["--conditions=browser", __filename, ...process.argv.slice(2)], {
9
+ stdio: "inherit",
10
+ cwd: process.cwd(),
11
+ env: process.env,
12
+ windowsHide: false,
13
+ });
14
+ if (result.error) {
15
+ console.error("[loom] Bun is required for the TUI. Install it from https://bun.sh/");
16
+ process.exit(1);
17
+ }
18
+ process.exit(result.status == null ? 1 : result.status);
27
19
  }
28
20
 
29
- const entry = path.join(__dirname, '..', 'src', 'tui-open.tsx');
30
- // Start bun from the package root so it discovers our bunfig.toml/tsconfig.json
31
- // (Solid JSX preloader); the user's real cwd rides along in LOOM_START_CWD and
32
- // is restored by the bootstrap shim before app code runs.
33
- const pkgRoot = path.join(__dirname, '..');
34
- process.env.LOOM_START_CWD = process.cwd();
35
- process.env.LOOM_BIN_NAME = "loom";
36
- const bun = findBun();
37
-
38
- if (bun) {
39
- // Pin Solid to its CLIENT build: from this spawn context bun can resolve
40
- // solid-js under the "node" export condition (= SSR build, renders one
41
- // static frame then ignores all signal updates -> frozen splash).
42
- const result = spawnSync(bun, ['run', entry, ...process.argv.slice(2)], {
43
- stdio: 'inherit',
44
- cwd: pkgRoot,
45
- env: process.env,
46
- });
47
- process.exit(result.status ?? 0);
48
- }
49
-
50
- console.log('Loom TUI requires bun (https://bun.sh) to run the TSX/JSX pipeline.');
51
- console.log('Install bun first, then use: bun run ' + entry);
52
- process.exit(1);
21
+ process.env.BUN_CONFIG = path.join(__dirname, "..", "bunfig.toml");
22
+ require("../src/tui-preload.js");
23
+ (async () => { await import("../src/tui-open.tsx"); })();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "loom-agent",
3
- "version": "1.2.9",
3
+ "version": "1.2.12",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/toshalkumbhar8979-design/loomcode.git"
@@ -9,10 +9,14 @@
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
+ "publishConfig": {
15
+ "access": "public",
16
+ "tag": "latest"
17
+ },
14
18
  "bin": {
15
- "loom": "bin/loom.js",
19
+ "loom": "bin/loom-bun.js",
16
20
  "loom-tui": "bin/loom-tui.js"
17
21
  },
18
22
  "scripts": {
@@ -23,13 +27,13 @@
23
27
  "test:unit": "bun test src/core/agents.test.js src/core/hooks.test.js src/core/custom-commands.test.js src/core/background-tasks.test.js src/core/memory.test.js src/core/subagent-log.test.js src/core/session.test.js src/tools/index.test.js src/providers/providers.test.js src/providers/caching.test.js src/providers/caching-thinking.test.js src/providers/registry.test.js src/mcp/mcp-client.test.js src/mcp/mcp-manager.test.js src/core/session-store.test.js src/skills/skills-manager.test.js src/core/usage.test.js src/core/permissions.test.js src/core/format.test.js src/acp/acp-server.test.js src/web/web-server.test.js src/tui/keybinds.test.ts",
24
28
  "lint:core": "node node_modules/typescript/bin/tsc -p tsconfig.core.json",
25
29
  "smoke:acp": "node scripts/acp-smoke.js",
26
- "tui": "node bin/loom-tui.js",
30
+ "tui": "bun bin/loom-tui.js",
27
31
  "tui:win": "\"%USERPROFILE%\\..\\bun\\bin\\bun.exe\" run src/tui-open.tsx",
28
32
  "prepublishOnly": "npm test",
29
33
  "lint": "tsc --noEmit"
30
34
  },
31
35
  "dependencies": {
32
- "@anthropic-ai/sdk": "^0.37.0",
36
+ "@anthropic-ai/sdk": "^0.120.0",
33
37
  "@opentui/core": "^0.5.1",
34
38
  "@opentui/solid": "^0.5.1",
35
39
  "@shikijs/themes": "4.4.3",
@@ -41,23 +45,26 @@
41
45
  "dayjs": "^1.11.0",
42
46
  "diff": "^7.0.0",
43
47
  "dotenv": "^16.4.0",
44
- "glob": "^11.0.0",
48
+ "glob": "^13.0.6",
45
49
  "inquirer": "^12.1.0",
46
- "openai": "^4.73.0",
50
+ "openai": "^5.23.2",
47
51
  "ora": "^8.0.0",
48
52
  "shell-quote": "^1.8.1",
49
- "solid-js": "^1.9.14",
53
+ "solid-js": "1.9.12",
50
54
  "strip-ansi": "^7.1.0",
51
55
  "tiktoken": "^1.0.22",
52
56
  "uuid": "^11.0.0",
53
57
  "wrap-ansi": "^9.0.0"
54
58
  },
55
59
  "optionalDependencies": {
56
- "@opentui/core-win32-x64": "^0.5.1",
57
60
  "@opentui/core-darwin-arm64": "^0.5.1",
58
61
  "@opentui/core-darwin-x64": "^0.5.1",
62
+ "@opentui/core-linux-arm64": "^0.5.1",
59
63
  "@opentui/core-linux-x64": "^0.5.1",
60
- "@opentui/core-linux-arm64": "^0.5.1"
64
+ "@opentui/core-win32-x64": "^0.5.1"
65
+ },
66
+ "overrides": {
67
+ "glob": "^13.0.6"
61
68
  },
62
69
  "devDependencies": {
63
70
  "@types/bun": "^1.2.0",
@@ -66,6 +73,7 @@
66
73
  "files": [
67
74
  "bunfig.toml",
68
75
  "tsconfig.json",
76
+ "bin/loom-bun.js",
69
77
  "bin/loom.js",
70
78
  "bin/loom-tui.js",
71
79
  "src/**/*.js",
package/src/core/cli.js CHANGED
@@ -554,6 +554,18 @@ if (args.includes('--help') || args.includes('-h')) {
554
554
 
555
555
  if ((args.includes('--tui') || (!args.includes('--basic') && process.stdin.isTTY))) {
556
556
  const canRaw = process.stdin.isTTY && typeof process.stdin.setRawMode === 'function';
557
+ // Fast path: when this process is ALREADY bun (launched via bin/loom-bun.js),
558
+ // import the TUI in-process — no spawn, no second console-mode negotiation.
559
+ const underBun = typeof Bun !== 'undefined' && !!process.versions.bun;
560
+ if (underBun && canRaw) {
561
+ try {
562
+ const tuiModule = '../tui-open.tsx';
563
+ await import(tuiModule);
564
+ return;
565
+ } catch (err) {
566
+ console.error('[loom] TUI failed: ' + (err && err.message ? err.message : err));
567
+ }
568
+ }
557
569
  if (canRaw) {
558
570
  // Launch new OpenTUI TUI via bun
559
571
  const bunPath = findBun();
@@ -566,10 +578,11 @@ if (args.includes('--help') || args.includes('-h')) {
566
578
  const pkgRoot = path.join(__dirname, '..', '..');
567
579
  process.env.LOOM_START_CWD = process.cwd();
568
580
  process.env.LOOM_BIN_NAME = "loom";
581
+ process.env.BUN_CONFIG = path.join(pkgRoot, "bunfig.toml");
569
582
  // --conditions=browser pins Solid to its client build (see
570
583
  // bin/loom-tui.js) — without it the SSR build loads and the TUI
571
584
  // renders one static frame then never updates.
572
- const tuiArgs = [tuiEntry];
585
+ const tuiArgs = ["--conditions=browser", tuiEntry];
573
586
  if (sessionId) tuiArgs.push('-s', sessionId);
574
587
  if (autoMode) tuiArgs.push('--auto');
575
588
  const prompt = promptArgs.join(' ');
@@ -100,9 +100,12 @@ class Session {
100
100
  this.todos = [];
101
101
  this.compactCount = 0;
102
102
  this.lastCompact = null;
103
- // Start MCP tool discovery in the background so the first turn doesn't
104
- // stall on server startup (npx downloads, etc.).
105
- try { require('../mcp/mcp-client').warm(); } catch {}
103
+ // Let the TUI render before starting MCP discovery. The first warm-up can
104
+ // launch npx and download packages, so starting it in the constructor can
105
+ // make startup appear frozen before the renderer gets its first frame.
106
+ setTimeout(() => {
107
+ try { require('../mcp/mcp-client').warm(); } catch {}
108
+ }, 0);
106
109
  }
107
110
 
108
111
  setMode(mode) {
package/src/tui/App.tsx CHANGED
@@ -2,6 +2,7 @@
2
2
  // useKeyboard handles ALL text input char-by-char.
3
3
  import { onMount, onCleanup, createMemo, Show } from "solid-js";
4
4
  import { useKeyboard, usePaste, useRenderer, useSelectionHandler } from "@opentui/solid";
5
+ import { createTimeline } from "@opentui/core";
5
6
  import path from "path";
6
7
  import fs from "fs";
7
8
  import os from "os";
@@ -462,10 +463,10 @@ export function App(props: { initialPrompt?: string; resumeSession?: string; aut
462
463
  if (resp.interrupted) {
463
464
  // Keep the partial text in the bubble; the session already stored it,
464
465
  // so the user can type "continue" to resume the task.
465
- patchMessageAt(idx, { thinking: false, interrupted: true, thinkTime: Date.now() - t0, isError: false });
466
+ patchMessageAt(idx, { thinking: false, interrupted: true, thinkTime: Date.now() - t0, isError: false, parts: snapshotParts() });
466
467
  appendMessage({ role: "system", content: "Interrupted \u2014 partial response kept. Type \"continue\" to resume the task." });
467
468
  } else {
468
- patchMessageAt(idx, { content: resp.content || "(no response)", thinking: false, thinkTime: Date.now() - t0, isError: isErr });
469
+ patchMessageAt(idx, { content: resp.content || "(no response)", thinking: false, thinkTime: Date.now() - t0, isError: isErr, parts: snapshotParts() });
469
470
  }
470
471
  if (!isErr && sess.mode === "plan") {
471
472
  appendMessage({ role: "system", content: "Plan complete \u2014 press Tab to switch to Build, then send \"go\" to execute." });
@@ -475,15 +476,20 @@ export function App(props: { initialPrompt?: string; resumeSession?: string; aut
475
476
  ensureTextPart("Error: " + String(e?.message || e).slice(0, 500));
476
477
  flushStream();
477
478
  var err = e || {};
478
- patchMessageAt(idx, { content: "Error: " + String(err.message || err).slice(0, 500), thinking: false, isError: true, thinkTime: Date.now() - t0 });
479
+ patchMessageAt(idx, { content: "Error: " + String(err.message || err).slice(0, 500), thinking: false, isError: true, thinkTime: Date.now() - t0, parts: snapshotParts() });
479
480
  }).finally(function() {
480
481
  clearInterval(speedTimer);
481
482
  if (flushTimer) { clearTimeout(flushTimer); flushTimer = null; }
482
483
  if (offTodos) { offTodos(); offTodos = null; }
483
484
  // Freeze the subagent panel at its final state ("finished").
484
485
  if (subAcc.agent) patchMessageAt(idx, { subagent: { agent: subAcc.agent, text: subAcc.text, log: subAcc.log, status: subAcc.status, done: true } });
485
- setThinking(false); setThinkStart(null); recomputeTodos(); refreshUsage();
486
- flushQueueSoon();
486
+ // Let the settled assistant message render before the global turn state
487
+ // changes; otherwise the test renderer can sample the old frame after
488
+ // thinking() becomes false.
489
+ setTimeout(function() {
490
+ setThinking(false); setThinkStart(null); recomputeTodos(); refreshUsage();
491
+ flushQueueSoon();
492
+ }, 0);
487
493
  });
488
494
  } catch (e: any) {
489
495
  // A provider that throws synchronously (bad key, malformed config) must
@@ -494,8 +500,10 @@ export function App(props: { initialPrompt?: string; resumeSession?: string; aut
494
500
  clearInterval(speedTimer);
495
501
  if (flushTimer) { clearTimeout(flushTimer); flushTimer = null; }
496
502
  if (offTodos) { offTodos(); offTodos = null; }
497
- setThinking(false); setThinkStart(null); recomputeTodos(); refreshUsage();
498
- flushQueueSoon();
503
+ setTimeout(function() {
504
+ setThinking(false); setThinkStart(null); recomputeTodos(); refreshUsage();
505
+ flushQueueSoon();
506
+ }, 0);
499
507
  }
500
508
  }
501
509
 
@@ -1475,6 +1483,12 @@ export function App(props: { initialPrompt?: string; resumeSession?: string; aut
1475
1483
  let _skillToastOff: (() => void) | null = null;
1476
1484
  let _skillDoneOff: (() => void) | null = null;
1477
1485
  onMount(function() {
1486
+ // Keep the renderer's frame pump alive. The engine only runs its
1487
+ // frame callback while there's a non-complete timeline registered;
1488
+ // without one it drops the renderer to "not live" after the first
1489
+ // paint and no further frames are flushed (frozen splash).
1490
+ createTimeline({ duration: Infinity, autoplay: true });
1491
+
1478
1492
  refreshProviderState();
1479
1493
  refreshUsage();
1480
1494
  wireTodoEvents();
@@ -4,6 +4,7 @@ import { palette } from "../theme.ts";
4
4
  import {
5
5
  showThinking, userExpandedIdx, setUserExpandedIdx, sidebarVisible,
6
6
  thoughtExpanded, setThoughtExpanded, thoughtClosed, setThoughtClosed,
7
+ thinking,
7
8
  } from "../store.ts";
8
9
  import { toolDisplay } from "../tool-display.ts";
9
10
  import { formatDiffCount } from "../../core/file-diffs.js";
@@ -321,9 +322,9 @@ function PartList(props: { m: any; idx: number }) {
321
322
  {parts().map((p: any, i: number) => {
322
323
  if (p.type === "reasoning") {
323
324
  if (!showThinking()) return null;
324
- const live = !!m.thinking && i === lastReasonIdx();
325
- const open = live ? !thoughtClosed().has(props.idx) : (thoughtExpanded().get(props.idx)?.has(i) ?? false);
326
- blockPrev = open;
325
+ const live = () => !!m.thinking && thinking() && i === lastReasonIdx();
326
+ const open = () => live() ? !thoughtClosed().has(props.idx) : (thoughtExpanded().get(props.idx)?.has(i) ?? false);
327
+ blockPrev = open();
327
328
  return <ThoughtPart p={p} i={i} idx={props.idx} live={live} open={open} />;
328
329
  }
329
330
  if (p.type === "tool") {
@@ -370,9 +371,9 @@ function deriveParts(m: any): any[] {
370
371
  // below (click collapses it mid-turn); once the model moves on (a tool or text
371
372
  // part arrives, or the turn ends) it settles to a clickable "+ Thought · Ns"
372
373
  // line — opencode's minimal mode. Clicking toggles the body in either state.
373
- function ThoughtPart(props: { p: any; i: number; idx: number; live: boolean; open: boolean }) {
374
+ function ThoughtPart(props: { p: any; i: number; idx: number; live: () => boolean; open: () => boolean }) {
374
375
  const toggle = () => {
375
- if (props.live) {
376
+ if (props.live()) {
376
377
  const closed = new Set(thoughtClosed());
377
378
  if (closed.has(props.idx)) closed.delete(props.idx); else closed.add(props.idx);
378
379
  setThoughtClosed(closed);
@@ -392,9 +393,9 @@ function ThoughtPart(props: { p: any; i: number; idx: number; live: boolean; ope
392
393
  onMouseUp={() => toggle()}
393
394
  onKeyDown={(e: any) => { if (e.name === "return" || e.name === "space") toggle(); }}
394
395
  >
395
- {props.live ? <ThinkingLabel /> : (props.open ? "- " : "+ ") + "Thought" + (ms() ? " \u00B7 " + ms() : "")}
396
+ {props.live() ? <ThinkingLabel /> : (props.open() ? "- " : "+ ") + "Thought" + (ms() ? " \u00B7 " + ms() : "")}
396
397
  </text>
397
- <Show when={props.open}>
398
+ <Show when={props.open()}>
398
399
  <box paddingLeft={2}>
399
400
  <MdText md={String(props.p.text || "").slice(0, 20000)} />
400
401
  </box>
@@ -345,9 +345,8 @@ export function InputBar() {
345
345
  </box>
346
346
  </box>
347
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>
348
+ <box paddingX={1} flexDirection="row">
349
+ <text fg={ui.fgMuted}>{statusLine().cwd + " " + statusLine().right}</text>
351
350
  </box>
352
351
  </box>
353
352
  );
@@ -1,8 +1,21 @@
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";
2
4
  import { palette, LOOM_LOGO } from "../theme.ts";
3
5
  import { providerName, modelName, providerKeyOk } from "../store.ts";
4
6
  import { InputBar } from "./InputBar.tsx";
5
7
 
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
+
6
19
  export function SplashScreen() {
7
20
  const ui = palette("loom");
8
21
 
@@ -27,7 +40,7 @@ export function SplashScreen() {
27
40
  </box>
28
41
 
29
42
  <box marginTop={0}>
30
- <text fg={ui.fgMuted}>{"v1.1.0"}</text>
43
+ <text fg={ui.fgMuted}>{LOOM_VERSION}</text>
31
44
  </box>
32
45
 
33
46
  <box marginY={1} width={74}>
@@ -104,7 +104,7 @@ export function SubagentPanel() {
104
104
  // Set the DEFAULT model for the selected agent id ("provider/model-id").
105
105
  const cur = all()[sel()];
106
106
  if (!cur) return;
107
- const { loadConfig, saveConfig } = require("../../../config/settings.js");
107
+ const { loadConfig, saveConfig } = require("../../config/settings.js");
108
108
  const cfg = loadConfig();
109
109
  cfg.agents = cfg.agents || {};
110
110
  const curModel = (cfg.agents[cur.agentId] && cfg.agents[cur.agentId].model) || "";
@@ -0,0 +1,10 @@
1
+ // Compile entry for the standalone executable (bun build --compile).
2
+ // Compiled binaries skip bunfig.toml entirely, so the preload-side effects
3
+ // (Windows VT/console-mode fix, UTF-8 codepage, crash black-box, optional
4
+ // LOOM_START_CWD restore) are imported here explicitly. The dynamic import
5
+ // keeps tui-open.tsx's module graph evaluating AFTER those effects run.
6
+ import "./tui-preload.js";
7
+ // Compiled binaries skip bunfig.toml, so the Solid JSX runtime loader must be
8
+ // registered explicitly before the app graph evaluates.
9
+ import "@opentui/solid/preload";
10
+ await import("./tui-open.tsx");
package/src/tui-open.tsx CHANGED
@@ -8,6 +8,16 @@ import { defaultMcpInstall } from "./core/plugin-cmd.js";
8
8
  globalThis.__loomTrace?.("entry", new Error("module imports evaluated"));
9
9
  const args = process.argv.slice(2);
10
10
 
11
+ // Restore the user's project directory as the very first module-body
12
+ // statement — i.e. AFTER the entire import graph above has finished
13
+ // evaluating under Bun's original working directory (where bunfig.toml /
14
+ // tsconfig.json were discovered). Doing this any earlier breaks Solid's
15
+ // JSX transform; doing it here means every lazy process.cwd() call in app
16
+ // code still sees the project the user launched from.
17
+ if (process.env.LOOM_START_CWD) {
18
+ try { process.chdir(process.env.LOOM_START_CWD); } catch {}
19
+ }
20
+
11
21
  // Windows: switch both console codepages to UTF-8 so the box-drawing logo
12
22
  // and VT input sequences work even when launched without the bootstrap
13
23
  // shim (bun run src/tui-open.tsx). No-op elsewhere / if FFI is unavailable.
@@ -1,51 +1,125 @@
1
- // Loaded via bunfig.toml preload BEFORE any app code, so this replaces the
2
- // old CJS bootstrap hop entirely: the launch chain stays a plain direct
3
- // "bun src/tui-open.tsx" (identical shape to repo runs) while we still get
4
- // to restore the user's project directory early and keep the crash
5
- // black-box watching.
6
- const fs = require("fs");
7
- const os = require("os");
8
- const path = require("path");
9
-
10
- if (process.platform === "win32") {
11
- // UTF-8 console codepage: legacy CPs render the box-drawing logo as "?"
12
- // garbage and can mangle VT input sequences.
13
- try {
14
- const { dlopen } = require("bun:ffi");
15
- const k32 = dlopen("kernel32.dll", {
16
- SetConsoleOutputCP: { args: ["uint"], returns: "int" },
17
- SetConsoleCP: { args: ["uint"], returns: "int" },
18
- });
19
- k32.symbols.SetConsoleOutputCP(65001);
20
- k32.symbols.SetConsoleCP(65001);
21
- } catch {}
22
- }
23
-
24
- if (process.env.LOOM_START_CWD) {
25
- try { process.chdir(process.env.LOOM_START_CWD); } catch {}
26
- }
27
-
28
- const crashPath = () => path.join(os.homedir(), ".loom", "tui-crash.log");
29
- function record(kind, err) {
30
- try {
31
- fs.mkdirSync(path.dirname(crashPath()), { recursive: true });
32
- fs.appendFileSync(
33
- crashPath(),
34
- `[${new Date().toISOString()}] ${kind}: ${(err && (err.stack || err.message)) || String(err)}\n`
35
- );
36
- } catch {}
37
- }
38
- globalThis.__loomTrace = record;
39
- process.on("uncaughtException", (e) => record("uncaughtException", e));
40
- process.on("unhandledRejection", (r) => record("unhandledRejection", r));
41
-
42
- const __hbStart = Date.now();
43
- let __hbTick = 0;
44
- let __hbScheduled = __hbStart;
45
- const __hbTimer = setInterval(() => {
46
- const now = Date.now();
47
- record("heartbeat", new Error(`tick=${++__hbTick} lag=${now - __hbScheduled}ms uptime=${now - __hbStart}ms`));
48
- __hbScheduled = now + 3000;
49
- }, 3000);
50
- try { __hbTimer.unref?.(); } catch {}
51
- process.on("exit", () => { try { record("exit", new Error(`uptime=${Date.now() - __hbStart}ms ticks=${__hbTick}`)); } catch {} });
1
+ // Loaded via bunfig.toml preload BEFORE any app code, so this replaces the
2
+ // old CJS bootstrap hop entirely: the launch chain stays a plain direct
3
+ // "bun src/tui-open.tsx" (identical shape to repo runs) while we still get
4
+ // to restore the user's project directory early and keep the crash
5
+ // black-box watching.
6
+ const fs = require("fs");
7
+ const os = require("os");
8
+ const path = require("path");
9
+
10
+ if (process.platform === "win32") {
11
+ // UTF-8 console codepage: legacy CPs render the box-drawing logo as "?"
12
+ // garbage and can mangle VT input sequences.
13
+ try {
14
+ const { dlopen } = require("bun:ffi");
15
+ const k32 = dlopen("kernel32.dll", {
16
+ SetConsoleOutputCP: { args: ["uint"], returns: "int" },
17
+ SetConsoleCP: { args: ["uint"], returns: "int" },
18
+ });
19
+ k32.symbols.SetConsoleOutputCP(65001);
20
+ k32.symbols.SetConsoleCP(65001);
21
+ } catch {}
22
+ }
23
+
24
+ // Windows VT-mode re-enable — OPT-IN via LOOM_FORCE_VT=1.
25
+ // Off by default: OpenTUI manages its own console modes, and forcing flags
26
+ // underneath it can desync its input parsing. Only enable when debugging
27
+ // console-mode issues on a specific terminal.
28
+ if (process.platform === "win32" && process.env.LOOM_FORCE_VT === "1") {
29
+ try {
30
+ const { dlopen } = require("bun:ffi");
31
+ const k32 = dlopen("kernel32.dll", {
32
+ GetStdHandle: { args: ["int"], returns: "ptr" },
33
+ GetConsoleMode: { args: ["ptr", "ptr"], returns: "int" },
34
+ SetConsoleMode: { args: ["ptr", "uint"], returns: "int" },
35
+ });
36
+ const STD_INPUT_HANDLE = -10;
37
+ const STD_OUTPUT_HANDLE = -11;
38
+
39
+ // Output: processed + wrap-at-EOL + VT processing (so ANSI repaints work).
40
+ const ENABLE_PROCESSED_OUTPUT = 0x0001;
41
+ const ENABLE_WRAP_AT_EOL_OUTPUT = 0x0002;
42
+ const ENABLE_VIRTUAL_TERMINAL_PROCESSING = 0x0004;
43
+ // Input: raw-ish mode for OpenTUI — VT input + processed + mouse + window,
44
+ // with line/echo/quick-edit cleared so keys stream and don't echo.
45
+ const ENABLE_PROCESSED_INPUT = 0x0001;
46
+ const ENABLE_MOUSE_INPUT = 0x0010;
47
+ const ENABLE_WINDOW_INPUT = 0x0008;
48
+ const ENABLE_VIRTUAL_TERMINAL_INPUT = 0x0200;
49
+ const ENABLE_QUICK_EDIT_MODE = 0x0040;
50
+ const ENABLE_LINE_INPUT = 0x0002;
51
+ const ENABLE_ECHO_INPUT = 0x0004;
52
+
53
+ const outH = k32.symbols.GetStdHandle(STD_OUTPUT_HANDLE);
54
+ const inH = k32.symbols.GetStdHandle(STD_INPUT_HANDLE);
55
+ const modeBuf = new Uint32Array(1);
56
+ const modePtr = Bun.ptr(modeBuf);
57
+
58
+ if (outH && !outH.isNull && k32.symbols.GetConsoleMode(outH, modePtr)) {
59
+ const cur = modeBuf[0];
60
+ const next = cur | ENABLE_PROCESSED_OUTPUT | ENABLE_WRAP_AT_EOL_OUTPUT | ENABLE_VIRTUAL_TERMINAL_PROCESSING;
61
+ if (next !== cur) k32.symbols.SetConsoleMode(outH, next);
62
+ }
63
+ if (inH && !inH.isNull && k32.symbols.GetConsoleMode(inH, modePtr)) {
64
+ const cur = modeBuf[0];
65
+ const next = (cur | ENABLE_PROCESSED_INPUT | ENABLE_MOUSE_INPUT | ENABLE_WINDOW_INPUT | ENABLE_VIRTUAL_TERMINAL_INPUT)
66
+ & ~(ENABLE_LINE_INPUT | ENABLE_ECHO_INPUT | ENABLE_QUICK_EDIT_MODE);
67
+ if (next !== cur) k32.symbols.SetConsoleMode(inH, next);
68
+ }
69
+ } catch {}
70
+ }
71
+
72
+ // NOTE: LOOM_START_CWD is deliberately NOT applied here. Changing the working
73
+ // directory before the app's module graph loads breaks how Bun resolves the
74
+ // Solid JSX transform (proven: identical entry paints 19KB from repo cwd,
75
+ // 0 bytes from a foreign cwd). The restore happens in tui-open.tsx instead —
76
+ // as a module-body statement, i.e. AFTER every import has finished loading.
77
+
78
+ const crashPath = () => path.join(os.homedir(), ".loom", "tui-crash.log");
79
+ const MAX_LOG_BYTES = 1024 * 1024; // 1 MB cap, then rotate to .old
80
+ function rotateIfNeeded() {
81
+ try {
82
+ const p = crashPath();
83
+ if (fs.existsSync(p) && fs.statSync(p).size > MAX_LOG_BYTES) {
84
+ fs.renameSync(p, p + ".old");
85
+ }
86
+ } catch {}
87
+ }
88
+ function record(kind, err) {
89
+ try {
90
+ fs.mkdirSync(path.dirname(crashPath()), { recursive: true });
91
+ rotateIfNeeded();
92
+ fs.appendFileSync(
93
+ crashPath(),
94
+ `[${new Date().toISOString()}] ${kind}: ${(err && (err.stack || err.message)) || String(err)}\n`
95
+ );
96
+ } catch {}
97
+ }
98
+ globalThis.__loomTrace = record;
99
+ process.on("uncaughtException", (e) => record("uncaughtException", e));
100
+ process.on("unhandledRejection", (r) => record("unhandledRejection", r));
101
+
102
+ // stdout byte-counter: frames flowing = counter climbs. This splits the two
103
+ // remaining frozen-splash suspects with certainty — if the counter climbs but
104
+ // the screen is frozen, the console is dropping VT repaints (mode flags); if
105
+ // the counter is flat, the renderer flush-loop itself is stalled.
106
+ let __stdoutBytes = 0;
107
+ const __origWrite = process.stdout.write.bind(process.stdout);
108
+ process.stdout.write = function (chunk, ...rest) {
109
+ try {
110
+ if (typeof chunk === "string") __stdoutBytes += Buffer.byteLength(chunk, "utf8");
111
+ else if (chunk && chunk.length) __stdoutBytes += chunk.length;
112
+ } catch {}
113
+ return __origWrite(chunk, ...rest);
114
+ };
115
+
116
+ const __hbStart = Date.now();
117
+ let __hbTick = 0;
118
+ let __hbScheduled = __hbStart;
119
+ const __hbTimer = setInterval(() => {
120
+ const now = Date.now();
121
+ record("heartbeat", new Error(`tick=${++__hbTick} lag=${now - __hbScheduled}ms uptime=${now - __hbStart}ms stdoutBytes=${__stdoutBytes}`));
122
+ __hbScheduled = now + 3000;
123
+ }, 3000);
124
+ try { __hbTimer.unref?.(); } catch {}
125
+ process.on("exit", () => { try { record("exit", new Error(`uptime=${Date.now() - __hbStart}ms ticks=${__hbTick} stdoutBytes=${__stdoutBytes}`)); } catch {} });