@synqa/cli 1.1.1 → 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 +66 -8
- package/bin/synqa.js +0 -0
- package/dist/agent.js +19 -5
- package/dist/api.js +2 -1
- package/dist/commands.js +144 -17
- package/dist/compact.js +4 -1
- package/dist/config.js +52 -9
- package/dist/hooks.js +219 -0
- package/dist/main.js +144 -66
- package/dist/modes.js +20 -0
- package/dist/paths.js +22 -15
- package/dist/prompt.js +56 -5
- package/dist/providers.js +234 -0
- package/dist/sanitize.js +6 -0
- package/dist/select.js +131 -0
- package/dist/tiers.js +1 -1
- package/dist/tools.js +9 -1
- package/dist/ui.js +29 -9
- package/dist/update.js +71 -0
- package/package.json +1 -1
package/dist/ui.js
CHANGED
|
@@ -203,6 +203,8 @@ export function balanceLine(b ) {
|
|
|
203
203
|
|
|
204
204
|
|
|
205
205
|
|
|
206
|
+
|
|
207
|
+
|
|
206
208
|
|
|
207
209
|
|
|
208
210
|
// "Synqa." on one line: the name bold in the terminal's own text colour, the period terracotta.
|
|
@@ -238,8 +240,9 @@ export function renderBanner(info , ui ) {
|
|
|
238
240
|
const effort = `${info.effort ?? "default"} effort`;
|
|
239
241
|
const where = oneLine(shortenHome(info.cwd, info.home));
|
|
240
242
|
const notes = [];
|
|
241
|
-
notes.push([
|
|
242
|
-
|
|
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)]);
|
|
243
246
|
else notes.push([balanceLine(info.balance), (t) => dim(s, t)]);
|
|
244
247
|
for (const w of info.warnings ?? []) notes.push([oneLine(w), (t) => dim(s, t)]);
|
|
245
248
|
const lead = " ";
|
|
@@ -247,7 +250,7 @@ export function renderBanner(info , ui ) {
|
|
|
247
250
|
const noteLines = notes.map(([t, p]) => dim(s, lead + hook) + p(fit(t, room - lead.length - hook.length, ui)));
|
|
248
251
|
const w = room - lead.length;
|
|
249
252
|
const meta = [
|
|
250
|
-
lead + dim(s, fit(`${version}${sep}${info.tier.display}${sep}${effort}`, w, ui)),
|
|
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)),
|
|
251
254
|
lead + dim(s, fitPath(where, w, ui)),
|
|
252
255
|
];
|
|
253
256
|
if (!ui.unicode || ui.columns < 60) {
|
|
@@ -281,6 +284,9 @@ export const PLACEHOLDER = 'Try "explain this project", or type / for commands';
|
|
|
281
284
|
|
|
282
285
|
|
|
283
286
|
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
|
|
284
290
|
|
|
285
291
|
|
|
286
292
|
// The two lines under the input: what is known about the session, then key hints. Unknown values
|
|
@@ -294,27 +300,30 @@ export function renderStatusLines(p , ui ) {
|
|
|
294
300
|
const ws = home === "~" ? "~" : oneLine(home.split(/[\\/]/).filter(Boolean).pop() ?? home);
|
|
295
301
|
|
|
296
302
|
const st = (t ) => stone(s, t);
|
|
297
|
-
const
|
|
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 });
|
|
298
306
|
if (p.effort !== "default") parts.push({ k: "effort", t: `${p.effort} effort`, paint: st });
|
|
299
307
|
parts.push({ k: "ws", t: Array.from(ws).slice(0, 32).join(""), paint: st });
|
|
300
308
|
const ctxMax = p.contextMax ?? CONTEXT_TOKENS;
|
|
301
309
|
if (p.contextTokens) parts.push({ k: "ctx", t: `ctx ${Math.min(100, Math.round((100 * p.contextTokens) / ctxMax))}%`, paint: st });
|
|
302
310
|
if (p.routing && p.routing !== "?") parts.push({ k: "routing", t: Array.from(oneLine(p.routing)).slice(0, 12).join(""), paint: st });
|
|
303
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) });
|
|
304
313
|
const right = p.balance && p.balance.kind === "ok" ? `${formatUsd(p.balance.account.creditRemaining)} credit` : "";
|
|
305
314
|
const kept = parts.slice();
|
|
306
315
|
let showRight = right !== "";
|
|
307
316
|
const leftW = () => kept.reduce((w, x, i) => w + textWidth(x.t) + (i ? sep.length : 0), 0);
|
|
308
317
|
const fits = () => leftW() + (showRight ? 2 + textWidth(right) : 0) <= room;
|
|
309
318
|
// Dropped first to last: routing, ctx, workspace, effort, the balance, then tokens.
|
|
310
|
-
for (const k of ["routing", "ctx", "ws", "effort", "right", "tokens"]) {
|
|
319
|
+
for (const k of ["update", "routing", "ctx", "ws", "effort", "mode", "right", "tokens"]) {
|
|
311
320
|
if (fits()) break;
|
|
312
321
|
if (k === "right") { showRight = false; continue; }
|
|
313
322
|
const i = kept.findIndex((x) => x.k === k);
|
|
314
323
|
if (i > 0) kept.splice(i, 1);
|
|
315
324
|
}
|
|
316
325
|
let line = kept.map((x) => x.paint(x.t)).join(dim(s, sep));
|
|
317
|
-
if (!fits()) line = terracottaBold(s, fit(
|
|
326
|
+
if (!fits()) line = terracottaBold(s, fit(head, room, ui));
|
|
318
327
|
else if (showRight) line += " ".repeat(Math.max(2, room - leftW() - textWidth(right))) + sage(s, right);
|
|
319
328
|
return [line, keyHints(ui)];
|
|
320
329
|
}
|
|
@@ -342,10 +351,22 @@ export function keyHints(ui ) {
|
|
|
342
351
|
|
|
343
352
|
|
|
344
353
|
|
|
354
|
+
|
|
345
355
|
|
|
346
356
|
|
|
347
357
|
const CR_ERASE = "\r\x1b[2K";
|
|
348
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
|
+
|
|
349
370
|
export class Spinner {
|
|
350
371
|
out ;
|
|
351
372
|
o ;
|
|
@@ -374,7 +395,7 @@ export class Spinner {
|
|
|
374
395
|
(h ).unref?.();
|
|
375
396
|
return h;
|
|
376
397
|
});
|
|
377
|
-
this.timer = set(() => this.draw(), this.o.intervalMs ??
|
|
398
|
+
this.timer = set(() => this.draw(), this.o.intervalMs ?? THINKING_INTERVAL_MS);
|
|
378
399
|
}
|
|
379
400
|
|
|
380
401
|
elapsedText() {
|
|
@@ -383,8 +404,7 @@ export class Spinner {
|
|
|
383
404
|
}
|
|
384
405
|
|
|
385
406
|
draw() {
|
|
386
|
-
const
|
|
387
|
-
const f = g.spin[this.frame++ % g.spin.length] ;
|
|
407
|
+
const f = (this.o.frameFn ?? ((i ) => thinkingFrame(i, this.o.ui)))(this.frame++);
|
|
388
408
|
const st = styleOf(this.o.ui);
|
|
389
409
|
const text = fit(`${f} waiting ${this.elapsedText()}`, this.o.ui.columns - 1, this.o.ui);
|
|
390
410
|
// The frame is terracotta, the rest stone. fit() keeps the frame first, so the split is safe.
|
package/dist/update.js
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
// Update check and /update. The check is one short, silent GET to the npm registry; the update
|
|
2
|
+
// runs npm itself (no shell string on POSIX; npm.cmd through a shell on Windows).
|
|
3
|
+
import { spawn } from "node:child_process";
|
|
4
|
+
import { homedir } from "node:os";
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
export const REGISTRY_URL = "https://registry.npmjs.org/@synqa/cli/latest";
|
|
8
|
+
export const PACKAGE_SPEC = "@synqa/cli@latest";
|
|
9
|
+
|
|
10
|
+
// "1.2.0" -> [1, 2, 0]; anything that is not a plain x.y.z (with an optional -pre part) is undefined.
|
|
11
|
+
export function parseVersion(v ) {
|
|
12
|
+
const m = /^v?(\d+)\.(\d+)\.(\d+)(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/.exec(v.trim());
|
|
13
|
+
return m ? [Number(m[1]), Number(m[2]), Number(m[3])] : undefined;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// Numeric, not textual: 1.10.0 is newer than 1.9.0. Returns <0, 0 or >0; undefined if either is unparseable.
|
|
17
|
+
export function compareVersions(a , b ) {
|
|
18
|
+
const x = parseVersion(a);
|
|
19
|
+
const y = parseVersion(b);
|
|
20
|
+
if (!x || !y) return undefined;
|
|
21
|
+
for (let i = 0; i < 3; i++) {
|
|
22
|
+
const d = (x[i] ) - (y[i] );
|
|
23
|
+
if (d !== 0) return d;
|
|
24
|
+
}
|
|
25
|
+
return 0;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export const isNewer = (latest , current ) => (compareVersions(latest, current) ?? 0) > 0;
|
|
29
|
+
|
|
30
|
+
// The latest published version if it is newer than `current`, else undefined. Every failure
|
|
31
|
+
// (offline, slow, odd answer) is silent.
|
|
32
|
+
export async function checkForUpdate(current , fetchFn = fetch, timeoutMs = 1500) {
|
|
33
|
+
const ac = new AbortController();
|
|
34
|
+
const timer = setTimeout(() => ac.abort(), timeoutMs);
|
|
35
|
+
(timer ).unref?.();
|
|
36
|
+
try {
|
|
37
|
+
const res = await fetchFn(REGISTRY_URL, { headers: { accept: "application/json" }, signal: ac.signal });
|
|
38
|
+
if (!res.ok) return undefined;
|
|
39
|
+
const body = (await res.json()) ;
|
|
40
|
+
const latest = typeof body.version === "string" ? body.version : "";
|
|
41
|
+
return isNewer(latest, current) ? latest : undefined;
|
|
42
|
+
} catch {
|
|
43
|
+
return undefined;
|
|
44
|
+
} finally {
|
|
45
|
+
clearTimeout(timer);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// How /update runs npm. POSIX: spawn("npm", [...]) with no shell. Windows: npm is npm.cmd, which
|
|
50
|
+
// Node can only start through a shell.
|
|
51
|
+
export function updateCommand(platform = process.platform) {
|
|
52
|
+
return { file: platform === "win32" ? "npm.cmd" : "npm", args: ["install", "-g", PACKAGE_SPEC], shell: platform === "win32" };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function runUpdate(platform = process.platform, spawnFn = spawn) {
|
|
56
|
+
const c = updateCommand(platform);
|
|
57
|
+
return new Promise((resolve) => {
|
|
58
|
+
try {
|
|
59
|
+
const child = spawnFn(c.file, c.args, {
|
|
60
|
+
stdio: "inherit", shell: c.shell,
|
|
61
|
+
// Never the workspace: npm must not pick up a project's .npmrc or a planted npm.cmd.
|
|
62
|
+
cwd: homedir(),
|
|
63
|
+
...(platform === "win32" ? { env: { ...process.env, NoDefaultCurrentDirectoryInExePath: "1" } } : {}),
|
|
64
|
+
});
|
|
65
|
+
child.on("error", () => resolve(1));
|
|
66
|
+
child.on("close", (code ) => resolve(code ?? 1));
|
|
67
|
+
} catch {
|
|
68
|
+
resolve(1);
|
|
69
|
+
}
|
|
70
|
+
});
|
|
71
|
+
}
|