glm-coding-router 2.0.0 → 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 -534
- package/dist/cli.js +8 -3
- 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/status.js +28 -15
- package/dist/commands/usage.js +33 -18
- package/dist/core/key-inspector.js +45 -0
- package/dist/core/user-env.js +17 -7
- package/dist/core/zai-quota.js +110 -8
- package/dist/templates/agents-block.js +53 -53
- package/dist/templates/claude-block.js +56 -56
- package/dist/tui/command-ui.js +158 -0
- package/dist/tui/render.js +70 -4
- package/package.json +1 -1
package/dist/core/zai-quota.js
CHANGED
|
@@ -16,7 +16,89 @@ export function describeWindow(limit) {
|
|
|
16
16
|
}
|
|
17
17
|
return `window unit=${String(limit.unit)} x ${String(limit.number)}`;
|
|
18
18
|
}
|
|
19
|
-
/**
|
|
19
|
+
/**
|
|
20
|
+
* Thrown by `fetchZaiQuota` instead of a plain `Error`. `message` is always a
|
|
21
|
+
* safe, locally-constructed string — it never contains the raw response body,
|
|
22
|
+
* a provider `msg`, headers, or a URL with credentials (spec §D "Security").
|
|
23
|
+
*/
|
|
24
|
+
export class ZaiQuotaError extends Error {
|
|
25
|
+
kind;
|
|
26
|
+
httpStatus;
|
|
27
|
+
/** Only meaningful when kind === "network": distinguishes a timeout from any other network failure. */
|
|
28
|
+
timeout;
|
|
29
|
+
constructor(kind, message, options = {}) {
|
|
30
|
+
super(message);
|
|
31
|
+
this.name = "ZaiQuotaError";
|
|
32
|
+
this.kind = kind;
|
|
33
|
+
this.httpStatus = options.httpStatus;
|
|
34
|
+
this.timeout = options.timeout ?? false;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
function isPlainObject(value) {
|
|
38
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
39
|
+
}
|
|
40
|
+
function isFiniteNumberOrAbsent(value) {
|
|
41
|
+
return value === undefined || (typeof value === "number" && Number.isFinite(value));
|
|
42
|
+
}
|
|
43
|
+
/** Every quota field is optional, but any field that IS present must be well-typed. */
|
|
44
|
+
function isValidLimit(entry) {
|
|
45
|
+
if (!isPlainObject(entry))
|
|
46
|
+
return false;
|
|
47
|
+
if (entry.type !== undefined && typeof entry.type !== "string")
|
|
48
|
+
return false;
|
|
49
|
+
return (isFiniteNumberOrAbsent(entry.unit) &&
|
|
50
|
+
isFiniteNumberOrAbsent(entry.number) &&
|
|
51
|
+
isFiniteNumberOrAbsent(entry.usage) &&
|
|
52
|
+
isFiniteNumberOrAbsent(entry.currentValue) &&
|
|
53
|
+
isFiniteNumberOrAbsent(entry.remaining) &&
|
|
54
|
+
isFiniteNumberOrAbsent(entry.percentage) &&
|
|
55
|
+
isFiniteNumberOrAbsent(entry.nextResetTime));
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Validate the monitor response shape (spec §D "Success validation"). Empty
|
|
59
|
+
* `limits` is a valid, fully-authenticated response meaning "no windows
|
|
60
|
+
* reported" — never treated as zero quota. A missing or wrong-type `limits`
|
|
61
|
+
* field means the schema did not match, which is unverified, not rejected.
|
|
62
|
+
*/
|
|
63
|
+
function parseQuotaBody(body) {
|
|
64
|
+
if (!isPlainObject(body)) {
|
|
65
|
+
return { ok: false, kind: "invalid-response", message: "Z.ai monitor endpoint returned an unexpected response shape." };
|
|
66
|
+
}
|
|
67
|
+
if (typeof body.code !== "number") {
|
|
68
|
+
return { ok: false, kind: "invalid-response", message: "Z.ai monitor endpoint response is missing a status code." };
|
|
69
|
+
}
|
|
70
|
+
if (body.code !== 200) {
|
|
71
|
+
return { ok: false, kind: "provider", message: `Z.ai monitor endpoint rejected the request (code ${body.code}).` };
|
|
72
|
+
}
|
|
73
|
+
if (body.success !== undefined && body.success !== true) {
|
|
74
|
+
return { ok: false, kind: "provider", message: "Z.ai monitor endpoint reported an unsuccessful request." };
|
|
75
|
+
}
|
|
76
|
+
if (!isPlainObject(body.data)) {
|
|
77
|
+
return { ok: false, kind: "invalid-response", message: "Z.ai monitor endpoint response is missing usage data." };
|
|
78
|
+
}
|
|
79
|
+
const data = body.data;
|
|
80
|
+
if (data.level !== undefined && typeof data.level !== "string") {
|
|
81
|
+
return { ok: false, kind: "invalid-response", message: "Z.ai monitor endpoint response has an invalid level field." };
|
|
82
|
+
}
|
|
83
|
+
if (!Array.isArray(data.limits)) {
|
|
84
|
+
return { ok: false, kind: "invalid-response", message: "Z.ai monitor endpoint response is missing usage limits." };
|
|
85
|
+
}
|
|
86
|
+
for (const entry of data.limits) {
|
|
87
|
+
if (!isValidLimit(entry)) {
|
|
88
|
+
return { ok: false, kind: "invalid-response", message: "Z.ai monitor endpoint response contains a malformed limit entry." };
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return {
|
|
92
|
+
ok: true,
|
|
93
|
+
data: { level: data.level, limits: data.limits },
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Fetch and validate the Z.ai quota snapshot. Never logs the Authorization
|
|
98
|
+
* header. Throws `ZaiQuotaError` on any failure — callers branch on `.kind`.
|
|
99
|
+
* `redirect: "error"` refuses to forward the bearer token to a redirect
|
|
100
|
+
* target (spec §D "Security").
|
|
101
|
+
*/
|
|
20
102
|
export async function fetchZaiQuota(key, fetchImpl) {
|
|
21
103
|
let response;
|
|
22
104
|
try {
|
|
@@ -24,23 +106,43 @@ export async function fetchZaiQuota(key, fetchImpl) {
|
|
|
24
106
|
method: "GET",
|
|
25
107
|
headers: { Authorization: `Bearer ${key}`, "Content-Type": "application/json" },
|
|
26
108
|
signal: AbortSignal.timeout(10_000),
|
|
109
|
+
redirect: "error",
|
|
27
110
|
});
|
|
28
111
|
}
|
|
29
112
|
catch (error) {
|
|
30
|
-
|
|
113
|
+
const isTimeout = error instanceof Error && error.name === "TimeoutError";
|
|
114
|
+
throw new ZaiQuotaError("network", isTimeout ? "Z.ai monitor endpoint timed out." : "Z.ai monitor endpoint was unreachable.", { timeout: isTimeout });
|
|
115
|
+
}
|
|
116
|
+
if (response.status === 401) {
|
|
117
|
+
throw new ZaiQuotaError("unauthorized", "Z.ai monitor endpoint rejected the key (HTTP 401).", {
|
|
118
|
+
httpStatus: 401,
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
if (response.status === 403) {
|
|
122
|
+
throw new ZaiQuotaError("forbidden", "Z.ai monitor endpoint denied access (HTTP 403).", {
|
|
123
|
+
httpStatus: 403,
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
if (response.status === 429) {
|
|
127
|
+
throw new ZaiQuotaError("rate-limited", "Z.ai monitor endpoint is rate-limiting this key (HTTP 429).", {
|
|
128
|
+
httpStatus: 429,
|
|
129
|
+
});
|
|
31
130
|
}
|
|
32
131
|
if (!response.ok) {
|
|
33
|
-
throw new
|
|
132
|
+
throw new ZaiQuotaError("http", `Z.ai monitor endpoint returned HTTP ${response.status}.`, {
|
|
133
|
+
httpStatus: response.status,
|
|
134
|
+
});
|
|
34
135
|
}
|
|
35
136
|
let body;
|
|
36
137
|
try {
|
|
37
|
-
body =
|
|
138
|
+
body = await response.json();
|
|
38
139
|
}
|
|
39
140
|
catch {
|
|
40
|
-
throw new
|
|
141
|
+
throw new ZaiQuotaError("invalid-response", "Z.ai monitor endpoint returned a non-JSON body.");
|
|
41
142
|
}
|
|
42
|
-
|
|
43
|
-
|
|
143
|
+
const parsed = parseQuotaBody(body);
|
|
144
|
+
if (!parsed.ok) {
|
|
145
|
+
throw new ZaiQuotaError(parsed.kind, parsed.message);
|
|
44
146
|
}
|
|
45
|
-
return
|
|
147
|
+
return parsed.data;
|
|
46
148
|
}
|
|
@@ -1,55 +1,55 @@
|
|
|
1
1
|
/** Managed block content for AGENTS.md (spec §24). Keep in sync with the spec. */
|
|
2
|
-
export const AGENTS_MANAGED_BLOCK = `<!-- glm-coding-router:start -->
|
|
3
|
-
|
|
4
|
-
## GLM Worker Delegation
|
|
5
|
-
|
|
6
|
-
Available commands:
|
|
7
|
-
|
|
8
|
-
- \`glm-worker "<task>"\`
|
|
9
|
-
- \`glm-review "<task>"\`
|
|
10
|
-
|
|
11
|
-
Codex is the primary orchestrator.
|
|
12
|
-
|
|
13
|
-
Delegate:
|
|
14
|
-
- CRUD
|
|
15
|
-
- boilerplate
|
|
16
|
-
- tests
|
|
17
|
-
- documentation
|
|
18
|
-
- mechanical refactoring
|
|
19
|
-
- repository exploration
|
|
20
|
-
- straightforward implementation
|
|
21
|
-
|
|
22
|
-
Keep in Codex:
|
|
23
|
-
- requirements
|
|
24
|
-
- planning
|
|
25
|
-
- architecture
|
|
26
|
-
- ambiguous business logic
|
|
27
|
-
- complex debugging
|
|
28
|
-
- security decisions
|
|
29
|
-
- integration
|
|
30
|
-
- final review
|
|
31
|
-
|
|
32
|
-
Before delegation create a task packet:
|
|
33
|
-
|
|
34
|
-
TASK
|
|
35
|
-
SCOPE
|
|
36
|
-
FILES ALLOWED TO MODIFY
|
|
37
|
-
FILES NOT TO MODIFY
|
|
38
|
-
REQUIREMENTS
|
|
39
|
-
CONSTRAINTS
|
|
40
|
-
ACCEPTANCE CRITERIA
|
|
41
|
-
VALIDATION
|
|
42
|
-
EXPECTED OUTPUT
|
|
43
|
-
|
|
44
|
-
Never trust a worker's success report without inspecting the resulting changes.
|
|
45
|
-
|
|
46
|
-
Worker exit codes 41 and 42 are NOT crashes:
|
|
47
|
-
|
|
48
|
-
- stdout carries one JSON object: \`{"status":"handoff_required", ...}\`
|
|
49
|
-
- read \`handoff_path\` — a handoff.md with what was done, what remains, files
|
|
50
|
-
changed, and untracked files that are NOT in diff.patch
|
|
51
|
-
- continue the task yourself in the SAME worktree, starting from "Remaining"
|
|
52
|
-
- do not re-run the worker until quota resets
|
|
53
|
-
- 41 means nothing was spawned; 42 means work was done and is preserved
|
|
54
|
-
|
|
2
|
+
export const AGENTS_MANAGED_BLOCK = `<!-- glm-coding-router:start -->
|
|
3
|
+
|
|
4
|
+
## GLM Worker Delegation
|
|
5
|
+
|
|
6
|
+
Available commands:
|
|
7
|
+
|
|
8
|
+
- \`glm-worker "<task>"\`
|
|
9
|
+
- \`glm-review "<task>"\`
|
|
10
|
+
|
|
11
|
+
Codex is the primary orchestrator.
|
|
12
|
+
|
|
13
|
+
Delegate:
|
|
14
|
+
- CRUD
|
|
15
|
+
- boilerplate
|
|
16
|
+
- tests
|
|
17
|
+
- documentation
|
|
18
|
+
- mechanical refactoring
|
|
19
|
+
- repository exploration
|
|
20
|
+
- straightforward implementation
|
|
21
|
+
|
|
22
|
+
Keep in Codex:
|
|
23
|
+
- requirements
|
|
24
|
+
- planning
|
|
25
|
+
- architecture
|
|
26
|
+
- ambiguous business logic
|
|
27
|
+
- complex debugging
|
|
28
|
+
- security decisions
|
|
29
|
+
- integration
|
|
30
|
+
- final review
|
|
31
|
+
|
|
32
|
+
Before delegation create a task packet:
|
|
33
|
+
|
|
34
|
+
TASK
|
|
35
|
+
SCOPE
|
|
36
|
+
FILES ALLOWED TO MODIFY
|
|
37
|
+
FILES NOT TO MODIFY
|
|
38
|
+
REQUIREMENTS
|
|
39
|
+
CONSTRAINTS
|
|
40
|
+
ACCEPTANCE CRITERIA
|
|
41
|
+
VALIDATION
|
|
42
|
+
EXPECTED OUTPUT
|
|
43
|
+
|
|
44
|
+
Never trust a worker's success report without inspecting the resulting changes.
|
|
45
|
+
|
|
46
|
+
Worker exit codes 41 and 42 are NOT crashes:
|
|
47
|
+
|
|
48
|
+
- stdout carries one JSON object: \`{"status":"handoff_required", ...}\`
|
|
49
|
+
- read \`handoff_path\` — a handoff.md with what was done, what remains, files
|
|
50
|
+
changed, and untracked files that are NOT in diff.patch
|
|
51
|
+
- continue the task yourself in the SAME worktree, starting from "Remaining"
|
|
52
|
+
- do not re-run the worker until quota resets
|
|
53
|
+
- 41 means nothing was spawned; 42 means work was done and is preserved
|
|
54
|
+
|
|
55
55
|
<!-- glm-coding-router:end -->`;
|
|
@@ -1,58 +1,58 @@
|
|
|
1
1
|
/** Managed block content for CLAUDE.md (spec §20). Keep in sync with the spec. */
|
|
2
|
-
export const CLAUDE_MANAGED_BLOCK = `<!-- glm-coding-router:start -->
|
|
3
|
-
|
|
4
|
-
## GLM Worker Delegation
|
|
5
|
-
|
|
6
|
-
GLM workers available:
|
|
7
|
-
|
|
8
|
-
- \`glm-worker "<task>"\`
|
|
9
|
-
- \`glm-review "<task>"\`
|
|
10
|
-
|
|
11
|
-
Delegate well-scoped, implementation-heavy work to GLM.
|
|
12
|
-
|
|
13
|
-
Use GLM for:
|
|
14
|
-
- repository exploration
|
|
15
|
-
- CRUD
|
|
16
|
-
- boilerplate
|
|
17
|
-
- tests
|
|
18
|
-
- documentation
|
|
19
|
-
- mechanical refactoring
|
|
20
|
-
- straightforward implementation
|
|
21
|
-
|
|
22
|
-
Claude remains responsible for:
|
|
23
|
-
- requirements
|
|
24
|
-
- architecture
|
|
25
|
-
- ambiguous business rules
|
|
26
|
-
- security-sensitive decisions
|
|
27
|
-
- complex debugging
|
|
28
|
-
- integration
|
|
29
|
-
- final review
|
|
30
|
-
|
|
31
|
-
Before delegation, define:
|
|
32
|
-
- task
|
|
33
|
-
- scope
|
|
34
|
-
- allowed files
|
|
35
|
-
- forbidden files
|
|
36
|
-
- requirements
|
|
37
|
-
- constraints
|
|
38
|
-
- acceptance criteria
|
|
39
|
-
- validation command
|
|
40
|
-
- expected output
|
|
41
|
-
|
|
42
|
-
After worker completion:
|
|
43
|
-
1. inspect the actual diff
|
|
44
|
-
2. validate against requirements
|
|
45
|
-
3. run relevant tests
|
|
46
|
-
4. resolve integration problems
|
|
47
|
-
5. accept only after verification
|
|
48
|
-
|
|
49
|
-
Worker exit codes 41 and 42 are NOT crashes:
|
|
50
|
-
|
|
51
|
-
- stdout carries one JSON object: \`{"status":"handoff_required", ...}\`
|
|
52
|
-
- read \`handoff_path\` — a handoff.md with what was done, what remains, files
|
|
53
|
-
changed, and untracked files that are NOT in diff.patch
|
|
54
|
-
- continue the task yourself in the SAME worktree, starting from "Remaining"
|
|
55
|
-
- do not re-run the worker until quota resets
|
|
56
|
-
- 41 means nothing was spawned; 42 means work was done and is preserved
|
|
57
|
-
|
|
2
|
+
export const CLAUDE_MANAGED_BLOCK = `<!-- glm-coding-router:start -->
|
|
3
|
+
|
|
4
|
+
## GLM Worker Delegation
|
|
5
|
+
|
|
6
|
+
GLM workers available:
|
|
7
|
+
|
|
8
|
+
- \`glm-worker "<task>"\`
|
|
9
|
+
- \`glm-review "<task>"\`
|
|
10
|
+
|
|
11
|
+
Delegate well-scoped, implementation-heavy work to GLM.
|
|
12
|
+
|
|
13
|
+
Use GLM for:
|
|
14
|
+
- repository exploration
|
|
15
|
+
- CRUD
|
|
16
|
+
- boilerplate
|
|
17
|
+
- tests
|
|
18
|
+
- documentation
|
|
19
|
+
- mechanical refactoring
|
|
20
|
+
- straightforward implementation
|
|
21
|
+
|
|
22
|
+
Claude remains responsible for:
|
|
23
|
+
- requirements
|
|
24
|
+
- architecture
|
|
25
|
+
- ambiguous business rules
|
|
26
|
+
- security-sensitive decisions
|
|
27
|
+
- complex debugging
|
|
28
|
+
- integration
|
|
29
|
+
- final review
|
|
30
|
+
|
|
31
|
+
Before delegation, define:
|
|
32
|
+
- task
|
|
33
|
+
- scope
|
|
34
|
+
- allowed files
|
|
35
|
+
- forbidden files
|
|
36
|
+
- requirements
|
|
37
|
+
- constraints
|
|
38
|
+
- acceptance criteria
|
|
39
|
+
- validation command
|
|
40
|
+
- expected output
|
|
41
|
+
|
|
42
|
+
After worker completion:
|
|
43
|
+
1. inspect the actual diff
|
|
44
|
+
2. validate against requirements
|
|
45
|
+
3. run relevant tests
|
|
46
|
+
4. resolve integration problems
|
|
47
|
+
5. accept only after verification
|
|
48
|
+
|
|
49
|
+
Worker exit codes 41 and 42 are NOT crashes:
|
|
50
|
+
|
|
51
|
+
- stdout carries one JSON object: \`{"status":"handoff_required", ...}\`
|
|
52
|
+
- read \`handoff_path\` — a handoff.md with what was done, what remains, files
|
|
53
|
+
changed, and untracked files that are NOT in diff.patch
|
|
54
|
+
- continue the task yourself in the SAME worktree, starting from "Remaining"
|
|
55
|
+
- do not re-run the worker until quota resets
|
|
56
|
+
- 41 means nothing was spawned; 42 means work was done and is preserved
|
|
57
|
+
|
|
58
58
|
<!-- glm-coding-router:end -->`;
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared presentation layer for the management screens — `doctor`, `status`,
|
|
3
|
+
* `usage`, and the root landing page (specs/terminal-ui-doctor.md §A). This
|
|
4
|
+
* module never fetches data, reads credentials, or makes routing decisions;
|
|
5
|
+
* it only turns already-computed values into aligned, width-aware text.
|
|
6
|
+
*
|
|
7
|
+
* All ANSI stays in render.ts (`paint`); this file composes plain strings
|
|
8
|
+
* and colors them through that one choke point.
|
|
9
|
+
*/
|
|
10
|
+
import { displayWidth, padEndDisplay, paint, truncate } from "./render.js";
|
|
11
|
+
const STATUS_TAG = {
|
|
12
|
+
ok: "[OK]",
|
|
13
|
+
warn: "[WARN]",
|
|
14
|
+
fail: "[FAIL]",
|
|
15
|
+
info: "[INFO]",
|
|
16
|
+
};
|
|
17
|
+
const STATUS_STYLE = {
|
|
18
|
+
ok: "green",
|
|
19
|
+
warn: "yellow",
|
|
20
|
+
fail: "red",
|
|
21
|
+
info: "cyan",
|
|
22
|
+
};
|
|
23
|
+
/** Widest tag ("[WARN]"/"[FAIL]"/"[INFO]") plus one separating space. */
|
|
24
|
+
const TAG_COLUMN = 7;
|
|
25
|
+
const BASE_INDENT = " ";
|
|
26
|
+
const LABEL_COLUMN = 24;
|
|
27
|
+
const MIN_BOX_WIDTH = 44;
|
|
28
|
+
const MAX_BOX_WIDTH = 78;
|
|
29
|
+
const BAR_SLOTS = 20;
|
|
30
|
+
function effectiveWidth(writer) {
|
|
31
|
+
return Number.isFinite(writer.columns) && writer.columns > 0 ? Math.floor(writer.columns) : 80;
|
|
32
|
+
}
|
|
33
|
+
/** Strip control characters from text that did not originate in this module (spec §A). */
|
|
34
|
+
function sanitize(text, allowNewline) {
|
|
35
|
+
const pattern = allowNewline ? /[\x00-\x09\x0B\x0C\x0E-\x1F\x7F]/g : /[\x00-\x1F\x7F]/g;
|
|
36
|
+
return text.replace(pattern, "");
|
|
37
|
+
}
|
|
38
|
+
function border(width) {
|
|
39
|
+
return `+${"-".repeat(Math.max(width - 2, 0))}+`;
|
|
40
|
+
}
|
|
41
|
+
function boxLine(text, width) {
|
|
42
|
+
const inner = Math.max(width - 4, 0);
|
|
43
|
+
return `| ${padEndDisplay(truncate(text, inner), inner)} |`;
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Wrap `text` to `width`-wide lines (measured in display columns, not code
|
|
47
|
+
* units — spec §A "Width uses display cells") without breaking a word
|
|
48
|
+
* mid-way when avoidable.
|
|
49
|
+
*/
|
|
50
|
+
function wrap(text, width) {
|
|
51
|
+
if (width <= 0)
|
|
52
|
+
return [text];
|
|
53
|
+
const words = text.split(/\s+/).filter((w) => w.length > 0);
|
|
54
|
+
const lines = [];
|
|
55
|
+
let current = "";
|
|
56
|
+
for (const word of words) {
|
|
57
|
+
const candidate = current.length === 0 ? word : `${current} ${word}`;
|
|
58
|
+
if (displayWidth(candidate) > width && current.length > 0) {
|
|
59
|
+
lines.push(current);
|
|
60
|
+
current = word;
|
|
61
|
+
}
|
|
62
|
+
else {
|
|
63
|
+
current = candidate;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
if (current.length > 0)
|
|
67
|
+
lines.push(current);
|
|
68
|
+
return lines.length > 0 ? lines : [""];
|
|
69
|
+
}
|
|
70
|
+
export function createCommandUi(writer, options = {}) {
|
|
71
|
+
const quiet = options.quiet ?? false;
|
|
72
|
+
return {
|
|
73
|
+
header(title, subtitle) {
|
|
74
|
+
if (quiet)
|
|
75
|
+
return "";
|
|
76
|
+
const width = effectiveWidth(writer);
|
|
77
|
+
const cleanTitle = sanitize(title, false);
|
|
78
|
+
const cleanSubtitle = subtitle !== undefined ? sanitize(subtitle, false) : undefined;
|
|
79
|
+
if (width < MIN_BOX_WIDTH) {
|
|
80
|
+
const lines = [paint(writer, "cyan", truncate(cleanTitle, width))];
|
|
81
|
+
if (cleanSubtitle)
|
|
82
|
+
lines.push(paint(writer, "dim", truncate(cleanSubtitle, width)));
|
|
83
|
+
return lines.join("\n");
|
|
84
|
+
}
|
|
85
|
+
const boxWidth = Math.min(width, MAX_BOX_WIDTH);
|
|
86
|
+
const lines = [border(boxWidth), boxLine(cleanTitle, boxWidth)];
|
|
87
|
+
if (cleanSubtitle)
|
|
88
|
+
lines.push(boxLine(cleanSubtitle, boxWidth));
|
|
89
|
+
lines.push(border(boxWidth));
|
|
90
|
+
return lines.join("\n");
|
|
91
|
+
},
|
|
92
|
+
section(title) {
|
|
93
|
+
return paint(writer, "bold", sanitize(title, false));
|
|
94
|
+
},
|
|
95
|
+
row(label, value, status) {
|
|
96
|
+
const width = effectiveWidth(writer);
|
|
97
|
+
const cleanLabel = sanitize(label, false);
|
|
98
|
+
const cleanValue = sanitize(value, false);
|
|
99
|
+
const tagField = status !== undefined ? STATUS_TAG[status].padEnd(TAG_COLUMN) : "";
|
|
100
|
+
const labelField = padEndDisplay(cleanLabel, LABEL_COLUMN);
|
|
101
|
+
const plainPrefix = `${BASE_INDENT}${tagField}${labelField}`;
|
|
102
|
+
const singleLine = `${plainPrefix}${cleanValue}`;
|
|
103
|
+
const coloredPrefix = () => {
|
|
104
|
+
if (status === undefined)
|
|
105
|
+
return plainPrefix;
|
|
106
|
+
const tag = STATUS_TAG[status];
|
|
107
|
+
return `${BASE_INDENT}${paint(writer, STATUS_STYLE[status], tag)}${tagField.slice(tag.length)}${labelField}`;
|
|
108
|
+
};
|
|
109
|
+
if (cleanValue.length === 0 || displayWidth(singleLine.replace(/\s+$/, "")) <= width) {
|
|
110
|
+
return `${coloredPrefix()}${cleanValue}`.replace(/\s+$/, "");
|
|
111
|
+
}
|
|
112
|
+
// Stack: tag + label on one line, value wrapped and indented beneath.
|
|
113
|
+
const tagLine = status !== undefined
|
|
114
|
+
? `${BASE_INDENT}${paint(writer, STATUS_STYLE[status], STATUS_TAG[status])} ${cleanLabel}`
|
|
115
|
+
: `${BASE_INDENT}${cleanLabel}`;
|
|
116
|
+
const valueIndent = `${BASE_INDENT}${" ".repeat(TAG_COLUMN)}`;
|
|
117
|
+
const valueWidth = Math.max(width - valueIndent.length, 8);
|
|
118
|
+
const valueLines = wrap(cleanValue, valueWidth).map((line) => `${valueIndent}${line}`);
|
|
119
|
+
return [tagLine, ...valueLines].join("\n");
|
|
120
|
+
},
|
|
121
|
+
detail(text) {
|
|
122
|
+
const width = effectiveWidth(writer);
|
|
123
|
+
const indent = `${BASE_INDENT}${" ".repeat(TAG_COLUMN - 2)}`;
|
|
124
|
+
const availableWidth = Math.max(width - indent.length, 8);
|
|
125
|
+
const cleanText = sanitize(text, true);
|
|
126
|
+
const lines = [];
|
|
127
|
+
for (const paragraph of cleanText.split("\n")) {
|
|
128
|
+
if (paragraph.length === 0) {
|
|
129
|
+
lines.push("");
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
for (const line of wrap(paragraph, availableWidth)) {
|
|
133
|
+
lines.push(`${indent}${line}`);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
return lines.join("\n");
|
|
137
|
+
},
|
|
138
|
+
footer(text) {
|
|
139
|
+
if (quiet)
|
|
140
|
+
return "";
|
|
141
|
+
const cleanText = sanitize(text, true);
|
|
142
|
+
return cleanText
|
|
143
|
+
.split("\n")
|
|
144
|
+
.map((line) => (line.length === 0 ? "" : `${BASE_INDENT}${line}`))
|
|
145
|
+
.join("\n");
|
|
146
|
+
},
|
|
147
|
+
bar(percent) {
|
|
148
|
+
if (percent === undefined || !Number.isFinite(percent)) {
|
|
149
|
+
return `[${"-".repeat(BAR_SLOTS)}] unknown`;
|
|
150
|
+
}
|
|
151
|
+
const clamped = Math.min(100, Math.max(0, percent));
|
|
152
|
+
const filled = Math.round((clamped / 100) * BAR_SLOTS);
|
|
153
|
+
const bar = `[${"#".repeat(filled)}${"-".repeat(BAR_SLOTS - filled)}]`;
|
|
154
|
+
const style = clamped >= 90 ? "red" : clamped >= 70 ? "yellow" : "green";
|
|
155
|
+
return `${paint(writer, style, bar)} ${Math.round(clamped)}% used`;
|
|
156
|
+
},
|
|
157
|
+
};
|
|
158
|
+
}
|
package/dist/tui/render.js
CHANGED
|
@@ -50,7 +50,8 @@ export function createWriter(stream, opts) {
|
|
|
50
50
|
const terminal = stream;
|
|
51
51
|
const isTTY = terminal.isTTY === true;
|
|
52
52
|
const noColor = (process.env.NO_COLOR ?? "").length > 0;
|
|
53
|
-
const
|
|
53
|
+
const dumbTerm = process.env.TERM === "dumb";
|
|
54
|
+
const color = opts?.color ?? (isTTY && !noColor && !dumbTerm);
|
|
54
55
|
return {
|
|
55
56
|
write(text) {
|
|
56
57
|
stream.write(text);
|
|
@@ -64,15 +65,80 @@ export function createWriter(stream, opts) {
|
|
|
64
65
|
};
|
|
65
66
|
}
|
|
66
67
|
/**
|
|
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
|
|
68
124
|
* character so a truncated path stays visually distinguishable from a real one.
|
|
69
125
|
*/
|
|
70
126
|
export function truncate(text, max) {
|
|
71
127
|
if (max <= 0) {
|
|
72
128
|
return "";
|
|
73
129
|
}
|
|
74
|
-
if (text
|
|
130
|
+
if (displayWidth(text) <= max) {
|
|
75
131
|
return text;
|
|
76
132
|
}
|
|
77
|
-
|
|
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 + "…";
|
|
78
144
|
}
|