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