cmdzero 0.8.4 → 0.9.0

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.
@@ -502,7 +502,7 @@
502
502
  const s = state.selected;
503
503
  if (!s) return;
504
504
  try {
505
- const meta = await api('resolve', { loc: s.loc });
505
+ const meta = await api('resolve', { loc: s.loc, ancestors: ancestorStamps(s.el) });
506
506
  if (state.selected !== s) return; // selection changed meanwhile
507
507
  s.meta = meta;
508
508
  renderPopover();
@@ -1067,33 +1067,75 @@
1067
1067
  // literal we can edit in place (headings, buttons, links, paragraphs — not
1068
1068
  // containers, and not text shared across multiple instances).
1069
1069
  function canEditWholeText(s) {
1070
- const lits = s?.meta?.texts;
1070
+ if (!s || !document.contains(s.el)) return false;
1071
+ // Text traced to a call site's prop: the ancestors already picked out which
1072
+ // usage this is, so several instances of the shared component are fine —
1073
+ // the edit lands on this one's data, not on the component they share.
1074
+ const src = s.meta?.textSource;
1075
+ if (src) return !!textHost(s.el, src);
1076
+ const lits = s.meta?.texts;
1071
1077
  if (!lits || lits.length !== 1 || s.instances > 1) return false;
1072
- return document.contains(s.el) && lits[0].value.trim() === s.el.textContent.trim();
1078
+ return normText(lits[0].value) === normText(s.el.textContent);
1079
+ }
1080
+
1081
+ // Rendered text and source text never match byte for byte: HTML collapses
1082
+ // runs of whitespace, JSX literals wrap across lines, and a word-splitting
1083
+ // animation may join with non-breaking spaces the source spells as plain ones.
1084
+ // Compare what a reader sees, not what the file holds.
1085
+ const normText = (s) => (s || '').replace(/\s+/g, ' ').trim();
1086
+
1087
+ // The element that displays the whole traced string. A reveal animation
1088
+ // renders a span per word, so the span you clicked holds "something." while
1089
+ // the sentence lives a level or two up — that ancestor is what gets edited.
1090
+ function textHost(el, src) {
1091
+ const want = normText(src.value);
1092
+ if (!src.wholeText) return normText(el.textContent) === want ? el : null;
1093
+ for (let p = el; p; p = p.parentElement) {
1094
+ if (normText(p.textContent) === want) return p;
1095
+ }
1096
+ return null;
1097
+ }
1098
+
1099
+ // The stamps above this element, nearest first. The daemon needs them to tell
1100
+ // which call site rendered a shared component's markup.
1101
+ function ancestorStamps(el) {
1102
+ const out = [];
1103
+ for (let p = el?.parentElement; p; p = p.parentElement) {
1104
+ const cz = p.getAttribute('data-cz');
1105
+ if (cz) out.push(cz);
1106
+ }
1107
+ return out;
1073
1108
  }
1074
1109
 
1075
1110
  function startTextEdit(selectAll = true) {
1076
1111
  const s = state.selected;
1077
1112
  // the popover can outlive its selection (HMR, reloads) — never throw
1078
- const literal = s?.meta?.texts?.[0]?.value;
1079
- if (literal == null || !document.contains(s.el)) return;
1080
- if (state.editing && state.editing.el === s.el) return; // already editing this one
1113
+ const source = s?.meta?.textSource || null;
1114
+ const literal = source ? source.value : s?.meta?.texts?.[0]?.value;
1115
+ if (literal == null || !s || !document.contains(s.el)) return;
1116
+ // For traced text the element holding the sentence may be an ancestor of the
1117
+ // one clicked — edit that, or a reveal's per-word spans would each try to
1118
+ // hold the whole heading.
1119
+ const host = source ? textHost(s.el, source) : s.el;
1120
+ if (!host) return;
1121
+ if (state.editing && state.editing.el === host) return; // already editing this one
1081
1122
  const frag = document.createDocumentFragment();
1082
- while (s.el.firstChild) frag.appendChild(s.el.firstChild);
1123
+ while (host.firstChild) frag.appendChild(host.firstChild);
1083
1124
  state.editing = {
1084
- el: s.el,
1125
+ el: host,
1085
1126
  loc: s.loc,
1127
+ source, // set when the text lives in a call site's prop rather than here
1086
1128
  literal,
1087
1129
  frag,
1088
- prevUserSelect: s.el.style.userSelect,
1130
+ prevUserSelect: host.style.userSelect,
1089
1131
  };
1090
- s.el.textContent = literal;
1091
- s.el.style.userSelect = 'text'; // buttons often have user-select: none
1092
- try { s.el.contentEditable = 'plaintext-only'; } catch { s.el.contentEditable = 'true'; }
1093
- s.el.focus({ preventScroll: true });
1132
+ host.textContent = literal;
1133
+ host.style.userSelect = 'text'; // buttons often have user-select: none
1134
+ try { host.contentEditable = 'plaintext-only'; } catch { host.contentEditable = 'true'; }
1135
+ host.focus({ preventScroll: true });
1094
1136
  const sel = document.getSelection();
1095
- if (selectAll) sel?.selectAllChildren(s.el);
1096
- else { const rng = document.createRange(); rng.selectNodeContents(s.el); rng.collapse(false); sel?.removeAllRanges(); sel?.addRange(rng); }
1137
+ if (selectAll) sel?.selectAllChildren(host);
1138
+ else { const rng = document.createRange(); rng.selectNodeContents(host); rng.collapse(false); sel?.removeAllRanges(); sel?.addRange(rng); }
1097
1139
  hint.textContent = 'editing text — type to change · ⌘0 or click another element saves · Esc cancels';
1098
1140
  }
1099
1141
 
@@ -1114,7 +1156,18 @@
1114
1156
  return;
1115
1157
  }
1116
1158
  try {
1117
- await api('edit-text', { loc: ed.loc, oldText: ed.literal, newText });
1159
+ if (ed.source) {
1160
+ // The string belongs to this call site, not to the component whose
1161
+ // markup we clicked — writing there would retitle every usage at once.
1162
+ await api('edit-prop', {
1163
+ loc: ed.source.loc,
1164
+ prop: ed.source.prop,
1165
+ oldText: ed.literal,
1166
+ newText,
1167
+ });
1168
+ } else {
1169
+ await api('edit-text', { loc: ed.loc, oldText: ed.literal, newText });
1170
+ }
1118
1171
  // keep the new text; HMR re-renders the component (animations included)
1119
1172
  } catch (e) {
1120
1173
  restore();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cmdzero",
3
- "version": "0.8.4",
3
+ "version": "0.9.0",
4
4
  "description": "Tweak your UI live in the browser and write the changes straight to source. Copy and Tailwind edits cost zero tokens; everything else routes to a right-sized model via headless claude.",
5
5
  "license": "MIT",
6
6
  "type": "module",
package/src/resolver.js CHANGED
@@ -18,25 +18,32 @@ function walk(node, cb) {
18
18
  }
19
19
  }
20
20
 
21
- /** loc format: "<relative file>:<line 1-based>:<col 0-based>" (matches the babel stamp) */
21
+ /**
22
+ * loc format: "<file>:<line 1-based>:<col 0-based>" (matches the babel stamp).
23
+ *
24
+ * The file arrives in one of three shapes, depending on who stamped it: the
25
+ * babel plugin emits a root-relative path, and the @cmdzero/react dev runtime
26
+ * passes on whatever the bundler handed jsxDEV — absolute under webpack, but
27
+ * "[project]/components/Foo.tsx" under Turbopack. Normalize that prefix away
28
+ * here, at the boundary every caller shares: /api/nl parses locs itself and
29
+ * resolves them straight against the root, so normalizing further in (say, in
30
+ * loadTarget) leaves that lane reaching for <root>/[project]/... and dying on
31
+ * ENOENT while the copy and style lanes look fine.
32
+ */
22
33
  export function parseLoc(loc) {
23
34
  const m = /^(.*):(\d+):(\d+)$/.exec(loc);
24
35
  if (!m) throw new Error(`bad loc: ${loc}`);
25
- return { file: m[1], line: Number(m[2]), col: Number(m[3]) };
36
+ return { file: m[1].replace(/^\[project\]\//, ''), line: Number(m[2]), col: Number(m[3]) };
26
37
  }
27
38
 
28
39
  export function loadTarget(root, loc) {
29
40
  const parsed = parseLoc(loc);
30
41
  const { line, col } = parsed;
31
- // Stamps come from three sources: the babel plugin (root-relative path,
32
- // 0-based column) and the @cmdzero/react dev runtime (1-based column), which
33
- // reports whatever the bundler handed jsxDEV an absolute path under
34
- // webpack, but "[project]/..." under Turbopack. Normalize the path and match
35
- // either column convention.
36
- const raw = parsed.file.replace(/^\[project\]\//, '');
37
- // Resolve before the escape check: the guard has to see a normalized path,
38
- // or a "foo/../../.." stamp walks straight past a leading-".." test.
39
- const file = path.relative(root, path.resolve(root, raw));
42
+ // parseLoc has already normalized the path's shape; columns still come in two
43
+ // conventions (babel 0-based, the dev runtime 1-based) and are matched below.
44
+ // Resolve before the escape check: the guard has to see a normalized path, or
45
+ // a "foo/../../.." stamp walks straight past a leading-".." test.
46
+ const file = path.relative(root, path.resolve(root, parsed.file));
40
47
  if (file.startsWith('..') || path.isAbsolute(file)) {
41
48
  throw new Error('file outside root');
42
49
  }
@@ -57,12 +64,192 @@ export function loadTarget(root, loc) {
57
64
  return { file, abs, content, element, ast };
58
65
  }
59
66
 
60
- export function describeTarget(root, loc) {
61
- const { file, content, element } = loadTarget(root, loc);
67
+ /** The nearest enclosing function whose name looks like a component. */
68
+ function enclosingComponent(ast, chain) {
69
+ for (const node of chain) {
70
+ if (!FN_TYPES.has(node.type)) continue;
71
+ const name = nameOfFunction(ast, node);
72
+ if (name && /^[A-Z]/.test(name)) return { fn: node, name };
73
+ }
74
+ return null;
75
+ }
76
+
77
+ /** Is `name` destructured out of the component's props? ({ text, as }) => ... */
78
+ function isDestructuredProp(fn, name) {
79
+ const param = fn?.params?.[0];
80
+ if (param?.type !== 'ObjectPattern') return false;
81
+ return param.properties.some(
82
+ (p) => p.type === 'ObjectProperty' && p.key?.type === 'Identifier' && p.key.name === name
83
+ );
84
+ }
85
+
86
+ /** The `{ident}` an element renders as its only child, if that's its shape. */
87
+ function soleExpressionIdentifier(element) {
88
+ const meaningful = element.children.filter(
89
+ (c) => !(c.type === 'JSXText' && !c.value.trim())
90
+ );
91
+ if (meaningful.length !== 1) return null;
92
+ const only = meaningful[0];
93
+ if (only.type !== 'JSXExpressionContainer') return null;
94
+ const expr = unwrapExpr(only.expression);
95
+ return expr?.type === 'Identifier' ? expr.name : null;
96
+ }
97
+
98
+ function usageLoc(usage) {
99
+ const s = usage.element.openingElement.loc.start;
100
+ return `${usage.file}:${s.line}:${s.column}`;
101
+ }
102
+
103
+ /** `words.map(cb)` → "words" */
104
+ function mapCallArrayName(node) {
105
+ const call = unwrapExpr(node);
106
+ if (call?.type !== 'CallExpression') return null;
107
+ const callee = call.callee;
108
+ if (
109
+ callee?.type !== 'MemberExpression' ||
110
+ callee.property?.type !== 'Identifier' ||
111
+ !['map', 'flatMap'].includes(callee.property.name) ||
112
+ callee.object?.type !== 'Identifier'
113
+ ) return null;
114
+ return callee.object.name;
115
+ }
116
+
117
+ /** `const words = text.split(" ")` → "text" */
118
+ function splitSourceOf(ast, arrName) {
119
+ let prop = null;
120
+ walk(ast, (n) => {
121
+ if (prop) return;
122
+ if (n.type !== 'VariableDeclarator' || n.id?.type !== 'Identifier' || n.id.name !== arrName) return;
123
+ const init = unwrapExpr(n.init);
124
+ if (
125
+ init?.type === 'CallExpression' &&
126
+ init.callee?.type === 'MemberExpression' &&
127
+ init.callee.property?.type === 'Identifier' &&
128
+ init.callee.property.name === 'split' &&
129
+ init.callee.object?.type === 'Identifier'
130
+ ) prop = init.callee.object.name;
131
+ });
132
+ return prop;
133
+ }
134
+
135
+ /**
136
+ * Text reached one hop indirectly: a reveal animation splits a prop into a span
137
+ * per word, so the span you can actually click renders {word}, not {text}. The
138
+ * word isn't addressable in the source — the sentence is — so the editable unit
139
+ * is the whole prop either way.
140
+ *
141
+ * Covers the wrapper that renders {words.map(...)} and any element sitting
142
+ * inside the map callback.
143
+ */
144
+ function splitMappedProp(ast, chain, element) {
145
+ const meaningful = element.children.filter((c) => !(c.type === 'JSXText' && !c.value.trim()));
146
+ if (meaningful.length === 1 && meaningful[0].type === 'JSXExpressionContainer') {
147
+ const arrName = mapCallArrayName(meaningful[0].expression);
148
+ if (arrName) {
149
+ const prop = splitSourceOf(ast, arrName);
150
+ if (prop) return prop;
151
+ }
152
+ }
153
+ for (const node of chain) {
154
+ const arrName = mapCallArrayName(node);
155
+ if (!arrName) continue;
156
+ const prop = splitSourceOf(ast, arrName);
157
+ if (prop) return prop;
158
+ }
159
+ return null;
160
+ }
161
+
162
+ /**
163
+ * Text is data: `<RevealWords text="..."/>` renders {text} inside a span that
164
+ * lives in Reveal.tsx, so every heading stamps as that same span. Editing there
165
+ * rewrites the shared component and changes the copy under every call site at
166
+ * once — the string belongs to the call site's prop.
167
+ *
168
+ * The stamp can't say which call site rendered this element, so the overlay
169
+ * sends the clicked node's ancestor stamps. The nearest one from another file is
170
+ * the call site; look the component up there. Two usages in that file and we
171
+ * genuinely cannot tell which heading was clicked, so say so rather than rewrite
172
+ * the wrong one.
173
+ */
174
+ function traceTextProp(root, { file, ast, chain, element }, ancestors = []) {
175
+ const component = enclosingComponent(ast, chain);
176
+ if (!component) return { source: null, note: null };
177
+
178
+ // {text} straight from the prop, or {word} one hop through split/map — the
179
+ // latter can only be rewritten a sentence at a time.
180
+ let propName = null;
181
+ let wholeText = false;
182
+ const direct = soleExpressionIdentifier(element);
183
+ if (direct && isDestructuredProp(component.fn, direct)) {
184
+ propName = direct;
185
+ } else {
186
+ const mapped = splitMappedProp(ast, chain, element);
187
+ if (mapped && isDestructuredProp(component.fn, mapped)) {
188
+ propName = mapped;
189
+ wholeText = true;
190
+ }
191
+ }
192
+ if (!propName) return { source: null, note: null };
193
+
194
+ // Nearest ancestor first; the element's own file is the component, not a call site.
195
+ const seen = new Set();
196
+ for (const stamp of ancestors) {
197
+ let candidate;
198
+ try { candidate = parseLoc(stamp).file; } catch { continue; }
199
+ candidate = path.isAbsolute(candidate) ? path.relative(root, candidate) : candidate;
200
+ if (candidate === file || seen.has(candidate)) continue;
201
+ seen.add(candidate);
202
+
203
+ const here = findUsages(root, component.name).filter((u) => u.file === candidate);
204
+ if (here.length === 0) continue;
205
+ if (here.length > 1) {
206
+ return {
207
+ source: null,
208
+ note: `this text comes from <${component.name}>'s ${propName} prop, but ${candidate} has ${here.length} usages — I can't tell which one you clicked. Select the heading itself, or describe the change.`,
209
+ };
210
+ }
211
+
212
+ const attr = here[0].element.openingElement.attributes.find(
213
+ (a) =>
214
+ a.type === 'JSXAttribute' &&
215
+ a.name.name === propName &&
216
+ a.value?.type === 'StringLiteral'
217
+ );
218
+ if (!attr) {
219
+ return {
220
+ source: null,
221
+ note: `<${component.name}> in ${candidate} passes ${propName} as an expression, not a plain string, so there's no literal to edit here.`,
222
+ };
223
+ }
224
+ return {
225
+ source: {
226
+ file: candidate,
227
+ loc: usageLoc(here[0]),
228
+ component: component.name,
229
+ prop: propName,
230
+ value: attr.value.value,
231
+ // The clicked element shows a slice of this string (one word of a
232
+ // reveal), so the edit replaces the whole sentence. The overlay uses
233
+ // this to edit the element that actually holds all of it.
234
+ wholeText,
235
+ },
236
+ note: null,
237
+ };
238
+ }
239
+ return { source: null, note: null };
240
+ }
241
+
242
+ export function describeTarget(root, loc, { ancestors = [] } = {}) {
243
+ const { file, content, element, ast } = loadTarget(root, loc);
62
244
  const opening = element.openingElement;
63
245
  const texts = element.children
64
246
  .filter((c) => c.type === 'JSXText' && c.value.trim())
65
247
  .map((c) => ({ value: c.value.trim(), start: c.start, end: c.end }));
248
+
249
+ // Only trace when there's no literal of our own to edit.
250
+ const traced = texts.length === 0
251
+ ? traceTextProp(root, { file, ast, chain: ancestorChain(ast, element), element }, ancestors)
252
+ : { source: null, note: null };
66
253
  const classAttr = opening.attributes.find(
67
254
  (a) =>
68
255
  a.type === 'JSXAttribute' &&
@@ -77,10 +264,36 @@ export function describeTarget(root, loc) {
77
264
  lines: { start: element.loc.start.line, end: element.loc.end.line },
78
265
  snippet: content.slice(element.start, element.end),
79
266
  texts,
267
+ // Where this element's text really lives, when it renders a prop rather than
268
+ // a literal of its own. Null when it has its own text, or when the call site
269
+ // can't be pinned down (textSourceNote says why).
270
+ textSource: traced.source,
271
+ textSourceNote: traced.note,
80
272
  className: classAttr ? classAttr.value.value : null,
81
273
  };
82
274
  }
83
275
 
276
+ /**
277
+ * Rewrite a string prop at a call site: `<RevealWords text="old"/>` → "new".
278
+ * This is the write half of traceTextProp — the edit lands on the call site's
279
+ * data, so sibling usages of the same component keep their own copy.
280
+ */
281
+ export function applyPropTextEdit(root, loc, prop, oldText, newText) {
282
+ const { abs, content, element } = loadTarget(root, loc);
283
+ const attr = element.openingElement.attributes.find(
284
+ (a) => a.type === 'JSXAttribute' && a.name.name === prop && a.value?.type === 'StringLiteral'
285
+ );
286
+ if (!attr) throw new Error(`no string ${prop} prop to edit at ${loc}`);
287
+ if (oldText != null && attr.value.value !== oldText) {
288
+ throw new Error(`${prop} has changed since it was read — reload and try again`);
289
+ }
290
+ // A JSX attribute string is not a JS string: backslash escapes don't apply,
291
+ // entities do. Escape the two characters that would end or reopen it.
292
+ const escaped = newText.replace(/&/g, '&amp;').replace(/"/g, '&quot;');
293
+ const next = content.slice(0, attr.value.start) + `"${escaped}"` + content.slice(attr.value.end);
294
+ return writeChecked(abs, content, next);
295
+ }
296
+
84
297
  export function applyTextEdit(root, loc, oldText, newText) {
85
298
  const { abs, content, element } = loadTarget(root, loc);
86
299
  const node = element.children.find(
package/src/server.js CHANGED
@@ -2,7 +2,7 @@ import http from 'node:http';
2
2
  import fs from 'node:fs';
3
3
  import path from 'node:path';
4
4
  import { fileURLToPath } from 'node:url';
5
- import { describeTarget, applyTextEdit, applyClassEdit, applyStyleEdit, applyDeleteElement, applyMove, parseLoc, checkSyntax } from './resolver.js';
5
+ import { describeTarget, applyTextEdit, applyPropTextEdit, applyClassEdit, applyStyleEdit, applyDeleteElement, applyMove, parseLoc, checkSyntax } from './resolver.js';
6
6
  import { classify, buildPrompt, runClaude, runDeploy } from './router.js';
7
7
  import { initTelemetry, DISCLOSURE } from './telemetry.js';
8
8
 
@@ -126,7 +126,18 @@ export function startServer({ root, port = 4100, heartbeatMs = 15000 }) {
126
126
  const body = await readJson(req);
127
127
 
128
128
  if (url.pathname === '/api/resolve') {
129
- return json(res, describeTarget(root, body.loc));
129
+ // ancestors let the daemon tell two call sites of the same component
130
+ // apart — without them, every <RevealWords> heading looks identical.
131
+ return json(res, describeTarget(root, body.loc, { ancestors: body.ancestors || [] }));
132
+ }
133
+
134
+ if (url.pathname === '/api/edit-prop') {
135
+ const id = nextId++;
136
+ const write = applyPropTextEdit(root, body.loc, body.prop, body.oldText, body.newText);
137
+ remember(id, write);
138
+ telemetry.record('copy');
139
+ broadcast({ type: 'tweak', id, kind: 'copy', status: 'done', tokens: 0, label: `copy: "${body.newText.slice(0, 40)}"`, ...recordSavings(0, 50) });
140
+ return json(res, { ok: true, id });
130
141
  }
131
142
 
132
143
  if (url.pathname === '/api/edit-text') {