tweaklocal 0.3.1 → 0.3.3

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.
@@ -16,6 +16,12 @@
16
16
  #twk-root{position:fixed;inset:0;pointer-events:none;z-index:2147483000;font-family:ui-sans-serif,system-ui,sans-serif}
17
17
  .twk-outline{position:fixed;border:1.5px solid #6366f1;border-radius:3px;background:rgba(99,102,241,.08);pointer-events:none;transition:all .04s linear}
18
18
  .twk-outline.twk-selected{border-color:#10b981;background:rgba(16,185,129,.06)}
19
+ .twk-multi{position:fixed;border:1.5px solid #f59e0b;border-radius:3px;background:rgba(245,158,11,.10);pointer-events:none}
20
+ .twk-multibar{position:fixed;left:50%;bottom:14px;transform:translateX(-50%);background:#111827;color:#f9fafb;border:1.5px solid #f59e0b;border-radius:10px;box-shadow:0 8px 30px rgba(0,0,0,.4);padding:8px;pointer-events:auto;display:flex;gap:6px;align-items:center;font-size:12px;z-index:2147483001}
21
+ .twk-multibar input{background:#1f2937;border:1px solid #374151;border-radius:6px;color:#f9fafb;padding:5px 8px;font-size:12px;outline:none;width:220px}
22
+ .twk-multibar button{background:#374151;color:#f9fafb;border:none;border-radius:6px;padding:4px 9px;font-size:12px;cursor:pointer}
23
+ .twk-multibar button.twk-danger{background:#dc2626}
24
+ .twk-multibar .twk-count{color:#fbbf24;font-weight:700}
19
25
  .twk-badge{position:fixed;background:#312e81;color:#e0e7ff;font-size:11px;padding:2px 7px;border-radius:4px;pointer-events:none;white-space:nowrap;transform:translateY(-100%)}
20
26
  .twk-pop-label{position:fixed;background:#10b981;color:#052e1b;font-size:13.8px;font-weight:700;padding:3px 10px;border-radius:8px 8px 0 0;pointer-events:none;white-space:nowrap;transform:translate(-50%,-100%);text-align:center}
21
27
  .twk-delete-btn{position:fixed;width:22px;height:22px;border-radius:50%;background:#dc2626;color:#fff;border:none;cursor:pointer;pointer-events:auto;display:flex;align-items:center;justify-content:center;padding:0;box-shadow:0 2px 6px rgba(0,0,0,.4)}
@@ -61,8 +67,13 @@
61
67
  selected: null, // { el, loc }
62
68
  editing: null, // { el, original }
63
69
  tailwind: true, // daemon reports whether the app uses Tailwind
70
+ model: 'auto', // NL model override; 'auto' = router picks
71
+ multi: [], // [{ el, loc }] shift-click multi-selection
64
72
  };
65
73
 
74
+ // Stack of undoable tweak ids (LIFO) for ⌘Z / Ctrl-Z global undo.
75
+ const undoStack = [];
76
+
66
77
  // ---------- helpers ----------
67
78
  const el = (tag, cls, text) => {
68
79
  const n = document.createElement(tag);
@@ -192,30 +203,39 @@
192
203
  deleteBtn.style.display = 'none';
193
204
  deleteBtn.title = 'Delete element';
194
205
  root.appendChild(deleteBtn);
206
+ // All instances of a mapped template share one source stamp; the DOM position
207
+ // of a given instance tells the daemon WHICH data item it is (list items
208
+ // render in array order).
209
+ function instanceIndex(elm, loc) {
210
+ const instances = [...document.querySelectorAll(`[data-twk="${CSS.escape(loc)}"]`)];
211
+ const i = instances.indexOf(elm);
212
+ return i >= 0 ? i : undefined;
213
+ }
214
+
215
+ async function deleteOne({ el: elm, loc }) {
216
+ const payload = { loc, index: instanceIndex(elm, loc) };
217
+ // Dry-run first so a refusal never flickers the element.
218
+ await api('delete', { ...payload, dryRun: true });
219
+ if (document.contains(elm)) elm.style.display = 'none'; // optimistic; HMR makes it real
220
+ await api('delete', payload);
221
+ }
222
+
195
223
  deleteBtn.onclick = async (e) => {
196
224
  e.preventDefault();
197
225
  e.stopPropagation();
198
226
  const sel = state.selected;
199
227
  if (!sel || !document.contains(sel.el)) return;
200
- // Check first so a refusal never flickers the element or loses the
201
- // selection — only hide optimistically once we know it'll succeed.
202
228
  deleteBtn.disabled = true;
203
229
  try {
204
- await api('delete', { loc: sel.loc, dryRun: true });
230
+ await deleteOne(sel);
205
231
  } catch (err) {
232
+ if (document.contains(sel.el)) sel.el.style.display = '';
206
233
  deleteBtn.disabled = false;
207
- addTweak({ id: 'x' + Date.now(), status: 'error', label: err.message.slice(0, 140) });
234
+ addTweak({ id: 'x' + Date.now(), status: 'error', label: `delete: ${err.message.slice(0, 140)}` });
208
235
  return;
209
236
  }
210
237
  deleteBtn.disabled = false;
211
- sel.el.style.display = 'none'; // optimistic; HMR makes it real
212
238
  deselect();
213
- try {
214
- await api('delete', { loc: sel.loc });
215
- } catch (err) {
216
- sel.el.style.display = '';
217
- addTweak({ id: 'x' + Date.now(), status: 'error', label: `delete: ${err.message}` });
218
- }
219
239
  };
220
240
 
221
241
  function select(target) {
@@ -252,6 +272,97 @@
252
272
  deleteBtn.style.display = 'none';
253
273
  }
254
274
 
275
+ // ---------- multi-select (shift-click) ----------
276
+ const multiBoxes = []; // pooled outline divs
277
+ const multiBar = el('div', 'twk-multibar');
278
+ multiBar.style.display = 'none';
279
+ root.appendChild(multiBar);
280
+
281
+ function toggleMulti(target) {
282
+ const loc = target.getAttribute('data-twk');
283
+ const i = state.multi.findIndex((m) => m.el === target);
284
+ if (i >= 0) state.multi.splice(i, 1);
285
+ else {
286
+ // leaving single-selection mode: fold the current single selection in too
287
+ if (state.selected && !state.multi.some((m) => m.el === state.selected.el)) {
288
+ state.multi.push({ el: state.selected.el, loc: state.selected.loc });
289
+ }
290
+ if (!state.multi.some((m) => m.el === target)) state.multi.push({ el: target, loc });
291
+ deselect();
292
+ }
293
+ renderMulti();
294
+ }
295
+
296
+ function clearMulti() {
297
+ state.multi = [];
298
+ renderMulti();
299
+ }
300
+
301
+ function positionMulti() {
302
+ state.multi.forEach((m, i) => {
303
+ if (!multiBoxes[i]) { multiBoxes[i] = el('div', 'twk-multi'); root.appendChild(multiBoxes[i]); }
304
+ const box = multiBoxes[i];
305
+ if (!document.contains(m.el)) { box.style.display = 'none'; return; }
306
+ box.style.display = 'block';
307
+ positionBox(box, m.el);
308
+ });
309
+ for (let i = state.multi.length; i < multiBoxes.length; i++) multiBoxes[i].style.display = 'none';
310
+ }
311
+
312
+ let multiInput;
313
+ function renderMulti() {
314
+ positionMulti();
315
+ if (!state.multi.length) { multiBar.style.display = 'none'; return; }
316
+ multiBar.style.display = 'flex';
317
+ multiBar.textContent = '';
318
+ multiBar.append(el('span', 'twk-count', String(state.multi.length)));
319
+ multiBar.append(el('span', null, 'selected'));
320
+ multiInput = el('input');
321
+ multiInput.placeholder = 'Describe a change for all…';
322
+ multiInput.value = multiBar._draft || '';
323
+ multiInput.oninput = () => { multiBar._draft = multiInput.value; };
324
+ multiInput.onkeydown = (e) => { if (e.key === 'Enter') applyMultiNL(); e.stopPropagation(); };
325
+ const apply = el('button', 'twk-primary', 'Apply');
326
+ apply.onclick = applyMultiNL;
327
+ const del = el('button', 'twk-danger', 'Delete all');
328
+ del.onclick = deleteMulti;
329
+ const clear = el('button', null, 'Clear');
330
+ clear.onclick = clearMulti;
331
+ multiBar.append(multiInput, apply, del, clear);
332
+ }
333
+
334
+ async function applyMultiNL() {
335
+ const instruction = (multiInput?.value || '').trim();
336
+ if (!instruction) return;
337
+ const targets = state.multi.slice();
338
+ multiBar._draft = '';
339
+ clearMulti();
340
+ for (const t of targets) {
341
+ try {
342
+ const r = await api('nl', { loc: t.loc, instruction, model: state.model });
343
+ addTweak({ id: r.id, status: 'queued', model: r.model, label: `${shortLoc(t.loc)}: ${instruction.slice(0, 40)}` });
344
+ } catch (e) {
345
+ addTweak({ id: 'x' + Date.now() + t.loc, status: 'error', label: `${shortLoc(t.loc)}: ${e.message}` });
346
+ }
347
+ }
348
+ }
349
+
350
+ async function deleteMulti() {
351
+ const targets = state.multi.slice();
352
+ clearMulti();
353
+ // Delete bottom-up so a mapped-list removal doesn't shift the index of a
354
+ // not-yet-deleted sibling in the same list.
355
+ targets.sort((a, b) => (instanceIndex(b.el, b.loc) ?? 0) - (instanceIndex(a.el, a.loc) ?? 0));
356
+ for (const t of targets) {
357
+ try {
358
+ await deleteOne(t);
359
+ } catch (e) {
360
+ if (document.contains(t.el)) t.el.style.display = '';
361
+ addTweak({ id: 'x' + Date.now() + t.loc, status: 'error', label: `delete ${shortLoc(t.loc)}: ${e.message}` });
362
+ }
363
+ }
364
+ }
365
+
255
366
  function reposition() {
256
367
  const s = state.selected;
257
368
  if (!s) return;
@@ -322,12 +433,25 @@
322
433
  function fontStep(target, dir) {
323
434
  const scale = readTheme().textSizes;
324
435
  const classes = classList(target);
436
+ // An arbitrary size like `text-[34px]` also sets font-size and would
437
+ // override the scale class we add — remove it too, and seed the step from
438
+ // the element's rendered px so the first bump goes the right direction.
439
+ const arbitrary = classes.filter((c) => /^text-\[[^\]]+\]$/.test(c));
325
440
  const cur = classes.find((c) => scale.includes(c));
326
- let idx = cur ? scale.indexOf(cur) : scale.indexOf('text-base');
441
+ let idx;
442
+ if (cur) idx = scale.indexOf(cur);
443
+ else {
444
+ const px = parseFloat(getComputedStyle(target).fontSize) || 16;
445
+ // nearest scale entry by rendered size, else middle
446
+ idx = scale.indexOf('text-base');
447
+ if (idx < 0) idx = Math.floor(scale.length / 2);
448
+ }
327
449
  if (idx < 0) idx = Math.floor(scale.length / 2);
328
450
  const next = Math.min(Math.max(idx + dir, 0), scale.length - 1);
329
- if (scale[next] === cur) return null;
330
- return { remove: cur ? [cur] : [], add: [scale[next]] };
451
+ const add = scale[next];
452
+ const remove = [...(cur ? [cur] : []), ...arbitrary];
453
+ if (add === cur && !arbitrary.length) return null;
454
+ return { remove, add: [add] };
331
455
  }
332
456
 
333
457
  // Property editor: which classes to strip when setting each property.
@@ -608,14 +732,14 @@
608
732
 
609
733
  const nlRow = el('div', 'twk-row');
610
734
  const input = el('input');
611
- input.placeholder = 'Describe a change… (routed to the right model)';
735
+ input.placeholder = 'Describe a change…';
612
736
  const go = el('button', 'twk-primary', 'Go');
613
737
  const send = async () => {
614
738
  const instruction = input.value.trim();
615
739
  if (!instruction) return;
616
740
  input.value = '';
617
741
  try {
618
- const r = await api('nl', { loc: s.loc, instruction });
742
+ const r = await api('nl', { loc: s.loc, instruction, model: state.model });
619
743
  addTweak({ id: r.id, status: 'queued', model: r.model, label: instruction.slice(0, 60) });
620
744
  } catch (e) {
621
745
  addTweak({ id: 'x' + Date.now(), status: 'error', label: e.message });
@@ -625,6 +749,20 @@
625
749
  input.onkeydown = (e) => { if (e.key === 'Enter') send(); e.stopPropagation(); };
626
750
  nlRow.append(input, go);
627
751
  pop.appendChild(nlRow);
752
+
753
+ // Model picker: Auto (router) or a forced tier.
754
+ const modelRow = el('div', 'twk-row');
755
+ modelRow.append(el('span', 'twk-label', 'Model'));
756
+ const modelSel = el('select');
757
+ for (const [label, value] of [['Auto (routed)', 'auto'], ['Sonnet', 'claude-sonnet-5'], ['Opus', 'claude-opus-4-8'], ['Fable', 'claude-fable-5']]) {
758
+ const o = el('option', null, label);
759
+ o.value = value;
760
+ if (value === state.model) o.selected = true;
761
+ modelSel.appendChild(o);
762
+ }
763
+ modelSel.onchange = () => { state.model = modelSel.value; };
764
+ modelRow.append(modelSel);
765
+ pop.appendChild(modelRow);
628
766
  // delete lives as a floating icon on the element itself (deleteBtn)
629
767
  }
630
768
 
@@ -733,7 +871,14 @@
733
871
  row._dot.className = 'twk-dot ' + t.status;
734
872
  const inFlight = t.status === 'queued' || t.status === 'running';
735
873
  row._cancel.style.display = inFlight ? '' : 'none';
736
- row._undo.style.display = t.status === 'done' && !String(t.id).startsWith('x') ? '' : 'none';
874
+ const undoable = t.status === 'done' && !String(t.id).startsWith('x');
875
+ row._undo.style.display = undoable ? '' : 'none';
876
+ const key = String(t.id);
877
+ if (undoable && !undoStack.includes(key)) undoStack.push(key);
878
+ if (t.status === 'reverted') {
879
+ const i = undoStack.indexOf(key);
880
+ if (i >= 0) undoStack.splice(i, 1);
881
+ }
737
882
  }
738
883
  const bits = [];
739
884
  if (t.model) bits.push(t.model.replace(/^claude-/, '') + (t.effort ? ` @ ${t.effort}` : ''));
@@ -768,22 +913,64 @@
768
913
 
769
914
  function setMode(on) {
770
915
  state.selectMode = on;
771
- hint.textContent = on ? 'select mode — click an element · Esc to exit' : '⌘. select mode';
772
- if (!on) { setHover(null); deselect(); }
916
+ hint.textContent = on ? 'select mode — click · shift-click multi · Tab next · ⌘Z undo · Esc exit' : '⌘. select mode';
917
+ if (!on) { setHover(null); deselect(); clearMulti(); }
773
918
  }
774
919
 
920
+ // Visible, stamped elements in document order — the Tab cycle.
921
+ function stampedEls() {
922
+ return [...document.querySelectorAll('[data-twk]')].filter((n) => n.getClientRects().length);
923
+ }
924
+ function selectNext(dir) {
925
+ const els = stampedEls();
926
+ if (!els.length) return;
927
+ const cur = state.selected?.el;
928
+ let idx = cur ? els.indexOf(cur) : -1;
929
+ idx = (idx + dir + els.length) % els.length;
930
+ const next = els[idx];
931
+ select(next);
932
+ next.scrollIntoView({ block: 'center', behavior: 'smooth' });
933
+ }
934
+
935
+ async function undoLast() {
936
+ const id = undoStack.pop();
937
+ if (id == null) return;
938
+ try {
939
+ await api('undo', { id });
940
+ setTimeout(reposition, 350);
941
+ } catch { /* already reverted / unknown — drop it */ }
942
+ }
943
+
944
+ // ⌘Z / Ctrl-Z should still do native undo inside inputs and while editing copy.
945
+ const inTextField = (t) =>
946
+ t instanceof Element && (t.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(t.tagName));
947
+
775
948
  addEventListener('keydown', (e) => {
776
949
  if ((e.metaKey || e.ctrlKey) && e.key === '.') {
777
950
  e.preventDefault();
778
951
  setMode(!state.selectMode);
779
952
  return;
780
953
  }
954
+ // Global undo (works whenever the overlay is active, not just in select
955
+ // mode) — but never hijack undo inside a text field or mid copy-edit.
956
+ if ((e.metaKey || e.ctrlKey) && (e.key === 'z' || e.key === 'Z') && !e.shiftKey) {
957
+ if (state.editing || inTextField(e.target)) return;
958
+ e.preventDefault();
959
+ undoLast();
960
+ return;
961
+ }
781
962
  if (!state.selectMode) return;
782
963
  if (e.key === 'Escape') {
783
964
  if (state.editing) return finishTextEdit(false);
965
+ if (state.multi.length) return clearMulti();
784
966
  if (state.selected) return deselect();
785
967
  return setMode(false);
786
968
  }
969
+ if (e.key === 'Tab' && !state.editing) {
970
+ e.preventDefault();
971
+ selectNext(e.shiftKey ? -1 : 1);
972
+ return;
973
+ }
787
974
  if (e.key === 'Enter' && state.editing) {
788
975
  e.preventDefault();
789
976
  finishTextEdit(true);
@@ -812,10 +999,13 @@
812
999
  e.preventDefault();
813
1000
  e.stopPropagation();
814
1001
  const target = e.target instanceof Element ? e.target.closest('[data-twk]') : null;
1002
+ // Shift/⌘-click toggles an element in the multi-selection set.
1003
+ if (target && (e.shiftKey || e.metaKey || e.ctrlKey)) return toggleMulti(target);
1004
+ if (state.multi.length) clearMulti();
815
1005
  if (target) select(target);
816
1006
  else deselect();
817
1007
  }, true);
818
1008
 
819
- addEventListener('scroll', () => { reposition(); setHover(state.hoverEl); }, true);
820
- addEventListener('resize', () => reposition());
1009
+ addEventListener('scroll', () => { reposition(); positionMulti(); setHover(state.hoverEl); }, true);
1010
+ addEventListener('resize', () => { reposition(); positionMulti(); });
821
1011
  })();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tweaklocal",
3
- "version": "0.3.1",
3
+ "version": "0.3.3",
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
@@ -94,27 +94,72 @@ export function applyClassEdit(root, loc, removes = [], adds = []) {
94
94
  const { abs, content, element } = loadTarget(root, loc);
95
95
  const opening = element.openingElement;
96
96
  const attr = opening.attributes.find(
97
- (a) =>
98
- a.type === 'JSXAttribute' &&
99
- a.name.name === 'className' &&
100
- a.value &&
101
- a.value.type === 'StringLiteral'
97
+ (a) => a.type === 'JSXAttribute' && a.name.name === 'className'
102
98
  );
103
- let next;
104
- if (attr) {
105
- let classes = attr.value.value.split(/\s+/).filter(Boolean);
106
- classes = classes.filter((c) => !removes.includes(c));
99
+
100
+ const editList = (list) => {
101
+ let classes = list.split(/\s+/).filter(Boolean).filter((c) => !removes.includes(c));
107
102
  for (const a of adds) if (!classes.includes(a)) classes.push(a);
108
- next =
109
- content.slice(0, attr.value.start + 1) +
110
- classes.join(' ') +
111
- content.slice(attr.value.end - 1);
112
- } else {
103
+ return classes.join(' ');
104
+ };
105
+
106
+ // No className yet — add a fresh string attribute.
107
+ if (!attr) {
113
108
  const pos = opening.name.end;
114
- next =
115
- content.slice(0, pos) + ` className="${adds.join(' ')}"` + content.slice(pos);
109
+ return writeChecked(abs, content, content.slice(0, pos) + ` className="${adds.join(' ')}"` + content.slice(pos));
116
110
  }
117
- return writeChecked(abs, content, next);
111
+
112
+ // className="..." (plain string attribute)
113
+ if (attr.value && attr.value.type === 'StringLiteral') {
114
+ const next = content.slice(0, attr.value.start + 1) + editList(attr.value.value) + content.slice(attr.value.end - 1);
115
+ return writeChecked(abs, content, next);
116
+ }
117
+
118
+ const expr = attr.value && attr.value.type === 'JSXExpressionContainer' ? attr.value.expression : null;
119
+
120
+ // className={"..."} / className={'...'} (string inside an expression)
121
+ if (expr && expr.type === 'StringLiteral') {
122
+ const next = content.slice(0, expr.start) + JSON.stringify(editList(expr.value)) + content.slice(expr.end);
123
+ return writeChecked(abs, content, next);
124
+ }
125
+
126
+ // className={`... ${dyn} ...`} — edit only the STATIC class tokens; keep the
127
+ // dynamic `${...}` expressions verbatim, and stay a single className. (The
128
+ // old code appended a second className attribute here, which React silently
129
+ // ignored — the reported "font change didn't persist" bug.)
130
+ if (expr && expr.type === 'TemplateLiteral') {
131
+ const SENT = '\u0000'; // placeholder marking where each `${expr}` sits
132
+ const parts = [];
133
+ expr.quasis.forEach((q, i) => {
134
+ parts.push(q.value.raw);
135
+ if (i < expr.expressions.length) parts.push(SENT);
136
+ });
137
+ let tokens = parts
138
+ .join('')
139
+ .split(/\s+/)
140
+ .filter(Boolean)
141
+ .flatMap((t) => {
142
+ if (!t.includes(SENT)) return [t];
143
+ const out = [];
144
+ t.split(SENT).forEach((seg, i, a) => { if (seg) out.push(seg); if (i < a.length - 1) out.push(SENT); });
145
+ return out;
146
+ });
147
+ tokens = tokens.filter((t) => t === SENT || !removes.includes(t));
148
+ for (const a of adds) if (!tokens.includes(a)) tokens.push(a);
149
+ let ei = 0;
150
+ const rebuilt = tokens
151
+ .map((t) => (t === SENT ? '${' + content.slice(expr.expressions[ei].start, expr.expressions[ei++].end) + '}' : t))
152
+ .join(' ');
153
+ const next = content.slice(0, expr.start) + '`' + rebuilt + '`' + content.slice(expr.end);
154
+ return writeChecked(abs, content, next);
155
+ }
156
+
157
+ // Computed className (cn(...), a variable, &&/ternary at the top level) —
158
+ // refuse rather than write a duplicate/broken attribute. The overlay surfaces
159
+ // this; size/color still work via the inline-style lane.
160
+ throw new Error(
161
+ "className here is computed, so I can't edit its classes deterministically. Use the size/color controls (inline style) or describe the change to route it through the model."
162
+ );
118
163
  }
119
164
 
120
165
  /**
@@ -255,16 +300,17 @@ function nameOfFunction(ast, fn) {
255
300
 
256
301
  // Walk up from the element looking for a `{...}` JSX expression whose whole
257
302
  // removal parses — covers `{cond && <el/>}` (conditional) and `{arr.map(...)}`
258
- // (list template: removing it removes the rendered list). Never crosses a
259
- // function boundary except an inline callback (a function that is itself a
260
- // call argument, e.g. the .map render callback).
303
+ // (list template). Never crosses a function boundary except an inline callback
304
+ // (a function that is itself a call argument, e.g. the .map render callback).
305
+ // For lists, also returns the map CallExpression so the caller can target the
306
+ // underlying data array instead of the whole list.
261
307
  function removableExpressionContainer(chain) {
262
308
  for (let i = 1; i < chain.length; i++) {
263
309
  const node = chain[i];
264
310
  if (node.type === 'JSXExpressionContainer') {
265
311
  const inner = chain[i - 1];
266
312
  const isList = FN_TYPES.has(inner?.type) || inner?.type === 'CallExpression';
267
- return { container: node, kind: isList ? 'list' : 'conditional block' };
313
+ return { container: node, kind: isList ? 'list' : 'conditional block', mapCall: isList ? inner : null };
268
314
  }
269
315
  if (FN_TYPES.has(node.type)) {
270
316
  // crossing a function is only allowed for inline callbacks (map etc.)
@@ -275,6 +321,68 @@ function removableExpressionContainer(chain) {
275
321
  return null;
276
322
  }
277
323
 
324
+ // Unwrap TS wrappers around an initializer (as const / satisfies / parens).
325
+ function unwrapExpr(node) {
326
+ let n = node;
327
+ while (n && (n.type === 'TSAsExpression' || n.type === 'TSSatisfiesExpression' || n.type === 'TSNonNullExpression' || n.type === 'ParenthesizedExpression'))
328
+ n = n.expression;
329
+ return n;
330
+ }
331
+
332
+ // For `{items.map(cb)}`: resolve `items` to a local `const items = [...]`
333
+ // ArrayExpression in the same file. Returns the array node or null.
334
+ function resolveMappedArray(ast, mapCall) {
335
+ let call = mapCall;
336
+ // chain may have handed us the arrow (expression-body case) — find the call
337
+ if (call && FN_TYPES.has(call.type)) return null; // caller passes the real call below
338
+ if (!call || call.type !== 'CallExpression') return null;
339
+ const callee = call.callee;
340
+ if (
341
+ callee?.type !== 'MemberExpression' ||
342
+ callee.property?.type !== 'Identifier' ||
343
+ !['map', 'flatMap'].includes(callee.property.name) ||
344
+ callee.object?.type !== 'Identifier'
345
+ ) return null;
346
+ const arrName = callee.object.name;
347
+ let arr = null;
348
+ walk(ast, (n) => {
349
+ if (arr) return;
350
+ if (n.type === 'VariableDeclarator' && n.id?.type === 'Identifier' && n.id.name === arrName) {
351
+ const init = unwrapExpr(n.init);
352
+ if (init?.type === 'ArrayExpression') arr = init;
353
+ }
354
+ });
355
+ return arr;
356
+ }
357
+
358
+ // Remove the k-th element of an ArrayExpression, handling commas and whole
359
+ // lines. Returns new content, or null if the result doesn't parse.
360
+ function removeArrayElement(content, arrayNode, k, file) {
361
+ const el = arrayNode.elements[k];
362
+ if (!el) return null;
363
+ let start = el.start;
364
+ let end = el.end;
365
+ const after = content.slice(end).match(/^\s*,/);
366
+ if (after) end += after[0].length;
367
+ else {
368
+ const before = content.slice(0, start).match(/,\s*$/);
369
+ if (before) start -= before[0].length;
370
+ }
371
+ const lineStart = content.lastIndexOf('\n', start - 1) + 1;
372
+ const trailing = content.slice(end).match(/^[ \t]*\n/);
373
+ if (/^[ \t]*$/.test(content.slice(lineStart, start)) && trailing) {
374
+ start = lineStart;
375
+ end += trailing[0].length;
376
+ }
377
+ const next = content.slice(0, start) + content.slice(end);
378
+ try {
379
+ parseSource(next, file);
380
+ return next;
381
+ } catch {
382
+ return null;
383
+ }
384
+ }
385
+
278
386
  // Walk the project for JSX usages of <Name ...>. Returns [{file, element}].
279
387
  function findUsages(root, name) {
280
388
  const usages = [];
@@ -309,15 +417,16 @@ function findUsages(root, name) {
309
417
  /**
310
418
  * Delete behavior, in order:
311
419
  * 1. Normal element (has siblings): remove it in place.
312
- * 2. Element inside a `{...}` JSX expression whose removal parses — a list
313
- * template (`{arr.map(...)}`) or a conditional (`{cond && <el/>}`):
314
- * remove the whole expression. Deleting a list template removes the
315
- * rendered list that's what the on-screen "container" is.
316
- * 3. Sole return of a NAMED component used in exactly one place: remove that
420
+ * 2. List template (`{arr.map(...)}`): the clicked DOM instance's `index`
421
+ * identifies the data item remove that ONE entry from the local array
422
+ * literal. Deleting one card removes one card, not the list. (To remove a
423
+ * whole list, select its surrounding wrapper element instead.)
424
+ * 3. Conditional (`{cond && <el/>}`): remove the whole conditional block.
425
+ * 4. Sole return of a NAMED component used in exactly one place: remove that
317
426
  * usage at its call site.
318
427
  * Otherwise refuse with an actionable message (ambiguous → model lane).
319
428
  */
320
- export function applyDeleteElement(root, loc, { dryRun = false } = {}) {
429
+ export function applyDeleteElement(root, loc, { dryRun = false, index } = {}) {
321
430
  // reuse loadTarget's AST — element identity must hold for the ancestor walk
322
431
  const { abs, content, element, file, ast } = loadTarget(root, loc);
323
432
 
@@ -331,6 +440,22 @@ export function applyDeleteElement(root, loc, { dryRun = false } = {}) {
331
440
  const chain = ancestorChain(ast, element);
332
441
 
333
442
  const wrapper = removableExpressionContainer(chain);
443
+ if (wrapper && wrapper.kind === 'list') {
444
+ const arr = resolveMappedArray(ast, wrapper.mapCall);
445
+ if (arr && Number.isInteger(index) && index >= 0 && index < arr.elements.length) {
446
+ const next = removeArrayElement(content, arr, index, file);
447
+ if (next !== null) {
448
+ const detail = `item ${index + 1} of ${arr.elements.length}`;
449
+ if (dryRun) return { abs, before: content, after: next, wouldWrite: false, removedBlock: detail };
450
+ return { ...writeChecked(abs, content, next), removedBlock: detail };
451
+ }
452
+ }
453
+ // data not editable deterministically (imported/computed array, or the
454
+ // instance index is unknown) — never silently delete the whole list
455
+ throw new Error(
456
+ "this is one item in a rendered list whose data I can't safely edit here. Describe the change (e.g. \"remove the second stat\") and it'll route through the model — or select the list's surrounding container to delete the whole list."
457
+ );
458
+ }
334
459
  if (wrapper) {
335
460
  const next = removeElementFromContent(content, wrapper.container, file);
336
461
  if (next !== null) {
package/src/server.js CHANGED
@@ -128,12 +128,12 @@ export function startServer({ root, port = 4100 }) {
128
128
 
129
129
  if (url.pathname === '/api/delete') {
130
130
  if (body.dryRun) {
131
- applyDeleteElement(root, body.loc, { dryRun: true }); // throws if unsafe, writes nothing
131
+ applyDeleteElement(root, body.loc, { dryRun: true, index: body.index }); // throws if unsafe, writes nothing
132
132
  return json(res, { ok: true, dryRun: true });
133
133
  }
134
134
  const id = nextId++;
135
135
  const target = describeTarget(root, body.loc);
136
- const write = applyDeleteElement(root, body.loc);
136
+ const write = applyDeleteElement(root, body.loc, { index: body.index });
137
137
  remember(id, write);
138
138
  telemetry.record('delete');
139
139
  const label = write.removedUsage
@@ -173,6 +173,12 @@ export function startServer({ root, port = 4100 }) {
173
173
  const { file } = parseLoc(body.loc);
174
174
  const target = describeTarget(root, body.loc);
175
175
  const route = await classify(body.instruction, { cwd: root });
176
+ // Manual model override from the picker: keep the router's effort/tier
177
+ // (still sized to the request) but force the model the user chose.
178
+ if (body.model && body.model !== 'auto') {
179
+ route.model = body.model;
180
+ route.tier = 'manual';
181
+ }
176
182
  const abs = path.resolve(root, file);
177
183
  remember(id, { abs, before: fs.readFileSync(abs, 'utf8') });
178
184
  telemetry.record('nl');