@synqa/cli 1.1.0 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/tools.js CHANGED
@@ -1,11 +1,15 @@
1
1
  import { spawnSync } from "node:child_process";
2
+ import { Worker } from "node:worker_threads";
2
3
  import { closeSync, mkdirSync, openSync, readdirSync, readSync, statSync } from "node:fs";
3
- import { dirname, join, relative, sep } from "node:path";
4
+ import { dirname, isAbsolute, join, relative, sep } from "node:path";
4
5
  import { MAX_DIFF_LINES, renderDiff, unifiedDiff } from "./diff.js";
5
6
  import { snapshot } from "./journal.js";
6
7
 
7
- import { commandTouchesSecrets, confine, fold, isSensitivePath, writeConfined } from "./paths.js";
8
+ import { MUTATING_TOOLS } from "./modes.js";
9
+
10
+ import { commandTouchesSecrets, confine, fold, isHomeDir, isSecretStemSource, isSensitivePath, writeConfined } from "./paths.js";
8
11
  import { visibleEscapes } from "./sanitize.js";
12
+
9
13
 
10
14
  const MAX_FILE_BYTES = 1_000_000;
11
15
  const MAX_PATTERN = 200;
@@ -26,11 +30,33 @@ function readCapped(p ) {
26
30
  return st.size > MAX_FILE_BYTES ? text + `\n[file truncated at ${MAX_FILE_BYTES} bytes]` : text;
27
31
  }
28
32
 
29
- // Child processes get the parent environment minus every SYNQA_* variable (the API key).
33
+ // Environment variables that commonly hold credentials, by name: the API key (SYNQA_*), tokens,
34
+ // secrets, keys and passwords (TOKENIZERS_PARALLELISM, AWS_REGION, AWS_DEFAULT_REGION and
35
+ // AWS_PROFILE stay), connection strings, the cloud, code host and package registry families
36
+ // (NPM_* includes npm_config_*), git credential helpers and the SSH agent. Model provider keys are
37
+ // caught by the KEY and TOKEN rules. Narrowing this list must keep the regression list in
38
+ // test/hardening.test.ts passing.
39
+ const SECRET_ENV = [
40
+ /^SYNQA_/, /TOKEN(?!IZER)/, /AUTHTOKEN/, /SECRET/, /(^|_)KEYS?(_|$)/, /APIKEY/, /PASSWORD/, /PASSWD/, /(^|_)PASS$/, /_PWD$/,
41
+ /CREDENTIAL/, /CONNECTION_?STRING/, /(^|_)AUTH$/, /^AUTH_/, /ASKPASS/, /^VSCODE_GIT_/, /^SSH_AUTH_SOCK$/,
42
+ /^SLACK_WEBHOOK_URL$/, /^SENTRY_DSN$/, /^DATABASE_URL$/,
43
+ /^AWS_(?!REGION$|DEFAULT_REGION$|PROFILE$)/, /^GH_/, /^GITHUB_/, /^GITLAB_/, /^NPM_/, /^HF_/, /^HUGGING_?FACE/, /^AZURE_/,
44
+ /^GOOGLE_APPLICATION_CREDENTIALS$/,
45
+ ];
46
+ // A URL, URI or DSN value with a user and password in it (postgres://user:pass@host) is a secret;
47
+ // a plain URL is kept.
48
+ const URL_NAME = /(^|_)(URL|URI|DSN)$/;
49
+ const USERINFO = /^[a-z][a-z0-9+.-]*:\/\/[^/\s@:]*:[^/\s@]*@/i;
50
+
51
+ // Child processes get the parent environment minus every variable that looks like a credential.
52
+ // SSH_AUTH_SOCK is one of them, so git over SSH that needs the agent runs in the user's own terminal.
30
53
  export function bashEnv(env ) {
31
54
  const out = {};
32
55
  for (const [k, v] of Object.entries(env)) {
33
- if (v !== undefined && !k.toUpperCase().startsWith("SYNQA_")) out[k] = v;
56
+ const up = k.toUpperCase();
57
+ if (v === undefined || SECRET_ENV.some((re) => re.test(up))) continue;
58
+ if (URL_NAME.test(up) && USERINFO.test(v.trim())) continue;
59
+ out[k] = v;
34
60
  }
35
61
  return out;
36
62
  }
@@ -43,14 +69,18 @@ export function bashEnv(env )
43
69
 
44
70
 
45
71
 
72
+
46
73
 
47
74
 
75
+
76
+
48
77
 
78
+
49
79
 
50
80
 
51
81
  const MAX_OUT = 20_000;
52
82
  const MAX_RESULTS = 500;
53
- const SKIP_DIRS = new Set([".git", "node_modules", ".ssh", ".aws", ".gnupg", ".kube"]);
83
+ const SKIP_DIRS = new Set([".git", "node_modules", ".ssh", ".aws", ".gnupg", ".kube", ".trash"]);
54
84
 
55
85
  export const TOOL_DEFS = [
56
86
  def("read_file", "Read a text file inside the working directory.", {
@@ -113,7 +143,7 @@ function escapeRe(s ) {
113
143
  return s.replace(/[.+^${}()|[\]\\]/g, "\\$&");
114
144
  }
115
145
 
116
- function walk(root , dir , out ) {
146
+ function walk(root , dir , out , secret ) {
117
147
  if (out.length >= MAX_RESULTS * 20) return;
118
148
  let entries;
119
149
  try {
@@ -124,12 +154,13 @@ function walk(root , dir , out ) {
124
154
  for (const e of entries) {
125
155
  if (SKIP_DIRS.has(fold(e.name))) continue;
126
156
  const full = join(dir, e.name);
127
- if (e.isDirectory()) walk(root, full, out);
157
+ // A folder of credentials or a browser profile is never walked into (it can be huge, too).
158
+ if (e.isDirectory()) { if (!secret(full)) walk(root, full, out, secret); }
128
159
  else if (e.isFile()) out.push(relative(root, full).split(sep).join("/"));
129
160
  }
130
161
  }
131
162
 
132
- const RC_FILES = new Set([".bashrc", ".zshrc", ".profile", ".bash_profile", ".zprofile", ".zshenv", ".bash_login"]);
163
+ const RC_FILES = new Set([".bashrc", ".zshrc", ".profile", ".bash_profile", ".zprofile", ".zshenv", ".bash_login", ".zlogin", ".zlogout", ".bash_logout", ".kshrc", ".cshrc", ".tcshrc", ".login", "config.fish"]);
133
164
 
134
165
  // Why an approved write to this target can run code later (hooks, build files, shell startup files,
135
166
  // CI, editor tasks, executables). Compared folded, so ".GIT/hooks" counts as ".git/hooks".
@@ -139,6 +170,15 @@ export function riskReasons(root , abs ) {
139
170
  const base = parts[parts.length - 1] ?? "";
140
171
  const dirs = parts.slice(0, -1);
141
172
  const reasons = [];
173
+ const full = "/" + fold(abs.split(sep).join("/")).replace(/^\/+/, "");
174
+ if (rel === ".." || rel.startsWith("../") || isAbsolute(rel)) reasons.push("outside the workspace");
175
+ if (/\/library\/(launchagents|launchdaemons)\//.test(full)) reasons.push("a launchd job (runs at login or boot)");
176
+ if (full.includes("/.config/autostart/")) reasons.push("an autostart entry (runs at login)");
177
+ if (/\/(\.config|\.local\/share)\/systemd\//.test(full)) reasons.push("a systemd unit (runs as a service)");
178
+ if (full.includes("/.config/fish/")) reasons.push("a fish startup file (runs in every new shell)");
179
+ if (full.includes("/.local/bin/")) reasons.push("a program on your PATH");
180
+ if (full.includes("/start menu/programs/startup/")) reasons.push("a Windows startup entry (runs at login)");
181
+ if (dirs.includes(".synqa") && base === "hooks.json") reasons.push("Synqa hooks (commands that run at session start and around tools)");
142
182
  if (rel.startsWith(".git/hooks/") || rel.includes("/.git/hooks/")) reasons.push("a git hook (runs on git actions)");
143
183
  else if (dirs.includes(".git")) reasons.push("inside .git");
144
184
  if (base === "package.json") reasons.push("package.json (scripts can run on install or test)");
@@ -179,19 +219,31 @@ export function hasNestedQuantifier(pattern ) {
179
219
  return NESTED_QUANTIFIER.test(pattern);
180
220
  }
181
221
 
182
- // A file that holds secrets is opened only after an explicit yes. With --yes, or with nobody to ask,
183
- // the answer is no: a prompt injection must not be able to read keys by running unattended. Every
184
- // tool that reads a file's bytes (read_file, edit_file) goes through this before opening it.
185
- async function sensitiveGate(ctx , p , verb ) {
186
- if (!isSensitivePath(p)) return undefined;
187
- if (ctx.yes) return "error: refused: this file may hold secrets and --yes never opens those";
188
- if (!(await ctx.confirm(`${verb} ${visibleEscapes(p)}? It may hold secrets, and its text would be sent to the model.`))) return "denied by user";
189
- return undefined;
222
+ // A file that holds secrets (see isSensitivePath) is never opened, written or edited by a tool:
223
+ // not with --yes, not with a yes to a question. The answer names no content, so it is not an
224
+ // oracle either. The person can open the file themselves; the model never gets its text.
225
+ export const SECRET_REFUSAL = "error: refused: this file may hold secrets (keys, tokens or credentials), so Synqa never reads, writes or edits it. Open it yourself if you need to.";
226
+
227
+ const secretIn = (ctx ) => (p ) => isSensitivePath(p, undefined, ctx.home);
228
+
229
+ function sensitiveGate(ctx , p ) {
230
+ return secretIn(ctx)(p) ? SECRET_REFUSAL : undefined;
231
+ }
232
+
233
+ // grep and glob also skip source files named like a secret store (secrets.py): see isSecretStemSource.
234
+ const skipIn = (ctx ) => (p ) => secretIn(ctx)(p) || isSecretStemSource(p);
235
+
236
+ // secrets.py, credentials.js and the like are opened only after an explicit yes, --yes or not;
237
+ // with nobody to ask the answer is no.
238
+ async function stemGate(ctx , p , verb ) {
239
+ if (!isSecretStemSource(p)) return undefined;
240
+ const ok = await ctx.confirm(`${verb} ${visibleEscapes(p)}? Its name says it may hold secrets, and its text would be sent to the model.`);
241
+ return ok ? undefined : "denied by user";
190
242
  }
191
243
 
192
244
  async function toolRead(ctx , a ) {
193
245
  const p = confine(ctx.cwd, str(a, "path"));
194
- const refused = await sensitiveGate(ctx, p, "Read");
246
+ const refused = sensitiveGate(ctx, p) ?? (await stemGate(ctx, p, "Read"));
195
247
  if (refused) return refused;
196
248
  return truncate(readCapped(p));
197
249
  }
@@ -203,20 +255,22 @@ async function approve(ctx , question , path , before
203
255
  const rel = relative(ctx.cwd, path) || path;
204
256
  // A file that can run code later (hook, package.json, shell startup file, dotfile) is shown in
205
257
  // full: a payload on line 60 must not hide behind a cut.
206
- const risky = riskReasons(ctx.cwd, path).length > 0 || isSensitivePath(path);
207
- const maxLines = risky ? Infinity : MAX_DIFF_LINES;
208
- const detail = before === "too-large"
258
+ // A write to a secrets-named source file (secrets.py) asks too, like reading it does.
259
+ const risky = riskReasons(ctx.cwd, path).length > 0 || isSecretStemSource(path);
260
+ const diff = (maxLines ) => before === "too-large"
209
261
  ? " (the existing file is too large to preview)"
210
- : renderDiff(unifiedDiff(rel, before === null ? null : before.toString("utf8"), after), { color: ctx.color ?? false }, maxLines);
262
+ : renderDiff(unifiedDiff(rel, before === null ? null : before.toString("utf8"), after), ctx.style ?? { color: ctx.color ?? false, depth: 8 }, maxLines);
211
263
  // --yes skips the question only for an ordinary file with no AGENTS.md loaded. A write that can
212
264
  // run code later, a secrets file, or any write while repository text steers the model always asks
213
265
  // (and is denied when nobody can answer): a shell prompt is no boundary if another tool can plant
214
266
  // a hook, a Makefile or a workflow that runs without one.
215
267
  if (ctx.yes && !risky && !ctx.notesLoaded) {
216
- ctx.show?.(detail);
268
+ // Already approved by --yes: the shown diff may be cut for length.
269
+ ctx.show?.(diff(MAX_DIFF_LINES));
217
270
  return true;
218
271
  }
219
- return ctx.confirm(question, detail);
272
+ // A question always shows the whole change: nothing can hide below a cut while you decide.
273
+ return ctx.confirm(question, diff(Infinity));
220
274
  }
221
275
 
222
276
  function journalWrite(ctx , written , before , after ) {
@@ -228,6 +282,8 @@ function journalWrite(ctx , written , before , after
228
282
  async function toolWrite(ctx , a ) {
229
283
  const p = confine(ctx.cwd, str(a, "path"));
230
284
  const content = str(a, "content");
285
+ const refused = sensitiveGate(ctx, p);
286
+ if (refused) return refused;
231
287
  const before = snapshot(p);
232
288
  const q = `Write ${content.length} chars to ${visibleEscapes(p)}?${riskNote(ctx.cwd, p)}`;
233
289
  if (!(await approve(ctx, q, p, before, content))) return "denied by user";
@@ -240,7 +296,7 @@ async function toolEdit(ctx , a )
240
296
  const p = confine(ctx.cwd, str(a, "path"));
241
297
  const oldS = str(a, "old_string");
242
298
  const newS = str(a, "new_string");
243
- const refused = await sensitiveGate(ctx, p, "Edit");
299
+ const refused = sensitiveGate(ctx, p) ?? (await stemGate(ctx, p, "Edit"));
244
300
  if (refused) return refused;
245
301
  const text = readCapped(p);
246
302
  if (text.endsWith(`[file truncated at ${MAX_FILE_BYTES} bytes]`)) return "error: file too large to edit";
@@ -259,25 +315,50 @@ async function toolEdit(ctx , a )
259
315
  function toolGlob(ctx , a ) {
260
316
  const re = globToRegExp(str(a, "pattern"));
261
317
  const all = [];
262
- walk(ctx.cwd, ctx.cwd, all);
263
- const hits = all.filter((f) => re.test(f) && !isSensitivePath(join(ctx.cwd, f))).sort().slice(0, MAX_RESULTS);
318
+ walk(ctx.cwd, ctx.cwd, all, secretIn(ctx));
319
+ const hits = all.filter((f) => re.test(f) && !skipIn(ctx)(join(ctx.cwd, f))).sort().slice(0, MAX_RESULTS);
264
320
  return hits.length ? hits.join("\n") : "no matches";
265
321
  }
266
322
 
267
- function toolGrep(ctx , a ) {
323
+ // The matching loop runs in a worker thread, so a pattern that backtracks badly ((a|a)*$ and the
324
+ // like, which no static check catches) is stopped after GREP_BUDGET_MS instead of hanging the session.
325
+ const GREP_BUDGET_MS = 10_000;
326
+ const GREP_WORKER = `
327
+ const { parentPort, workerData } = require("node:worker_threads");
328
+ const re = new RegExp(workerData.pattern);
329
+ const out = [];
330
+ for (const f of workerData.files) {
331
+ if (out.length >= workerData.max) break;
332
+ f.text.split("\\n").forEach((line, i) => {
333
+ if (out.length < workerData.max && re.test(line.slice(0, 2000))) out.push(f.rel + ":" + (i + 1) + ":" + line.slice(0, 300));
334
+ });
335
+ }
336
+ parentPort.postMessage(out);
337
+ `;
338
+
339
+ function grepInWorker(pattern , files , budgetMs ) {
340
+ return new Promise((resolve, reject) => {
341
+ const w = new Worker(GREP_WORKER, { eval: true, workerData: { pattern, files, max: MAX_RESULTS } });
342
+ const timer = setTimeout(() => { void w.terminate(); resolve("timeout"); }, budgetMs);
343
+ w.once("message", (m ) => { clearTimeout(timer); void w.terminate(); resolve(m); });
344
+ w.once("error", (e) => { clearTimeout(timer); reject(e); });
345
+ });
346
+ }
347
+
348
+ async function toolGrep(ctx , a ) {
268
349
  const pat = str(a, "pattern");
269
350
  if (pat.length > MAX_PATTERN) return `error: pattern longer than ${MAX_PATTERN} characters`;
270
351
  if (hasNestedQuantifier(pat)) return "error: pattern has a quantified group containing a quantifier (nested quantifier); rewrite it";
271
- const re = new RegExp(pat);
352
+ new RegExp(pat); // a syntax error is reported here, before any file is read
272
353
  const base = confine(ctx.cwd, typeof a.path === "string" && a.path ? a.path : ".");
273
- const files = [];
274
- if (statSync(base).isDirectory()) walk(base, base, files);
275
- else files.push("");
276
- const out = [];
277
- for (const f of files) {
278
- if (out.length >= MAX_RESULTS) break;
354
+ const paths = [];
355
+ if (statSync(base).isDirectory()) walk(base, base, paths, secretIn(ctx));
356
+ else paths.push("");
357
+ const files = [];
358
+ let bytes = 0;
359
+ for (const f of paths) {
279
360
  const full = f ? join(base, f) : base;
280
- if (isSensitivePath(full)) continue;
361
+ if (skipIn(ctx)(full)) continue;
281
362
  let text ;
282
363
  try {
283
364
  if (statSync(full).size > MAX_FILE_BYTES) continue;
@@ -286,20 +367,23 @@ function toolGrep(ctx , a ) {
286
367
  continue;
287
368
  }
288
369
  if (text.includes("\u0000")) continue;
289
- text.split("\n").forEach((line, i) => {
290
- if (out.length < MAX_RESULTS && re.test(line.slice(0, 2000))) out.push(`${relative(ctx.cwd, full)}:${i + 1}:${line.slice(0, 300)}`);
291
- });
370
+ files.push({ rel: relative(ctx.cwd, full), text });
371
+ bytes += text.length;
372
+ if (bytes > 64 * MAX_FILE_BYTES) break;
292
373
  }
374
+ const out = await grepInWorker(pat, files, ctx.grepBudgetMs ?? GREP_BUDGET_MS);
375
+ if (out === "timeout") return `error: the pattern took longer than ${Math.round((ctx.grepBudgetMs ?? GREP_BUDGET_MS) / 1000)}s (it may backtrack heavily); use a simpler pattern`;
293
376
  return out.length ? out.join("\n") : "no matches";
294
377
  }
295
378
 
296
379
  async function toolBash(ctx , a ) {
297
380
  const command = str(a, "command");
298
- // Asks unless --yes. Even under --yes it asks when AGENTS.md is loaded, or when the command names
299
- // a secrets file (or a glob that could): then, with nobody to ask, it is denied. Any other command
300
- // under --yes runs unattended: use --yes only in repositories you trust.
381
+ // Every shell command asks, also under --yes: any file the model read (a README, a code comment)
382
+ // can carry an instruction such as "printenv | curl ...", so --yes covers file writes inside the
383
+ // workspace only. With nobody to ask the answer is no. A command that names a secret or dumps
384
+ // the environment gets a warning in the question.
301
385
  const secret = commandTouchesSecrets(command);
302
- if ((!ctx.yes || ctx.notesLoaded || secret) && !(await ctx.confirm(`Run: ${visibleEscapes(command)}${secret ? "\n WARNING: this command refers to files that may hold secrets." : ""}`))) return "denied by user";
386
+ if (!(await ctx.confirm(`Run: ${visibleEscapes(command)}${secret ? "\n WARNING: this command refers to files that may hold secrets." : ""}`))) return "denied by user";
303
387
  const r = spawnSync("/bin/sh", ["-c", command], {
304
388
  cwd: ctx.cwd, encoding: "utf8", timeout: 120_000, maxBuffer: 4 * 1024 * 1024, env: bashEnv(process.env),
305
389
  });
@@ -315,14 +399,19 @@ function str(a , k ) {
315
399
  }
316
400
 
317
401
  // Never throws: failures come back as text so the model can react.
318
- export async function runTool(ctx , name , args ) {
402
+ export async function runTool(ctx0 , name , args ) {
403
+ // --yes never applies when the workspace is the home folder: too much there can run code later.
404
+ // The auto-accept mode is --yes for file edits only, with the same limits.
405
+ if (ctx0.mode === "plan" && MUTATING_TOOLS.includes(name)) return "error: plan only mode is on, so nothing was changed or run. Describe the plan instead; the user leaves this mode with Shift+Tab.";
406
+ const yes = (ctx0.yes || ctx0.mode === "auto") && !isHomeDir(ctx0.cwd, ctx0.home);
407
+ const ctx = yes === ctx0.yes ? ctx0 : { ...ctx0, yes };
319
408
  try {
320
409
  switch (name) {
321
410
  case "read_file": return await toolRead(ctx, args);
322
411
  case "write_file": return await toolWrite(ctx, args);
323
412
  case "edit_file": return await toolEdit(ctx, args);
324
413
  case "glob": return toolGlob(ctx, args);
325
- case "grep": return toolGrep(ctx, args);
414
+ case "grep": return await toolGrep(ctx, args);
326
415
  case "bash": return await toolBash(ctx, args);
327
416
  default: return `error: unknown tool ${name}`;
328
417
  }
package/dist/ui.js CHANGED
@@ -11,9 +11,10 @@ import { formatUsd } from "./account.js";
11
11
 
12
12
  import { sanitize, visibleEscapes } from "./sanitize.js";
13
13
  import { groupDigits } from "./statusbar.js";
14
-
15
- import { accent, bold, boneBold, colorDepth, colorEnabled, red, sage, stone, stoneBold, terracotta } from "./theme.js";
16
-
14
+ import { CONTEXT_TOKENS } from "./tiers.js";
15
+
16
+ import { bold, colorDepth, colorEnabled, detectBackground, dim, red, sage, stone, terracotta, terracottaBold } from "./theme.js";
17
+
17
18
 
18
19
  // ---------------------------------------------------------------- terminal capabilities
19
20
 
@@ -22,7 +23,8 @@ import { accent, bold, boneBold, colorDepth, colorEnabled, red, sage, stone, sto
22
23
 
23
24
 
24
25
 
25
-
26
+
27
+
26
28
 
27
29
 
28
30
 
@@ -46,13 +48,14 @@ export function makeUi(env , out , platform = process.platfo
46
48
  return {
47
49
  color: colorEnabled(env, tty),
48
50
  depth: colorDepth(env),
51
+ bg: detectBackground(env),
49
52
  unicode: detectUnicode(env, platform),
50
53
  tty,
51
54
  get columns() { return Math.max(20, cols()); },
52
55
  };
53
56
  }
54
57
 
55
- export const styleOf = (ui ) => ({ color: ui.color, depth: ui.depth });
58
+ export const styleOf = (ui ) => ({ color: ui.color, depth: ui.depth, bg: ui.bg });
56
59
 
57
60
 
58
61
 
@@ -154,7 +157,7 @@ export function shortenHome(path , home = homedir()) {
154
157
  return path;
155
158
  }
156
159
 
157
- const pad = (s , w ) => s + " ".repeat(Math.max(0, w - textWidth(s)));
160
+ export const pad = (s , w ) => s + " ".repeat(Math.max(0, w - textWidth(s)));
158
161
 
159
162
  // ---------------------------------------------------------------- version
160
163
 
@@ -189,87 +192,153 @@ export function balanceLine(b ) {
189
192
  return credit;
190
193
  }
191
194
 
192
- // ---------------------------------------------------------------- banner
195
+ // ---------------------------------------------------------------- start screen
193
196
 
194
197
 
195
198
 
196
199
 
200
+
197
201
 
198
202
 
199
203
 
200
204
 
205
+  
206
+
207
+
201
208
 
202
209
 
203
- // "Synqa. CLI v1.1.0" with the name in bold bone and the period in terracotta. `text` may have
204
- // been cut by fit(); anything that no longer starts with "Synqa." is painted whole.
205
- function paintTitle(s , text ) {
206
- if (!text.startsWith("Synqa.")) return boneBold(s, text);
207
- return boneBold(s, "Synqa") + terracotta(s, ".") + boneBold(s, text.slice(6));
210
+ // "Synqa." on one line: the name bold in the terminal's own text colour, the period terracotta.
211
+ function paintName(s ) {
212
+ return bold(s, "Synqa") + terracotta(s, ".");
208
213
  }
209
214
 
210
- const TILE_W = 6;
211
- const TILE_GAP = 2;
212
-
213
- // The 3-line monogram: a rounded terracotta tile with a bold bone S and a terracotta period.
214
- function monogram(s ) {
215
- return [
216
- terracotta(s, "╭────╮"),
217
- terracotta(s, "│ ") + boneBold(s, "S") + terracotta(s, ". │"),
218
- terracotta(s, "╰────╯"),
215
+ // The wordmark in half-block letters, four rows: the S at cap height, y n q a at x-height with the
216
+ // y and q descenders on the last row. Letters carry no colour code, so they are drawn in the
217
+ // terminal's own text colour (ink on a light background, bone-like on a dark one); the full stop
218
+ // is a terracotta square on the baseline.
219
+ const LETTERS = {
220
+ S: ["▄▀▀▀", " ▀▀▄", "▄▄▄▀", " "],
221
+ y: [" ", "█ █", "▀▄▄█", "▄▄▄▀"],
222
+ n: [" ", "█▀▀▄", "█ █", " "],
223
+ q: [" ", "▄▀▀█", "▀▄▄█", " █"],
224
+ a: [" ", "▄▀▀█", "▀▄▄█", " "],
225
+ };
226
+ export const WORDMARK = [0, 1, 2, 3].map((r) => ["S", "y", "n", "q", "a"].map((c) => (LETTERS[c] )[r]).join(" ").trimEnd());
227
+ export const WORDMARK_W = Math.max(...WORDMARK.map(textWidth)) + 2; // plus a space and the dot
228
+ const DOT_ROW = 2;
229
+
230
+ // The start screen header, in the style of modern coding CLIs, with no box: the Synqa wordmark (60 columns and up,
231
+ // on a UTF-8 terminal), or a one-line bold "Synqa." below that; then dim lines with the version,
232
+ // tier and effort, and the workspace; then dim "⎿" lines for notes and warnings. Every line fits
233
+ // in columns - 1 cells (visible width, escapes not counted), so nothing wraps. Fields are our own
234
+ // tables or sanitized; the API key is never an input.
235
+ export function renderBanner(info , ui ) {
236
+ const s = styleOf(ui);
237
+ const room = ui.columns - 1;
238
+ const sep = ui.unicode ? " · " : " | ";
239
+ const version = `v${oneLine(info.version)}`;
240
+ const effort = `${info.effort ?? "default"} effort`;
241
+ const where = oneLine(shortenHome(info.cwd, info.home));
242
+ const notes = [];
243
+ if (info.locked) notes.push(["Sign in to Synqa Cloud to unlock chat and other providers", (t) => dim(s, t)]);
244
+ else notes.push([info.notesChars > 0 ? `AGENTS.md loaded (${groupDigits(info.notesChars)} chars)` : "no AGENTS.md yet: /init writes one", (t) => dim(s, t)]);
245
+ if (info.locked) { /* no balance before sign-in */ } else if (info.balance.kind === "ok") notes.push([balanceLine(info.balance).replace(" · ", sep), (t) => sage(s, t)]);
246
+ else notes.push([balanceLine(info.balance), (t) => dim(s, t)]);
247
+ for (const w of info.warnings ?? []) notes.push([oneLine(w), (t) => dim(s, t)]);
248
+ const lead = " ";
249
+ const hook = ui.unicode ? "⎿ " : "- ";
250
+ const noteLines = notes.map(([t, p]) => dim(s, lead + hook) + p(fit(t, room - lead.length - hook.length, ui)));
251
+ const w = room - lead.length;
252
+ const meta = [
253
+ lead + dim(s, fit(`${version}${sep}${info.provider ? [info.provider.name, info.provider.model].filter(Boolean).map((x) => oneLine(x )).join(sep) : info.tier.display}${sep}${effort}`, w, ui)),
254
+ lead + dim(s, fitPath(where, w, ui)),
219
255
  ];
256
+ if (!ui.unicode || ui.columns < 60) {
257
+ const name = w >= 6 ? lead + paintName(s) : lead + bold(s, fit("Synqa.", w, ui));
258
+ return [name, ...meta, ...noteLines];
259
+ }
260
+ const mark = WORDMARK.map((row, i) => lead + row + (i === DOT_ROW ? " " + terracotta(s, "▄") : ""));
261
+ return [...mark, "", ...meta, ...noteLines];
220
262
  }
221
263
 
222
- // A boxed welcome panel. Fields are plain text built from our own tables or sanitized; the API key
223
- // is never an input. On a terminal that is not UTF-8, or a very narrow one, there is no box or
224
- // tile: a "[S.] Synqa. CLI v..." line and the rows.
225
- export function renderBanner(info , ui ) {
264
+ // A thin rule across the terminal, one cell short of the edge so it never wraps.
265
+ export function rule(ui ) {
266
+ return stone(styleOf(ui), (ui.unicode ? "─" : "-").repeat(Math.max(1, ui.columns - 1)));
267
+ }
268
+
269
+ export const PROMPT_MARK = (ui ) => (ui.unicode ? "❯" : ">");
270
+
271
+ export function promptText(ui ) {
272
+ return `${terracottaBold(styleOf(ui), PROMPT_MARK(ui))} `;
273
+ }
274
+
275
+ export const PLACEHOLDER = 'Try "explain this project", or type / for commands';
276
+
277
+
278
+
279
+
280
+
281
+
282
+
283
+
284
+
285
+
286
+
287
+
288
+
289
+
290
+
291
+
292
+ // The two lines under the input: what is known about the session, then key hints. Unknown values
293
+ // are left out (no "?" or "-" placeholders); separators are dim. The least important parts drop
294
+ // first when the line would not fit in columns - 1 cells.
295
+ export function renderStatusLines(p , ui ) {
226
296
  const s = styleOf(ui);
297
+ const room = ui.columns - 1;
227
298
  const sep = ui.unicode ? " · " : " | ";
228
- const notes = info.notesChars > 0 ? `AGENTS.md loaded (${groupDigits(info.notesChars)} chars)` : "no AGENTS.md";
229
- const balance = balanceLine(info.balance).replace(" · ", sep);
230
- const balancePaint = (t ) => (info.balance.kind === "ok" ? sage(s, t) : stone(s, t));
231
- const rows = [
232
- ["Tier", `${info.tier.id} (${info.tier.display})`, (t) => boneBold(s, t)],
233
- ["Workspace", oneLine(shortenHome(info.cwd, info.home)), (t) => t],
234
- ["Notes", notes, (t) => t],
235
- ["Balance", balance, balancePaint],
236
- ];
237
- const version = oneLine(info.version);
238
- const title = `Synqa. CLI v${version}`;
239
- const labelW = Math.max(...rows.map((r) => r[0].length)) + 2;
240
- const minBox = 30;
241
- if (!ui.unicode || ui.columns < minBox) {
242
- const room = ui.columns - 1;
243
- const head = fit(`[S.] ${title}`, room, ui);
244
- const first = head.startsWith("[S.] ")
245
- ? terracotta(s, "[S.]") + " " + paintTitle(s, head.slice(5))
246
- : boneBold(s, head);
247
- return [first, ...rows.map(([l, v, p]) => {
248
- const text = fit(` ${pad(l, labelW)}${v}`, room, ui);
249
- const lead = 2 + labelW;
250
- return text.length <= lead ? stoneBold(s, text) : stoneBold(s, text.slice(0, lead)) + p(text.slice(lead));
251
- })];
299
+ const home = shortenHome(p.cwd, p.home);
300
+ const ws = home === "~" ? "~" : oneLine(home.split(/[\\/]/).filter(Boolean).pop() ?? home);
301
+
302
+ const st = (t ) => stone(s, t);
303
+ const head = p.provider ? [p.provider.name, p.provider.model].filter(Boolean).map((x) => oneLine(x )).join(sep) : p.tier.display;
304
+ const parts = [{ k: "tier", t: head, paint: (t) => terracottaBold(s, t) }];
305
+ if (p.mode) parts.push({ k: "mode", t: oneLine(p.mode), paint: st });
306
+ if (p.effort !== "default") parts.push({ k: "effort", t: `${p.effort} effort`, paint: st });
307
+ parts.push({ k: "ws", t: Array.from(ws).slice(0, 32).join(""), paint: st });
308
+ const ctxMax = p.contextMax ?? CONTEXT_TOKENS;
309
+ if (p.contextTokens) parts.push({ k: "ctx", t: `ctx ${Math.min(100, Math.round((100 * p.contextTokens) / ctxMax))}%`, paint: st });
310
+ if (p.routing && p.routing !== "?") parts.push({ k: "routing", t: Array.from(oneLine(p.routing)).slice(0, 12).join(""), paint: st });
311
+ parts.push({ k: "tokens", t: `${groupDigits(p.tokens)} tokens`, paint: st });
312
+ if (p.updateHint) parts.push({ k: "update", t: oneLine(p.updateHint), paint: (t) => sage(s, t) });
313
+ const right = p.balance && p.balance.kind === "ok" ? `${formatUsd(p.balance.account.creditRemaining)} credit` : "";
314
+ const kept = parts.slice();
315
+ let showRight = right !== "";
316
+ const leftW = () => kept.reduce((w, x, i) => w + textWidth(x.t) + (i ? sep.length : 0), 0);
317
+ const fits = () => leftW() + (showRight ? 2 + textWidth(right) : 0) <= room;
318
+ // Dropped first to last: routing, ctx, workspace, effort, the balance, then tokens.
319
+ for (const k of ["update", "routing", "ctx", "ws", "effort", "mode", "right", "tokens"]) {
320
+ if (fits()) break;
321
+ if (k === "right") { showRight = false; continue; }
322
+ const i = kept.findIndex((x) => x.k === k);
323
+ if (i > 0) kept.splice(i, 1);
252
324
  }
253
- const tile = ui.columns >= 46;
254
- const left = tile ? TILE_W + TILE_GAP : 0;
255
- const natural = Math.max(textWidth(title) + 4, left + Math.max(...rows.map((r) => labelW + textWidth(r[1])))) + 4;
256
- const width = Math.min(natural, ui.columns - 1);
257
- const inner = width - 4; // "│ " ... " │"
258
- const edge = (t ) => stone(s, t);
259
- const top = (() => {
260
- const t = fit(title, width - 6, ui);
261
- const fill = width - 5 - textWidth(t); // "╭─ " + title + " " + fill + "╮"
262
- return edge("╭─ ") + paintTitle(s, t) + edge(" " + "─".repeat(Math.max(0, fill)) + "╮");
263
- })();
264
- const mono = tile ? monogram(s) : [];
265
- const body = rows.map(([label, value, paint], i) => {
266
- const lead = tile ? (mono[i] ?? " ".repeat(TILE_W)) + " ".repeat(TILE_GAP) : "";
267
- const v = fit(value, inner - left - labelW, ui);
268
- const gap = " ".repeat(Math.max(0, inner - left - labelW - textWidth(v)));
269
- return edge("│") + " " + lead + stoneBold(s, pad(label, labelW)) + paint(v) + gap + " " + edge("│");
270
- });
271
- const bottom = edge("╰" + "─".repeat(width - 2) + "╯");
272
- return [top, ...body, bottom];
325
+ let line = kept.map((x) => x.paint(x.t)).join(dim(s, sep));
326
+ if (!fits()) line = terracottaBold(s, fit(head, room, ui));
327
+ else if (showRight) line += " ".repeat(Math.max(2, room - leftW() - textWidth(right))) + sage(s, right);
328
+ return [line, keyHints(ui)];
329
+ }
330
+
331
+ // "/ commands · ↑↓ history · esc clear · ctrl+c quit": keys bold, labels dim, cut to fit.
332
+ export function keyHints(ui ) {
333
+ const s = styleOf(ui);
334
+ const room = ui.columns - 1;
335
+ const hints = [["/", "commands"], [ui.unicode ? "↑↓" : "up/down", "history"], ["tab", "complete"], ["ctrl+c", "quit"]];
336
+ const sep = ui.unicode ? " · " : " | ";
337
+ const kept = hints.slice();
338
+ const w = () => kept.reduce((n, [k, l], i) => n + k.length + 1 + l.length + (i ? sep.length : 0), 0);
339
+ while (kept.length > 1 && w() > room) kept.splice(kept.length - 2, 1);
340
+ if (w() > room) return dim(s, fit("/ for commands", room, ui));
341
+ return kept.map(([k, l]) => bold(s, k) + " " + dim(s, l)).join(dim(s, sep));
273
342
  }
274
343
 
275
344
  // ---------------------------------------------------------------- spinner
@@ -282,10 +351,22 @@ export function renderBanner(info , ui ) {
282
351
 
283
352
 
284
353
 
354
+
285
355
 
286
356
 
287
357
  const CR_ERASE = "\r\x1b[2K";
288
358
 
359
+ // The thinking mark: [S] with the dot bobbing . then middle dot, ring-less dot, middle dot. Every frame
360
+ // is exactly four cells wide and one line, so nothing around it ever shifts.
361
+ const BOB_UNI = [".", "\u00b7", "\u02d9", "\u00b7"];
362
+ const BOB_ASCII = [".", ",", "\x27", ","];
363
+ export const THINKING_FPS = 8;
364
+ export const THINKING_INTERVAL_MS = 125; // 8 frames per second, only while waiting for the first token
365
+ export function thinkingFrame(i , ui ) {
366
+ const bob = ui.unicode ? BOB_UNI : BOB_ASCII;
367
+ return `[S${bob[((i % bob.length) + bob.length) % bob.length] }]`;
368
+ }
369
+
289
370
  export class Spinner {
290
371
  out ;
291
372
  o ;
@@ -314,7 +395,7 @@ export class Spinner {
314
395
  (h ).unref?.();
315
396
  return h;
316
397
  });
317
- this.timer = set(() => this.draw(), this.o.intervalMs ?? 80);
398
+ this.timer = set(() => this.draw(), this.o.intervalMs ?? THINKING_INTERVAL_MS);
318
399
  }
319
400
 
320
401
  elapsedText() {
@@ -323,8 +404,7 @@ export class Spinner {
323
404
  }
324
405
 
325
406
  draw() {
326
- const g = glyphs(this.o.ui);
327
- const f = g.spin[this.frame++ % g.spin.length] ;
407
+ const f = (this.o.frameFn ?? ((i ) => thinkingFrame(i, this.o.ui)))(this.frame++);
328
408
  const st = styleOf(this.o.ui);
329
409
  const text = fit(`${f} waiting ${this.elapsedText()}`, this.o.ui.columns - 1, this.o.ui);
330
410
  // The frame is terracotta, the rest stone. fit() keeps the frame first, so the split is safe.