@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.
@@ -0,0 +1,61 @@
1
+ // In-memory journal of the files the agent wrote, so /undo can put the last one back.
2
+ import { existsSync, readFileSync, rmSync, statSync } from "node:fs";
3
+ import { relative } from "node:path";
4
+ import { confine, writeConfined } from "./paths.js";
5
+
6
+
7
+
8
+
9
+
10
+
11
+
12
+ export const MAX_JOURNAL = 100;
13
+ export const MAX_SNAPSHOT_BYTES = 5_000_000;
14
+
15
+ // The current bytes of a file, null when it does not exist, "too-large" when it is not safe to keep.
16
+ export function snapshot(path ) {
17
+ if (!existsSync(path)) return null;
18
+ if (statSync(path).size > MAX_SNAPSHOT_BYTES) return "too-large";
19
+ return readFileSync(path);
20
+ }
21
+
22
+
23
+
24
+
25
+
26
+ export class EditJournal {
27
+ entries = [];
28
+
29
+ get size() {
30
+ return this.entries.length;
31
+ }
32
+
33
+ record(e ) {
34
+ this.entries.push(e);
35
+ if (this.entries.length > MAX_JOURNAL) this.entries.shift();
36
+ }
37
+
38
+ peek() {
39
+ return this.entries[this.entries.length - 1];
40
+ }
41
+
42
+ // Reverts the newest entry. If the file changed since the agent wrote it (the user edited it, or a
43
+ // command did), the caller must confirm first: `force` is that answer.
44
+ undo(cwd , force ) {
45
+ const e = this.peek();
46
+ if (!e) return { ok: false, reason: "empty", message: "nothing to undo" };
47
+ try {
48
+ const abs = confine(cwd, e.path);
49
+ const current = existsSync(abs) ? readFileSync(abs) : null;
50
+ if (!force && (current === null || !current.equals(e.after))) {
51
+ return { ok: false, reason: "changed-since", message: "the file changed after the agent wrote it" };
52
+ }
53
+ if (e.before === null) rmSync(abs, { force: true });
54
+ else writeConfined(cwd, e.path, e.before);
55
+ } catch (err) {
56
+ return { ok: false, reason: "error", message: err instanceof Error ? err.message : String(err) };
57
+ }
58
+ this.entries.pop();
59
+ return { ok: true, path: relative(cwd, e.path) || e.path, removed: e.before === null, before: e.before, after: e.after };
60
+ }
61
+ }
package/dist/main.js ADDED
@@ -0,0 +1,254 @@
1
+ import { createInterface } from "node:readline/promises";
2
+ import { runAgent } from "./agent.js";
3
+ import { checkKey } from "./api.js";
4
+ import { announceNotes, freshHistory, runCommand, statusParts } from "./commands.js";
5
+
6
+ import { looksLikeKey, readConfig, resolveApiKey, writeConfig, configPath } from "./config.js";
7
+ import { EditJournal } from "./journal.js";
8
+ import { unsafeStartDir } from "./paths.js";
9
+ import { sanitize } from "./sanitize.js";
10
+ import { formatStatus, StatusBar } from "./statusbar.js";
11
+ import { DEFAULT_TIER, findEffort, findTier, routingLabel } from "./tiers.js";
12
+ import { accent, dim, stone } from "./theme.js";
13
+
14
+ import { fetchAccount } from "./account.js";
15
+ import { balanceFrom, formatFooter, makeUi, readVersion, renderBanner, Spinner, styleOf, TurnRenderer } from "./ui.js";
16
+
17
+
18
+ const VERSION = readVersion();
19
+
20
+ // The balance fetch gets this long at startup and after a turn; the banner never waits longer.
21
+ const BALANCE_TIMEOUT_MS = 2000;
22
+
23
+ const HELP = `Usage:
24
+ synqa interactive session in the current folder
25
+ synqa "do something" one-shot task
26
+ synqa login save your API key
27
+ Options:
28
+ -y, --yes do not ask before write, edit and bash
29
+ -m, --model <tier> blink | sonata | aeon (default blink)
30
+ -e, --effort <level> default | low | medium (default default)
31
+ --allow-home allow starting in your home folder or the filesystem root
32
+ -h, --help -v, --version
33
+ In a session: /model, /effort, /status, /init, /compact, /undo, /clear, /help, /exit`;
34
+
35
+ // Masked single-line input: nothing typed is echoed.
36
+ export function readSecret(promptText ) {
37
+ return new Promise((resolve, reject) => {
38
+ const stdin = process.stdin;
39
+ if (!stdin.isTTY) {
40
+ reject(new Error("login needs an interactive terminal (or set SYNQA_API_KEY)"));
41
+ return;
42
+ }
43
+ process.stdout.write(promptText);
44
+ let value = "";
45
+ stdin.setRawMode(true);
46
+ stdin.resume();
47
+ stdin.setEncoding("utf8");
48
+ const onData = (chunk ) => {
49
+ for (const ch of chunk) {
50
+ if (ch === "\r" || ch === "\n") {
51
+ stdin.setRawMode(false);
52
+ stdin.pause();
53
+ stdin.off("data", onData);
54
+ process.stdout.write("\n");
55
+ resolve(value.trim());
56
+ return;
57
+ }
58
+ if (ch === "\u0003") {
59
+ stdin.setRawMode(false);
60
+ process.stdout.write("\n");
61
+ reject(new Error("cancelled"));
62
+ return;
63
+ }
64
+ if (ch === "\u007f" || ch === "\b") value = value.slice(0, -1);
65
+ else if (ch >= " ") value += ch;
66
+ }
67
+ };
68
+ stdin.on("data", onData);
69
+ });
70
+ }
71
+
72
+ // The error text can come from the network, so it is sanitized before printing.
73
+ export function loginFailureMessage(err ) {
74
+ return sanitize(`Could not verify the key: ${err instanceof Error ? err.message : String(err)}`);
75
+ }
76
+
77
+ export async function login() {
78
+ const key = await readSecret("Paste your Synqa API key (input is hidden): ");
79
+ if (!looksLikeKey(key)) {
80
+ console.error("That does not look like a Synqa key (expected synqa_live_...).");
81
+ return 1;
82
+ }
83
+ try {
84
+ await checkKey(key);
85
+ } catch (err) {
86
+ console.error(loginFailureMessage(err));
87
+ return 1;
88
+ }
89
+ const file = writeConfig({ ...readConfig(), apiKey: key });
90
+ console.log(`Key verified and saved to ${file}`);
91
+ return 0;
92
+ }
93
+
94
+
95
+
96
+ export function parseArgs(argv ) {
97
+ const a = { allowHome: false, yes: false, model: "", effort: "", help: false, version: false, rest: [] };
98
+ for (let i = 0; i < argv.length; i++) {
99
+ const t = argv[i] ;
100
+ if (t === "-y" || t === "--yes") a.yes = true;
101
+ else if (t === "--allow-home") a.allowHome = true;
102
+ else if (t === "-h" || t === "--help") a.help = true;
103
+ else if (t === "-v" || t === "--version") a.version = true;
104
+ else if (t === "-m" || t === "--model") a.model = argv[++i] ?? "";
105
+ else if (t === "-e" || t === "--effort") a.effort = argv[++i] ?? "";
106
+ else a.rest.push(t);
107
+ }
108
+ return a;
109
+ }
110
+
111
+ export async function main(argv ) {
112
+ const args = parseArgs(argv);
113
+ if (args.help) { console.log(HELP); return 0; }
114
+ if (args.version) { console.log(VERSION); return 0; }
115
+ if (args.rest[0] === "login") return login();
116
+
117
+ const unsafe = unsafeStartDir(process.cwd());
118
+ if (unsafe && !args.allowHome) {
119
+ console.error(`Refusing to start in ${unsafe}: the agent could read your keys and settings there. cd into a project folder, or pass --allow-home if you really mean it.`);
120
+ return 1;
121
+ }
122
+ const apiKey = resolveApiKey();
123
+ if (!apiKey) {
124
+ console.error(`No API key. Run "synqa login" or set SYNQA_API_KEY. (config: ${configPath()})`);
125
+ return 1;
126
+ }
127
+ const ui = makeUi(process.env, process.stdout);
128
+ const style = styleOf(ui);
129
+ const tier = findTier(args.model || readConfig().tier || DEFAULT_TIER);
130
+ if (!tier) { console.error(`Unknown tier "${args.model}". Use blink, sonata or aeon.`); return 1; }
131
+ const effort = args.effort ? findEffort(args.effort) : "default";
132
+ if (!effort) { console.error(`Unknown effort "${args.effort}". Use default, low or medium.`); return 1; }
133
+
134
+ const interactive = Boolean(process.stdin.isTTY) && args.rest.length === 0;
135
+ const rl = createInterface({ input: process.stdin, output: process.stdout, terminal: Boolean(process.stdin.isTTY) });
136
+ const say = (s ) => console.log(s);
137
+ // Chrome (spinner, cards, footer) is for interactive terminals only. Pipes and one-shot runs get
138
+ // the plain text output they had before.
139
+ const chrome = interactive && ui.tty;
140
+ const spinner = new Spinner(process.stdout, { enabled: chrome, ui });
141
+ const renderer = new TurnRenderer(process.stdout, ui, spinner);
142
+ const inCard = (t ) => (chrome ? renderer.body(t) : t);
143
+ // `detail` is the coloured diff the tools built; the question is sanitized, the diff is not
144
+ // (the tools already escaped every file character in it).
145
+ const confirm = async (q , detail ) => {
146
+ if (!process.stdin.isTTY) return false;
147
+ spinner.stop();
148
+ if (detail) say(inCard(detail));
149
+ const ans = await rl.question(`${accent(style, "?")} ${sanitize(q)} [y/N] `);
150
+ return /^y(es)?$/i.test(ans.trim());
151
+ };
152
+
153
+ const cwd = process.cwd();
154
+ const st = {
155
+ apiKey, cwd, tier, effort, history: [], journal: new EditJournal(),
156
+ total: { prompt: 0, completion: 0 }, last: { prompt: 0, completion: 0 }, routing: "?", notesChars: 0, notesShown: 0,
157
+ };
158
+ st.history = freshHistory(st);
159
+ const ctx = { cwd, confirm, yes: args.yes, color: style.color, journal: st.journal, show: (t ) => say(inCard(t)), get notesLoaded() { return st.notesChars > 0; } };
160
+
161
+ // The pinned bar needs a terminal on both ends; otherwise the status is an ordinary line.
162
+ const bar = new StatusBar(
163
+ process.stdout,
164
+ interactive && Boolean(process.stdout.isTTY) && process.env.TERM !== "dumb",
165
+ (s) => dim(style, s),
166
+ );
167
+ const refresh = () => {
168
+ if (bar.enabled) bar.update(formatStatus(statusParts(st), (process.stdout.columns ?? 80) - 1));
169
+ };
170
+ const restore = () => { spinner.stop(); bar.uninstall(); };
171
+ process.on("exit", restore);
172
+ process.stdout.on("resize", () => { bar.resize(); refresh(); });
173
+
174
+ // Known balance for the footer; refreshed in the background after each turn, never awaited.
175
+ let balance = { kind: "unavailable" };
176
+ const loadBalance = async () => balanceFrom(await fetchAccount(apiKey, undefined, BALANCE_TIMEOUT_MS));
177
+ const refreshBalance = () => { void loadBalance().then((b) => { balance = b; }, () => {}); };
178
+
179
+ const turn = async (text ) => {
180
+ st.history.push({ role: "user", content: text });
181
+ try {
182
+ const r = await runAgent({
183
+ apiKey, tier: st.tier, effort: st.effort, history: st.history, ctx,
184
+ events: chrome
185
+ ? {
186
+ onRequest: () => renderer.requestStart(),
187
+ onText: (t) => renderer.text(t),
188
+ onTool: (name, a) => renderer.toolStart(name, a),
189
+ onToolResult: (name, _a, result) => renderer.toolResult(name, result),
190
+ onTurn: () => renderer.requestEnd(),
191
+ }
192
+ : {
193
+ onText: (t) => process.stdout.write(sanitize(t)),
194
+ onTool: (name, a) => process.stdout.write(`\n${dim(style, `> ${sanitize(name)} ${sanitize(a.length > 120 ? a.slice(0, 117) + "..." : a)}`)}\n`),
195
+ onTurn: () => {},
196
+ },
197
+ });
198
+ st.total = { prompt: st.total.prompt + r.total.prompt, completion: st.total.completion + r.total.completion };
199
+ st.last = r.lastUsage;
200
+ st.routing = routingLabel(r.routing);
201
+ st.ttftMs = r.ttftMs;
202
+ st.tokPerSec = r.tokPerSec;
203
+ if (chrome) {
204
+ renderer.turnEnd();
205
+ const footer = formatFooter({
206
+ ttftMs: r.ttftMs, tokPerSec: r.tokPerSec, routing: st.routing,
207
+ creditLeft: balance.kind === "ok" ? balance.account.creditRemaining : undefined,
208
+ }, ui);
209
+ if (footer) say(stone(style, footer));
210
+ refreshBalance();
211
+ } else {
212
+ process.stdout.write("\n");
213
+ }
214
+ } catch (err) {
215
+ spinner.stop();
216
+ console.error(`\nerror: ${sanitize(err instanceof Error ? err.message : String(err))}`);
217
+ }
218
+ refresh();
219
+ };
220
+
221
+ try {
222
+ if (!interactive) {
223
+ const prompt = args.rest.join(" ");
224
+ if (!prompt) { console.error("Nothing to do. Pass a task or run in a terminal."); return 1; }
225
+ announceNotes(st, say);
226
+ await turn(prompt);
227
+ say(dim(style, formatStatus(statusParts(st))));
228
+ return 0;
229
+ }
230
+
231
+ // The balance may take a moment; the banner waits at most BALANCE_TIMEOUT_MS for it.
232
+ balance = await loadBalance();
233
+ for (const line of renderBanner({ version: VERSION, tier: st.tier, cwd, notesChars: st.notesChars, balance }, ui)) say(line);
234
+ say(dim(style, "Type /help for commands."));
235
+ announceNotes(st, say);
236
+ bar.install();
237
+ refresh();
238
+ for (;;) {
239
+ // Without a pinned bar the status is printed above each prompt instead.
240
+ if (!bar.enabled) say(dim(style, formatStatus(statusParts(st))));
241
+ let line ;
242
+ try { line = (await rl.question(`${accent(style, ui.unicode ? "❯" : ">")} `)).trim(); } catch { break; }
243
+ if (!line) continue;
244
+ const r = await runCommand(line, st, { print: say, confirm, style });
245
+ refresh();
246
+ if (r.exit) break;
247
+ if (!r.handled) await turn(line);
248
+ }
249
+ return 0;
250
+ } finally {
251
+ restore();
252
+ rl.close();
253
+ }
254
+ }
package/dist/paths.js ADDED
@@ -0,0 +1,131 @@
1
+ import { closeSync, constants, lstatSync, openSync, realpathSync, writeSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { basename, dirname, isAbsolute, join, parse, relative, resolve, sep } from "node:path";
4
+ import { configDir } from "./config.js";
5
+
6
+ export class ConfineError extends Error {}
7
+
8
+ function realOfExisting(p ) {
9
+ // Resolve symlinks on the nearest existing ancestor, then re-append the missing tail.
10
+ // A missing component that is itself a symlink is a dangling link: refuse it, because a
11
+ // write would follow it to wherever it points.
12
+ let cur = p;
13
+ const tail = [];
14
+ for (;;) {
15
+ try {
16
+ const real = realpathSync(cur);
17
+ return tail.length ? join(real, ...tail.reverse()) : real;
18
+ } catch {
19
+ let isLink = false;
20
+ try {
21
+ isLink = lstatSync(cur).isSymbolicLink();
22
+ } catch {
23
+ isLink = false;
24
+ }
25
+ if (isLink) throw new ConfineError(`path goes through a dangling symlink: ${p}`);
26
+ const parent = dirname(cur);
27
+ if (parent === cur) return p;
28
+ tail.push(cur.slice(parent.length).replace(/^[\\/]+/, ""));
29
+ cur = parent;
30
+ }
31
+ }
32
+ }
33
+
34
+ // Returns the absolute path if it stays inside root (after ".." and symlink resolution), else throws.
35
+ export function confine(root , requested ) {
36
+ const realRoot = realpathSync(root);
37
+ const abs = isAbsolute(requested) ? resolve(requested) : resolve(realRoot, requested);
38
+ const real = realOfExisting(abs);
39
+ const rel = relative(realRoot, real);
40
+ if (rel === "") return real;
41
+ if (rel === ".." || rel.startsWith(".." + sep) || isAbsolute(rel)) {
42
+ throw new ConfineError(`path escapes the working directory: ${requested}`);
43
+ }
44
+ return real;
45
+ }
46
+
47
+ // Re-confines right before writing and refuses to follow a symlink as the last component.
48
+ export function writeConfined(root , requested , content ) {
49
+ const p = confine(root, requested);
50
+ const fd = openSync(p, constants.O_WRONLY | constants.O_CREAT | constants.O_TRUNC | constants.O_NOFOLLOW, 0o644);
51
+ try {
52
+ writeSync(fd, content);
53
+ } finally {
54
+ closeSync(fd);
55
+ }
56
+ return p;
57
+ }
58
+
59
+ // One form for every comparison: NFKC (full-width and compatibility characters become plain ones)
60
+ // and lower case, because a client Mac's file system is case-insensitive: ".ENV" is ".env".
61
+ export function fold(s ) {
62
+ return s.normalize("NFKC").toLowerCase();
63
+ }
64
+
65
+ // Folders whose contents are credentials.
66
+ const SENSITIVE_DIRS = new Set([".ssh", ".aws", ".gnupg"]);
67
+ // Exact file names that hold secrets (compared folded).
68
+ const SENSITIVE_NAMES = new Set([
69
+ ".env", ".envrc", ".netrc", ".npmrc", ".pypirc", ".git-credentials", ".dev.vars", "keys.json", "credentials.json", ".yarnrc.yml", ".pgpass", ".my.cnf",
70
+ ]);
71
+ const EXAMPLE_SUFFIX = /\.(example|sample|template|dist)$/;
72
+
73
+ // Files and folders that hold secrets: .env files, private keys, cloud, package manager and
74
+ // container credentials, and this CLI's own config (which holds the API key). Names only, no file
75
+ // is opened. Every segment is folded first (see fold).
76
+ export function isSensitivePath(abs , cfg = configDir()) {
77
+ const norm = fold(abs.split(sep).join("/"));
78
+ const parts = norm.split("/").filter(Boolean);
79
+ if (parts.some((d) => SENSITIVE_DIRS.has(d))) return true;
80
+ for (let i = 0; i + 1 < parts.length; i++) {
81
+ if (parts[i] === ".config" && parts[i + 1] === "synqa") return true;
82
+ if (parts[i] === ".kube" && parts[i + 1] === "config") return true;
83
+ if (parts[i] === ".docker" && parts[i + 1] === "config.json") return true;
84
+ }
85
+ const c = fold(cfg.split(sep).join("/")).replace(/\/+$/, "");
86
+ if (c && (norm === c || norm.startsWith(c + "/"))) return true;
87
+ const name = parts[parts.length - 1] ?? "";
88
+ // Remotes can hold tokens: .git/config, .git/config.worktree, .git/worktrees/<name>/config, at any depth.
89
+ if (parts.slice(0, -1).includes(".git") && (name === "config" || name.startsWith("config."))) return true;
90
+ if (SENSITIVE_NAMES.has(name)) return true;
91
+ if (name.startsWith(".env.") && !EXAMPLE_SUFFIX.test(name)) return true;
92
+ if (/\.(pem|key|p12|pfx|tfvars|tfstate)$/.test(name)) return true;
93
+ if (/^secrets\./.test(name) && !EXAMPLE_SUFFIX.test(name)) return true;
94
+ return /^id_[a-z0-9_]+$/.test(name); // id_rsa, id_ed25519; id_rsa.pub is not matched
95
+ }
96
+
97
+ // Names a shell command can use to reach a secret, including globs that expand to them. Heuristic:
98
+ // it makes an unattended (--yes) command ask when it looks at secrets, it does not make bash safe.
99
+ const SECRET_WORDS = [
100
+ /\.env/, /\.ssh/, /\.aws/, /\.gnupg/, /\.kube/, /\.docker/, /\.netrc/, /\.npmrc/, /\.pypirc/, /\.git-credentials/,
101
+ /\.dev\.vars/, /keys\.json/, /credentials/, /\.tfvars/, /\bsecrets?\b/, /\.(pem|key|p12|pfx)\b/, /\bid_[a-z0-9]/,
102
+ /\.config\/synqa/, /synqa\/config/, /(^|[\s/])\.[\w.-]*[*?[]/, /~\/?\./,
103
+ ];
104
+
105
+ export function commandTouchesSecrets(command , cfg = configDir()) {
106
+ // Quotes and backslashes are dropped first so .e""nv and .\env read as .env.
107
+ const c = fold(command).replace(/["'\\`]/g, "");
108
+ if (SECRET_WORDS.some((re) => re.test(c))) return true;
109
+ const dir = fold(cfg);
110
+ return dir !== "" && c.includes(dir);
111
+ }
112
+
113
+ // A start folder this CLI refuses without --allow-home: the home folder or the filesystem root.
114
+ // Returns the reason, or undefined when the folder is fine.
115
+ export function unsafeStartDir(cwd , home = homedir()) {
116
+ let real ;
117
+ let realHome ;
118
+ try {
119
+ real = realpathSync(cwd);
120
+ } catch {
121
+ return undefined;
122
+ }
123
+ try {
124
+ realHome = realpathSync(home);
125
+ } catch {
126
+ realHome = home;
127
+ }
128
+ if (real === parse(real).root) return "the filesystem root";
129
+ if (real === realHome) return "your home folder";
130
+ return undefined;
131
+ }
@@ -0,0 +1,53 @@
1
+ // Model-controlled text must never reach the terminal as control sequences.
2
+
3
+ // OSC (ESC ] ... BEL or ST), CSI (ESC [ ... final byte), and any other two-byte ESC sequence.
4
+ const ESC_SEQ = /\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)?|\x1b\[[0-?]*[ -/]*[@-~]?|\x1b[@-_]?/g;
5
+ // C0 except \n and \t, DEL, and C1.
6
+ const CONTROL = /[\x00-\x08\x0b-\x1f\x7f-\x9f]/g;
7
+ // Bidi controls and invisible characters that can reorder or hide text, as inclusive code point
8
+ // ranges: U+061C, U+200B-200F, U+2028-2029, U+202A-202E, U+2060-2064, U+2066-2069, U+FEFF. Built from numbers so this
9
+ // source file itself holds none of them.
10
+ const INVISIBLE_RANGES = [
11
+ [0x061c, 0x061c],
12
+ [0x200b, 0x200f],
13
+ [0x2028, 0x2029],
14
+ [0x202a, 0x202e],
15
+ [0x2060, 0x2064],
16
+ [0x2066, 0x2069],
17
+ [0xfeff, 0xfeff],
18
+ ];
19
+ const hex4 = (n ) => n.toString(16).padStart(4, "0");
20
+ const INVISIBLE = new RegExp(
21
+ "[" + INVISIBLE_RANGES.map(([a, b]) => `\\u${hex4(a)}-\\u${hex4(b)}`).join("") + "]",
22
+ "g",
23
+ );
24
+
25
+ export function sanitize(s ) {
26
+ return s.replace(ESC_SEQ, "").replace(CONTROL, "").replace(INVISIBLE, "");
27
+ }
28
+
29
+ // For text the user must judge before approving (shell commands, paths): show escapes literally.
30
+ export function visibleEscapes(s ) {
31
+ return s
32
+ .replace(/[\x00-\x08\x0a-\x1f\x7f-\x9f]/g, (c) => {
33
+ if (c === "\n") return "\\n";
34
+ if (c === "\r") return "\\r";
35
+ return "\\x" + c.charCodeAt(0).toString(16).padStart(2, "0");
36
+ })
37
+ .replace(INVISIBLE, (c) => "\\u" + hex4(c.charCodeAt(0)));
38
+ }
39
+
40
+ // Untrusted text that goes inside a fenced block of the prompt (AGENTS.md, the compact summary).
41
+ // Order matters: NFKC first (full-width and compatibility forms become plain ones), then every
42
+ // format and default-ignorable character is removed (soft hyphen, zero-width and bidi marks, the
43
+ // word joiner range, BOM: they could split a tag so a match misses it), then the cap, so the
44
+ // normalization cannot grow the text past it, and last every "<" becomes "\u2039" so no tag, in any
45
+ // spelling, can open or close the fence.
46
+ export function fenceText(s , max ) {
47
+ return s
48
+ .normalize("NFKC")
49
+ .replace(/[\p{Cf}\u00ad\u034f\u115f\u1160\u17b4\u17b5\u180b-\u180f\u2060-\u206f\u3164\ufe00-\ufe0f\uffa0]/gu, "")
50
+ .trim()
51
+ .slice(0, max)
52
+ .replace(/</g, "\u2039");
53
+ }
@@ -0,0 +1,114 @@
1
+ // The status bar: one line with the tier, routing mode, TTFT, tok/s and session tokens. On a
2
+ // terminal it is pinned to the bottom row (the scroll region stops one row short of it); anywhere
3
+ // else it is printed as an ordinary line by the caller, so nothing here needs a terminal to be tested.
4
+
5
+
6
+
7
+
8
+
9
+
10
+
11
+
12
+
13
+
14
+
15
+ // 1234567 -> "1,234,567" without relying on the locale.
16
+ export function groupDigits(n ) {
17
+ return String(Math.max(0, Math.round(n))).replace(/\B(?=(\d{3})+(?!\d))/g, ",");
18
+ }
19
+
20
+ export function formatTtft(ms ) {
21
+ return ms === undefined || !Number.isFinite(ms) || ms < 0 ? "-" : `${(ms / 1000).toFixed(2)}s`;
22
+ }
23
+
24
+ export function formatRate(tps ) {
25
+ return tps === undefined || !Number.isFinite(tps) || tps <= 0 ? "-" : `${tps.toFixed(1)} tok/s`;
26
+ }
27
+
28
+ // Fits `width` columns by dropping segments from the least important end; the tier always stays.
29
+ export function formatStatus(p , width ) {
30
+ const tier = p.tier.display;
31
+ const effort = p.effort === "default" ? "" : `effort ${p.effort}`;
32
+ const full = [tier, effort, p.routing, `TTFT ${formatTtft(p.ttftMs)}`, formatRate(p.tokPerSec), `${groupDigits(p.tokens)} tokens`];
33
+ const variants = [
34
+ full,
35
+ [tier, p.routing, `TTFT ${formatTtft(p.ttftMs)}`, formatRate(p.tokPerSec), `${groupDigits(p.tokens)} tokens`],
36
+ [tier, p.routing, formatRate(p.tokPerSec), `${groupDigits(p.tokens)} tok`],
37
+ [tier, p.routing, `${groupDigits(p.tokens)} tok`],
38
+ [tier, p.routing],
39
+ ];
40
+ const join = (parts ) => parts.filter((x) => x !== "").join(" | ");
41
+ if (width === undefined) return join(full);
42
+ for (const v of variants) {
43
+ const t = join(v);
44
+ if (t.length <= width) return t;
45
+ }
46
+ const last = join(variants[variants.length - 1] );
47
+ return width > 1 ? last.slice(0, width - 1) + "…" : last.slice(0, width);
48
+ }
49
+
50
+
51
+
52
+ const ESC = "\x1b";
53
+
54
+ // Pins `text` to the last terminal row. All escape sequences are fixed strings made here; the text
55
+ // comes from formatStatus (tier names and numbers), never from the model.
56
+ export class StatusBar {
57
+ out ;
58
+ on ;
59
+ text = "";
60
+ installed = false;
61
+ paint ;
62
+
63
+ constructor(out , enabled , paint = (s) => s) {
64
+ this.out = out;
65
+ this.paint = paint;
66
+ // Needs room for at least the bar plus a few lines of scrollback.
67
+ this.on = enabled && (out.rows ?? 0) >= 6 && (out.columns ?? 0) >= 20;
68
+ }
69
+
70
+ get enabled() {
71
+ return this.on;
72
+ }
73
+
74
+ rows() {
75
+ return this.out.rows ?? 24;
76
+ }
77
+
78
+ cols() {
79
+ return this.out.columns ?? 80;
80
+ }
81
+
82
+ install() {
83
+ if (!this.on || this.installed) return;
84
+ this.installed = true;
85
+ // A newline guarantees a spare row below the cursor, then the cursor goes back up.
86
+ this.out.write(`\n${ESC}[1A${ESC}7${ESC}[1;${this.rows() - 1}r${ESC}8`);
87
+ this.draw();
88
+ }
89
+
90
+ update(text ) {
91
+ this.text = text;
92
+ this.draw();
93
+ }
94
+
95
+ // Terminal size changed: move the scroll region and repaint.
96
+ resize() {
97
+ if (!this.on || !this.installed) return;
98
+ this.out.write(`${ESC}7${ESC}[1;${this.rows() - 1}r${ESC}8`);
99
+ this.draw();
100
+ }
101
+
102
+ draw() {
103
+ if (!this.on || !this.installed) return;
104
+ const line = this.text.length > this.cols() - 1 ? this.text.slice(0, this.cols() - 2) + "…" : this.text;
105
+ this.out.write(`${ESC}7${ESC}[${this.rows()};1H${ESC}[2K${this.paint(line)}${ESC}8`);
106
+ }
107
+
108
+ // Gives the whole screen back and clears the bar row.
109
+ uninstall() {
110
+ if (!this.on || !this.installed) return;
111
+ this.installed = false;
112
+ this.out.write(`${ESC}7${ESC}[r${ESC}[${this.rows()};1H${ESC}[2K${ESC}8`);
113
+ }
114
+ }