@aayambansal/squint 0.1.3 → 0.2.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/dist/cli.js CHANGED
@@ -2,19 +2,27 @@
2
2
  import {
3
3
  DevServer,
4
4
  buildFixPrompt,
5
- detectDevCommand
6
- } from "./chunk-FQDSJRTY.js";
5
+ detectDevCommand,
6
+ screenshotVariants
7
+ } from "./chunk-2IW2MMCY.js";
7
8
  import {
8
- runAgent
9
- } from "./chunk-MGHJSERZ.js";
9
+ applyVariant,
10
+ cleanVariants,
11
+ listVariants,
12
+ runVariants
13
+ } from "./chunk-LCS47EAG.js";
10
14
  import {
11
15
  composePrompt
12
- } from "./chunk-NT2HR4RD.js";
16
+ } from "./chunk-P3H4N2EN.js";
17
+ import {
18
+ runAgent
19
+ } from "./chunk-OTG4ZB4R.js";
13
20
  import {
14
21
  buildGatePrompt,
22
+ detectFastGates,
15
23
  detectGates,
16
24
  runGates
17
- } from "./chunk-43VZK47W.js";
25
+ } from "./chunk-DLCRYRWQ.js";
18
26
  import {
19
27
  buildReviewPrompt,
20
28
  buildRuntimeFixPrompt,
@@ -24,15 +32,20 @@ import {
24
32
  probeRuntime,
25
33
  runtimeSummary,
26
34
  saveState
27
- } from "./chunk-MOYLED4S.js";
28
- import "./chunk-ABF4QAMZ.js";
29
- import "./chunk-MURKQ7SI.js";
35
+ } from "./chunk-REDPR3KI.js";
36
+ import "./chunk-VJGE7HSP.js";
37
+ import {
38
+ findChrome
39
+ } from "./chunk-P3V5CWH5.js";
30
40
  import {
31
41
  detectEngines,
32
- engines,
33
42
  getEngine
34
- } from "./chunk-YIVPCWSG.js";
43
+ } from "./chunk-2LRIKWBU.js";
44
+ import {
45
+ enrich
46
+ } from "./chunk-XZKQZKEE.js";
35
47
  import {
48
+ isGitRepo,
36
49
  restoreSnapshot,
37
50
  takeSnapshot
38
51
  } from "./chunk-WTA6YYBY.js";
@@ -57,7 +70,13 @@ var ConfigSchema = z.object({
57
70
  /** Automatically send dev-server errors back to the engine (max 2 attempts). */
58
71
  autoFix: z.boolean().optional(),
59
72
  /** Probe the running app's runtime after each clean turn (default on). */
60
- autoProbe: z.boolean().optional()
73
+ autoProbe: z.boolean().optional(),
74
+ /** Run typecheck+lint after every turn (default on where detected). */
75
+ autoCheck: z.boolean().optional(),
76
+ /** Terminal bell when a turn finishes (default on). */
77
+ bell: z.boolean().optional(),
78
+ /** TUI theme name (amber, ocean, moss, rose, mono). */
79
+ theme: z.string().optional()
61
80
  });
62
81
  function defaultPaths(cwd) {
63
82
  const xdg = process.env.XDG_CONFIG_HOME;
@@ -98,9 +117,9 @@ function resolveModel(config, engineId, override) {
98
117
  function setConfigValue(file, key, value) {
99
118
  const current = readConfigFile(file);
100
119
  let next;
101
- if (key === "engine") {
102
- next = { ...current, engine: value };
103
- } else if (key === "autoDev" || key === "autoFix" || key === "autoProbe") {
120
+ if (key === "engine" || key === "theme") {
121
+ next = { ...current, [key]: value };
122
+ } else if (key === "autoDev" || key === "autoFix" || key === "autoProbe" || key === "autoCheck" || key === "bell") {
104
123
  if (value !== "true" && value !== "false") {
105
124
  throw new Error(`"${key}" must be true or false`);
106
125
  }
@@ -110,7 +129,9 @@ function setConfigValue(file, key, value) {
110
129
  if (!engineId) throw new Error("Usage: squint config set models.<engineId> <model>");
111
130
  next = { ...current, models: { ...current.models, [engineId]: value } };
112
131
  } else {
113
- throw new Error(`Unknown config key "${key}". Supported: engine, autoDev, autoFix, autoProbe, models.<engineId>`);
132
+ throw new Error(
133
+ `Unknown config key "${key}". Supported: engine, theme, autoDev, autoFix, autoProbe, autoCheck, bell, models.<engineId>`
134
+ );
114
135
  }
115
136
  fs.mkdirSync(path.dirname(file), { recursive: true });
116
137
  fs.writeFileSync(file, JSON.stringify(next, null, 2) + "\n");
@@ -118,132 +139,39 @@ function setConfigValue(file, key, value) {
118
139
  }
119
140
 
120
141
  // src/tui/App.tsx
121
- import { Box, Static, Text as Text2, useApp, useInput } from "ink";
122
- import path2 from "path";
123
- import { useCallback, useEffect as useEffect2, useRef, useState as useState2 } from "react";
124
-
125
- // src/tui/messages.tsx
126
- import { Text } from "ink";
127
- import { useEffect, useState } from "react";
128
-
129
- // src/tui/theme.ts
130
- var theme = {
131
- accent: "#e8a33d",
132
- dim: "gray",
133
- user: "#7aa2f7",
134
- error: "#f7768e",
135
- success: "#9ece6a",
136
- tool: "#7dcfff"
137
- };
138
-
139
- // src/tui/messages.tsx
140
- import { jsx, jsxs } from "react/jsx-runtime";
141
- var SPINNER_FRAMES = ["\u280B", "\u2819", "\u2839", "\u2838", "\u283C", "\u2834", "\u2826", "\u2827", "\u2807", "\u280F"];
142
- function WorkingLine({ startedAt }) {
143
- const [frame, setFrame] = useState(0);
144
- const [elapsed, setElapsed] = useState(0);
145
- useEffect(() => {
146
- const timer = setInterval(() => {
147
- setFrame((f) => (f + 1) % SPINNER_FRAMES.length);
148
- setElapsed(Math.floor((Date.now() - startedAt) / 1e3));
149
- }, 80);
150
- return () => clearInterval(timer);
151
- }, [startedAt]);
152
- return /* @__PURE__ */ jsxs(Text, { children: [
153
- /* @__PURE__ */ jsx(Text, { color: theme.accent, children: SPINNER_FRAMES[frame] }),
154
- /* @__PURE__ */ jsxs(Text, { color: theme.dim, children: [
155
- " ",
156
- "working\u2026 ",
157
- elapsed,
158
- "s \xB7 esc to interrupt"
159
- ] })
160
- ] });
161
- }
162
- function MessageLine({ message }) {
163
- switch (message.role) {
164
- case "user":
165
- return /* @__PURE__ */ jsxs(Text, { color: theme.user, wrap: "wrap", children: [
166
- "\u276F ",
167
- message.text
168
- ] });
169
- case "assistant":
170
- return /* @__PURE__ */ jsx(Text, { wrap: "wrap", children: message.text });
171
- case "status":
172
- return /* @__PURE__ */ jsxs(Text, { color: theme.dim, wrap: "wrap", children: [
173
- "\xB7 ",
174
- message.text
175
- ] });
176
- case "tool":
177
- return /* @__PURE__ */ jsxs(Text, { color: theme.tool, wrap: "wrap", children: [
178
- "\u2699 ",
179
- message.text
180
- ] });
181
- case "thinking":
182
- return /* @__PURE__ */ jsx(Text, { color: theme.dim, italic: true, wrap: "wrap", children: message.text });
183
- case "error":
184
- return /* @__PURE__ */ jsxs(Text, { color: theme.error, wrap: "wrap", children: [
185
- "\u2717 ",
186
- message.text
187
- ] });
188
- }
189
- }
142
+ import { Box as Box2, Static, Text as Text3, useApp, useInput } from "ink";
143
+ import path3 from "path";
144
+ import { useMemo, useRef, useState as useState2, useSyncExternalStore } from "react";
190
145
 
191
- // src/tui/App.tsx
192
- import { jsx as jsx2, jsxs as jsxs2 } from "react/jsx-runtime";
146
+ // src/session/engine.ts
147
+ import path2 from "path";
193
148
  var MAX_AUTO_FIX_ATTEMPTS = 2;
194
149
  var delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
195
- function App({ cwd, initialEngine, initialModel, autoDev, autoFix, autoProbe }) {
196
- const { exit } = useApp();
197
- const [messages, setMessages] = useState2([]);
198
- const [input, setInput] = useState2("");
199
- const [running, setRunning] = useState2(false);
200
- const [runStartedAt, setRunStartedAt] = useState2(0);
201
- const [engineId, setEngineId] = useState2(initialEngine);
202
- const [model, setModel] = useState2(initialModel);
203
- const [liveText, setLiveText] = useState2("");
204
- const [devState, setDevState] = useState2("stopped");
205
- const [devUrl, setDevUrl] = useState2(null);
206
- const liveRef = useRef("");
207
- const idRef = useRef(0);
208
- const sessionRef = useRef(void 0);
209
- const devRef = useRef(null);
210
- const abortRef = useRef(null);
211
- const pendingFixRef = useRef(null);
212
- const snapshotRef = useRef(null);
213
- const fixAttemptsRef = useRef(0);
214
- const reviewTipShownRef = useRef(false);
215
- const historyRef = useRef([]);
216
- const historyIndexRef = useRef(-1);
217
- const push = useCallback((role, text) => {
218
- idRef.current += 1;
219
- setMessages((prev) => [...prev, { id: idRef.current, role, text }]);
220
- }, []);
221
- const commitLive = useCallback(() => {
222
- if (liveRef.current.length > 0) {
223
- push("assistant", liveRef.current);
224
- liveRef.current = "";
225
- setLiveText("");
226
- }
227
- }, [push]);
228
- const getDevServer = useCallback(() => {
229
- if (!devRef.current) {
230
- devRef.current = new DevServer(cwd, {
231
- onStateChange: setDevState,
232
- onUrl: setDevUrl
233
- });
234
- }
235
- return devRef.current;
236
- }, [cwd]);
237
- useEffect2(() => {
238
- if (autoDev && detectDevCommand(cwd)) {
239
- getDevServer().start();
150
+ var Session = class {
151
+ constructor(opts) {
152
+ this.opts = opts;
153
+ this.state = {
154
+ items: [],
155
+ liveText: "",
156
+ running: false,
157
+ runStartedAt: 0,
158
+ engineId: opts.engineId,
159
+ model: opts.model,
160
+ devState: "stopped",
161
+ devUrl: null,
162
+ totals: { costUsd: 0, turns: 0 },
163
+ queue: [],
164
+ mode: "safe"
165
+ };
166
+ if (opts.autoDev && detectDevCommand(opts.cwd)) {
167
+ this.devServer().start();
240
168
  }
241
- const saved = loadState(cwd);
169
+ const saved = loadState(opts.cwd);
242
170
  if (saved) {
243
171
  try {
244
172
  if (getEngine(saved.engine).supportsResume) {
245
173
  const mins = Math.max(1, Math.round((Date.now() - saved.at) / 6e4));
246
- push(
174
+ this.push(
247
175
  "status",
248
176
  `previous session (${mins}m ago${saved.lastAsk ? ` \xB7 "${saved.lastAsk}"` : ""}) \u2014 /resume to continue`
249
177
  );
@@ -251,353 +179,988 @@ function App({ cwd, initialEngine, initialModel, autoDev, autoFix, autoProbe })
251
179
  } catch {
252
180
  }
253
181
  }
254
- return () => devRef.current?.stop();
255
- }, [autoDev, cwd, getDevServer, push]);
256
- const handleEvent = useCallback(
257
- (event) => {
258
- switch (event.type) {
259
- case "status":
260
- commitLive();
261
- push("status", event.text);
262
- break;
263
- case "delta":
264
- liveRef.current += event.text;
265
- setLiveText(liveRef.current);
266
- break;
267
- case "text":
268
- if (event.streamed) {
269
- liveRef.current = "";
270
- setLiveText("");
271
- push("assistant", event.text);
272
- } else {
273
- liveRef.current += (liveRef.current.length > 0 ? "\n" : "") + event.text;
274
- setLiveText(liveRef.current);
275
- }
276
- break;
277
- case "thinking":
278
- commitLive();
279
- push("thinking", event.text);
280
- break;
281
- case "tool":
282
- commitLive();
283
- push("tool", event.detail ? `${event.name} \xB7 ${event.detail}` : event.name);
284
- break;
285
- case "error":
286
- commitLive();
287
- push("error", event.text);
288
- break;
289
- case "result":
290
- if (event.sessionId) sessionRef.current = event.sessionId;
291
- break;
292
- case "raw":
293
- break;
182
+ }
183
+ opts;
184
+ state;
185
+ listeners = /* @__PURE__ */ new Set();
186
+ nextId = 0;
187
+ live = "";
188
+ sessionId;
189
+ dev = null;
190
+ abort = null;
191
+ pendingFix = null;
192
+ checkpoints = [];
193
+ fixAttempts = 0;
194
+ reviewTipShown = false;
195
+ startedAt = Date.now();
196
+ subscribe(listener) {
197
+ this.listeners.add(listener);
198
+ return () => this.listeners.delete(listener);
199
+ }
200
+ getState() {
201
+ return this.state;
202
+ }
203
+ dispose() {
204
+ this.abort?.abort();
205
+ this.dev?.stop();
206
+ }
207
+ interrupt() {
208
+ this.abort?.abort();
209
+ }
210
+ /** Frontend-originated status line (view-level commands like /theme). */
211
+ note(text) {
212
+ this.push("status", text);
213
+ }
214
+ setMode(mode) {
215
+ this.notify({ mode });
216
+ 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";
217
+ this.push("status", `mode \u2192 ${mode} \xB7 ${hint}`);
218
+ }
219
+ cycleMode() {
220
+ const order = ["safe", "plan", "yolo"];
221
+ const next = order[(order.indexOf(this.state.mode) + 1) % order.length];
222
+ this.setMode(next);
223
+ }
224
+ /** One-line goodbye: what this session amounted to. */
225
+ summary() {
226
+ const mins = Math.max(1, Math.round((Date.now() - this.startedAt) / 6e4));
227
+ const { turns, costUsd } = this.state.totals;
228
+ const parts = [`${turns} turn${turns === 1 ? "" : "s"}`];
229
+ if (costUsd > 0) parts.push(`$${costUsd.toFixed(2)}`);
230
+ parts.push(`${mins}m`);
231
+ return `session: ${parts.join(" \xB7 ")}`;
232
+ }
233
+ /**
234
+ * Route one line of user input: slash command or an ask for the engine.
235
+ * Asks arriving mid-turn queue up and dispatch in order once the
236
+ * current turn (including its fix cycle) settles.
237
+ */
238
+ input(raw) {
239
+ const value = raw.trim();
240
+ if (value.length === 0) return;
241
+ if (this.state.running) {
242
+ if (value === "/queue clear") {
243
+ this.notify({ queue: [] });
244
+ this.push("status", "queue cleared");
245
+ return;
294
246
  }
295
- },
296
- [push, commitLive]
297
- );
298
- const runTurn = useCallback(
299
- async (prompt, display) => {
300
- push("user", display);
301
- setRunning(true);
302
- setRunStartedAt(Date.now());
303
- const runStart = Date.now();
304
- const engine = getEngine(engineId);
305
- const abort = new AbortController();
306
- abortRef.current = abort;
307
- const result = await runAgent(
308
- engine,
309
- {
310
- prompt,
311
- cwd,
312
- model,
313
- sessionId: engine.supportsResume ? sessionRef.current : void 0
314
- },
315
- handleEvent,
316
- abort.signal
317
- );
318
- abortRef.current = null;
319
- commitLive();
320
- if (result.ok) {
321
- const cost = result.costUsd !== void 0 ? ` \xB7 $${result.costUsd.toFixed(2)}` : "";
322
- const secs = result.durationMs !== void 0 ? ` \xB7 ${(result.durationMs / 1e3).toFixed(0)}s` : "";
323
- push("status", `done${secs}${cost}`);
324
- if (sessionRef.current) {
325
- saveState(cwd, {
326
- engine: engineId,
327
- sessionId: sessionRef.current,
328
- model,
329
- lastAsk: display.length > 80 ? `${display.slice(0, 79)}\u2026` : display,
330
- at: Date.now()
331
- });
247
+ this.notify({ queue: [...this.state.queue, value] });
248
+ return;
249
+ }
250
+ if (value.startsWith("/")) {
251
+ this.command(value);
252
+ } else {
253
+ void this.submit(value);
254
+ }
255
+ }
256
+ /** Dispatch queued input after the current work settles. */
257
+ drainQueue() {
258
+ if (this.state.running) return;
259
+ const [next, ...rest] = this.state.queue;
260
+ if (next === void 0) return;
261
+ this.notify({ queue: rest });
262
+ this.input(next);
263
+ }
264
+ // ---------- internals ----------
265
+ notify(patch) {
266
+ this.state = { ...this.state, ...patch };
267
+ for (const listener of this.listeners) listener();
268
+ }
269
+ push(role, text) {
270
+ this.nextId += 1;
271
+ this.notify({ items: [...this.state.items, { id: this.nextId, role, text }] });
272
+ }
273
+ setLive(text) {
274
+ this.live = text;
275
+ this.notify({ liveText: text });
276
+ }
277
+ /**
278
+ * Static transcript items are immutable once rendered, so in-progress
279
+ * assistant text accumulates in a live buffer and commits as one block.
280
+ */
281
+ commitLive() {
282
+ if (this.live.length > 0) {
283
+ const text = this.live;
284
+ this.live = "";
285
+ this.state = { ...this.state, liveText: "" };
286
+ this.push("assistant", text);
287
+ }
288
+ }
289
+ devServer() {
290
+ if (!this.dev) {
291
+ this.dev = new DevServer(this.opts.cwd, {
292
+ onStateChange: (devState) => this.notify({ devState }),
293
+ onUrl: (url) => this.notify({ devUrl: url })
294
+ });
295
+ }
296
+ return this.dev;
297
+ }
298
+ turnEdits = 0;
299
+ turnTools = 0;
300
+ handleEvent = (event) => {
301
+ switch (event.type) {
302
+ case "status":
303
+ this.commitLive();
304
+ this.push("status", event.text);
305
+ break;
306
+ case "delta":
307
+ this.setLive(this.live + event.text);
308
+ break;
309
+ case "text":
310
+ if (event.streamed) {
311
+ this.live = "";
312
+ this.state = { ...this.state, liveText: "" };
313
+ this.push("assistant", event.text);
314
+ } else {
315
+ this.setLive(this.live + (this.live.length > 0 ? "\n" : "") + event.text);
332
316
  }
317
+ break;
318
+ case "thinking":
319
+ this.commitLive();
320
+ this.push("thinking", event.text);
321
+ break;
322
+ case "tool": {
323
+ this.commitLive();
324
+ this.turnTools += 1;
325
+ if (/edit|write|patch|apply/i.test(event.name)) this.turnEdits += 1;
326
+ this.push("tool", event.detail ? `${event.name} \xB7 ${event.detail}` : event.name);
327
+ break;
333
328
  }
334
- const dev = devRef.current;
335
- if (result.error !== "interrupted" && dev && (dev.state === "running" || dev.state === "starting")) {
336
- await delay(1500);
337
- const errors = dev.errorsSince(runStart);
338
- if (errors.length > 0) {
339
- pendingFixRef.current = {
340
- prompt: buildFixPrompt(errors, dev.tail(30)),
341
- display: "\u26D1 fix dev server errors"
329
+ case "error":
330
+ this.commitLive();
331
+ this.push("error", event.text);
332
+ break;
333
+ case "result":
334
+ if (event.sessionId) this.sessionId = event.sessionId;
335
+ break;
336
+ case "raw":
337
+ break;
338
+ }
339
+ };
340
+ /** Run one engine turn. `display` is what the transcript shows as the ask. */
341
+ async runTurn(prompt, display) {
342
+ this.push("user", display);
343
+ this.turnEdits = 0;
344
+ this.turnTools = 0;
345
+ this.notify({ running: true, runStartedAt: Date.now() });
346
+ const runStart = Date.now();
347
+ const engine = getEngine(this.state.engineId);
348
+ this.abort = new AbortController();
349
+ const result = await runAgent(
350
+ engine,
351
+ {
352
+ prompt,
353
+ cwd: this.opts.cwd,
354
+ model: this.state.model,
355
+ mode: this.state.mode,
356
+ sessionId: engine.supportsResume ? this.sessionId : void 0
357
+ },
358
+ this.handleEvent,
359
+ this.abort.signal
360
+ );
361
+ this.abort = null;
362
+ this.commitLive();
363
+ if (result.ok) {
364
+ const cost = result.costUsd !== void 0 ? ` \xB7 $${result.costUsd.toFixed(2)}` : "";
365
+ 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"}` : "";
367
+ this.push("status", `done${secs}${cost}${work}`);
368
+ this.notify({
369
+ totals: {
370
+ costUsd: this.state.totals.costUsd + (result.costUsd ?? 0),
371
+ turns: this.state.totals.turns + 1
372
+ }
373
+ });
374
+ if (this.sessionId) {
375
+ saveState(this.opts.cwd, {
376
+ engine: this.state.engineId,
377
+ sessionId: this.sessionId,
378
+ model: this.state.model,
379
+ lastAsk: display.length > 80 ? `${display.slice(0, 79)}\u2026` : display,
380
+ at: Date.now()
381
+ });
382
+ }
383
+ }
384
+ if (result.ok && this.opts.autoCheck !== false) {
385
+ const fastGates = detectFastGates(this.opts.cwd);
386
+ if (fastGates.length > 0) {
387
+ const gateResults = await runGates(this.opts.cwd, fastGates);
388
+ const failures = gateResults.filter((r) => !r.ok);
389
+ if (failures.length > 0) {
390
+ this.pendingFix = {
391
+ prompt: buildGatePrompt(failures),
392
+ display: `\u26D1 fix ${failures.map((f) => f.gate.id).join(" + ")} errors`
342
393
  };
343
- push("error", `dev server: ${errors.length} error line(s)
344
- ${errors.slice(-5).join("\n")}`);
345
- if (autoFix && fixAttemptsRef.current < MAX_AUTO_FIX_ATTEMPTS) {
346
- fixAttemptsRef.current += 1;
347
- push("status", `auto-fix attempt ${fixAttemptsRef.current}/${MAX_AUTO_FIX_ATTEMPTS}`);
348
- setRunning(false);
349
- await runTurn(pendingFixRef.current.prompt, pendingFixRef.current.display);
394
+ this.push(
395
+ "error",
396
+ failures.map((f) => `\u2717 ${f.gate.id} \xB7 ${f.outputTail.split("\n").slice(-3).join("\n")}`).join("\n")
397
+ );
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);
350
403
  return;
351
404
  }
352
- push("status", "type /fix to send them to the engine");
353
- } else {
354
- pendingFixRef.current = null;
355
- if (autoProbe !== false && devUrl) {
356
- const report = await probeRuntime(devUrl);
357
- const summary = report ? runtimeSummary(report) : null;
358
- if (report && summary) {
359
- pendingFixRef.current = {
360
- prompt: buildRuntimeFixPrompt(report),
361
- display: "\u26D1 fix runtime errors"
362
- };
363
- push("error", `runtime: ${summary}`);
364
- if (autoFix && fixAttemptsRef.current < MAX_AUTO_FIX_ATTEMPTS) {
365
- fixAttemptsRef.current += 1;
366
- push("status", `auto-fix attempt ${fixAttemptsRef.current}/${MAX_AUTO_FIX_ATTEMPTS}`);
367
- setRunning(false);
368
- await runTurn(pendingFixRef.current.prompt, pendingFixRef.current.display);
369
- return;
370
- }
371
- push("status", "type /fix to send them to the engine");
405
+ this.push("status", "type /fix to send them to the engine");
406
+ this.notify({ running: false });
407
+ this.drainQueue();
408
+ return;
409
+ }
410
+ }
411
+ }
412
+ const dev = this.dev;
413
+ if (result.error !== "interrupted" && dev && (dev.state === "running" || dev.state === "starting")) {
414
+ await delay(1500);
415
+ const errors = dev.errorsSince(runStart);
416
+ if (errors.length > 0) {
417
+ this.pendingFix = {
418
+ prompt: buildFixPrompt(errors, dev.tail(30)),
419
+ display: "\u26D1 fix dev server errors"
420
+ };
421
+ this.push("error", `dev server: ${errors.length} error line(s)
422
+ ${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");
431
+ } else {
432
+ this.pendingFix = null;
433
+ 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
+ };
441
+ 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;
372
448
  }
449
+ this.push("status", "type /fix to send them to the engine");
373
450
  }
374
- if (!reviewTipShownRef.current && devUrl) {
375
- reviewTipShownRef.current = true;
376
- push("status", "tip: /review screenshots the app and has the engine critique its own work");
377
- }
451
+ }
452
+ if (!this.reviewTipShown && this.state.devUrl) {
453
+ this.reviewTipShown = true;
454
+ this.push("status", "tip: /review screenshots the app and has the engine critique its own work");
378
455
  }
379
456
  }
380
- setRunning(false);
381
- },
382
- [cwd, engineId, model, push, handleEvent, commitLive, autoFix, autoProbe, devUrl]
383
- );
384
- const capture = useCallback(async () => {
385
- if (!devUrl) {
386
- push("error", "dev server not running \u2014 /dev first");
457
+ }
458
+ this.notify({ running: false });
459
+ this.drainQueue();
460
+ }
461
+ async submit(ask) {
462
+ this.fixAttempts = 0;
463
+ const snapshot = takeSnapshot(this.opts.cwd);
464
+ if (snapshot) {
465
+ this.checkpoints.push({
466
+ snapshot,
467
+ label: ask.length > 60 ? `${ask.slice(0, 59)}\u2026` : ask,
468
+ at: Date.now()
469
+ });
470
+ if (this.checkpoints.length > 20) this.checkpoints.shift();
471
+ }
472
+ const isFirstTurn = this.sessionId === void 0;
473
+ let prompt = isFirstTurn ? composePrompt(ask, { cwd: this.opts.cwd, firstTurn: true }) : ask;
474
+ const enrichment = enrich(this.opts.cwd, ask);
475
+ if (enrichment.matchedSkills.length > 0) {
476
+ this.push("status", `skills: ${enrichment.matchedSkills.join(", ")}`);
477
+ }
478
+ prompt += enrichment.sections;
479
+ await this.runTurn(prompt, ask);
480
+ }
481
+ /** Restore files to the state before checkpoint `index`; drop it and everything after. */
482
+ restoreTo(index) {
483
+ const checkpoint = this.checkpoints[index];
484
+ if (!checkpoint) {
485
+ this.push("status", "no such checkpoint \u2014 /checkpoints lists them");
486
+ return;
487
+ }
488
+ const result = restoreSnapshot(this.opts.cwd, checkpoint.snapshot);
489
+ if (result.restored) {
490
+ const dropped = this.checkpoints.length - index;
491
+ this.checkpoints = this.checkpoints.slice(0, index);
492
+ this.push(
493
+ "status",
494
+ `restored files to before "${checkpoint.label}"${dropped > 1 ? ` \xB7 ${dropped} asks rolled back` : ""}${result.deletedFiles > 0 ? ` \xB7 removed ${result.deletedFiles} created file(s)` : ""} \u2014 the conversation continues from here`
495
+ );
496
+ } else {
497
+ this.push("error", `restore failed: ${result.detail ?? "unknown error"}`);
498
+ }
499
+ }
500
+ /** Screenshot the running app (and watch its runtime where CDP is available). */
501
+ async capture() {
502
+ if (!this.state.devUrl) {
503
+ this.push("error", "dev server not running \u2014 /dev first");
387
504
  return null;
388
505
  }
389
- push("status", "capturing screenshots\u2026");
390
- const result = await captureViewports(cwd, devUrl);
506
+ this.push("status", "capturing screenshots\u2026");
507
+ const result = await captureViewports(this.opts.cwd, this.state.devUrl);
391
508
  if (!result) {
392
- push("error", "no Chrome/Chromium found for screenshots");
509
+ this.push("error", "no Chrome/Chromium found for screenshots");
393
510
  return null;
394
511
  }
395
- for (const err of result.errors) push("error", `screenshot ${err}`);
512
+ for (const err of result.errors) this.push("error", `screenshot ${err}`);
396
513
  if (result.shots.length > 0) {
397
- push("status", `captured ${result.shots.map((s) => s.name).join(", ")} \u2192 ${path2.dirname(result.shots[0].path)}`);
514
+ this.push(
515
+ "status",
516
+ `captured ${result.shots.map((s) => s.name).join(", ")} \u2192 ${path2.dirname(result.shots[0].path)}`
517
+ );
398
518
  }
399
519
  if (result.runtime) {
400
520
  const summary = runtimeSummary(result.runtime);
401
521
  if (summary) {
402
- push("error", `runtime: ${summary}`);
403
- pendingFixRef.current = {
404
- prompt: buildRuntimeFixPrompt(result.runtime),
405
- display: "\u26D1 fix runtime errors"
406
- };
407
- push("status", "type /fix to send them to the engine");
522
+ 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");
408
525
  } else {
409
- push("status", "runtime clean \u2014 no console errors, exceptions, or failed requests");
526
+ this.push("status", "runtime clean \u2014 no console errors, exceptions, or failed requests");
410
527
  }
411
528
  }
412
529
  if (result.a11y && result.a11y.length > 0) {
413
- push("error", `a11y: ${result.a11y.length} finding(s)
530
+ this.push("error", `a11y: ${result.a11y.length} finding(s)
414
531
  ${result.a11y.slice(0, 5).join("\n")}`);
415
- push("status", "/review folds these into the fix pass");
532
+ this.push("status", "/review folds these into the fix pass");
416
533
  }
417
534
  return result.shots.length > 0 ? result : null;
418
- }, [cwd, devUrl, push]);
419
- const submit = useCallback(
420
- async (ask) => {
421
- fixAttemptsRef.current = 0;
422
- snapshotRef.current = takeSnapshot(cwd);
423
- const isFirstTurn = sessionRef.current === void 0;
424
- const prompt = isFirstTurn ? composePrompt(ask, { cwd, firstTurn: true }) : ask;
425
- await runTurn(prompt, ask);
426
- },
427
- [cwd, runTurn]
428
- );
429
- const handleCommand = useCallback(
430
- (command) => {
431
- const [name, ...rest] = command.slice(1).split(/\s+/);
432
- const arg = rest.join(" ").trim();
433
- switch (name) {
434
- case "engine":
435
- if (!arg) {
436
- push("status", `engines: ${engines.map((e) => e.id).join(", ")}`);
437
- } else {
438
- try {
439
- getEngine(arg);
440
- setEngineId(arg);
441
- sessionRef.current = void 0;
442
- push("status", `engine \u2192 ${arg} (new session)`);
443
- } catch (err) {
444
- push("error", err instanceof Error ? err.message : String(err));
445
- }
446
- }
447
- break;
448
- case "model":
449
- setModel(arg || void 0);
450
- push("status", arg ? `model \u2192 ${arg}` : "model \u2192 engine default");
451
- break;
452
- case "dev": {
453
- const dev = getDevServer();
454
- if (dev.state === "stopped" || dev.state === "crashed") {
455
- const devCommand = detectDevCommand(cwd);
456
- if (!devCommand) {
457
- push("error", "no dev/start script found in package.json");
458
- } else {
459
- dev.start(devCommand);
460
- push("status", `dev server starting \xB7 ${devCommand.display}`);
461
- }
462
- } else {
463
- dev.stop();
464
- setDevUrl(null);
465
- push("status", "dev server stopped");
535
+ }
536
+ command(commandLine) {
537
+ const [name, ...rest] = commandLine.slice(1).split(/\s+/);
538
+ const arg = rest.join(" ").trim();
539
+ switch (name) {
540
+ case "engine":
541
+ if (!arg) {
542
+ this.push("status", "usage: /engine <id> \u2014 see squint engines");
543
+ } else {
544
+ try {
545
+ getEngine(arg);
546
+ this.sessionId = void 0;
547
+ this.notify({ engineId: arg });
548
+ this.push("status", `engine \u2192 ${arg} (new session)`);
549
+ } catch (err) {
550
+ this.push("error", err instanceof Error ? err.message : String(err));
466
551
  }
467
- break;
468
552
  }
469
- case "fix":
470
- if (!pendingFixRef.current) {
471
- push("status", "nothing to fix \u2014 no captured errors or failed gates");
553
+ break;
554
+ case "model":
555
+ this.notify({ model: arg || void 0 });
556
+ this.push("status", arg ? `model \u2192 ${arg}` : "model \u2192 engine default");
557
+ break;
558
+ case "mode":
559
+ if (arg === "plan" || arg === "safe" || arg === "yolo") {
560
+ this.setMode(arg);
561
+ } else {
562
+ this.push("status", "usage: /mode plan|safe|yolo \u2014 or shift+tab to cycle");
563
+ }
564
+ break;
565
+ case "dev": {
566
+ const dev = this.devServer();
567
+ if (dev.state === "stopped" || dev.state === "crashed") {
568
+ const devCommand = detectDevCommand(this.opts.cwd);
569
+ if (!devCommand) {
570
+ this.push("error", "no dev/start script found in package.json");
472
571
  } else {
473
- void runTurn(pendingFixRef.current.prompt, pendingFixRef.current.display);
572
+ dev.start(devCommand);
573
+ this.push("status", `dev server starting \xB7 ${devCommand.display}`);
474
574
  }
475
- break;
476
- case "check":
477
- void (async () => {
478
- const gates = detectGates(cwd);
479
- if (gates.length === 0) {
480
- push("status", "no gates detected in this project");
481
- return;
482
- }
483
- push("status", `running gates: ${gates.map((g) => g.id).join(" \u2192 ")}`);
484
- setRunning(true);
485
- setRunStartedAt(Date.now());
486
- const results = await runGates(cwd, gates, (result) => {
487
- push(
488
- result.ok ? "status" : "error",
489
- `${result.ok ? "\u2713" : "\u2717"} ${result.gate.id} \xB7 ${(result.durationMs / 1e3).toFixed(1)}s`
490
- );
491
- });
492
- setRunning(false);
493
- const failures = results.filter((r) => !r.ok);
494
- if (failures.length > 0) {
495
- pendingFixRef.current = {
496
- prompt: buildGatePrompt(failures),
497
- display: `\u26D1 fix failing gates: ${failures.map((f) => f.gate.id).join(", ")}`
498
- };
499
- push("status", "type /fix to send failures to the engine");
500
- } else {
501
- push("status", "all gates passed");
502
- }
503
- })();
504
- break;
505
- case "shot":
506
- void capture();
507
- break;
508
- case "review":
509
- void (async () => {
510
- const result = await capture();
511
- if (result) {
512
- await runTurn(
513
- buildReviewPrompt(result.shots, arg || void 0, result.runtime, result.a11y),
514
- `\u{1F441} review rendered UI${arg ? ` \xB7 ${arg}` : ""}`
515
- );
516
- }
517
- })();
518
- break;
519
- case "undo": {
520
- const snapshot = snapshotRef.current;
521
- if (!snapshot) {
522
- push("status", "nothing to undo \u2014 no ask this session, or not a git repo with commits");
523
- break;
575
+ } else {
576
+ dev.stop();
577
+ this.notify({ devUrl: null });
578
+ this.push("status", "dev server stopped");
579
+ }
580
+ break;
581
+ }
582
+ case "fix":
583
+ if (!this.pendingFix) {
584
+ this.push("status", "nothing to fix \u2014 no captured errors or failed gates");
585
+ } else {
586
+ void this.runTurn(this.pendingFix.prompt, this.pendingFix.display);
587
+ }
588
+ break;
589
+ case "check":
590
+ void (async () => {
591
+ const gates = detectGates(this.opts.cwd);
592
+ if (gates.length === 0) {
593
+ this.push("status", "no gates detected in this project");
594
+ return;
524
595
  }
525
- const result = restoreSnapshot(cwd, snapshot);
526
- if (result.restored) {
527
- snapshotRef.current = null;
528
- push(
529
- "status",
530
- `reverted the last ask${result.deletedFiles > 0 ? ` \xB7 removed ${result.deletedFiles} created file(s)` : ""}`
596
+ this.push("status", `running gates: ${gates.map((g) => g.id).join(" \u2192 ")}`);
597
+ this.notify({ running: true, runStartedAt: Date.now() });
598
+ const results = await runGates(this.opts.cwd, gates, (result) => {
599
+ this.push(
600
+ result.ok ? "status" : "error",
601
+ `${result.ok ? "\u2713" : "\u2717"} ${result.gate.id} \xB7 ${(result.durationMs / 1e3).toFixed(1)}s`
531
602
  );
603
+ });
604
+ this.notify({ running: false });
605
+ this.drainQueue();
606
+ const failures = results.filter((r) => !r.ok);
607
+ 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");
532
613
  } else {
533
- push("error", `undo failed: ${result.detail ?? "unknown error"}`);
614
+ this.push("status", "all gates passed");
615
+ }
616
+ })();
617
+ break;
618
+ case "shot":
619
+ void this.capture();
620
+ break;
621
+ case "review":
622
+ void (async () => {
623
+ const result = await this.capture();
624
+ if (result) {
625
+ await this.runTurn(
626
+ buildReviewPrompt(result.shots, arg || void 0, result.runtime, result.a11y),
627
+ `\u{1F441} review rendered UI${arg ? ` \xB7 ${arg}` : ""}`
628
+ );
534
629
  }
630
+ })();
631
+ break;
632
+ case "undo":
633
+ if (this.checkpoints.length === 0) {
634
+ this.push("status", "nothing to undo \u2014 no ask this session, or not a git repo with commits");
635
+ } else {
636
+ this.restoreTo(this.checkpoints.length - 1);
637
+ }
638
+ break;
639
+ case "checkpoints":
640
+ if (this.checkpoints.length === 0) {
641
+ this.push("status", "no checkpoints yet \u2014 one is taken before every ask in a git repo");
642
+ } else {
643
+ const lines = this.checkpoints.map((c, i) => {
644
+ const mins = Math.max(0, Math.round((Date.now() - c.at) / 6e4));
645
+ return `${i + 1}. ${c.label} \xB7 ${mins}m ago`;
646
+ });
647
+ this.push("status", `${lines.join("\n")}
648
+ /restore <n> rewinds files to before that ask \xB7 /undo pops the last`);
649
+ }
650
+ break;
651
+ case "restore": {
652
+ const index = Number.parseInt(arg, 10);
653
+ if (!Number.isInteger(index) || index < 1 || index > this.checkpoints.length) {
654
+ this.push("status", `usage: /restore <1\u2013${Math.max(this.checkpoints.length, 1)}> \u2014 see /checkpoints`);
655
+ } else {
656
+ this.restoreTo(index - 1);
657
+ }
658
+ break;
659
+ }
660
+ case "resume": {
661
+ const saved = loadState(this.opts.cwd);
662
+ if (!saved) {
663
+ this.push("status", "no previous session for this project");
535
664
  break;
536
665
  }
537
- case "resume": {
538
- const saved = loadState(cwd);
539
- if (!saved) {
540
- push("status", "no previous session for this project");
666
+ try {
667
+ if (!getEngine(saved.engine).supportsResume) {
668
+ this.push("status", `previous engine ${saved.engine} cannot resume sessions`);
541
669
  break;
542
670
  }
543
- try {
544
- if (!getEngine(saved.engine).supportsResume) {
545
- push("status", `previous engine ${saved.engine} cannot resume sessions`);
546
- break;
547
- }
548
- } catch {
549
- push("error", `previous engine ${saved.engine} is no longer available`);
671
+ } catch {
672
+ this.push("error", `previous engine ${saved.engine} is no longer available`);
673
+ break;
674
+ }
675
+ this.sessionId = saved.sessionId;
676
+ this.notify({ engineId: saved.engine, model: saved.model ?? this.state.model });
677
+ this.push("status", `resumed ${saved.engine} session${saved.lastAsk ? ` \xB7 "${saved.lastAsk}"` : ""}`);
678
+ break;
679
+ }
680
+ case "variants": {
681
+ const [sub, ...subRest] = arg.split(/\s+/);
682
+ if (sub === "apply") {
683
+ const id = subRest[0];
684
+ if (!id) {
685
+ this.push("status", "usage: /variants apply <id>");
550
686
  break;
551
687
  }
552
- setEngineId(saved.engine);
553
- if (saved.model) setModel(saved.model);
554
- sessionRef.current = saved.sessionId;
555
- push("status", `resumed ${saved.engine} session${saved.lastAsk ? ` \xB7 "${saved.lastAsk}"` : ""}`);
688
+ const applied = applyVariant(this.opts.cwd, id);
689
+ if (applied.ok) {
690
+ cleanVariants(this.opts.cwd);
691
+ this.push("status", `applied ${id} to the working tree \u2014 review with git diff`);
692
+ } else {
693
+ this.push("error", applied.detail ?? "apply failed");
694
+ }
556
695
  break;
557
696
  }
558
- case "clear":
559
- setMessages([]);
560
- sessionRef.current = void 0;
561
- clearState(cwd);
697
+ if (sub === "clean") {
698
+ this.push("status", `removed ${cleanVariants(this.opts.cwd)} variant(s)`);
562
699
  break;
563
- case "help":
564
- push(
565
- "status",
566
- "/engine <id> \xB7 /model <name> \xB7 /dev (start/stop server) \xB7 /check (quality gates) \xB7 /fix (send failures) \xB7 /shot (screenshots) \xB7 /review [focus] (visual self-critique) \xB7 /undo (revert last ask) \xB7 /resume (last session) \xB7 /clear (new session) \xB7 /quit"
567
- );
700
+ }
701
+ if (sub === "list") {
702
+ const ids = listVariants(this.opts.cwd);
703
+ this.push("status", ids.length > 0 ? ids.join(" \xB7 ") : "no variants \u2014 /variants <2-4> <ask>");
568
704
  break;
569
- case "quit":
570
- case "exit":
571
- devRef.current?.stop();
572
- exit();
705
+ }
706
+ const n = Number.parseInt(sub ?? "", 10);
707
+ const ask = subRest.join(" ").trim();
708
+ if (!Number.isInteger(n) || n < 2 || n > 4 || ask.length === 0) {
709
+ this.push("status", "usage: /variants <2-4> <ask> \xB7 /variants apply <id> \xB7 list \xB7 clean");
573
710
  break;
574
- default:
575
- push("error", `unknown command /${name} \u2014 try /help`);
711
+ }
712
+ if (!isGitRepo(this.opts.cwd)) {
713
+ this.push("error", "variants need a git repo with at least one commit");
714
+ break;
715
+ }
716
+ void (async () => {
717
+ cleanVariants(this.opts.cwd);
718
+ this.push("status", `generating ${n} directions in parallel \u2014 this runs ${n} engine sessions`);
719
+ this.notify({ running: true, runStartedAt: Date.now() });
720
+ const engine = getEngine(this.state.engineId);
721
+ const runs = await runVariants(
722
+ this.opts.cwd,
723
+ ask,
724
+ n,
725
+ engine,
726
+ this.state.model,
727
+ (familyId, text) => this.push("status", `[${familyId}] ${text}`)
728
+ );
729
+ const succeeded = runs.filter((r) => r.result.ok);
730
+ if (succeeded.length > 0 && findChrome() && detectDevCommand(this.opts.cwd)) {
731
+ this.push("status", "capturing one screenshot per variant\u2026");
732
+ const shots = await screenshotVariants(this.opts.cwd, succeeded.map((r) => r.variant));
733
+ for (const shot of shots) {
734
+ this.push(
735
+ shot.path ? "status" : "error",
736
+ `[${shot.familyId}] ${shot.path ?? shot.error ?? "screenshot failed"}`
737
+ );
738
+ }
739
+ }
740
+ this.notify({ running: false });
741
+ this.push(
742
+ succeeded.length > 0 ? "status" : "error",
743
+ `${succeeded.length}/${runs.length} variants ready \u2014 /variants apply <id> keeps one, /variants clean discards`
744
+ );
745
+ this.drainQueue();
746
+ })();
747
+ break;
748
+ }
749
+ case "clear":
750
+ this.sessionId = void 0;
751
+ clearState(this.opts.cwd);
752
+ this.notify({ items: [], totals: { costUsd: 0, turns: 0 } });
753
+ 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
+ );
759
+ break;
760
+ case "quit":
761
+ case "exit":
762
+ this.push("status", this.summary());
763
+ this.dispose();
764
+ this.opts.onQuit?.();
765
+ break;
766
+ default:
767
+ this.push("error", `unknown command /${name} \u2014 try /help`);
768
+ }
769
+ }
770
+ };
771
+
772
+ // src/tui/lineEditor.ts
773
+ var emptyLine = { text: "", cursor: 0 };
774
+ function fromText(text) {
775
+ return { text, cursor: text.length };
776
+ }
777
+ function insert(line, str) {
778
+ const clean = str.replace(/\r/g, "").replace(/\n+/g, " ");
779
+ return {
780
+ text: line.text.slice(0, line.cursor) + clean + line.text.slice(line.cursor),
781
+ cursor: line.cursor + clean.length
782
+ };
783
+ }
784
+ function backspace(line) {
785
+ if (line.cursor === 0) return line;
786
+ return {
787
+ text: line.text.slice(0, line.cursor - 1) + line.text.slice(line.cursor),
788
+ cursor: line.cursor - 1
789
+ };
790
+ }
791
+ function left(line) {
792
+ return { ...line, cursor: Math.max(0, line.cursor - 1) };
793
+ }
794
+ function right(line) {
795
+ return { ...line, cursor: Math.min(line.text.length, line.cursor + 1) };
796
+ }
797
+ function home(line) {
798
+ return { ...line, cursor: 0 };
799
+ }
800
+ function end(line) {
801
+ return { ...line, cursor: line.text.length };
802
+ }
803
+ var isWordChar = (ch) => /[\p{L}\p{N}_/@.-]/u.test(ch);
804
+ function wordLeft(line) {
805
+ let i = line.cursor;
806
+ while (i > 0 && !isWordChar(line.text[i - 1])) i--;
807
+ while (i > 0 && isWordChar(line.text[i - 1])) i--;
808
+ return { ...line, cursor: i };
809
+ }
810
+ function wordRight(line) {
811
+ let i = line.cursor;
812
+ const n = line.text.length;
813
+ while (i < n && !isWordChar(line.text[i])) i++;
814
+ while (i < n && isWordChar(line.text[i])) i++;
815
+ return { ...line, cursor: i };
816
+ }
817
+ function killToEnd(line) {
818
+ return { text: line.text.slice(0, line.cursor), cursor: line.cursor };
819
+ }
820
+ function killToStart(line) {
821
+ return { text: line.text.slice(line.cursor), cursor: 0 };
822
+ }
823
+ function killWordBack(line) {
824
+ const target = wordLeft(line).cursor;
825
+ return {
826
+ text: line.text.slice(0, target) + line.text.slice(line.cursor),
827
+ cursor: target
828
+ };
829
+ }
830
+
831
+ // src/tui/markdown.tsx
832
+ import { Box, Text } from "ink";
833
+
834
+ // src/tui/themeContext.tsx
835
+ import { createContext, useContext } from "react";
836
+
837
+ // src/tui/theme.ts
838
+ var THEMES = {
839
+ amber: {
840
+ name: "amber",
841
+ accent: "#e8a33d",
842
+ dim: "gray",
843
+ user: "#7aa2f7",
844
+ error: "#f7768e",
845
+ success: "#9ece6a",
846
+ tool: "#7dcfff"
847
+ },
848
+ ocean: {
849
+ name: "ocean",
850
+ accent: "#56b6c2",
851
+ dim: "gray",
852
+ user: "#61afef",
853
+ error: "#e06c75",
854
+ success: "#98c379",
855
+ tool: "#c678dd"
856
+ },
857
+ moss: {
858
+ name: "moss",
859
+ accent: "#a7c080",
860
+ dim: "gray",
861
+ user: "#7fbbb3",
862
+ error: "#e67e80",
863
+ success: "#83c092",
864
+ tool: "#d699b6"
865
+ },
866
+ rose: {
867
+ name: "rose",
868
+ accent: "#ebbcba",
869
+ dim: "gray",
870
+ user: "#9ccfd8",
871
+ error: "#eb6f92",
872
+ success: "#31748f",
873
+ tool: "#c4a7e7"
874
+ },
875
+ mono: {
876
+ name: "mono",
877
+ accent: "white",
878
+ dim: "gray",
879
+ user: "white",
880
+ error: "white",
881
+ success: "white",
882
+ tool: "gray"
883
+ }
884
+ };
885
+ var DEFAULT_THEME = "amber";
886
+ function resolveTheme(name, env = process.env) {
887
+ if (env.NO_COLOR !== void 0 && env.NO_COLOR !== "") return THEMES.mono;
888
+ return THEMES[name ?? DEFAULT_THEME] ?? THEMES[DEFAULT_THEME];
889
+ }
890
+ var theme = THEMES[DEFAULT_THEME];
891
+
892
+ // src/tui/themeContext.tsx
893
+ var ThemeContext = createContext(theme);
894
+ var ThemeProvider = ThemeContext.Provider;
895
+ function useTheme() {
896
+ return useContext(ThemeContext);
897
+ }
898
+
899
+ // src/tui/markdown.tsx
900
+ import { jsx, jsxs } from "react/jsx-runtime";
901
+ var INLINE_RE = /(\*\*[^*]+\*\*|\*[^*\s][^*]*\*|`[^`]+`)/g;
902
+ function parseInline(text) {
903
+ const segments = [];
904
+ let last = 0;
905
+ for (const match of text.matchAll(INLINE_RE)) {
906
+ if (match.index > last) segments.push({ text: text.slice(last, match.index) });
907
+ const token = match[0];
908
+ if (token.startsWith("**")) segments.push({ text: token.slice(2, -2), bold: true });
909
+ else if (token.startsWith("`")) segments.push({ text: token.slice(1, -1), code: true });
910
+ else segments.push({ text: token.slice(1, -1), italic: true });
911
+ last = match.index + token.length;
912
+ }
913
+ if (last < text.length) segments.push({ text: text.slice(last) });
914
+ return segments;
915
+ }
916
+ function parseBlocks(text) {
917
+ const blocks = [];
918
+ let code = null;
919
+ for (const rawLine of text.split("\n")) {
920
+ const line = rawLine.replace(/\s+$/, "");
921
+ if (code) {
922
+ if (/^\s*```/.test(line)) {
923
+ blocks.push({ type: "code", ...code });
924
+ code = null;
925
+ } else {
926
+ code.lines.push(rawLine);
576
927
  }
577
- },
578
- [push, exit, cwd, getDevServer, runTurn, capture]
928
+ continue;
929
+ }
930
+ const fence = /^\s*```(\S*)/.exec(line);
931
+ if (fence) {
932
+ code = { lang: fence[1] ?? "", lines: [] };
933
+ continue;
934
+ }
935
+ const heading = /^(#{1,4})\s+(.*)$/.exec(line);
936
+ if (heading) {
937
+ blocks.push({ type: "heading", level: heading[1].length, text: heading[2] });
938
+ continue;
939
+ }
940
+ if (/^\s*([-*_])\s*\1\s*\1[\s\-*_]*$/.test(line) && line.trim().length >= 3) {
941
+ blocks.push({ type: "hr" });
942
+ continue;
943
+ }
944
+ const item = /^(\s*)[-*+]\s+(.*)$/.exec(line);
945
+ if (item) {
946
+ blocks.push({ type: "item", text: item[2], indent: Math.floor(item[1].length / 2) });
947
+ continue;
948
+ }
949
+ const ordered = /^(\s*)(\d+)[.)]\s+(.*)$/.exec(line);
950
+ if (ordered) {
951
+ blocks.push({
952
+ type: "item",
953
+ text: ordered[3],
954
+ indent: Math.floor(ordered[1].length / 2),
955
+ ordered: ordered[2]
956
+ });
957
+ continue;
958
+ }
959
+ const quote = /^\s*>\s?(.*)$/.exec(line);
960
+ if (quote) {
961
+ blocks.push({ type: "quote", text: quote[1] });
962
+ continue;
963
+ }
964
+ blocks.push({ type: "para", text: line });
965
+ }
966
+ if (code) blocks.push({ type: "code", ...code });
967
+ return blocks;
968
+ }
969
+ function Inline({ text, dim }) {
970
+ const theme2 = useTheme();
971
+ const segments = parseInline(text);
972
+ return /* @__PURE__ */ jsx(Text, { wrap: "wrap", dimColor: dim, children: segments.map(
973
+ (segment, index) => segment.code ? /* @__PURE__ */ jsx(Text, { color: theme2.tool, children: segment.text }, index) : /* @__PURE__ */ jsx(Text, { bold: segment.bold, italic: segment.italic, children: segment.text }, index)
974
+ ) });
975
+ }
976
+ function Markdown({ text }) {
977
+ const theme2 = useTheme();
978
+ const blocks = parseBlocks(text);
979
+ return /* @__PURE__ */ jsx(Box, { flexDirection: "column", children: blocks.map((block, index) => {
980
+ switch (block.type) {
981
+ case "heading":
982
+ return /* @__PURE__ */ jsx(Text, { bold: true, color: block.level <= 2 ? theme2.accent : void 0, wrap: "wrap", children: block.text }, index);
983
+ case "item":
984
+ return /* @__PURE__ */ jsxs(Box, { paddingLeft: 1 + block.indent * 2, children: [
985
+ /* @__PURE__ */ jsxs(Text, { color: theme2.accent, children: [
986
+ block.ordered ? `${block.ordered}.` : "\u2022",
987
+ " "
988
+ ] }),
989
+ /* @__PURE__ */ jsx(Inline, { text: block.text })
990
+ ] }, index);
991
+ case "quote":
992
+ return /* @__PURE__ */ jsxs(Box, { paddingLeft: 1, children: [
993
+ /* @__PURE__ */ jsx(Text, { color: theme2.dim, children: "\u2502 " }),
994
+ /* @__PURE__ */ jsx(Inline, { text: block.text, dim: true })
995
+ ] }, index);
996
+ case "code":
997
+ return /* @__PURE__ */ jsx(Box, { flexDirection: "column", paddingLeft: 1, children: block.lines.map((line, lineIndex) => /* @__PURE__ */ jsxs(Text, { children: [
998
+ /* @__PURE__ */ jsx(Text, { color: theme2.dim, children: "\u258F " }),
999
+ /* @__PURE__ */ jsx(Text, { color: theme2.tool, children: line.length > 0 ? line : " " })
1000
+ ] }, lineIndex)) }, index);
1001
+ case "hr":
1002
+ return /* @__PURE__ */ jsx(Text, { color: theme2.dim, children: "\u2500".repeat(32) }, index);
1003
+ case "para":
1004
+ return block.text.length === 0 ? /* @__PURE__ */ jsx(Text, { children: " " }, index) : /* @__PURE__ */ jsx(Inline, { text: block.text }, index);
1005
+ }
1006
+ }) });
1007
+ }
1008
+
1009
+ // src/tui/messages.tsx
1010
+ import { Text as Text2 } from "ink";
1011
+ import { useEffect, useState } from "react";
1012
+ import { jsx as jsx2, jsxs as jsxs2 } from "react/jsx-runtime";
1013
+ var SPINNER_FRAMES = ["\u280B", "\u2819", "\u2839", "\u2838", "\u283C", "\u2834", "\u2826", "\u2827", "\u2807", "\u280F"];
1014
+ var PHRASES = ["working", "thinking", "squinting", "crafting", "still at it"];
1015
+ function WorkingLine({ startedAt }) {
1016
+ const theme2 = useTheme();
1017
+ const [frame, setFrame] = useState(0);
1018
+ const [elapsed, setElapsed] = useState(0);
1019
+ useEffect(() => {
1020
+ const timer = setInterval(() => {
1021
+ setFrame((f) => (f + 1) % SPINNER_FRAMES.length);
1022
+ setElapsed(Math.floor((Date.now() - startedAt) / 1e3));
1023
+ }, 80);
1024
+ return () => clearInterval(timer);
1025
+ }, [startedAt]);
1026
+ const phrase = PHRASES[Math.min(Math.floor(elapsed / 8), PHRASES.length - 1)];
1027
+ return /* @__PURE__ */ jsxs2(Text2, { children: [
1028
+ /* @__PURE__ */ jsx2(Text2, { color: theme2.accent, children: SPINNER_FRAMES[frame] }),
1029
+ /* @__PURE__ */ jsxs2(Text2, { color: theme2.dim, children: [
1030
+ " ",
1031
+ phrase,
1032
+ "\u2026 ",
1033
+ elapsed,
1034
+ "s \xB7 esc to interrupt"
1035
+ ] })
1036
+ ] });
1037
+ }
1038
+ function toolGlyph(text) {
1039
+ const name = text.split(/[\s·]/, 1)[0]?.toLowerCase() ?? "";
1040
+ if (name.includes("todo")) return "\u2630";
1041
+ if (name.includes("read") || name.includes("view") || name.includes("cat")) return "\u2299";
1042
+ if (name.includes("edit") || name.includes("write") || name.includes("patch") || name.includes("apply")) return "\u270E";
1043
+ if (name.includes("bash") || name.includes("shell") || name.includes("exec") || name.includes("command")) return "$";
1044
+ if (name.includes("grep") || name.includes("glob") || name.includes("search") || name.includes("find")) return "\u2315";
1045
+ if (name.includes("web") || name.includes("fetch") || name.includes("http")) return "\u21E3";
1046
+ if (name.includes("task") || name.includes("agent")) return "\u25C7";
1047
+ return "\u2699";
1048
+ }
1049
+ function MessageLine({ message }) {
1050
+ const theme2 = useTheme();
1051
+ switch (message.role) {
1052
+ case "user":
1053
+ return /* @__PURE__ */ jsxs2(Text2, { color: theme2.user, wrap: "wrap", children: [
1054
+ "\u276F ",
1055
+ message.text
1056
+ ] });
1057
+ case "assistant":
1058
+ return /* @__PURE__ */ jsx2(Markdown, { text: message.text });
1059
+ case "status":
1060
+ return /* @__PURE__ */ jsxs2(Text2, { color: theme2.dim, wrap: "wrap", children: [
1061
+ "\xB7 ",
1062
+ message.text
1063
+ ] });
1064
+ case "tool":
1065
+ return /* @__PURE__ */ jsxs2(Text2, { color: theme2.tool, wrap: "wrap", children: [
1066
+ toolGlyph(message.text),
1067
+ " ",
1068
+ message.text
1069
+ ] });
1070
+ case "thinking":
1071
+ return /* @__PURE__ */ jsx2(Text2, { color: theme2.dim, italic: true, wrap: "wrap", children: message.text });
1072
+ case "error":
1073
+ return /* @__PURE__ */ jsxs2(Text2, { color: theme2.error, wrap: "wrap", children: [
1074
+ "\u2717 ",
1075
+ message.text
1076
+ ] });
1077
+ }
1078
+ }
1079
+
1080
+ // src/tui/App.tsx
1081
+ import { jsx as jsx3, jsxs as jsxs3 } from "react/jsx-runtime";
1082
+ function App({
1083
+ cwd,
1084
+ initialEngine,
1085
+ initialModel,
1086
+ autoDev,
1087
+ autoFix,
1088
+ autoProbe,
1089
+ autoCheck,
1090
+ bell,
1091
+ initialTheme
1092
+ }) {
1093
+ const { exit } = useApp();
1094
+ const [themeName, setThemeName] = useState2(() => resolveTheme(initialTheme).name);
1095
+ const theme2 = resolveTheme(themeName);
1096
+ const sessionRef = useRef(null);
1097
+ if (!sessionRef.current) {
1098
+ sessionRef.current = new Session({
1099
+ cwd,
1100
+ engineId: initialEngine,
1101
+ model: initialModel,
1102
+ autoDev,
1103
+ autoFix,
1104
+ autoProbe,
1105
+ autoCheck,
1106
+ // Delay lets the goodbye summary land in the Static scrollback.
1107
+ onQuit: () => setTimeout(() => exit(), 60)
1108
+ });
1109
+ }
1110
+ const session = sessionRef.current;
1111
+ const state = useSyncExternalStore(
1112
+ useMemo(() => (listener) => session.subscribe(listener), [session]),
1113
+ () => session.getState()
579
1114
  );
1115
+ const [line, setLine] = useState2(emptyLine);
1116
+ const historyRef = useRef([]);
1117
+ const historyIndexRef = useRef(-1);
1118
+ const ctrlCArmedAtRef = useRef(0);
1119
+ const wasRunningRef = useRef(false);
1120
+ if (wasRunningRef.current && !state.running && bell !== false) {
1121
+ process.stdout.write("\x07");
1122
+ }
1123
+ wasRunningRef.current = state.running;
580
1124
  useInput((char, key) => {
581
1125
  if (key.ctrl && char === "c") {
582
- devRef.current?.stop();
583
- exit();
1126
+ const now = Date.now();
1127
+ if (now - ctrlCArmedAtRef.current < 2e3) {
1128
+ session.note(session.summary());
1129
+ session.dispose();
1130
+ setTimeout(() => exit(), 60);
1131
+ } else {
1132
+ ctrlCArmedAtRef.current = now;
1133
+ session.note("press ctrl+c again to exit");
1134
+ }
584
1135
  return;
585
1136
  }
586
- if (running) {
587
- if (key.escape) abortRef.current?.abort();
1137
+ if (key.escape && state.running) {
1138
+ session.interrupt();
1139
+ return;
1140
+ }
1141
+ if (key.tab && key.shift) {
1142
+ session.cycleMode();
588
1143
  return;
589
1144
  }
590
1145
  if (key.return) {
591
- const value = input.trim();
592
- setInput("");
1146
+ const value = line.text.trim();
1147
+ setLine(emptyLine);
593
1148
  historyIndexRef.current = -1;
594
1149
  if (!value) return;
595
1150
  historyRef.current.push(value);
596
- if (value.startsWith("/")) {
597
- handleCommand(value);
598
- } else {
599
- void submit(value);
1151
+ if (value === "/theme" || value.startsWith("/theme ")) {
1152
+ const requested = value.slice("/theme".length).trim();
1153
+ if (!requested) {
1154
+ session.note(`themes: ${Object.keys(THEMES).join(", ")} \u2014 /theme <name>`);
1155
+ } else if (THEMES[requested]) {
1156
+ setThemeName(requested);
1157
+ session.note(`theme \u2192 ${requested}`);
1158
+ } else {
1159
+ session.note(`unknown theme "${requested}" \u2014 themes: ${Object.keys(THEMES).join(", ")}`);
1160
+ }
1161
+ return;
600
1162
  }
1163
+ session.input(value);
601
1164
  return;
602
1165
  }
603
1166
  if (key.upArrow) {
@@ -605,7 +1168,7 @@ ${result.a11y.slice(0, 5).join("\n")}`);
605
1168
  if (history.length === 0) return;
606
1169
  const next = historyIndexRef.current === -1 ? history.length - 1 : Math.max(historyIndexRef.current - 1, 0);
607
1170
  historyIndexRef.current = next;
608
- setInput(history[next] ?? "");
1171
+ setLine(fromText(history[next] ?? ""));
609
1172
  return;
610
1173
  }
611
1174
  if (key.downArrow) {
@@ -614,44 +1177,93 @@ ${result.a11y.slice(0, 5).join("\n")}`);
614
1177
  const next = historyIndexRef.current + 1;
615
1178
  if (next >= history.length) {
616
1179
  historyIndexRef.current = -1;
617
- setInput("");
1180
+ setLine(emptyLine);
618
1181
  } else {
619
1182
  historyIndexRef.current = next;
620
- setInput(history[next] ?? "");
1183
+ setLine(fromText(history[next] ?? ""));
621
1184
  }
622
1185
  return;
623
1186
  }
1187
+ if (key.leftArrow) {
1188
+ setLine((prev) => key.meta ? wordLeft(prev) : left(prev));
1189
+ return;
1190
+ }
1191
+ if (key.rightArrow) {
1192
+ setLine((prev) => key.meta ? wordRight(prev) : right(prev));
1193
+ return;
1194
+ }
624
1195
  if (key.backspace || key.delete) {
625
- setInput((prev) => prev.slice(0, -1));
1196
+ setLine((prev) => key.meta ? killWordBack(prev) : backspace(prev));
626
1197
  return;
627
1198
  }
628
- if (char && !key.ctrl && !key.meta) {
629
- setInput((prev) => prev + char);
1199
+ if (key.ctrl) {
1200
+ switch (char) {
1201
+ case "a":
1202
+ setLine(home);
1203
+ return;
1204
+ case "e":
1205
+ setLine(end);
1206
+ return;
1207
+ case "k":
1208
+ setLine(killToEnd);
1209
+ return;
1210
+ case "u":
1211
+ setLine(killToStart);
1212
+ return;
1213
+ case "w":
1214
+ setLine(killWordBack);
1215
+ return;
1216
+ }
1217
+ return;
1218
+ }
1219
+ if (char && !key.meta) {
1220
+ setLine((prev) => insert(prev, char));
630
1221
  }
631
1222
  });
632
- const devBadge = devState === "running" ? ` \xB7 ${devUrl ?? "dev running"}` : devState === "starting" ? " \xB7 dev starting\u2026" : devState === "crashed" ? " \xB7 dev crashed" : "";
633
- return /* @__PURE__ */ jsxs2(Box, { flexDirection: "column", paddingX: 1, children: [
634
- /* @__PURE__ */ jsx2(Static, { items: messages, children: (message) => /* @__PURE__ */ jsx2(Box, { children: /* @__PURE__ */ jsx2(MessageLine, { message }) }, message.id) }),
635
- liveText.length > 0 && /* @__PURE__ */ jsx2(Box, { children: /* @__PURE__ */ jsx2(Text2, { wrap: "wrap", children: liveText }) }),
636
- /* @__PURE__ */ jsx2(Box, { marginTop: 1, children: running ? /* @__PURE__ */ jsx2(WorkingLine, { startedAt: runStartedAt }) : /* @__PURE__ */ jsxs2(Text2, { children: [
637
- /* @__PURE__ */ jsx2(Text2, { color: theme.accent, children: "\u276F " }),
638
- input,
639
- /* @__PURE__ */ jsx2(Text2, { color: theme.accent, children: "\u258F" })
1223
+ const devBadge = state.devState === "running" ? ` \xB7 ${state.devUrl ?? "dev running"}` : state.devState === "starting" ? " \xB7 dev starting\u2026" : state.devState === "crashed" ? " \xB7 dev crashed" : "";
1224
+ const totalsBadge = state.totals.turns > 0 ? ` \xB7 ${state.totals.turns} turn${state.totals.turns === 1 ? "" : "s"}${state.totals.costUsd > 0 ? ` \xB7 $${state.totals.costUsd.toFixed(2)}` : ""}` : "";
1225
+ return /* @__PURE__ */ jsx3(ThemeProvider, { value: theme2, children: /* @__PURE__ */ jsxs3(Box2, { flexDirection: "column", paddingX: 1, children: [
1226
+ /* @__PURE__ */ jsx3(Static, { items: state.items, children: (item) => /* @__PURE__ */ jsx3(Box2, { children: /* @__PURE__ */ jsx3(MessageLine, { message: item }) }, item.id) }),
1227
+ state.liveText.length > 0 && /* @__PURE__ */ jsx3(Box2, { children: /* @__PURE__ */ jsx3(Markdown, { text: state.liveText }) }),
1228
+ state.running && /* @__PURE__ */ jsx3(Box2, { marginTop: 1, children: /* @__PURE__ */ jsx3(WorkingLine, { startedAt: state.runStartedAt }) }),
1229
+ state.queue.map((queued, index) => /* @__PURE__ */ jsx3(Box2, { children: /* @__PURE__ */ jsxs3(Text3, { color: theme2.dim, children: [
1230
+ "\u22EF queued: ",
1231
+ queued
1232
+ ] }) }, index)),
1233
+ /* @__PURE__ */ jsx3(Box2, { marginTop: state.running ? 0 : 1, children: /* @__PURE__ */ jsxs3(Text3, { children: [
1234
+ /* @__PURE__ */ jsx3(Text3, { color: theme2.accent, children: "\u276F " }),
1235
+ line.text.slice(0, line.cursor),
1236
+ /* @__PURE__ */ jsx3(Text3, { inverse: true, children: line.text[line.cursor] ?? " " }),
1237
+ line.text.slice(line.cursor + 1)
640
1238
  ] }) }),
641
- /* @__PURE__ */ jsx2(Box, { children: /* @__PURE__ */ jsxs2(Text2, { color: theme.dim, children: [
642
- engineId,
643
- model ? ` \xB7 ${model}` : "",
1239
+ /* @__PURE__ */ jsx3(Box2, { children: /* @__PURE__ */ jsxs3(Text3, { color: theme2.dim, children: [
1240
+ /* @__PURE__ */ jsxs3(
1241
+ Text3,
1242
+ {
1243
+ color: state.mode === "yolo" ? theme2.error : state.mode === "plan" ? theme2.user : theme2.dim,
1244
+ bold: state.mode !== "safe",
1245
+ children: [
1246
+ "[",
1247
+ state.mode,
1248
+ "]"
1249
+ ]
1250
+ }
1251
+ ),
1252
+ " ",
1253
+ state.engineId,
1254
+ state.model ? ` \xB7 ${state.model}` : "",
644
1255
  " \xB7 ",
645
- path2.basename(cwd),
1256
+ path3.basename(cwd),
646
1257
  devBadge,
647
- " \xB7 /help"
1258
+ totalsBadge,
1259
+ " \xB7 shift+tab mode \xB7 /help"
648
1260
  ] }) })
649
- ] });
1261
+ ] }) });
650
1262
  }
651
1263
 
652
1264
  // src/cli.tsx
653
- import { jsx as jsx3 } from "react/jsx-runtime";
654
- var VERSION = "0.1.3";
1265
+ import { jsx as jsx4 } from "react/jsx-runtime";
1266
+ var VERSION = "0.2.1";
655
1267
  var program = new Command();
656
1268
  program.name("squint").description("Lovable for your terminal \u2014 a frontend harness on top of Claude Code, Codex, and friends.").version(VERSION);
657
1269
  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(
@@ -685,18 +1297,41 @@ program.command("engines").description("List engines and whether they are instal
685
1297
  console.log(`${status} ${engine.id.padEnd(10)} ${engine.name.padEnd(14)} ${location}`);
686
1298
  }
687
1299
  });
688
- program.command("doctor").description("Check squint prerequisites and engine availability").action(async () => {
1300
+ program.command("doctor").description("Check squint prerequisites and engine availability").option("--probe", "run each detected engine with a one-word prompt to verify auth works").action(async (options) => {
689
1301
  const [major] = process.versions.node.split(".");
690
- const nodeOk = Number(major) >= 20;
691
- console.log(`${nodeOk ? pc.green("\u2713") : pc.red("\u2717")} node ${process.versions.node}${nodeOk ? "" : " (need >= 20)"}`);
1302
+ const nodeOk = Number(major) >= 22;
1303
+ console.log(`${nodeOk ? pc.green("\u2713") : pc.red("\u2717")} node ${process.versions.node}${nodeOk ? "" : " (need >= 22)"}`);
692
1304
  const detected = detectEngines();
693
1305
  for (const { engine, path: binaryPath } of detected) {
694
1306
  const status = binaryPath ? pc.green("\u2713") : pc.yellow("\u25CB");
695
1307
  console.log(`${status} ${engine.name}${binaryPath ? "" : pc.dim(` \u2014 install: ${engine.install}`)}`);
696
1308
  }
697
- const { findChrome } = await import("./chrome-4WNYZZ42.js");
698
- const { hasWebSocket } = await import("./cdp-R7Y2XSZQ.js");
699
- const chrome = findChrome();
1309
+ if (options.probe) {
1310
+ const { runAgent: runAgent2 } = await import("./run-XHLCTF6V.js");
1311
+ console.log(pc.dim("\nprobing engines with a one-word prompt (verifies auth end to end)\u2026"));
1312
+ for (const { engine, path: binaryPath } of detected) {
1313
+ if (!binaryPath) continue;
1314
+ const startedAt = Date.now();
1315
+ const abort = new AbortController();
1316
+ const timer = setTimeout(() => abort.abort(), 9e4);
1317
+ const result = await runAgent2(
1318
+ engine,
1319
+ { prompt: "Reply with exactly: ok", cwd: process.cwd() },
1320
+ () => {
1321
+ },
1322
+ abort.signal
1323
+ );
1324
+ clearTimeout(timer);
1325
+ const secs = ((Date.now() - startedAt) / 1e3).toFixed(1);
1326
+ const detail = (result.error ?? "failed").split("\n").filter((l) => l.trim()).at(-1) ?? "failed";
1327
+ console.log(
1328
+ result.ok ? `${pc.green("\u2713")} ${engine.id.padEnd(10)} responded in ${secs}s` : `${pc.red("\u2717")} ${engine.id.padEnd(10)} ${pc.dim(detail.slice(0, 110))}`
1329
+ );
1330
+ }
1331
+ }
1332
+ const { findChrome: findChrome2 } = await import("./chrome-RD7XQ767.js");
1333
+ const { hasWebSocket } = await import("./cdp-RTWPFIX5.js");
1334
+ const chrome = findChrome2();
700
1335
  console.log(
701
1336
  chrome ? `${pc.green("\u2713")} Chrome ${pc.dim(chrome)}` : `${pc.yellow("\u25CB")} Chrome ${pc.dim("\u2014 screenshots and runtime probing disabled")}`
702
1337
  );
@@ -713,7 +1348,7 @@ ${available.length} engine(s) ready.`));
713
1348
  }
714
1349
  });
715
1350
  program.command("init").description("Scaffold a new Vite + React + TS + Tailwind app with token-first CSS").argument("[dir]", "target directory", ".").option("--force", "write into a non-empty directory").option("--no-install", "skip npm install").action(async (dir, options) => {
716
- const { installDependencies, writeTemplate } = await import("./init-7AYGAKOS.js");
1351
+ const { installDependencies, writeTemplate } = await import("./init-LSYB32NS.js");
717
1352
  let result;
718
1353
  try {
719
1354
  result = writeTemplate(dir, { force: options.force });
@@ -737,10 +1372,49 @@ program.command("init").description("Scaffold a new Vite + React + TS + Tailwind
737
1372
  Next: ${pc.bold(`${cd}${options.install ? "" : "npm install && "}squint`)}`);
738
1373
  console.log(pc.dim("then describe what to build \u2014 /dev starts the preview server"));
739
1374
  });
1375
+ var skillsCommand = program.command("skills").description("Project knowledge injected into asks (.squint/rules.md + .squint/skills/)");
1376
+ skillsCommand.command("list").description("Show always-on rules and trigger-matched skills").action(async () => {
1377
+ const { loadRules, loadSkills } = await import("./skills-UGHU22BS.js");
1378
+ const cwd = process.cwd();
1379
+ const rules = loadRules(cwd);
1380
+ console.log(rules ? `${pc.green("\u2713")} rules.md ${pc.dim(`(${rules.split("\n").length} lines, always on)`)}` : pc.dim("\u25CB no .squint/rules.md"));
1381
+ const skills = loadSkills(cwd);
1382
+ if (skills.length === 0) {
1383
+ console.log(pc.dim("\u25CB no skills \u2014 squint skills init writes an example"));
1384
+ return;
1385
+ }
1386
+ for (const skill of skills) {
1387
+ console.log(`${pc.green("\u2713")} ${skill.name.padEnd(20)} ${pc.dim(`triggers: ${skill.triggers.join(", ")}`)}`);
1388
+ }
1389
+ });
1390
+ skillsCommand.command("init").description("Scaffold .squint/rules.md and an example skill").action(async () => {
1391
+ const fs2 = await import("fs");
1392
+ const nodePath = await import("path");
1393
+ const cwd = process.cwd();
1394
+ const skillsDir = nodePath.join(cwd, ".squint", "skills");
1395
+ fs2.mkdirSync(skillsDir, { recursive: true });
1396
+ const rules = nodePath.join(cwd, ".squint", "rules.md");
1397
+ if (!fs2.existsSync(rules)) {
1398
+ fs2.writeFileSync(
1399
+ rules,
1400
+ "# Project rules\n\nThese ride along on every squint ask. Keep them short \u2014 cut anything that would not cause a mistake if removed.\n"
1401
+ );
1402
+ console.log(pc.green("\u2713 .squint/rules.md"));
1403
+ }
1404
+ const example = nodePath.join(skillsDir, "example.md");
1405
+ if (!fs2.existsSync(example)) {
1406
+ fs2.writeFileSync(
1407
+ example,
1408
+ "---\ntriggers: example, sample\n---\n\nThis note is injected only when an ask mentions one of the triggers above.\nDocument the parts of this repo an agent would otherwise rediscover every time:\nwhere state lives, which helpers to reuse, what not to touch.\n"
1409
+ );
1410
+ console.log(pc.green("\u2713 .squint/skills/example.md"));
1411
+ }
1412
+ console.log(pc.dim("rules are always-on; skills inject when an ask mentions a trigger"));
1413
+ });
740
1414
  program.command("tag").description("Add the element picker to this Vite app (Alt+S in the browser \u2192 click \u2192 file:line:col)").action(async () => {
741
1415
  const fs2 = await import("fs");
742
1416
  const nodePath = await import("path");
743
- const { patchViteConfig, TAGGER_FILENAME, TAGGER_SOURCE } = await import("./source-MEXHWVP4.js");
1417
+ const { patchViteConfig, TAGGER_FILENAME, TAGGER_SOURCE } = await import("./source-ZZU245VN.js");
744
1418
  const cwd = process.cwd();
745
1419
  const taggerPath = nodePath.join(cwd, TAGGER_FILENAME);
746
1420
  fs2.writeFileSync(taggerPath, TAGGER_SOURCE);
@@ -776,21 +1450,21 @@ variantsCommand.command("gen").description("Generate n variants of one ask in pa
776
1450
  process.exitCode = 1;
777
1451
  return;
778
1452
  }
779
- const { isGitRepo } = await import("./snapshot-KKUY5RR6.js");
780
- if (!isGitRepo(cwd)) {
1453
+ const { isGitRepo: isGitRepo2 } = await import("./snapshot-KKUY5RR6.js");
1454
+ if (!isGitRepo2(cwd)) {
781
1455
  console.error(pc.red("\u2717 variants need a git repo with at least one commit"));
782
1456
  process.exitCode = 1;
783
1457
  return;
784
1458
  }
785
- const { runVariants, cleanVariants } = await import("./variants-VVIYT7WI.js");
1459
+ const { runVariants: runVariants2, cleanVariants: cleanVariants2 } = await import("./variants-7A7723L7.js");
786
1460
  const config = loadConfig(defaultPaths(cwd));
787
1461
  const engineId = resolveEngineId(config, options.engine);
788
1462
  const engine = getEngine(engineId);
789
1463
  const model = resolveModel(config, engineId, options.model);
790
1464
  const ask = promptWords.join(" ");
791
- cleanVariants(cwd);
1465
+ cleanVariants2(cwd);
792
1466
  console.log(pc.dim(`generating ${n} directions in parallel via ${engine.id} \u2014 this runs ${n} engine sessions`));
793
- const runs = await runVariants(
1467
+ const runs = await runVariants2(
794
1468
  cwd,
795
1469
  ask,
796
1470
  n,
@@ -800,9 +1474,9 @@ variantsCommand.command("gen").description("Generate n variants of one ask in pa
800
1474
  );
801
1475
  const succeeded = runs.filter((r) => r.result.ok);
802
1476
  if (options.shots && succeeded.length > 0) {
803
- const { screenshotVariants } = await import("./shots-ADBOBALL.js");
1477
+ const { screenshotVariants: screenshotVariants2 } = await import("./shots-LRYYMTPK.js");
804
1478
  console.log(pc.dim("capturing screenshots\u2026"));
805
- const shots = await screenshotVariants(cwd, succeeded.map((r) => r.variant));
1479
+ const shots = await screenshotVariants2(cwd, succeeded.map((r) => r.variant));
806
1480
  for (const shot of shots) {
807
1481
  console.log(`${pc.green("\u2713")} ${shot.familyId.padEnd(18)} ${shot.path ?? pc.dim(shot.error ?? "")}`);
808
1482
  }
@@ -815,8 +1489,8 @@ ${succeeded.length}/${runs.length} variants ready in .squint/variants/ \u2014 `
815
1489
  }
816
1490
  );
817
1491
  variantsCommand.command("list").description("List generated variants").action(async () => {
818
- const { listVariants } = await import("./variants-VVIYT7WI.js");
819
- const ids = listVariants(process.cwd());
1492
+ const { listVariants: listVariants2 } = await import("./variants-7A7723L7.js");
1493
+ const ids = listVariants2(process.cwd());
820
1494
  if (ids.length === 0) {
821
1495
  console.log(pc.dim('no variants \u2014 squint variants gen <n> "<ask>"'));
822
1496
  return;
@@ -824,26 +1498,26 @@ variantsCommand.command("list").description("List generated variants").action(as
824
1498
  for (const id of ids) console.log(id);
825
1499
  });
826
1500
  variantsCommand.command("apply").description("Apply one variant\u2019s changes to the main tree and discard the rest").argument("<id>", "family id of the winning variant").action(async (id) => {
827
- const { applyVariant, cleanVariants } = await import("./variants-VVIYT7WI.js");
1501
+ const { applyVariant: applyVariant2, cleanVariants: cleanVariants2 } = await import("./variants-7A7723L7.js");
828
1502
  const cwd = process.cwd();
829
- const result = applyVariant(cwd, id);
1503
+ const result = applyVariant2(cwd, id);
830
1504
  if (!result.ok) {
831
1505
  console.error(pc.red(`\u2717 ${result.detail}`));
832
1506
  process.exitCode = 1;
833
1507
  return;
834
1508
  }
835
- cleanVariants(cwd);
1509
+ cleanVariants2(cwd);
836
1510
  console.log(pc.green(`\u2713 applied ${id} to the working tree`) + pc.dim(" \u2014 review with git diff"));
837
1511
  });
838
1512
  variantsCommand.command("clean").description("Discard all variants").action(async () => {
839
- const { cleanVariants } = await import("./variants-VVIYT7WI.js");
840
- const count = cleanVariants(process.cwd());
1513
+ const { cleanVariants: cleanVariants2 } = await import("./variants-7A7723L7.js");
1514
+ const count = cleanVariants2(process.cwd());
841
1515
  console.log(pc.dim(`removed ${count} variant(s)`));
842
1516
  });
843
1517
  program.command("brief").description("Set a committed design direction for this project (.squint/brief.md)").argument("[family]", "aesthetic family id (omit to list)").option("--force", "overwrite an existing project brief").action(async (familyId, options) => {
844
1518
  const fs2 = await import("fs");
845
1519
  const nodePath = await import("path");
846
- const { FAMILIES, getFamily, renderFamilyBrief } = await import("./families-RVP5BWQD.js");
1520
+ const { FAMILIES, getFamily, renderFamilyBrief } = await import("./families-2G5SLLY7.js");
847
1521
  if (!familyId) {
848
1522
  console.log(pc.bold("Aesthetic families") + pc.dim(" \u2014 squint brief <id>\n"));
849
1523
  for (const family2 of FAMILIES) {
@@ -870,7 +1544,7 @@ program.command("brief").description("Set a committed design direction for this
870
1544
  console.log(pc.dim("every squint ask in this repo now holds this direction \u2014 edit the file to remix"));
871
1545
  });
872
1546
  program.command("check").description("Run this project\u2019s quality gates (typecheck, lint, test, build)").action(async () => {
873
- const { detectGates: detectGates2, runGates: runGates2 } = await import("./gates-G5DABJWQ.js");
1547
+ const { detectGates: detectGates2, runGates: runGates2 } = await import("./gates-I2GGQGMA.js");
874
1548
  const cwd = process.cwd();
875
1549
  const gates = detectGates2(cwd);
876
1550
  if (gates.length === 0) {
@@ -886,7 +1560,7 @@ program.command("check").description("Run this project\u2019s quality gates (typ
886
1560
  if (results.some((r) => !r.ok)) process.exitCode = 1;
887
1561
  });
888
1562
  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) => {
889
- const { captureViewports: captureViewports2 } = await import("./preview-AA6YIE5W.js");
1563
+ const { captureViewports: captureViewports2 } = await import("./preview-XQ7EOHV4.js");
890
1564
  const result = await captureViewports2(process.cwd(), url);
891
1565
  if (!result) {
892
1566
  console.error(pc.red("\u2717 no Chrome/Chromium found"));
@@ -919,7 +1593,7 @@ program.action(() => {
919
1593
  const engineId = resolveEngineId(config);
920
1594
  const model = resolveModel(config, engineId);
921
1595
  render(
922
- /* @__PURE__ */ jsx3(
1596
+ /* @__PURE__ */ jsx4(
923
1597
  App,
924
1598
  {
925
1599
  cwd,
@@ -927,7 +1601,10 @@ program.action(() => {
927
1601
  initialModel: model,
928
1602
  autoDev: config.autoDev,
929
1603
  autoFix: config.autoFix,
930
- autoProbe: config.autoProbe
1604
+ autoProbe: config.autoProbe,
1605
+ autoCheck: config.autoCheck,
1606
+ bell: config.bell,
1607
+ initialTheme: config.theme
931
1608
  }
932
1609
  )
933
1610
  );