Skip to content

Commit 49927bd

Browse files
mason: ship precompiled TUI loader
Co-authored-by: Alfonso [Magic Context] <288211368+alfonso-magic-context@users.noreply.github.com>
1 parent 4ea539b commit 49927bd

6 files changed

Lines changed: 382 additions & 70 deletions

File tree

packages/plugin/.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
src/tui-compiled/

packages/plugin/package.json

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,19 +25,21 @@
2525
"files": [
2626
"dist",
2727
"src/tui",
28+
"src/tui-compiled",
2829
"src/shared",
2930
"README.md"
3031
],
3132
"scripts": {
3233
"build": "bun build src/index.ts --outdir dist --target node --format esm --external @opencode-ai/plugin --external @huggingface/transformers --external onnxruntime-web --external bun:sqlite --external node:sqlite && tsc --emitDeclarationOnly",
34+
"build:tui": "bun scripts/build-tui.ts",
3335
"typecheck": "tsc --noEmit && tsc -p tsconfig.scripts.json",
3436
"test": "bun test",
3537
"lint": "biome check .",
3638
"lint:fix": "biome check --write .",
3739
"format": "biome format --write .",
3840
"format:check": "biome format .",
3941
"clean": "rm -rf dist",
40-
"prepublishOnly": "bun run build"
42+
"prepublishOnly": "bun run build && bun run build:tui"
4143
},
4244
"dependencies": {
4345
"@huggingface/transformers": "^4.1.0",
@@ -67,7 +69,7 @@
6769
},
6870
"./tui": {
6971
"types": "./src/tui/index.tsx",
70-
"import": "./src/tui/index.tsx"
72+
"import": "./src/tui/entry.mjs"
7173
}
7274
},
7375
"oc-plugin": [
Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
import { copyFile, mkdir, readdir, readFile, rm, writeFile } from "node:fs/promises";
2+
import { createRequire } from "node:module";
3+
import { basename, dirname, join, relative } from "node:path";
4+
import { fileURLToPath, pathToFileURL } from "node:url";
5+
6+
const pluginRoot = join(dirname(fileURLToPath(import.meta.url)), "..");
7+
const sourceRoot = join(pluginRoot, "src/tui");
8+
const outputRoot = join(pluginRoot, "src/tui-compiled");
9+
const runtimeSpecifiers = new Set([
10+
"@opentui/core",
11+
"@opentui/core/testing",
12+
"@opentui/solid",
13+
"@opentui/solid/components",
14+
"@opentui/solid/jsx-runtime",
15+
"@opentui/solid/jsx-dev-runtime",
16+
"solid-js",
17+
"solid-js/store",
18+
]);
19+
20+
type TransformSolidSource = (
21+
code: string,
22+
options: {
23+
filename: string;
24+
moduleName: string;
25+
resolvePath: (specifier: string) => string | null;
26+
},
27+
) => Promise<string>;
28+
29+
type SolidTransformModule = {
30+
transformSolidSource?: TransformSolidSource;
31+
};
32+
33+
function runtimeModuleId(specifier: string): string {
34+
return `opentui:runtime-module:${encodeURIComponent(specifier)}`;
35+
}
36+
37+
function asTransformSolidSource(mod: SolidTransformModule, from: string): TransformSolidSource {
38+
if (typeof mod.transformSolidSource !== "function") {
39+
throw new Error(`@opentui/solid transform loaded from ${from} without transformSolidSource`);
40+
}
41+
return mod.transformSolidSource;
42+
}
43+
44+
async function importTransformModule(specifier: string): Promise<SolidTransformModule> {
45+
return (await import(specifier)) as SolidTransformModule;
46+
}
47+
48+
async function resolveSolidTransformPath(): Promise<string> {
49+
const packageJsonSpecifier = "@opentui/solid/package.json";
50+
const errors: string[] = [];
51+
52+
try {
53+
const packageJsonUrl = import.meta.resolve(packageJsonSpecifier);
54+
return join(dirname(fileURLToPath(packageJsonUrl)), "scripts/solid-transform.js");
55+
} catch (error) {
56+
errors.push(`import.meta.resolve: ${error instanceof Error ? error.message : String(error)}`);
57+
}
58+
59+
try {
60+
const require = createRequire(import.meta.url);
61+
return join(dirname(require.resolve(packageJsonSpecifier)), "scripts/solid-transform.js");
62+
} catch (error) {
63+
errors.push(`require.resolve: ${error instanceof Error ? error.message : String(error)}`);
64+
}
65+
66+
throw new Error(`Unable to resolve @opentui/solid transform (${errors.join("; ")})`);
67+
}
68+
69+
async function loadTransformSolidSource(): Promise<TransformSolidSource> {
70+
const bareTransformSpecifier = "@opentui/solid/scripts/solid-transform.js";
71+
72+
try {
73+
return asTransformSolidSource(
74+
await importTransformModule(bareTransformSpecifier),
75+
bareTransformSpecifier,
76+
);
77+
} catch {
78+
const transformPath = await resolveSolidTransformPath();
79+
return asTransformSolidSource(
80+
await importTransformModule(pathToFileURL(transformPath).href),
81+
transformPath,
82+
);
83+
}
84+
}
85+
86+
function isShippedSourceFile(filePath: string): boolean {
87+
if (/\.test\.tsx?$/.test(basename(filePath))) return false;
88+
return filePath.endsWith(".tsx") || filePath.endsWith(".ts");
89+
}
90+
91+
async function listSourceFiles(dir: string): Promise<string[]> {
92+
const entries = await readdir(dir, { withFileTypes: true });
93+
entries.sort((a, b) => a.name.localeCompare(b.name));
94+
95+
const files: string[] = [];
96+
for (const entry of entries) {
97+
const entryPath = join(dir, entry.name);
98+
if (entry.isDirectory()) {
99+
files.push(...(await listSourceFiles(entryPath)));
100+
} else if (entry.isFile() && isShippedSourceFile(entryPath)) {
101+
files.push(entryPath);
102+
}
103+
}
104+
return files;
105+
}
106+
107+
async function copyPlainTypeScript(sourceFile: string, outputFile: string): Promise<void> {
108+
await mkdir(dirname(outputFile), { recursive: true });
109+
await copyFile(sourceFile, outputFile);
110+
}
111+
112+
async function compileTsx(
113+
transformSolidSource: TransformSolidSource,
114+
sourceFile: string,
115+
outputFile: string,
116+
): Promise<void> {
117+
const code = await readFile(sourceFile, "utf8");
118+
const compiled = await transformSolidSource(code, {
119+
filename: sourceFile,
120+
moduleName: runtimeModuleId("@opentui/solid"),
121+
resolvePath: (specifier: string) =>
122+
runtimeSpecifiers.has(specifier) ? runtimeModuleId(specifier) : null,
123+
});
124+
125+
await mkdir(dirname(outputFile), { recursive: true });
126+
await writeFile(outputFile, compiled);
127+
}
128+
129+
const transformSolidSource = await loadTransformSolidSource();
130+
const files = await listSourceFiles(sourceRoot);
131+
132+
await rm(outputRoot, { recursive: true, force: true });
133+
134+
for (const sourceFile of files) {
135+
const relativePath = relative(sourceRoot, sourceFile);
136+
const outputFile = join(outputRoot, relativePath);
137+
138+
if (sourceFile.endsWith(".tsx")) {
139+
// OpenTUI skips the Solid compile-time transform for packages loaded from
140+
// node_modules. Without this precompiled copy, JSX children such as
141+
// signal-derived counts are evaluated once during element creation and
142+
// the sidebar freezes on its first paint. The virtual ids are required so
143+
// the compiled package binds the host process's single OpenTUI/Solid
144+
// runtime instead of loading a second copy from the plugin package.
145+
await compileTsx(transformSolidSource, sourceFile, outputFile);
146+
} else {
147+
await copyPlainTypeScript(sourceFile, outputFile);
148+
}
149+
}
150+
151+
console.log(`build-tui: wrote ${files.length} file(s) to ${relative(pluginRoot, outputRoot)}`);

packages/plugin/scripts/smoke-tui-import.ts

Lines changed: 12 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,16 @@
1-
// Import smoke test for the raw-TSX TUI entry (`./tui` export).
1+
// Import smoke test for the bare-Bun TUI loader fallback.
22
//
3-
// The TUI entry (src/tui/index.tsx) uses `/** @jsxImportSource @opentui/solid */`,
4-
// so loading it requires @opentui/solid + solid-js to resolve from the plugin
5-
// package itself. When those weren't declared as deps, OpenCode 1.17.10's
6-
// OpenTUI 0.4.2 bump surfaced an immediate load failure:
7-
// Cannot find module '@opentui/solid/jsx-dev-runtime'
8-
// `bun test` doesn't catch this because no suite imports the TSX entry. This
9-
// script imports it exactly like OpenCode loads the `./tui` export, so a missing
10-
// or version-mismatched OpenTUI/Solid runtime fails the smoke instead of shipping
11-
// a TUI that won't load. Run: bun packages/plugin/scripts/smoke-tui-import.ts
3+
// The published `./tui` export points at src/tui/entry.mjs. When the host does
4+
// not provide OpenTUI's virtual runtime-module registry, the loader falls back
5+
// to src/tui/index.tsx, which still needs @opentui/solid + solid-js to resolve
6+
// from the plugin package itself. `bun test` does not import that entry, so this
7+
// catches missing or version-mismatched OpenTUI/Solid runtime deps before a TUI
8+
// that cannot load is shipped. Run: bun packages/plugin/scripts/smoke-tui-import.ts
129
import { dirname, join } from "node:path";
1310
import { fileURLToPath } from "node:url";
1411

1512
const here = dirname(fileURLToPath(import.meta.url));
16-
const entry = join(here, "../src/tui/index.tsx");
13+
const entry = join(here, "../src/tui/entry.mjs");
1714

1815
let failures = 0;
1916
function check(name: string, cond: boolean, detail?: string): void {
@@ -26,18 +23,18 @@ function check(name: string, cond: boolean, detail?: string): void {
2623
}
2724

2825
try {
29-
// Resolving the OpenTUI JSX runtime the TSX entry compiles against is the
30-
// exact thing that broke; importing the entry exercises it end to end.
26+
// With no virtual runtime-module registry installed, the loader must catch the
27+
// virtual import failure and load the raw TSX fallback end to end.
3128
const mod = (await import(entry)) as { default?: { id?: string; tui?: unknown } };
32-
check("TUI entry imports without a missing-runtime error", true);
29+
check("TUI loader imports through the raw-TSX fallback", true);
3330
check(
3431
"exports the { id, tui } plugin shape",
3532
mod.default?.id === "opencode-magic-context" && typeof mod.default?.tui === "function",
3633
`got id=${mod.default?.id} tui=${typeof mod.default?.tui}`,
3734
);
3835
} catch (error) {
3936
check(
40-
"TUI entry imports without a missing-runtime error",
37+
"TUI loader imports through the raw-TSX fallback",
4138
false,
4239
error instanceof Error ? error.message : String(error),
4340
);

0 commit comments

Comments
 (0)