@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/README.md +83 -53
- package/dist/api.js +44 -3
- package/dist/commands.js +6 -13
- package/dist/main.js +146 -40
- package/dist/paths.js +150 -38
- package/dist/prompt.js +330 -0
- package/dist/statusbar.js +16 -9
- package/dist/theme.js +47 -7
- package/dist/tools.js +125 -44
- package/dist/ui.js +129 -69
- package/package.json +1 -1
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
|
|
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
|
-
|
|
35
|
-
[tier,
|
|
36
|
-
[tier,
|
|
37
|
-
[tier,
|
|
38
|
-
[tier,
|
|
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(
|
|
48
|
+
if (width === undefined) return join(variants[0] );
|
|
42
49
|
for (const v of variants) {
|
|
43
|
-
const
|
|
44
|
-
if (
|
|
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
|
|
4
|
-
//
|
|
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
|
|
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
|
-
|
|
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 ===
|
|
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)}
|
|
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 ) {
|