Skip to content

Commit d191aea

Browse files
committed
feat: improve get_code performance
1 parent 6e33c28 commit d191aea

File tree

16 files changed

+233
-75
lines changed

16 files changed

+233
-75
lines changed

docs/extension/design.md

Lines changed: 19 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -8,43 +8,47 @@ This document describes the implementation design for MCP `get_code` in `package
88
- Exactly one visible root node required.
99
- Build a visible tree with depth capping.
1010

11-
2. **Collect data**
11+
2. **Plan assets and plugin overrides**
12+
- Plan vector roots (vector-only containers) and mark their descendants for skipping.
13+
- If a plugin is enabled, pre-resolve plugin components for instances.
14+
- When a plugin returns component/code, skip collecting its descendants and reuse the cached plugin output in render.
15+
16+
3. **Collect data**
1217
- `collectNodeData()`
13-
- `getCSSAsync()` once per node.
18+
- `getCSSAsync()` once per node (skipped nodes are excluded).
1419
- `getStyledTextSegments()` for text nodes.
1520
- Preprocess styles (clean background, expand shorthands, merge inferred layout, infer resizing, apply overflow).
1621
- Apply positioning (auto layout absolute, constraints).
1722
- Replace image fills with uploaded assets.
1823

19-
3. **Normalize variables**
24+
4. **Normalize variables**
2025
- Normalize variable names and codeSyntax to a canonical form.
2126
- Capture variable usage candidates for token detection.
2227

23-
4. **Asset planning and export**
24-
- Plan vector/image export at the tree level.
25-
- Export SVGs/images only once.
26-
2728
5. **Sanitize styles**
2829
- Patch known layout issues (negative gap).
2930
- Ensure layout parents are `position: relative` when children are absolute.
3031

3132
6. **Build layout-only styles**
3233
- Extract layout-related CSS into a secondary map for SVG external layout.
3334

34-
7. **Render markup**
35+
7. **Export assets**
36+
- Export SVGs/images only once per planned asset.
37+
38+
8. **Render markup**
3539
- Render nodes into JSX/HTML component tree.
3640
- Inject SVG content or `<img>` when applicable.
3741
- Apply Tailwind class conversion.
3842

39-
8. **Token pipeline**
43+
9. **Token pipeline**
4044
- Detect token references in output code.
4145
- Apply plugin transforms to token names.
4246
- Rewrite code with transformed token names.
4347
- Resolve tokens to values when requested.
4448

45-
9. **Truncate and finalize output**
46-
- Enforce payload size limits.
47-
- Emit warnings only for truncation or inferred auto layout.
49+
10. **Truncate and finalize output**
50+
- Enforce payload size limits.
51+
- Emit warnings only for truncation or inferred auto layout.
4852

4953
## Tree and layout semantics
5054

@@ -106,6 +110,7 @@ This document describes the implementation design for MCP `get_code` in `package
106110
- Vector containers can be converted to a single SVG if all leaves are vector-like.
107111
- SVG nodes only receive external layout styles, not visual paint styles.
108112
- Placeholder SVG is emitted when export fails or is unavailable, using node width/height.
113+
- Vector-root descendants are not rendered; their CSS is not collected.
109114

110115
## Token pipeline (detailed)
111116

@@ -127,3 +132,5 @@ This document describes the implementation design for MCP `get_code` in `package
127132
- Single-pass CSS collection per node.
128133
- Asset export is planned and executed once.
129134
- Token detection is string-based and bounded by truncation.
135+
- Skip CSS collection for vector-root descendants and plugin-rendered subtrees.
136+
- Variable candidate scan is limited to bound variables and paint references (not inferred variables).

docs/extension/requirements.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ This document records the source requirements and hard constraints for the MCP `
99
- Do not use `renderBounds` diffs for positioning.
1010
- Do not inject positioning containers on GROUP/BOOLEAN nodes.
1111
- Keep `getCSSAsync()` at most once per node.
12+
- If a plugin returns component/code for an instance, do not collect or render its descendants.
1213

1314
## Scope
1415

@@ -88,6 +89,11 @@ Figma `relativeTransform` is relative to the container parent, not to a GROUP/BO
8889
- When vector export fails or assets are unavailable, preserve layout using a placeholder SVG with node size.
8990
- Omit `assets` when empty.
9091

92+
## Plugin output
93+
94+
- If a plugin returns component/code for an instance, the instance subtree is not collected.
95+
- Plugin output is preferred over fallback rendering for that instance.
96+
9197
## Token handling
9298

9399
- Token detection is based on the final emitted markup (after plugin transform and token rewrites).
@@ -105,9 +111,12 @@ Figma `relativeTransform` is relative to the container parent, not to a GROUP/BO
105111

106112
- Only emit `warnings` for truncation and inferred auto layout.
107113
- Other degradations should be logged to console with `[tempad-dev]` prefix.
114+
- The tool may log high-level timing info to console for performance diagnostics.
108115

109116
## Performance
110117

111118
- `getCSSAsync` must be called at most once per node.
112119
- `getStyledTextSegments` only for text nodes.
113120
- Avoid repeated vector export calls; plan and export once per tree.
121+
- Skip style collection for vector-root descendants (they are not rendered).
122+
- Variable candidate scanning uses bound variables and paint references; inferred variables are not required.

packages/extension/mcp/tools/code/assets/plan.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import type { NodeSnapshot, VisibleTree } from '../model'
22

33
export type AssetPlan = {
44
vectorRoots: Set<string>
5+
skippedIds: Set<string>
56
}
67

78
export function planAssets(tree: VisibleTree): AssetPlan {
@@ -32,7 +33,7 @@ export function planAssets(tree: VisibleTree): AssetPlan {
3233
}
3334
}
3435

35-
return { vectorRoots }
36+
return { vectorRoots, skippedIds: skipped }
3637
}
3738

3839
function computeVectorInfo(

packages/extension/mcp/tools/code/collect.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,12 +15,14 @@ import { REQUESTED_SEGMENT_FIELDS } from './text/types'
1515
export async function collectNodeData(
1616
tree: VisibleTree,
1717
config: CodegenConfig,
18-
assetRegistry: Map<string, AssetDescriptor>
18+
assetRegistry: Map<string, AssetDescriptor>,
19+
skipIds?: Set<string>
1920
): Promise<CollectedData> {
2021
const styles = new Map<string, Record<string, string>>()
2122
const textSegments = new Map<string, StyledTextSegment[] | null>()
2223

2324
for (const id of tree.order) {
25+
if (skipIds?.has(id)) continue
2426
const snapshot = tree.nodes.get(id)
2527
if (!snapshot) continue
2628
const node = snapshot.node

packages/extension/mcp/tools/code/index.ts

Lines changed: 109 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,13 @@ import type { AssetDescriptor, GetCodeResult } from '@tempad-dev/mcp-shared'
22

33
import { MCP_MAX_PAYLOAD_BYTES } from '@tempad-dev/mcp-shared'
44

5+
import type { CodegenConfig } from '@/utils/codegen'
6+
57
import { activePlugin } from '@/ui/state'
68
import { stringifyComponent } from '@/utils/component'
79
import { simplifyColorMixToRgba } from '@/utils/css'
810

11+
import type { VisibleTree } from './model'
912
import type { CodeLanguage, RenderContext } from './render'
1013

1114
import { currentCodegenConfig } from '../config'
@@ -15,6 +18,7 @@ import { planAssets } from './assets/plan'
1518
import { collectNodeData } from './collect'
1619
import { buildGetCodeWarnings, truncateCode } from './messages'
1720
import { renderTree } from './render'
21+
import { resolvePluginComponent, type PluginComponent } from './render/plugin'
1822
import { sanitizeStyles } from './sanitize'
1923
import { buildLayoutStyles } from './styles'
2024
import {
@@ -71,6 +75,14 @@ export async function handleGetCode(
7175
preferredLang?: CodeLanguage,
7276
resolveTokens?: boolean
7377
): Promise<GetCodeResult> {
78+
const now = () => (typeof performance !== 'undefined' ? performance.now() : Date.now())
79+
const startedAt = now()
80+
const timings: Array<[string, number]> = []
81+
const stamp = (label: string, start: number) => {
82+
const elapsed = Math.round((now() - start) * 10) / 10
83+
timings.push([label, elapsed])
84+
}
85+
7486
if (nodes.length !== 1) {
7587
throw new Error('Select exactly one node or provide a single root node id.')
7688
}
@@ -80,7 +92,9 @@ export async function handleGetCode(
8092
throw new Error('The selected node is not visible.')
8193
}
8294

95+
let t = now()
8396
const tree = buildVisibleTree(nodes)
97+
stamp('tree', t)
8498
const rootId = tree.rootIds[0]
8599
if (!rootId) {
86100
throw new Error('No renderable nodes found for the current selection.')
@@ -94,20 +108,52 @@ export async function handleGetCode(
94108
const config = currentCodegenConfig()
95109
const pluginCode = activePlugin.value?.code
96110

97-
const mappings = buildVariableMappings(nodes)
111+
t = now()
112+
const variableCache = new Map<string, Variable | null>()
113+
const mappings = buildVariableMappings(nodes, variableCache)
114+
stamp('vars', t)
115+
116+
t = now()
117+
const plan = planAssets(tree)
118+
stamp('plan-assets', t)
119+
120+
let pluginComponents: Map<string, PluginComponent | null> | undefined
121+
const pluginSkipped = new Set<string>()
122+
if (pluginCode) {
123+
pluginComponents = await collectPluginComponents(tree, config, pluginCode, preferredLang)
124+
if (pluginComponents.size) {
125+
for (const [id, component] of pluginComponents.entries()) {
126+
if (!component) continue
127+
const snapshot = tree.nodes.get(id)
128+
if (!snapshot) continue
129+
snapshot.children.forEach((childId) => skipDescendants(childId, tree, pluginSkipped))
130+
}
131+
}
132+
}
98133

99134
const assetRegistry = new Map<string, AssetDescriptor>()
100-
const collected = await collectNodeData(tree, config, assetRegistry)
135+
const skipIds =
136+
plan.skippedIds.size || pluginSkipped.size
137+
? new Set<string>([...plan.skippedIds, ...pluginSkipped])
138+
: plan.skippedIds
139+
t = now()
140+
const collected = await collectNodeData(tree, config, assetRegistry, skipIds)
141+
stamp('collect', t)
101142

102143
// Normalize codeSyntax outputs (e.g. "$kui-space-0") before Tailwind conversion.
103-
const usedCandidateIds = normalizeStyleVars(collected.styles, mappings)
104-
105-
const plan = planAssets(tree)
144+
t = now()
145+
const usedCandidateIds = normalizeStyleVars(collected.styles, mappings, variableCache)
146+
stamp('normalize-vars', t)
106147

107148
// Post-process styles (negative gap, auto-relative, etc.) after var normalization.
149+
t = now()
108150
sanitizeStyles(tree, collected.styles, plan.vectorRoots)
109151
const layoutStyles = buildLayoutStyles(collected.styles, plan.vectorRoots)
152+
stamp('layout', t)
153+
154+
t = now()
110155
const svgs = await exportVectorAssets(tree, plan, config, assetRegistry)
156+
stamp('export-assets', t)
111157

112158
const nodeMap = new Map<string, SceneNode>()
113159
collected.nodes.forEach((snap, id) => nodeMap.set(id, snap.node))
@@ -118,12 +164,15 @@ export async function handleGetCode(
118164
nodes: nodeMap,
119165
svgs,
120166
textSegments: collected.textSegments,
167+
pluginComponents,
121168
pluginCode,
122169
config,
123170
preferredLang
124171
}
125172

173+
t = now()
126174
const componentTree = await renderTree(rootId, tree, ctx)
175+
stamp('render', t)
127176

128177
if (!componentTree) {
129178
throw new Error('Unable to build markup for the current selection.')
@@ -132,52 +181,70 @@ export async function handleGetCode(
132181
const resolvedLang = preferredLang ?? ctx.detectedLang ?? 'jsx'
133182
const rootTag = collected.nodes.get(rootId)?.tag
134183

184+
t = now()
135185
const rawMarkup =
136186
typeof componentTree === 'string'
137187
? normalizeRootString(componentTree, rootTag, rootId, resolvedLang)
138188
: stringifyComponent(componentTree, {
139189
lang: resolvedLang,
140190
isInline: (tag) => COMPACT_TAGS.has(tag)
141191
})
192+
stamp('stringify', t)
142193

194+
t = now()
143195
const MAX_CODE_CHARS = Math.floor(MCP_MAX_PAYLOAD_BYTES * 0.6)
144196
let { code, truncated } = truncateCode(rawMarkup, MAX_CODE_CHARS)
197+
stamp('truncate', t)
145198

146199
// Token pipeline: detect -> transform -> rewrite -> detect
147200
const candidateIds = usedCandidateIds.size
148201
? new Set<string>([...mappings.variableIds, ...usedCandidateIds])
149202
: mappings.variableIds
150-
const variableCache = new Map<string, Variable | null>()
203+
t = now()
151204
const sourceIndex = buildSourceNameIndex(candidateIds, variableCache)
152205
const sourceNames = new Set(sourceIndex.keys())
153206
const usedNamesRaw = extractTokenNames(code, sourceNames)
207+
stamp('tokens:detect', t)
154208

209+
t = now()
155210
const { rewriteMap, finalBridge } = await applyPluginTransformToNames(
156211
usedNamesRaw,
157212
sourceIndex,
158213
pluginCode,
159214
config
160215
)
161216

217+
let hasRenames = false
218+
for (const [key, next] of rewriteMap) {
219+
if (key !== next) {
220+
hasRenames = true
221+
break
222+
}
223+
}
224+
162225
code = rewriteTokenNamesInCode(code, rewriteMap)
163226
if (code.length > MAX_CODE_CHARS) {
164227
code = code.slice(0, MAX_CODE_CHARS)
165228
truncated = true
166229
}
167230

168-
const usedNamesFinal = extractTokenNames(code, sourceNames)
169-
const finalBridgeFiltered = filterBridge(finalBridge, usedNamesFinal)
231+
const usedNamesFinal = hasRenames ? extractTokenNames(code, sourceNames) : usedNamesRaw
232+
const finalBridgeFiltered = hasRenames ? filterBridge(finalBridge, usedNamesFinal) : finalBridge
233+
stamp('tokens:rewrite', t)
170234

235+
t = now()
171236
const { usedTokens, tokensByCanonical, canonicalById } = await buildUsedTokens(
172237
usedNamesFinal,
173238
finalBridgeFiltered,
174239
config,
175240
pluginCode,
176241
variableCache
177242
)
243+
stamp('tokens:used', t)
178244

179245
let resolvedTokens: Record<string, string> | undefined
180246
if (resolveTokens) {
247+
t = now()
181248
const resolvedByFinal = buildResolvedTokenMap(
182249
finalBridgeFiltered,
183250
tokensByCanonical,
@@ -190,6 +257,7 @@ export async function handleGetCode(
190257
truncated = true
191258
}
192259
resolvedTokens = mapResolvedTokens(resolvedByFinal)
260+
stamp('tokens:resolve', t)
193261
}
194262

195263
const warnings = buildGetCodeWarnings(code, MAX_CODE_CHARS, truncated)
@@ -209,6 +277,14 @@ export async function handleGetCode(
209277
}
210278
: undefined
211279

280+
const elapsed = Math.round((now() - startedAt) * 10) / 10
281+
console.info(`[tempad-dev] get_code total ${elapsed}ms`)
282+
if (timings.length) {
283+
const detail = timings.map(([label, ms]) => `${label}=${ms}ms`).join(' ')
284+
const info = `nodes=${tree.order.length} text=${collected.textSegments.size} vectors=${plan.vectorRoots.size} assets=${assetRegistry.size}`
285+
console.info(`[tempad-dev] get_code timings ${detail} (${info})`)
286+
}
287+
212288
return {
213289
lang: resolvedLang,
214290
code,
@@ -219,6 +295,31 @@ export async function handleGetCode(
219295
}
220296
}
221297

298+
async function collectPluginComponents(
299+
tree: VisibleTree,
300+
config: CodegenConfig,
301+
pluginCode: string,
302+
preferredLang?: CodeLanguage
303+
): Promise<Map<string, PluginComponent | null>> {
304+
const out = new Map<string, PluginComponent | null>()
305+
for (const id of tree.order) {
306+
const snapshot = tree.nodes.get(id)
307+
if (!snapshot) continue
308+
if (snapshot.node.type !== 'INSTANCE') continue
309+
const component = await resolvePluginComponent(snapshot.node, config, pluginCode, preferredLang)
310+
out.set(id, component)
311+
}
312+
return out
313+
}
314+
315+
function skipDescendants(id: string, tree: VisibleTree, skipped: Set<string>): void {
316+
const node = tree.nodes.get(id)
317+
if (!node) return
318+
if (skipped.has(id)) return
319+
skipped.add(id)
320+
node.children.forEach((childId) => skipDescendants(childId, tree, skipped))
321+
}
322+
222323
function normalizeRootString(
223324
content: string,
224325
fallbackTag: string | undefined,

0 commit comments

Comments
 (0)