@monte3l/groundwork 0.0.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/README.md +23 -0
- package/bin/m3l-groundwork.mjs +10 -0
- package/dist/assets.d.ts +20 -0
- package/dist/assets.js +79 -0
- package/dist/caps.d.ts +25 -0
- package/dist/caps.js +69 -0
- package/dist/conflicts.d.ts +12 -0
- package/dist/conflicts.js +77 -0
- package/dist/emit.d.ts +7 -0
- package/dist/emit.js +42 -0
- package/dist/git.d.ts +3 -0
- package/dist/git.js +9 -0
- package/dist/harness/conformance.d.ts +20 -0
- package/dist/harness/conformance.js +18 -0
- package/dist/harness/frontmatter.d.ts +38 -0
- package/dist/harness/frontmatter.js +204 -0
- package/dist/harness/grade.d.ts +4 -0
- package/dist/harness/grade.js +105 -0
- package/dist/harness/rules.d.ts +55 -0
- package/dist/harness/rules.js +580 -0
- package/dist/harness/types.d.ts +32 -0
- package/dist/harness/types.js +9 -0
- package/dist/inventory.d.ts +63 -0
- package/dist/inventory.js +66 -0
- package/dist/jsonc.d.ts +14 -0
- package/dist/jsonc.js +83 -0
- package/dist/main.d.ts +24 -0
- package/dist/main.js +297 -0
- package/dist/merge-json.d.ts +74 -0
- package/dist/merge-json.js +135 -0
- package/dist/mode.d.ts +19 -0
- package/dist/mode.js +53 -0
- package/dist/packs.d.ts +61 -0
- package/dist/packs.js +186 -0
- package/dist/plugin.d.ts +23 -0
- package/dist/plugin.js +79 -0
- package/dist/report.d.ts +4 -0
- package/dist/report.js +323 -0
- package/dist/survey/fs-walk.d.ts +14 -0
- package/dist/survey/fs-walk.js +60 -0
- package/dist/survey/survey-docs.d.ts +4 -0
- package/dist/survey/survey-docs.js +69 -0
- package/dist/survey/survey-harness.d.ts +4 -0
- package/dist/survey/survey-harness.js +121 -0
- package/dist/survey/survey-shape.d.ts +4 -0
- package/dist/survey/survey-shape.js +182 -0
- package/dist/survey/survey-toolchain.d.ts +4 -0
- package/dist/survey/survey-toolchain.js +217 -0
- package/dist/survey/survey.d.ts +5 -0
- package/dist/survey/survey.js +21 -0
- package/dist/survey/types.d.ts +117 -0
- package/dist/survey/types.js +8 -0
- package/dist/tokens.d.ts +13 -0
- package/dist/tokens.js +13 -0
- package/dist/toolchain/conformance.d.ts +20 -0
- package/dist/toolchain/conformance.js +30 -0
- package/dist/toolchain/grade.d.ts +4 -0
- package/dist/toolchain/grade.js +244 -0
- package/dist/toolchain/rules.d.ts +118 -0
- package/dist/toolchain/rules.js +706 -0
- package/dist/toolchain/tsconfig-chain.d.ts +36 -0
- package/dist/toolchain/tsconfig-chain.js +116 -0
- package/dist/toolchain/types.d.ts +27 -0
- package/dist/toolchain/types.js +9 -0
- package/package.json +59 -0
- package/plugin/skills/customize/SKILL.md +305 -0
- package/plugin/src/domain-map.ts +134 -0
- package/plugin/src/index.ts +4 -0
- package/plugin/src/kind-facet-map.ts +174 -0
- package/plugin/src/pack-map.ts +65 -0
- package/templates/core/.claude/agents/Explore.md +43 -0
- package/templates/core/.claude/agents/code-implementer.md +258 -0
- package/templates/core/.claude/agents/code-reviewer.md +163 -0
- package/templates/core/.claude/agents/silent-failure-hunter.md +191 -0
- package/templates/core/.claude/agents/test-author.md +211 -0
- package/templates/core/.claude/hooks/guard-branch-isolation.mjs +123 -0
- package/templates/core/.claude/hooks/guard-double-background.mjs +113 -0
- package/templates/core/.claude/hooks/guard-git-push-signed.mjs +90 -0
- package/templates/core/.claude/hooks/guard-hub-src-writes.mjs +88 -0
- package/templates/core/.claude/hooks/guard-js-extension.mjs +66 -0
- package/templates/core/.claude/hooks/guard-no-commonjs.mjs +105 -0
- package/templates/core/.claude/hooks/guard-protected-paths.mjs +45 -0
- package/templates/core/.claude/hooks/guard-secret-writes.mjs +183 -0
- package/templates/core/.claude/hooks/inject-decision-gate.mjs +119 -0
- package/templates/core/.claude/hooks/post-edit-verify.mjs +150 -0
- package/templates/core/.claude/rules/agent-dispatch.md +121 -0
- package/templates/core/.claude/rules/refactoring.md +52 -0
- package/templates/core/.claude/rules/src.md +114 -0
- package/templates/core/.claude/rules/tests.md +129 -0
- package/templates/core/.claude/settings.json +111 -0
- package/templates/core/.claude/skills/creating-prs/SKILL.md +132 -0
- package/templates/core/.claude/skills/finishing-work/SKILL.md +117 -0
- package/templates/core/.claude/skills/harness-guidance/SKILL.md +140 -0
- package/templates/core/.claude/skills/harness-guidance/references/official-sources.md +58 -0
- package/templates/core/.claude/skills/starting-work/SKILL.md +94 -0
- package/templates/core/.claude/skills/triaging-ci/SKILL.md +111 -0
- package/templates/core/.claude/skills/typescript-guidance/SKILL.md +143 -0
- package/templates/core/.claude/skills/typescript-guidance/references/typescript-sources.md +102 -0
- package/templates/core/.claude/skills/writing-commits/SKILL.md +248 -0
- package/templates/core/.github/workflows/ci.yml +123 -0
- package/templates/core/.github/workflows/dependency-review.yml +26 -0
- package/templates/core/.github/workflows/security-audit.yml +54 -0
- package/templates/core/.node-version +1 -0
- package/templates/core/.prettierignore +5 -0
- package/templates/core/.prettierrc.json +4 -0
- package/templates/core/CLAUDE.md +127 -0
- package/templates/core/README.md +24 -0
- package/templates/core/_gitignore +19 -0
- package/templates/core/_npmrc +1 -0
- package/templates/core/bin/check-exports.mjs +92 -0
- package/templates/core/bin/check-harness.mjs +27 -0
- package/templates/core/bin/check-node-version.mjs +51 -0
- package/templates/core/bin/check-toolchain.mjs +20 -0
- package/templates/core/bin/lib/agent-roster.mjs +8 -0
- package/templates/core/bin/lib/frontmatter.mjs +210 -0
- package/templates/core/bin/lib/harness-rules.mjs +916 -0
- package/templates/core/bin/lib/protected-paths.mjs +23 -0
- package/templates/core/bin/lib/report.mjs +56 -0
- package/templates/core/bin/lib/signed-range.mjs +178 -0
- package/templates/core/bin/lib/toolchain-rules.mjs +1264 -0
- package/templates/core/bin/lib/verify-steps.mjs +131 -0
- package/templates/core/bin/lib/verify-steps.packs.json +1 -0
- package/templates/core/bin/lint-commit.mjs +50 -0
- package/templates/core/bin/strip-claude-trailers.mjs +25 -0
- package/templates/core/bin/verify.mjs +64 -0
- package/templates/core/commitlint.config.js +11 -0
- package/templates/core/docs/research/harness-refresh.md +27 -0
- package/templates/core/docs/research/typescript-refresh.md +32 -0
- package/templates/core/eslint.config.js +105 -0
- package/templates/core/knip.json +6 -0
- package/templates/core/lefthook.yml +39 -0
- package/templates/core/package.json +58 -0
- package/templates/core/pnpm-workspace.yaml +13 -0
- package/templates/core/src/index.ts +12 -0
- package/templates/core/tests/index.test.ts +8 -0
- package/templates/core/tsconfig.base.json +36 -0
- package/templates/core/tsconfig.build.json +10 -0
- package/templates/core/tsconfig.json +11 -0
- package/templates/core/vitest.config.ts +32 -0
- package/templates/packs/README.md +81 -0
- package/templates/packs/harness-extras/files/.claude/agents/type-design-analyzer.md +188 -0
- package/templates/packs/harness-extras/files/.claude/hooks/guard-readonly-bash.mjs +324 -0
- package/templates/packs/harness-extras/files/.claude/hooks/reinject-compact-handoff.mjs +197 -0
- package/templates/packs/harness-extras/files/.claude/hooks/write-compact-handoff.mjs +180 -0
- package/templates/packs/harness-extras/files/bin/check-file-budget.mjs +407 -0
- package/templates/packs/harness-extras/files/bin/file-budget-baseline.json +1 -0
- package/templates/packs/harness-extras/pack.json +65 -0
- package/templates/packs/statusline/files/.claude/hooks/statusline-layout.mjs +365 -0
- package/templates/packs/statusline/files/.claude/hooks/statusline.mjs +996 -0
- package/templates/packs/statusline/files/.claude/hooks/subagent-statusline.mjs +203 -0
- package/templates/packs/statusline/pack.json +31 -0
|
@@ -0,0 +1,365 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure presentation primitives shared by both status-line entry points
|
|
3
|
+
* (`statusline.mjs`, `subagent-statusline.mjs`): terminal-width fitting, ANSI
|
|
4
|
+
* colors, and the two small formatters both scripts need. No I/O, no
|
|
5
|
+
* shebang, no direct-run block -- this module is only ever imported, and
|
|
6
|
+
* neither entry point imports the other.
|
|
7
|
+
*
|
|
8
|
+
* Anthropic's own statusLine docs (code.claude.com/docs/en/statusline) state
|
|
9
|
+
* that a statusLine script must read the `COLUMNS`/`LINES` env vars to learn
|
|
10
|
+
* the terminal width; `tput cols` does not work inside a statusLine
|
|
11
|
+
* subprocess. `terminalColumns` is the read side of that contract;
|
|
12
|
+
* `displayWidth`/`truncateToWidth`/`fitRow` are what make a real width
|
|
13
|
+
* budget actionable -- segment-level priority dropping plus
|
|
14
|
+
* ANSI/OSC-8-aware truncation, so a narrow terminal degrades by omitting the
|
|
15
|
+
* least important segments first rather than wrapping mid-line.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* SGR (`\x1b[...m`) and OSC-8 (`\x1b]8;;URL\x07...\x1b]8;;\x07`) sequences.
|
|
20
|
+
*
|
|
21
|
+
* Carries the `/g` flag, so `.lastIndex` is stateful across calls to
|
|
22
|
+
* `.test()`/`.exec()` on this exact object — every internal use in this file
|
|
23
|
+
* either goes through `.replace()` (which resets `lastIndex` itself) or
|
|
24
|
+
* explicitly resets `lastIndex` before use (see `tokenize` below). A caller
|
|
25
|
+
* that runs `.test()`/`.exec()` on this constant directly, more than once,
|
|
26
|
+
* must reset `.lastIndex = 0` between calls or clone the pattern first.
|
|
27
|
+
*/
|
|
28
|
+
// eslint-disable-next-line no-control-regex -- intentionally matches the ESC (\x1b) and BEL (\x07) control characters that delimit ANSI/OSC-8 sequences
|
|
29
|
+
export const ESCAPE_SEQUENCE_RE = /\x1b\[[0-9;]*m|\x1b\]8;;[^\x07]*\x07/g;
|
|
30
|
+
|
|
31
|
+
const COMBINING_RANGES = [
|
|
32
|
+
[0x03_00, 0x03_6f],
|
|
33
|
+
[0x1a_b0, 0x1a_ff],
|
|
34
|
+
[0x1d_c0, 0x1d_ff],
|
|
35
|
+
[0x20_d0, 0x20_ff],
|
|
36
|
+
[0xfe_20, 0xfe_2f],
|
|
37
|
+
];
|
|
38
|
+
|
|
39
|
+
const PUA_RANGES = [
|
|
40
|
+
[0xe0_00, 0xf8_ff],
|
|
41
|
+
[0xf_00_00, 0xf_ff_fd],
|
|
42
|
+
[0x10_00_00, 0x10_ff_fd],
|
|
43
|
+
];
|
|
44
|
+
|
|
45
|
+
const WIDE_RANGES = [
|
|
46
|
+
[0x11_00, 0x11_5f],
|
|
47
|
+
[0x2e_80, 0x30_3e],
|
|
48
|
+
[0x30_41, 0x33_ff],
|
|
49
|
+
[0x34_00, 0x4d_bf],
|
|
50
|
+
[0x4e_00, 0x9f_ff],
|
|
51
|
+
[0xa0_00, 0xa4_cf],
|
|
52
|
+
[0xac_00, 0xd7_a3],
|
|
53
|
+
[0xf9_00, 0xfa_ff],
|
|
54
|
+
[0xff_00, 0xff_60],
|
|
55
|
+
[0xff_e0, 0xff_e6],
|
|
56
|
+
[0x2_00_00, 0x3_ff_fd],
|
|
57
|
+
];
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Codepoints a terminal draws two cells wide because the font renders them as
|
|
61
|
+
* an emoji, not a text glyph. Asked of the engine's own Unicode tables rather
|
|
62
|
+
* than a hand-kept range: the old blanket `U+2600-27BF` / `U+1F300-1FAFF` ranges
|
|
63
|
+
* were wrong in both directions (`⚠ U+26A0`, `✓ U+2713` and `➜ U+279C` are one
|
|
64
|
+
* cell, only the `Emoji_Presentation` subset of those blocks is two), and a
|
|
65
|
+
* table would go stale as Unicode adds emoji. A property escape is built into
|
|
66
|
+
* V8, so it costs no dependency. It does not cover East Asian Wide text, which
|
|
67
|
+
* is why `WIDE_RANGES` stays.
|
|
68
|
+
*/
|
|
69
|
+
const EMOJI_PRESENTATION_RE = /^\p{Emoji_Presentation}$/u;
|
|
70
|
+
|
|
71
|
+
/** Codepoints that *can* be drawn as an emoji when followed by U+FE0F. */
|
|
72
|
+
const EMOJI_CAPABLE_RE = /^\p{Emoji}$/u;
|
|
73
|
+
|
|
74
|
+
const ZERO_WIDTH_JOINER = 0x20_0d;
|
|
75
|
+
const VARIATION_SELECTOR_16 = 0xfe_0f;
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* @param {number} code a Unicode codepoint.
|
|
79
|
+
* @param {ReadonlyArray<readonly [number, number]>} ranges inclusive
|
|
80
|
+
* `[start, end]` pairs.
|
|
81
|
+
* @returns {boolean} whether `code` falls in any of `ranges`.
|
|
82
|
+
*/
|
|
83
|
+
function inRanges(code, ranges) {
|
|
84
|
+
return ranges.some(([start, end]) => code >= start && code <= end);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* The display width of a single codepoint on its own, per this module's East
|
|
89
|
+
* Asian/emoji/Nerd-Font/combining-mark rules. Sequence-dependent codepoints
|
|
90
|
+
* (a ZWJ, or a VS16 that widens the glyph before it) are resolved by
|
|
91
|
+
* {@link clusters}, not here.
|
|
92
|
+
*
|
|
93
|
+
* @param {string} cp a single codepoint (as produced by iterating a string
|
|
94
|
+
* with `[...str]`).
|
|
95
|
+
* @returns {0 | 1 | 2}
|
|
96
|
+
*/
|
|
97
|
+
function codepointWidth(cp) {
|
|
98
|
+
const code = cp.codePointAt(0) ?? 0;
|
|
99
|
+
if (
|
|
100
|
+
code === VARIATION_SELECTOR_16 ||
|
|
101
|
+
code === ZERO_WIDTH_JOINER ||
|
|
102
|
+
inRanges(code, COMBINING_RANGES)
|
|
103
|
+
) {
|
|
104
|
+
return 0;
|
|
105
|
+
}
|
|
106
|
+
if (inRanges(code, PUA_RANGES)) return 1;
|
|
107
|
+
if (EMOJI_PRESENTATION_RE.test(cp) || inRanges(code, WIDE_RANGES)) return 2;
|
|
108
|
+
return 1;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Splits escape-free text into terminal cells' worth of glyphs: one entry per
|
|
113
|
+
* visible glyph, with anything that only modifies the glyph before it folded
|
|
114
|
+
* into that entry. Folding matters twice over -- the width is right (a
|
|
115
|
+
* `👨💻` ZWJ sequence is two cells, not the four its three codepoints sum
|
|
116
|
+
* to; `⚠️` is two because U+FE0F asks for emoji presentation), and
|
|
117
|
+
* {@link truncateToWidth} can never cut a sequence in half.
|
|
118
|
+
*
|
|
119
|
+
* Not handled: a regional-indicator flag pair (`🇮🇹`) is two cells but
|
|
120
|
+
* measures four, and the width of an emoji sequence is taken from its first
|
|
121
|
+
* codepoint. Both only ever over-count, so a row drops a segment early rather
|
|
122
|
+
* than wrapping.
|
|
123
|
+
*
|
|
124
|
+
* @param {string} text text containing no ANSI/OSC-8 sequences.
|
|
125
|
+
* @returns {Array<{ raw: string, width: 0 | 1 | 2 }>}
|
|
126
|
+
*/
|
|
127
|
+
function clusters(text) {
|
|
128
|
+
/** @type {Array<{ raw: string, width: 0 | 1 | 2 }>} */
|
|
129
|
+
const out = [];
|
|
130
|
+
let afterJoiner = false;
|
|
131
|
+
for (const cp of text) {
|
|
132
|
+
const code = cp.codePointAt(0) ?? 0;
|
|
133
|
+
const previous = out.at(-1);
|
|
134
|
+
|
|
135
|
+
if (previous !== undefined && (afterJoiner || codepointWidth(cp) === 0)) {
|
|
136
|
+
previous.raw += cp;
|
|
137
|
+
if (
|
|
138
|
+
code === VARIATION_SELECTOR_16 &&
|
|
139
|
+
previous.width === 1 &&
|
|
140
|
+
EMOJI_CAPABLE_RE.test([...previous.raw][0] ?? "")
|
|
141
|
+
) {
|
|
142
|
+
previous.width = 2;
|
|
143
|
+
}
|
|
144
|
+
afterJoiner = code === ZERO_WIDTH_JOINER;
|
|
145
|
+
continue;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
out.push({ raw: cp, width: codepointWidth(cp) });
|
|
149
|
+
afterJoiner = code === ZERO_WIDTH_JOINER;
|
|
150
|
+
}
|
|
151
|
+
return out;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* The terminal-column width of `str` once ANSI/OSC-8 escape sequences are
|
|
156
|
+
* stripped, accounting for zero-width combining marks and joiners, emoji
|
|
157
|
+
* sequences, Nerd Font/PUA single-width glyphs, and East Asian Wide/emoji
|
|
158
|
+
* double-width codepoints.
|
|
159
|
+
*
|
|
160
|
+
* @param {string} str
|
|
161
|
+
* @returns {number}
|
|
162
|
+
*/
|
|
163
|
+
export function displayWidth(str) {
|
|
164
|
+
const stripped = str.replace(ESCAPE_SEQUENCE_RE, "");
|
|
165
|
+
let width = 0;
|
|
166
|
+
for (const cluster of clusters(stripped)) width += cluster.width;
|
|
167
|
+
return width;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* @typedef {{ type: "esc", raw: string }} EscToken
|
|
172
|
+
* @typedef {{ type: "char", raw: string, width: 0 | 1 | 2 }} CharToken
|
|
173
|
+
*/
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Tokenizes `str` into an ordered list of escape-sequence and visible-glyph
|
|
177
|
+
* tokens, preserving original order.
|
|
178
|
+
*
|
|
179
|
+
* @param {string} str
|
|
180
|
+
* @returns {Array<EscToken | CharToken>}
|
|
181
|
+
*/
|
|
182
|
+
function tokenize(str) {
|
|
183
|
+
/** @type {Array<EscToken | CharToken>} */
|
|
184
|
+
const tokens = [];
|
|
185
|
+
let cursor = 0;
|
|
186
|
+
ESCAPE_SEQUENCE_RE.lastIndex = 0;
|
|
187
|
+
let match = ESCAPE_SEQUENCE_RE.exec(str);
|
|
188
|
+
while (match !== null) {
|
|
189
|
+
if (match.index > cursor) {
|
|
190
|
+
for (const { raw, width } of clusters(str.slice(cursor, match.index))) {
|
|
191
|
+
tokens.push({ type: "char", raw, width });
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
tokens.push({ type: "esc", raw: match[0] });
|
|
195
|
+
cursor = match.index + match[0].length;
|
|
196
|
+
match = ESCAPE_SEQUENCE_RE.exec(str);
|
|
197
|
+
}
|
|
198
|
+
if (cursor < str.length) {
|
|
199
|
+
for (const { raw, width } of clusters(str.slice(cursor))) {
|
|
200
|
+
tokens.push({ type: "char", raw, width });
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
return tokens;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Whether `raw` is an SGR sequence that opens a color/style (anything other
|
|
208
|
+
* than the exact reset `\x1b[0m`).
|
|
209
|
+
*
|
|
210
|
+
* @param {string} raw
|
|
211
|
+
* @returns {boolean}
|
|
212
|
+
*/
|
|
213
|
+
function isColorOpen(raw) {
|
|
214
|
+
return raw.startsWith("\x1b[") && raw !== "\x1b[0m";
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Truncates `str` to fit within `maxWidth` display columns, preserving ANSI
|
|
219
|
+
* color/OSC-8 sequences and never cutting mid-codepoint or mid-escape. If a
|
|
220
|
+
* color was left open by the truncation point, appends a reset so the
|
|
221
|
+
* cut-off segment can't bleed color into whatever follows it.
|
|
222
|
+
*
|
|
223
|
+
* @param {string} str
|
|
224
|
+
* @param {number} maxWidth
|
|
225
|
+
* @param {string} [ellipsis]
|
|
226
|
+
* @returns {string}
|
|
227
|
+
*/
|
|
228
|
+
export function truncateToWidth(str, maxWidth, ellipsis = "…") {
|
|
229
|
+
if (maxWidth <= 0) return "";
|
|
230
|
+
if (displayWidth(str) <= maxWidth) return str;
|
|
231
|
+
|
|
232
|
+
const tokens = tokenize(str);
|
|
233
|
+
const ellipsisWidth = displayWidth(ellipsis);
|
|
234
|
+
const limit = maxWidth - ellipsisWidth;
|
|
235
|
+
|
|
236
|
+
let accumulated = 0;
|
|
237
|
+
let colorOpen = false;
|
|
238
|
+
const kept = [];
|
|
239
|
+
for (const token of tokens) {
|
|
240
|
+
if (token.type === "esc") {
|
|
241
|
+
kept.push(token.raw);
|
|
242
|
+
colorOpen =
|
|
243
|
+
token.raw === "\x1b[0m" ? false : colorOpen || isColorOpen(token.raw);
|
|
244
|
+
continue;
|
|
245
|
+
}
|
|
246
|
+
if (accumulated + token.width > limit) break;
|
|
247
|
+
accumulated += token.width;
|
|
248
|
+
kept.push(token.raw);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
return kept.join("") + ellipsis + (colorOpen ? "\x1b[0m" : "");
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* @typedef {{ id: string, priority: number, text: string, minWidth: number }} RowSegment
|
|
256
|
+
*/
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* @param {ReadonlyArray<RowSegment>} segments original-order segment list.
|
|
260
|
+
* @param {Set<string>} kept ids currently retained.
|
|
261
|
+
* @param {string} separator
|
|
262
|
+
* @returns {number}
|
|
263
|
+
*/
|
|
264
|
+
function currentWidth(segments, kept, separator) {
|
|
265
|
+
const texts = segments.filter((s) => kept.has(s.id)).map((s) => s.text);
|
|
266
|
+
return displayWidth(texts.join(separator));
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* Fits `segments` into `budget` display columns, dropping the
|
|
271
|
+
* lowest-priority segment (right-side ties dropped first) until the joined
|
|
272
|
+
* width fits, then truncating a sole surviving over-budget segment as a last
|
|
273
|
+
* resort. Segments are always joined and returned in their original array
|
|
274
|
+
* order, never priority order. Pure: never mutates the input `segments`.
|
|
275
|
+
*
|
|
276
|
+
* The sole-survivor truncation floors at `Math.max(segment.minWidth, budget)`
|
|
277
|
+
* — a segment is never cut below its own `minWidth`, even when `budget` is
|
|
278
|
+
* smaller. This is a deliberate floor, not a bug: at a pathologically narrow
|
|
279
|
+
* `COLUMNS` (below a segment's `minWidth`, e.g. under ~14 once the fixed
|
|
280
|
+
* 10-column gutter is subtracted) the returned row can still exceed `budget`
|
|
281
|
+
* by a few columns. An unreadably-truncated segment is worse than a row a
|
|
282
|
+
* few columns over budget at a terminal width no real usage reaches.
|
|
283
|
+
*
|
|
284
|
+
* @param {ReadonlyArray<RowSegment>} segments
|
|
285
|
+
* @param {number} budget
|
|
286
|
+
* @param {string} separator
|
|
287
|
+
* @returns {string}
|
|
288
|
+
*/
|
|
289
|
+
export function fitRow(segments, budget, separator) {
|
|
290
|
+
const kept = new Set(segments.map((s) => s.id));
|
|
291
|
+
|
|
292
|
+
while (kept.size > 1 && currentWidth(segments, kept, separator) > budget) {
|
|
293
|
+
let dropId = null;
|
|
294
|
+
let dropPriority = Number.POSITIVE_INFINITY;
|
|
295
|
+
for (const s of segments) {
|
|
296
|
+
if (!kept.has(s.id)) continue;
|
|
297
|
+
if (s.priority <= dropPriority) {
|
|
298
|
+
dropPriority = s.priority;
|
|
299
|
+
dropId = s.id;
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
if (dropId === null) break;
|
|
303
|
+
kept.delete(dropId);
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
const survivors = segments.filter((s) => kept.has(s.id));
|
|
307
|
+
|
|
308
|
+
if (survivors.length === 1 && displayWidth(survivors[0].text) > budget) {
|
|
309
|
+
const [sole] = survivors;
|
|
310
|
+
return truncateToWidth(sole.text, Math.max(sole.minWidth, budget));
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
return survivors.map((s) => s.text).join(separator);
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* Reads the terminal width a statusLine command was launched with. Per
|
|
318
|
+
* Anthropic's statusLine docs, `COLUMNS`/`LINES` are the only reliable
|
|
319
|
+
* source inside a statusLine subprocess — `tput cols` does not work there.
|
|
320
|
+
*
|
|
321
|
+
* @param {{ COLUMNS?: unknown } | undefined} env
|
|
322
|
+
* @returns {number} the parsed column count, or `80` when absent/invalid.
|
|
323
|
+
*/
|
|
324
|
+
export function terminalColumns(env) {
|
|
325
|
+
const n = Number.parseInt(String(env?.COLUMNS ?? ""), 10);
|
|
326
|
+
return Number.isFinite(n) && n > 0 ? n : 80;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
export const GREEN = "\x1b[32m";
|
|
330
|
+
export const YELLOW = "\x1b[33m";
|
|
331
|
+
export const RED = "\x1b[31m";
|
|
332
|
+
export const CYAN = "\x1b[36m";
|
|
333
|
+
export const BLUE = "\x1b[34m";
|
|
334
|
+
export const MAGENTA = "\x1b[35m";
|
|
335
|
+
export const DIM = "\x1b[2m";
|
|
336
|
+
export const RESET = "\x1b[0m";
|
|
337
|
+
export const SEGMENT_SEPARATOR = `${DIM} · ${RESET}`;
|
|
338
|
+
export const PLACEHOLDER = `${DIM}—${RESET}`;
|
|
339
|
+
export const GUTTER_WIDTH = 10;
|
|
340
|
+
|
|
341
|
+
/**
|
|
342
|
+
* Compact token-count formatter (`45000` -> `"45k"`, `15500` -> `"15.5k"`).
|
|
343
|
+
*
|
|
344
|
+
* @param {number} n always finite when called.
|
|
345
|
+
* @returns {string}
|
|
346
|
+
*/
|
|
347
|
+
export function formatTokenCount(n) {
|
|
348
|
+
if (Math.abs(n) < 1000) return String(Math.round(n));
|
|
349
|
+
const kk = n / 1000;
|
|
350
|
+
const rounded = Math.round(kk * 10) / 10;
|
|
351
|
+
return Number.isInteger(rounded)
|
|
352
|
+
? `${rounded.toFixed(0)}k`
|
|
353
|
+
: `${rounded.toFixed(1)}k`;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/**
|
|
357
|
+
* @param {number} deltaSec seconds remaining, may be negative/zero.
|
|
358
|
+
* @returns {string} `"now"`, `"NNm"`, or `"NhMMm"`.
|
|
359
|
+
*/
|
|
360
|
+
export function formatDuration(deltaSec) {
|
|
361
|
+
if (deltaSec <= 0) return "now";
|
|
362
|
+
const h = Math.floor(deltaSec / 3600);
|
|
363
|
+
const m = Math.floor((deltaSec % 3600) / 60);
|
|
364
|
+
return h > 0 ? `${h}h${String(m).padStart(2, "0")}m` : `${m}m`;
|
|
365
|
+
}
|