@synqa/cli 1.1.0 → 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/dist/prompt.js ADDED
@@ -0,0 +1,330 @@
1
+ // The interactive prompt: the slash command table, the inline command menu, /help, and the frame
2
+ // drawn around the input line (a rule above, a rule, the status line and key hints below).
3
+ //
4
+ // The frame and menu use only node:readline keypress events, no extra package. Keys from the
5
+ // terminal are read here and forwarded to readline through a PassThrough, so the menu can take the
6
+ // arrow keys, Tab, Enter and Esc for itself while it is open. The only control sequences written
7
+ // are the fixed strings in this file (cursor up and column, erase line); every piece of text is our
8
+ // own table or the user's own typing, already shown by readline.
9
+ import { PassThrough } from "node:stream";
10
+ import { emitKeypressEvents } from "node:readline";
11
+
12
+ import { bold, dim, stone, terracottaBold } from "./theme.js";
13
+ import { fit, oneLine, pad, PLACEHOLDER, rule, styleOf, textWidth } from "./ui.js";
14
+
15
+
16
+
17
+
18
+ export const SLASH_COMMANDS = [
19
+ { name: "/model", args: "blink|sonata|aeon", desc: "Switch tier", group: "Session" },
20
+ { name: "/effort", args: "default|low|medium", desc: "Set reasoning effort", group: "Session" },
21
+ { name: "/status", desc: "Tier, routing, tokens, credit and weekly pool", group: "Session" },
22
+ { name: "/compact", desc: "Summarize the conversation to save context", group: "Session" },
23
+ { name: "/clear", desc: "Start a fresh conversation", group: "Session" },
24
+ { name: "/init", desc: "Write a starter AGENTS.md for this project", group: "Project" },
25
+ { name: "/undo", desc: "Revert the last file the agent wrote", group: "Project" },
26
+ { name: "/help", desc: "Show commands and keys", group: "General" },
27
+ { name: "/exit", desc: "Leave Synqa", group: "General" },
28
+ ];
29
+
30
+ // Commands whose name starts with what was typed, then those that contain it.
31
+ export function matchCommands(typed ) {
32
+ const t = typed.toLowerCase();
33
+ if (!t.startsWith("/")) return [];
34
+ const starts = SLASH_COMMANDS.filter((c) => c.name.startsWith(t));
35
+ const contains = SLASH_COMMANDS.filter((c) => !c.name.startsWith(t) && c.name.includes(t.slice(1)));
36
+ return [...starts, ...contains];
37
+ }
38
+
39
+ function distance(a , b ) {
40
+ const d = Array.from({ length: a.length + 1 }, (_, i) => [i, ...Array (b.length).fill(0)]);
41
+ for (let j = 1; j <= b.length; j++) (d[0] )[j] = j;
42
+ for (let i = 1; i <= a.length; i++) {
43
+ for (let j = 1; j <= b.length; j++) {
44
+ const row = d[i] ;
45
+ const prev = d[i - 1] ;
46
+ row[j] = Math.min((prev[j] ) + 1, (row[j - 1] ) + 1, (prev[j - 1] ) + (a[i - 1] === b[j - 1] ? 0 : 1));
47
+ }
48
+ }
49
+ return (d[a.length] )[b.length] ;
50
+ }
51
+
52
+ // One line for an unknown command, with the nearest name when one is close.
53
+ export function unknownCommandHint(word ) {
54
+ const w = oneLine(word).toLowerCase().slice(0, 40);
55
+ const near = matchCommands(w)[0] ?? SLASH_COMMANDS
56
+ .map((c) => ({ c, d: distance(w, c.name) }))
57
+ .filter((x) => x.d <= 2)
58
+ .sort((a, b) => a.d - b.d)[0]?.c;
59
+ return near ? `Unknown command ${w}. Did you mean ${near.name}? Type / to see all commands.` : `Unknown command ${w}. Type / to see all commands.`;
60
+ }
61
+
62
+ const usage = (c ) => (c.args ? `${c.name} ${c.args}` : c.name);
63
+
64
+ // /help: grouped, aligned, names bold and descriptions dim.
65
+ export function renderHelp(ui ) {
66
+ const s = styleOf(ui);
67
+ const room = ui.columns - 1;
68
+ const w = Math.max(...SLASH_COMMANDS.map((c) => usage(c).length)) + 3;
69
+ const out = [];
70
+ for (const group of ["Session", "Project", "General"] ) {
71
+ out.push(stone(s, group));
72
+ for (const c of SLASH_COMMANDS.filter((x) => x.group === group)) {
73
+ const u = usage(c);
74
+ if (4 + w >= room) { out.push(" " + bold(s, fit(u, room - 2, ui))); continue; }
75
+ out.push(" " + bold(s, c.name) + (c.args ? dim(s, " " + c.args) : "") + " ".repeat(w - u.length) + dim(s, fit(c.desc, room - 2 - w, ui)));
76
+ }
77
+ }
78
+ out.push(dim(s, fit("Type / for the command menu: arrows move, Tab or Enter completes, Esc closes.", room, ui)));
79
+ return out;
80
+ }
81
+
82
+ export const SLASH_HELP = renderHelp({ color: false, unicode: true, tty: false, columns: 100 }).join("\n");
83
+
84
+ const MENU_MAX = 8;
85
+
86
+ // The menu under the input: the selected row has a terracotta pointer and a bold name.
87
+ export function renderMenu(items , selected , ui ) {
88
+ const s = styleOf(ui);
89
+ const room = ui.columns - 1;
90
+ const pointer = ui.unicode ? "❯" : ">";
91
+ const nameW = Math.max(...SLASH_COMMANDS.map((c) => c.name.length)) + 2;
92
+ const start = Math.max(0, Math.min(selected - MENU_MAX + 1, items.length - MENU_MAX));
93
+ return items.slice(start, start + MENU_MAX).map((c, i) => {
94
+ const on = start + i === selected;
95
+ const lead = on ? terracottaBold(s, pointer) + " " : " ";
96
+ const name = pad(c.name, nameW);
97
+ const descRoom = room - 2 - nameW;
98
+ const desc = descRoom > 4 ? fit(c.desc, descRoom, ui) : "";
99
+ if (descRoom <= 4) return lead + (on ? terracottaBold(s, fit(c.name, room - 2, ui)) : fit(c.name, room - 2, ui));
100
+ return lead + (on ? terracottaBold(s, name) + stone(s, desc) : name + dim(s, desc));
101
+ });
102
+ }
103
+
104
+ // ---------------------------------------------------------------- the frame
105
+
106
+
107
+
108
+
109
+
110
+ const ESC = "\x1b";
111
+
112
+ // Draws below the input line and routes keys. One per session, TTY only.
113
+ export class PromptFrame {
114
+ input ;
115
+ rl ;
116
+ shown = 0; // rows drawn below the input line
117
+ active = false; // the main prompt is being answered (not a y/N question, not a turn)
118
+ selected = 0;
119
+ dismissed = false;
120
+ placeholder = false;
121
+ lastLine = "";
122
+ started = false;
123
+ pasting = false; // between the terminal's paste-start and paste-end marks
124
+ ctrlC = false; // Ctrl+C was pressed (typed, not pasted)
125
+
126
+ // True once the person pressed Ctrl+C. An end of input is not an interrupt.
127
+ get interrupted() {
128
+ return this.ctrlC;
129
+ }
130
+ onKey = (s , key ) => this.key(s, key);
131
+
132
+ stdin ;
133
+ out ;
134
+ ui ;
135
+ below ; // the status lines
136
+ promptWidth ;
137
+
138
+ paste ; // bracketed paste is switched on (stdout is the terminal)
139
+ pasteOk = false; // a prompt that takes typed text is open: pasted text is typed in
140
+
141
+ // `opts.paste` false when stdout is not the terminal (a piped one-shot): the escape that switches
142
+ // bracketed paste on would land in the output instead.
143
+ constructor(stdin , out , ui , below , promptWidth , opts = {}) {
144
+ this.paste = opts.paste ?? true;
145
+ this.stdin = stdin;
146
+ this.out = out;
147
+ this.ui = ui;
148
+ this.below = below;
149
+ this.promptWidth = promptWidth;
150
+ const pass = new PassThrough() ;
151
+ pass.isTTY = true;
152
+ pass.setRawMode = () => {};
153
+ this.input = pass;
154
+ }
155
+
156
+ start(rl ) {
157
+ this.rl = rl;
158
+ emitKeypressEvents(this.stdin);
159
+ this.stdin.setRawMode?.(true);
160
+ this.stdin.on("keypress", this.onKey);
161
+ this.stdin.resume();
162
+ // Bracketed paste: the terminal marks pasted text, so a pasted newline is not an Enter.
163
+ if (this.paste) this.out.write(`${ESC}[?2004h`);
164
+ this.started = true;
165
+ rl.on("close", () => this.stop());
166
+ }
167
+
168
+ // Gives the terminal back: raw mode and bracketed paste off. Safe to call any number of times.
169
+ stop() {
170
+ if (!this.started) return;
171
+ this.started = false;
172
+ this.stdin.off("keypress", this.onKey);
173
+ if (this.paste) this.out.write(`${ESC}[?2004l`);
174
+ this.stdin.setRawMode?.(false);
175
+ this.stdin.pause();
176
+ this.input.end();
177
+ }
178
+
179
+ line() {
180
+ return (this.rl )?.line ?? "";
181
+ }
182
+
183
+ menuItems() {
184
+ const l = this.line();
185
+ if (!this.active || this.dismissed || !l.startsWith("/") || /\s/.test(l)) return [];
186
+ return matchCommands(l);
187
+ }
188
+
189
+ // The rule above the prompt; then the prompt itself is asked by the caller.
190
+ async ask(promptText ) {
191
+ const rl = this.rl ;
192
+ this.out.write(rule(this.ui) + "\n");
193
+ this.active = true;
194
+ this.pasteOk = true;
195
+ this.selected = 0;
196
+ this.dismissed = false;
197
+ this.lastLine = "";
198
+ const answer = rl.question(promptText);
199
+ this.draw();
200
+ try {
201
+ return await answer;
202
+ } finally {
203
+ this.active = false;
204
+ this.pasteOk = false;
205
+ }
206
+ }
207
+
208
+ // A plain prompt with no frame or menu (an ASCII terminal): pasted text is typed in, nothing drawn.
209
+ async askPlain(promptText ) {
210
+ this.pasteOk = true;
211
+ try {
212
+ return await (this.rl ).question(promptText);
213
+ } finally {
214
+ this.pasteOk = false;
215
+ }
216
+ }
217
+
218
+ // Erases what is drawn below the input line (before Enter, so the transcript stays clean).
219
+ clear() {
220
+ if (this.shown === 0) return;
221
+ let s = "";
222
+ for (let i = 0; i < this.shown; i++) s += `\r\n${ESC}[2K`;
223
+ s += `${ESC}[${this.shown}A${ESC}[${this.cursorCol()}G`;
224
+ this.out.write(s);
225
+ this.shown = 0;
226
+ }
227
+
228
+ cursorCol() {
229
+ const pos = (this.rl )?.getCursorPos?.();
230
+ return (pos?.cols ?? this.promptWidth + textWidth(this.line())) + 1;
231
+ }
232
+
233
+ cols() {
234
+ return this.ui.columns;
235
+ }
236
+
237
+ // What goes under the input: the rule, then the menu or the status lines.
238
+ belowLines() {
239
+ const items = this.menuItems();
240
+ if (this.selected >= items.length) this.selected = 0;
241
+ const body = items.length ? renderMenu(items, this.selected, this.ui) : this.below();
242
+ return [rule(this.ui), ...body];
243
+ }
244
+
245
+ draw() {
246
+ if (!this.active) return;
247
+ const l = this.line();
248
+ // A line that wraps would put the frame in the wrong place: draw nothing until it is short.
249
+ if (this.promptWidth + textWidth(l) >= this.cols() - 1) { this.clear(); return; }
250
+ const lines = this.belowLines();
251
+ const n = Math.max(lines.length, this.shown);
252
+ let s = "";
253
+ for (let i = 0; i < n; i++) s += `\r\n${ESC}[2K` + (lines[i] ?? "");
254
+ const col = this.cursorCol();
255
+ s += `${ESC}[${n}A${ESC}[${col}G`;
256
+ if (l === "") {
257
+ s += dim(styleOf(this.ui), fit(PLACEHOLDER, this.cols() - 1 - col, this.ui)) + `${ESC}[${col}G`;
258
+ this.placeholder = true;
259
+ } else if (this.placeholder) {
260
+ s = `${ESC}[K` + s;
261
+ this.placeholder = false;
262
+ }
263
+ this.out.write(s);
264
+ this.shown = lines.length;
265
+ }
266
+
267
+ forward(seq ) {
268
+ this.input.write(seq);
269
+ }
270
+
271
+ replaceLine(text ) {
272
+ // ctrl+e (end of line), ctrl+u (delete to the start), then the new text.
273
+ this.forward("\x05\x15" + text);
274
+ }
275
+
276
+ key(s , key ) {
277
+ const seq = key?.sequence ?? s ?? "";
278
+ if (key?.name === "paste-start") { this.pasting = true; return; }
279
+ if (key?.name === "paste-end") { this.pasting = false; this.after(); return; }
280
+ if (!this.pasting && seq === "\x03") this.ctrlC = true;
281
+ if (this.pasting) {
282
+ // Pasted text is never a key press. While a y/N question (or a turn) holds the input it is
283
+ // dropped, so a pasted "y" and newline cannot approve anything. At the prompt it is typed
284
+ // as text, with each newline as a space so a paste never sends the line.
285
+ if (!this.pasteOk) return;
286
+ const name = key?.name;
287
+ this.forward(name === "return" || name === "enter" || seq === "\r" || seq === "\n" ? " " : seq);
288
+ return;
289
+ }
290
+ if (!this.active) { this.forward(seq); return; }
291
+ const items = this.menuItems();
292
+ const name = key?.name;
293
+ // A lone Esc closes the menu (node reports it with meta set on some versions).
294
+ if (items.length && name === "escape") { this.dismissed = true; this.draw(); return; }
295
+ if (items.length && !key?.ctrl && !key?.meta) {
296
+ if (name === "up" || name === "down") {
297
+ this.selected = (this.selected + (name === "up" ? -1 : 1) + items.length) % items.length;
298
+ this.draw();
299
+ return;
300
+ }
301
+ const pick = items[this.selected] ;
302
+ if (name === "tab") { this.replaceLine(pick.args ? pick.name + " " : pick.name); this.after(); return; }
303
+ if (name === "return" || name === "enter") {
304
+ if (pick.args && this.line().toLowerCase() !== pick.name) { this.replaceLine(pick.name + " "); this.after(); return; }
305
+ this.clear();
306
+ this.replaceLine(pick.name);
307
+ this.forward("\r");
308
+ return;
309
+ }
310
+ }
311
+ if (name === "return" || name === "enter") { this.clear(); this.forward(seq); return; }
312
+ if (name === "escape" && this.line() === "") return; // nothing to dismiss; a lone Esc is ignored
313
+ this.forward(seq);
314
+ this.after();
315
+ }
316
+
317
+ // Readline handles the forwarded keys on the next tick; redraw after it has.
318
+ after() {
319
+ setImmediate(() => {
320
+ const l = this.line();
321
+ if (l !== this.lastLine) { this.dismissed = false; this.selected = 0; }
322
+ this.lastLine = l;
323
+ this.draw();
324
+ });
325
+ }
326
+
327
+ redraw() {
328
+ this.draw();
329
+ }
330
+ }
package/dist/statusbar.js CHANGED
@@ -26,22 +26,29 @@ export function formatRate(tps ) {
26
26
  }
27
27
 
28
28
  // Fits `width` columns by dropping segments from the least important end; the tier always stays.
29
+ // Unknown values (routing "?", no TTFT or rate yet) are left out, never shown as "?" or "-".
29
30
  export function formatStatus(p , width ) {
30
31
  const tier = p.tier.display;
31
32
  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 route = p.routing && p.routing !== "?" ? p.routing : "";
34
+ const ttft = formatTtft(p.ttftMs);
35
+ const t = ttft === "-" ? "" : `TTFT ${ttft}`;
36
+ const r = formatRate(p.tokPerSec);
37
+ const rate = r === "-" ? "" : r;
38
+ const toks = `${groupDigits(p.tokens)} tokens`;
39
+ const short = `${groupDigits(p.tokens)} tok`;
33
40
  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],
41
+ [tier, effort, route, t, rate, toks],
42
+ [tier, route, t, rate, toks],
43
+ [tier, route, rate, short],
44
+ [tier, route, short],
45
+ [tier, route],
39
46
  ];
40
47
  const join = (parts ) => parts.filter((x) => x !== "").join(" | ");
41
- if (width === undefined) return join(full);
48
+ if (width === undefined) return join(variants[0] );
42
49
  for (const v of variants) {
43
- const t = join(v);
44
- if (t.length <= width) return t;
50
+ const s = join(v);
51
+ if (s.length <= width) return s;
45
52
  }
46
53
  const last = join(variants[variants.length - 1] );
47
54
  return width > 1 ? last.slice(0, width - 1) + "…" : last.slice(0, width);
package/dist/theme.js CHANGED
@@ -1,7 +1,8 @@
1
1
  // The Synqa palette and every colour escape the CLI emits. This file is the only place that builds
2
2
  // an escape sequence for colour; text from the model or from tools never reaches it as a code.
3
- // 24-bit colour when the terminal says it has it (COLORTERM=truecolor|24bit), otherwise the nearest
4
- // xterm-256 index. NO_COLOR, a non-TTY or TERM=dumb turn all of it off.
3
+ // 24-bit colour only when the terminal is known to draw it (COLORTERM=truecolor|24bit, or a
4
+ // TERM_PROGRAM known for it); macOS Terminal.app and anything unknown get xterm-256 colours, which
5
+ // every modern terminal draws. NO_COLOR, a non-TTY or TERM=dumb turn all of it off.
5
6
 
6
7
 
7
8
 
@@ -12,8 +13,11 @@ export const SAGE = [0x4a, 0x7c, 0x59]; // #4a7c59 sage: balance highlights
12
13
 
13
14
 
14
15
 
15
- // depth is optional so a bare { color: true } keeps meaning 24-bit.
16
-
16
+ // depth is optional; absent means 256 colours, the safe choice (Terminal.app has no 24-bit colour).
17
+ // bg is the terminal background when the environment says so (COLORFGBG); bone text is used only
18
+ // on a known dark background, plain bold otherwise, so names read on light and dark themes.
19
+
20
+
17
21
 
18
22
  export function colorEnabled(env , isTTY ) {
19
23
  if (env.NO_COLOR !== undefined && env.NO_COLOR !== "") return false;
@@ -21,9 +25,30 @@ export function colorEnabled(env , isTTY
21
25
  return isTTY;
22
26
  }
23
27
 
28
+ // Terminals that draw 24-bit colour, by TERM_PROGRAM (compared lower case).
29
+ const TRUECOLOR_PROGRAMS = new Set(["iterm.app", "wezterm", "vscode", "ghostty", "hyper", "tabby", "warpterminal", "rio", "kitty", "alacritty", "contour", "zed"]);
30
+
24
31
  export function colorDepth(env ) {
32
+ const program = (env.TERM_PROGRAM ?? "").toLowerCase();
33
+ // Terminal.app before macOS 26 misdraws 24-bit escapes, and a COLORTERM copied from another
34
+ // terminal's profile (or passed through ssh or tmux) must not switch them on there.
35
+ if (program === "apple_terminal") return 8;
25
36
  const c = (env.COLORTERM ?? "").toLowerCase();
26
- return c === "truecolor" || c === "24bit" ? 24 : 8;
37
+ if (c === "truecolor" || c === "24bit") return 24;
38
+ if (TRUECOLOR_PROGRAMS.has(program)) return 24;
39
+ const term = (env.TERM ?? "").toLowerCase();
40
+ if (/(^|-)(kitty|alacritty|direct|truecolor|ghostty|wezterm)($|-)/.test(term) || term.endsWith("-direct")) return 24;
41
+ return 8;
42
+ }
43
+
44
+ // COLORFGBG ("15;0", or "15;default;0") names the background colour index when a terminal sets
45
+ // it (rxvt, Konsole, iTerm2 with the option on). 0-6 and 8 are dark; 7 and 9-15 are light.
46
+ export function detectBackground(env ) {
47
+ const raw = env.COLORFGBG;
48
+ if (!raw) return undefined;
49
+ const bg = Number(raw.split(";").pop());
50
+ if (!Number.isInteger(bg) || bg < 0 || bg > 15) return undefined;
51
+ return bg === 7 || bg >= 9 ? "light" : "dark";
27
52
  }
28
53
 
29
54
  const CUBE = [0, 95, 135, 175, 215, 255] ;
@@ -53,8 +78,17 @@ export function rgbTo256(rgb ) {
53
78
  const RESET = "\x1b[0m";
54
79
 
55
80
  // The SGR parameters that select `rgb` as the foreground.
81
+ // Hand-picked 256-colour stand-ins where the nearest match loses the brand: the nearest index for
82
+ // terracotta (167) is a pink salmon; 166 (#d75f00) keeps the burnt orange and reads on light and
83
+ // dark backgrounds.
84
+ const TUNED_256 = new Map ([[TERRACOTTA.join(","), 166]]);
85
+
86
+ export function to256(rgb ) {
87
+ return TUNED_256.get(rgb.join(",")) ?? rgbTo256(rgb);
88
+ }
89
+
56
90
  export function fgParams(depth , rgb ) {
57
- return depth === 8 ? `38;5;${rgbTo256(rgb)}` : `38;2;${rgb[0]};${rgb[1]};${rgb[2]}`;
91
+ return depth === 24 ? `38;2;${rgb[0]};${rgb[1]};${rgb[2]}` : `38;5;${to256(rgb)}`;
58
92
  }
59
93
 
60
94
  function paint(s , params , text ) {
@@ -65,8 +99,14 @@ export function terracotta(s , text ) {
65
99
  return paint(s, fgParams(s.depth, TERRACOTTA), text);
66
100
  }
67
101
 
102
+ // Bone on a known dark background; plain bold (the terminal's own text colour) otherwise, because
103
+ // bone is nearly invisible on a light background.
68
104
  export function boneBold(s , text ) {
69
- return paint(s, `1;${fgParams(s.depth, BONE)}`, text);
105
+ return paint(s, s.bg === "dark" ? `1;${fgParams(s.depth, BONE)}` : "1", text);
106
+ }
107
+
108
+ export function terracottaBold(s , text ) {
109
+ return paint(s, `1;${fgParams(s.depth, TERRACOTTA)}`, text);
70
110
  }
71
111
 
72
112
  export function stone(s , text ) {