glm-coding-router 1.1.2 → 2.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -21
- package/README.md +542 -426
- package/dist/bin/glm-review.js +28 -3
- package/dist/bin/glm-worker.js +30 -4
- package/dist/budget/estimator.js +218 -0
- package/dist/budget/manager.js +223 -0
- package/dist/cli.js +46 -3
- package/dist/commands/dashboard.js +348 -0
- package/dist/commands/doctor-auth.js +107 -0
- package/dist/commands/doctor-command.js +171 -41
- package/dist/commands/landing.js +47 -0
- package/dist/commands/runs.js +568 -0
- package/dist/commands/status.js +28 -15
- package/dist/commands/usage.js +34 -58
- package/dist/commands/watch.js +289 -0
- package/dist/core/config.js +61 -0
- package/dist/core/errors.js +24 -0
- package/dist/core/key-inspector.js +45 -0
- package/dist/core/paths.js +32 -0
- package/dist/core/process.js +83 -0
- package/dist/core/prompt.js +18 -5
- package/dist/core/routing-flags.js +59 -0
- package/dist/core/user-env.js +17 -7
- package/dist/core/zai-quota.js +148 -0
- package/dist/events/bus.js +64 -0
- package/dist/events/claude-adapter.js +416 -0
- package/dist/events/types.js +9 -0
- package/dist/handoff/bundle.js +203 -0
- package/dist/handoff/parent-handoff.js +48 -0
- package/dist/mcp/server.js +45 -1
- package/dist/routing/glm-routing.js +131 -0
- package/dist/runs/checkpoint.js +204 -0
- package/dist/runs/drain.js +165 -0
- package/dist/runs/heartbeat.js +45 -0
- package/dist/runs/registry.js +350 -0
- package/dist/runs/store.js +186 -0
- package/dist/runs/ulid.js +112 -0
- package/dist/runs/worker-run.js +672 -0
- package/dist/templates/agents-block.js +53 -44
- package/dist/templates/claude-block.js +56 -47
- package/dist/templates/glm-delegation-skill.js +76 -65
- package/dist/tui/command-ui.js +158 -0
- package/dist/tui/progress.js +338 -0
- package/dist/tui/render.js +144 -0
- package/package.json +1 -1
|
@@ -0,0 +1,338 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { logger } from "../core/logging.js";
|
|
3
|
+
import { ansi, createWriter, paint, truncate } from "./render.js";
|
|
4
|
+
/**
|
|
5
|
+
* Decide the mode from flags / env / config / TTY-ness. The order is
|
|
6
|
+
* contractual: an orchestrator's `--quiet` or a CI environment must be able to
|
|
7
|
+
* force silence even when config asks for rich, while an explicit env override
|
|
8
|
+
* still beats config so `GLM_ROUTER_PROGRESS=nested glm-worker …` works on a
|
|
9
|
+
* machine whose config says otherwise. `auto` (the config default) picks rich
|
|
10
|
+
* for humans at a terminal and nested for everything piped.
|
|
11
|
+
*/
|
|
12
|
+
export function resolveProgressMode(input) {
|
|
13
|
+
const env = input.env ?? {};
|
|
14
|
+
if (input.flagOff || input.quiet || env.CI === "true" || env.GLM_ROUTER_PROGRESS === "off") {
|
|
15
|
+
return "off";
|
|
16
|
+
}
|
|
17
|
+
const override = env.GLM_ROUTER_PROGRESS;
|
|
18
|
+
if (override === "rich" || override === "nested") {
|
|
19
|
+
return override;
|
|
20
|
+
}
|
|
21
|
+
if (env.GLM_ROUTER_NESTED === "1") {
|
|
22
|
+
return "nested";
|
|
23
|
+
}
|
|
24
|
+
const configured = input.configMode;
|
|
25
|
+
if (configured === "rich" || configured === "nested" || configured === "off") {
|
|
26
|
+
return configured;
|
|
27
|
+
}
|
|
28
|
+
return input.isTTY ? "rich" : "nested";
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Subscribe a progress renderer to the bus. The stream is a parameter, never
|
|
32
|
+
* `process.stdout`, because that channel belongs to the worker's final answer
|
|
33
|
+
* (contracts C1/C2) — production callers pass `process.stderr`, tests pass a
|
|
34
|
+
* memory stream. `detach()` unsubscribes and restores the cursor.
|
|
35
|
+
*/
|
|
36
|
+
export function attachProgress(bus, opts) {
|
|
37
|
+
if (opts.mode === "off") {
|
|
38
|
+
// Rendering nothing still needs a uniform handle so callers can detach blindly.
|
|
39
|
+
return { detach() { } };
|
|
40
|
+
}
|
|
41
|
+
// Nested lines are parsed by orchestrators, so that mode defaults to plain
|
|
42
|
+
// text unless the caller explicitly opts into color; rich keeps the writer's
|
|
43
|
+
// own TTY/NO_COLOR default.
|
|
44
|
+
const color = opts.color ?? (opts.mode === "nested" ? false : undefined);
|
|
45
|
+
const writer = createWriter(opts.stream, { color });
|
|
46
|
+
const renderer = opts.mode === "nested"
|
|
47
|
+
? new NestedRenderer(writer, bus.runId)
|
|
48
|
+
: new RichRenderer(writer, opts.project);
|
|
49
|
+
const unsubscribe = bus.subscribe((event) => {
|
|
50
|
+
// The bus already catches subscriber exceptions; the renderer guards itself
|
|
51
|
+
// too so a half-broken state machine degrades instead of compounding.
|
|
52
|
+
try {
|
|
53
|
+
renderer.handle(event);
|
|
54
|
+
}
|
|
55
|
+
catch (error) {
|
|
56
|
+
logger.debug(`progress renderer threw on ${event.type}: ${errorMessage(error)}`);
|
|
57
|
+
}
|
|
58
|
+
});
|
|
59
|
+
return {
|
|
60
|
+
detach() {
|
|
61
|
+
unsubscribe();
|
|
62
|
+
renderer.close();
|
|
63
|
+
},
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
/** Last 4 characters of the run id, uppercased — short enough to say aloud. */
|
|
67
|
+
function shortRunId(runId) {
|
|
68
|
+
return runId.slice(-4).toUpperCase();
|
|
69
|
+
}
|
|
70
|
+
/** Duration in seconds with one decimal, the resolution that is readable and honest. */
|
|
71
|
+
function formatSeconds(durationMs) {
|
|
72
|
+
return `${(durationMs / 1000).toFixed(1)}s`;
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* The one-word verb for a nested turn line. Validators are recognized via the
|
|
76
|
+
* `ValidationStarted` that the adapter emits immediately before the matching
|
|
77
|
+
* `ToolStarted`, so no tool-input inspection is needed here (C3).
|
|
78
|
+
*/
|
|
79
|
+
function nestedVerb(tool, isValidation) {
|
|
80
|
+
if (tool === "Read" || tool === "Grep" || tool === "Glob") {
|
|
81
|
+
return "exploring";
|
|
82
|
+
}
|
|
83
|
+
if (tool === "Edit" || tool === "Write" || tool === "MultiEdit") {
|
|
84
|
+
return "editing";
|
|
85
|
+
}
|
|
86
|
+
if (tool === "Bash") {
|
|
87
|
+
return isValidation ? "running tests" : "running";
|
|
88
|
+
}
|
|
89
|
+
return `using ${tool}`;
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Nested mode: one stable `[GLM] …` line per significant event and no cursor
|
|
93
|
+
* control, so an orchestrator can `readline` stderr without speaking ANSI.
|
|
94
|
+
* These lines are a machine interface — change them only for cause.
|
|
95
|
+
*/
|
|
96
|
+
class NestedRenderer {
|
|
97
|
+
writer;
|
|
98
|
+
short;
|
|
99
|
+
/** Turn whose first tool has already been announced; 0 = none. */
|
|
100
|
+
announcedTurn = 0;
|
|
101
|
+
/** Turn of a `ValidationStarted` awaiting its `ToolStarted`. */
|
|
102
|
+
validationTurn = null;
|
|
103
|
+
finished = false;
|
|
104
|
+
constructor(writer, runId) {
|
|
105
|
+
this.writer = writer;
|
|
106
|
+
this.short = shortRunId(runId);
|
|
107
|
+
}
|
|
108
|
+
handle(event) {
|
|
109
|
+
if (this.finished) {
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
switch (event.type) {
|
|
113
|
+
case "RunStarted":
|
|
114
|
+
this.writer.line(`[GLM] ${paint(this.writer, "cyan", "●")} #${this.short} started • ${event.model}`);
|
|
115
|
+
break;
|
|
116
|
+
case "ValidationStarted":
|
|
117
|
+
// Consumed by the ToolStarted that follows it in the same turn.
|
|
118
|
+
this.validationTurn = event.turn;
|
|
119
|
+
break;
|
|
120
|
+
case "ToolStarted": {
|
|
121
|
+
if (event.turn === this.announcedTurn) {
|
|
122
|
+
break; // later tools of a turn print nothing
|
|
123
|
+
}
|
|
124
|
+
this.announcedTurn = event.turn;
|
|
125
|
+
const isValidation = this.validationTurn === event.turn;
|
|
126
|
+
if (isValidation) {
|
|
127
|
+
this.validationTurn = null;
|
|
128
|
+
}
|
|
129
|
+
this.writer.line(`[GLM] turn ${event.turn} • ${nestedVerb(event.tool, isValidation)} ${event.summary}`);
|
|
130
|
+
break;
|
|
131
|
+
}
|
|
132
|
+
case "ValidationCompleted":
|
|
133
|
+
this.writer.line(`[GLM] ${paint(this.writer, event.ok ? "green" : "red", event.ok ? "✓" : "✗")} tests ` +
|
|
134
|
+
`${event.ok ? "passed" : "failed"}`);
|
|
135
|
+
break;
|
|
136
|
+
case "ToolDenied":
|
|
137
|
+
this.writer.line(`[GLM] ${paint(this.writer, "yellow", "⚠")} denied: ${event.tool} — ${event.reason}`);
|
|
138
|
+
break;
|
|
139
|
+
case "ApiRetry":
|
|
140
|
+
this.writer.line(`[GLM] ${paint(this.writer, "yellow", "⚠")} retry: ${event.reason}`);
|
|
141
|
+
break;
|
|
142
|
+
case "RunCompleted":
|
|
143
|
+
this.writer.line(`[GLM] ${paint(this.writer, "green", "✓")} #${this.short} • ` +
|
|
144
|
+
`${formatSeconds(event.durationMs)} • ${event.turns} turns • ${event.filesChanged} files`);
|
|
145
|
+
this.finished = true;
|
|
146
|
+
break;
|
|
147
|
+
case "RunFailed":
|
|
148
|
+
this.writer.line(`[GLM] ${paint(this.writer, "red", "✗")} #${this.short} • ${event.reason}`);
|
|
149
|
+
this.finished = true;
|
|
150
|
+
break;
|
|
151
|
+
case "RunCancelled":
|
|
152
|
+
this.writer.line(`[GLM] ${paint(this.writer, "red", "✗")} #${this.short} • cancelled`);
|
|
153
|
+
this.finished = true;
|
|
154
|
+
break;
|
|
155
|
+
default:
|
|
156
|
+
break; // TurnStarted, Heartbeat, AgentInitialized, … — deliberately silent
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
close() {
|
|
160
|
+
// No cursor was ever hidden in nested mode.
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
/** Width of the rich header's label column ("Project" + 2 spaces, doc §7). */
|
|
164
|
+
const LABEL_WIDTH = 9;
|
|
165
|
+
/** Width of the rich tool column ("Write" + separator, doc §7). */
|
|
166
|
+
const TOOL_WIDTH = 5;
|
|
167
|
+
/** Width of the footer label column ("Duration" + 3 spaces, doc §7). */
|
|
168
|
+
const FOOTER_LABEL_WIDTH = 11;
|
|
169
|
+
const TITLE_BY_KIND = {
|
|
170
|
+
worker: "GLM Worker",
|
|
171
|
+
review: "GLM Review",
|
|
172
|
+
delegate: "GLM Delegate",
|
|
173
|
+
};
|
|
174
|
+
/**
|
|
175
|
+
* Rich mode: the doc §7 box header plus a per-turn tool tree. On a TTY the
|
|
176
|
+
* current turn block is redrawn in place (cursor-up + erase per line); on a
|
|
177
|
+
* non-TTY stream it appends, one entry behind, so the `└─` connector of the
|
|
178
|
+
* last-known entry is never written wrong — piped output must stay readable
|
|
179
|
+
* with zero escape codes, not a pile of them.
|
|
180
|
+
*/
|
|
181
|
+
class RichRenderer {
|
|
182
|
+
writer;
|
|
183
|
+
projectName;
|
|
184
|
+
short = "";
|
|
185
|
+
currentTurn = 0;
|
|
186
|
+
/** Plain content lines of the current turn block, connectors excluded. */
|
|
187
|
+
entries = [];
|
|
188
|
+
/** TTY: lines of the current block currently on screen (the redraw budget). */
|
|
189
|
+
drawn = 0;
|
|
190
|
+
/** non-TTY: entries of the current block already written for good. */
|
|
191
|
+
flushed = 0;
|
|
192
|
+
finished = false;
|
|
193
|
+
cursorHidden = false;
|
|
194
|
+
constructor(writer, project) {
|
|
195
|
+
this.writer = writer;
|
|
196
|
+
this.projectName = project;
|
|
197
|
+
}
|
|
198
|
+
handle(event) {
|
|
199
|
+
if (this.finished) {
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
switch (event.type) {
|
|
203
|
+
case "RunStarted":
|
|
204
|
+
this.writeHeader(event);
|
|
205
|
+
break;
|
|
206
|
+
case "TurnStarted":
|
|
207
|
+
this.beginTurn(event.turn);
|
|
208
|
+
break;
|
|
209
|
+
case "ToolStarted":
|
|
210
|
+
// Defensive: a replayed stream without TurnStarted still groups by turn.
|
|
211
|
+
if (event.turn !== this.currentTurn) {
|
|
212
|
+
this.beginTurn(event.turn);
|
|
213
|
+
}
|
|
214
|
+
this.addEntry(`${event.tool.padEnd(TOOL_WIDTH)} ${this.summaryFit(event.summary)}`);
|
|
215
|
+
break;
|
|
216
|
+
case "ToolDenied":
|
|
217
|
+
this.addEntry(`${paint(this.writer, "yellow", "⚠")} denied: ${event.tool} — ${this.summaryFit(event.reason)}`);
|
|
218
|
+
break;
|
|
219
|
+
case "ApiRetry":
|
|
220
|
+
this.addEntry(`${paint(this.writer, "yellow", "⚠")} retry: ${this.summaryFit(event.reason)}`);
|
|
221
|
+
break;
|
|
222
|
+
case "ValidationCompleted":
|
|
223
|
+
this.addEntry(`${paint(this.writer, event.ok ? "green" : "red", event.ok ? "✓" : "✗")} tests ` +
|
|
224
|
+
`${event.ok ? "passed" : "failed"}`);
|
|
225
|
+
break;
|
|
226
|
+
case "RunCompleted":
|
|
227
|
+
this.writeSuccessFooter(event);
|
|
228
|
+
break;
|
|
229
|
+
case "RunFailed":
|
|
230
|
+
this.writeFailureClose(`✗ Failed — ${event.reason}`);
|
|
231
|
+
break;
|
|
232
|
+
case "RunCancelled":
|
|
233
|
+
this.writeFailureClose("✗ Cancelled");
|
|
234
|
+
break;
|
|
235
|
+
default:
|
|
236
|
+
break; // Heartbeat feeds the registry, not this tree
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
close() {
|
|
240
|
+
this.restoreCursor();
|
|
241
|
+
}
|
|
242
|
+
beginTurn(turn) {
|
|
243
|
+
this.finalizeBlock();
|
|
244
|
+
this.currentTurn = turn;
|
|
245
|
+
this.entries.length = 0;
|
|
246
|
+
this.drawn = 0;
|
|
247
|
+
this.flushed = 0;
|
|
248
|
+
this.writer.line(`${paint(this.writer, "cyan", "◉")} Turn ${turn}`);
|
|
249
|
+
}
|
|
250
|
+
addEntry(content) {
|
|
251
|
+
this.entries.push(content);
|
|
252
|
+
this.refresh();
|
|
253
|
+
}
|
|
254
|
+
/** Bring the on-screen representation of the current block up to date. */
|
|
255
|
+
refresh() {
|
|
256
|
+
if (this.writer.isTTY) {
|
|
257
|
+
for (let i = 0; i < this.drawn; i++) {
|
|
258
|
+
this.writer.write(ansi.cursorUp(1) + ansi.clearLine);
|
|
259
|
+
}
|
|
260
|
+
this.drawn = 0;
|
|
261
|
+
for (const line of this.blockLines()) {
|
|
262
|
+
this.writer.write(`${line}\n`);
|
|
263
|
+
this.drawn++;
|
|
264
|
+
}
|
|
265
|
+
return;
|
|
266
|
+
}
|
|
267
|
+
// Append-only: hold the newest entry back until it is provably not the
|
|
268
|
+
// last one, because written bytes cannot be taken back.
|
|
269
|
+
while (this.entries.length - this.flushed >= 2) {
|
|
270
|
+
this.writer.line(` ├─ ${this.entries[this.flushed]}`);
|
|
271
|
+
this.flushed++;
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
/** Freeze the current block: previous turns are history and never redrawn. */
|
|
275
|
+
finalizeBlock() {
|
|
276
|
+
if (!this.writer.isTTY && this.entries.length > this.flushed) {
|
|
277
|
+
this.writer.line(` └─ ${this.entries[this.entries.length - 1]}`);
|
|
278
|
+
this.flushed = this.entries.length;
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
blockLines() {
|
|
282
|
+
return this.entries.map((content, index) => index === this.entries.length - 1 ? ` └─ ${content}` : ` ├─ ${content}`);
|
|
283
|
+
}
|
|
284
|
+
writeHeader(event) {
|
|
285
|
+
this.short = shortRunId(event.runId);
|
|
286
|
+
if (this.writer.isTTY) {
|
|
287
|
+
this.writer.write(ansi.hideCursor);
|
|
288
|
+
this.cursorHidden = true;
|
|
289
|
+
}
|
|
290
|
+
const project = this.projectName ?? (path.basename(event.cwd) || event.cwd);
|
|
291
|
+
const rows = [
|
|
292
|
+
`${"Run".padEnd(LABEL_WIDTH)}#${this.short}`,
|
|
293
|
+
`${"Model".padEnd(LABEL_WIDTH)}${event.model}`,
|
|
294
|
+
`${"Project".padEnd(LABEL_WIDTH)}${project}`,
|
|
295
|
+
];
|
|
296
|
+
const prefix = `─ ${TITLE_BY_KIND[event.kind]} `;
|
|
297
|
+
const inner = Math.max(...rows.map((row) => row.length), prefix.length);
|
|
298
|
+
this.writer.line(`╭${prefix}${"─".repeat(inner + 2 - prefix.length)}╮`);
|
|
299
|
+
for (const row of rows) {
|
|
300
|
+
this.writer.line(`│ ${row.padEnd(inner)} │`);
|
|
301
|
+
}
|
|
302
|
+
this.writer.line(`╰${"─".repeat(inner + 2)}╯`);
|
|
303
|
+
this.writer.line();
|
|
304
|
+
}
|
|
305
|
+
writeSuccessFooter(event) {
|
|
306
|
+
this.finalizeBlock();
|
|
307
|
+
this.writer.line();
|
|
308
|
+
this.writer.line(`${paint(this.writer, "green", "✓")} Completed`);
|
|
309
|
+
this.writer.line();
|
|
310
|
+
this.writer.line(`${"Duration".padEnd(FOOTER_LABEL_WIDTH)}${formatSeconds(event.durationMs)}`);
|
|
311
|
+
this.writer.line(`${"Turns".padEnd(FOOTER_LABEL_WIDTH)}${event.turns}`);
|
|
312
|
+
this.writer.line(`${"Files".padEnd(FOOTER_LABEL_WIDTH)}${event.filesChanged}`);
|
|
313
|
+
this.finish();
|
|
314
|
+
}
|
|
315
|
+
writeFailureClose(text) {
|
|
316
|
+
this.finalizeBlock();
|
|
317
|
+
this.writer.line();
|
|
318
|
+
this.writer.line(paint(this.writer, "red", text));
|
|
319
|
+
this.finish();
|
|
320
|
+
}
|
|
321
|
+
finish() {
|
|
322
|
+
this.finished = true;
|
|
323
|
+
this.restoreCursor();
|
|
324
|
+
}
|
|
325
|
+
restoreCursor() {
|
|
326
|
+
if (this.cursorHidden) {
|
|
327
|
+
this.writer.write(ansi.showCursor);
|
|
328
|
+
this.cursorHidden = false;
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
/** Cap a summary to the terminal width so one long path cannot wrap the tree. */
|
|
332
|
+
summaryFit(text) {
|
|
333
|
+
return truncate(text, Math.max(20, this.writer.columns - (TOOL_WIDTH + 7)));
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
function errorMessage(error) {
|
|
337
|
+
return error instanceof Error ? error.message : String(error);
|
|
338
|
+
}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Writer abstraction over an injectable output stream (specs/v2-architecture.md,
|
|
3
|
+
* Phase C, decision D1: dependency-free TUI).
|
|
4
|
+
*
|
|
5
|
+
* This is the ONLY file in the codebase that contains ANSI escape sequences, so
|
|
6
|
+
* swapping the rendering backend later (e.g. Ink) is a contained change. It also
|
|
7
|
+
* keeps contract C1 testable: a caller injects the stream it wants to assert on,
|
|
8
|
+
* and this module never touches `process.stdout` or `process.stderr` itself.
|
|
9
|
+
*/
|
|
10
|
+
/**
|
|
11
|
+
* The escape sequences the TUI needs. Kept as data, not methods on Writer, so
|
|
12
|
+
* a renderer can compose them (cursorUp + clearLine per redrawn line) in one
|
|
13
|
+
* write call where the platform batches better.
|
|
14
|
+
*/
|
|
15
|
+
export const ansi = {
|
|
16
|
+
/** Move the cursor up `n` lines; empty string for n <= 0 (a no-op move). */
|
|
17
|
+
cursorUp(n) {
|
|
18
|
+
return n > 0 ? `\u001b[${n}A` : "";
|
|
19
|
+
},
|
|
20
|
+
/** Erase the whole current line (cursor column is untouched). */
|
|
21
|
+
clearLine: "\u001b[2K",
|
|
22
|
+
hideCursor: "\u001b[?25l",
|
|
23
|
+
showCursor: "\u001b[?25h",
|
|
24
|
+
};
|
|
25
|
+
const STYLE_CODES = {
|
|
26
|
+
dim: "\u001b[2m",
|
|
27
|
+
bold: "\u001b[1m",
|
|
28
|
+
green: "\u001b[32m",
|
|
29
|
+
red: "\u001b[31m",
|
|
30
|
+
yellow: "\u001b[33m",
|
|
31
|
+
cyan: "\u001b[36m",
|
|
32
|
+
};
|
|
33
|
+
const RESET = "\u001b[0m";
|
|
34
|
+
/**
|
|
35
|
+
* Wrap `text` in the escape pair for `style`, or return it unchanged when the
|
|
36
|
+
* writer has color off — piped output must stay byte-clean for machines.
|
|
37
|
+
*/
|
|
38
|
+
export function paint(writer, style, text) {
|
|
39
|
+
return writer.color ? `${STYLE_CODES[style]}${text}${RESET}` : text;
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Build a Writer over any writable stream. `isTTY`/`columns` are read off the
|
|
43
|
+
* stream when present (only real terminals have them); `color` defaults to
|
|
44
|
+
* `isTTY && !NO_COLOR` because honoring NO_COLOR is part of the platform
|
|
45
|
+
* contract, and an explicit `opts.color` overrides both.
|
|
46
|
+
*/
|
|
47
|
+
export function createWriter(stream, opts) {
|
|
48
|
+
// NodeJS.WritableStream does not declare terminal-only members, so narrow
|
|
49
|
+
// through a structural shape instead of trusting the declared type.
|
|
50
|
+
const terminal = stream;
|
|
51
|
+
const isTTY = terminal.isTTY === true;
|
|
52
|
+
const noColor = (process.env.NO_COLOR ?? "").length > 0;
|
|
53
|
+
const dumbTerm = process.env.TERM === "dumb";
|
|
54
|
+
const color = opts?.color ?? (isTTY && !noColor && !dumbTerm);
|
|
55
|
+
return {
|
|
56
|
+
write(text) {
|
|
57
|
+
stream.write(text);
|
|
58
|
+
},
|
|
59
|
+
line(text) {
|
|
60
|
+
stream.write((text ?? "") + "\n");
|
|
61
|
+
},
|
|
62
|
+
isTTY,
|
|
63
|
+
color,
|
|
64
|
+
columns: terminal.columns ?? 80,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Terminal display width of one Unicode code point: 0 for combining marks,
|
|
69
|
+
* variation selectors and control characters, 2 for East Asian Wide/Fullwidth
|
|
70
|
+
* ranges and common emoji, 1 otherwise. A simplified, dependency-free
|
|
71
|
+
* approximation of Markus Kuhn's wcwidth (specs/terminal-ui-doctor.md §A
|
|
72
|
+
* "Width uses display cells, not ANSI string length") — good enough for
|
|
73
|
+
* layout purposes without pulling in a wcwidth/string-width package, which
|
|
74
|
+
* would break the TUI's dependency-free design (specs/v2-architecture.md D1).
|
|
75
|
+
*/
|
|
76
|
+
function codePointWidth(cp) {
|
|
77
|
+
if (cp === 0 ||
|
|
78
|
+
(cp >= 0x0001 && cp <= 0x001f) ||
|
|
79
|
+
(cp >= 0x007f && cp <= 0x009f) ||
|
|
80
|
+
(cp >= 0x0300 && cp <= 0x036f) || // combining diacritical marks
|
|
81
|
+
(cp >= 0x200b && cp <= 0x200f) || // zero-width space/joiners, LTR/RTL marks
|
|
82
|
+
cp === 0xfeff || // zero-width no-break space / BOM
|
|
83
|
+
(cp >= 0xfe00 && cp <= 0xfe0f) || // variation selectors
|
|
84
|
+
(cp >= 0x20d0 && cp <= 0x20ff) || // combining marks for symbols
|
|
85
|
+
(cp >= 0x1f3fb && cp <= 0x1f3ff) // emoji skin-tone modifiers
|
|
86
|
+
) {
|
|
87
|
+
return 0;
|
|
88
|
+
}
|
|
89
|
+
if ((cp >= 0x1100 && cp <= 0x115f) || // Hangul Jamo
|
|
90
|
+
(cp >= 0x2e80 && cp <= 0x303e) || // CJK radicals, Kangxi, CJK punctuation
|
|
91
|
+
(cp >= 0x3041 && cp <= 0x33ff) || // Hiragana, Katakana, CJK compat, enclosed CJK
|
|
92
|
+
(cp >= 0x3400 && cp <= 0x4dbf) || // CJK Unified Ideographs Extension A
|
|
93
|
+
(cp >= 0x4e00 && cp <= 0x9fff) || // CJK Unified Ideographs
|
|
94
|
+
(cp >= 0xa000 && cp <= 0xa4cf) || // Yi
|
|
95
|
+
(cp >= 0xac00 && cp <= 0xd7a3) || // Hangul syllables
|
|
96
|
+
(cp >= 0xf900 && cp <= 0xfaff) || // CJK compatibility ideographs
|
|
97
|
+
(cp >= 0xfe30 && cp <= 0xfe4f) || // CJK compatibility forms
|
|
98
|
+
(cp >= 0xff00 && cp <= 0xff60) || // Fullwidth forms
|
|
99
|
+
(cp >= 0xffe0 && cp <= 0xffe6) || // Fullwidth signs
|
|
100
|
+
(cp >= 0x1f300 && cp <= 0x1f64f) || // emoji: symbols/pictographs, emoticons
|
|
101
|
+
(cp >= 0x1f680 && cp <= 0x1f6ff) || // transport/map symbols
|
|
102
|
+
(cp >= 0x1f900 && cp <= 0x1f9ff) || // supplemental symbols/pictographs
|
|
103
|
+
(cp >= 0x20000 && cp <= 0x3fffd) // CJK unified ideographs, supplementary planes
|
|
104
|
+
) {
|
|
105
|
+
return 2;
|
|
106
|
+
}
|
|
107
|
+
return 1;
|
|
108
|
+
}
|
|
109
|
+
/** Total terminal display width of `text`, iterating by code point (not UTF-16 unit). */
|
|
110
|
+
export function displayWidth(text) {
|
|
111
|
+
let width = 0;
|
|
112
|
+
for (const char of text) {
|
|
113
|
+
width += codePointWidth(char.codePointAt(0) ?? 0);
|
|
114
|
+
}
|
|
115
|
+
return width;
|
|
116
|
+
}
|
|
117
|
+
/** Right-pad `text` with spaces until its DISPLAY width reaches `target` (never cuts). */
|
|
118
|
+
export function padEndDisplay(text, target) {
|
|
119
|
+
const pad = target - displayWidth(text);
|
|
120
|
+
return pad > 0 ? text + " ".repeat(pad) : text;
|
|
121
|
+
}
|
|
122
|
+
/**
|
|
123
|
+
* Cut `text` to `max` display columns, marking the cut with a single ellipsis
|
|
124
|
+
* character so a truncated path stays visually distinguishable from a real one.
|
|
125
|
+
*/
|
|
126
|
+
export function truncate(text, max) {
|
|
127
|
+
if (max <= 0) {
|
|
128
|
+
return "";
|
|
129
|
+
}
|
|
130
|
+
if (displayWidth(text) <= max) {
|
|
131
|
+
return text;
|
|
132
|
+
}
|
|
133
|
+
const budget = max - 1; // reserve one column for the ellipsis
|
|
134
|
+
let width = 0;
|
|
135
|
+
let result = "";
|
|
136
|
+
for (const char of text) {
|
|
137
|
+
const w = codePointWidth(char.codePointAt(0) ?? 0);
|
|
138
|
+
if (width + w > budget)
|
|
139
|
+
break;
|
|
140
|
+
result += char;
|
|
141
|
+
width += w;
|
|
142
|
+
}
|
|
143
|
+
return result + "…";
|
|
144
|
+
}
|