@solidrt/cli 0.0.49 → 0.0.50

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/AGENTS.md CHANGED
@@ -24,7 +24,11 @@ bundled `flux` runtime, not on Bun. Invoke via `bunx srt <command>`.
24
24
  separately (server distributes code; clients on other devices connect to it).
25
25
  - `bunx srt run src/index.tsx --capture out.script.json` - records keydown/keyup
26
26
  from every connected client into one script file (written on client
27
- disconnect), for replaying later with `render --script`.
27
+ disconnect), for replaying later with `render --script`. The file is JSON
28
+ Lines and hand-authorable - the exact shape (`after` ms, `type`, `key`,
29
+ `device`) is in `docs/cli.md` under `--capture`. For probing app state
30
+ without a display, `-- <args...>` reaches the app as `flux:process` argv
31
+ (also `docs/cli.md`), which is often simpler than scripting input.
28
32
 
29
33
  ## Verifying without a display (headless / CI / agent box)
30
34
 
@@ -35,7 +39,8 @@ Two reliable checks that need no GUI:
35
39
  renders offscreen via EGL and writes `frame-NNNNNN.png`. This actually
36
40
  proves the app renders. Combine with `--fps`/`--duration` (defaults
37
41
  1280x720, 60fps, 1s). No display needed: rendering uses SDL's offscreen
38
- driver (falling back to a hidden window where EGL cannot go headless).
42
+ driver, or alloy's own EGL pbuffer where that driver cannot go headless
43
+ (see the ANGLE gotcha below).
39
44
 
40
45
  Also headless: the bundled flux runtime runs a plain `.js` file directly -
41
46
  `node_modules/@solidrt/<platform>/flux script.js` (e.g.
@@ -49,6 +54,14 @@ behavior in isolation.
49
54
  - `--size` is physical output pixels: layout runs at exactly that size
50
55
  (display scale is pinned to 1), so frames are identical on every machine.
51
56
  - Run from the project directory. There is no `bunx --cwd` flag.
57
+ - On ANGLE stacks (Windows, macOS) SDL's offscreen driver cannot go
58
+ headless (no EGL device enumeration), so `render` there builds its own
59
+ EGL pbuffer context behind SDL's dummy video driver instead; the log says
60
+ "using a headless EGL context". If that also fails it renders into a
61
+ hidden window, which needs an interactive desktop session (fails under a
62
+ service, in Session 0, or over SSH-only). Verified headless on Linux
63
+ (Wayland) and the pbuffer path on Windows from a desktop session; a
64
+ non-interactive Windows session and macOS are unverified.
52
65
 
53
66
  ## Sessions (parallel dev servers on one machine)
54
67
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@solidrt/cli",
3
- "version": "0.0.49",
3
+ "version": "0.0.50",
4
4
  "license": "MIT",
5
5
  "author": "Antoine van Wel",
6
6
  "type": "module",
@@ -19,6 +19,7 @@
19
19
  "@babel/core": "^7.0.0",
20
20
  "@babel/plugin-syntax-jsx": "^7.0.0",
21
21
  "@babel/preset-typescript": "^7.0.0",
22
+ "@clack/prompts": "^1.7.0",
22
23
  "@jridgewell/remapping": "^2.3.0",
23
24
  "@jridgewell/trace-mapping": "^0.3.25",
24
25
  "@modelcontextprotocol/sdk": "^1.29.0",
@@ -28,16 +29,16 @@
28
29
  "zod": "^4.4.3"
29
30
  },
30
31
  "optionalDependencies": {
31
- "@solidrt/darwin-arm64": "0.0.49",
32
- "@solidrt/linux-arm64-gnu": "0.0.49",
33
- "@solidrt/linux-x64-gnu": "0.0.49",
34
- "@solidrt/win32-x64-msvc": "0.0.49"
32
+ "@solidrt/darwin-arm64": "0.0.50",
33
+ "@solidrt/linux-arm64-gnu": "0.0.50",
34
+ "@solidrt/linux-x64-gnu": "0.0.50",
35
+ "@solidrt/win32-x64-msvc": "0.0.50"
35
36
  },
36
37
  "peerDependencies": {
37
38
  "typescript": "^7"
38
39
  },
39
40
  "devDependencies": {
40
- "@solidrt/flux-types": "0.0.49",
41
+ "@solidrt/flux-types": "0.0.50",
41
42
  "@types/babel__core": "^7.20.5",
42
43
  "@types/bun": "latest"
43
44
  }
@@ -280,6 +280,12 @@ work stops being free" below is where it does not. Rules, in order of leverage:
280
280
  snapshot instead of re-rasterizing. A window shader's output is invisible
281
281
  to get_snapshot and every other MCP tool; `srt render` is the only way to
282
282
  see it (Run / verify below).
283
+ 7. `flux:wasm` is not the fast lane. It runs a pure interpreter (wasmi, no
284
+ JIT), so tight typed compute gains a small constant factor over the same
285
+ loop in JavaScript, nowhere near browser wasm speed, and every host call
286
+ costs marshalling. Use it to ship one compiled module across every target
287
+ without native binaries, not to speed up per-frame work; for that, rules
288
+ 1-2 (move it to the GPU, cut property writes) are the leverage.
283
289
 
284
290
  ### Isolates: heavy work off the JS thread
285
291
 
@@ -432,7 +438,7 @@ its tools over guessing at runtime state:
432
438
  re-fetch ids and restart cursors
433
439
  - get_stats: fps, CPU/memory, frame phase timings, setProperty rate, plus
434
440
  layout-activity counters for the last rebuild (nodes, measureCalls,
435
- paraShapes, dirtiedNodes, cacheGets/cacheHits) - when layoutMs looks
441
+ paraShapes/wordHits, dirtiedNodes, cacheGets/cacheHits) - when layoutMs looks
436
442
  wrong, these say whether the cost is text shaping, invalidation breadth,
437
443
  or a defeated layout cache (healthy incremental rebuilds show a near-100%
438
444
  cacheHits rate). reusedPerSec/skippedPerSec are the demand gate's visible
@@ -10,12 +10,13 @@
10
10
  "android": "srt client --android"
11
11
  },
12
12
  "dependencies": {
13
- "@solidrt/core": "0.0.49",
14
- "@solidrt/components": "0.0.49"
13
+ "@solidrt/core": "0.0.50",
14
+ "@solidrt/components": "0.0.50",
15
+ "@solidrt/3d": "0.0.50"
15
16
  },
16
17
  "devDependencies": {
17
- "@solidrt/cli": "0.0.49",
18
- "@solidrt/flux-types": "0.0.49",
18
+ "@solidrt/cli": "0.0.50",
19
+ "@solidrt/flux-types": "0.0.50",
19
20
  "typescript": "^7"
20
21
  }
21
22
  }
package/src/args.ts CHANGED
@@ -35,7 +35,7 @@ export let { values, positionals } = parseArgs({
35
35
  port: { type: "string" },
36
36
  android: { type: "boolean", default: false },
37
37
  device: { type: "string" },
38
- template: { type: "string", short: "t" },
38
+ with: { type: "string" },
39
39
  },
40
40
  allowPositionals: true,
41
41
  })
@@ -159,7 +159,7 @@ Commands:
159
159
  mcp MCP server (stdio) exposing the running dev server to coding agents
160
160
 
161
161
  init options:
162
- -t, --template <name> Start from a named template (skips the interactive picker)
162
+ --with <pkg,pkg> Extensions to include, e.g. @solidrt/components,@solidrt/3d (skips the picker)
163
163
 
164
164
  run/server options:
165
165
  -s, --session <N> Session number: dev server on port 34884+N, client slot N (default: 0)
@@ -1,7 +1,7 @@
1
1
  import { cp, mkdir, readFile, readdir, writeFile } from "node:fs/promises"
2
2
  import { basename, dirname, join, resolve } from "node:path"
3
3
  import { source, values } from "../args"
4
- import { select, text } from "../prompt"
4
+ import { multiselect, note, text } from "../prompt"
5
5
 
6
6
  const DEFAULT_NAME = "solidrt-app"
7
7
 
@@ -29,76 +29,57 @@ function packageName(dir: string): string {
29
29
  }
30
30
 
31
31
  const DEFAULT_TEMPLATE = "default"
32
- const TEMPLATE_MANIFEST = "template.json"
33
-
34
- // Each template's template.json declares which level the scaffolded app is
35
- // written at: "core" (only @solidrt/core, no component framework) or
36
- // "components" (built with @solidrt/components). The level decides the
37
- // generated dependencies; the description labels the template in the picker.
38
- interface TemplateInfo {
39
- name: string
40
- level: "core" | "components"
32
+
33
+ // Optional packages an app can opt into on top of core. Each maps to a
34
+ // dependency in the scaffold package.json (kept when selected, removed
35
+ // otherwise) and optionally to a starter under scaffold/templates/.
36
+ interface Extension {
37
+ pkg: string
38
+ template?: string
41
39
  description: string
42
40
  }
43
41
 
44
- // Templates are the directories under scaffold/templates/; each holds the files
45
- // that become the new project's src/, plus a template.json manifest. `default`
46
- // sorts first as the starting point, the rest alphabetically.
47
- async function listTemplates(): Promise<TemplateInfo[]> {
48
- let entries = await readdir(TEMPLATES_DIR, { withFileTypes: true })
49
- let names = entries
50
- .filter((e) => e.isDirectory())
51
- .map((e) => e.name)
52
- .sort((a, b) =>
53
- a === DEFAULT_TEMPLATE ? -1 : b === DEFAULT_TEMPLATE ? 1 : a.localeCompare(b),
54
- )
55
- let templates: TemplateInfo[] = []
56
- for (let name of names) {
57
- // A missing manifest falls back to the components level: it keeps every
58
- // dependency, so the scaffolded app works at either level.
59
- let manifest = await readFile(join(TEMPLATES_DIR, name, TEMPLATE_MANIFEST), "utf8")
60
- .then((raw) => JSON.parse(raw))
61
- .catch(() => ({}))
62
- templates.push({
63
- name,
64
- level: manifest.level === "core" ? "core" : "components",
65
- description: typeof manifest.description === "string" ? manifest.description : "",
66
- })
67
- }
68
- return templates
69
- }
42
+ const EXTENSIONS: Extension[] = [
43
+ {
44
+ pkg: "@solidrt/components",
45
+ template: "components",
46
+ description: "component framework: widgets, theming, navigation",
47
+ },
48
+ { pkg: "@solidrt/3d", description: "general purpose 3D library" },
49
+ ]
70
50
 
71
- // Resolve which template to scaffold from: an explicit --template if valid, an
72
- // interactive picker on a TTY, else `default` (or the first available).
73
- async function resolveTemplate(): Promise<TemplateInfo> {
74
- let templates = await listTemplates()
75
- if (templates.length === 0) {
76
- console.error(`!! No templates found in ${TEMPLATES_DIR}`)
77
- process.exit(1)
78
- }
79
- let chosen = values.template
80
- if (chosen) {
81
- let found = templates.find((t) => t.name === chosen)
82
- if (!found) {
83
- let names = templates.map((t) => t.name).join(", ")
84
- console.error(`!! Unknown template "${chosen}"; choose from: ${names}`)
85
- process.exit(1)
51
+ // Resolve which extensions the app takes: an explicit --with list if valid,
52
+ // an interactive picker on a TTY, else none (core only).
53
+ async function resolveExtensions(): Promise<Extension[]> {
54
+ let raw = values.with
55
+ if (raw !== undefined) {
56
+ let names = raw.split(",").map((n) => n.trim()).filter(Boolean)
57
+ let chosen: Extension[] = []
58
+ for (let name of names) {
59
+ let found = EXTENSIONS.find((e) => e.pkg === name)
60
+ if (!found) {
61
+ let all = EXTENSIONS.map((e) => e.pkg).join(", ")
62
+ console.error(`!! Unknown extension "${name}"; choose from: ${all}`)
63
+ process.exit(1)
64
+ }
65
+ if (!chosen.includes(found)) chosen.push(found)
86
66
  }
87
- return found
88
- }
89
- if (process.stdin.isTTY) {
90
- let picked = await select(
91
- "Select a template",
92
- templates.map((t) => {
93
- // Core is the runtime every app has; anything else is a package the
94
- // app opts into, so the picker marks it as such.
95
- let name = t.level === "core" ? t.name : `${t.name} (extension)`
96
- return { label: t.description ? `${name} - ${t.description}` : name, value: t.name }
97
- }),
98
- )
99
- return templates.find((t) => t.name === picked)!
67
+ return chosen
100
68
  }
101
- return templates.find((t) => t.name === DEFAULT_TEMPLATE) ?? templates[0]!
69
+ if (!process.stdin.isTTY) return []
70
+ // Core is the runtime every app has, so it is not a choice.
71
+ note("@solidrt/core is always included", "Packages")
72
+ let picked = await multiselect(
73
+ "Select extensions",
74
+ EXTENSIONS.map((e) => ({ label: `${e.pkg} - ${e.description}`, value: e.pkg })),
75
+ )
76
+ return EXTENSIONS.filter((e) => picked.includes(e.pkg))
77
+ }
78
+
79
+ // The starter src/ comes from the first selected extension that brings a
80
+ // template; with none, the core `default` starter.
81
+ function resolveTemplate(extensions: Extension[]): string {
82
+ return extensions.find((e) => e.template)?.template ?? DEFAULT_TEMPLATE
102
83
  }
103
84
 
104
85
  export async function runInitCommand() {
@@ -120,9 +101,11 @@ export async function runInitCommand() {
120
101
  process.exit(1)
121
102
  }
122
103
 
123
- let template = await resolveTemplate()
104
+ let extensions = await resolveExtensions()
105
+ let template = resolveTemplate(extensions)
106
+ let summary = ["@solidrt/core", ...extensions.map((e) => e.pkg)].join(", ")
124
107
 
125
- console.log(`>> Scaffolding SolidRT project in ${resolve(dir)} (${template.name})`)
108
+ console.log(`>> Scaffolding SolidRT project in ${resolve(dir)} (${summary})`)
126
109
  for (let { from, to } of TEMPLATE_FILES) {
127
110
  let dest = join(dir, to)
128
111
  await mkdir(dirname(dest), { recursive: true })
@@ -130,13 +113,11 @@ export async function runInitCommand() {
130
113
  console.log(` Write ${to}`)
131
114
  }
132
115
 
133
- // The chosen template's files become the project's src/. Entries may be
134
- // nested directories (e.g. an asset folder), so copy recursively. The
135
- // manifest describes the template rather than belonging to the app.
136
- let templateDir = join(TEMPLATES_DIR, template.name)
116
+ // The template's files become the project's src/. Entries may be nested
117
+ // directories (e.g. an asset folder), so copy recursively.
118
+ let templateDir = join(TEMPLATES_DIR, template)
137
119
  await mkdir(join(dir, "src"), { recursive: true })
138
120
  for (let file of await readdir(templateDir)) {
139
- if (file === TEMPLATE_MANIFEST) continue
140
121
  await cp(join(templateDir, file), join(dir, "src", file), { recursive: true })
141
122
  console.log(` Write src/${file}`)
142
123
  }
@@ -149,12 +130,15 @@ export async function runInitCommand() {
149
130
  await writeFile(join(dir, "assets", "icon.svg"), await readFile(join(SCAFFOLD_DIR, "icon.svg")))
150
131
  console.log(" Write assets/icon.svg")
151
132
 
152
- // The scaffold package.json carries a placeholder name; set it from the
153
- // target folder. A core-level app gets no component framework dependency.
133
+ // The scaffold package.json carries a placeholder name and every extension
134
+ // dependency; set the name from the target folder and keep only the
135
+ // selected extensions.
154
136
  let pkgPath = join(dir, "package.json")
155
137
  let pkg = JSON.parse(await readFile(pkgPath, "utf8"))
156
138
  pkg.name = packageName(dir)
157
- if (template.level === "core") delete pkg.dependencies["@solidrt/components"]
139
+ for (let ext of EXTENSIONS) {
140
+ if (!extensions.includes(ext)) delete pkg.dependencies[ext.pkg]
141
+ }
158
142
  await writeFile(pkgPath, JSON.stringify(pkg, null, 2) + "\n")
159
143
 
160
144
  // Deps are declared in scaffold/package.json (Solid peers resolve via
@@ -10,8 +10,8 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"
10
10
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
11
11
  import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js"
12
12
  import { dirname, join, resolve } from "node:path"
13
- import { existsSync, readdirSync, readFileSync } from "node:fs"
14
- import { values } from "../args"
13
+ import { existsSync, readdirSync, readFileSync, realpathSync } from "node:fs"
14
+ import { values, DEFAULT_DEV_PORT } from "../args"
15
15
  import { DEV_PORT } from "../dev-server"
16
16
  import { devDir } from "../dev-dir"
17
17
 
@@ -35,12 +35,30 @@ function findProjectDir(): string | null {
35
35
  }
36
36
  }
37
37
 
38
+ // The two sides of a projectDir comparison come from different processes
39
+ // (the server's entry path, the bridge's cwd) and only agree by construction
40
+ // on the directory, not the spelling: an editor-spawned bridge on Windows
41
+ // keeps its parent's lower-case drive letter while a shell writes it upper
42
+ // case, and 8.3 names, symlinks and subst drives are the same class. Compare
43
+ // the canonical path, so the spelling never decides.
44
+ function sameDir(a: string, b: string): boolean {
45
+ if (a === b) return true
46
+ try {
47
+ return realpathSync.native(a) === realpathSync.native(b)
48
+ } catch {
49
+ return false
50
+ }
51
+ }
52
+
53
+ // Only ESRCH means the process is gone. EPERM is a live process this bridge
54
+ // may not signal (Windows reports it for other users' processes), and a
55
+ // bare try/catch would drop that healthy server from the registry.
38
56
  function pidAlive(pid: number): boolean {
39
57
  try {
40
58
  process.kill(pid, 0)
41
59
  return true
42
- } catch {
43
- return false
60
+ } catch (e: any) {
61
+ return e?.code === "EPERM"
44
62
  }
45
63
  }
46
64
 
@@ -80,7 +98,8 @@ async function resolvePort(): Promise<PortResult> {
80
98
  message: `No package.json found above ${process.cwd()}, so no dev server can be resolved by project. Pass -s <N> or --port <N> to srt mcp.`,
81
99
  }
82
100
  }
83
- let matches = liveRecords().filter((r) => r.projectDir === project && pidAlive(r.pid))
101
+ let records = liveRecords()
102
+ let matches = records.filter((r) => sameDir(r.projectDir, project) && pidAlive(r.pid))
84
103
  if (matches.length > 1) {
85
104
  let ports = matches
86
105
  .map((r) => r.port)
@@ -89,7 +108,25 @@ async function resolvePort(): Promise<PortResult> {
89
108
  return { ok: false, message: `${matches.length} dev servers are serving this project (ports ${ports}); pass -s <N> to srt mcp` }
90
109
  }
91
110
  if (matches.length === 0) {
92
- return { ok: false, message: `No dev server for ${project}. Start one with srt run, or pass -s <N> to srt mcp.` }
111
+ // A lookup by key that fails against a small table prints the table: an
112
+ // empty registry, a dead pid and a record for another project are three
113
+ // different problems, and the reader can only tell them apart if the
114
+ // candidates are listed next to the key that was looked up.
115
+ let listing =
116
+ records.length === 0
117
+ ? `Registry ${devDir("servers")}: no records.`
118
+ : `Registry ${devDir("servers")}: ${records.length} record(s).\n` +
119
+ records
120
+ .map((r) => {
121
+ let session = r.port - DEFAULT_DEV_PORT
122
+ let flag = session >= 0 && session < 100 ? `-s ${session}` : `--port ${r.port}`
123
+ return ` port ${r.port} (${flag}) pid ${r.pid} (${pidAlive(r.pid) ? "alive" : "dead"}) serving ${r.projectDir}`
124
+ })
125
+ .join("\n")
126
+ return {
127
+ ok: false,
128
+ message: `No dev server for ${project}.\n${listing}\nStart one with srt run, or pin one of the servers above by passing its flag to srt mcp.`,
129
+ }
93
130
  }
94
131
  let port = matches[0]!.port
95
132
  // The record is a hint; the server is authoritative. The probe catches a
@@ -97,7 +134,7 @@ async function resolvePort(): Promise<PortResult> {
97
134
  try {
98
135
  let probe = await fetch(`http://127.0.0.1:${port}/__control__/clients`)
99
136
  let body: any = await probe.json().catch(() => null)
100
- if (!probe.ok || body?.projectDir !== project) {
137
+ if (!probe.ok || typeof body?.projectDir !== "string" || !sameDir(body.projectDir, project)) {
101
138
  return {
102
139
  ok: false,
103
140
  message: `The server on port ${port} is not serving ${project}${
@@ -206,7 +243,7 @@ let TOOLS: {
206
243
  name: "get_stats",
207
244
  readOnly: true,
208
245
  description:
209
- "Performance statistics from a running app client: fps, CPU%, memory, smoothed JS/layout/paint/hover frame times (ms), setProperty writes per frame, demand-gate reuse/skip counts per second, and live texture count. Layout-activity counters cover the last full rebuild, raw: nodes (live node count, mounted AND detached), mountedNodes/orphanNodes (live at query time: nodes reachable from the root vs not - orphans growing at a stable tree shape mean an unmount leak; absent when no engine is running), measureCalls (text measures; mostly cache hits, cheap), paraShapes (paragraphs actually shaped; the expensive signal - high layoutMs with near-zero paraShapes means the cost is not text shaping), dirtiedNodes (layout caches cleared by property writes since the previous rebuild; how much of the tree a write burst invalidated), cacheGets/cacheHits (layout-cache lookups during the rebuild; a hit on a container skips its whole subtree, so a healthy incremental rebuild shows a near-100% hit rate - a low rate at scale means the layout cache is being defeated). GPU-side health, read live at query time (absent when no engine is running): rasterQueue (raster commands sent but not yet executed; stuck nonzero means the raster thread is backlogged - the state where fps and frameMs go blind because no frames complete), idleTicks (cumulative idle frame signals emitted while the GPU had nothing queued; idleTicks racing while rasterQueue sits nonzero would mean the idle-tick gate is broken), fenceTimeouts (cumulative present-fence waits that expired instead of signaling - each one is a frame where the GPU was over budget for 100ms+ and one-frame-in-flight pacing was lost; zero on a healthy machine, climbing means the GPU is the bottleneck right now), gpuPasses/gpuPassMs (cumulative shader/pipeline target renders on the raster thread and the wall time they took in whole ms - diff two queries to get a rate; passes racing far ahead of frames means redundant target re-renders, the failure mode where fps and frameMs look healthy while the raster thread drowns; the ms figure is raster-thread occupancy issuing the passes, not GPU-side duration), rasterCmdMs (cumulative wall time in whole ms the raster thread spent executing non-frame commands - texture uploads, readbacks, offscreen rasterizations, shader compiles, param writes and the target re-renders they trigger; the work frameMs never sees, so rasterCmdMs growing much faster than frames are presented means the raster thread is drowning in side work even if every counter above looks calm).",
246
+ "Performance statistics from a running app client: fps, CPU%, memory, smoothed JS/layout/paint/hover frame times (ms), setProperty writes per frame, demand-gate reuse/skip counts per second, and live texture count. Layout-activity counters cover the last full rebuild, raw: nodes (live node count, mounted AND detached), mountedNodes/orphanNodes (live at query time: nodes reachable from the root vs not - orphans growing at a stable tree shape mean an unmount leak; absent when no engine is running), measureCalls (text measures; mostly cache hits, cheap), paraShapes (paragraphs actually shaped, i.e. words the shared word cache did not have; the expensive signal - high layoutMs with near-zero paraShapes means the cost is not text shaping), wordHits (words answered from the shared word cache; hits high and paraShapes near zero on a text change means only the changed words were reshaped), dirtiedNodes (layout caches cleared by property writes since the previous rebuild; how much of the tree a write burst invalidated), cacheGets/cacheHits (layout-cache lookups during the rebuild; a hit on a container skips its whole subtree, so a healthy incremental rebuild shows a near-100% hit rate - a low rate at scale means the layout cache is being defeated). GPU-side health, read live at query time (absent when no engine is running): rasterQueue (raster commands sent but not yet executed; stuck nonzero means the raster thread is backlogged - the state where fps and frameMs go blind because no frames complete), idleTicks (cumulative idle frame signals emitted while the GPU had nothing queued; idleTicks racing while rasterQueue sits nonzero would mean the idle-tick gate is broken), fenceTimeouts (cumulative present-fence waits that expired instead of signaling - each one is a frame where the GPU was over budget for 100ms+ and one-frame-in-flight pacing was lost; zero on a healthy machine, climbing means the GPU is the bottleneck right now), gpuPasses/gpuPassMs (cumulative shader/pipeline target renders on the raster thread and the wall time they took in whole ms - diff two queries to get a rate; passes racing far ahead of frames means redundant target re-renders, the failure mode where fps and frameMs look healthy while the raster thread drowns; the ms figure is raster-thread occupancy issuing the passes, not GPU-side duration), rasterCmdMs (cumulative wall time in whole ms the raster thread spent executing non-frame commands - texture uploads, readbacks, offscreen rasterizations, shader compiles, param writes and the target re-renders they trigger; the work frameMs never sees, so rasterCmdMs growing much faster than frames are presented means the raster thread is drowning in side work even if every counter above looks calm).",
210
247
  inputSchema: { client: CLIENT_ARG },
211
248
  },
212
249
  {
package/src/prompt.ts CHANGED
@@ -1,18 +1,21 @@
1
- import { createInterface, emitKeypressEvents } from "node:readline"
1
+ import * as clack from "@clack/prompts"
2
+
3
+ // Thin wrappers over @clack/prompts. Every prompt guards on a TTY: a non-TTY
4
+ // stdin resolves the default rather than blocking on input that will never
5
+ // arrive. Cancelling (ctrl-c) exits the process.
6
+
7
+ function unwrap<T>(value: T | symbol): T {
8
+ if (clack.isCancel(value)) {
9
+ clack.cancel("Cancelled")
10
+ process.exit(130)
11
+ }
12
+ return value as T
13
+ }
2
14
 
3
- // Single-line text prompt with an optional default (shown in parentheses, used
4
- // when the answer is blank). Non-TTY stdin resolves the default rather than
5
- // blocking on input that will never arrive.
6
- export function text(message: string, def = ""): Promise<string> {
7
- return new Promise<string>((resolve) => {
8
- if (!process.stdin.isTTY) return resolve(def)
9
- let rl = createInterface({ input: process.stdin, output: process.stdout })
10
- let suffix = def ? ` (${def})` : ""
11
- rl.question(`? ${message}${suffix}: `, (answer) => {
12
- rl.close()
13
- resolve(answer.trim() || def)
14
- })
15
- })
15
+ // Single-line text prompt; a blank answer resolves the default.
16
+ export async function text(message: string, def = ""): Promise<string> {
17
+ if (!process.stdin.isTTY) return def
18
+ return unwrap(await clack.text({ message, defaultValue: def, placeholder: def }))
16
19
  }
17
20
 
18
21
  export interface SelectOption {
@@ -20,65 +23,36 @@ export interface SelectOption {
20
23
  value: string
21
24
  }
22
25
 
23
- // Minimal arrow-key single-select prompt, built on node:readline (same
24
- // dependency-free approach as repl.ts). Renders the option list, moves the
25
- // highlight on up/down, resolves the chosen value on enter. Options are plain
26
- // strings or { label, value } pairs when the display text differs from the
27
- // resolved value. Callers guard on process.stdin.isTTY; a non-TTY stdin here
28
- // resolves the first option rather than hanging on input that will never
29
- // arrive.
30
- export function select(message: string, options: Array<string | SelectOption>): Promise<string> {
26
+ // Arrow-key single-select; non-TTY resolves the first option.
27
+ export async function select(message: string, options: Array<string | SelectOption>): Promise<string> {
31
28
  let items = options.map((o) => (typeof o === "string" ? { label: o, value: o } : o))
32
- return new Promise((resolve) => {
33
- let input = process.stdin
34
- let output = process.stdout
35
- if (!input.isTTY) return resolve(items[0]!.value)
36
-
37
- let selected = 0
38
- emitKeypressEvents(input)
39
- let wasRaw = input.isRaw
40
- input.setRawMode(true)
41
-
42
- let render = (first = false) => {
43
- // After the first paint the cursor sits below the block; move it back up
44
- // to the message line so the list redraws in place.
45
- if (!first) output.write(`\x1b[${items.length + 1}A`)
46
- output.write(`\x1b[K? ${message}\n`)
47
- for (let i = 0; i < items.length; i++) {
48
- let active = i === selected
49
- let pointer = active ? "\x1b[36m> " : " "
50
- let reset = active ? "\x1b[0m" : ""
51
- output.write(`\x1b[K${pointer}${items[i]!.label}${reset}\n`)
52
- }
53
- }
54
-
55
- let cleanup = () => {
56
- input.off("keypress", onKey)
57
- input.setRawMode(wasRaw)
58
- input.pause()
59
- }
29
+ if (!process.stdin.isTTY) return items[0]!.value
30
+ return unwrap(await clack.select({ message, options: items }))
31
+ }
60
32
 
61
- let onKey = (_str: string, key: { name: string; ctrl: boolean } | undefined) => {
62
- if (!key) return
63
- if (key.name === "up") {
64
- selected = (selected - 1 + items.length) % items.length
65
- render()
66
- } else if (key.name === "down") {
67
- selected = (selected + 1) % items.length
68
- render()
69
- } else if (key.name === "return" || key.name === "enter") {
70
- cleanup()
71
- output.write("\n")
72
- resolve(items[selected]!.value)
73
- } else if (key.ctrl && (key.name === "c" || key.name === "d")) {
74
- cleanup()
75
- output.write("\n")
76
- process.exit(130)
77
- }
78
- }
33
+ export interface MultiSelectOption {
34
+ label: string
35
+ value: string
36
+ checked?: boolean
37
+ }
79
38
 
80
- input.on("keypress", onKey)
81
- input.resume()
82
- render(true)
83
- })
39
+ // Space toggles, enter confirms; resolves the selected values in option
40
+ // order. Non-TTY resolves the preselected values.
41
+ export async function multiselect(message: string, options: MultiSelectOption[]): Promise<string[]> {
42
+ let preset = options.filter((o) => o.checked).map((o) => o.value)
43
+ if (!process.stdin.isTTY) return preset
44
+ let picked = unwrap(
45
+ await clack.multiselect({
46
+ message,
47
+ options: options.map((o) => ({ label: o.label, value: o.value })),
48
+ initialValues: preset,
49
+ required: false,
50
+ }),
51
+ )
52
+ return options.filter((o) => picked.includes(o.value)).map((o) => o.value)
84
53
  }
54
+
55
+ // Boxed informational message; silent on a non-TTY.
56
+ export function note(message: string, title?: string) {
57
+ if (process.stdin.isTTY) clack.note(message, title)
58
+ }
@@ -1,4 +0,0 @@
1
- {
2
- "level": "components",
3
- "description": "components starter application"
4
- }
@@ -1,4 +0,0 @@
1
- {
2
- "level": "core",
3
- "description": "core starter application"
4
- }