@solidrt/cli 0.0.27 → 0.0.28

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@solidrt/cli",
3
- "version": "0.0.27",
3
+ "version": "0.0.28",
4
4
  "license": "MIT",
5
5
  "author": "Antoine van Wel",
6
6
  "type": "module",
@@ -27,16 +27,16 @@
27
27
  "zod": "^4.4.3"
28
28
  },
29
29
  "optionalDependencies": {
30
- "@solidrt/darwin-arm64": "0.0.27",
31
- "@solidrt/linux-x64-gnu": "0.0.27",
32
- "@solidrt/win32-x64-msvc": "0.0.27"
30
+ "@solidrt/darwin-arm64": "0.0.28",
31
+ "@solidrt/linux-x64-gnu": "0.0.28",
32
+ "@solidrt/win32-x64-msvc": "0.0.28"
33
33
  },
34
34
  "peerDependencies": {
35
- "@solidrt/core": "0.0.27",
35
+ "@solidrt/core": "0.0.28",
36
36
  "typescript": "^7"
37
37
  },
38
38
  "devDependencies": {
39
- "@solidrt/flux-types": "0.0.27",
39
+ "@solidrt/flux-types": "0.0.28",
40
40
  "@types/bun": "latest"
41
41
  }
42
42
  }
@@ -4,6 +4,23 @@ This project uses SolidRT: a custom SolidJS renderer that paints through a Rust
4
4
  runtime. No DOM, no HTML, no CSS cascade. If you are an AI assistant, read this
5
5
  before writing or editing code here.
6
6
 
7
+ ## Levels: core, and frameworks on top
8
+
9
+ - @solidrt/core is the low-level foundation: host intrinsics (`<window>`,
10
+ `<view>`, `<text>`, the detached `d-*` drawing primitives) with flat props
11
+ that feed the layout and paint engine directly. An app can be written
12
+ entirely at this level.
13
+ - Higher-level component frameworks build on core. @solidrt/components is
14
+ the first-party one: themed widgets (Window, View, Text, Button,
15
+ ScrollView, SafeArea, ...) with the `layout={{...}}`/`style={{...}}` prop
16
+ split. It is not privileged - a framework is just functions returning core
17
+ JSX, and an app can use a third-party one or grow its own.
18
+
19
+ Match the level the code you are editing already uses. package.json shows
20
+ the choice this app made: if no component framework is among the
21
+ dependencies, the app is core-only - do not add one for a change core
22
+ covers.
23
+
7
24
  Authoritative references ship inside the installed packages - read them:
8
25
  - node_modules/solid-js/CHEATSHEET.md - SolidJS 2.0 reactivity/control-flow model
9
26
  - node_modules/@solidrt/components/AGENTS.md - the component vocabulary; build UI from these
@@ -24,10 +41,10 @@ Authoritative references ship inside the installed packages - read them:
24
41
 
25
42
  1. This is SolidJS 2.0 (see CHEATSHEET.md for the reactivity/control-flow
26
43
  model), rendering through a custom Rust runtime instead of the DOM. Build
27
- UI from @solidrt/components (Window, View, Text, Image, TextInput,
28
- ScrollView, Pressable, Button, SafeArea, theme/setTheme) - it is the
29
- higher-level, batteries-included vocabulary and is where most app code
30
- should live.
44
+ UI at the level the app uses (see "Levels" above): core intrinsics
45
+ directly, or a component framework such as @solidrt/components (Window,
46
+ View, Text, Image, TextInput, ScrollView, Pressable, Button, SafeArea,
47
+ theme/setTheme) - the first-party, batteries-included one.
31
48
  2. Most components split their props into two objects: `layout={{...}}` for
32
49
  anything that feeds the layout engine (flex/grid, sizing, padding/margin,
33
50
  position; font fields for Text) and `style={{...}}` for paint-only
@@ -52,7 +69,8 @@ Authoritative references ship inside the installed packages - read them:
52
69
  (runtime-paced, auto-cleans), re-exported from @solidrt/core.
53
70
  requestAnimationFrame(t => {}) exists as a web-standard one-shot but is
54
71
  not the preferred animation driver.
55
- 8. Reach for @solidrt/core directly only for what components doesn't wrap:
72
+ 8. In a components-based app, reach for @solidrt/core directly only for
73
+ what components doesn't wrap:
56
74
  raw host intrinsics and the `d-` (detached, non-layout) primitives like
57
75
  `d-rect`/`d-path`/`d-oval` for vector art or perf-sensitive positioned
58
76
  drawing, device/GPU subpath imports (@solidrt/core/camera, /microphone,
@@ -76,11 +94,29 @@ Authoritative references ship inside the installed packages - read them:
76
94
  `compute` is the tracked read phase; `apply(value, prev)` runs untracked
77
95
  and is where side effects/DOM-equivalent writes belong. The old
78
96
  single-arg `createEffect(fn)` form is gone - using it is an error.
97
+ 13. A scroll container (ScrollView, or anything on createScroll) needs an
98
+ explicit main-axis size - a height, or flex inside a sized parent. With
99
+ neither it resolves to 0 and its content silently vanishes; maxHeight
100
+ alone does not size it (the auto size it would clamp is already 0). The
101
+ runtime warns when this happens.
102
+ 14. Text `lineHeight` is a MULTIPLIER of fontSize (the theme uses 1.3-1.6),
103
+ not pixels. A CSS-reflex value like 22 makes each line box 22x the font
104
+ size: the text becomes blank space and the parent balloons.
105
+ 15. Signal writes flush on a microtask: a handler that sets a signal and
106
+ immediately reads it back gets the OLD value. Read the new value in an
107
+ effect, or call `flush()` (from @solidjs/signals) to force it through.
108
+ 16. Portals cannot mount during the app's initial render: a Modal (or any
109
+ createPortal content) that is visible at first mount throws "no mount
110
+ target". Gate it behind a signal that starts false and open it after
111
+ startup - overlay content is opened, not born open.
79
112
 
80
113
  ## Run / verify
81
114
 
82
115
  - bunx srt run src/index.tsx - dev server + window (needs a display)
83
- - bunx srt bundle src/index.tsx - exit 0 means it compiles
116
+ - bunx srt check src/index.tsx - exit 0 means it compiles and the app's
117
+ types hold (dependency-internal type errors are hidden). Builds in memory:
118
+ writes nothing and never triggers a dev-server reload, so use this while
119
+ iterating - `srt bundle` writes output files and reloads connected clients
84
120
  - bunx srt render src/index.tsx --size 480x640 --duration 1 --fps 2 - headless
85
121
  render to PNG frames (proves it renders; see the cli AGENTS.md for where the
86
122
  frames land)
@@ -93,12 +129,25 @@ its tools over guessing at runtime state:
93
129
 
94
130
  - list_clients: connected app clients, their platform and runtime capabilities
95
131
  - get_logs: console output and runtime errors (seq cursor; `wait_ms` long-poll
96
- to catch output right after a reload)
132
+ to catch output right after a reload; `level`/`contains` filters; repeated
133
+ lines collapse into one entry with a `repeats` count)
97
134
  - get_render_tree: what the app actually rendered - node kinds, text, and
98
- window-relative boxes
99
- - get_stats: fps, CPU/memory, frame phase timings, setProperty rate
135
+ window-relative boxes. Whole trees get large: `query` finds nodes by
136
+ kind/text, then `root` + `depth` inspect just that region
137
+ - client ids and log cursors die with the dev server: list_clients and
138
+ get_logs responses carry `generation`, and a changed generation means
139
+ re-fetch ids and restart cursors
140
+ - get_stats: fps, CPU/memory, frame phase timings, setProperty rate, plus
141
+ layout-activity counters for the last rebuild (nodes, measureCalls,
142
+ paraShapes, dirtiedNodes, cacheGets/cacheHits) - when layoutMs looks
143
+ wrong, these say whether the cost is text shaping, invalidation breadth,
144
+ or a defeated layout cache (healthy incremental rebuilds show a near-100%
145
+ cacheHits rate)
100
146
  - get_snapshot: PNG capture of any render-tree node's pixels (get node ids
101
- from get_render_tree; the window node captures everything)
147
+ from get_render_tree; the window node captures everything). Pass `save_to`
148
+ on get_snapshot or get_texture to also write the PNG to a file - the image
149
+ in the tool result cannot be saved afterwards, so decide before capturing
150
+ (e.g. keep a before/after pair to diff)
102
151
  - get_gpu_resources: inventory of GPU state - textures (size, render target
103
152
  or not), vertex buffers (byteLength), pipelines (draw count, attribute
104
153
  layout, bound textures, last-applied uniform values)
@@ -111,7 +160,7 @@ its tools over guessing at runtime state:
111
160
  from pixels
112
161
  - reload: rebuild from source and push to every client - THE dev loop is
113
162
  edit -> reload -> get_logs -> get_snapshot. reload surfaces build errors
114
- but not type errors; run the typecheck separately.
163
+ but not type errors; run `bunx srt check` for those.
115
164
 
116
165
  The tools need a running app: if list_clients is empty, ask the user to start
117
166
  `bunx srt run src/index.tsx`.
@@ -125,10 +174,10 @@ The tools need a running app: if list_clients is empty, ask the user to start
125
174
  setFocus(node.id) from the window's ref or onKeyDown never fires. This
126
175
  runtime names arrow keys "Left"/"Right"/"Up"/"Down", not "ArrowLeft".
127
176
  - Idle frames skip work: shaders/pipelines only re-render when their params
128
- change, so measure performance while uniforms are actually changing, and
129
- a get_snapshot of an idle client can time out - retry, make the app
130
- produce a frame, or use get_texture on the pipeline's render target, which
131
- reads the last-drawn frame without needing a new one.
177
+ change, so measure performance while uniforms are actually changing.
178
+ get_snapshot works on an idle client (it requests its own frame); a
179
+ timeout means the JS thread is busy or wedged. get_texture on a pipeline's
180
+ render target reads the last-drawn frame without needing a new one.
132
181
  - When a human reports a visual bug: capture a snapshot and SAY WHAT YOU SEE
133
182
  in it before investigating, so you agree on the symptom. If you cannot see
134
183
  the problem in the capture, say that instead of guessing.
@@ -160,3 +209,23 @@ The tools need a running app: if list_clients is empty, ask the user to start
160
209
  a second one - self-running animation (game clocks, shader-driven
161
210
  effects) must make one state change at startup to prime the loop; after
162
211
  that its own writes keep it awake.
212
+ - Layout is incremental: a change re-solves only the dirty path, and clean
213
+ subtrees answer from a per-node cache, so long lists no longer cap layout
214
+ (a thousand-node tree relays out in well under a millisecond). If layoutMs
215
+ still grows with tree size, read the get_stats counters - a low
216
+ cacheHits/cacheGets ratio means the layout cache is being defeated, high
217
+ paraShapes means text is actually reshaping. Very long lists still pay
218
+ for the initial mount and for memory, so windowing stays sensible at the
219
+ thousands-of-rows scale.
220
+ - Remote images: createImage (and Image) dedupes repeated URLs, caches the
221
+ bytes on disk, and the runtime rate-limits concurrent asset fetches per
222
+ host - do not build your own promise cache around it. Images are fetched
223
+ with no freshness check (an already-cached URL is never re-checked), so
224
+ use versioned URLs for content that changes. Use Image's `fallback` prop
225
+ (an image source) for the broken-image case instead of catching errors
226
+ yourself.
227
+ - fetch() never caches by default and ignores server cache headers. Caching
228
+ is explicit and per call: `fetch(url, { cache: "force-cache" })` for
229
+ assets (serve from disk or fetch-and-store, no freshness),
230
+ `{ cache: "reload" }` to refresh an entry. Image/createImage already do
231
+ this for you.
@@ -9,12 +9,12 @@
9
9
  "android": "srt client --android"
10
10
  },
11
11
  "dependencies": {
12
- "@solidrt/core": "0.0.27",
13
- "@solidrt/components": "0.0.27"
12
+ "@solidrt/core": "0.0.28",
13
+ "@solidrt/components": "0.0.28"
14
14
  },
15
15
  "devDependencies": {
16
- "@solidrt/cli": "0.0.27",
17
- "@solidrt/flux-types": "0.0.27",
16
+ "@solidrt/cli": "0.0.28",
17
+ "@solidrt/flux-types": "0.0.28",
18
18
  "typescript": "^7"
19
19
  }
20
20
  }
@@ -0,0 +1,15 @@
1
+ // Components template: built with the @solidrt/components framework.
2
+ import { render } from "@solidrt/core"
3
+ import { Window, SafeArea, Text } from "@solidrt/components"
4
+
5
+ function App() {
6
+ return (
7
+ <Window>
8
+ <SafeArea>
9
+ <Text>Hello, World!</Text>
10
+ </SafeArea>
11
+ </Window>
12
+ )
13
+ }
14
+
15
+ render(() => <App />)
@@ -0,0 +1,4 @@
1
+ {
2
+ "level": "components",
3
+ "description": "components, blank start"
4
+ }
@@ -1,5 +1,5 @@
1
+ // Core-only template: built from @solidrt/core primitives.
1
2
  import { render, For, onFrame, createSignal, createLinearGradient } from "@solidrt/core"
2
- import { View, Window } from "@solidrt/components"
3
3
 
4
4
  const FADE = 360
5
5
 
@@ -13,7 +13,7 @@ let SEGMENTS = [
13
13
  { base: 540, light: "#7ea9ea", dark: "#5681c1", d: "M100.000 50.000 L75.000 75.000 L75.000 65.830 C73.810 65.830 72.711 65.195 72.116 64.165 C71.521 63.135 71.521 61.865 72.116 60.835 C72.711 59.805 73.810 59.170 75.000 59.170 L75.000 50.000 L75.000 40.830 C73.810 40.830 72.711 40.195 72.116 39.165 C71.521 38.135 71.521 36.865 72.116 35.835 C72.711 34.805 73.810 34.170 75.000 34.170 L75.000 25.000 L100.000 50.000 Z" },
14
14
  ]
15
15
 
16
- let LAST = SEGMENTS[SEGMENTS.length - 1].base
16
+ let LAST = SEGMENTS[SEGMENTS.length - 1]!.base
17
17
  let IN_DONE = LAST + FADE
18
18
  let CYCLE = IN_DONE + LAST + FADE
19
19
 
@@ -32,7 +32,7 @@ function Icon() {
32
32
  let start = -1
33
33
  onFrame((t) => (start < 0 && (start = t), setClock((t - start) % CYCLE)))
34
34
  return (
35
- <View layout={{ width: 100, height: 100 }} style={{ scale: 3 }}>
35
+ <view width={100} height={100} scale={3}>
36
36
  <For each={SEGMENTS}>
37
37
  {(seg) => {
38
38
  let a = () => {
@@ -43,7 +43,7 @@ function Icon() {
43
43
  return <d-path d={seg.d} color={fill(seg, a())} />
44
44
  }}
45
45
  </For>
46
- </View>
46
+ </view>
47
47
  )
48
48
  }
49
49
 
@@ -54,12 +54,10 @@ function App() {
54
54
  ])
55
55
 
56
56
  return (
57
- <Window
58
- layout={{ alignItems: "center", justifyContent: "center" }}
59
- style={{ backgroundColor }}
60
- >
57
+ <window alignItems="center" justifyContent="center">
58
+ <d-rect color={backgroundColor} />
61
59
  <Icon />
62
- </Window>
60
+ </window>
63
61
  )
64
62
  }
65
63
 
@@ -0,0 +1,4 @@
1
+ {
2
+ "level": "core",
3
+ "description": "core, animated logo"
4
+ }
@@ -0,0 +1,4 @@
1
+ {
2
+ "level": "components",
3
+ "description": "components, widget tour"
4
+ }
@@ -1,9 +1,12 @@
1
- import { render } from "@solidrt/core"
1
+ // Core-only template: built from @solidrt/core primitives.
2
+ import { render, safeArea } from "@solidrt/core"
2
3
 
3
4
  function App() {
4
5
  return (
5
6
  <window>
6
- <text>Hello, World!</text>
7
+ <view flex={1} paddingTop={safeArea().top} paddingBottom={safeArea().bottom}>
8
+ <text>Hello, World!</text>
9
+ </view>
7
10
  </window>
8
11
  )
9
12
  }
@@ -0,0 +1,4 @@
1
+ {
2
+ "level": "core",
3
+ "description": "core, blank start"
4
+ }
@@ -6,6 +6,7 @@
6
6
  "jsxImportSource": "@solidrt/core",
7
7
  "moduleResolution": "bundler",
8
8
  "strict": true,
9
+ "skipLibCheck": true,
9
10
  "types": ["@solidrt/flux-types"]
10
11
  },
11
12
  "include": ["src"]
package/server/control.ts CHANGED
@@ -75,7 +75,19 @@ function findClient(param: string | undefined): { ws: ServerWebSocket } | { erro
75
75
  }
76
76
  let id = parseInt(param, 10)
77
77
  let entry = entries.find(([, info]) => info.id === id)
78
- if (!entry) return { error: Response.json({ error: `No client with id ${param}` }, { status: 404 }) }
78
+ if (!entry) {
79
+ let ids = entries.map(([, info]) => info.id)
80
+ return {
81
+ error: Response.json(
82
+ {
83
+ error:
84
+ `Client ${param} is gone (connected ids: ${ids.length ? ids.join(", ") : "none"}). ` +
85
+ "Ids reset when the dev server restarts; call list_clients for current ones.",
86
+ },
87
+ { status: 404 },
88
+ ),
89
+ }
90
+ }
79
91
  return { ws: entry[0] }
80
92
  }
81
93
 
@@ -89,42 +101,91 @@ async function handleQuery(query: Map<string, string>, kind: string, extra?: Rec
89
101
  target.ws.send(JSON.stringify({ type: "query", kind, id, ...extra }))
90
102
  let msg = await Promise.race([reply, sleep(QUERY_TIMEOUT_MS)])
91
103
  pendingQueries.delete(id)
92
- if (!msg) return Response.json({ error: "Query timed out" }, { status: 504 })
104
+ if (!msg)
105
+ return Response.json(
106
+ { error: "Query timed out: the client is connected but did not answer (JS thread busy or app wedged?)" },
107
+ { status: 504 },
108
+ )
93
109
  // Error strings may carry stack traces (e.g. a debug command threw); remap
94
110
  // bundle positions to .tsx sources like appendLog does for forwarded logs.
95
111
  if (msg.error) return Response.json({ error: remapPositions(String(msg.error), state.currentMap) }, { status: 502 })
96
112
  return Response.json(msg.data)
97
113
  }
98
114
 
99
- // GET /__control__/logs?since=N&wait=MS: entries with seq > since, plus the
100
- // latest seq as the next cursor. With `wait`, holds the response until a new
101
- // entry arrives or the timeout passes (long-poll), so a caller can follow the
102
- // stream without tight polling.
115
+ // Merge runs of consecutive identical entries (same client, level, text) into
116
+ // one entry carrying `repeats` and the run's last seq/at, so 176 copies of one
117
+ // error read as a single line and a `since` cursor still skips the whole run.
118
+ function collapseRepeats(entries: LogEntry[]): (LogEntry & { repeats?: number })[] {
119
+ let out: (LogEntry & { repeats?: number })[] = []
120
+ for (let e of entries) {
121
+ let last = out[out.length - 1]
122
+ if (last && last.client === e.client && last.level === e.level && last.text === e.text) {
123
+ last.repeats = (last.repeats ?? 1) + 1
124
+ last.seq = e.seq
125
+ last.at = e.at
126
+ } else {
127
+ out.push({ ...e })
128
+ }
129
+ }
130
+ return out
131
+ }
132
+
133
+ // GET /__control__/logs?since=N&wait=MS&level=L1,L2&contains=TEXT: entries with
134
+ // seq > since, plus the latest seq as the next cursor and the server
135
+ // generation. `level` keeps only the listed levels; `contains` keeps entries
136
+ // whose text has the substring (case-insensitive). Consecutive identical
137
+ // entries come back collapsed with a `repeats` count. With `wait`, holds the
138
+ // response until an entry passes the filters or the timeout expires
139
+ // (long-poll), so a caller can follow the stream without tight polling.
103
140
  async function handleLogs(query: Map<string, string>): Promise<Response> {
104
141
  let since = parseInt(query.get("since") ?? "0", 10) || 0
105
142
  let wait = Math.min(parseInt(query.get("wait") ?? "0", 10) || 0, MAX_WAIT_MS)
106
- let entries = logs.filter((e) => e.seq > since)
107
- if (entries.length === 0 && wait > 0) {
143
+ let levels = query
144
+ .get("level")
145
+ ?.split(",")
146
+ .map((l) => l.trim())
147
+ .filter(Boolean)
148
+ let contains = query.get("contains")?.toLowerCase()
149
+ let select = () =>
150
+ logs.filter(
151
+ (e) =>
152
+ e.seq > since &&
153
+ (!levels || levels.length === 0 || levels.includes(e.level)) &&
154
+ (!contains || e.text.toLowerCase().includes(contains)),
155
+ )
156
+ let entries = select()
157
+ // Filtered long-poll: an append may not pass the filters, so keep waiting
158
+ // until one does or the deadline runs out.
159
+ let deadline = Date.now() + wait
160
+ while (entries.length === 0 && Date.now() < deadline) {
108
161
  await new Promise<void>((resolve) => {
109
- let timer = setTimeout(resolve, wait)
162
+ let timer = setTimeout(resolve, deadline - Date.now())
110
163
  logWaiters.push(() => {
111
164
  clearTimeout(timer)
112
165
  resolve()
113
166
  })
114
167
  })
115
- entries = logs.filter((e) => e.seq > since)
168
+ entries = select()
116
169
  }
117
- return Response.json({ entries, latest: logSeq })
170
+ return Response.json({ entries: collapseRepeats(entries), latest: logSeq, generation: state.generation })
118
171
  }
119
172
 
120
173
  export async function handleControl(req: Request, path: string, query: Map<string, string>): Promise<Response> {
121
174
  switch (path) {
122
175
  case "/__control__/clients":
123
- return Response.json(clientList())
176
+ return Response.json({ generation: state.generation, clients: clientList() })
124
177
  case "/__control__/logs":
125
178
  return handleLogs(query)
126
- case "/__control__/tree":
127
- return handleQuery(query, "tree")
179
+ case "/__control__/tree": {
180
+ let extra: Record<string, unknown> = {}
181
+ let root = parseInt(query.get("root") ?? "", 10)
182
+ if (Number.isFinite(root)) extra.root = root
183
+ let depth = parseInt(query.get("depth") ?? "", 10)
184
+ if (Number.isFinite(depth)) extra.depth = depth
185
+ let q = query.get("query")
186
+ if (q) extra.query = q
187
+ return handleQuery(query, "tree", extra)
188
+ }
128
189
  case "/__control__/stats":
129
190
  return handleQuery(query, "stats")
130
191
  case "/__control__/snapshot": {
package/server/state.ts CHANGED
@@ -37,6 +37,13 @@ export let state = {
37
37
  config: undefined as unknown as Config,
38
38
  clients: new Map<ServerWebSocket, ClientInfo>(),
39
39
  nextClientId: 0,
40
+ /**
41
+ * Identity of this server run, included in control responses that carry
42
+ * cross-call state (client ids, log seq cursors). Both reset on restart, so
43
+ * a consumer that sees the generation change knows its ids and cursors are
44
+ * from a dead server and must be re-fetched.
45
+ */
46
+ generation: Date.now(),
40
47
  /**
41
48
  * The latched reload message (JSON text), replayed to late-joining clients.
42
49
  * Set by /__internal__/reload posts with `latch`, cleared by a broadcast stop.
package/src/args.ts CHANGED
@@ -46,6 +46,9 @@ export function validateArgs() {
46
46
  usage("srt bundle [options] <entry.[tsx|jsx|ts|js|srt.js|srt.bin]>")
47
47
  }
48
48
  break
49
+ case "check":
50
+ if (!source || !isSource) usage("srt check <entry.[tsx|jsx|ts|js]>")
51
+ break
49
52
  case "render":
50
53
  if (!source || !isTsx) usage("srt render <entry.[tsx|jsx]>")
51
54
  break
@@ -74,6 +77,7 @@ Commands:
74
77
  server [file] Start dev server only
75
78
  client Start solidrt-go client only
76
79
  bundle <file> Transpile TS/JS/TSX/JSX to JS or bytecode
80
+ check <file> Verify the app builds and typechecks, without writing anything
77
81
  render <file.tsx|jsx> Replay a script (optional) and render frames for video generation
78
82
  pack <file> Bundle + compile to a standalone executable (experimental)
79
83
  mcp MCP server (stdio) exposing the running dev server to coding agents
@@ -0,0 +1,94 @@
1
+ import { existsSync } from "node:fs"
2
+ import { dirname, join, resolve } from "node:path"
3
+ import { source } from "../args"
4
+ import { bundleWith } from "../bundler"
5
+
6
+ // srt check: verify the app without side effects. Bundles in memory (nothing
7
+ // written, so no dev-server reload fires and no build outputs land in the
8
+ // project) and typechecks with the project's own tsc, reporting only
9
+ // diagnostics in app code. @solidrt packages ship raw .ts sources, so a strict
10
+ // consumer config surfaces their internal errors too; those are counted and
11
+ // hidden, not the caller's problem to wade through.
12
+
13
+ // Walk up from the entry to the enclosing project (tsconfig.json or, failing
14
+ // that, package.json).
15
+ function findProjectRoot(entry: string): string | null {
16
+ let dir = dirname(resolve(entry))
17
+ let byConfig: string | null = null
18
+ let byPackage: string | null = null
19
+ while (true) {
20
+ if (!byConfig && existsSync(join(dir, "tsconfig.json"))) byConfig = dir
21
+ if (!byPackage && existsSync(join(dir, "package.json"))) byPackage = dir
22
+ let parent = dirname(dir)
23
+ if (parent === dir) return byConfig ?? byPackage
24
+ dir = parent
25
+ }
26
+ }
27
+
28
+ // One tsc --pretty false diagnostic: the "path(line,col): error TS...: ..."
29
+ // head line plus any indented continuation lines.
30
+ type Diagnostic = { head: string; lines: string[]; inDependencies: boolean }
31
+
32
+ function parseDiagnostics(output: string): Diagnostic[] {
33
+ let diagnostics: Diagnostic[] = []
34
+ let current: Diagnostic | null = null
35
+ for (let line of output.split("\n")) {
36
+ let head = /^(.*?)\(\d+,\d+\): (error|warning) TS\d+: /.exec(line) ?? /^(error|warning) TS\d+: /.exec(line)
37
+ if (head) {
38
+ let file = line.includes("): ") ? head[1]! : ""
39
+ current = { head: line, lines: [line], inDependencies: file.includes("node_modules") }
40
+ diagnostics.push(current)
41
+ } else if (current && line.trim() !== "") {
42
+ current.lines.push(line)
43
+ }
44
+ }
45
+ return diagnostics
46
+ }
47
+
48
+ async function typecheck(root: string): Promise<{ app: Diagnostic[]; hidden: number } | null> {
49
+ let tsc = join(root, "node_modules", ".bin", process.platform === "win32" ? "tsc.exe" : "tsc")
50
+ if (!existsSync(tsc)) {
51
+ console.warn("Typecheck skipped: no tsc in the project (add the typescript devDependency)")
52
+ return null
53
+ }
54
+ let proc = Bun.spawn([tsc, "--noEmit", "--pretty", "false"], { cwd: root, stdout: "pipe", stderr: "pipe" })
55
+ let [out, err] = await Promise.all([new Response(proc.stdout).text(), new Response(proc.stderr).text()])
56
+ await proc.exited
57
+ let diagnostics = parseDiagnostics(out + err)
58
+ let app = diagnostics.filter((d) => !d.inDependencies)
59
+ return { app, hidden: diagnostics.length - app.length }
60
+ }
61
+
62
+ export async function runCheckCommand() {
63
+ let entry = source!
64
+ let failed = false
65
+
66
+ let result = await bundleWith({ entry, dev: true, minify: false })
67
+ if (!result) {
68
+ // bundleWith already printed the compile errors.
69
+ failed = true
70
+ }
71
+
72
+ let root = findProjectRoot(entry)
73
+ if (!root) {
74
+ console.warn("Typecheck skipped: no tsconfig.json or package.json above the entry")
75
+ } else {
76
+ let types = await typecheck(root)
77
+ if (types) {
78
+ for (let d of types.app) console.error(d.lines.join("\n"))
79
+ if (types.app.length > 0) {
80
+ failed = true
81
+ let hidden = types.hidden > 0 ? ` (${types.hidden} in dependencies hidden)` : ""
82
+ console.error(`${types.app.length} type error${types.app.length === 1 ? "" : "s"} in app code${hidden}`)
83
+ } else if (types.hidden > 0) {
84
+ console.log(`Types OK (${types.hidden} dependency-internal errors hidden)`)
85
+ } else {
86
+ console.log("Types OK")
87
+ }
88
+ }
89
+ }
90
+
91
+ if (failed) process.exit(1)
92
+ console.log("Check passed")
93
+ process.exit(0)
94
+ }
@@ -29,23 +29,48 @@ 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"
41
+ description: string
42
+ }
32
43
 
33
44
  // Templates are the directories under scaffold/templates/; each holds the files
34
- // that become the new project's src/. `default` sorts first as the starting
35
- // point, the rest alphabetically.
36
- async function listTemplates(): Promise<string[]> {
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[]> {
37
48
  let entries = await readdir(TEMPLATES_DIR, { withFileTypes: true })
38
- return entries
49
+ let names = entries
39
50
  .filter((e) => e.isDirectory())
40
51
  .map((e) => e.name)
41
52
  .sort((a, b) =>
42
53
  a === DEFAULT_TEMPLATE ? -1 : b === DEFAULT_TEMPLATE ? 1 : a.localeCompare(b),
43
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
44
69
  }
45
70
 
46
71
  // Resolve which template to scaffold from: an explicit --template if valid, an
47
72
  // interactive picker on a TTY, else `default` (or the first available).
48
- async function resolveTemplate(): Promise<string> {
73
+ async function resolveTemplate(): Promise<TemplateInfo> {
49
74
  let templates = await listTemplates()
50
75
  if (templates.length === 0) {
51
76
  console.error(`!! No templates found in ${TEMPLATES_DIR}`)
@@ -53,14 +78,25 @@ async function resolveTemplate(): Promise<string> {
53
78
  }
54
79
  let chosen = values.template
55
80
  if (chosen) {
56
- if (!templates.includes(chosen)) {
57
- console.error(`!! Unknown template "${chosen}"; choose from: ${templates.join(", ")}`)
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}`)
58
85
  process.exit(1)
59
86
  }
60
- return chosen
87
+ return found
88
+ }
89
+ if (process.stdin.isTTY) {
90
+ let picked = await select(
91
+ "Select a template",
92
+ templates.map((t) => ({
93
+ label: t.description ? `${t.name} - ${t.description}` : t.name,
94
+ value: t.name,
95
+ })),
96
+ )
97
+ return templates.find((t) => t.name === picked)!
61
98
  }
62
- if (process.stdin.isTTY) return select("Select a template", templates)
63
- return templates.includes(DEFAULT_TEMPLATE) ? DEFAULT_TEMPLATE : templates[0]!
99
+ return templates.find((t) => t.name === DEFAULT_TEMPLATE) ?? templates[0]!
64
100
  }
65
101
 
66
102
  export async function runInitCommand() {
@@ -84,7 +120,7 @@ export async function runInitCommand() {
84
120
 
85
121
  let template = await resolveTemplate()
86
122
 
87
- console.log(`>> Scaffolding SolidRT project in ${resolve(dir)} (${template})`)
123
+ console.log(`>> Scaffolding SolidRT project in ${resolve(dir)} (${template.name})`)
88
124
  for (let { from, to } of TEMPLATE_FILES) {
89
125
  let dest = join(dir, to)
90
126
  await mkdir(dirname(dest), { recursive: true })
@@ -93,19 +129,22 @@ export async function runInitCommand() {
93
129
  }
94
130
 
95
131
  // The chosen template's files become the project's src/. Entries may be
96
- // nested directories (e.g. an asset folder), so copy recursively.
97
- let templateDir = join(TEMPLATES_DIR, template)
132
+ // nested directories (e.g. an asset folder), so copy recursively. The
133
+ // manifest describes the template rather than belonging to the app.
134
+ let templateDir = join(TEMPLATES_DIR, template.name)
98
135
  await mkdir(join(dir, "src"), { recursive: true })
99
136
  for (let file of await readdir(templateDir)) {
137
+ if (file === TEMPLATE_MANIFEST) continue
100
138
  await cp(join(templateDir, file), join(dir, "src", file), { recursive: true })
101
139
  console.log(` Write src/${file}`)
102
140
  }
103
141
 
104
142
  // The scaffold package.json carries a placeholder name; set it from the
105
- // target folder.
143
+ // target folder. A core-level app gets no component framework dependency.
106
144
  let pkgPath = join(dir, "package.json")
107
145
  let pkg = JSON.parse(await readFile(pkgPath, "utf8"))
108
146
  pkg.name = packageName(dir)
147
+ if (template.level === "core") delete pkg.dependencies["@solidrt/components"]
109
148
  await writeFile(pkgPath, JSON.stringify(pkg, null, 2) + "\n")
110
149
 
111
150
  // Deps are declared in scaffold/package.json (Solid peers resolve via
@@ -9,6 +9,7 @@ import { z } from "zod"
9
9
  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
+ import { resolve } from "node:path"
12
13
  import { DEV_PORT } from "../dev-server"
13
14
 
14
15
  const CONTROL_BASE = `http://127.0.0.1:${DEV_PORT}/__control__`
@@ -44,17 +45,24 @@ let CLIENT_ARG = z
44
45
  .describe("Client id from list_clients (default: the only connected client)")
45
46
  .optional()
46
47
 
48
+ let SAVE_TO_ARG = z
49
+ .string()
50
+ .describe(
51
+ "Also write the PNG to this file path (relative paths resolve against the project root; parent directories are created)",
52
+ )
53
+ .optional()
54
+
47
55
  let TOOLS: { name: string; description: string; inputSchema: Record<string, z.ZodTypeAny> }[] = [
48
56
  {
49
57
  name: "list_clients",
50
58
  description:
51
- "List the app clients connected to the SolidRT dev server. Each entry has id (pass it as `client` to the other tools), platform, runtime version (git describe; a -dirty suffix means the binary was built from uncommitted engine changes), build profile (debug/release), and the capability names compiled into that client's runtime. Use version/profile to check whether a connected binary contains a given engine change before debugging against it.",
59
+ "List the app clients connected to the SolidRT dev server. Returns `generation` (identity of this server run: client ids and log cursors are only valid within one generation, so if it changed since your last call, re-fetch ids and cursors) and `clients`. Each entry has id (pass it as `client` to the other tools), platform, runtime version (git describe; a -dirty suffix means the binary was built from uncommitted engine changes), build profile (debug/release), and the capability names compiled into that client's runtime. Use version/profile to check whether a connected binary contains a given engine change before debugging against it.",
52
60
  inputSchema: {},
53
61
  },
54
62
  {
55
63
  name: "get_logs",
56
64
  description:
57
- "Read console output and runtime errors from connected app clients. Returns entries (seq, at, client, level, text) plus `latest`, the newest seq. Pass `since` (a seq or `latest` from a previous call) to only get newer entries; pass `wait_ms` to hold the call until new output arrives, e.g. right after triggering a reload.",
65
+ "Read console output and runtime errors from connected app clients. Returns entries (seq, at, client, level, text; consecutive identical entries are collapsed into one with a `repeats` count and the run's last seq), plus `latest` (the newest seq) and `generation` (identity of this server run; if it changed since your last call, your seq cursor and client ids are stale - start over from since 0). Pass `since` (a seq or `latest` from a previous call) to only get newer entries; pass `wait_ms` to hold the call until new output arrives, e.g. right after triggering a reload; pass `level`/`contains` to filter, e.g. level \"error\" to skip chatty output.",
58
66
  inputSchema: {
59
67
  since: z
60
68
  .number()
@@ -64,28 +72,57 @@ let TOOLS: { name: string; description: string; inputSchema: Record<string, z.Zo
64
72
  wait_ms: z
65
73
  .number()
66
74
  .int()
67
- .describe("If nothing is newer than `since`, wait up to this many milliseconds for new output (max 30000)")
75
+ .describe("If nothing matches newer than `since`, wait up to this many milliseconds for new output (max 30000)")
76
+ .optional(),
77
+ level: z
78
+ .string()
79
+ .describe('Only return entries with one of these levels, comma-separated (e.g. "error" or "error,warn")')
80
+ .optional(),
81
+ contains: z
82
+ .string()
83
+ .describe("Only return entries whose text contains this substring (case-insensitive)")
68
84
  .optional(),
69
85
  },
70
86
  },
71
87
  {
72
88
  name: "get_stats",
73
89
  description:
74
- "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.",
90
+ "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), 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).",
75
91
  inputSchema: { client: CLIENT_ARG },
76
92
  },
77
93
  {
78
94
  name: "get_render_tree",
79
95
  description:
80
- "Snapshot of a running app client's render tree: node id, kind, window-relative box (x, y, width, height), text content, and children. Use it to verify what the app actually rendered and where.",
81
- inputSchema: { client: CLIENT_ARG },
96
+ "Snapshot of a running app client's render tree: node id, kind, window-relative box (x, y, width, height), text content, and children. Use it to verify what the app actually rendered and where. Whole trees get large: prefer `query` to find nodes by kind or text first, then `root` + `depth` to inspect the region around a match. A node whose children were cut off by `depth` carries `childCount`; descend into it with root=<its id>.",
97
+ inputSchema: {
98
+ root: z
99
+ .number()
100
+ .int()
101
+ .describe("Only return the subtree under this node id (default: the whole tree)")
102
+ .optional(),
103
+ depth: z
104
+ .number()
105
+ .int()
106
+ .describe("Levels of children to include below the root (default: unlimited; 0 = the root node only)")
107
+ .optional(),
108
+ query: z
109
+ .string()
110
+ .describe(
111
+ "Search instead of snapshot: return `matches`, nodes whose kind equals or text contains this " +
112
+ "(case-insensitive), each with a `path` of ancestor ids from the search root. Combine with `root` to " +
113
+ "scope the search; `depth` is ignored.",
114
+ )
115
+ .optional(),
116
+ client: CLIENT_ARG,
117
+ },
82
118
  },
83
119
  {
84
120
  name: "get_snapshot",
85
121
  description:
86
- "Capture a PNG image of any node in a running app client's render tree, by node id (get ids from get_render_tree). Returns the rendered pixels of that node's subtree, so you can see what the app actually drew. The node must be currently mounted and have a non-zero layout box.",
122
+ "Capture a PNG image of any node in a running app client's render tree, by node id (get ids from get_render_tree). Returns the rendered pixels of that node's subtree, so you can see what the app actually drew. The node must be currently mounted and have a non-zero layout box. Works on an idle client (the capture requests its own frame); a timeout means the client's JS thread is busy or wedged, not that the app is idle.",
87
123
  inputSchema: {
88
124
  nodeId: z.number().int().describe("Id of the node to capture, from get_render_tree"),
125
+ save_to: SAVE_TO_ARG,
89
126
  client: CLIENT_ARG,
90
127
  },
91
128
  },
@@ -105,6 +142,7 @@ let TOOLS: { name: string; description: string; inputSchema: Record<string, z.Zo
105
142
  y: z.number().int().describe("Crop rect top edge in texture pixels").optional(),
106
143
  width: z.number().int().describe("Crop rect width in texture pixels").optional(),
107
144
  height: z.number().int().describe("Crop rect height in texture pixels").optional(),
145
+ save_to: SAVE_TO_ARG,
108
146
  client: CLIENT_ARG,
109
147
  },
110
148
  },
@@ -156,13 +194,22 @@ async function callTool(name: string, args: any): Promise<ControlResult> {
156
194
  let params = new URLSearchParams()
157
195
  if (typeof args?.since === "number") params.set("since", String(args.since))
158
196
  if (typeof args?.wait_ms === "number") params.set("wait", String(args.wait_ms))
197
+ if (typeof args?.level === "string") params.set("level", args.level)
198
+ if (typeof args?.contains === "string") params.set("contains", args.contains)
159
199
  let qs = params.toString()
160
200
  return control(qs ? `/logs?${qs}` : "/logs")
161
201
  }
162
202
  case "get_stats":
163
203
  return control(`/stats${clientParam(args)}`)
164
- case "get_render_tree":
165
- return control(`/tree${clientParam(args)}`)
204
+ case "get_render_tree": {
205
+ let params = new URLSearchParams()
206
+ if (typeof args?.root === "number") params.set("root", String(args.root))
207
+ if (typeof args?.depth === "number") params.set("depth", String(args.depth))
208
+ if (typeof args?.query === "string") params.set("query", args.query)
209
+ if (typeof args?.client === "number") params.set("client", String(args.client))
210
+ let qs = params.toString()
211
+ return control(qs ? `/tree?${qs}` : "/tree")
212
+ }
166
213
  case "reload":
167
214
  return control("/reload", "POST")
168
215
  case "get_snapshot": {
@@ -204,15 +251,29 @@ async function callTool(name: string, args: any): Promise<ControlResult> {
204
251
  }
205
252
  }
206
253
 
207
- function toContent(name: string, result: ControlResult): CallToolResult {
254
+ async function toContent(name: string, result: ControlResult, args?: any): Promise<CallToolResult> {
208
255
  if (!result.ok) return { content: [{ type: "text", text: result.message }], isError: true }
209
256
  if (name === "get_snapshot" || name === "get_texture") {
210
257
  let { pngBase64, width, height } = result.body
211
258
  let label = name === "get_snapshot" ? "Captured node snapshot" : "Texture contents"
259
+ let text = `${label}: ${width}x${height} px`
260
+ // save_to is handled here in the bridge, not by the dev server: this
261
+ // process runs on the caller's machine, so the path lands where the
262
+ // agent expects it. The image content block alone is a dead end for
263
+ // that - the model sees the pixels but never the bytes.
264
+ if (typeof args?.save_to === "string") {
265
+ let path = resolve(args.save_to)
266
+ try {
267
+ await Bun.write(path, Buffer.from(pngBase64, "base64"))
268
+ text += `, saved to ${path}`
269
+ } catch (e) {
270
+ return { content: [{ type: "text", text: `Captured, but saving to ${path} failed: ${e}` }], isError: true }
271
+ }
272
+ }
212
273
  return {
213
274
  content: [
214
275
  { type: "image", data: pngBase64, mimeType: "image/png" },
215
- { type: "text", text: `${label}: ${width}x${height} px` },
276
+ { type: "text", text },
216
277
  ],
217
278
  }
218
279
  }
@@ -226,7 +287,7 @@ export async function runMcpCommand() {
226
287
  server.registerTool(
227
288
  tool.name,
228
289
  { description: tool.description, inputSchema: tool.inputSchema },
229
- async (args: any) => toContent(tool.name, await callTool(tool.name, args ?? {})),
290
+ async (args: any) => toContent(tool.name, await callTool(tool.name, args ?? {}), args),
230
291
  )
231
292
  }
232
293
 
package/src/main.ts CHANGED
@@ -3,6 +3,7 @@
3
3
  import { values, command, validateArgs, printUsage } from "./args"
4
4
  import { runInitCommand } from "./commands/init"
5
5
  import { runBundleCommand } from "./commands/bundle"
6
+ import { runCheckCommand } from "./commands/check"
6
7
  import { runPackCommand } from "./commands/pack"
7
8
  import { runRenderCommand } from "./commands/render"
8
9
  import { runServerCommand } from "./commands/server"
@@ -41,6 +42,8 @@ if (command === "init") {
41
42
  await runInitCommand()
42
43
  } else if (command === "bundle") {
43
44
  await runBundleCommand()
45
+ } else if (command === "check") {
46
+ await runCheckCommand()
44
47
  } else if (command === "pack") {
45
48
  await runPackCommand()
46
49
  } else if (command === "render") {
package/src/prompt.ts CHANGED
@@ -15,16 +15,24 @@ export function text(message: string, def = ""): Promise<string> {
15
15
  })
16
16
  }
17
17
 
18
+ export interface SelectOption {
19
+ label: string
20
+ value: string
21
+ }
22
+
18
23
  // Minimal arrow-key single-select prompt, built on node:readline (same
19
24
  // dependency-free approach as repl.ts). Renders the option list, moves the
20
- // highlight on up/down, resolves the chosen value on enter. Callers guard on
21
- // process.stdin.isTTY; a non-TTY stdin here resolves the first option rather
22
- // than hanging on input that will never arrive.
23
- export function select(message: string, options: string[]): Promise<string> {
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> {
31
+ let items = options.map((o) => (typeof o === "string" ? { label: o, value: o } : o))
24
32
  return new Promise((resolve) => {
25
33
  let input = process.stdin
26
34
  let output = process.stdout
27
- if (!input.isTTY) return resolve(options[0]!)
35
+ if (!input.isTTY) return resolve(items[0]!.value)
28
36
 
29
37
  let selected = 0
30
38
  emitKeypressEvents(input)
@@ -34,13 +42,13 @@ export function select(message: string, options: string[]): Promise<string> {
34
42
  let render = (first = false) => {
35
43
  // After the first paint the cursor sits below the block; move it back up
36
44
  // to the message line so the list redraws in place.
37
- if (!first) output.write(`\x1b[${options.length + 1}A`)
45
+ if (!first) output.write(`\x1b[${items.length + 1}A`)
38
46
  output.write(`\x1b[K? ${message}\n`)
39
- for (let i = 0; i < options.length; i++) {
47
+ for (let i = 0; i < items.length; i++) {
40
48
  let active = i === selected
41
49
  let pointer = active ? "\x1b[36m> " : " "
42
50
  let reset = active ? "\x1b[0m" : ""
43
- output.write(`\x1b[K${pointer}${options[i]}${reset}\n`)
51
+ output.write(`\x1b[K${pointer}${items[i]!.label}${reset}\n`)
44
52
  }
45
53
  }
46
54
 
@@ -53,15 +61,15 @@ export function select(message: string, options: string[]): Promise<string> {
53
61
  let onKey = (_str: string, key: { name: string; ctrl: boolean } | undefined) => {
54
62
  if (!key) return
55
63
  if (key.name === "up") {
56
- selected = (selected - 1 + options.length) % options.length
64
+ selected = (selected - 1 + items.length) % items.length
57
65
  render()
58
66
  } else if (key.name === "down") {
59
- selected = (selected + 1) % options.length
67
+ selected = (selected + 1) % items.length
60
68
  render()
61
69
  } else if (key.name === "return" || key.name === "enter") {
62
70
  cleanup()
63
71
  output.write("\n")
64
- resolve(options[selected]!)
72
+ resolve(items[selected]!.value)
65
73
  } else if (key.ctrl && (key.name === "c" || key.name === "d")) {
66
74
  cleanup()
67
75
  output.write("\n")