@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/README.md +145 -57
- package/bin/synqa.js +0 -0
- package/dist/agent.js +19 -5
- package/dist/api.js +46 -4
- package/dist/commands.js +150 -30
- package/dist/compact.js +4 -1
- package/dist/config.js +52 -9
- package/dist/hooks.js +219 -0
- package/dist/main.js +275 -91
- package/dist/modes.js +20 -0
- package/dist/paths.js +157 -38
- package/dist/prompt.js +381 -0
- package/dist/providers.js +234 -0
- package/dist/sanitize.js +6 -0
- package/dist/select.js +131 -0
- package/dist/statusbar.js +16 -9
- package/dist/theme.js +47 -7
- package/dist/tiers.js +1 -1
- package/dist/tools.js +133 -44
- package/dist/ui.js +152 -72
- package/dist/update.js +71 -0
- package/package.json +1 -1
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 {
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
183
|
-
//
|
|
184
|
-
//
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
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
|
|
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
|
-
|
|
207
|
-
const
|
|
208
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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) && !
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
274
|
-
if (statSync(base).isDirectory()) walk(base, base,
|
|
275
|
-
else
|
|
276
|
-
const
|
|
277
|
-
|
|
278
|
-
|
|
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 (
|
|
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
|
-
|
|
290
|
-
|
|
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
|
-
//
|
|
299
|
-
//
|
|
300
|
-
//
|
|
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 (
|
|
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(
|
|
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
|
-
|
|
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
|
-
// ----------------------------------------------------------------
|
|
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.
|
|
204
|
-
|
|
205
|
-
|
|
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
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
//
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
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
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
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
|
|
229
|
-
const
|
|
230
|
-
|
|
231
|
-
const
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
const
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
if (
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
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
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
const
|
|
265
|
-
const
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
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 ??
|
|
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
|
|
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.
|