@synqa/cli 1.1.1 → 1.2.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 +74 -9
- package/bin/synqa.js +0 -0
- package/dist/agent.js +84 -14
- package/dist/api.js +68 -7
- 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 +180 -75
- package/dist/modes.js +20 -0
- package/dist/paths.js +22 -15
- package/dist/prompt.js +84 -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 +137 -10
- package/dist/update.js +71 -0
- package/package.json +1 -1
package/dist/ui.js
CHANGED
|
@@ -9,6 +9,7 @@ import { readFileSync } from "node:fs";
|
|
|
9
9
|
import { homedir } from "node:os";
|
|
10
10
|
import { formatUsd } from "./account.js";
|
|
11
11
|
|
|
12
|
+
|
|
12
13
|
import { sanitize, visibleEscapes } from "./sanitize.js";
|
|
13
14
|
import { groupDigits } from "./statusbar.js";
|
|
14
15
|
import { CONTEXT_TOKENS } from "./tiers.js";
|
|
@@ -203,6 +204,8 @@ export function balanceLine(b ) {
|
|
|
203
204
|
|
|
204
205
|
|
|
205
206
|
|
|
207
|
+
|
|
208
|
+
|
|
206
209
|
|
|
207
210
|
|
|
208
211
|
// "Synqa." on one line: the name bold in the terminal's own text colour, the period terracotta.
|
|
@@ -238,8 +241,9 @@ export function renderBanner(info , ui ) {
|
|
|
238
241
|
const effort = `${info.effort ?? "default"} effort`;
|
|
239
242
|
const where = oneLine(shortenHome(info.cwd, info.home));
|
|
240
243
|
const notes = [];
|
|
241
|
-
notes.push([
|
|
242
|
-
|
|
244
|
+
if (info.locked) notes.push(["Sign in to Synqa Cloud to unlock chat and other providers", (t) => dim(s, t)]);
|
|
245
|
+
else notes.push([info.notesChars > 0 ? `AGENTS.md loaded (${groupDigits(info.notesChars)} chars)` : "no AGENTS.md yet: /init writes one", (t) => dim(s, t)]);
|
|
246
|
+
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
247
|
else notes.push([balanceLine(info.balance), (t) => dim(s, t)]);
|
|
244
248
|
for (const w of info.warnings ?? []) notes.push([oneLine(w), (t) => dim(s, t)]);
|
|
245
249
|
const lead = " ";
|
|
@@ -247,7 +251,7 @@ export function renderBanner(info , ui ) {
|
|
|
247
251
|
const noteLines = notes.map(([t, p]) => dim(s, lead + hook) + p(fit(t, room - lead.length - hook.length, ui)));
|
|
248
252
|
const w = room - lead.length;
|
|
249
253
|
const meta = [
|
|
250
|
-
lead + dim(s, fit(`${version}${sep}${info.tier.display}${sep}${effort}`, w, ui)),
|
|
254
|
+
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
255
|
lead + dim(s, fitPath(where, w, ui)),
|
|
252
256
|
];
|
|
253
257
|
if (!ui.unicode || ui.columns < 60) {
|
|
@@ -281,6 +285,9 @@ export const PLACEHOLDER = 'Try "explain this project", or type / for commands';
|
|
|
281
285
|
|
|
282
286
|
|
|
283
287
|
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
|
|
284
291
|
|
|
285
292
|
|
|
286
293
|
// The two lines under the input: what is known about the session, then key hints. Unknown values
|
|
@@ -294,27 +301,30 @@ export function renderStatusLines(p , ui ) {
|
|
|
294
301
|
const ws = home === "~" ? "~" : oneLine(home.split(/[\\/]/).filter(Boolean).pop() ?? home);
|
|
295
302
|
|
|
296
303
|
const st = (t ) => stone(s, t);
|
|
297
|
-
const
|
|
304
|
+
const head = p.provider ? [p.provider.name, p.provider.model].filter(Boolean).map((x) => oneLine(x )).join(sep) : p.tier.display;
|
|
305
|
+
const parts = [{ k: "tier", t: head, paint: (t) => terracottaBold(s, t) }];
|
|
306
|
+
if (p.mode) parts.push({ k: "mode", t: oneLine(p.mode), paint: st });
|
|
298
307
|
if (p.effort !== "default") parts.push({ k: "effort", t: `${p.effort} effort`, paint: st });
|
|
299
308
|
parts.push({ k: "ws", t: Array.from(ws).slice(0, 32).join(""), paint: st });
|
|
300
309
|
const ctxMax = p.contextMax ?? CONTEXT_TOKENS;
|
|
301
310
|
if (p.contextTokens) parts.push({ k: "ctx", t: `ctx ${Math.min(100, Math.round((100 * p.contextTokens) / ctxMax))}%`, paint: st });
|
|
302
311
|
if (p.routing && p.routing !== "?") parts.push({ k: "routing", t: Array.from(oneLine(p.routing)).slice(0, 12).join(""), paint: st });
|
|
303
312
|
parts.push({ k: "tokens", t: `${groupDigits(p.tokens)} tokens`, paint: st });
|
|
313
|
+
if (p.updateHint) parts.push({ k: "update", t: oneLine(p.updateHint), paint: (t) => sage(s, t) });
|
|
304
314
|
const right = p.balance && p.balance.kind === "ok" ? `${formatUsd(p.balance.account.creditRemaining)} credit` : "";
|
|
305
315
|
const kept = parts.slice();
|
|
306
316
|
let showRight = right !== "";
|
|
307
317
|
const leftW = () => kept.reduce((w, x, i) => w + textWidth(x.t) + (i ? sep.length : 0), 0);
|
|
308
318
|
const fits = () => leftW() + (showRight ? 2 + textWidth(right) : 0) <= room;
|
|
309
319
|
// Dropped first to last: routing, ctx, workspace, effort, the balance, then tokens.
|
|
310
|
-
for (const k of ["routing", "ctx", "ws", "effort", "right", "tokens"]) {
|
|
320
|
+
for (const k of ["update", "routing", "ctx", "ws", "effort", "mode", "right", "tokens"]) {
|
|
311
321
|
if (fits()) break;
|
|
312
322
|
if (k === "right") { showRight = false; continue; }
|
|
313
323
|
const i = kept.findIndex((x) => x.k === k);
|
|
314
324
|
if (i > 0) kept.splice(i, 1);
|
|
315
325
|
}
|
|
316
326
|
let line = kept.map((x) => x.paint(x.t)).join(dim(s, sep));
|
|
317
|
-
if (!fits()) line = terracottaBold(s, fit(
|
|
327
|
+
if (!fits()) line = terracottaBold(s, fit(head, room, ui));
|
|
318
328
|
else if (showRight) line += " ".repeat(Math.max(2, room - leftW() - textWidth(right))) + sage(s, right);
|
|
319
329
|
return [line, keyHints(ui)];
|
|
320
330
|
}
|
|
@@ -342,10 +352,22 @@ export function keyHints(ui ) {
|
|
|
342
352
|
|
|
343
353
|
|
|
344
354
|
|
|
355
|
+
|
|
345
356
|
|
|
346
357
|
|
|
347
358
|
const CR_ERASE = "\r\x1b[2K";
|
|
348
359
|
|
|
360
|
+
// The thinking mark: [S] with the dot bobbing . then middle dot, ring-less dot, middle dot. Every frame
|
|
361
|
+
// is exactly four cells wide and one line, so nothing around it ever shifts.
|
|
362
|
+
const BOB_UNI = [".", "\u00b7", "\u02d9", "\u00b7"];
|
|
363
|
+
const BOB_ASCII = [".", ",", "\x27", ","];
|
|
364
|
+
export const THINKING_FPS = 8;
|
|
365
|
+
export const THINKING_INTERVAL_MS = 125; // 8 frames per second, only while waiting for the first token
|
|
366
|
+
export function thinkingFrame(i , ui ) {
|
|
367
|
+
const bob = ui.unicode ? BOB_UNI : BOB_ASCII;
|
|
368
|
+
return `[S${bob[((i % bob.length) + bob.length) % bob.length] }]`;
|
|
369
|
+
}
|
|
370
|
+
|
|
349
371
|
export class Spinner {
|
|
350
372
|
out ;
|
|
351
373
|
o ;
|
|
@@ -374,7 +396,7 @@ export class Spinner {
|
|
|
374
396
|
(h ).unref?.();
|
|
375
397
|
return h;
|
|
376
398
|
});
|
|
377
|
-
this.timer = set(() => this.draw(), this.o.intervalMs ??
|
|
399
|
+
this.timer = set(() => this.draw(), this.o.intervalMs ?? THINKING_INTERVAL_MS);
|
|
378
400
|
}
|
|
379
401
|
|
|
380
402
|
elapsedText() {
|
|
@@ -383,8 +405,7 @@ export class Spinner {
|
|
|
383
405
|
}
|
|
384
406
|
|
|
385
407
|
draw() {
|
|
386
|
-
const
|
|
387
|
-
const f = g.spin[this.frame++ % g.spin.length] ;
|
|
408
|
+
const f = (this.o.frameFn ?? ((i ) => thinkingFrame(i, this.o.ui)))(this.frame++);
|
|
388
409
|
const st = styleOf(this.o.ui);
|
|
389
410
|
const text = fit(`${f} waiting ${this.elapsedText()}`, this.o.ui.columns - 1, this.o.ui);
|
|
390
411
|
// The frame is terracotta, the rest stone. fit() keeps the frame first, so the split is safe.
|
|
@@ -405,6 +426,94 @@ export class Spinner {
|
|
|
405
426
|
}
|
|
406
427
|
}
|
|
407
428
|
|
|
429
|
+
// ---------------------------------------------------------------- tool-call progress
|
|
430
|
+
|
|
431
|
+
export function formatBytes(n ) {
|
|
432
|
+
return n < 1024 ? `${n} B` : `${(n / 1024).toFixed(1)} KB`;
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
export const PROGRESS_INTERVAL_MS = 120; // the line is redrawn at most this often, however fast chunks arrive
|
|
436
|
+
|
|
437
|
+
// A live line for a tool call that is still streaming in, for example
|
|
438
|
+
// ⠋ write_file index.html · streaming 18.4 KB...
|
|
439
|
+
// The frame is the terracotta dot-mark of the spinner family, the rest stone. It is one line,
|
|
440
|
+
// always shorter than the terminal, redrawn in place by a throttled timer. With `enabled` false
|
|
441
|
+
// (a pipe, TERM=dumb, a one-shot run) nothing is ever written: no animation, no control codes.
|
|
442
|
+
export class ToolProgressLine {
|
|
443
|
+
out ;
|
|
444
|
+
o ;
|
|
445
|
+
timer = undefined;
|
|
446
|
+
frame = 0;
|
|
447
|
+
drawn = false;
|
|
448
|
+
last ;
|
|
449
|
+
|
|
450
|
+
constructor(out , o ) {
|
|
451
|
+
this.out = out;
|
|
452
|
+
this.o = o;
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
get active() {
|
|
456
|
+
return this.timer !== undefined;
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
// The newest state of the call being received. The first update draws at once; later ones only
|
|
460
|
+
// change what the next timed redraw shows.
|
|
461
|
+
update(p ) {
|
|
462
|
+
if (!this.o.enabled) return;
|
|
463
|
+
this.last = p;
|
|
464
|
+
if (this.timer !== undefined) return;
|
|
465
|
+
this.draw();
|
|
466
|
+
const set = this.o.setTimer ?? ((fn , ms ) => {
|
|
467
|
+
const h = setInterval(fn, ms);
|
|
468
|
+
(h ).unref?.();
|
|
469
|
+
return h;
|
|
470
|
+
});
|
|
471
|
+
this.timer = set(() => this.draw(), this.o.intervalMs ?? PROGRESS_INTERVAL_MS);
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
// The plain line for the current state, cut to the terminal width.
|
|
475
|
+
line() {
|
|
476
|
+
const p = this.last;
|
|
477
|
+
if (!p) return "";
|
|
478
|
+
const ui = this.o.ui;
|
|
479
|
+
const g = glyphs(ui);
|
|
480
|
+
const frames = g.spin;
|
|
481
|
+
const f = frames[this.frame % frames.length] ;
|
|
482
|
+
const room = ui.columns - 1;
|
|
483
|
+
const tail = ` ${g.dot} streaming ${formatBytes(p.bytes)}${g.ell}`;
|
|
484
|
+
const lead = f + " ";
|
|
485
|
+
const avail = room - textWidth(lead) - textWidth(tail);
|
|
486
|
+
if (avail < 6) return fit(lead + oneLine(p.name) + tail, room, ui);
|
|
487
|
+
const name = fit(Array.from(oneLine(p.name)).slice(0, 40).join("") || "tool", avail, ui);
|
|
488
|
+
const pathRoom = avail - textWidth(name) - 1;
|
|
489
|
+
const path = p.path && pathRoom >= 4 ? " " + fitPath(oneLine(p.path), pathRoom, ui) : "";
|
|
490
|
+
return lead + name + path + tail;
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
draw() {
|
|
494
|
+
const st = styleOf(this.o.ui);
|
|
495
|
+
const text = this.line();
|
|
496
|
+
const f = (glyphs(this.o.ui).spin[this.frame % glyphs(this.o.ui).spin.length] );
|
|
497
|
+
this.frame++;
|
|
498
|
+
if (text === "") return;
|
|
499
|
+
this.out.write(CR_ERASE + terracotta(st, text.slice(0, f.length)) + stone(st, text.slice(f.length)));
|
|
500
|
+
this.drawn = true;
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
// Clears the line and stops. Safe to call at any time, any number of times.
|
|
504
|
+
stop() {
|
|
505
|
+
if (this.timer !== undefined) {
|
|
506
|
+
(this.o.clearTimer ?? ((h ) => clearInterval(h )))(this.timer);
|
|
507
|
+
this.timer = undefined;
|
|
508
|
+
}
|
|
509
|
+
this.last = undefined;
|
|
510
|
+
if (this.drawn) {
|
|
511
|
+
this.out.write(CR_ERASE);
|
|
512
|
+
this.drawn = false;
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
|
|
408
517
|
// ---------------------------------------------------------------- tool cards
|
|
409
518
|
|
|
410
519
|
// The one piece of an argument list worth showing in a card header.
|
|
@@ -523,12 +632,26 @@ export class TurnRenderer {
|
|
|
523
632
|
out ;
|
|
524
633
|
ui ;
|
|
525
634
|
spinner ;
|
|
635
|
+
progress ;
|
|
526
636
|
midLine = false; // the last thing written was model text with no trailing newline
|
|
527
637
|
|
|
528
|
-
constructor(out , ui , spinner ) {
|
|
638
|
+
constructor(out , ui , spinner , progress ) {
|
|
529
639
|
this.out = out;
|
|
530
640
|
this.ui = ui;
|
|
531
641
|
this.spinner = spinner;
|
|
642
|
+
this.progress = progress;
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
// A tool call is streaming in: the waiting spinner gives way to the live progress line. Text
|
|
646
|
+
// that ended without a newline is finished first, because the line is redrawn in place.
|
|
647
|
+
toolProgress(p ) {
|
|
648
|
+
if (!this.progress) return;
|
|
649
|
+
this.spinner.stop();
|
|
650
|
+
if (!this.progress.active && this.midLine) {
|
|
651
|
+
this.out.write("\n");
|
|
652
|
+
this.midLine = false;
|
|
653
|
+
}
|
|
654
|
+
this.progress.update(p);
|
|
532
655
|
}
|
|
533
656
|
|
|
534
657
|
// A request is about to be sent: show that we are waiting for the first token.
|
|
@@ -539,6 +662,7 @@ export class TurnRenderer {
|
|
|
539
662
|
// Model text. The spinner is erased first; the text is stripped of every escape sequence.
|
|
540
663
|
text(t ) {
|
|
541
664
|
this.spinner.stop();
|
|
665
|
+
this.progress?.stop();
|
|
542
666
|
const clean = sanitize(t);
|
|
543
667
|
if (clean === "") return;
|
|
544
668
|
this.out.write(clean);
|
|
@@ -548,10 +672,12 @@ export class TurnRenderer {
|
|
|
548
672
|
// The request finished (with or without a tool call).
|
|
549
673
|
requestEnd() {
|
|
550
674
|
this.spinner.stop();
|
|
675
|
+
this.progress?.stop();
|
|
551
676
|
}
|
|
552
677
|
|
|
553
678
|
toolStart(name , rawArgs ) {
|
|
554
679
|
this.spinner.stop();
|
|
680
|
+
this.progress?.stop();
|
|
555
681
|
this.out.write((this.midLine ? "\n" : "") + cardHeader(name, rawArgs, this.ui) + "\n");
|
|
556
682
|
this.midLine = false;
|
|
557
683
|
}
|
|
@@ -568,6 +694,7 @@ export class TurnRenderer {
|
|
|
568
694
|
// Leaves the cursor at the start of a line after a turn.
|
|
569
695
|
turnEnd() {
|
|
570
696
|
this.spinner.stop();
|
|
697
|
+
this.progress?.stop();
|
|
571
698
|
if (this.midLine) this.out.write("\n");
|
|
572
699
|
this.midLine = false;
|
|
573
700
|
}
|
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
|
+
}
|