@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 +6 -6
- package/scaffold/AGENTS.md +84 -15
- package/scaffold/package.json +4 -4
- package/scaffold/templates/components/index.tsx +15 -0
- package/scaffold/templates/components/template.json +4 -0
- package/scaffold/templates/default/index.tsx +7 -9
- package/scaffold/templates/default/template.json +4 -0
- package/scaffold/templates/gallery/template.json +4 -0
- package/scaffold/templates/minimal/index.tsx +5 -2
- package/scaffold/templates/minimal/template.json +4 -0
- package/scaffold/tsconfig.json +1 -0
- package/server/control.ts +75 -14
- package/server/state.ts +7 -0
- package/src/args.ts +4 -0
- package/src/commands/check.ts +94 -0
- package/src/commands/init.ts +53 -14
- package/src/commands/mcp.ts +73 -12
- package/src/main.ts +3 -0
- package/src/prompt.ts +19 -11
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@solidrt/cli",
|
|
3
|
-
"version": "0.0.
|
|
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.
|
|
31
|
-
"@solidrt/linux-x64-gnu": "0.0.
|
|
32
|
-
"@solidrt/win32-x64-msvc": "0.0.
|
|
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.
|
|
35
|
+
"@solidrt/core": "0.0.28",
|
|
36
36
|
"typescript": "^7"
|
|
37
37
|
},
|
|
38
38
|
"devDependencies": {
|
|
39
|
-
"@solidrt/flux-types": "0.0.
|
|
39
|
+
"@solidrt/flux-types": "0.0.28",
|
|
40
40
|
"@types/bun": "latest"
|
|
41
41
|
}
|
|
42
42
|
}
|
package/scaffold/AGENTS.md
CHANGED
|
@@ -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
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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.
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
129
|
-
|
|
130
|
-
|
|
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.
|
package/scaffold/package.json
CHANGED
|
@@ -9,12 +9,12 @@
|
|
|
9
9
|
"android": "srt client --android"
|
|
10
10
|
},
|
|
11
11
|
"dependencies": {
|
|
12
|
-
"@solidrt/core": "0.0.
|
|
13
|
-
"@solidrt/components": "0.0.
|
|
12
|
+
"@solidrt/core": "0.0.28",
|
|
13
|
+
"@solidrt/components": "0.0.28"
|
|
14
14
|
},
|
|
15
15
|
"devDependencies": {
|
|
16
|
-
"@solidrt/cli": "0.0.
|
|
17
|
-
"@solidrt/flux-types": "0.0.
|
|
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 />)
|
|
@@ -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]
|
|
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
|
-
<
|
|
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
|
-
</
|
|
46
|
+
</view>
|
|
47
47
|
)
|
|
48
48
|
}
|
|
49
49
|
|
|
@@ -54,12 +54,10 @@ function App() {
|
|
|
54
54
|
])
|
|
55
55
|
|
|
56
56
|
return (
|
|
57
|
-
<
|
|
58
|
-
|
|
59
|
-
style={{ backgroundColor }}
|
|
60
|
-
>
|
|
57
|
+
<window alignItems="center" justifyContent="center">
|
|
58
|
+
<d-rect color={backgroundColor} />
|
|
61
59
|
<Icon />
|
|
62
|
-
</
|
|
60
|
+
</window>
|
|
63
61
|
)
|
|
64
62
|
}
|
|
65
63
|
|
|
@@ -1,9 +1,12 @@
|
|
|
1
|
-
|
|
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
|
-
<
|
|
7
|
+
<view flex={1} paddingTop={safeArea().top} paddingBottom={safeArea().bottom}>
|
|
8
|
+
<text>Hello, World!</text>
|
|
9
|
+
</view>
|
|
7
10
|
</window>
|
|
8
11
|
)
|
|
9
12
|
}
|
package/scaffold/tsconfig.json
CHANGED
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)
|
|
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)
|
|
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
|
-
//
|
|
100
|
-
//
|
|
101
|
-
//
|
|
102
|
-
|
|
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
|
|
107
|
-
|
|
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,
|
|
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 =
|
|
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
|
-
|
|
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
|
+
}
|
package/src/commands/init.ts
CHANGED
|
@@ -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
|
|
35
|
-
// point, the rest alphabetically.
|
|
36
|
-
async function listTemplates(): Promise<
|
|
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
|
-
|
|
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<
|
|
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
|
-
|
|
57
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
package/src/commands/mcp.ts
CHANGED
|
@@ -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
|
|
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
|
|
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: {
|
|
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
|
-
|
|
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
|
|
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.
|
|
21
|
-
//
|
|
22
|
-
//
|
|
23
|
-
|
|
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(
|
|
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[${
|
|
45
|
+
if (!first) output.write(`\x1b[${items.length + 1}A`)
|
|
38
46
|
output.write(`\x1b[K? ${message}\n`)
|
|
39
|
-
for (let i = 0; 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}${
|
|
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 +
|
|
64
|
+
selected = (selected - 1 + items.length) % items.length
|
|
57
65
|
render()
|
|
58
66
|
} else if (key.name === "down") {
|
|
59
|
-
selected = (selected + 1) %
|
|
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(
|
|
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")
|