@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/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([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)]);
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 parts = [{ k: "tier", t: p.tier.display, paint: (t) => terracottaBold(s, t) }];
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(p.tier.display, room, ui));
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 ?? 80);
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 g = glyphs(this.o.ui);
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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@synqa/cli",
3
- "version": "1.1.1",
3
+ "version": "1.2.1",
4
4
  "description": "Synqa terminal coding agent",
5
5
  "type": "module",
6
6
  "bin": {