@synqa/cli 0.0.0-stage → 1.1.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 +278 -2
- package/bin/synqa.js +8 -0
- package/dist/account.js +93 -0
- package/dist/agent.js +122 -0
- package/dist/api.js +200 -0
- package/dist/commands.js +183 -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 +360 -0
- package/dist/paths.js +243 -0
- package/dist/prompt.js +330 -0
- package/dist/sanitize.js +53 -0
- package/dist/statusbar.js +121 -0
- package/dist/theme.js +146 -0
- package/dist/tiers.js +53 -0
- package/dist/tools.js +413 -0
- package/dist/ui.js +574 -0
- package/package.json +35 -4
package/dist/ui.js
ADDED
|
@@ -0,0 +1,574 @@
|
|
|
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
|
+
import { CONTEXT_TOKENS } from "./tiers.js";
|
|
15
|
+
|
|
16
|
+
import { bold, colorDepth, colorEnabled, detectBackground, dim, red, sage, stone, terracotta, terracottaBold } from "./theme.js";
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
// ---------------------------------------------------------------- terminal capabilities
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
// UTF-8 detection from the locale variables; Windows Terminal, VS Code and ConEmu are UTF-8 safe
|
|
33
|
+
// even without a locale. TERM=dumb and the old Windows console get ASCII.
|
|
34
|
+
export function detectUnicode(env , platform = process.platform) {
|
|
35
|
+
if (env.TERM === "dumb") return false;
|
|
36
|
+
if (platform === "win32") return Boolean(env.WT_SESSION || env.TERM_PROGRAM === "vscode" || env.ConEmuANSI === "ON");
|
|
37
|
+
const loc = env.LC_ALL || env.LC_CTYPE || env.LANG || "";
|
|
38
|
+
if (loc === "") return true; // unset: modern terminals are UTF-8
|
|
39
|
+
return /utf-?8/i.test(loc);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
export function makeUi(env , out , platform = process.platform) {
|
|
45
|
+
const tty = Boolean(out.isTTY) && env.TERM !== "dumb";
|
|
46
|
+
// `columns` is read on every use, so a resized window is picked up by the next render.
|
|
47
|
+
const cols = () => (typeof out.columns === "number" && out.columns > 0 ? Math.floor(out.columns) : 80);
|
|
48
|
+
return {
|
|
49
|
+
color: colorEnabled(env, tty),
|
|
50
|
+
depth: colorDepth(env),
|
|
51
|
+
bg: detectBackground(env),
|
|
52
|
+
unicode: detectUnicode(env, platform),
|
|
53
|
+
tty,
|
|
54
|
+
get columns() { return Math.max(20, cols()); },
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export const styleOf = (ui ) => ({ color: ui.color, depth: ui.depth, bg: ui.bg });
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
const UNI = {
|
|
66
|
+
tl: "┌", tr: "┐", bl: "└", br: "┘", h: "─", v: "│",
|
|
67
|
+
ell: "…", dot: "·", bolt: "⚡", ok: "✓", bad: "✗",
|
|
68
|
+
spin: ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"],
|
|
69
|
+
};
|
|
70
|
+
const ASCII = {
|
|
71
|
+
tl: "+", tr: "+", bl: "+", br: "+", h: "-", v: "|",
|
|
72
|
+
ell: "...", dot: "|", bolt: "*", ok: "ok", bad: "x",
|
|
73
|
+
spin: ["|", "/", "-", "\\"],
|
|
74
|
+
};
|
|
75
|
+
export const glyphs = (ui ) => (ui.unicode ? UNI : ASCII);
|
|
76
|
+
|
|
77
|
+
// ---------------------------------------------------------------- text fitting
|
|
78
|
+
|
|
79
|
+
// Terminal cell width of one code point: 0 for combining marks, 2 for wide East Asian and emoji.
|
|
80
|
+
// Emoji with emoji presentation in the U+2000-2BFF blocks: terminals draw them two cells wide.
|
|
81
|
+
// Text-presentation symbols such as the check mark (U+2713) stay one cell.
|
|
82
|
+
const WIDE_SYMBOLS = [
|
|
83
|
+
[0x231a, 0x231b], [0x23e9, 0x23ec], [0x23f0, 0x23f0], [0x23f3, 0x23f3], [0x25fd, 0x25fe], [0x2614, 0x2615],
|
|
84
|
+
[0x2648, 0x2653], [0x267f, 0x267f], [0x2693, 0x2693], [0x26a1, 0x26a1], [0x26aa, 0x26ab], [0x26bd, 0x26be],
|
|
85
|
+
[0x26c4, 0x26c5], [0x26ce, 0x26ce], [0x26d4, 0x26d4], [0x26ea, 0x26ea], [0x26f2, 0x26f3], [0x26f5, 0x26f5],
|
|
86
|
+
[0x26fa, 0x26fa], [0x26fd, 0x26fd], [0x2705, 0x2705], [0x270a, 0x270b], [0x2728, 0x2728], [0x274c, 0x274c],
|
|
87
|
+
[0x274e, 0x274e], [0x2753, 0x2755], [0x2757, 0x2757], [0x2795, 0x2797], [0x27b0, 0x27b0], [0x27bf, 0x27bf],
|
|
88
|
+
[0x2b1b, 0x2b1c], [0x2b50, 0x2b50], [0x2b55, 0x2b55],
|
|
89
|
+
];
|
|
90
|
+
|
|
91
|
+
// Terminal cell width of one code point: 0 for combining marks and variation selectors (all of
|
|
92
|
+
// \p{M}) and zero width format characters, 2 for wide East Asian characters and emoji.
|
|
93
|
+
function cellWidth(cp ) {
|
|
94
|
+
if (cp === 0x200b || cp === 0x200c || cp === 0x200d || cp === 0x2060) return 0;
|
|
95
|
+
if (/^\p{M}$/u.test(String.fromCodePoint(cp))) return 0;
|
|
96
|
+
if (
|
|
97
|
+
(cp >= 0x1100 && cp <= 0x115f) || (cp >= 0x2e80 && cp <= 0xa4cf) || (cp >= 0xac00 && cp <= 0xd7a3) ||
|
|
98
|
+
(cp >= 0xf900 && cp <= 0xfaff) || (cp >= 0xfe30 && cp <= 0xfe6f) || (cp >= 0xff00 && cp <= 0xff60) ||
|
|
99
|
+
(cp >= 0xffe0 && cp <= 0xffe6) || (cp >= 0x1f300 && cp <= 0x1faff) || (cp >= 0x20000 && cp <= 0x3fffd)
|
|
100
|
+
) return 2;
|
|
101
|
+
for (const [lo, hi] of WIDE_SYMBOLS) if (cp >= lo && cp <= hi) return 2;
|
|
102
|
+
return 1;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function textWidth(s ) {
|
|
106
|
+
let w = 0;
|
|
107
|
+
for (const ch of s) w += cellWidth(ch.codePointAt(0) );
|
|
108
|
+
return w;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// One safe printable line: escapes and control characters removed, whitespace collapsed.
|
|
112
|
+
export function oneLine(s ) {
|
|
113
|
+
return sanitize(s).replace(/\s+/g, " ").trim();
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Cuts to `max` cells, keeping the start, ending with an ellipsis.
|
|
117
|
+
export function fit(s , max , ui ) {
|
|
118
|
+
if (max <= 0) return "";
|
|
119
|
+
if (textWidth(s) <= max) return s;
|
|
120
|
+
const ell = glyphs(ui).ell;
|
|
121
|
+
const room = max - ell.length;
|
|
122
|
+
if (room <= 0) return ell.slice(0, max);
|
|
123
|
+
let out = "";
|
|
124
|
+
let w = 0;
|
|
125
|
+
for (const ch of s) {
|
|
126
|
+
const cw = cellWidth(ch.codePointAt(0) );
|
|
127
|
+
if (w + cw > room) break;
|
|
128
|
+
out += ch;
|
|
129
|
+
w += cw;
|
|
130
|
+
}
|
|
131
|
+
return out + ell;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Cuts to `max` cells, keeping the END (the file name matters most in a path).
|
|
135
|
+
export function fitPath(s , max , ui ) {
|
|
136
|
+
if (max <= 0) return "";
|
|
137
|
+
if (textWidth(s) <= max) return s;
|
|
138
|
+
const ell = glyphs(ui).ell;
|
|
139
|
+
const room = max - ell.length;
|
|
140
|
+
if (room <= 0) return ell.slice(0, max);
|
|
141
|
+
const chars = Array.from(s);
|
|
142
|
+
let out = "";
|
|
143
|
+
let w = 0;
|
|
144
|
+
for (let i = chars.length - 1; i >= 0; i--) {
|
|
145
|
+
const cw = cellWidth((chars[i] ).codePointAt(0) );
|
|
146
|
+
if (w + cw > room) break;
|
|
147
|
+
out = chars[i] + out;
|
|
148
|
+
w += cw;
|
|
149
|
+
}
|
|
150
|
+
return ell + out;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export function shortenHome(path , home = homedir()) {
|
|
154
|
+
if (!home || home === "/" ) return path;
|
|
155
|
+
if (path === home) return "~";
|
|
156
|
+
if (path.startsWith(home + "/") || path.startsWith(home + "\\")) return "~" + path.slice(home.length);
|
|
157
|
+
return path;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export const pad = (s , w ) => s + " ".repeat(Math.max(0, w - textWidth(s)));
|
|
161
|
+
|
|
162
|
+
// ---------------------------------------------------------------- version
|
|
163
|
+
|
|
164
|
+
// package.json sits next to src/ and dist/, in a checkout and in the installed package.
|
|
165
|
+
export function readVersion() {
|
|
166
|
+
try {
|
|
167
|
+
const raw = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")) ;
|
|
168
|
+
return typeof raw.version === "string" && /^[\w.+-]{1,32}$/.test(raw.version) ? raw.version : "unknown";
|
|
169
|
+
} catch {
|
|
170
|
+
return "unknown";
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// ---------------------------------------------------------------- balance
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
export function balanceFrom(r ) {
|
|
182
|
+
if (r.ok) return { kind: "ok", account: r.account };
|
|
183
|
+
return r.notFound ? { kind: "not-yet" } : { kind: "unavailable" };
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
export function balanceLine(b ) {
|
|
187
|
+
if (b.kind === "not-yet") return "balance not available yet";
|
|
188
|
+
if (b.kind === "unavailable") return "balance unavailable";
|
|
189
|
+
const a = b.account;
|
|
190
|
+
const credit = `${formatUsd(a.creditRemaining)} credit`;
|
|
191
|
+
if (a.poolRemaining !== null && a.poolLimit !== null) return `${credit} · weekly pool ${formatUsd(a.poolRemaining)} of ${formatUsd(a.poolLimit)}`;
|
|
192
|
+
return credit;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// ---------------------------------------------------------------- start screen
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
// "Synqa." on one line: the name bold in the terminal's own text colour, the period terracotta.
|
|
209
|
+
function paintName(s ) {
|
|
210
|
+
return bold(s, "Synqa") + terracotta(s, ".");
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// The wordmark in half-block letters, four rows: the S at cap height, y n q a at x-height with the
|
|
214
|
+
// y and q descenders on the last row. Letters carry no colour code, so they are drawn in the
|
|
215
|
+
// terminal's own text colour (ink on a light background, bone-like on a dark one); the full stop
|
|
216
|
+
// is a terracotta square on the baseline.
|
|
217
|
+
const LETTERS = {
|
|
218
|
+
S: ["▄▀▀▀", " ▀▀▄", "▄▄▄▀", " "],
|
|
219
|
+
y: [" ", "█ █", "▀▄▄█", "▄▄▄▀"],
|
|
220
|
+
n: [" ", "█▀▀▄", "█ █", " "],
|
|
221
|
+
q: [" ", "▄▀▀█", "▀▄▄█", " █"],
|
|
222
|
+
a: [" ", "▄▀▀█", "▀▄▄█", " "],
|
|
223
|
+
};
|
|
224
|
+
export const WORDMARK = [0, 1, 2, 3].map((r) => ["S", "y", "n", "q", "a"].map((c) => (LETTERS[c] )[r]).join(" ").trimEnd());
|
|
225
|
+
export const WORDMARK_W = Math.max(...WORDMARK.map(textWidth)) + 2; // plus a space and the dot
|
|
226
|
+
const DOT_ROW = 2;
|
|
227
|
+
|
|
228
|
+
// The start screen header, in the style of modern coding CLIs, with no box: the Synqa wordmark (60 columns and up,
|
|
229
|
+
// on a UTF-8 terminal), or a one-line bold "Synqa." below that; then dim lines with the version,
|
|
230
|
+
// tier and effort, and the workspace; then dim "⎿" lines for notes and warnings. Every line fits
|
|
231
|
+
// in columns - 1 cells (visible width, escapes not counted), so nothing wraps. Fields are our own
|
|
232
|
+
// tables or sanitized; the API key is never an input.
|
|
233
|
+
export function renderBanner(info , ui ) {
|
|
234
|
+
const s = styleOf(ui);
|
|
235
|
+
const room = ui.columns - 1;
|
|
236
|
+
const sep = ui.unicode ? " · " : " | ";
|
|
237
|
+
const version = `v${oneLine(info.version)}`;
|
|
238
|
+
const effort = `${info.effort ?? "default"} effort`;
|
|
239
|
+
const where = oneLine(shortenHome(info.cwd, info.home));
|
|
240
|
+
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
|
+
else notes.push([balanceLine(info.balance), (t) => dim(s, t)]);
|
|
244
|
+
for (const w of info.warnings ?? []) notes.push([oneLine(w), (t) => dim(s, t)]);
|
|
245
|
+
const lead = " ";
|
|
246
|
+
const hook = ui.unicode ? "⎿ " : "- ";
|
|
247
|
+
const noteLines = notes.map(([t, p]) => dim(s, lead + hook) + p(fit(t, room - lead.length - hook.length, ui)));
|
|
248
|
+
const w = room - lead.length;
|
|
249
|
+
const meta = [
|
|
250
|
+
lead + dim(s, fit(`${version}${sep}${info.tier.display}${sep}${effort}`, w, ui)),
|
|
251
|
+
lead + dim(s, fitPath(where, w, ui)),
|
|
252
|
+
];
|
|
253
|
+
if (!ui.unicode || ui.columns < 60) {
|
|
254
|
+
const name = w >= 6 ? lead + paintName(s) : lead + bold(s, fit("Synqa.", w, ui));
|
|
255
|
+
return [name, ...meta, ...noteLines];
|
|
256
|
+
}
|
|
257
|
+
const mark = WORDMARK.map((row, i) => lead + row + (i === DOT_ROW ? " " + terracotta(s, "▄") : ""));
|
|
258
|
+
return [...mark, "", ...meta, ...noteLines];
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// A thin rule across the terminal, one cell short of the edge so it never wraps.
|
|
262
|
+
export function rule(ui ) {
|
|
263
|
+
return stone(styleOf(ui), (ui.unicode ? "─" : "-").repeat(Math.max(1, ui.columns - 1)));
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
export const PROMPT_MARK = (ui ) => (ui.unicode ? "❯" : ">");
|
|
267
|
+
|
|
268
|
+
export function promptText(ui ) {
|
|
269
|
+
return `${terracottaBold(styleOf(ui), PROMPT_MARK(ui))} `;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
export const PLACEHOLDER = 'Try "explain this project", or type / for commands';
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
// The two lines under the input: what is known about the session, then key hints. Unknown values
|
|
287
|
+
// are left out (no "?" or "-" placeholders); separators are dim. The least important parts drop
|
|
288
|
+
// first when the line would not fit in columns - 1 cells.
|
|
289
|
+
export function renderStatusLines(p , ui ) {
|
|
290
|
+
const s = styleOf(ui);
|
|
291
|
+
const room = ui.columns - 1;
|
|
292
|
+
const sep = ui.unicode ? " · " : " | ";
|
|
293
|
+
const home = shortenHome(p.cwd, p.home);
|
|
294
|
+
const ws = home === "~" ? "~" : oneLine(home.split(/[\\/]/).filter(Boolean).pop() ?? home);
|
|
295
|
+
|
|
296
|
+
const st = (t ) => stone(s, t);
|
|
297
|
+
const parts = [{ k: "tier", t: p.tier.display, paint: (t) => terracottaBold(s, t) }];
|
|
298
|
+
if (p.effort !== "default") parts.push({ k: "effort", t: `${p.effort} effort`, paint: st });
|
|
299
|
+
parts.push({ k: "ws", t: Array.from(ws).slice(0, 32).join(""), paint: st });
|
|
300
|
+
const ctxMax = p.contextMax ?? CONTEXT_TOKENS;
|
|
301
|
+
if (p.contextTokens) parts.push({ k: "ctx", t: `ctx ${Math.min(100, Math.round((100 * p.contextTokens) / ctxMax))}%`, paint: st });
|
|
302
|
+
if (p.routing && p.routing !== "?") parts.push({ k: "routing", t: Array.from(oneLine(p.routing)).slice(0, 12).join(""), paint: st });
|
|
303
|
+
parts.push({ k: "tokens", t: `${groupDigits(p.tokens)} tokens`, paint: st });
|
|
304
|
+
const right = p.balance && p.balance.kind === "ok" ? `${formatUsd(p.balance.account.creditRemaining)} credit` : "";
|
|
305
|
+
const kept = parts.slice();
|
|
306
|
+
let showRight = right !== "";
|
|
307
|
+
const leftW = () => kept.reduce((w, x, i) => w + textWidth(x.t) + (i ? sep.length : 0), 0);
|
|
308
|
+
const fits = () => leftW() + (showRight ? 2 + textWidth(right) : 0) <= room;
|
|
309
|
+
// Dropped first to last: routing, ctx, workspace, effort, the balance, then tokens.
|
|
310
|
+
for (const k of ["routing", "ctx", "ws", "effort", "right", "tokens"]) {
|
|
311
|
+
if (fits()) break;
|
|
312
|
+
if (k === "right") { showRight = false; continue; }
|
|
313
|
+
const i = kept.findIndex((x) => x.k === k);
|
|
314
|
+
if (i > 0) kept.splice(i, 1);
|
|
315
|
+
}
|
|
316
|
+
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));
|
|
318
|
+
else if (showRight) line += " ".repeat(Math.max(2, room - leftW() - textWidth(right))) + sage(s, right);
|
|
319
|
+
return [line, keyHints(ui)];
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// "/ commands · ↑↓ history · esc clear · ctrl+c quit": keys bold, labels dim, cut to fit.
|
|
323
|
+
export function keyHints(ui ) {
|
|
324
|
+
const s = styleOf(ui);
|
|
325
|
+
const room = ui.columns - 1;
|
|
326
|
+
const hints = [["/", "commands"], [ui.unicode ? "↑↓" : "up/down", "history"], ["tab", "complete"], ["ctrl+c", "quit"]];
|
|
327
|
+
const sep = ui.unicode ? " · " : " | ";
|
|
328
|
+
const kept = hints.slice();
|
|
329
|
+
const w = () => kept.reduce((n, [k, l], i) => n + k.length + 1 + l.length + (i ? sep.length : 0), 0);
|
|
330
|
+
while (kept.length > 1 && w() > room) kept.splice(kept.length - 2, 1);
|
|
331
|
+
if (w() > room) return dim(s, fit("/ for commands", room, ui));
|
|
332
|
+
return kept.map(([k, l]) => bold(s, k) + " " + dim(s, l)).join(dim(s, sep));
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// ---------------------------------------------------------------- spinner
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
const CR_ERASE = "\r\x1b[2K";
|
|
348
|
+
|
|
349
|
+
export class Spinner {
|
|
350
|
+
out ;
|
|
351
|
+
o ;
|
|
352
|
+
timer = undefined;
|
|
353
|
+
t0 = 0;
|
|
354
|
+
frame = 0;
|
|
355
|
+
drawn = false;
|
|
356
|
+
|
|
357
|
+
constructor(out , o ) {
|
|
358
|
+
this.out = out;
|
|
359
|
+
this.o = o;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
get active() {
|
|
363
|
+
return this.timer !== undefined;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
start() {
|
|
367
|
+
if (!this.o.enabled || this.timer !== undefined) return;
|
|
368
|
+
const now = this.o.now ?? (() => Date.now());
|
|
369
|
+
this.t0 = now();
|
|
370
|
+
this.frame = 0;
|
|
371
|
+
this.draw();
|
|
372
|
+
const set = this.o.setTimer ?? ((fn , ms ) => {
|
|
373
|
+
const h = setInterval(fn, ms);
|
|
374
|
+
(h ).unref?.();
|
|
375
|
+
return h;
|
|
376
|
+
});
|
|
377
|
+
this.timer = set(() => this.draw(), this.o.intervalMs ?? 80);
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
elapsedText() {
|
|
381
|
+
const now = this.o.now ?? (() => Date.now());
|
|
382
|
+
return `${(Math.max(0, now() - this.t0) / 1000).toFixed(1)}s`;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
draw() {
|
|
386
|
+
const g = glyphs(this.o.ui);
|
|
387
|
+
const f = g.spin[this.frame++ % g.spin.length] ;
|
|
388
|
+
const st = styleOf(this.o.ui);
|
|
389
|
+
const text = fit(`${f} waiting ${this.elapsedText()}`, this.o.ui.columns - 1, this.o.ui);
|
|
390
|
+
// The frame is terracotta, the rest stone. fit() keeps the frame first, so the split is safe.
|
|
391
|
+
this.out.write(CR_ERASE + terracotta(st, text.slice(0, f.length)) + stone(st, text.slice(f.length)));
|
|
392
|
+
this.drawn = true;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
// Clears the line and stops. Safe to call at any time, any number of times.
|
|
396
|
+
stop() {
|
|
397
|
+
if (this.timer !== undefined) {
|
|
398
|
+
(this.o.clearTimer ?? ((h ) => clearInterval(h )))(this.timer);
|
|
399
|
+
this.timer = undefined;
|
|
400
|
+
}
|
|
401
|
+
if (this.drawn) {
|
|
402
|
+
this.out.write(CR_ERASE);
|
|
403
|
+
this.drawn = false;
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
// ---------------------------------------------------------------- tool cards
|
|
409
|
+
|
|
410
|
+
// The one piece of an argument list worth showing in a card header.
|
|
411
|
+
export function toolLabel(name , rawArgs ) {
|
|
412
|
+
let a = {};
|
|
413
|
+
try {
|
|
414
|
+
const v = JSON.parse(rawArgs || "{}");
|
|
415
|
+
if (v && typeof v === "object" && !Array.isArray(v)) a = v ;
|
|
416
|
+
} catch { /* shown as no label */ }
|
|
417
|
+
const pick = (k ) => (typeof a[k] === "string" ? (a[k] ) : "");
|
|
418
|
+
switch (name) {
|
|
419
|
+
case "read_file": case "write_file": case "edit_file": return visibleEscapes(pick("path"));
|
|
420
|
+
case "glob": case "grep": return visibleEscapes(pick("pattern"));
|
|
421
|
+
case "bash": return visibleEscapes(pick("command"));
|
|
422
|
+
default: return "";
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
export function cardHeader(name , rawArgs , ui ) {
|
|
427
|
+
const g = glyphs(ui);
|
|
428
|
+
const s = styleOf(ui);
|
|
429
|
+
const prefix = `${g.tl}${g.h} `;
|
|
430
|
+
const n = fit(Array.from(oneLine(name)).slice(0, 40).join("") || "tool", Math.max(8, ui.columns - 1 - textWidth(prefix)), ui);
|
|
431
|
+
const label = oneLine(toolLabel(name, rawArgs));
|
|
432
|
+
const room = ui.columns - 1 - textWidth(prefix) - textWidth(n);
|
|
433
|
+
if (!label) return stone(s, prefix) + bold(s, n);
|
|
434
|
+
const sepText = ` ${g.dot} `;
|
|
435
|
+
const shown = name === "bash" ? fit(label, room - sepText.length, ui) : fitPath(label, room - sepText.length, ui);
|
|
436
|
+
return stone(s, prefix) + bold(s, n) + stone(s, sepText + shown);
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
// One short line about what a tool returned. No file contents are shown for read, glob or grep.
|
|
440
|
+
export function summarizeResult(name , result ) {
|
|
441
|
+
const lines = result.split("\n");
|
|
442
|
+
const first = oneLine(lines[0] ?? "");
|
|
443
|
+
if (result === "denied by user") return { ok: false, text: "denied" };
|
|
444
|
+
if (result.startsWith("error:")) return { ok: false, text: first.replace(/^error:\s*/, "") || "error" };
|
|
445
|
+
switch (name) {
|
|
446
|
+
case "read_file": return { ok: true, text: `${groupDigits(lines.length)} lines` };
|
|
447
|
+
case "glob": case "grep": {
|
|
448
|
+
const n = result.trim() === "" || /^no (matches|files)/i.test(result.trim()) ? 0 : lines.filter((l) => l.trim() !== "").length;
|
|
449
|
+
return { ok: true, text: `${groupDigits(n)} ${n === 1 ? "match" : "matches"}` };
|
|
450
|
+
}
|
|
451
|
+
case "bash": {
|
|
452
|
+
const body = lines.filter((l) => l.trim() !== "");
|
|
453
|
+
const code = /^\[(exit \d+|signal [^\]]*)\]$/.exec(body[body.length - 1] ?? "");
|
|
454
|
+
const status = code ? (code[1] ) : "done";
|
|
455
|
+
const firstOut = oneLine(body[0] ?? "");
|
|
456
|
+
const hasOut = code ? body.length > 1 : body.length > 0;
|
|
457
|
+
return { ok: status === "exit 0" || !code, text: hasOut ? `${status} · ${firstOut}` : status };
|
|
458
|
+
}
|
|
459
|
+
default: return { ok: true, text: first || "done" };
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
export function cardFooter(name , result , ui ) {
|
|
464
|
+
const g = glyphs(ui);
|
|
465
|
+
const s = styleOf(ui);
|
|
466
|
+
const r = summarizeResult(name, result);
|
|
467
|
+
const prefix = `${g.bl}${g.h} `;
|
|
468
|
+
const mark = r.ok ? sage(s, g.ok) : red(s, g.bad);
|
|
469
|
+
const text = r.text.replace(" · ", ui.unicode ? " · " : " | ");
|
|
470
|
+
const room = ui.columns - 1 - prefix.length - (r.ok ? g.ok.length : g.bad.length) - 1;
|
|
471
|
+
return stone(s, prefix) + mark + " " + stone(s, fit(text, room, ui));
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
// Puts the card's left edge in front of every line of a block (the diff preview).
|
|
475
|
+
export function cardBody(text , ui ) {
|
|
476
|
+
const bar = stone(styleOf(ui), glyphs(ui).v);
|
|
477
|
+
return text.split("\n").map((l) => bar + l).join("\n");
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
// ---------------------------------------------------------------- footer
|
|
481
|
+
|
|
482
|
+
|
|
483
|
+
|
|
484
|
+
|
|
485
|
+
|
|
486
|
+
|
|
487
|
+
|
|
488
|
+
|
|
489
|
+
export function formatMs(ms ) {
|
|
490
|
+
return ms < 1000 ? `${Math.round(ms)}ms` : `${(ms / 1000).toFixed(2)}s`;
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
const good = (n ) => n !== undefined && Number.isFinite(n);
|
|
494
|
+
|
|
495
|
+
// `⚡ 340ms TTFT · 68.4 tok/s · local · $9.98 left`. Parts that are unknown are left out, and the
|
|
496
|
+
// least important parts drop first when the line would not fit.
|
|
497
|
+
export function formatFooter(p , ui ) {
|
|
498
|
+
const g = glyphs(ui);
|
|
499
|
+
const ttft = good(p.ttftMs) && p.ttftMs >= 0 ? `${formatMs(p.ttftMs)} TTFT` : "";
|
|
500
|
+
const rate = good(p.tokPerSec) && p.tokPerSec > 0 ? `${p.tokPerSec.toFixed(1)} tok/s` : "";
|
|
501
|
+
const route = p.routing && p.routing !== "?" ? Array.from(oneLine(p.routing)).slice(0, 12).join("") : "";
|
|
502
|
+
const left = good(p.creditLeft) ? `${formatUsd(p.creditLeft)} left` : "";
|
|
503
|
+
const join = (parts ) => {
|
|
504
|
+
const kept = parts.filter((x) => x !== "");
|
|
505
|
+
return kept.length ? `${g.bolt} ${kept.join(` ${g.dot} `)}` : "";
|
|
506
|
+
};
|
|
507
|
+
const width = ui.columns - 1;
|
|
508
|
+
const variants = [[ttft, rate, route, left], [ttft, rate, route], [ttft, rate], [ttft], [rate]];
|
|
509
|
+
for (const v of variants) {
|
|
510
|
+
const t = join(v);
|
|
511
|
+
if (t !== "" && textWidth(t) <= width) return t;
|
|
512
|
+
}
|
|
513
|
+
const any = join([ttft, rate, route, left]);
|
|
514
|
+
return any === "" ? "" : fit(any, width, ui);
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
// ---------------------------------------------------------------- turn renderer
|
|
518
|
+
|
|
519
|
+
|
|
520
|
+
|
|
521
|
+
// Turns agent events into terminal output. One instance per session.
|
|
522
|
+
export class TurnRenderer {
|
|
523
|
+
out ;
|
|
524
|
+
ui ;
|
|
525
|
+
spinner ;
|
|
526
|
+
midLine = false; // the last thing written was model text with no trailing newline
|
|
527
|
+
|
|
528
|
+
constructor(out , ui , spinner ) {
|
|
529
|
+
this.out = out;
|
|
530
|
+
this.ui = ui;
|
|
531
|
+
this.spinner = spinner;
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
// A request is about to be sent: show that we are waiting for the first token.
|
|
535
|
+
requestStart() {
|
|
536
|
+
this.spinner.start();
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
// Model text. The spinner is erased first; the text is stripped of every escape sequence.
|
|
540
|
+
text(t ) {
|
|
541
|
+
this.spinner.stop();
|
|
542
|
+
const clean = sanitize(t);
|
|
543
|
+
if (clean === "") return;
|
|
544
|
+
this.out.write(clean);
|
|
545
|
+
this.midLine = !clean.endsWith("\n");
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
// The request finished (with or without a tool call).
|
|
549
|
+
requestEnd() {
|
|
550
|
+
this.spinner.stop();
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
toolStart(name , rawArgs ) {
|
|
554
|
+
this.spinner.stop();
|
|
555
|
+
this.out.write((this.midLine ? "\n" : "") + cardHeader(name, rawArgs, this.ui) + "\n");
|
|
556
|
+
this.midLine = false;
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
toolResult(name , result ) {
|
|
560
|
+
this.out.write(cardFooter(name, result, this.ui) + "\n");
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
// The diff preview, inside the card.
|
|
564
|
+
body(text ) {
|
|
565
|
+
return cardBody(text, this.ui);
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
// Leaves the cursor at the start of a line after a turn.
|
|
569
|
+
turnEnd() {
|
|
570
|
+
this.spinner.stop();
|
|
571
|
+
if (this.midLine) this.out.write("\n");
|
|
572
|
+
this.midLine = false;
|
|
573
|
+
}
|
|
574
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,37 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@synqa/cli",
|
|
3
|
-
"version": "
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "1.1.1",
|
|
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
|
+
}
|