@aayambansal/squint 0.2.1 → 0.2.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 CHANGED
@@ -123,9 +123,10 @@ squint check # gates: typecheck -> lint -> test -> build
123
123
  squint shot http://localhost:5173 # screenshots at 390/768/1440
124
124
  squint brief # list design directions
125
125
  squint brief cinematic-dark # commit one for this repo
126
- squint tag # add the Alt+S element picker to any Vite app
126
+ squint tag # Alt+S element picker: pin elements + notes, alt+enter copies all
127
127
  squint variants gen 3 "<ask>" # 3 parallel design explorations
128
128
  squint variants apply terminal # keep the winner
129
+ squint skills init # scaffold .squint/rules.md + a trigger-matched skill
129
130
  squint config set engine claude
130
131
  squint config set models.claude claude-sonnet-5
131
132
  squint config set autoDev true # dev server starts with the TUI
@@ -134,6 +135,7 @@ squint config set autoCheck false # skip the per-turn typecheck+lint pass
134
135
  squint config set theme ocean # amber · ocean · moss · rose · mono
135
136
  squint config set bell false # no bell on turn completion
136
137
  squint doctor # engines + Chrome + WebSocket check
138
+ squint doctor --probe # run every engine end to end, verify auth actually works
137
139
  ```
138
140
 
139
141
  **Inside the TUI:**
@@ -144,10 +146,24 @@ squint doctor # engines + Chrome + WebSocket check
144
146
  order; `/queue clear` drops them. `Esc` interrupts the current turn.
145
147
  - **Editing**: a real line editor — arrows move, `alt+←/→` jump words, `ctrl+a/e/k/u/w`,
146
148
  `↑/↓` history. `ctrl+c` twice exits with a session summary.
147
- - **Commands**: `/dev` `/check` `/fix` `/shot` `/review [focus]` `/undo` `/checkpoints`
148
- `/restore <n>` `/mode` `/theme` `/resume` `/engine <id>` `/model <name>` `/clear`.
149
- - Assistant output renders as markdown; the footer tracks session turns and cost; a bell
150
- rings when a turn finishes.
149
+ - **Problems**: findings from gates, the dev server, the runtime probe, and a11y sweeps
150
+ collect into a list — `/problems` shows it, `/fix` sends everything as one turn,
151
+ `/fix <n>` targets one. The footer counts what's open.
152
+ - **Variants without leaving**: `/variants 3 <ask>` runs parallel explorations with
153
+ streaming per-family status; `/variants apply <id>` keeps the winner.
154
+ - **Commands**: type `/` and matching commands appear with descriptions; tab completes.
155
+ `/dev` `/check` `/problems` `/fix [n]` `/shot` `/review [focus]` `/variants` `/undo`
156
+ `/checkpoints` `/restore <n>` `/mode` `/theme` `/copy` `/save` `/resume` `/clear`.
157
+ - **Visual pulse**: every clean turn is screenshotted and pixel-compared with the last —
158
+ drift shows up as a number, not a surprise. `.squint/locks` lists paths the engine must
159
+ never touch; `/save` exports the transcript as markdown.
160
+ - Assistant output renders as markdown; the done line measures real work via git
161
+ (`3 files +42 −7`); the footer tracks session turns and cost; a bell rings when a
162
+ turn finishes.
163
+
164
+ **Project knowledge** rides along automatically: `.squint/rules.md` on every ask, and
165
+ `.squint/skills/*.md` (frontmatter `triggers: auth, login`) only when an ask mentions a
166
+ trigger — deterministic context routing, no embeddings.
151
167
 
152
168
  ## Design directions
153
169
 
@@ -0,0 +1,11 @@
1
+ #!/usr/bin/env node
2
+ import {
3
+ cdpCapture,
4
+ hasWebSocket,
5
+ pixelDiffPct
6
+ } from "./chunk-OC6RU6XH.js";
7
+ export {
8
+ cdpCapture,
9
+ hasWebSocket,
10
+ pixelDiffPct
11
+ };
@@ -51,12 +51,29 @@ function matchSkills(skills, ask) {
51
51
  const haystack = ask.toLowerCase();
52
52
  return skills.filter((skill) => skill.triggers.some((t) => haystack.includes(t)));
53
53
  }
54
+ function loadLocks(cwd) {
55
+ try {
56
+ return fs.readFileSync(path.join(cwd, ".squint", "locks"), "utf8").split("\n").map((line) => line.trim()).filter((line) => line.length > 0 && !line.startsWith("#"));
57
+ } catch {
58
+ return [];
59
+ }
60
+ }
54
61
  function enrich(cwd, ask) {
55
62
  const parts = [];
56
63
  const rules = loadRules(cwd);
57
64
  if (rules) parts.push(`## Project rules (always apply)
58
65
 
59
66
  ${rules}`);
67
+ const locks = loadLocks(cwd);
68
+ if (locks.length > 0) {
69
+ parts.push(
70
+ `## Locked files (hard constraint)
71
+
72
+ Never modify these paths, no matter what the task seems to need:
73
+ ${locks.map((l) => `- ${l}`).join("\n")}
74
+ If the task appears to require changing them, stop and explain instead.`
75
+ );
76
+ }
60
77
  const matched = matchSkills(loadSkills(cwd), ask);
61
78
  for (const skill of matched) {
62
79
  parts.push(`## Project notes: ${skill.name}
@@ -76,5 +93,6 @@ export {
76
93
  loadSkills,
77
94
  loadRules,
78
95
  matchSkills,
96
+ loadLocks,
79
97
  enrich
80
98
  };
@@ -0,0 +1,39 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/session/commands.ts
4
+ var COMMANDS = [
5
+ { name: "dev", description: "start/stop the project dev server" },
6
+ { name: "check", description: "run all quality gates (typecheck, lint, test, build)" },
7
+ { name: "problems", description: "list open findings from gates, dev server, runtime, a11y" },
8
+ { name: "fix", args: "[n]", description: "send all open problems to the engine, or just problem n" },
9
+ { name: "shot", description: "screenshot the app at mobile/tablet/desktop" },
10
+ { name: "review", args: "[focus]", description: "screenshots + the engine critiques its own rendered work" },
11
+ { name: "variants", args: "<2-4> <ask>", description: "parallel design explorations; apply/list/clean" },
12
+ { name: "undo", description: "revert the last ask (files only)" },
13
+ { name: "checkpoints", description: "list per-ask checkpoints" },
14
+ { name: "restore", args: "<n>", description: "rewind files to before ask n" },
15
+ { name: "mode", args: "plan|safe|yolo", description: "how much the engine may do (shift+tab cycles)" },
16
+ { name: "engine", args: "<id>", description: "switch backend (new session)" },
17
+ { name: "model", args: "[name]", description: "model override for the engine" },
18
+ { name: "theme", args: "[name]", description: "switch the TUI theme", viewLevel: true },
19
+ { name: "copy", description: "copy the last reply to the clipboard" },
20
+ { name: "save", description: "export the transcript to .squint/transcripts/" },
21
+ { name: "queue", args: "clear", description: "drop queued asks" },
22
+ { name: "resume", description: "pick up the previous session for this repo" },
23
+ { name: "clear", description: "new session (transcript, totals, persisted state)" },
24
+ { name: "help", description: "list commands" },
25
+ { name: "quit", description: "exit with a session summary" }
26
+ ];
27
+ function completeCommand(partial) {
28
+ const query = partial.toLowerCase();
29
+ return COMMANDS.filter((c) => c.name.startsWith(query));
30
+ }
31
+ function commandHelp() {
32
+ return COMMANDS.map((c) => `/${c.name}${c.args ? ` ${c.args}` : ""} \u2014 ${c.description}`).join("\n");
33
+ }
34
+
35
+ export {
36
+ COMMANDS,
37
+ completeCommand,
38
+ commandHelp
39
+ };
@@ -152,6 +152,55 @@ var describe = (value) => {
152
152
  }
153
153
  return String(value);
154
154
  };
155
+ async function pixelDiffPct(chromePath, pngA, pngB) {
156
+ const { child, wsUrl, profileDir } = await launchChrome(chromePath);
157
+ let connection = null;
158
+ try {
159
+ connection = await CdpConnection.connect(wsUrl, 1e4);
160
+ const { targetId } = await connection.send("Target.createTarget", { url: "about:blank" });
161
+ const { sessionId } = await connection.send("Target.attachToTarget", { targetId, flatten: true });
162
+ await connection.send("Runtime.enable", {}, sessionId);
163
+ const expression = `(async () => {
164
+ const load = (src) => new Promise((resolve, reject) => {
165
+ const img = new Image();
166
+ img.onload = () => resolve(img);
167
+ img.onerror = () => reject(new Error('decode'));
168
+ img.src = src;
169
+ });
170
+ const a = await load('data:image/png;base64,${pngA.toString("base64")}');
171
+ const b = await load('data:image/png;base64,${pngB.toString("base64")}');
172
+ const w = Math.min(a.width, b.width), h = Math.min(a.height, b.height);
173
+ if (w === 0 || h === 0) return null;
174
+ const draw = (img) => {
175
+ const c = document.createElement('canvas');
176
+ c.width = w; c.height = h;
177
+ const ctx = c.getContext('2d');
178
+ ctx.drawImage(img, 0, 0);
179
+ return ctx.getImageData(0, 0, w, h).data;
180
+ };
181
+ const da = draw(a), db = draw(b);
182
+ let differ = 0, total = 0;
183
+ for (let i = 0; i < da.length; i += 8) {
184
+ total++;
185
+ if (Math.abs(da[i] - db[i]) > 8 || Math.abs(da[i + 1] - db[i + 1]) > 8 || Math.abs(da[i + 2] - db[i + 2]) > 8) differ++;
186
+ }
187
+ const sizePenalty = (a.width !== b.width || a.height !== b.height) ? 1 : 0;
188
+ return Math.min(100, (differ / total) * 100 + sizePenalty);
189
+ })()`;
190
+ const { result } = await connection.send(
191
+ "Runtime.evaluate",
192
+ { expression, awaitPromise: true, returnByValue: true },
193
+ sessionId
194
+ );
195
+ return typeof result?.value === "number" ? result.value : null;
196
+ } catch {
197
+ return null;
198
+ } finally {
199
+ connection?.close();
200
+ child.kill("SIGKILL");
201
+ setTimeout(() => fs.rmSync(profileDir, { recursive: true, force: true }), 500).unref?.();
202
+ }
203
+ }
155
204
  async function cdpCapture(chromePath, url, outDir, viewports, settleMs = 2500, audit = false) {
156
205
  const { child, wsUrl, profileDir } = await launchChrome(chromePath);
157
206
  const report = { consoleErrors: [], pageErrors: [], failedRequests: [] };
@@ -239,5 +288,6 @@ async function cdpCapture(chromePath, url, outDir, viewports, settleMs = 2500, a
239
288
 
240
289
  export {
241
290
  hasWebSocket,
291
+ pixelDiffPct,
242
292
  cdpCapture
243
293
  };
@@ -2,7 +2,7 @@
2
2
  import {
3
3
  cdpCapture,
4
4
  hasWebSocket
5
- } from "./chunk-VJGE7HSP.js";
5
+ } from "./chunk-OC6RU6XH.js";
6
6
  import {
7
7
  findChrome,
8
8
  screenshot
@@ -16,7 +16,7 @@ import path2 from "path";
16
16
  // src/state/state.ts
17
17
  import fs from "fs";
18
18
  import path from "path";
19
- var IGNORED = ["preview/", "state.json", "variants/"];
19
+ var IGNORED = ["preview/", "state.json", "variants/", "transcripts/"];
20
20
  function ensureSquintIgnore(cwd) {
21
21
  const dir = path.join(cwd, ".squint");
22
22
  fs.mkdirSync(dir, { recursive: true });
@@ -126,16 +126,29 @@ ${blocks.join("\n\n")}
126
126
 
127
127
  Fix these first \u2014 a page that errors is broken regardless of how it looks.`;
128
128
  }
129
- async function probeRuntime(url) {
129
+ async function probeRuntime(url, cwd) {
130
130
  const chrome = findChrome();
131
131
  if (!chrome || !hasWebSocket()) return null;
132
132
  try {
133
- const { report } = await cdpCapture(chrome, url, os.tmpdir(), [], 1500);
134
- return report;
133
+ const dir = cwd ? previewDir(cwd) : os.tmpdir();
134
+ const { report, shots } = await cdpCapture(
135
+ chrome,
136
+ url,
137
+ dir,
138
+ cwd ? [{ name: "pulse", width: 1280, height: 800 }] : [],
139
+ 1500
140
+ );
141
+ return { report, pulsePath: shots[0]?.path };
135
142
  } catch {
136
143
  return null;
137
144
  }
138
145
  }
146
+ async function comparePulse(previous, current) {
147
+ const chrome = findChrome();
148
+ if (!chrome || !hasWebSocket()) return null;
149
+ const { pixelDiffPct } = await import("./cdp-SFWNP7OA.js");
150
+ return pixelDiffPct(chrome, previous, current);
151
+ }
139
152
  function buildRuntimeFixPrompt(report) {
140
153
  return `The running app has runtime problems.${runtimeSection(report)}
141
154
 
@@ -169,6 +182,7 @@ export {
169
182
  captureViewports,
170
183
  runtimeSummary,
171
184
  probeRuntime,
185
+ comparePulse,
172
186
  buildRuntimeFixPrompt,
173
187
  buildReviewPrompt
174
188
  };
@@ -28,6 +28,22 @@ function takeSnapshot(cwd) {
28
28
  return null;
29
29
  }
30
30
  }
31
+ function diffStatSince(cwd, snapshot) {
32
+ try {
33
+ const source = snapshot.stashHash ?? "HEAD";
34
+ const stat = git(cwd, ["diff", "--shortstat", source]);
35
+ const files = /(\d+) files? changed/.exec(stat);
36
+ if (!files) return null;
37
+ const insertions = /(\d+) insertions?/.exec(stat);
38
+ const deletions = /(\d+) deletions?/.exec(stat);
39
+ let out = `${files[1]} file${files[1] === "1" ? "" : "s"}`;
40
+ if (insertions) out += ` +${insertions[1]}`;
41
+ if (deletions) out += ` \u2212${deletions[1]}`;
42
+ return out;
43
+ } catch {
44
+ return null;
45
+ }
46
+ }
31
47
  function restoreSnapshot(cwd, snapshot) {
32
48
  try {
33
49
  const before = new Set(snapshot.untracked);
@@ -50,5 +66,6 @@ function restoreSnapshot(cwd, snapshot) {
50
66
  export {
51
67
  isGitRepo,
52
68
  takeSnapshot,
69
+ diffStatSince,
53
70
  restoreSnapshot
54
71
  };
package/dist/cli.js CHANGED
@@ -1,4 +1,10 @@
1
1
  #!/usr/bin/env node
2
+ import {
3
+ diffStatSince,
4
+ isGitRepo,
5
+ restoreSnapshot,
6
+ takeSnapshot
7
+ } from "./chunk-YHRAOBI2.js";
2
8
  import {
3
9
  DevServer,
4
10
  buildFixPrompt,
@@ -17,6 +23,9 @@ import {
17
23
  import {
18
24
  runAgent
19
25
  } from "./chunk-OTG4ZB4R.js";
26
+ import {
27
+ completeCommand
28
+ } from "./chunk-EECGXFRX.js";
20
29
  import {
21
30
  buildGatePrompt,
22
31
  detectFastGates,
@@ -28,12 +37,13 @@ import {
28
37
  buildRuntimeFixPrompt,
29
38
  captureViewports,
30
39
  clearState,
40
+ comparePulse,
31
41
  loadState,
32
42
  probeRuntime,
33
43
  runtimeSummary,
34
44
  saveState
35
- } from "./chunk-REDPR3KI.js";
36
- import "./chunk-VJGE7HSP.js";
45
+ } from "./chunk-SU6YRRMF.js";
46
+ import "./chunk-OC6RU6XH.js";
37
47
  import {
38
48
  findChrome
39
49
  } from "./chunk-P3V5CWH5.js";
@@ -43,12 +53,7 @@ import {
43
53
  } from "./chunk-2LRIKWBU.js";
44
54
  import {
45
55
  enrich
46
- } from "./chunk-XZKQZKEE.js";
47
- import {
48
- isGitRepo,
49
- restoreSnapshot,
50
- takeSnapshot
51
- } from "./chunk-WTA6YYBY.js";
56
+ } from "./chunk-4LAU5TGK.js";
52
57
 
53
58
  // src/cli.tsx
54
59
  import { Command } from "commander";
@@ -161,7 +166,8 @@ var Session = class {
161
166
  devUrl: null,
162
167
  totals: { costUsd: 0, turns: 0 },
163
168
  queue: [],
164
- mode: "safe"
169
+ mode: "safe",
170
+ problems: []
165
171
  };
166
172
  if (opts.autoDev && detectDevCommand(opts.cwd)) {
167
173
  this.devServer().start();
@@ -188,10 +194,13 @@ var Session = class {
188
194
  sessionId;
189
195
  dev = null;
190
196
  abort = null;
191
- pendingFix = null;
197
+ /** Full problem records; state carries the summaries. */
198
+ problemPrompts = /* @__PURE__ */ new Map();
199
+ nextProblemId = 0;
192
200
  checkpoints = [];
193
201
  fixAttempts = 0;
194
202
  reviewTipShown = false;
203
+ lastPulse = null;
195
204
  startedAt = Date.now();
196
205
  subscribe(listener) {
197
206
  this.listeners.add(listener);
@@ -211,6 +220,47 @@ var Session = class {
211
220
  note(text) {
212
221
  this.push("status", text);
213
222
  }
223
+ /** Register a finding; a fresh finding from a source supersedes its old one. */
224
+ addProblem(source, summary, prompt) {
225
+ const kept = this.state.problems.filter((p) => p.source !== source);
226
+ for (const gone of this.state.problems) {
227
+ if (gone.source === source) this.problemPrompts.delete(gone.id);
228
+ }
229
+ this.nextProblemId += 1;
230
+ this.problemPrompts.set(this.nextProblemId, prompt);
231
+ this.notify({ problems: [...kept, { id: this.nextProblemId, source, summary }] });
232
+ }
233
+ clearProblems(source) {
234
+ const removed = this.state.problems.filter((p) => p.source === source);
235
+ if (removed.length === 0) return;
236
+ for (const problem of removed) this.problemPrompts.delete(problem.id);
237
+ this.notify({ problems: this.state.problems.filter((p) => p.source !== source) });
238
+ }
239
+ /** One prompt covering the given problems, oldest first. */
240
+ combinedFixPrompt(problems) {
241
+ const sections = problems.map(
242
+ (p) => this.problemPrompts.get(p.id) ?? `Fix this reported problem: ${p.summary}`
243
+ );
244
+ return sections.join("\n\n---\n\n");
245
+ }
246
+ dispatchFix(problems) {
247
+ if (problems.length === 0) return;
248
+ const prompt = this.combinedFixPrompt(problems);
249
+ const display = `\u26D1 fix: ${problems.map((p) => p.source).join(" + ")}`;
250
+ for (const problem of problems) this.problemPrompts.delete(problem.id);
251
+ this.notify({ problems: this.state.problems.filter((p) => !problems.includes(p)) });
252
+ void this.runTurn(prompt, display);
253
+ }
254
+ /** Launch a capped auto-fix turn over all open problems. Returns true if launched. */
255
+ maybeAutoFix() {
256
+ if (!this.opts.autoFix || this.fixAttempts >= MAX_AUTO_FIX_ATTEMPTS) return false;
257
+ if (this.state.problems.length === 0) return false;
258
+ this.fixAttempts += 1;
259
+ this.push("status", `auto-fix attempt ${this.fixAttempts}/${MAX_AUTO_FIX_ATTEMPTS}`);
260
+ this.notify({ running: false });
261
+ this.dispatchFix([...this.state.problems]);
262
+ return true;
263
+ }
214
264
  setMode(mode) {
215
265
  this.notify({ mode });
216
266
  const hint = mode === "plan" ? "read-only: the engine investigates and proposes, edits nothing" : mode === "yolo" ? "no approval friction \u2014 the engine can do anything" : "edits auto-approved inside the workspace";
@@ -363,7 +413,9 @@ var Session = class {
363
413
  if (result.ok) {
364
414
  const cost = result.costUsd !== void 0 ? ` \xB7 $${result.costUsd.toFixed(2)}` : "";
365
415
  const secs = result.durationMs !== void 0 ? ` \xB7 ${(result.durationMs / 1e3).toFixed(0)}s` : "";
366
- const work = this.turnEdits > 0 ? ` \xB7 ${this.turnEdits} edit${this.turnEdits === 1 ? "" : "s"}` : this.turnTools > 0 ? ` \xB7 ${this.turnTools} tool call${this.turnTools === 1 ? "" : "s"}` : "";
416
+ const checkpoint = this.checkpoints.at(-1);
417
+ const stat = checkpoint ? diffStatSince(this.opts.cwd, checkpoint.snapshot) : null;
418
+ const work = stat ? ` \xB7 ${stat}` : this.turnEdits > 0 ? ` \xB7 ${this.turnEdits} edit${this.turnEdits === 1 ? "" : "s"}` : this.turnTools > 0 ? ` \xB7 ${this.turnTools} tool call${this.turnTools === 1 ? "" : "s"}` : "";
367
419
  this.push("status", `done${secs}${cost}${work}`);
368
420
  this.notify({
369
421
  totals: {
@@ -387,26 +439,22 @@ var Session = class {
387
439
  const gateResults = await runGates(this.opts.cwd, fastGates);
388
440
  const failures = gateResults.filter((r) => !r.ok);
389
441
  if (failures.length > 0) {
390
- this.pendingFix = {
391
- prompt: buildGatePrompt(failures),
392
- display: `\u26D1 fix ${failures.map((f) => f.gate.id).join(" + ")} errors`
393
- };
442
+ this.addProblem(
443
+ "gates",
444
+ failures.map((f) => f.gate.id).join(" + "),
445
+ buildGatePrompt(failures)
446
+ );
394
447
  this.push(
395
448
  "error",
396
449
  failures.map((f) => `\u2717 ${f.gate.id} \xB7 ${f.outputTail.split("\n").slice(-3).join("\n")}`).join("\n")
397
450
  );
398
- if (this.opts.autoFix && this.fixAttempts < MAX_AUTO_FIX_ATTEMPTS) {
399
- this.fixAttempts += 1;
400
- this.push("status", `auto-fix attempt ${this.fixAttempts}/${MAX_AUTO_FIX_ATTEMPTS}`);
401
- this.notify({ running: false });
402
- await this.runTurn(this.pendingFix.prompt, this.pendingFix.display);
403
- return;
404
- }
405
- this.push("status", "type /fix to send them to the engine");
451
+ if (this.maybeAutoFix()) return;
452
+ this.push("status", "/fix sends open problems to the engine \xB7 /problems lists them");
406
453
  this.notify({ running: false });
407
454
  this.drainQueue();
408
455
  return;
409
456
  }
457
+ this.clearProblems("gates");
410
458
  }
411
459
  }
412
460
  const dev = this.dev;
@@ -414,39 +462,28 @@ var Session = class {
414
462
  await delay(1500);
415
463
  const errors = dev.errorsSince(runStart);
416
464
  if (errors.length > 0) {
417
- this.pendingFix = {
418
- prompt: buildFixPrompt(errors, dev.tail(30)),
419
- display: "\u26D1 fix dev server errors"
420
- };
465
+ this.addProblem(
466
+ "dev",
467
+ `${errors.length} dev server error line(s)`,
468
+ buildFixPrompt(errors, dev.tail(30))
469
+ );
421
470
  this.push("error", `dev server: ${errors.length} error line(s)
422
471
  ${errors.slice(-5).join("\n")}`);
423
- if (this.opts.autoFix && this.fixAttempts < MAX_AUTO_FIX_ATTEMPTS) {
424
- this.fixAttempts += 1;
425
- this.push("status", `auto-fix attempt ${this.fixAttempts}/${MAX_AUTO_FIX_ATTEMPTS}`);
426
- this.notify({ running: false });
427
- await this.runTurn(this.pendingFix.prompt, this.pendingFix.display);
428
- return;
429
- }
430
- this.push("status", "type /fix to send them to the engine");
472
+ if (this.maybeAutoFix()) return;
473
+ this.push("status", "/fix sends open problems to the engine \xB7 /problems lists them");
431
474
  } else {
432
- this.pendingFix = null;
475
+ this.clearProblems("dev");
433
476
  if (this.opts.autoProbe !== false && this.state.devUrl) {
434
- const report = await probeRuntime(this.state.devUrl);
435
- const summary = report ? runtimeSummary(report) : null;
436
- if (report && summary) {
437
- this.pendingFix = {
438
- prompt: buildRuntimeFixPrompt(report),
439
- display: "\u26D1 fix runtime errors"
440
- };
477
+ const probe = await probeRuntime(this.state.devUrl, this.opts.cwd);
478
+ const summary = probe ? runtimeSummary(probe.report) : null;
479
+ if (probe && summary) {
480
+ this.addProblem("runtime", summary, buildRuntimeFixPrompt(probe.report));
441
481
  this.push("error", `runtime: ${summary}`);
442
- if (this.opts.autoFix && this.fixAttempts < MAX_AUTO_FIX_ATTEMPTS) {
443
- this.fixAttempts += 1;
444
- this.push("status", `auto-fix attempt ${this.fixAttempts}/${MAX_AUTO_FIX_ATTEMPTS}`);
445
- this.notify({ running: false });
446
- await this.runTurn(this.pendingFix.prompt, this.pendingFix.display);
447
- return;
448
- }
449
- this.push("status", "type /fix to send them to the engine");
482
+ if (this.maybeAutoFix()) return;
483
+ this.push("status", "/fix sends open problems to the engine \xB7 /problems lists them");
484
+ } else if (probe) {
485
+ this.clearProblems("runtime");
486
+ await this.visualPulse(probe.pulsePath);
450
487
  }
451
488
  }
452
489
  if (!this.reviewTipShown && this.state.devUrl) {
@@ -497,6 +534,32 @@ ${errors.slice(-5).join("\n")}`);
497
534
  this.push("error", `restore failed: ${result.detail ?? "unknown error"}`);
498
535
  }
499
536
  }
537
+ /**
538
+ * Cross-turn visual drift check: compare this turn's pulse screenshot
539
+ * with the previous one and report how much of the page changed.
540
+ * Informational — changes are usually intended; surprises shouldn't be.
541
+ */
542
+ async visualPulse(pulsePath) {
543
+ if (!pulsePath) return;
544
+ let current;
545
+ try {
546
+ current = (await import("fs")).readFileSync(pulsePath);
547
+ } catch {
548
+ return;
549
+ }
550
+ const previous = this.lastPulse;
551
+ this.lastPulse = current;
552
+ if (!previous) {
553
+ this.push("status", "visual pulse: baseline captured");
554
+ return;
555
+ }
556
+ const pct = await comparePulse(previous, current);
557
+ if (pct === null) return;
558
+ this.push(
559
+ "status",
560
+ pct < 0.5 ? "visual pulse: stable vs last turn" : `visual pulse: ${pct.toFixed(1)}% of the page changed vs last turn`
561
+ );
562
+ }
500
563
  /** Screenshot the running app (and watch its runtime where CDP is available). */
501
564
  async capture() {
502
565
  if (!this.state.devUrl) {
@@ -520,16 +583,28 @@ ${errors.slice(-5).join("\n")}`);
520
583
  const summary = runtimeSummary(result.runtime);
521
584
  if (summary) {
522
585
  this.push("error", `runtime: ${summary}`);
523
- this.pendingFix = { prompt: buildRuntimeFixPrompt(result.runtime), display: "\u26D1 fix runtime errors" };
524
- this.push("status", "type /fix to send them to the engine");
586
+ this.addProblem("runtime", summary, buildRuntimeFixPrompt(result.runtime));
587
+ this.push("status", "/fix sends open problems to the engine");
525
588
  } else {
589
+ this.clearProblems("runtime");
526
590
  this.push("status", "runtime clean \u2014 no console errors, exceptions, or failed requests");
527
591
  }
528
592
  }
529
593
  if (result.a11y && result.a11y.length > 0) {
530
594
  this.push("error", `a11y: ${result.a11y.length} finding(s)
531
595
  ${result.a11y.slice(0, 5).join("\n")}`);
532
- this.push("status", "/review folds these into the fix pass");
596
+ this.addProblem(
597
+ "a11y",
598
+ `${result.a11y.length} accessibility finding(s)`,
599
+ `Fix these accessibility defects found by an automated sweep of the running app:
600
+
601
+ ${result.a11y.join("\n")}
602
+
603
+ They are objective defects, not style preferences.`
604
+ );
605
+ this.push("status", "/review folds these into the fix pass \xB7 /fix sends them directly");
606
+ } else if (result.runtime) {
607
+ this.clearProblems("a11y");
533
608
  }
534
609
  return result.shots.length > 0 ? result : null;
535
610
  }
@@ -579,11 +654,79 @@ ${result.a11y.slice(0, 5).join("\n")}`);
579
654
  }
580
655
  break;
581
656
  }
582
- case "fix":
583
- if (!this.pendingFix) {
584
- this.push("status", "nothing to fix \u2014 no captured errors or failed gates");
657
+ case "fix": {
658
+ if (this.state.problems.length === 0) {
659
+ this.push("status", "nothing to fix \u2014 no open problems");
660
+ break;
661
+ }
662
+ const index = Number.parseInt(arg, 10);
663
+ if (arg && Number.isInteger(index)) {
664
+ const target = this.state.problems[index - 1];
665
+ if (!target) {
666
+ this.push("status", `usage: /fix [1\u2013${this.state.problems.length}] \u2014 see /problems`);
667
+ break;
668
+ }
669
+ this.dispatchFix([target]);
670
+ } else {
671
+ this.dispatchFix([...this.state.problems]);
672
+ }
673
+ break;
674
+ }
675
+ case "save": {
676
+ void (async () => {
677
+ const fs2 = await import("fs");
678
+ const path4 = await import("path");
679
+ const dir = path4.join(this.opts.cwd, ".squint", "transcripts");
680
+ fs2.mkdirSync(dir, { recursive: true });
681
+ const stamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-").slice(0, 19);
682
+ const file = path4.join(dir, `${stamp}.md`);
683
+ const lines = [`# squint session \u2014 ${(/* @__PURE__ */ new Date()).toISOString().slice(0, 16)}`, ""];
684
+ for (const item of this.state.items) {
685
+ switch (item.role) {
686
+ case "user":
687
+ lines.push(`## \u276F ${item.text}`, "");
688
+ break;
689
+ case "assistant":
690
+ lines.push(item.text, "");
691
+ break;
692
+ case "tool":
693
+ lines.push(`- \u2699 ${item.text}`);
694
+ break;
695
+ case "thinking":
696
+ break;
697
+ default:
698
+ lines.push(`> ${item.role === "error" ? "\u2717 " : ""}${item.text.split("\n").join("\n> ")}`);
699
+ }
700
+ }
701
+ lines.push("", `> ${this.summary()}`);
702
+ fs2.writeFileSync(file, lines.join("\n") + "\n");
703
+ this.push("status", `saved transcript \u2192 ${path4.relative(this.opts.cwd, file)}`);
704
+ })();
705
+ break;
706
+ }
707
+ case "copy": {
708
+ const last = this.state.items.findLast((i) => i.role === "assistant");
709
+ if (!last) {
710
+ this.push("status", "nothing to copy yet");
711
+ break;
712
+ }
713
+ void import("child_process").then(({ spawn }) => {
714
+ const cmd = process.platform === "darwin" ? spawn("pbcopy") : process.platform === "win32" ? spawn("clip") : spawn("xclip", ["-selection", "clipboard"]);
715
+ cmd.on("error", () => this.push("error", "no clipboard tool found"));
716
+ cmd.on("close", (code) => {
717
+ if (code === 0) this.push("status", `copied last reply (${last.text.length} chars)`);
718
+ });
719
+ cmd.stdin?.end(last.text);
720
+ });
721
+ break;
722
+ }
723
+ case "problems":
724
+ if (this.state.problems.length === 0) {
725
+ this.push("status", "no open problems");
585
726
  } else {
586
- void this.runTurn(this.pendingFix.prompt, this.pendingFix.display);
727
+ const lines = this.state.problems.map((p, i) => `${i + 1}. [${p.source}] ${p.summary}`);
728
+ this.push("status", `${lines.join("\n")}
729
+ /fix sends all \xB7 /fix <n> targets one`);
587
730
  }
588
731
  break;
589
732
  case "check":
@@ -605,12 +748,10 @@ ${result.a11y.slice(0, 5).join("\n")}`);
605
748
  this.drainQueue();
606
749
  const failures = results.filter((r) => !r.ok);
607
750
  if (failures.length > 0) {
608
- this.pendingFix = {
609
- prompt: buildGatePrompt(failures),
610
- display: `\u26D1 fix failing gates: ${failures.map((f) => f.gate.id).join(", ")}`
611
- };
612
- this.push("status", "type /fix to send failures to the engine");
751
+ this.addProblem("gates", failures.map((f) => f.gate.id).join(" + "), buildGatePrompt(failures));
752
+ this.push("status", "/fix sends open problems to the engine \xB7 /problems lists them");
613
753
  } else {
754
+ this.clearProblems("gates");
614
755
  this.push("status", "all gates passed");
615
756
  }
616
757
  })();
@@ -751,12 +892,10 @@ ${result.a11y.slice(0, 5).join("\n")}`);
751
892
  clearState(this.opts.cwd);
752
893
  this.notify({ items: [], totals: { costUsd: 0, turns: 0 } });
753
894
  break;
754
- case "help":
755
- this.push(
756
- "status",
757
- "/engine <id> \xB7 /model <name> \xB7 /mode plan|safe|yolo \xB7 /dev \xB7 /check (gates) \xB7 /fix \xB7 /shot \xB7 /review [focus] \xB7 /variants <2-4> <ask> \xB7 /undo \xB7 /checkpoints \xB7 /restore <n> \xB7 /resume \xB7 /clear \xB7 /quit"
758
- );
895
+ case "help": {
896
+ void import("./commands-6XDXUVO6.js").then(({ commandHelp }) => this.push("status", commandHelp()));
759
897
  break;
898
+ }
760
899
  case "quit":
761
900
  case "exit":
762
901
  this.push("status", this.summary());
@@ -1142,6 +1281,13 @@ function App({
1142
1281
  session.cycleMode();
1143
1282
  return;
1144
1283
  }
1284
+ if (key.tab && line.text.startsWith("/") && !line.text.includes(" ")) {
1285
+ const matches = completeCommand(line.text.slice(1));
1286
+ if (matches.length > 0) {
1287
+ setLine(fromText(`/${matches[0].name}${matches[0].args ? " " : ""}`));
1288
+ }
1289
+ return;
1290
+ }
1145
1291
  if (key.return) {
1146
1292
  const value = line.text.trim();
1147
1293
  setLine(emptyLine);
@@ -1225,11 +1371,26 @@ function App({
1225
1371
  return /* @__PURE__ */ jsx3(ThemeProvider, { value: theme2, children: /* @__PURE__ */ jsxs3(Box2, { flexDirection: "column", paddingX: 1, children: [
1226
1372
  /* @__PURE__ */ jsx3(Static, { items: state.items, children: (item) => /* @__PURE__ */ jsx3(Box2, { children: /* @__PURE__ */ jsx3(MessageLine, { message: item }) }, item.id) }),
1227
1373
  state.liveText.length > 0 && /* @__PURE__ */ jsx3(Box2, { children: /* @__PURE__ */ jsx3(Markdown, { text: state.liveText }) }),
1374
+ state.items.length === 0 && state.liveText.length === 0 && !state.running && /* @__PURE__ */ jsxs3(Box2, { flexDirection: "column", marginTop: 1, children: [
1375
+ /* @__PURE__ */ jsx3(Text3, { color: theme2.dim, children: "describe what to build \u2014 or try:" }),
1376
+ /* @__PURE__ */ jsx3(Text3, { color: theme2.dim, children: " /dev start the preview server \xB7 /review after a change \xB7 /variants 3 explore wide" }),
1377
+ /* @__PURE__ */ jsx3(Text3, { color: theme2.dim, children: " shift+tab cycles plan/safe/yolo \xB7 type while the agent works to queue asks" })
1378
+ ] }),
1228
1379
  state.running && /* @__PURE__ */ jsx3(Box2, { marginTop: 1, children: /* @__PURE__ */ jsx3(WorkingLine, { startedAt: state.runStartedAt }) }),
1229
1380
  state.queue.map((queued, index) => /* @__PURE__ */ jsx3(Box2, { children: /* @__PURE__ */ jsxs3(Text3, { color: theme2.dim, children: [
1230
1381
  "\u22EF queued: ",
1231
1382
  queued
1232
1383
  ] }) }, index)),
1384
+ line.text.startsWith("/") && !line.text.includes(" ") && /* @__PURE__ */ jsx3(Box2, { flexDirection: "column", children: completeCommand(line.text.slice(1)).slice(0, 5).map((command, index) => /* @__PURE__ */ jsxs3(Text3, { color: index === 0 ? theme2.accent : theme2.dim, children: [
1385
+ "/",
1386
+ command.name,
1387
+ command.args ? ` ${command.args}` : "",
1388
+ " ",
1389
+ /* @__PURE__ */ jsxs3(Text3, { color: theme2.dim, children: [
1390
+ "\u2014 ",
1391
+ command.description
1392
+ ] })
1393
+ ] }, command.name)) }),
1233
1394
  /* @__PURE__ */ jsx3(Box2, { marginTop: state.running ? 0 : 1, children: /* @__PURE__ */ jsxs3(Text3, { children: [
1234
1395
  /* @__PURE__ */ jsx3(Text3, { color: theme2.accent, children: "\u276F " }),
1235
1396
  line.text.slice(0, line.cursor),
@@ -1256,14 +1417,21 @@ function App({
1256
1417
  path3.basename(cwd),
1257
1418
  devBadge,
1258
1419
  totalsBadge,
1259
- " \xB7 shift+tab mode \xB7 /help"
1420
+ state.problems.length > 0 && /* @__PURE__ */ jsxs3(Text3, { color: theme2.error, children: [
1421
+ " \xB7 ",
1422
+ state.problems.length,
1423
+ " problem",
1424
+ state.problems.length === 1 ? "" : "s"
1425
+ ] }),
1426
+ " ",
1427
+ "\xB7 shift+tab mode \xB7 /help"
1260
1428
  ] }) })
1261
1429
  ] }) });
1262
1430
  }
1263
1431
 
1264
1432
  // src/cli.tsx
1265
1433
  import { jsx as jsx4 } from "react/jsx-runtime";
1266
- var VERSION = "0.2.1";
1434
+ var VERSION = "0.2.3";
1267
1435
  var program = new Command();
1268
1436
  program.name("squint").description("Lovable for your terminal \u2014 a frontend harness on top of Claude Code, Codex, and friends.").version(VERSION);
1269
1437
  program.command("run").description("Run one prompt headlessly and stream the result").argument("<prompt...>", "what to build or change").option("-e, --engine <id>", "engine to use (claude, codex, gemini, opencode)").option("-m, --model <name>", "model override for the engine").option("--no-brief", "send the prompt without the squint design brief").option("--json", "emit normalized agent events as ndjson").action(
@@ -1330,7 +1498,7 @@ program.command("doctor").description("Check squint prerequisites and engine ava
1330
1498
  }
1331
1499
  }
1332
1500
  const { findChrome: findChrome2 } = await import("./chrome-RD7XQ767.js");
1333
- const { hasWebSocket } = await import("./cdp-RTWPFIX5.js");
1501
+ const { hasWebSocket } = await import("./cdp-SFWNP7OA.js");
1334
1502
  const chrome = findChrome2();
1335
1503
  console.log(
1336
1504
  chrome ? `${pc.green("\u2713")} Chrome ${pc.dim(chrome)}` : `${pc.yellow("\u25CB")} Chrome ${pc.dim("\u2014 screenshots and runtime probing disabled")}`
@@ -1374,7 +1542,7 @@ Next: ${pc.bold(`${cd}${options.install ? "" : "npm install && "}squint`)}`);
1374
1542
  });
1375
1543
  var skillsCommand = program.command("skills").description("Project knowledge injected into asks (.squint/rules.md + .squint/skills/)");
1376
1544
  skillsCommand.command("list").description("Show always-on rules and trigger-matched skills").action(async () => {
1377
- const { loadRules, loadSkills } = await import("./skills-UGHU22BS.js");
1545
+ const { loadRules, loadSkills } = await import("./skills-DNBHZAAU.js");
1378
1546
  const cwd = process.cwd();
1379
1547
  const rules = loadRules(cwd);
1380
1548
  console.log(rules ? `${pc.green("\u2713")} rules.md ${pc.dim(`(${rules.split("\n").length} lines, always on)`)}` : pc.dim("\u25CB no .squint/rules.md"));
@@ -1450,7 +1618,7 @@ variantsCommand.command("gen").description("Generate n variants of one ask in pa
1450
1618
  process.exitCode = 1;
1451
1619
  return;
1452
1620
  }
1453
- const { isGitRepo: isGitRepo2 } = await import("./snapshot-KKUY5RR6.js");
1621
+ const { isGitRepo: isGitRepo2 } = await import("./snapshot-H6VYFHLN.js");
1454
1622
  if (!isGitRepo2(cwd)) {
1455
1623
  console.error(pc.red("\u2717 variants need a git repo with at least one commit"));
1456
1624
  process.exitCode = 1;
@@ -1560,7 +1728,7 @@ program.command("check").description("Run this project\u2019s quality gates (typ
1560
1728
  if (results.some((r) => !r.ok)) process.exitCode = 1;
1561
1729
  });
1562
1730
  program.command("shot").description("Screenshot a running app at mobile/tablet/desktop viewports").argument("<url>", "URL of the running app (e.g. http://localhost:5173)").action(async (url) => {
1563
- const { captureViewports: captureViewports2 } = await import("./preview-XQ7EOHV4.js");
1731
+ const { captureViewports: captureViewports2 } = await import("./preview-UX4VS4SX.js");
1564
1732
  const result = await captureViewports2(process.cwd(), url);
1565
1733
  if (!result) {
1566
1734
  console.error(pc.red("\u2717 no Chrome/Chromium found"));
@@ -0,0 +1,11 @@
1
+ #!/usr/bin/env node
2
+ import {
3
+ COMMANDS,
4
+ commandHelp,
5
+ completeCommand
6
+ } from "./chunk-EECGXFRX.js";
7
+ export {
8
+ COMMANDS,
9
+ commandHelp,
10
+ completeCommand
11
+ };
@@ -4,11 +4,12 @@ import {
4
4
  buildReviewPrompt,
5
5
  buildRuntimeFixPrompt,
6
6
  captureViewports,
7
+ comparePulse,
7
8
  previewDir,
8
9
  probeRuntime,
9
10
  runtimeSummary
10
- } from "./chunk-REDPR3KI.js";
11
- import "./chunk-VJGE7HSP.js";
11
+ } from "./chunk-SU6YRRMF.js";
12
+ import "./chunk-OC6RU6XH.js";
12
13
  import "./chunk-P3V5CWH5.js";
13
14
  import "./chunk-2LRIKWBU.js";
14
15
  export {
@@ -16,6 +17,7 @@ export {
16
17
  buildReviewPrompt,
17
18
  buildRuntimeFixPrompt,
18
19
  captureViewports,
20
+ comparePulse,
19
21
  previewDir,
20
22
  probeRuntime,
21
23
  runtimeSummary
@@ -1,13 +1,15 @@
1
1
  #!/usr/bin/env node
2
2
  import {
3
3
  enrich,
4
+ loadLocks,
4
5
  loadRules,
5
6
  loadSkills,
6
7
  matchSkills,
7
8
  parseSkill
8
- } from "./chunk-XZKQZKEE.js";
9
+ } from "./chunk-4LAU5TGK.js";
9
10
  export {
10
11
  enrich,
12
+ loadLocks,
11
13
  loadRules,
12
14
  loadSkills,
13
15
  matchSkills,
@@ -1,10 +1,12 @@
1
1
  #!/usr/bin/env node
2
2
  import {
3
+ diffStatSince,
3
4
  isGitRepo,
4
5
  restoreSnapshot,
5
6
  takeSnapshot
6
- } from "./chunk-WTA6YYBY.js";
7
+ } from "./chunk-YHRAOBI2.js";
7
8
  export {
9
+ diffStatSince,
8
10
  isGitRepo,
9
11
  restoreSnapshot,
10
12
  takeSnapshot
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aayambansal/squint",
3
- "version": "0.2.1",
3
+ "version": "0.2.3",
4
4
  "description": "Lovable for your terminal — a frontend harness on top of Claude Code, Codex, and other coding agents.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -1,9 +0,0 @@
1
- #!/usr/bin/env node
2
- import {
3
- cdpCapture,
4
- hasWebSocket
5
- } from "./chunk-VJGE7HSP.js";
6
- export {
7
- cdpCapture,
8
- hasWebSocket
9
- };