tweaklocal 0.1.1 → 0.3.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.
@@ -40,7 +40,7 @@
40
40
  .twk-total a:hover{color:#a7f3d0}
41
41
  .twk-tweak{background:#111827;color:#e5e7eb;border-radius:8px;padding:7px 11px;font-size:13px;display:flex;gap:8px;align-items:center;box-shadow:0 4px 14px rgba(0,0,0,.3);white-space:nowrap;width:max-content;max-width:720px;overflow:hidden;text-overflow:ellipsis}
42
42
  .twk-dot{width:8px;height:8px;border-radius:50%;flex:none}
43
- .twk-dot.done{background:#10b981}.twk-dot.queued,.twk-dot.running{background:#f59e0b;animation:twk-pulse 1s infinite}.twk-dot.error{background:#ef4444}.twk-dot.reverted{background:#6b7280}
43
+ .twk-dot.done{background:#10b981}.twk-dot.queued,.twk-dot.running{background:#f59e0b;animation:twk-pulse 1s infinite}.twk-dot.error{background:#ef4444}.twk-dot.reverted,.twk-dot.cancelled{background:#6b7280}
44
44
  .twk-tweak button{background:none;border:none;color:#818cf8;cursor:pointer;font-size:12.5px;padding:0}
45
45
  .twk-meta{color:#9ca3af}
46
46
  .twk-hint{position:fixed;left:14px;bottom:14px;background:#111827;color:#9ca3af;font-size:12.5px;padding:6px 11px;border-radius:6px;pointer-events:none}
@@ -708,6 +708,13 @@
708
708
  row._dot = el('span', 'twk-dot');
709
709
  row._label = el('span', null, '');
710
710
  row._meta = el('span', 'twk-meta', '');
711
+ row._cancel = el('button', null, 'cancel');
712
+ row._cancel.style.display = 'none';
713
+ row._cancel.onclick = async () => {
714
+ try {
715
+ await api('cancel', { id: t.id });
716
+ } catch (e) { row._meta.textContent = e.message; }
717
+ };
711
718
  row._undo = el('button', null, 'undo');
712
719
  row._undo.style.display = 'none';
713
720
  row._undo.onclick = async () => {
@@ -716,7 +723,7 @@
716
723
  setTimeout(reposition, 350);
717
724
  } catch (e) { row._meta.textContent = e.message; }
718
725
  };
719
- row.append(row._dot, row._label, row._meta, row._undo);
726
+ row.append(row._dot, row._label, row._meta, row._cancel, row._undo);
720
727
  tray.insertBefore(row, totalBar.nextSibling);
721
728
  tweaks.set(String(t.id), row);
722
729
  while (tray.children.length > 7) tray.lastChild.remove();
@@ -724,10 +731,12 @@
724
731
  if (t.label) row._label.textContent = t.label;
725
732
  if (t.status) {
726
733
  row._dot.className = 'twk-dot ' + t.status;
734
+ const inFlight = t.status === 'queued' || t.status === 'running';
735
+ row._cancel.style.display = inFlight ? '' : 'none';
727
736
  row._undo.style.display = t.status === 'done' && !String(t.id).startsWith('x') ? '' : 'none';
728
737
  }
729
738
  const bits = [];
730
- if (t.model) bits.push(t.model);
739
+ if (t.model) bits.push(t.model.replace(/^claude-/, '') + (t.effort ? ` @ ${t.effort}` : ''));
731
740
  if (t.tokens === 0) bits.push('0 tokens');
732
741
  if (t.durationMs) bits.push((t.durationMs / 1000).toFixed(1) + 's');
733
742
  if (t.costUSD != null) bits.push('$' + t.costUSD.toFixed(3));
package/package.json CHANGED
@@ -1,17 +1,35 @@
1
1
  {
2
2
  "name": "tweaklocal",
3
- "version": "0.1.1",
3
+ "version": "0.3.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",
7
- "bin": { "tweaklocal": "bin/tweaklocal.js" },
8
- "scripts": { "start": "node bin/tweaklocal.js" },
9
- "files": ["bin", "src", "overlay", "README.md"],
7
+ "bin": {
8
+ "tweaklocal": "bin/tweaklocal.js"
9
+ },
10
+ "scripts": {
11
+ "start": "node bin/tweaklocal.js"
12
+ },
13
+ "files": [
14
+ "bin",
15
+ "src",
16
+ "overlay",
17
+ "README.md"
18
+ ],
10
19
  "engines": {
11
20
  "node": ">=18"
12
21
  },
13
22
  "dependencies": {
14
23
  "@babel/parser": "^7.26.0"
15
24
  },
16
- "keywords": ["devtools", "tailwind", "visual-editor", "claude", "ai", "nextjs", "vite", "react"]
25
+ "keywords": [
26
+ "devtools",
27
+ "tailwind",
28
+ "visual-editor",
29
+ "claude",
30
+ "ai",
31
+ "nextjs",
32
+ "vite",
33
+ "react"
34
+ ]
17
35
  }
package/src/resolver.js CHANGED
@@ -181,13 +181,9 @@ export function applyStyleEdit(root, loc, styles) {
181
181
  );
182
182
  }
183
183
 
184
- /**
185
- * Delete the element from source, consuming its whole line(s) when it sits
186
- * alone on them. Refuses (throws) if the removal would leave the file
187
- * unparseable — e.g. deleting a component's only root element.
188
- */
189
- export function applyDeleteElement(root, loc, { dryRun = false } = {}) {
190
- const { abs, content, element, file } = loadTarget(root, loc);
184
+ // Remove an element from its source string, consuming its whole line(s) when
185
+ // it sits alone on them. Returns the new content, or null if unparseable.
186
+ function removeElementFromContent(content, element, file) {
191
187
  let start = element.start;
192
188
  let end = element.end;
193
189
  const lineStart = content.lastIndexOf('\n', start - 1) + 1;
@@ -199,22 +195,127 @@ export function applyDeleteElement(root, loc, { dryRun = false } = {}) {
199
195
  const next = content.slice(0, start) + content.slice(end);
200
196
  try {
201
197
  parseSource(next, file);
198
+ return next;
202
199
  } catch {
203
- // Almost always: this element is the ONLY thing its component (or a
204
- // .map() callback) renders, so removing it would leave an empty
205
- // `return ()` or `.map(() => ())` — invalid JS. Detect that specific,
206
- // near-universal case to give an actionable message instead of a
207
- // generic parse error.
208
- const before = content.slice(0, start).trimEnd();
209
- const soleReturn = /(return\s*\(?|=>\s*\(?)$/.test(before);
200
+ return null;
201
+ }
202
+ }
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;
219
+ }
220
+ });
221
+ if (!inner) return null;
222
+
223
+ // named function declaration: function Foo() {}
224
+ if (inner.id && inner.id.name) return inner.id.name;
225
+
226
+ // arrow/function bound to a name: const Foo = () => {} / const Foo = function(){}
227
+ let name = null;
228
+ walk(ast, (n) => {
229
+ if (
230
+ n.type === 'VariableDeclarator' &&
231
+ n.init && n.init.start === inner.start && n.init.end === inner.end &&
232
+ n.id && n.id.type === 'Identifier'
233
+ ) name = n.id.name;
234
+ });
235
+ return name; // null if the innermost fn is an anonymous callback (map, etc.)
236
+ }
237
+
238
+ // Walk the project for JSX usages of <Name ...>. Returns [{file, element}].
239
+ function findUsages(root, name) {
240
+ const usages = [];
241
+ const skip = new Set(['node_modules', '.next', '.git', 'dist', '.turbo']);
242
+ const stack = [root];
243
+ while (stack.length) {
244
+ const dir = stack.pop();
245
+ let entries;
246
+ try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { continue; }
247
+ for (const e of entries) {
248
+ const full = path.join(dir, e.name);
249
+ if (e.isDirectory()) { if (!skip.has(e.name)) stack.push(full); continue; }
250
+ if (!/\.(jsx|tsx)$/.test(e.name)) continue;
251
+ let content, ast;
252
+ try {
253
+ content = fs.readFileSync(full, 'utf8');
254
+ if (!content.includes('<' + name)) continue; // cheap prefilter
255
+ ast = parseSource(content, e.name);
256
+ } catch { continue; }
257
+ walk(ast, (n) => {
258
+ if (n.type !== 'JSXElement') return;
259
+ const openName = n.openingElement.name;
260
+ if (openName.type === 'JSXIdentifier' && openName.name === name) {
261
+ usages.push({ file: path.relative(root, full), abs: full, content, element: n });
262
+ }
263
+ });
264
+ }
265
+ }
266
+ return usages;
267
+ }
268
+
269
+ /**
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).
276
+ */
277
+ export function applyDeleteElement(root, loc, { dryRun = false } = {}) {
278
+ const { abs, content, element, file } = loadTarget(root, loc);
279
+
280
+ const inPlace = removeElementFromContent(content, element, file);
281
+ if (inPlace !== null) {
282
+ if (dryRun) return { abs, before: content, after: inPlace, wouldWrite: false };
283
+ return writeChecked(abs, content, inPlace);
284
+ }
285
+
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);
289
+ if (!name || !/^[A-Z]/.test(name)) {
290
+ // no resolvable component name (anonymous default, or a .map() list item)
210
291
  throw new Error(
211
- soleReturn
212
- ? "can't delete — it's the only thing this component (or list item) renders; deleting it would leave nothing to return. Describe the change instead (e.g. \"remove this from the list\") and it'll route through the model."
213
- : 'deleting this element would break the file — describe the change instead'
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."
214
293
  );
215
294
  }
216
- if (dryRun) return { abs, before: content, after: next, wouldWrite: false };
217
- return writeChecked(abs, content, next);
295
+
296
+ const usages = findUsages(root, name).filter(
297
+ (u) => !(u.abs === abs && u.element.start === element.start && u.element.end === element.end)
298
+ );
299
+ if (usages.length === 0) {
300
+ throw new Error(
301
+ `can't find where <${name}> is used, so there's no usage to remove. Describe the change instead and it'll route through the model.`
302
+ );
303
+ }
304
+ if (usages.length > 1) {
305
+ throw new Error(
306
+ `<${name}> is used in ${usages.length} places — deleting one is ambiguous. Select the specific <${name}> instance you want removed, or describe the change.`
307
+ );
308
+ }
309
+
310
+ const usage = usages[0];
311
+ const removed = removeElementFromContent(usage.content, usage.element, usage.file);
312
+ if (removed === null) {
313
+ throw new Error(
314
+ `removing the <${name}> usage would break ${usage.file}. Describe the change instead.`
315
+ );
316
+ }
317
+ if (dryRun) return { abs: usage.abs, before: usage.content, after: removed, wouldWrite: false, removedUsage: name };
318
+ return { ...writeChecked(usage.abs, usage.content, removed), removedUsage: name };
218
319
  }
219
320
 
220
321
  /** Returns null if the file parses, else the parse error message. */
package/src/router.js CHANGED
@@ -1,24 +1,183 @@
1
1
  import { spawn } from 'node:child_process';
2
2
 
3
- // Behavior verbs only — visual states like "on hover"/"on focus" are style
4
- // (Tailwind variants), not functionality.
5
- const FUNCTIONALITY = /(click|toggle|open|close|submit|fetch|load|save|state|counter|count|when |scroll to|navigate|link to|form|validate|sort|filter|search|api|localstorage|modal|dropdown|expand|collapse|disable|enable|animate|animation|add a |remove the )/i;
3
+ // ---------------------------------------------------------------------------
4
+ // Model routing — layered router (deterministic first, LLM tiebreak second).
5
+ //
6
+ // Tiers (see README for the routing table):
7
+ // 1 component tweaks / styles / copy / existing components → Sonnet-tier
8
+ // 2 new logic & functionality, low error rate → Opus-tier
9
+ // 3 multi-feature / cross-cutting / mission-critical → Opus xhigh → Fable
10
+ //
11
+ // "Speed" within a tier is the claude CLI's --effort flag (low..max), scaled
12
+ // by a complexity score. Layer 1 is lexicon+structure heuristics (<1ms, free);
13
+ // when its signals are ambiguous, Layer 2 asks Haiku to classify via
14
+ // --json-schema (~1-3s, ~$0.001). TWEAKLOCAL_ROUTER=heuristic|hybrid|llm.
15
+ // ---------------------------------------------------------------------------
6
16
 
7
- /**
8
- * Route a natural-language tweak to a model tier.
9
- * Style/copy fast model, functionality → reasoning model.
10
- * Overridable via TWEAKLOCAL_FAST_MODEL / TWEAKLOCAL_SMART_MODEL.
11
- */
12
- export function classify(instruction) {
13
- const isFunc = FUNCTIONALITY.test(instruction);
17
+ const MODELS = {
18
+ t1: process.env.TWEAKLOCAL_T1_MODEL || process.env.TWEAKLOCAL_FAST_MODEL || 'claude-sonnet-5',
19
+ t2: process.env.TWEAKLOCAL_T2_MODEL || process.env.TWEAKLOCAL_SMART_MODEL || 'claude-opus-4-8',
20
+ t3: process.env.TWEAKLOCAL_T3_MODEL || 'claude-opus-4-8',
21
+ t3Critical: process.env.TWEAKLOCAL_T3_CRITICAL_MODEL || 'claude-fable-5',
22
+ };
23
+
24
+ // --- Layer 1 lexicons ------------------------------------------------------
25
+
26
+ const STYLE_COPY =
27
+ /(colou?r|\b(red|blue|green|yellow|orange|purple|pink|white|black|gr[ae]y|teal|indigo|emerald|amber|slate|navy|cream)\b|padding|margin|spacing|font|bigger|smaller|larger|tighter|wider|bold|italic|underline|round(ed)?|corner|radius|shadow|glow|gradient|background|border|align|cent(er|re)|width|height|gap|opacity|hover|focus|responsive|mobile|breakpoint|dark mode|reword|rewrite|rephrase|shorten|copy|headline|heading|title|caption|label|tone|punchier|catch(y|ier)|wording|typo|spelling)/i;
28
+
29
+ const EXISTING_COMPONENT =
30
+ /(add another|duplicate|a copy of|move (the|this|it)|reorder|swap|reuse|use the existing|another instance|same as the|like the one)/i;
31
+
32
+ const NEW_LOGIC =
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
+
35
+ 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;
37
+
38
+ const MULTI_SCOPE =
39
+ /(across (the|all)|all pages|every (page|component|section)|entire (site|app|page)|throughout|end.to.end|site.wide|app.wide)/i;
40
+
41
+ const BIG_REWORK = /(refactor|redesign|rebuild|overhaul|rework|migrate)/i;
42
+
43
+ // --- Layer 1: deterministic scoring ---------------------------------------
44
+
45
+ function complexityOf(instruction) {
46
+ let score = 0;
47
+ const words = instruction.trim().split(/\s+/).length;
48
+ if (words > 12) score++;
49
+ if (words > 30) score++;
50
+ const clauses =
51
+ (instruction.match(/\b(and|then|also|plus|as well as|after that)\b/gi) || []).length +
52
+ (instruction.match(/[;,]/g) || []).length;
53
+ if (clauses >= 2) score++;
54
+ if (clauses >= 4) score++;
55
+ if (MULTI_SCOPE.test(instruction) || BIG_REWORK.test(instruction)) score++;
56
+ return score >= 3 ? 'high' : score >= 1 ? 'medium' : 'low';
57
+ }
58
+
59
+ function heuristicTier(instruction) {
60
+ const critical = MISSION_CRITICAL.test(instruction);
61
+ const multi = MULTI_SCOPE.test(instruction);
62
+ const rework = BIG_REWORK.test(instruction);
63
+ const logic = NEW_LOGIC.test(instruction);
64
+ const surface = STYLE_COPY.test(instruction) || EXISTING_COMPONENT.test(instruction);
65
+
66
+ if (critical) return { tier: 3, critical: true, confident: true };
67
+ if ((multi || rework) && (logic || rework || complexityOf(instruction) === 'high'))
68
+ return { tier: 3, critical: false, confident: true };
69
+ if (logic) return { tier: 2, critical: false, confident: true };
70
+ if (surface) return { tier: 1, critical: false, confident: true };
71
+ // no signal at all — default to the cheap tier but flag for the LLM tiebreak
72
+ return { tier: 1, critical: false, confident: false };
73
+ }
74
+
75
+ // --- Layer 2: Haiku classifier for ambiguous requests ----------------------
76
+
77
+ const CLASSIFY_SCHEMA = JSON.stringify({
78
+ type: 'object',
79
+ properties: {
80
+ tier: { type: 'integer', enum: [1, 2, 3] },
81
+ complexity: { type: 'string', enum: ['low', 'medium', 'high'] },
82
+ },
83
+ required: ['tier', 'complexity'],
84
+ additionalProperties: false,
85
+ });
86
+
87
+ function llmClassify(instruction, cwd) {
88
+ return new Promise((resolve) => {
89
+ const prompt = [
90
+ 'Classify this UI change request for model routing. Reply with JSON only.',
91
+ 'tier 1: visual tweaks, styles, copy edits, or rearranging existing components.',
92
+ 'tier 2: new logic or functionality within one area (handlers, state, forms, data fetching).',
93
+ 'tier 3: multi-feature or cross-cutting work, or anything touching auth/payments/data/security.',
94
+ 'complexity: low = one small change; medium = a few steps; high = many steps or broad scope.',
95
+ `Request: "${instruction.slice(0, 400)}"`,
96
+ ].join('\n');
97
+ const child = spawn(
98
+ 'claude',
99
+ ['-p', prompt, '--model', 'haiku', '--effort', 'low', '--json-schema', CLASSIFY_SCHEMA, '--output-format', 'json'],
100
+ { cwd, env: { ...process.env, CLAUDE_CODE_ENTRYPOINT: undefined } }
101
+ );
102
+ let out = '';
103
+ const timer = setTimeout(() => { try { child.kill('SIGKILL'); } catch {} }, 15000);
104
+ timer.unref?.();
105
+ child.stdout.on('data', (d) => (out += d));
106
+ child.on('close', () => {
107
+ clearTimeout(timer);
108
+ try {
109
+ const envelope = JSON.parse(out);
110
+ const parsed = typeof envelope.result === 'string' ? JSON.parse(envelope.result) : envelope.result;
111
+ if (parsed && [1, 2, 3].includes(parsed.tier)) return resolve(parsed);
112
+ } catch { /* fall through */ }
113
+ resolve(null);
114
+ });
115
+ child.on('error', () => { clearTimeout(timer); resolve(null); });
116
+ });
117
+ }
118
+
119
+ // --- Route resolution -------------------------------------------------------
120
+
121
+ const EFFORT = {
122
+ 1: { low: 'low', medium: 'medium', high: 'high' },
123
+ 2: { low: 'medium', medium: 'high', high: 'xhigh' },
124
+ };
125
+
126
+ function resolveRoute(tier, complexity, critical, source) {
127
+ if (tier === 3) {
128
+ const frontier = critical || complexity === 'high';
129
+ return {
130
+ tier,
131
+ complexity,
132
+ model: frontier ? MODELS.t3Critical : MODELS.t3,
133
+ // Fable's thinking is always on; effort still scales depth. Opus tier-3
134
+ // runs at xhigh — the setting recommended for the hardest agentic work.
135
+ effort: frontier ? 'high' : 'xhigh',
136
+ kind: critical ? 'mission-critical' : 'multi-feature',
137
+ source,
138
+ };
139
+ }
14
140
  return {
15
- kind: isFunc ? 'functionality' : 'style/copy',
16
- model: isFunc
17
- ? process.env.TWEAKLOCAL_SMART_MODEL || 'sonnet'
18
- : process.env.TWEAKLOCAL_FAST_MODEL || 'haiku',
141
+ tier,
142
+ complexity,
143
+ model: tier === 1 ? MODELS.t1 : MODELS.t2,
144
+ effort: EFFORT[tier][complexity],
145
+ kind: tier === 1 ? 'tweak' : 'feature',
146
+ source,
19
147
  };
20
148
  }
21
149
 
150
+ /**
151
+ * Route an instruction to {tier, complexity, model, effort}. Async because
152
+ * ambiguous requests may consult the Haiku classifier (hybrid mode).
153
+ */
154
+ export async function classify(instruction, { cwd } = {}) {
155
+ const mode = process.env.TWEAKLOCAL_ROUTER || 'hybrid';
156
+ const h = heuristicTier(instruction);
157
+ let tier = h.tier;
158
+ let critical = h.critical;
159
+ let complexity = complexityOf(instruction);
160
+ let source = 'heuristic';
161
+
162
+ const wantLLM = mode === 'llm' || (mode === 'hybrid' && !h.confident);
163
+ if (wantLLM) {
164
+ const llm = await llmClassify(instruction, cwd);
165
+ if (llm) {
166
+ tier = llm.tier;
167
+ complexity = llm.complexity;
168
+ critical = critical || (llm.tier === 3 && MISSION_CRITICAL.test(instruction));
169
+ source = 'llm';
170
+ }
171
+ }
172
+ return resolveRoute(tier, complexity, critical, source);
173
+ }
174
+
175
+ /** Synchronous heuristic-only classification (used by tests and dry runs). */
176
+ export function classifySync(instruction) {
177
+ const h = heuristicTier(instruction);
178
+ return resolveRoute(h.tier, complexityOf(instruction), h.critical, 'heuristic');
179
+ }
180
+
22
181
  export function buildPrompt({ file, target, instruction, tailwind = true }) {
23
182
  const styleRule = tailwind
24
183
  ? 'use Tailwind classes for styling'
@@ -36,31 +195,57 @@ export function buildPrompt({ file, target, instruction, tailwind = true }) {
36
195
  ].join('\n');
37
196
  }
38
197
 
39
- export function runClaude({ prompt, model, cwd, onEvent }) {
198
+ /**
199
+ * Spawn a headless claude run. `onSpawn` receives the child process so the
200
+ * caller can register it for cancellation. If cancelled (child killed), the
201
+ * promise resolves with { ok: false, cancelled: true }.
202
+ */
203
+ export function runClaude({ prompt, model, effort, cwd, onEvent, onSpawn }) {
40
204
  return new Promise((resolve) => {
41
205
  const started = Date.now();
206
+ const args = [
207
+ '-p',
208
+ prompt,
209
+ '--model',
210
+ model,
211
+ '--allowedTools',
212
+ 'Read,Edit',
213
+ '--permission-mode',
214
+ 'acceptEdits',
215
+ '--output-format',
216
+ 'json',
217
+ ];
218
+ if (effort) args.push('--effort', effort);
42
219
  const child = spawn(
43
220
  'claude',
44
- [
45
- '-p',
46
- prompt,
47
- '--model',
48
- model,
49
- '--allowedTools',
50
- 'Read,Edit',
51
- '--permission-mode',
52
- 'acceptEdits',
53
- '--output-format',
54
- 'json',
55
- ],
56
- { cwd, env: { ...process.env, CLAUDE_CODE_ENTRYPOINT: undefined } }
221
+ args,
222
+ // detached → the child leads its own process group, so cancelling can
223
+ // signal the whole group (claude + any tool subprocesses it spawns).
224
+ // Killing only the top process orphans children that keep stdio pipes
225
+ // open, which would stall the 'close' event and the restore that follows.
226
+ { cwd, env: { ...process.env, CLAUDE_CODE_ENTRYPOINT: undefined }, detached: true }
57
227
  );
228
+ let cancelled = false;
229
+ const signalGroup = (sig) => {
230
+ try { process.kill(-child.pid, sig); } // negative pid = process group
231
+ catch { try { child.kill(sig); } catch {} } // fall back to the single process
232
+ };
233
+ child.__cancel = () => {
234
+ cancelled = true;
235
+ signalGroup('SIGTERM');
236
+ setTimeout(() => { if (!child.killed) signalGroup('SIGKILL'); }, 1500).unref?.();
237
+ };
238
+ onSpawn?.(child);
58
239
  let out = '';
59
240
  let err = '';
60
241
  child.stdout.on('data', (d) => (out += d));
61
242
  child.stderr.on('data', (d) => (err += d));
62
243
  child.on('close', (code) => {
63
244
  const durationMs = Date.now() - started;
245
+ if (cancelled) {
246
+ resolve({ ok: false, cancelled: true, durationMs });
247
+ return;
248
+ }
64
249
  if (code !== 0) {
65
250
  resolve({ ok: false, error: err.trim() || `claude exited ${code}`, durationMs });
66
251
  return;
package/src/server.js CHANGED
@@ -30,6 +30,7 @@ function detectTailwind(root) {
30
30
 
31
31
  export function startServer({ root, port = 4100 }) {
32
32
  const undoStack = new Map(); // id -> { abs, before }
33
+ const running = new Map(); // id -> child process (for cancellation)
33
34
  const sseClients = new Set();
34
35
  let nextId = 1;
35
36
  const tailwind = detectTailwind(root);
@@ -135,7 +136,10 @@ export function startServer({ root, port = 4100 }) {
135
136
  const write = applyDeleteElement(root, body.loc);
136
137
  remember(id, write);
137
138
  telemetry.record('delete');
138
- broadcast({ type: 'tweak', id, kind: 'delete', status: 'done', tokens: 0, label: `deleted <${target.tagName}>`, ...recordSavings(0, 50) });
139
+ const label = write.removedUsage
140
+ ? `removed <${write.removedUsage}> usage`
141
+ : `deleted <${target.tagName}>`;
142
+ broadcast({ type: 'tweak', id, kind: 'delete', status: 'done', tokens: 0, label, ...recordSavings(0, 50) });
139
143
  return json(res, { ok: true, id });
140
144
  }
141
145
 
@@ -148,24 +152,55 @@ export function startServer({ root, port = 4100 }) {
148
152
  return json(res, { ok: true });
149
153
  }
150
154
 
155
+ if (url.pathname === '/api/cancel') {
156
+ const child = running.get(String(body.id));
157
+ if (!child) return json(res, { ok: false, error: 'task not running' }, 404);
158
+ child.__cancel(); // kills the process; the nl handler restores the file
159
+ return json(res, { ok: true });
160
+ }
161
+
162
+ if (url.pathname === '/api/classify') {
163
+ // dry-run the router: no file writes, no model edit run (the hybrid
164
+ // router may still consult the Haiku classifier on ambiguous input)
165
+ const route = await classify(body.instruction || '', { cwd: root });
166
+ return json(res, { ok: true, ...route });
167
+ }
168
+
151
169
  if (url.pathname === '/api/nl') {
152
170
  const id = nextId++;
153
171
  const { file } = parseLoc(body.loc);
154
172
  const target = describeTarget(root, body.loc);
155
- const route = classify(body.instruction);
173
+ const route = await classify(body.instruction, { cwd: root });
156
174
  const abs = path.resolve(root, file);
157
175
  remember(id, { abs, before: fs.readFileSync(abs, 'utf8') });
158
176
  telemetry.record('nl');
159
- broadcast({ type: 'tweak', id, kind: route.kind, status: 'queued', model: route.model, label: body.instruction.slice(0, 60) });
160
- json(res, { ok: true, id, model: route.model, kind: route.kind });
177
+ broadcast({ type: 'tweak', id, kind: route.kind, status: 'queued', model: route.model, effort: route.effort, tier: route.tier, label: body.instruction.slice(0, 60) });
178
+ json(res, { ok: true, id, model: route.model, effort: route.effort, tier: route.tier, kind: route.kind });
179
+
180
+ const restore = () => {
181
+ const entry = undoStack.get(String(id));
182
+ if (entry) fs.writeFileSync(entry.abs, entry.before);
183
+ };
161
184
 
162
185
  const prompt = buildPrompt({ file, target, instruction: body.instruction, tailwind });
163
186
  const result = await runClaude({
164
187
  prompt,
165
188
  model: route.model,
189
+ effort: route.effort,
166
190
  cwd: root,
167
191
  onEvent: (e) => broadcast({ type: 'tweak', id, ...e }),
192
+ onSpawn: (child) => running.set(String(id), child),
168
193
  });
194
+ running.delete(String(id));
195
+
196
+ // Cancelled mid-run: undo any partial edit and stop — no retry, no
197
+ // savings credit.
198
+ if (result.cancelled) {
199
+ restore();
200
+ undoStack.delete(String(id));
201
+ broadcast({ type: 'tweak', id, status: 'cancelled', model: route.model });
202
+ return;
203
+ }
169
204
 
170
205
  // The model's edit must leave the file parseable: retry once with
171
206
  // the parse error, then revert if it's still broken.
@@ -175,15 +210,23 @@ export function startServer({ root, port = 4100 }) {
175
210
  const retry = await runClaude({
176
211
  prompt: `Your previous edit to ${file} left it with a JSX/JS syntax error:\n${parseErr}\n\nFix ${file} so it parses cleanly while preserving the intended change: ${body.instruction}\nEdit ONLY that file.`,
177
212
  model: route.model,
213
+ effort: route.effort,
178
214
  cwd: root,
215
+ onSpawn: (child) => running.set(String(id), child),
179
216
  });
217
+ running.delete(String(id));
218
+ if (retry.cancelled) {
219
+ restore();
220
+ undoStack.delete(String(id));
221
+ broadcast({ type: 'tweak', id, status: 'cancelled', model: route.model });
222
+ return;
223
+ }
180
224
  result.durationMs += retry.durationMs || 0;
181
225
  if (retry.costUSD) result.costUSD = (result.costUSD || 0) + retry.costUSD;
182
226
  parseErr = checkSyntax(root, file);
183
227
  }
184
228
  if (parseErr) {
185
- const entry = undoStack.get(String(id));
186
- fs.writeFileSync(entry.abs, entry.before);
229
+ restore();
187
230
  result.ok = false;
188
231
  result.error = `edit reverted — file was left unparseable: ${parseErr.slice(0, 120)}`;
189
232
  }
@@ -193,6 +236,8 @@ export function startServer({ root, port = 4100 }) {
193
236
  id,
194
237
  status: result.ok ? 'done' : 'error',
195
238
  model: route.model,
239
+ effort: route.effort,
240
+ tier: route.tier,
196
241
  durationMs: result.durationMs,
197
242
  costUSD: result.costUSD,
198
243
  error: result.error,