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