@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/README.md +83 -53
- package/dist/api.js +44 -3
- package/dist/commands.js +6 -13
- package/dist/main.js +146 -40
- package/dist/paths.js +150 -38
- package/dist/prompt.js +330 -0
- package/dist/statusbar.js +16 -9
- package/dist/theme.js +47 -7
- package/dist/tools.js +125 -44
- package/dist/ui.js +129 -69
- package/package.json +1 -1
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
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
183
|
-
//
|
|
184
|
-
//
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
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
|
|
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
|
-
|
|
207
|
-
const
|
|
208
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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) && !
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
274
|
-
if (statSync(base).isDirectory()) walk(base, base,
|
|
275
|
-
else
|
|
276
|
-
const
|
|
277
|
-
|
|
278
|
-
|
|
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 (
|
|
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
|
-
|
|
290
|
-
|
|
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
|
-
//
|
|
299
|
-
//
|
|
300
|
-
//
|
|
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 (
|
|
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(
|
|
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
|
-
|
|
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
|
-
// ----------------------------------------------------------------
|
|
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.
|
|
204
|
-
|
|
205
|
-
|
|
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
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
//
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
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
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
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
|
|
229
|
-
const
|
|
230
|
-
|
|
231
|
-
const
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
const
|
|
240
|
-
const
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
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
|
-
|
|
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];
|
|
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
|