tweaklocal 0.3.0 → 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.
- package/README.md +14 -1
- package/overlay/overlay.js +211 -21
- package/package.json +1 -1
- package/src/resolver.js +234 -53
- package/src/router.js +7 -2
- package/src/server.js +11 -3
package/README.md
CHANGED
|
@@ -4,7 +4,20 @@ Tweak your UI live in the browser — the changes are written straight to your s
|
|
|
4
4
|
|
|
5
5
|
- **Copy**: click any text, edit in place, Enter. Written to the JSX literal. 0 tokens.
|
|
6
6
|
- **Style**: padding/margin per side, font sizes and colors from *your* design system tokens. Deterministic Tailwind class edits. 0 tokens.
|
|
7
|
-
- **Anything else**: describe it —
|
|
7
|
+
- **Anything else**: describe it — a layered router picks a model *and* an effort level sized to the request, then runs headless `claude -p` scoped to the exact file your selection maps to.
|
|
8
|
+
|
|
9
|
+
## Model routing
|
|
10
|
+
|
|
11
|
+
| Tier | What it covers | Model | Effort (by complexity) |
|
|
12
|
+
|---|---|---|---|
|
|
13
|
+
| 1 | Styles, copy, component tweaks, existing components | `claude-sonnet-5` | low / medium / high |
|
|
14
|
+
| 2 | New logic & functionality (handlers, state, forms, data) | `claude-opus-4-8` | medium / high / xhigh |
|
|
15
|
+
| 3 | Multi-feature or cross-cutting work | `claude-opus-4-8` | xhigh |
|
|
16
|
+
| 3+ | Mission-critical (auth, payments, data, security) or very high complexity | `claude-fable-5` | high |
|
|
17
|
+
|
|
18
|
+
Routing is layered (RouteLLM-style): deterministic lexicon + structure scoring first (<1ms, free); requests with no clear signal fall through to a Haiku classifier with structured output (~1–3s, ~$0.001). Inspect any routing decision without running it: `POST /api/classify {"instruction": "..."}`.
|
|
19
|
+
|
|
20
|
+
Overrides: `TWEAKLOCAL_T1_MODEL`, `TWEAKLOCAL_T2_MODEL`, `TWEAKLOCAL_T3_MODEL`, `TWEAKLOCAL_T3_CRITICAL_MODEL`, and `TWEAKLOCAL_ROUTER=heuristic|hybrid|llm` (default `hybrid`).
|
|
8
21
|
|
|
9
22
|
## Quickstart (Next.js)
|
|
10
23
|
|
package/overlay/overlay.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
-
|
|
330
|
-
|
|
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…
|
|
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
|
-
|
|
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
|
|
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.
|
|
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
|
@@ -49,7 +49,7 @@ export function loadTarget(root, loc) {
|
|
|
49
49
|
});
|
|
50
50
|
element = element || nearMiss;
|
|
51
51
|
if (!element) throw new Error(`no JSX element at ${loc}`);
|
|
52
|
-
return { file, abs, content, element };
|
|
52
|
+
return { file, abs, content, element, ast };
|
|
53
53
|
}
|
|
54
54
|
|
|
55
55
|
export function describeTarget(root, loc) {
|
|
@@ -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
|
-
|
|
104
|
-
|
|
105
|
-
let classes =
|
|
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
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
/**
|
|
@@ -201,38 +246,141 @@ function removeElementFromContent(content, element, file) {
|
|
|
201
246
|
}
|
|
202
247
|
}
|
|
203
248
|
|
|
204
|
-
//
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
if (
|
|
213
|
-
n.type !== 'FunctionDeclaration' &&
|
|
214
|
-
n.type !== 'FunctionExpression' &&
|
|
215
|
-
n.type !== 'ArrowFunctionExpression'
|
|
216
|
-
) return;
|
|
217
|
-
if (n.start <= element.start && n.end >= element.end) {
|
|
218
|
-
if (!inner || (n.end - n.start) < (inner.end - inner.start)) inner = n;
|
|
249
|
+
// Ancestor chain from `element` up to the Program node (element first).
|
|
250
|
+
function ancestorChain(ast, element) {
|
|
251
|
+
const chain = [];
|
|
252
|
+
(function descend(node, trail) {
|
|
253
|
+
if (!node || typeof node.type !== 'string') return false;
|
|
254
|
+
if (node === element) {
|
|
255
|
+
chain.push(element, ...trail);
|
|
256
|
+
return true;
|
|
219
257
|
}
|
|
220
|
-
|
|
221
|
-
|
|
258
|
+
if (node.start > element.start || node.end < element.end) return false;
|
|
259
|
+
for (const key of Object.keys(node)) {
|
|
260
|
+
if (key === 'loc') continue;
|
|
261
|
+
const v = node[key];
|
|
262
|
+
const kids = Array.isArray(v) ? v : [v];
|
|
263
|
+
for (const kid of kids) {
|
|
264
|
+
if (kid && typeof kid.type === 'string' && descend(kid, [node, ...trail])) return true;
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
return false;
|
|
268
|
+
})(ast, []);
|
|
269
|
+
return chain;
|
|
270
|
+
}
|
|
222
271
|
|
|
223
|
-
|
|
224
|
-
|
|
272
|
+
const FN_TYPES = new Set(['FunctionDeclaration', 'FunctionExpression', 'ArrowFunctionExpression']);
|
|
273
|
+
|
|
274
|
+
// If the chain shows `element` is the RETURNED VALUE of the innermost enclosing
|
|
275
|
+
// function (only Return/Block between them), return that function node.
|
|
276
|
+
// This gate matters: without it, a parse-breaking delete anywhere inside a
|
|
277
|
+
// named component would wrongly escalate to removing the component's usage.
|
|
278
|
+
function returningFunction(chain) {
|
|
279
|
+
for (let i = 1; i < chain.length; i++) {
|
|
280
|
+
const t = chain[i].type;
|
|
281
|
+
if (t === 'ReturnStatement' || t === 'BlockStatement') continue;
|
|
282
|
+
return FN_TYPES.has(t) ? chain[i] : null;
|
|
283
|
+
}
|
|
284
|
+
return null;
|
|
285
|
+
}
|
|
225
286
|
|
|
226
|
-
|
|
287
|
+
function nameOfFunction(ast, fn) {
|
|
288
|
+
if (!fn) return null;
|
|
289
|
+
if (fn.id && fn.id.name) return fn.id.name;
|
|
227
290
|
let name = null;
|
|
228
291
|
walk(ast, (n) => {
|
|
229
292
|
if (
|
|
230
293
|
n.type === 'VariableDeclarator' &&
|
|
231
|
-
n.init && n.init.start ===
|
|
294
|
+
n.init && n.init.start === fn.start && n.init.end === fn.end &&
|
|
232
295
|
n.id && n.id.type === 'Identifier'
|
|
233
296
|
) name = n.id.name;
|
|
234
297
|
});
|
|
235
|
-
return name;
|
|
298
|
+
return name;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// Walk up from the element looking for a `{...}` JSX expression whose whole
|
|
302
|
+
// removal parses — covers `{cond && <el/>}` (conditional) and `{arr.map(...)}`
|
|
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.
|
|
307
|
+
function removableExpressionContainer(chain) {
|
|
308
|
+
for (let i = 1; i < chain.length; i++) {
|
|
309
|
+
const node = chain[i];
|
|
310
|
+
if (node.type === 'JSXExpressionContainer') {
|
|
311
|
+
const inner = chain[i - 1];
|
|
312
|
+
const isList = FN_TYPES.has(inner?.type) || inner?.type === 'CallExpression';
|
|
313
|
+
return { container: node, kind: isList ? 'list' : 'conditional block', mapCall: isList ? inner : null };
|
|
314
|
+
}
|
|
315
|
+
if (FN_TYPES.has(node.type)) {
|
|
316
|
+
// crossing a function is only allowed for inline callbacks (map etc.)
|
|
317
|
+
const parent = chain[i + 1];
|
|
318
|
+
if (!parent || parent.type !== 'CallExpression') return null;
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
return null;
|
|
322
|
+
}
|
|
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
|
+
}
|
|
236
384
|
}
|
|
237
385
|
|
|
238
386
|
// Walk the project for JSX usages of <Name ...>. Returns [{file, element}].
|
|
@@ -267,15 +415,20 @@ function findUsages(root, name) {
|
|
|
267
415
|
}
|
|
268
416
|
|
|
269
417
|
/**
|
|
270
|
-
* Delete behavior:
|
|
271
|
-
*
|
|
272
|
-
*
|
|
273
|
-
*
|
|
274
|
-
*
|
|
275
|
-
*
|
|
418
|
+
* Delete behavior, in order:
|
|
419
|
+
* 1. Normal element (has siblings): remove it in place.
|
|
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
|
|
426
|
+
* usage at its call site.
|
|
427
|
+
* Otherwise refuse with an actionable message (ambiguous → model lane).
|
|
276
428
|
*/
|
|
277
|
-
export function applyDeleteElement(root, loc, { dryRun = false } = {}) {
|
|
278
|
-
|
|
429
|
+
export function applyDeleteElement(root, loc, { dryRun = false, index } = {}) {
|
|
430
|
+
// reuse loadTarget's AST — element identity must hold for the ancestor walk
|
|
431
|
+
const { abs, content, element, file, ast } = loadTarget(root, loc);
|
|
279
432
|
|
|
280
433
|
const inPlace = removeElementFromContent(content, element, file);
|
|
281
434
|
if (inPlace !== null) {
|
|
@@ -283,13 +436,41 @@ export function applyDeleteElement(root, loc, { dryRun = false } = {}) {
|
|
|
283
436
|
return writeChecked(abs, content, inPlace);
|
|
284
437
|
}
|
|
285
438
|
|
|
286
|
-
//
|
|
287
|
-
const
|
|
288
|
-
|
|
439
|
+
// In-place removal breaks the file — walk up for a deletable wrapper.
|
|
440
|
+
const chain = ancestorChain(ast, element);
|
|
441
|
+
|
|
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
|
+
}
|
|
459
|
+
if (wrapper) {
|
|
460
|
+
const next = removeElementFromContent(content, wrapper.container, file);
|
|
461
|
+
if (next !== null) {
|
|
462
|
+
if (dryRun) return { abs, before: content, after: next, wouldWrite: false, removedBlock: wrapper.kind };
|
|
463
|
+
return { ...writeChecked(abs, content, next), removedBlock: wrapper.kind };
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
// Not a removable wrapper — usage removal, but ONLY when the element really
|
|
468
|
+
// is the returned output of a named component.
|
|
469
|
+
const fn = returningFunction(chain);
|
|
470
|
+
const name = nameOfFunction(ast, fn);
|
|
289
471
|
if (!name || !/^[A-Z]/.test(name)) {
|
|
290
|
-
// no resolvable component name (anonymous default, or a .map() list item)
|
|
291
472
|
throw new Error(
|
|
292
|
-
"can't delete this in place — it's the only thing rendered here
|
|
473
|
+
"can't delete this in place — it's the only thing rendered here and there's no removable wrapper. Describe the change instead (e.g. \"remove this from the list\") and it'll route through the model."
|
|
293
474
|
);
|
|
294
475
|
}
|
|
295
476
|
|
package/src/router.js
CHANGED
|
@@ -32,8 +32,10 @@ const EXISTING_COMPONENT =
|
|
|
32
32
|
const NEW_LOGIC =
|
|
33
33
|
/(implement|build|create|add a (new )?(feature|form|modal|dropdown|toggle|filter|search|carousel|slider|tab|accordion|tooltip|menu|pagination|stepper|wizard)|when (clicked|submitted|hovered|selected|scrolled)|on (click|submit|change|load|scroll)|fetch|\bapi\b|endpoint|request|\bstate\b|store|track|count(er|down)?|validat(e|ion)|debounce|localstorage|session storage|cookie|websocket|subscribe|drag|sortable|upload|download|timer|interval|keyboard shortcut)/i;
|
|
34
34
|
|
|
35
|
+
// "signup form" is a UI task (tier 2); "sign in with google" is auth (tier 3) —
|
|
36
|
+
// the sign in/up pattern only counts when followed by an auth-flow preposition.
|
|
35
37
|
const MISSION_CRITICAL =
|
|
36
|
-
/(\bauth\b|authentication|log ?in|log ?out|sign ?(in|up)|password|payment|checkout|billing|stripe|subscription|credit card|security|permission|\brole\b|admin|database|migration|schema|production|deploy|delete (all|account|user)|gdpr|\bpii\b|encrypt)/i;
|
|
38
|
+
/(\bauth\b|authentication|oauth|\bsso\b|log ?in|log ?out|sign ?(in|up) (with|via|using|flow)|password|payment|checkout|billing|stripe|subscription|credit card|security|permission|\brole\b|admin|database|migration|schema|production|deploy|delete (all|account|user)|gdpr|\bpii\b|encrypt)/i;
|
|
37
39
|
|
|
38
40
|
const MULTI_SCOPE =
|
|
39
41
|
/(across (the|all)|all pages|every (page|component|section)|entire (site|app|page)|throughout|end.to.end|site.wide|app.wide)/i;
|
|
@@ -107,7 +109,10 @@ function llmClassify(instruction, cwd) {
|
|
|
107
109
|
clearTimeout(timer);
|
|
108
110
|
try {
|
|
109
111
|
const envelope = JSON.parse(out);
|
|
110
|
-
|
|
112
|
+
// --json-schema puts the validated object in structured_output
|
|
113
|
+
const parsed =
|
|
114
|
+
envelope.structured_output ??
|
|
115
|
+
(typeof envelope.result === 'string' && envelope.result ? JSON.parse(envelope.result) : envelope.result);
|
|
111
116
|
if (parsed && [1, 2, 3].includes(parsed.tier)) return resolve(parsed);
|
|
112
117
|
} catch { /* fall through */ }
|
|
113
118
|
resolve(null);
|
package/src/server.js
CHANGED
|
@@ -128,17 +128,19 @@ 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
|
|
140
140
|
? `removed <${write.removedUsage}> usage`
|
|
141
|
-
:
|
|
141
|
+
: write.removedBlock
|
|
142
|
+
? `deleted ${write.removedBlock}`
|
|
143
|
+
: `deleted <${target.tagName}>`;
|
|
142
144
|
broadcast({ type: 'tweak', id, kind: 'delete', status: 'done', tokens: 0, label, ...recordSavings(0, 50) });
|
|
143
145
|
return json(res, { ok: true, id });
|
|
144
146
|
}
|
|
@@ -171,6 +173,12 @@ export function startServer({ root, port = 4100 }) {
|
|
|
171
173
|
const { file } = parseLoc(body.loc);
|
|
172
174
|
const target = describeTarget(root, body.loc);
|
|
173
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
|
+
}
|
|
174
182
|
const abs = path.resolve(root, file);
|
|
175
183
|
remember(id, { abs, before: fs.readFileSync(abs, 'utf8') });
|
|
176
184
|
telemetry.record('nl');
|