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.
@@ -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
- /** Fetch and validate the Z.ai quota snapshot. Never logs the Authorization header. */
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
- throw new Error(`Z.ai monitor endpoint unreachable (${error instanceof Error ? error.message : "network error"})`);
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 Error(`Z.ai monitor endpoint returned HTTP ${response.status}`);
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 = (await response.json());
138
+ body = await response.json();
38
139
  }
39
140
  catch {
40
- throw new Error("Z.ai monitor endpoint returned a non-JSON body");
141
+ throw new ZaiQuotaError("invalid-response", "Z.ai monitor endpoint returned a non-JSON body.");
41
142
  }
42
- if (body.code !== 200 || typeof body.data !== "object" || body.data === null) {
43
- throw new Error(`Z.ai monitor endpoint rejected the request (${body.msg ?? `code ${String(body.code)}`})`);
143
+ const parsed = parseQuotaBody(body);
144
+ if (!parsed.ok) {
145
+ throw new ZaiQuotaError(parsed.kind, parsed.message);
44
146
  }
45
- return body.data;
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
+ }
@@ -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 color = opts?.color ?? (isTTY && !noColor);
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
- * Cut `text` to `max` characters, marking the cut with a single ellipsis
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.length <= max) {
130
+ if (displayWidth(text) <= max) {
75
131
  return text;
76
132
  }
77
- return text.slice(0, max - 1) + "…";
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
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "glm-coding-router",
3
- "version": "2.0.0",
3
+ "version": "2.1.0",
4
4
  "description": "GLM Coding Plan workers for Claude Code and Codex",
5
5
  "type": "module",
6
6
  "license": "MIT",