@synqa/cli 0.0.0-stage → 1.1.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 +248 -2
- package/bin/synqa.js +8 -0
- package/dist/account.js +93 -0
- package/dist/agent.js +122 -0
- package/dist/api.js +159 -0
- package/dist/commands.js +190 -0
- package/dist/compact.js +85 -0
- package/dist/config.js +68 -0
- package/dist/diff.js +108 -0
- package/dist/init.js +177 -0
- package/dist/journal.js +61 -0
- package/dist/main.js +254 -0
- package/dist/paths.js +131 -0
- package/dist/sanitize.js +53 -0
- package/dist/statusbar.js +114 -0
- package/dist/theme.js +106 -0
- package/dist/tiers.js +53 -0
- package/dist/tools.js +332 -0
- package/dist/ui.js +514 -0
- package/package.json +35 -4
package/dist/ui.js
ADDED
|
@@ -0,0 +1,514 @@
|
|
|
1
|
+
// Terminal chrome: the welcome panel, the waiting spinner, tool-call cards and the per-turn footer.
|
|
2
|
+
// Everything here is pure or takes a stream-like object, so it is tested with fakes.
|
|
3
|
+
//
|
|
4
|
+
// Safety: the only control sequences this file ever writes are the fixed strings below (carriage
|
|
5
|
+
// return and "erase line" for the spinner). Every piece of text that comes from the model, a tool
|
|
6
|
+
// or the network goes through sanitize() first, which removes escape sequences and control
|
|
7
|
+
// characters, so chrome around it cannot be turned into cursor control.
|
|
8
|
+
import { readFileSync } from "node:fs";
|
|
9
|
+
import { homedir } from "node:os";
|
|
10
|
+
import { formatUsd } from "./account.js";
|
|
11
|
+
|
|
12
|
+
import { sanitize, visibleEscapes } from "./sanitize.js";
|
|
13
|
+
import { groupDigits } from "./statusbar.js";
|
|
14
|
+
|
|
15
|
+
import { accent, bold, boneBold, colorDepth, colorEnabled, red, sage, stone, stoneBold, terracotta } from "./theme.js";
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
// ---------------------------------------------------------------- terminal capabilities
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
// UTF-8 detection from the locale variables; Windows Terminal, VS Code and ConEmu are UTF-8 safe
|
|
31
|
+
// even without a locale. TERM=dumb and the old Windows console get ASCII.
|
|
32
|
+
export function detectUnicode(env , platform = process.platform) {
|
|
33
|
+
if (env.TERM === "dumb") return false;
|
|
34
|
+
if (platform === "win32") return Boolean(env.WT_SESSION || env.TERM_PROGRAM === "vscode" || env.ConEmuANSI === "ON");
|
|
35
|
+
const loc = env.LC_ALL || env.LC_CTYPE || env.LANG || "";
|
|
36
|
+
if (loc === "") return true; // unset: modern terminals are UTF-8
|
|
37
|
+
return /utf-?8/i.test(loc);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
export function makeUi(env , out , platform = process.platform) {
|
|
43
|
+
const tty = Boolean(out.isTTY) && env.TERM !== "dumb";
|
|
44
|
+
// `columns` is read on every use, so a resized window is picked up by the next render.
|
|
45
|
+
const cols = () => (typeof out.columns === "number" && out.columns > 0 ? Math.floor(out.columns) : 80);
|
|
46
|
+
return {
|
|
47
|
+
color: colorEnabled(env, tty),
|
|
48
|
+
depth: colorDepth(env),
|
|
49
|
+
unicode: detectUnicode(env, platform),
|
|
50
|
+
tty,
|
|
51
|
+
get columns() { return Math.max(20, cols()); },
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export const styleOf = (ui ) => ({ color: ui.color, depth: ui.depth });
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
const UNI = {
|
|
63
|
+
tl: "┌", tr: "┐", bl: "└", br: "┘", h: "─", v: "│",
|
|
64
|
+
ell: "…", dot: "·", bolt: "⚡", ok: "✓", bad: "✗",
|
|
65
|
+
spin: ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"],
|
|
66
|
+
};
|
|
67
|
+
const ASCII = {
|
|
68
|
+
tl: "+", tr: "+", bl: "+", br: "+", h: "-", v: "|",
|
|
69
|
+
ell: "...", dot: "|", bolt: "*", ok: "ok", bad: "x",
|
|
70
|
+
spin: ["|", "/", "-", "\\"],
|
|
71
|
+
};
|
|
72
|
+
export const glyphs = (ui ) => (ui.unicode ? UNI : ASCII);
|
|
73
|
+
|
|
74
|
+
// ---------------------------------------------------------------- text fitting
|
|
75
|
+
|
|
76
|
+
// Terminal cell width of one code point: 0 for combining marks, 2 for wide East Asian and emoji.
|
|
77
|
+
// Emoji with emoji presentation in the U+2000-2BFF blocks: terminals draw them two cells wide.
|
|
78
|
+
// Text-presentation symbols such as the check mark (U+2713) stay one cell.
|
|
79
|
+
const WIDE_SYMBOLS = [
|
|
80
|
+
[0x231a, 0x231b], [0x23e9, 0x23ec], [0x23f0, 0x23f0], [0x23f3, 0x23f3], [0x25fd, 0x25fe], [0x2614, 0x2615],
|
|
81
|
+
[0x2648, 0x2653], [0x267f, 0x267f], [0x2693, 0x2693], [0x26a1, 0x26a1], [0x26aa, 0x26ab], [0x26bd, 0x26be],
|
|
82
|
+
[0x26c4, 0x26c5], [0x26ce, 0x26ce], [0x26d4, 0x26d4], [0x26ea, 0x26ea], [0x26f2, 0x26f3], [0x26f5, 0x26f5],
|
|
83
|
+
[0x26fa, 0x26fa], [0x26fd, 0x26fd], [0x2705, 0x2705], [0x270a, 0x270b], [0x2728, 0x2728], [0x274c, 0x274c],
|
|
84
|
+
[0x274e, 0x274e], [0x2753, 0x2755], [0x2757, 0x2757], [0x2795, 0x2797], [0x27b0, 0x27b0], [0x27bf, 0x27bf],
|
|
85
|
+
[0x2b1b, 0x2b1c], [0x2b50, 0x2b50], [0x2b55, 0x2b55],
|
|
86
|
+
];
|
|
87
|
+
|
|
88
|
+
// Terminal cell width of one code point: 0 for combining marks and variation selectors (all of
|
|
89
|
+
// \p{M}) and zero width format characters, 2 for wide East Asian characters and emoji.
|
|
90
|
+
function cellWidth(cp ) {
|
|
91
|
+
if (cp === 0x200b || cp === 0x200c || cp === 0x200d || cp === 0x2060) return 0;
|
|
92
|
+
if (/^\p{M}$/u.test(String.fromCodePoint(cp))) return 0;
|
|
93
|
+
if (
|
|
94
|
+
(cp >= 0x1100 && cp <= 0x115f) || (cp >= 0x2e80 && cp <= 0xa4cf) || (cp >= 0xac00 && cp <= 0xd7a3) ||
|
|
95
|
+
(cp >= 0xf900 && cp <= 0xfaff) || (cp >= 0xfe30 && cp <= 0xfe6f) || (cp >= 0xff00 && cp <= 0xff60) ||
|
|
96
|
+
(cp >= 0xffe0 && cp <= 0xffe6) || (cp >= 0x1f300 && cp <= 0x1faff) || (cp >= 0x20000 && cp <= 0x3fffd)
|
|
97
|
+
) return 2;
|
|
98
|
+
for (const [lo, hi] of WIDE_SYMBOLS) if (cp >= lo && cp <= hi) return 2;
|
|
99
|
+
return 1;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function textWidth(s ) {
|
|
103
|
+
let w = 0;
|
|
104
|
+
for (const ch of s) w += cellWidth(ch.codePointAt(0) );
|
|
105
|
+
return w;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// One safe printable line: escapes and control characters removed, whitespace collapsed.
|
|
109
|
+
export function oneLine(s ) {
|
|
110
|
+
return sanitize(s).replace(/\s+/g, " ").trim();
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Cuts to `max` cells, keeping the start, ending with an ellipsis.
|
|
114
|
+
export function fit(s , max , ui ) {
|
|
115
|
+
if (max <= 0) return "";
|
|
116
|
+
if (textWidth(s) <= max) return s;
|
|
117
|
+
const ell = glyphs(ui).ell;
|
|
118
|
+
const room = max - ell.length;
|
|
119
|
+
if (room <= 0) return ell.slice(0, max);
|
|
120
|
+
let out = "";
|
|
121
|
+
let w = 0;
|
|
122
|
+
for (const ch of s) {
|
|
123
|
+
const cw = cellWidth(ch.codePointAt(0) );
|
|
124
|
+
if (w + cw > room) break;
|
|
125
|
+
out += ch;
|
|
126
|
+
w += cw;
|
|
127
|
+
}
|
|
128
|
+
return out + ell;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Cuts to `max` cells, keeping the END (the file name matters most in a path).
|
|
132
|
+
export function fitPath(s , max , ui ) {
|
|
133
|
+
if (max <= 0) return "";
|
|
134
|
+
if (textWidth(s) <= max) return s;
|
|
135
|
+
const ell = glyphs(ui).ell;
|
|
136
|
+
const room = max - ell.length;
|
|
137
|
+
if (room <= 0) return ell.slice(0, max);
|
|
138
|
+
const chars = Array.from(s);
|
|
139
|
+
let out = "";
|
|
140
|
+
let w = 0;
|
|
141
|
+
for (let i = chars.length - 1; i >= 0; i--) {
|
|
142
|
+
const cw = cellWidth((chars[i] ).codePointAt(0) );
|
|
143
|
+
if (w + cw > room) break;
|
|
144
|
+
out = chars[i] + out;
|
|
145
|
+
w += cw;
|
|
146
|
+
}
|
|
147
|
+
return ell + out;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export function shortenHome(path , home = homedir()) {
|
|
151
|
+
if (!home || home === "/" ) return path;
|
|
152
|
+
if (path === home) return "~";
|
|
153
|
+
if (path.startsWith(home + "/") || path.startsWith(home + "\\")) return "~" + path.slice(home.length);
|
|
154
|
+
return path;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const pad = (s , w ) => s + " ".repeat(Math.max(0, w - textWidth(s)));
|
|
158
|
+
|
|
159
|
+
// ---------------------------------------------------------------- version
|
|
160
|
+
|
|
161
|
+
// package.json sits next to src/ and dist/, in a checkout and in the installed package.
|
|
162
|
+
export function readVersion() {
|
|
163
|
+
try {
|
|
164
|
+
const raw = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")) ;
|
|
165
|
+
return typeof raw.version === "string" && /^[\w.+-]{1,32}$/.test(raw.version) ? raw.version : "unknown";
|
|
166
|
+
} catch {
|
|
167
|
+
return "unknown";
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// ---------------------------------------------------------------- balance
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
export function balanceFrom(r ) {
|
|
179
|
+
if (r.ok) return { kind: "ok", account: r.account };
|
|
180
|
+
return r.notFound ? { kind: "not-yet" } : { kind: "unavailable" };
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
export function balanceLine(b ) {
|
|
184
|
+
if (b.kind === "not-yet") return "balance not available yet";
|
|
185
|
+
if (b.kind === "unavailable") return "balance unavailable";
|
|
186
|
+
const a = b.account;
|
|
187
|
+
const credit = `${formatUsd(a.creditRemaining)} credit`;
|
|
188
|
+
if (a.poolRemaining !== null && a.poolLimit !== null) return `${credit} · weekly pool ${formatUsd(a.poolRemaining)} of ${formatUsd(a.poolLimit)}`;
|
|
189
|
+
return credit;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// ---------------------------------------------------------------- banner
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
// "Synqa. CLI v1.1.0" with the name in bold bone and the period in terracotta. `text` may have
|
|
204
|
+
// been cut by fit(); anything that no longer starts with "Synqa." is painted whole.
|
|
205
|
+
function paintTitle(s , text ) {
|
|
206
|
+
if (!text.startsWith("Synqa.")) return boneBold(s, text);
|
|
207
|
+
return boneBold(s, "Synqa") + terracotta(s, ".") + boneBold(s, text.slice(6));
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const TILE_W = 6;
|
|
211
|
+
const TILE_GAP = 2;
|
|
212
|
+
|
|
213
|
+
// The 3-line monogram: a rounded terracotta tile with a bold bone S and a terracotta period.
|
|
214
|
+
function monogram(s ) {
|
|
215
|
+
return [
|
|
216
|
+
terracotta(s, "╭────╮"),
|
|
217
|
+
terracotta(s, "│ ") + boneBold(s, "S") + terracotta(s, ". │"),
|
|
218
|
+
terracotta(s, "╰────╯"),
|
|
219
|
+
];
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// A boxed welcome panel. Fields are plain text built from our own tables or sanitized; the API key
|
|
223
|
+
// is never an input. On a terminal that is not UTF-8, or a very narrow one, there is no box or
|
|
224
|
+
// tile: a "[S.] Synqa. CLI v..." line and the rows.
|
|
225
|
+
export function renderBanner(info , ui ) {
|
|
226
|
+
const s = styleOf(ui);
|
|
227
|
+
const sep = ui.unicode ? " · " : " | ";
|
|
228
|
+
const notes = info.notesChars > 0 ? `AGENTS.md loaded (${groupDigits(info.notesChars)} chars)` : "no AGENTS.md";
|
|
229
|
+
const balance = balanceLine(info.balance).replace(" · ", sep);
|
|
230
|
+
const balancePaint = (t ) => (info.balance.kind === "ok" ? sage(s, t) : stone(s, t));
|
|
231
|
+
const rows = [
|
|
232
|
+
["Tier", `${info.tier.id} (${info.tier.display})`, (t) => boneBold(s, t)],
|
|
233
|
+
["Workspace", oneLine(shortenHome(info.cwd, info.home)), (t) => t],
|
|
234
|
+
["Notes", notes, (t) => t],
|
|
235
|
+
["Balance", balance, balancePaint],
|
|
236
|
+
];
|
|
237
|
+
const version = oneLine(info.version);
|
|
238
|
+
const title = `Synqa. CLI v${version}`;
|
|
239
|
+
const labelW = Math.max(...rows.map((r) => r[0].length)) + 2;
|
|
240
|
+
const minBox = 30;
|
|
241
|
+
if (!ui.unicode || ui.columns < minBox) {
|
|
242
|
+
const room = ui.columns - 1;
|
|
243
|
+
const head = fit(`[S.] ${title}`, room, ui);
|
|
244
|
+
const first = head.startsWith("[S.] ")
|
|
245
|
+
? terracotta(s, "[S.]") + " " + paintTitle(s, head.slice(5))
|
|
246
|
+
: boneBold(s, head);
|
|
247
|
+
return [first, ...rows.map(([l, v, p]) => {
|
|
248
|
+
const text = fit(` ${pad(l, labelW)}${v}`, room, ui);
|
|
249
|
+
const lead = 2 + labelW;
|
|
250
|
+
return text.length <= lead ? stoneBold(s, text) : stoneBold(s, text.slice(0, lead)) + p(text.slice(lead));
|
|
251
|
+
})];
|
|
252
|
+
}
|
|
253
|
+
const tile = ui.columns >= 46;
|
|
254
|
+
const left = tile ? TILE_W + TILE_GAP : 0;
|
|
255
|
+
const natural = Math.max(textWidth(title) + 4, left + Math.max(...rows.map((r) => labelW + textWidth(r[1])))) + 4;
|
|
256
|
+
const width = Math.min(natural, ui.columns - 1);
|
|
257
|
+
const inner = width - 4; // "│ " ... " │"
|
|
258
|
+
const edge = (t ) => stone(s, t);
|
|
259
|
+
const top = (() => {
|
|
260
|
+
const t = fit(title, width - 6, ui);
|
|
261
|
+
const fill = width - 5 - textWidth(t); // "╭─ " + title + " " + fill + "╮"
|
|
262
|
+
return edge("╭─ ") + paintTitle(s, t) + edge(" " + "─".repeat(Math.max(0, fill)) + "╮");
|
|
263
|
+
})();
|
|
264
|
+
const mono = tile ? monogram(s) : [];
|
|
265
|
+
const body = rows.map(([label, value, paint], i) => {
|
|
266
|
+
const lead = tile ? (mono[i] ?? " ".repeat(TILE_W)) + " ".repeat(TILE_GAP) : "";
|
|
267
|
+
const v = fit(value, inner - left - labelW, ui);
|
|
268
|
+
const gap = " ".repeat(Math.max(0, inner - left - labelW - textWidth(v)));
|
|
269
|
+
return edge("│") + " " + lead + stoneBold(s, pad(label, labelW)) + paint(v) + gap + " " + edge("│");
|
|
270
|
+
});
|
|
271
|
+
const bottom = edge("╰" + "─".repeat(width - 2) + "╯");
|
|
272
|
+
return [top, ...body, bottom];
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// ---------------------------------------------------------------- spinner
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
const CR_ERASE = "\r\x1b[2K";
|
|
288
|
+
|
|
289
|
+
export class Spinner {
|
|
290
|
+
out ;
|
|
291
|
+
o ;
|
|
292
|
+
timer = undefined;
|
|
293
|
+
t0 = 0;
|
|
294
|
+
frame = 0;
|
|
295
|
+
drawn = false;
|
|
296
|
+
|
|
297
|
+
constructor(out , o ) {
|
|
298
|
+
this.out = out;
|
|
299
|
+
this.o = o;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
get active() {
|
|
303
|
+
return this.timer !== undefined;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
start() {
|
|
307
|
+
if (!this.o.enabled || this.timer !== undefined) return;
|
|
308
|
+
const now = this.o.now ?? (() => Date.now());
|
|
309
|
+
this.t0 = now();
|
|
310
|
+
this.frame = 0;
|
|
311
|
+
this.draw();
|
|
312
|
+
const set = this.o.setTimer ?? ((fn , ms ) => {
|
|
313
|
+
const h = setInterval(fn, ms);
|
|
314
|
+
(h ).unref?.();
|
|
315
|
+
return h;
|
|
316
|
+
});
|
|
317
|
+
this.timer = set(() => this.draw(), this.o.intervalMs ?? 80);
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
elapsedText() {
|
|
321
|
+
const now = this.o.now ?? (() => Date.now());
|
|
322
|
+
return `${(Math.max(0, now() - this.t0) / 1000).toFixed(1)}s`;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
draw() {
|
|
326
|
+
const g = glyphs(this.o.ui);
|
|
327
|
+
const f = g.spin[this.frame++ % g.spin.length] ;
|
|
328
|
+
const st = styleOf(this.o.ui);
|
|
329
|
+
const text = fit(`${f} waiting ${this.elapsedText()}`, this.o.ui.columns - 1, this.o.ui);
|
|
330
|
+
// The frame is terracotta, the rest stone. fit() keeps the frame first, so the split is safe.
|
|
331
|
+
this.out.write(CR_ERASE + terracotta(st, text.slice(0, f.length)) + stone(st, text.slice(f.length)));
|
|
332
|
+
this.drawn = true;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// Clears the line and stops. Safe to call at any time, any number of times.
|
|
336
|
+
stop() {
|
|
337
|
+
if (this.timer !== undefined) {
|
|
338
|
+
(this.o.clearTimer ?? ((h ) => clearInterval(h )))(this.timer);
|
|
339
|
+
this.timer = undefined;
|
|
340
|
+
}
|
|
341
|
+
if (this.drawn) {
|
|
342
|
+
this.out.write(CR_ERASE);
|
|
343
|
+
this.drawn = false;
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
// ---------------------------------------------------------------- tool cards
|
|
349
|
+
|
|
350
|
+
// The one piece of an argument list worth showing in a card header.
|
|
351
|
+
export function toolLabel(name , rawArgs ) {
|
|
352
|
+
let a = {};
|
|
353
|
+
try {
|
|
354
|
+
const v = JSON.parse(rawArgs || "{}");
|
|
355
|
+
if (v && typeof v === "object" && !Array.isArray(v)) a = v ;
|
|
356
|
+
} catch { /* shown as no label */ }
|
|
357
|
+
const pick = (k ) => (typeof a[k] === "string" ? (a[k] ) : "");
|
|
358
|
+
switch (name) {
|
|
359
|
+
case "read_file": case "write_file": case "edit_file": return visibleEscapes(pick("path"));
|
|
360
|
+
case "glob": case "grep": return visibleEscapes(pick("pattern"));
|
|
361
|
+
case "bash": return visibleEscapes(pick("command"));
|
|
362
|
+
default: return "";
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
export function cardHeader(name , rawArgs , ui ) {
|
|
367
|
+
const g = glyphs(ui);
|
|
368
|
+
const s = styleOf(ui);
|
|
369
|
+
const prefix = `${g.tl}${g.h} `;
|
|
370
|
+
const n = fit(Array.from(oneLine(name)).slice(0, 40).join("") || "tool", Math.max(8, ui.columns - 1 - textWidth(prefix)), ui);
|
|
371
|
+
const label = oneLine(toolLabel(name, rawArgs));
|
|
372
|
+
const room = ui.columns - 1 - textWidth(prefix) - textWidth(n);
|
|
373
|
+
if (!label) return stone(s, prefix) + bold(s, n);
|
|
374
|
+
const sepText = ` ${g.dot} `;
|
|
375
|
+
const shown = name === "bash" ? fit(label, room - sepText.length, ui) : fitPath(label, room - sepText.length, ui);
|
|
376
|
+
return stone(s, prefix) + bold(s, n) + stone(s, sepText + shown);
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
// One short line about what a tool returned. No file contents are shown for read, glob or grep.
|
|
380
|
+
export function summarizeResult(name , result ) {
|
|
381
|
+
const lines = result.split("\n");
|
|
382
|
+
const first = oneLine(lines[0] ?? "");
|
|
383
|
+
if (result === "denied by user") return { ok: false, text: "denied" };
|
|
384
|
+
if (result.startsWith("error:")) return { ok: false, text: first.replace(/^error:\s*/, "") || "error" };
|
|
385
|
+
switch (name) {
|
|
386
|
+
case "read_file": return { ok: true, text: `${groupDigits(lines.length)} lines` };
|
|
387
|
+
case "glob": case "grep": {
|
|
388
|
+
const n = result.trim() === "" || /^no (matches|files)/i.test(result.trim()) ? 0 : lines.filter((l) => l.trim() !== "").length;
|
|
389
|
+
return { ok: true, text: `${groupDigits(n)} ${n === 1 ? "match" : "matches"}` };
|
|
390
|
+
}
|
|
391
|
+
case "bash": {
|
|
392
|
+
const body = lines.filter((l) => l.trim() !== "");
|
|
393
|
+
const code = /^\[(exit \d+|signal [^\]]*)\]$/.exec(body[body.length - 1] ?? "");
|
|
394
|
+
const status = code ? (code[1] ) : "done";
|
|
395
|
+
const firstOut = oneLine(body[0] ?? "");
|
|
396
|
+
const hasOut = code ? body.length > 1 : body.length > 0;
|
|
397
|
+
return { ok: status === "exit 0" || !code, text: hasOut ? `${status} · ${firstOut}` : status };
|
|
398
|
+
}
|
|
399
|
+
default: return { ok: true, text: first || "done" };
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
export function cardFooter(name , result , ui ) {
|
|
404
|
+
const g = glyphs(ui);
|
|
405
|
+
const s = styleOf(ui);
|
|
406
|
+
const r = summarizeResult(name, result);
|
|
407
|
+
const prefix = `${g.bl}${g.h} `;
|
|
408
|
+
const mark = r.ok ? sage(s, g.ok) : red(s, g.bad);
|
|
409
|
+
const text = r.text.replace(" · ", ui.unicode ? " · " : " | ");
|
|
410
|
+
const room = ui.columns - 1 - prefix.length - (r.ok ? g.ok.length : g.bad.length) - 1;
|
|
411
|
+
return stone(s, prefix) + mark + " " + stone(s, fit(text, room, ui));
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
// Puts the card's left edge in front of every line of a block (the diff preview).
|
|
415
|
+
export function cardBody(text , ui ) {
|
|
416
|
+
const bar = stone(styleOf(ui), glyphs(ui).v);
|
|
417
|
+
return text.split("\n").map((l) => bar + l).join("\n");
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
// ---------------------------------------------------------------- footer
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
export function formatMs(ms ) {
|
|
430
|
+
return ms < 1000 ? `${Math.round(ms)}ms` : `${(ms / 1000).toFixed(2)}s`;
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
const good = (n ) => n !== undefined && Number.isFinite(n);
|
|
434
|
+
|
|
435
|
+
// `⚡ 340ms TTFT · 68.4 tok/s · local · $9.98 left`. Parts that are unknown are left out, and the
|
|
436
|
+
// least important parts drop first when the line would not fit.
|
|
437
|
+
export function formatFooter(p , ui ) {
|
|
438
|
+
const g = glyphs(ui);
|
|
439
|
+
const ttft = good(p.ttftMs) && p.ttftMs >= 0 ? `${formatMs(p.ttftMs)} TTFT` : "";
|
|
440
|
+
const rate = good(p.tokPerSec) && p.tokPerSec > 0 ? `${p.tokPerSec.toFixed(1)} tok/s` : "";
|
|
441
|
+
const route = p.routing && p.routing !== "?" ? Array.from(oneLine(p.routing)).slice(0, 12).join("") : "";
|
|
442
|
+
const left = good(p.creditLeft) ? `${formatUsd(p.creditLeft)} left` : "";
|
|
443
|
+
const join = (parts ) => {
|
|
444
|
+
const kept = parts.filter((x) => x !== "");
|
|
445
|
+
return kept.length ? `${g.bolt} ${kept.join(` ${g.dot} `)}` : "";
|
|
446
|
+
};
|
|
447
|
+
const width = ui.columns - 1;
|
|
448
|
+
const variants = [[ttft, rate, route, left], [ttft, rate, route], [ttft, rate], [ttft], [rate]];
|
|
449
|
+
for (const v of variants) {
|
|
450
|
+
const t = join(v);
|
|
451
|
+
if (t !== "" && textWidth(t) <= width) return t;
|
|
452
|
+
}
|
|
453
|
+
const any = join([ttft, rate, route, left]);
|
|
454
|
+
return any === "" ? "" : fit(any, width, ui);
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
// ---------------------------------------------------------------- turn renderer
|
|
458
|
+
|
|
459
|
+
|
|
460
|
+
|
|
461
|
+
// Turns agent events into terminal output. One instance per session.
|
|
462
|
+
export class TurnRenderer {
|
|
463
|
+
out ;
|
|
464
|
+
ui ;
|
|
465
|
+
spinner ;
|
|
466
|
+
midLine = false; // the last thing written was model text with no trailing newline
|
|
467
|
+
|
|
468
|
+
constructor(out , ui , spinner ) {
|
|
469
|
+
this.out = out;
|
|
470
|
+
this.ui = ui;
|
|
471
|
+
this.spinner = spinner;
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
// A request is about to be sent: show that we are waiting for the first token.
|
|
475
|
+
requestStart() {
|
|
476
|
+
this.spinner.start();
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
// Model text. The spinner is erased first; the text is stripped of every escape sequence.
|
|
480
|
+
text(t ) {
|
|
481
|
+
this.spinner.stop();
|
|
482
|
+
const clean = sanitize(t);
|
|
483
|
+
if (clean === "") return;
|
|
484
|
+
this.out.write(clean);
|
|
485
|
+
this.midLine = !clean.endsWith("\n");
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
// The request finished (with or without a tool call).
|
|
489
|
+
requestEnd() {
|
|
490
|
+
this.spinner.stop();
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
toolStart(name , rawArgs ) {
|
|
494
|
+
this.spinner.stop();
|
|
495
|
+
this.out.write((this.midLine ? "\n" : "") + cardHeader(name, rawArgs, this.ui) + "\n");
|
|
496
|
+
this.midLine = false;
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
toolResult(name , result ) {
|
|
500
|
+
this.out.write(cardFooter(name, result, this.ui) + "\n");
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
// The diff preview, inside the card.
|
|
504
|
+
body(text ) {
|
|
505
|
+
return cardBody(text, this.ui);
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
// Leaves the cursor at the start of a line after a turn.
|
|
509
|
+
turnEnd() {
|
|
510
|
+
this.spinner.stop();
|
|
511
|
+
if (this.midLine) this.out.write("\n");
|
|
512
|
+
this.midLine = false;
|
|
513
|
+
}
|
|
514
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,37 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@synqa/cli",
|
|
3
|
-
"version": "
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "1.1.0",
|
|
4
|
+
"description": "Synqa terminal coding agent",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"synqa": "bin/synqa.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"bin",
|
|
11
|
+
"dist",
|
|
12
|
+
"README.md"
|
|
13
|
+
],
|
|
14
|
+
"engines": {
|
|
15
|
+
"node": ">=22.18.0 || >=23.6.0"
|
|
16
|
+
},
|
|
17
|
+
"scripts": {
|
|
18
|
+
"build": "node scripts/build.mjs",
|
|
19
|
+
"prepack": "node scripts/build.mjs",
|
|
20
|
+
"test": "node --test \"test/*.test.ts\""
|
|
21
|
+
},
|
|
22
|
+
"publishConfig": {
|
|
23
|
+
"access": "public"
|
|
24
|
+
},
|
|
25
|
+
"homepage": "https://synqa.ca",
|
|
26
|
+
"bugs": {
|
|
27
|
+
"url": "https://synqa.ca/client-dashboard#support"
|
|
28
|
+
},
|
|
29
|
+
"keywords": [
|
|
30
|
+
"synqa",
|
|
31
|
+
"cli",
|
|
32
|
+
"coding-agent",
|
|
33
|
+
"terminal",
|
|
34
|
+
"ai"
|
|
35
|
+
],
|
|
36
|
+
"license": "UNLICENSED"
|
|
37
|
+
}
|