@solidrt/cli 0.0.26 → 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.
@@ -5,16 +5,26 @@
5
5
  //
6
6
  // stdout is the JSON-RPC channel; nothing here may print to it.
7
7
 
8
+ import { z } from "zod"
9
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"
10
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
11
+ import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js"
12
+ import { resolve } from "node:path"
8
13
  import { DEV_PORT } from "../dev-server"
9
14
 
10
15
  const CONTROL_BASE = `http://127.0.0.1:${DEV_PORT}/__control__`
11
16
 
12
17
  type ControlResult = { ok: true; body: any } | { ok: false; message: string }
13
18
 
14
- async function control(path: string): Promise<ControlResult> {
19
+ async function control(path: string, method: "GET" | "POST" = "GET", payload?: unknown): Promise<ControlResult> {
15
20
  let resp
16
21
  try {
17
- resp = await fetch(CONTROL_BASE + path)
22
+ let init: RequestInit = { method }
23
+ if (payload !== undefined) {
24
+ init.headers = { "content-type": "application/json" }
25
+ init.body = JSON.stringify(payload)
26
+ }
27
+ resp = await fetch(CONTROL_BASE + path, init)
18
28
  } catch {
19
29
  return {
20
30
  ok: false,
@@ -29,70 +39,147 @@ async function control(path: string): Promise<ControlResult> {
29
39
  return { ok: true, body }
30
40
  }
31
41
 
32
- let TOOLS = [
42
+ let CLIENT_ARG = z
43
+ .number()
44
+ .int()
45
+ .describe("Client id from list_clients (default: the only connected client)")
46
+ .optional()
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
+
55
+ let TOOLS: { name: string; description: string; inputSchema: Record<string, z.ZodTypeAny> }[] = [
33
56
  {
34
57
  name: "list_clients",
35
58
  description:
36
- "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, and the capability names compiled into that client's runtime.",
37
- inputSchema: { type: "object", properties: {}, additionalProperties: false },
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.",
60
+ inputSchema: {},
38
61
  },
39
62
  {
40
63
  name: "get_logs",
41
64
  description:
42
- "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.",
43
66
  inputSchema: {
44
- type: "object",
45
- properties: {
46
- since: {
47
- type: "integer",
48
- description: "Only return entries with seq greater than this (default 0: the whole buffer)",
49
- },
50
- wait_ms: {
51
- type: "integer",
52
- description: "If nothing is newer than `since`, wait up to this many milliseconds for new output (max 30000)",
53
- },
54
- },
55
- additionalProperties: false,
67
+ since: z
68
+ .number()
69
+ .int()
70
+ .describe("Only return entries with seq greater than this (default 0: the whole buffer)")
71
+ .optional(),
72
+ wait_ms: z
73
+ .number()
74
+ .int()
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)")
84
+ .optional(),
56
85
  },
57
86
  },
58
87
  {
59
88
  name: "get_stats",
60
89
  description:
61
- "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.",
62
- inputSchema: {
63
- type: "object",
64
- properties: {
65
- client: { type: "integer", description: "Client id from list_clients (default: the only connected client)" },
66
- },
67
- additionalProperties: false,
68
- },
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).",
91
+ inputSchema: { client: CLIENT_ARG },
69
92
  },
70
93
  {
71
94
  name: "get_render_tree",
72
95
  description:
73
- "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.",
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>.",
74
97
  inputSchema: {
75
- type: "object",
76
- properties: {
77
- client: { type: "integer", description: "Client id from list_clients (default: the only connected client)" },
78
- },
79
- additionalProperties: false,
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,
80
117
  },
81
118
  },
82
119
  {
83
120
  name: "get_snapshot",
84
121
  description:
85
- "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.",
123
+ inputSchema: {
124
+ nodeId: z.number().int().describe("Id of the node to capture, from get_render_tree"),
125
+ save_to: SAVE_TO_ARG,
126
+ client: CLIENT_ARG,
127
+ },
128
+ },
129
+ {
130
+ name: "get_gpu_resources",
131
+ description:
132
+ "Inventory of a running app client's GPU resources: textures (id, size, whether a shader renders into it), vertex buffers (id, byteLength), and shader/pipeline targets (output textureId, kind, bufferId, topology, drawCount, depth, attribute layout, bound sampler texture ids, last-applied uniform values). Use it when the render tree is just a <texture> leaf and the interesting state lives behind it; follow up with get_texture or get_buffer to see contents.",
133
+ inputSchema: { client: CLIENT_ARG },
134
+ },
135
+ {
136
+ name: "get_texture",
137
+ description:
138
+ "Read back any GPU texture from a running app client as a PNG, by texture id (from get_gpu_resources, or the id returned by createImage/createShader/createPipeline in app code). Works on sampled textures (atlases, data textures) and shader/pipeline render targets alike, without needing a frame. Pass x/y/width/height to crop, e.g. one tile of an atlas.",
86
139
  inputSchema: {
87
- type: "object",
88
- properties: {
89
- nodeId: { type: "integer", description: "Id of the node to capture, from get_render_tree" },
90
- client: { type: "integer", description: "Client id from list_clients (default: the only connected client)" },
91
- },
92
- required: ["nodeId"],
93
- additionalProperties: false,
140
+ id: z.number().int().describe("Texture id, from get_gpu_resources"),
141
+ x: z.number().int().describe("Crop rect left edge in texture pixels (requires y, width, height)").optional(),
142
+ y: z.number().int().describe("Crop rect top edge in texture pixels").optional(),
143
+ width: z.number().int().describe("Crop rect width in texture pixels").optional(),
144
+ height: z.number().int().describe("Crop rect height in texture pixels").optional(),
145
+ save_to: SAVE_TO_ARG,
146
+ client: CLIENT_ARG,
94
147
  },
95
148
  },
149
+ {
150
+ name: "get_buffer",
151
+ description:
152
+ "Read back part of a GPU vertex buffer from a running app client, decoded to numbers. Returns values plus byteOffset/byteLength actually read and bufferByteLength. Reads are capped at 64 KiB per call; page through larger buffers with offset. Use it to verify geometry after a writeBuffer, e.g. the dynamic sprite tail of a vertex buffer.",
153
+ inputSchema: {
154
+ id: z.number().int().describe("Buffer id, from get_gpu_resources"),
155
+ offset: z.number().int().describe("Byte offset to start reading at (default 0)").optional(),
156
+ length: z.number().int().describe("Number of values to read (default: the rest of the buffer, capped)").optional(),
157
+ as: z.enum(["f32", "u16", "u8"]).describe("How to decode the bytes (default f32)").optional(),
158
+ client: CLIENT_ARG,
159
+ },
160
+ },
161
+ {
162
+ name: "list_debug",
163
+ description:
164
+ "List the debug commands the running app registered via registerDebug from srt:dev. Returns the command names; call one with call_debug. Empty when the app registered none.",
165
+ inputSchema: { client: CLIENT_ARG },
166
+ },
167
+ {
168
+ name: "call_debug",
169
+ description:
170
+ "Call a debug command the running app registered via registerDebug from srt:dev, by name (from list_debug). `args` is passed to the command's function as its single argument; the command's return value comes back JSON-serialized (undefined as null). Commands run synchronously on the app's JS thread - use them to query app state (positions, counters, internal flags) or trigger app behavior (toggle a mode, open a door) without touching its real input handling.",
171
+ inputSchema: {
172
+ name: z.string().describe("Debug command name, from list_debug"),
173
+ args: z.record(z.string(), z.any()).describe("Argument object passed to the command (default: none)").optional(),
174
+ client: CLIENT_ARG,
175
+ },
176
+ },
177
+ {
178
+ name: "reload",
179
+ description:
180
+ "Rebuild the app from source and push it to every connected client. Call this after editing the app's .tsx/.jsx source to apply the changes: it bundles once and reloads all clients, so a burst of edits becomes a single explicit reload. Returns the number of clients reloaded, or a build error if the source failed to compile. Follow with get_logs to see runtime output from the reloaded app.",
181
+ inputSchema: {},
182
+ },
96
183
  ]
97
184
 
98
185
  function clientParam(args: any): string {
@@ -107,50 +194,102 @@ async function callTool(name: string, args: any): Promise<ControlResult> {
107
194
  let params = new URLSearchParams()
108
195
  if (typeof args?.since === "number") params.set("since", String(args.since))
109
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)
110
199
  let qs = params.toString()
111
200
  return control(qs ? `/logs?${qs}` : "/logs")
112
201
  }
113
202
  case "get_stats":
114
203
  return control(`/stats${clientParam(args)}`)
115
- case "get_render_tree":
116
- 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
+ }
213
+ case "reload":
214
+ return control("/reload", "POST")
117
215
  case "get_snapshot": {
118
216
  if (typeof args?.nodeId !== "number") return { ok: false, message: "get_snapshot requires a numeric nodeId" }
119
217
  let params = new URLSearchParams({ node: String(args.nodeId) })
120
218
  if (typeof args?.client === "number") params.set("client", String(args.client))
121
219
  return control(`/snapshot?${params.toString()}`)
122
220
  }
221
+ case "get_gpu_resources":
222
+ return control(`/gpu${clientParam(args)}`)
223
+ case "list_debug":
224
+ return control(`/debug${clientParam(args)}`)
225
+ case "call_debug": {
226
+ if (typeof args?.name !== "string") return { ok: false, message: "call_debug requires a command name" }
227
+ let params = new URLSearchParams({ name: args.name })
228
+ if (typeof args?.client === "number") params.set("client", String(args.client))
229
+ return control(`/debug?${params.toString()}`, "POST", args?.args)
230
+ }
231
+ case "get_texture": {
232
+ if (typeof args?.id !== "number") return { ok: false, message: "get_texture requires a numeric id" }
233
+ let params = new URLSearchParams({ id: String(args.id) })
234
+ for (let key of ["x", "y", "width", "height"]) {
235
+ if (typeof args?.[key] === "number") params.set(key, String(args[key]))
236
+ }
237
+ if (typeof args?.client === "number") params.set("client", String(args.client))
238
+ return control(`/texture?${params.toString()}`)
239
+ }
240
+ case "get_buffer": {
241
+ if (typeof args?.id !== "number") return { ok: false, message: "get_buffer requires a numeric id" }
242
+ let params = new URLSearchParams({ id: String(args.id) })
243
+ if (typeof args?.offset === "number") params.set("offset", String(args.offset))
244
+ if (typeof args?.length === "number") params.set("length", String(args.length))
245
+ if (typeof args?.as === "string") params.set("as", args.as)
246
+ if (typeof args?.client === "number") params.set("client", String(args.client))
247
+ return control(`/buffer?${params.toString()}`)
248
+ }
123
249
  default:
124
250
  return { ok: false, message: `Unknown tool: ${name}` }
125
251
  }
126
252
  }
127
253
 
128
- export async function runMcpCommand() {
129
- let { Server } = await import("@modelcontextprotocol/sdk/server/index.js")
130
- let { StdioServerTransport } = await import("@modelcontextprotocol/sdk/server/stdio.js")
131
- let { ListToolsRequestSchema, CallToolRequestSchema } = await import("@modelcontextprotocol/sdk/types.js")
132
-
133
- let server = new Server({ name: "solidrt", version: "0.0.0" }, { capabilities: { tools: {} } })
134
-
135
- server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: TOOLS }))
136
-
137
- server.setRequestHandler(CallToolRequestSchema, async (request: any) => {
138
- let name = request.params.name
139
- let result = await callTool(name, request.params.arguments ?? {})
140
- if (!result.ok) {
141
- return { content: [{ type: "text", text: result.message }], isError: true }
142
- }
143
- if (name === "get_snapshot") {
144
- let { pngBase64, width, height } = result.body
145
- return {
146
- content: [
147
- { type: "image", data: pngBase64, mimeType: "image/png" },
148
- { type: "text", text: `Captured node snapshot: ${width}x${height} px` },
149
- ],
254
+ async function toContent(name: string, result: ControlResult, args?: any): Promise<CallToolResult> {
255
+ if (!result.ok) return { content: [{ type: "text", text: result.message }], isError: true }
256
+ if (name === "get_snapshot" || name === "get_texture") {
257
+ let { pngBase64, width, height } = result.body
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 }
150
271
  }
151
272
  }
152
- return { content: [{ type: "text", text: JSON.stringify(result.body, null, 2) }] }
153
- })
273
+ return {
274
+ content: [
275
+ { type: "image", data: pngBase64, mimeType: "image/png" },
276
+ { type: "text", text },
277
+ ],
278
+ }
279
+ }
280
+ return { content: [{ type: "text", text: JSON.stringify(result.body, null, 2) }] }
281
+ }
282
+
283
+ export async function runMcpCommand() {
284
+ let server = new McpServer({ name: "solidrt", version: "0.0.0" })
285
+
286
+ for (let tool of TOOLS) {
287
+ server.registerTool(
288
+ tool.name,
289
+ { description: tool.description, inputSchema: tool.inputSchema },
290
+ async (args: any) => toContent(tool.name, await callTool(tool.name, args ?? {}), args),
291
+ )
292
+ }
154
293
 
155
294
  // The stdin read keeps the process alive; it exits when the agent host
156
295
  // closes the pipe.
@@ -1,7 +1,7 @@
1
1
  import pkg from "../../package.json"
2
2
  import { source, isSource, isPrebuilt, values } from "../args"
3
3
  import { state, shutdown } from "../util"
4
- import { bundle, codeFromOutputs } from "../bundler"
4
+ import { bundle } from "../bundler"
5
5
  import { startServer, buildReload, sendReload, showBuildFailure } from "../dev-server"
6
6
  import { startRepl } from "../repl"
7
7
  import { startWatcher } from "../watcher"
@@ -27,8 +27,9 @@ export async function runServerCommand() {
27
27
  if (source && isSource) {
28
28
  let initialResult = await bundle()
29
29
  if (initialResult) {
30
- state.currentCode = await codeFromOutputs(initialResult.outputs)
31
- await sendReload(buildReload({ code: state.currentCode }), { latch: true })
30
+ state.currentCode = initialResult.code
31
+ state.currentMap = initialResult.map
32
+ await sendReload(buildReload({ code: state.currentCode }), { latch: true, map: state.currentMap })
32
33
  } else {
33
34
  await showBuildFailure()
34
35
  }
package/src/dev-server.ts CHANGED
@@ -30,9 +30,14 @@ async function post(path: string, body: object) {
30
30
  * Send a client-protocol message through the server: to the given client ids,
31
31
  * or to every client when omitted. `latch` keeps the message for late-joining
32
32
  * clients (code reloads latch, one-shot bytecode loads do not); `sourceDir`
33
- * moves the server's file-serving root (repl `load`).
33
+ * moves the server's file-serving root (repl `load`); `map` is the bundle's
34
+ * sourcemap, kept server-side for stack-trace remapping (omitting it clears
35
+ * the server's map, so a mapless reload never remaps against a stale one).
34
36
  */
35
- export async function sendReload(message: object, opts: { clients?: number[]; latch?: boolean; sourceDir?: string } = {}) {
37
+ export async function sendReload(
38
+ message: object,
39
+ opts: { clients?: number[]; latch?: boolean; sourceDir?: string; entry?: string; map?: string | null } = {},
40
+ ) {
36
41
  await post("/reload", { message, ...opts })
37
42
  }
38
43
 
@@ -125,12 +130,20 @@ export async function startServer() {
125
130
  // computes it and passes it down.
126
131
  state.serverUrl = `${address}:${DEV_PORT}`
127
132
 
133
+ // How the server rebuilds on an MCP-triggered reload: it cannot call
134
+ // Bun.build itself (it is a flux process), so it spawns srt's own bun on the
135
+ // standalone bundle-cli entry. Both paths are known here at spawn time.
136
+ let bundleCli = fileURLToPath(new URL("./bundle-cli.ts", import.meta.url))
137
+
128
138
  let config = {
129
139
  port: DEV_PORT,
130
140
  sourceDir: state.sourceDir,
131
141
  address,
132
142
  proxyFiles: values["proxy-files"],
133
143
  proxyHttp: values["proxy-http"],
144
+ entry: state.source,
145
+ minify: values.minify,
146
+ bundlerCmd: [process.execPath, bundleCli],
134
147
  cache: values["proxy-http"],
135
148
  cacheDir: process.cwd(),
136
149
  capture: state.capture,
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")
package/src/repl.ts CHANGED
@@ -3,7 +3,7 @@ import { resolve, dirname } from "path"
3
3
  import { readdirSync } from "node:fs"
4
4
  import { state, print, printErr, shutdown } from "./util"
5
5
  import { buildReload, getClients, sendReload, sendStop, sendStats, showBuildFailure } from "./dev-server"
6
- import { bundle, codeFromOutputs } from "./bundler"
6
+ import { bundle } from "./bundler"
7
7
  import { startWatcher, stopWatcher } from "./watcher"
8
8
 
9
9
  // Resolve repl client indexes ("0 2") against the server's client list,
@@ -27,6 +27,7 @@ async function cmdStop(args: string) {
27
27
  if (!args) {
28
28
  stopWatcher()
29
29
  state.currentCode = null
30
+ state.currentMap = null
30
31
  state.source = undefined
31
32
  await sendStop()
32
33
  print("[cli] Sent stop to all clients")
@@ -47,17 +48,18 @@ async function cmdReload(args: string) {
47
48
  await showBuildFailure()
48
49
  return
49
50
  }
50
- state.currentCode = await codeFromOutputs(result.outputs)
51
+ state.currentCode = result.code
52
+ state.currentMap = result.map
51
53
  }
52
54
  let msg = buildReload({ code: state.currentCode })
53
55
  if (!args) {
54
- await sendReload(msg, { latch: true })
56
+ await sendReload(msg, { latch: true, map: state.currentMap })
55
57
  print("[cli] Sent reload to all clients")
56
58
  return
57
59
  }
58
60
  let ids = await indexesToIds(args)
59
61
  if (ids.length) {
60
- await sendReload(msg, { clients: ids })
62
+ await sendReload(msg, { clients: ids, map: state.currentMap })
61
63
  print(`[cli] Sent reload to client(s) ${ids.join(", ")}`)
62
64
  }
63
65
  }
@@ -102,9 +104,11 @@ async function cmdLoad(file: string) {
102
104
  printErr("[cli] Build failed")
103
105
  return
104
106
  }
105
- state.currentCode = await codeFromOutputs(result.outputs)
107
+ state.currentCode = result.code
108
+ state.currentMap = result.map
106
109
  } else if (file.endsWith(".srt.js")) {
107
110
  state.currentCode = await Bun.file(path).text()
111
+ state.currentMap = null
108
112
  } else if (file.endsWith(".srt.bin")) {
109
113
  let bytes = await Bun.file(path).arrayBuffer()
110
114
  // One-shot: bytecode loads are pushed but not latched for late joiners.
@@ -118,8 +122,14 @@ async function cmdLoad(file: string) {
118
122
  state.source = path
119
123
  state.sourceDir = dirname(path)
120
124
  startWatcher()
121
- // The load also moves the server's file-serving root to the new source dir.
122
- await sendReload(buildReload({ code: state.currentCode }), { latch: true, sourceDir: state.sourceDir })
125
+ // The load also moves the server's file-serving root to the new source dir,
126
+ // and its rebuild entry to the new file (for a later MCP reload).
127
+ await sendReload(buildReload({ code: state.currentCode }), {
128
+ latch: true,
129
+ sourceDir: state.sourceDir,
130
+ entry: file.endsWith(".tsx") ? path : undefined,
131
+ map: state.currentMap,
132
+ })
123
133
  print(`[cli] Loaded ${file}`)
124
134
  }
125
135
 
package/src/util.ts CHANGED
@@ -8,6 +8,9 @@ export let state = {
8
8
  // What srt believes the current bundle is; the server process keeps its own
9
9
  // latched copy for late-joining clients (see packages/cli/server/).
10
10
  currentCode: null as string | null,
11
+ // The bundle's composed sourcemap (dev builds), sent to the server alongside
12
+ // reloads so it can remap logged stack traces to .tsx positions.
13
+ currentMap: null as string | null,
11
14
  source: undefined as string | undefined,
12
15
  sourceDir: process.cwd(),
13
16
  child: null as ReturnType<typeof Bun.spawn> | null,
package/src/watcher.ts CHANGED
@@ -2,7 +2,7 @@ import { watch } from "node:fs"
2
2
  import { resolve, dirname } from "path"
3
3
  import { state, print, printErr } from "./util"
4
4
  import { buildReload, sendReload, showBuildFailure } from "./dev-server"
5
- import { bundle, codeFromOutputs } from "./bundler"
5
+ import { bundle } from "./bundler"
6
6
 
7
7
  let currentWatcher: ReturnType<typeof watch> | null = null
8
8
 
@@ -31,7 +31,8 @@ export function startWatcher() {
31
31
  await showBuildFailure()
32
32
  return
33
33
  }
34
- state.currentCode = await codeFromOutputs(result.outputs)
35
- await sendReload(buildReload({ code: state.currentCode }), { latch: true })
34
+ state.currentCode = result.code
35
+ state.currentMap = result.map
36
+ await sendReload(buildReload({ code: state.currentCode }), { latch: true, map: state.currentMap })
36
37
  })
37
38
  }