tweaklocal 0.3.0 → 0.3.1

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 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 — routed to a fast model for style/copy or a reasoning model for functionality (headless `claude -p`), scoped to the exact file your selection maps to.
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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tweaklocal",
3
- "version": "0.3.0",
3
+ "version": "0.3.1",
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) {
@@ -201,38 +201,78 @@ function removeElementFromContent(content, element, file) {
201
201
  }
202
202
  }
203
203
 
204
- // If `element` is the sole rendered output of a NAMED component function,
205
- // return that component's name. Returns null when the innermost enclosing
206
- // function is an anonymous callback (e.g. a `.map()` list item) — those are
207
- // data-driven and belong in the model lane, not a usage removal.
208
- function soleReturnComponentName(ast, element) {
209
- // innermost function whose span contains the element
210
- let inner = null;
211
- walk(ast, (n) => {
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;
204
+ // Ancestor chain from `element` up to the Program node (element first).
205
+ function ancestorChain(ast, element) {
206
+ const chain = [];
207
+ (function descend(node, trail) {
208
+ if (!node || typeof node.type !== 'string') return false;
209
+ if (node === element) {
210
+ chain.push(element, ...trail);
211
+ return true;
219
212
  }
220
- });
221
- if (!inner) return null;
213
+ if (node.start > element.start || node.end < element.end) return false;
214
+ for (const key of Object.keys(node)) {
215
+ if (key === 'loc') continue;
216
+ const v = node[key];
217
+ const kids = Array.isArray(v) ? v : [v];
218
+ for (const kid of kids) {
219
+ if (kid && typeof kid.type === 'string' && descend(kid, [node, ...trail])) return true;
220
+ }
221
+ }
222
+ return false;
223
+ })(ast, []);
224
+ return chain;
225
+ }
222
226
 
223
- // named function declaration: function Foo() {}
224
- if (inner.id && inner.id.name) return inner.id.name;
227
+ const FN_TYPES = new Set(['FunctionDeclaration', 'FunctionExpression', 'ArrowFunctionExpression']);
225
228
 
226
- // arrow/function bound to a name: const Foo = () => {} / const Foo = function(){}
229
+ // If the chain shows `element` is the RETURNED VALUE of the innermost enclosing
230
+ // function (only Return/Block between them), return that function node.
231
+ // This gate matters: without it, a parse-breaking delete anywhere inside a
232
+ // named component would wrongly escalate to removing the component's usage.
233
+ function returningFunction(chain) {
234
+ for (let i = 1; i < chain.length; i++) {
235
+ const t = chain[i].type;
236
+ if (t === 'ReturnStatement' || t === 'BlockStatement') continue;
237
+ return FN_TYPES.has(t) ? chain[i] : null;
238
+ }
239
+ return null;
240
+ }
241
+
242
+ function nameOfFunction(ast, fn) {
243
+ if (!fn) return null;
244
+ if (fn.id && fn.id.name) return fn.id.name;
227
245
  let name = null;
228
246
  walk(ast, (n) => {
229
247
  if (
230
248
  n.type === 'VariableDeclarator' &&
231
- n.init && n.init.start === inner.start && n.init.end === inner.end &&
249
+ n.init && n.init.start === fn.start && n.init.end === fn.end &&
232
250
  n.id && n.id.type === 'Identifier'
233
251
  ) name = n.id.name;
234
252
  });
235
- return name; // null if the innermost fn is an anonymous callback (map, etc.)
253
+ return name;
254
+ }
255
+
256
+ // Walk up from the element looking for a `{...}` JSX expression whose whole
257
+ // 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).
261
+ function removableExpressionContainer(chain) {
262
+ for (let i = 1; i < chain.length; i++) {
263
+ const node = chain[i];
264
+ if (node.type === 'JSXExpressionContainer') {
265
+ const inner = chain[i - 1];
266
+ const isList = FN_TYPES.has(inner?.type) || inner?.type === 'CallExpression';
267
+ return { container: node, kind: isList ? 'list' : 'conditional block' };
268
+ }
269
+ if (FN_TYPES.has(node.type)) {
270
+ // crossing a function is only allowed for inline callbacks (map etc.)
271
+ const parent = chain[i + 1];
272
+ if (!parent || parent.type !== 'CallExpression') return null;
273
+ }
274
+ }
275
+ return null;
236
276
  }
237
277
 
238
278
  // Walk the project for JSX usages of <Name ...>. Returns [{file, element}].
@@ -267,15 +307,19 @@ function findUsages(root, name) {
267
307
  }
268
308
 
269
309
  /**
270
- * Delete behavior:
271
- * - Normal element (has siblings / non-sole child): remove it in place.
272
- * - Sole return of a component: the element IS the component's whole output,
273
- * so "delete this" means remove the component's rendered *usage*. If the
274
- * component is used in exactly one place, remove that usage deterministically.
275
- * Otherwise refuse with an actionable message (ambiguous model lane).
310
+ * Delete behavior, in order:
311
+ * 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
317
+ * usage at its call site.
318
+ * Otherwise refuse with an actionable message (ambiguous → model lane).
276
319
  */
277
320
  export function applyDeleteElement(root, loc, { dryRun = false } = {}) {
278
- const { abs, content, element, file } = loadTarget(root, loc);
321
+ // reuse loadTarget's AST element identity must hold for the ancestor walk
322
+ const { abs, content, element, file, ast } = loadTarget(root, loc);
279
323
 
280
324
  const inPlace = removeElementFromContent(content, element, file);
281
325
  if (inPlace !== null) {
@@ -283,13 +327,25 @@ export function applyDeleteElement(root, loc, { dryRun = false } = {}) {
283
327
  return writeChecked(abs, content, inPlace);
284
328
  }
285
329
 
286
- // Removing the element in place would break the file it's the sole return.
287
- const ast = parseSource(content, file);
288
- const name = soleReturnComponentName(ast, element);
330
+ // In-place removal breaks the file walk up for a deletable wrapper.
331
+ const chain = ancestorChain(ast, element);
332
+
333
+ const wrapper = removableExpressionContainer(chain);
334
+ if (wrapper) {
335
+ const next = removeElementFromContent(content, wrapper.container, file);
336
+ if (next !== null) {
337
+ if (dryRun) return { abs, before: content, after: next, wouldWrite: false, removedBlock: wrapper.kind };
338
+ return { ...writeChecked(abs, content, next), removedBlock: wrapper.kind };
339
+ }
340
+ }
341
+
342
+ // Not a removable wrapper — usage removal, but ONLY when the element really
343
+ // is the returned output of a named component.
344
+ const fn = returningFunction(chain);
345
+ const name = nameOfFunction(ast, fn);
289
346
  if (!name || !/^[A-Z]/.test(name)) {
290
- // no resolvable component name (anonymous default, or a .map() list item)
291
347
  throw new Error(
292
- "can't delete this in place — it's the only thing rendered here (likely a list item or an unnamed component). Describe the change instead (e.g. \"remove this from the list\") and it'll route through the model."
348
+ "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
349
  );
294
350
  }
295
351
 
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
- const parsed = typeof envelope.result === 'string' ? JSON.parse(envelope.result) : envelope.result;
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
@@ -138,7 +138,9 @@ export function startServer({ root, port = 4100 }) {
138
138
  telemetry.record('delete');
139
139
  const label = write.removedUsage
140
140
  ? `removed <${write.removedUsage}> usage`
141
- : `deleted <${target.tagName}>`;
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
  }