@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,996 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* statusLine: renders a fixed five-row layout -- session, model, context,
|
|
4
|
+
* quota, work -- built entirely from the JSON Claude Code pipes to stdin
|
|
5
|
+
* (code.claude.com/docs/en/statusline). The five-row guarantee is
|
|
6
|
+
* `renderStatusLine`'s own contract on the success path only: the CLI entry's
|
|
7
|
+
* `catch` (bottom of this file) falls back to a single minimal `ctx --%` line
|
|
8
|
+
* on a JSON-parse failure, by design -- the fallback must never itself risk
|
|
9
|
+
* throwing, so it does not attempt to build five gutter+placeholder rows.
|
|
10
|
+
*
|
|
11
|
+
* Each row is width-fit against the real terminal width via
|
|
12
|
+
* `statusline-layout.mjs`'s `fitRow`/`terminalColumns`/`displayWidth`: Anthropic's
|
|
13
|
+
* docs state that `COLUMNS` must be read from the environment (`tput cols`
|
|
14
|
+
* does not work inside a statusLine subprocess), and that reading is what lets
|
|
15
|
+
* a narrow terminal drop its lowest-priority segments instead of wrapping
|
|
16
|
+
* mid-line.
|
|
17
|
+
*
|
|
18
|
+
* `statusLine` is the one documented surface exposing live
|
|
19
|
+
* `context_window.used_percentage` -- no hook event receives token/context
|
|
20
|
+
* data -- so this is the one place a "when to compact" signal can live.
|
|
21
|
+
*
|
|
22
|
+
* Invariant: **no subprocess, no network.** The script runs on every new
|
|
23
|
+
* assistant message (debounced 300ms; a new trigger cancels an in-flight
|
|
24
|
+
* run), so a spawn here would be the most frequently paid cost in the whole
|
|
25
|
+
* harness. Git state is read straight from `.git/HEAD` via `node:fs` -- a
|
|
26
|
+
* local synchronous file read, not a `git` shell-out -- and memory from
|
|
27
|
+
* `process.availableMemory()`/`os.totalmem()`, local syscalls (see
|
|
28
|
+
* {@link resolveMemory} for why not `os.freemem()`). Every other field
|
|
29
|
+
* (`context_window.*`, `workspace.*`, `model`, `effort`, `cost`,
|
|
30
|
+
* `rate_limits`, `prompt_cache`, `agent`, ...) already arrives on stdin.
|
|
31
|
+
*
|
|
32
|
+
* Threshold values (70 / 90) match Anthropic's own documented multi-line
|
|
33
|
+
* status-line example (green under 70, yellow 70-89, red 90+) rather than
|
|
34
|
+
* inventing project-specific numbers.
|
|
35
|
+
*
|
|
36
|
+
* Advisory-only: any parse or read failure falls back to a minimal
|
|
37
|
+
* `ctx --%` segment rather than an empty or broken status line.
|
|
38
|
+
*/
|
|
39
|
+
import { readFileSync, realpathSync } from "node:fs";
|
|
40
|
+
import os from "node:os";
|
|
41
|
+
import { dirname, isAbsolute, join } from "node:path";
|
|
42
|
+
import process from "node:process";
|
|
43
|
+
import { fileURLToPath } from "node:url";
|
|
44
|
+
import {
|
|
45
|
+
BLUE,
|
|
46
|
+
CYAN,
|
|
47
|
+
DIM,
|
|
48
|
+
GREEN,
|
|
49
|
+
GUTTER_WIDTH,
|
|
50
|
+
MAGENTA,
|
|
51
|
+
PLACEHOLDER,
|
|
52
|
+
RED,
|
|
53
|
+
RESET,
|
|
54
|
+
SEGMENT_SEPARATOR,
|
|
55
|
+
YELLOW,
|
|
56
|
+
displayWidth,
|
|
57
|
+
fitRow,
|
|
58
|
+
formatDuration,
|
|
59
|
+
formatTokenCount,
|
|
60
|
+
terminalColumns,
|
|
61
|
+
} from "./statusline-layout.mjs";
|
|
62
|
+
|
|
63
|
+
export const WARN_THRESHOLD_PERCENT = 70;
|
|
64
|
+
export const HIGH_THRESHOLD_PERCENT = 90;
|
|
65
|
+
export const CONTEXT_BAR_WIDTH = 20;
|
|
66
|
+
export const QUOTA_BAR_WIDTH = 10;
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* @typedef {{ id: string, priority: number, text: string, minWidth: number }} RowSegment
|
|
70
|
+
*/
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Safe nested read: returns `undefined` as soon as any step along `path` is
|
|
74
|
+
* not an object, so every segment below can probe a payload field without a
|
|
75
|
+
* wall of `typeof` checks.
|
|
76
|
+
*
|
|
77
|
+
* @param {unknown} value
|
|
78
|
+
* @param {...string} path
|
|
79
|
+
* @returns {unknown}
|
|
80
|
+
*/
|
|
81
|
+
function pick(value, ...path) {
|
|
82
|
+
let current = value;
|
|
83
|
+
for (const key of path) {
|
|
84
|
+
if (typeof current !== "object" || current === null) return undefined;
|
|
85
|
+
current = /** @type {Record<string, unknown>} */ (current)[key];
|
|
86
|
+
}
|
|
87
|
+
return current;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* @param {unknown} value
|
|
92
|
+
* @returns {value is number}
|
|
93
|
+
*/
|
|
94
|
+
function isFiniteNumber(value) {
|
|
95
|
+
return typeof value === "number" && Number.isFinite(value);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* @param {unknown} value
|
|
100
|
+
* @returns {value is string}
|
|
101
|
+
*/
|
|
102
|
+
function isNonEmptyString(value) {
|
|
103
|
+
return typeof value === "string" && value.length > 0;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Strips C0/C1 control characters and DEL from a payload string before it is
|
|
108
|
+
* rendered. Branch names, session names, and repo names are user- or
|
|
109
|
+
* remote-controlled text; an embedded escape sequence must not be able to
|
|
110
|
+
* repaint the terminal or forge a segment of its own.
|
|
111
|
+
*
|
|
112
|
+
* @param {string} text
|
|
113
|
+
* @returns {string}
|
|
114
|
+
*/
|
|
115
|
+
export function sanitizeDisplayText(text) {
|
|
116
|
+
const controlChars = new RegExp(
|
|
117
|
+
"[" +
|
|
118
|
+
String.fromCharCode(0) +
|
|
119
|
+
"-" +
|
|
120
|
+
String.fromCharCode(31) +
|
|
121
|
+
String.fromCharCode(127) +
|
|
122
|
+
"-" +
|
|
123
|
+
String.fromCharCode(159) +
|
|
124
|
+
"]",
|
|
125
|
+
"g",
|
|
126
|
+
);
|
|
127
|
+
return text.replace(controlChars, "");
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* @param {unknown} payload the parsed statusLine stdin JSON
|
|
132
|
+
* @returns {number | null} `context_window.used_percentage`, rounded and
|
|
133
|
+
* clamped to `[0, 100]`, or null when the session has no context-window
|
|
134
|
+
* data yet (before the first API response, or immediately after
|
|
135
|
+
* `/compact`).
|
|
136
|
+
*/
|
|
137
|
+
export function resolveUsedPercentage(payload) {
|
|
138
|
+
const pct = pick(payload, "context_window", "used_percentage");
|
|
139
|
+
return isFiniteNumber(pct)
|
|
140
|
+
? Math.min(100, Math.max(0, Math.round(pct)))
|
|
141
|
+
: null;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* @param {number | null} pct
|
|
146
|
+
* @returns {"unknown" | "ok" | "warn" | "high"}
|
|
147
|
+
*/
|
|
148
|
+
export function zoneForPercentage(pct) {
|
|
149
|
+
if (pct === null) return "unknown";
|
|
150
|
+
if (pct >= HIGH_THRESHOLD_PERCENT) return "high";
|
|
151
|
+
if (pct >= WARN_THRESHOLD_PERCENT) return "warn";
|
|
152
|
+
return "ok";
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* @param {"unknown" | "ok" | "warn" | "high"} zone
|
|
157
|
+
* @returns {string} the ANSI color for a usage zone.
|
|
158
|
+
*/
|
|
159
|
+
function zoneColor(zone) {
|
|
160
|
+
if (zone === "high") return RED;
|
|
161
|
+
if (zone === "warn") return YELLOW;
|
|
162
|
+
return GREEN;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* @param {number} pct clamped to `[0, 100]`.
|
|
167
|
+
* @param {number} width
|
|
168
|
+
* @returns {string} a `█`/`░` bar `width` cells wide.
|
|
169
|
+
*/
|
|
170
|
+
function renderBar(pct, width) {
|
|
171
|
+
const filled = Math.min(width, Math.max(0, Math.round((pct / 100) * width)));
|
|
172
|
+
return `${"█".repeat(filled)}${"░".repeat(width - filled)}`;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* @param {unknown} headContent raw `.git/HEAD` file content.
|
|
177
|
+
* @returns {string | null} the branch name, or null for detached HEAD / a
|
|
178
|
+
* raw SHA / garbage.
|
|
179
|
+
*/
|
|
180
|
+
export function parseHeadRef(headContent) {
|
|
181
|
+
if (typeof headContent !== "string") return null;
|
|
182
|
+
const match = /^ref:\s*refs\/heads\/(.+)$/.exec(headContent.trim());
|
|
183
|
+
return match ? match[1].trim() : null;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* @param {unknown} headContent raw `.git/HEAD` file content.
|
|
188
|
+
* @returns {string | null} the abbreviated commit id when HEAD is detached (a
|
|
189
|
+
* rebase, a bisect, `git checkout <sha>`) -- a bare SHA-1 or SHA-256 hex id
|
|
190
|
+
* where a branch would be a `ref:` line -- or null. Kept apart from
|
|
191
|
+
* {@link parseHeadRef} so a commit id can never be mistaken for a branch name.
|
|
192
|
+
*/
|
|
193
|
+
export function parseDetachedHead(headContent) {
|
|
194
|
+
if (typeof headContent !== "string") return null;
|
|
195
|
+
const sha = headContent.trim();
|
|
196
|
+
return /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/i.test(sha) ? sha.slice(0, 7) : null;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* @param {unknown} content raw `.git` file content (linked worktree /
|
|
201
|
+
* submodule case).
|
|
202
|
+
* @returns {string | null} the pointed-to gitdir path, or null.
|
|
203
|
+
*/
|
|
204
|
+
export function parseGitdirPointer(content) {
|
|
205
|
+
if (typeof content !== "string") return null;
|
|
206
|
+
const match = /^gitdir:\s*(.+)$/m.exec(content);
|
|
207
|
+
return match ? match[1].trim() : null;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Walks upward from `startDir` looking for `.git` (a directory, the normal
|
|
212
|
+
* case, or a file pointing at the real gitdir, the linked-worktree /
|
|
213
|
+
* submodule case) and returns the directory that holds it -- the resolved
|
|
214
|
+
* workspace root. Pure with respect to actual disk I/O -- all reads go
|
|
215
|
+
* through the injected `readFile` -- so this is directly unit-testable
|
|
216
|
+
* without touching a real filesystem.
|
|
217
|
+
*
|
|
218
|
+
* `payload.workspace.current_dir` is not always the repo root: it diverges the
|
|
219
|
+
* moment a session `cd`s into a subdirectory or enters a worktree
|
|
220
|
+
* in-session, so the branch is resolved by walking up rather than trusting
|
|
221
|
+
* `startDir` verbatim.
|
|
222
|
+
*
|
|
223
|
+
* @param {(path: string) => string | null} readFile injected file reader;
|
|
224
|
+
* returns the file content or null when unreadable/absent.
|
|
225
|
+
* @param {unknown} startDir directory to start the upward walk from; a
|
|
226
|
+
* non-string or empty value returns null rather than throwing.
|
|
227
|
+
* @returns {string | null} the resolved workspace root, or null when no
|
|
228
|
+
* `.git` is found within the walk bound.
|
|
229
|
+
*/
|
|
230
|
+
export function resolveWorkspaceRoot(readFile, startDir) {
|
|
231
|
+
if (typeof startDir !== "string" || startDir.length === 0) return null;
|
|
232
|
+
|
|
233
|
+
let dir = startDir;
|
|
234
|
+
for (let i = 0; i < 40; i++) {
|
|
235
|
+
if (typeof readFile(join(dir, ".git", "HEAD")) === "string") return dir;
|
|
236
|
+
if (typeof readFile(join(dir, ".git")) === "string") return dir;
|
|
237
|
+
|
|
238
|
+
const parent = dirname(dir);
|
|
239
|
+
if (parent === dir) return null;
|
|
240
|
+
dir = parent;
|
|
241
|
+
}
|
|
242
|
+
return null;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Reads the raw `HEAD` file from the workspace root {@link resolveWorkspaceRoot}
|
|
247
|
+
* finds by walking upward from `startDir` -- handling both a `.git` directory
|
|
248
|
+
* (the normal case) and a `.git` file pointing at the real gitdir (the
|
|
249
|
+
* linked-worktree / submodule case).
|
|
250
|
+
*
|
|
251
|
+
* @param {(path: string) => string | null} readFile injected file reader;
|
|
252
|
+
* returns the file content or null when unreadable/absent.
|
|
253
|
+
* @param {unknown} startDir directory to start the upward walk from; a
|
|
254
|
+
* non-string or empty value returns null rather than throwing.
|
|
255
|
+
* @returns {string | null} the `HEAD` file's content, or null when it can't
|
|
256
|
+
* be resolved.
|
|
257
|
+
*/
|
|
258
|
+
export function readHead(readFile, startDir) {
|
|
259
|
+
const dir = resolveWorkspaceRoot(readFile, startDir);
|
|
260
|
+
if (dir === null) return null;
|
|
261
|
+
|
|
262
|
+
const headContent = readFile(join(dir, ".git", "HEAD"));
|
|
263
|
+
if (typeof headContent === "string") return headContent;
|
|
264
|
+
|
|
265
|
+
const gitEntry = readFile(join(dir, ".git"));
|
|
266
|
+
if (typeof gitEntry === "string") {
|
|
267
|
+
const pointer = parseGitdirPointer(gitEntry);
|
|
268
|
+
if (pointer === null || pointer.length === 0) return null;
|
|
269
|
+
const resolvedGitDir = isAbsolute(pointer) ? pointer : join(dir, pointer);
|
|
270
|
+
const linkedHead = readFile(join(resolvedGitDir, "HEAD"));
|
|
271
|
+
return typeof linkedHead === "string" ? linkedHead : null;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
return null;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* Builds a `{ id, priority, text, minWidth }` row segment, or `null` when
|
|
279
|
+
* `text` is absent -- the shared shape every `format*Segment` function below
|
|
280
|
+
* returns so `fitRow` can budget/drop them uniformly. Higher `priority`
|
|
281
|
+
* survives longer as a row narrows.
|
|
282
|
+
*
|
|
283
|
+
* @param {string} id
|
|
284
|
+
* @param {number} priority
|
|
285
|
+
* @param {string | null | undefined} text
|
|
286
|
+
* @param {number} minWidth
|
|
287
|
+
* @returns {RowSegment | null}
|
|
288
|
+
*/
|
|
289
|
+
function seg(id, priority, text, minWidth) {
|
|
290
|
+
return text === null || text === undefined
|
|
291
|
+
? null
|
|
292
|
+
: { id, priority, text, minWidth };
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* @param {string} label
|
|
297
|
+
* @returns {string} the dim, fixed-width row label.
|
|
298
|
+
*/
|
|
299
|
+
function gutter(label) {
|
|
300
|
+
return `${DIM}${label.padEnd(GUTTER_WIDTH)}${RESET}`;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* @param {string} label
|
|
305
|
+
* @param {ReadonlyArray<RowSegment | null>} segments
|
|
306
|
+
* @param {number} columns
|
|
307
|
+
* @returns {string} one rendered row: a fixed gutter label plus the
|
|
308
|
+
* width-fit, separator-joined segments -- `PLACEHOLDER` when every segment
|
|
309
|
+
* is absent, so every row always renders exactly one non-empty line.
|
|
310
|
+
*/
|
|
311
|
+
function buildRow(label, segments, columns) {
|
|
312
|
+
const g = gutter(label);
|
|
313
|
+
const nonEmpty = /** @type {RowSegment[]} */ (
|
|
314
|
+
segments.filter((s) => s !== null)
|
|
315
|
+
);
|
|
316
|
+
if (nonEmpty.length === 0) return `${g}${PLACEHOLDER}`;
|
|
317
|
+
const budget = columns - displayWidth(g);
|
|
318
|
+
return `${g}${fitRow(nonEmpty, budget, SEGMENT_SEPARATOR)}`;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* @param {unknown} payload
|
|
323
|
+
* @returns {RowSegment | null} dim `↳ agent-name` segment, or null when absent.
|
|
324
|
+
*/
|
|
325
|
+
export function formatAgentSegment(payload) {
|
|
326
|
+
const name = pick(payload, "agent", "name");
|
|
327
|
+
if (!isNonEmptyString(name)) return null;
|
|
328
|
+
return seg("agent", 55, `${DIM}↳ ${sanitizeDisplayText(name)}${RESET}`, 6);
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* Always renders, unlike most segments here: a session with no name is itself
|
|
333
|
+
* worth seeing, and `payload.session_name` is absent until a `--name`,
|
|
334
|
+
* `/rename`, or an AI-generated title exists.
|
|
335
|
+
*
|
|
336
|
+
* @param {unknown} payload
|
|
337
|
+
* @returns {RowSegment} the session name, or a dim `unnamed` marker -- never
|
|
338
|
+
* null.
|
|
339
|
+
*/
|
|
340
|
+
export function formatSessionNameSegment(payload) {
|
|
341
|
+
const name = pick(payload, "session_name");
|
|
342
|
+
const text = isNonEmptyString(name)
|
|
343
|
+
? sanitizeDisplayText(name)
|
|
344
|
+
: `${DIM}unnamed${RESET}`;
|
|
345
|
+
return /** @type {RowSegment} */ (seg("session_name", 100, text, 12));
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/**
|
|
349
|
+
* @param {string | null} branchName the resolved branch, or null.
|
|
350
|
+
* @param {string | null} [detachedSha] the abbreviated commit id when HEAD is
|
|
351
|
+
* detached; only shown when there is no branch, so a rebase or bisect keeps
|
|
352
|
+
* its git segment instead of silently losing it.
|
|
353
|
+
* @returns {RowSegment | null} the branch segment. `main` is flagged as a
|
|
354
|
+
* warning: the baseline's workflow is feature branches and PRs, never work
|
|
355
|
+
* directly on `main`.
|
|
356
|
+
*/
|
|
357
|
+
export function formatBranchSegment(branchName, detachedSha = null) {
|
|
358
|
+
if (!isNonEmptyString(branchName)) {
|
|
359
|
+
if (!isNonEmptyString(detachedSha)) return null;
|
|
360
|
+
return seg(
|
|
361
|
+
"branch",
|
|
362
|
+
95,
|
|
363
|
+
`${YELLOW}detached @ ${sanitizeDisplayText(detachedSha)}${RESET}`,
|
|
364
|
+
8,
|
|
365
|
+
);
|
|
366
|
+
}
|
|
367
|
+
const text =
|
|
368
|
+
branchName === "main"
|
|
369
|
+
? `${RED}⚠ main${RESET}`
|
|
370
|
+
: `${BLUE}🌿 ${sanitizeDisplayText(branchName)}${RESET}`;
|
|
371
|
+
return seg("branch", 95, text, 6);
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/**
|
|
375
|
+
* @param {unknown} payload
|
|
376
|
+
* @returns {RowSegment | null} the `🌳 name` segment, or null when
|
|
377
|
+
* `workspace.git_worktree` is absent/empty.
|
|
378
|
+
*/
|
|
379
|
+
export function formatWorktreeSegment(payload) {
|
|
380
|
+
const name = pick(payload, "workspace", "git_worktree");
|
|
381
|
+
if (!isNonEmptyString(name)) return null;
|
|
382
|
+
return seg(
|
|
383
|
+
"worktree",
|
|
384
|
+
85,
|
|
385
|
+
`${BLUE}🌳 ${sanitizeDisplayText(name)}${RESET}`,
|
|
386
|
+
6,
|
|
387
|
+
);
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/**
|
|
391
|
+
* @param {unknown} payload
|
|
392
|
+
* @returns {RowSegment | null} dim `owner/name` for the `origin` remote, with
|
|
393
|
+
* the host prefixed unless it is github.com; null outside a git repository
|
|
394
|
+
* or when no `origin` remote is configured.
|
|
395
|
+
*/
|
|
396
|
+
export function formatOriginRepoSegment(payload) {
|
|
397
|
+
const host = pick(payload, "workspace", "repo", "host");
|
|
398
|
+
const owner = pick(payload, "workspace", "repo", "owner");
|
|
399
|
+
const name = pick(payload, "workspace", "repo", "name");
|
|
400
|
+
if (!isNonEmptyString(owner) || !isNonEmptyString(name)) return null;
|
|
401
|
+
const prefix =
|
|
402
|
+
isNonEmptyString(host) && host !== "github.com" ? `${host}/` : "";
|
|
403
|
+
return seg(
|
|
404
|
+
"origin_repo",
|
|
405
|
+
40,
|
|
406
|
+
`${DIM}${sanitizeDisplayText(`${prefix}${owner}/${name}`)}${RESET}`,
|
|
407
|
+
10,
|
|
408
|
+
);
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
/**
|
|
412
|
+
* @param {unknown} payload
|
|
413
|
+
* @returns {RowSegment | null} the model's display name (falling back to its
|
|
414
|
+
* id), or null when neither is present.
|
|
415
|
+
*/
|
|
416
|
+
export function formatModelSegment(payload) {
|
|
417
|
+
const display = pick(payload, "model", "display_name");
|
|
418
|
+
const id = pick(payload, "model", "id");
|
|
419
|
+
const name = isNonEmptyString(display) ? display : id;
|
|
420
|
+
if (!isNonEmptyString(name)) return null;
|
|
421
|
+
return seg("model", 100, `${CYAN}${sanitizeDisplayText(name)}${RESET}`, 6);
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
/**
|
|
425
|
+
* @param {unknown} payload
|
|
426
|
+
* @returns {RowSegment | null} `effort.level`, or null when the current model
|
|
427
|
+
* doesn't support the effort parameter.
|
|
428
|
+
*/
|
|
429
|
+
export function formatEffortSegment(payload) {
|
|
430
|
+
const level = pick(payload, "effort", "level");
|
|
431
|
+
if (!isNonEmptyString(level)) return null;
|
|
432
|
+
return seg(
|
|
433
|
+
"effort",
|
|
434
|
+
80,
|
|
435
|
+
`${DIM}effort${RESET} ${MAGENTA}${sanitizeDisplayText(level)}${RESET}`,
|
|
436
|
+
8,
|
|
437
|
+
);
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
/**
|
|
441
|
+
* @param {unknown} payload
|
|
442
|
+
* @returns {RowSegment | null} a `thinking` marker when extended thinking is
|
|
443
|
+
* enabled; hidden when it is off, since off is the uninteresting case.
|
|
444
|
+
*/
|
|
445
|
+
export function formatThinkingSegment(payload) {
|
|
446
|
+
return pick(payload, "thinking", "enabled") === true
|
|
447
|
+
? seg("thinking", 60, `${MAGENTA}thinking${RESET}`, 8)
|
|
448
|
+
: null;
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
/**
|
|
452
|
+
* @param {unknown} payload
|
|
453
|
+
* @returns {RowSegment | null} a `⚡ fast` marker when fast mode is on.
|
|
454
|
+
*/
|
|
455
|
+
export function formatFastModeSegment(payload) {
|
|
456
|
+
return pick(payload, "fast_mode") === true
|
|
457
|
+
? seg("fast_mode", 70, `${YELLOW}⚡ fast${RESET}`, 6)
|
|
458
|
+
: null;
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
/**
|
|
462
|
+
* @param {unknown} payload
|
|
463
|
+
* @returns {RowSegment | null} the output style's name, hidden for `default`.
|
|
464
|
+
*/
|
|
465
|
+
export function formatOutputStyleSegment(payload) {
|
|
466
|
+
const name = pick(payload, "output_style", "name");
|
|
467
|
+
if (!isNonEmptyString(name) || name === "default") return null;
|
|
468
|
+
return seg(
|
|
469
|
+
"output_style",
|
|
470
|
+
40,
|
|
471
|
+
`${DIM}style ${sanitizeDisplayText(name)}${RESET}`,
|
|
472
|
+
8,
|
|
473
|
+
);
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
/**
|
|
477
|
+
* @param {unknown} payload
|
|
478
|
+
* @returns {RowSegment | null} the vim mode, or null when vim mode is off.
|
|
479
|
+
*/
|
|
480
|
+
export function formatVimModeSegment(payload) {
|
|
481
|
+
const mode = pick(payload, "vim", "mode");
|
|
482
|
+
if (!isNonEmptyString(mode)) return null;
|
|
483
|
+
return seg("vim_mode", 50, `${CYAN}${sanitizeDisplayText(mode)}${RESET}`, 6);
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
/**
|
|
487
|
+
* @param {unknown} payload
|
|
488
|
+
* @returns {RowSegment | null} the context-window usage bar, colored by
|
|
489
|
+
* zone; null when the session has no context data yet.
|
|
490
|
+
*/
|
|
491
|
+
export function formatContextBarSegment(payload) {
|
|
492
|
+
const pct = resolveUsedPercentage(payload);
|
|
493
|
+
if (pct === null) return null;
|
|
494
|
+
const color = zoneColor(zoneForPercentage(pct));
|
|
495
|
+
return seg(
|
|
496
|
+
"context_bar",
|
|
497
|
+
50,
|
|
498
|
+
`${color}${renderBar(pct, CONTEXT_BAR_WIDTH)}${RESET}`,
|
|
499
|
+
10,
|
|
500
|
+
);
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
/**
|
|
504
|
+
* Always renders: a dim `--%` stands in before the first API response and
|
|
505
|
+
* right after `/compact`, when `used_percentage` is `null`.
|
|
506
|
+
*
|
|
507
|
+
* @param {unknown} payload
|
|
508
|
+
* @returns {RowSegment}
|
|
509
|
+
*/
|
|
510
|
+
export function formatContextPercentSegment(payload) {
|
|
511
|
+
const pct = resolveUsedPercentage(payload);
|
|
512
|
+
const text =
|
|
513
|
+
pct === null
|
|
514
|
+
? `${DIM}--%${RESET}`
|
|
515
|
+
: `${zoneColor(zoneForPercentage(pct))}${pct}%${RESET}`;
|
|
516
|
+
return /** @type {RowSegment} */ (seg("context_pct", 100, text, 3));
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
/**
|
|
520
|
+
* @param {unknown} payload
|
|
521
|
+
* @returns {RowSegment | null} `45k/200k`: tokens in the window over the
|
|
522
|
+
* window's size; null when either is absent or non-positive.
|
|
523
|
+
*/
|
|
524
|
+
export function formatContextDenominatorSegment(payload) {
|
|
525
|
+
const numerator = pick(payload, "context_window", "total_input_tokens");
|
|
526
|
+
const denominator = pick(payload, "context_window", "context_window_size");
|
|
527
|
+
if (
|
|
528
|
+
!isFiniteNumber(numerator) ||
|
|
529
|
+
numerator <= 0 ||
|
|
530
|
+
!isFiniteNumber(denominator) ||
|
|
531
|
+
denominator <= 0
|
|
532
|
+
) {
|
|
533
|
+
return null;
|
|
534
|
+
}
|
|
535
|
+
const text = `${formatTokenCount(numerator)}/${formatTokenCount(denominator)}`;
|
|
536
|
+
return seg("context_denom", 80, text, 10);
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
/**
|
|
540
|
+
* @param {"unknown" | "ok" | "warn" | "high"} zone
|
|
541
|
+
* @returns {string | null} a `/compact` nudge once the window is filling up,
|
|
542
|
+
* null while there is plenty of room. This is the "when to compact" signal;
|
|
543
|
+
* what survives a compaction is a separate concern.
|
|
544
|
+
*/
|
|
545
|
+
export function buildCompactSuggestion(zone) {
|
|
546
|
+
if (zone === "high") return "/compact now";
|
|
547
|
+
if (zone === "warn") return "/compact soon";
|
|
548
|
+
return null;
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
/**
|
|
552
|
+
* @param {unknown} payload
|
|
553
|
+
* @returns {RowSegment | null} `~155k left`, plus a compact suggestion once
|
|
554
|
+
* the window passes the warn threshold; null when the remaining share or
|
|
555
|
+
* window size is absent.
|
|
556
|
+
*/
|
|
557
|
+
export function formatContextHeadroomSegment(payload) {
|
|
558
|
+
const remainingPct = pick(payload, "context_window", "remaining_percentage");
|
|
559
|
+
const windowSize = pick(payload, "context_window", "context_window_size");
|
|
560
|
+
if (
|
|
561
|
+
!isFiniteNumber(remainingPct) ||
|
|
562
|
+
!isFiniteNumber(windowSize) ||
|
|
563
|
+
windowSize <= 0
|
|
564
|
+
) {
|
|
565
|
+
return null;
|
|
566
|
+
}
|
|
567
|
+
const clamped = Math.min(100, Math.max(0, remainingPct));
|
|
568
|
+
const color = zoneColor(zoneForPercentage(Math.round(100 - clamped)));
|
|
569
|
+
const left = `~${formatTokenCount((clamped / 100) * windowSize)} left`;
|
|
570
|
+
const suggestion = buildCompactSuggestion(
|
|
571
|
+
zoneForPercentage(Math.round(100 - clamped)),
|
|
572
|
+
);
|
|
573
|
+
const text =
|
|
574
|
+
suggestion === null
|
|
575
|
+
? `${color}${left}${RESET}`
|
|
576
|
+
: `${color}${left}${RESET}${SEGMENT_SEPARATOR}${color}${suggestion}${RESET}`;
|
|
577
|
+
return seg("context_headroom", 60, text, 10);
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
/**
|
|
581
|
+
* @param {string} id
|
|
582
|
+
* @param {string} label
|
|
583
|
+
* @param {number} priority
|
|
584
|
+
* @param {unknown} window one of `rate_limits.five_hour` / `seven_day` /
|
|
585
|
+
* `spend_limit`.
|
|
586
|
+
* @param {number} nowMs
|
|
587
|
+
* @returns {RowSegment | null} `5h ███░░░░░░░ 23% ↻2h10m`; null when the
|
|
588
|
+
* window is absent (each window is independently absent, and Claude Code
|
|
589
|
+
* drops one once its `resets_at` passes).
|
|
590
|
+
*/
|
|
591
|
+
function formatQuotaWindow(id, label, priority, window, nowMs) {
|
|
592
|
+
const used = pick(window, "used_percentage");
|
|
593
|
+
if (!isFiniteNumber(used)) return null;
|
|
594
|
+
|
|
595
|
+
const barPct = Math.min(100, Math.max(0, used));
|
|
596
|
+
const color = zoneColor(zoneForPercentage(Math.round(barPct)));
|
|
597
|
+
const resetsAt = pick(window, "resets_at");
|
|
598
|
+
const resetText = isFiniteNumber(resetsAt)
|
|
599
|
+
? ` ${DIM}↻${formatDuration(Math.floor(resetsAt - nowMs / 1000))}${RESET}`
|
|
600
|
+
: "";
|
|
601
|
+
const text = `${DIM}${label}${RESET} ${color}${renderBar(barPct, QUOTA_BAR_WIDTH)}${RESET} ${Math.round(used)}%${resetText}`;
|
|
602
|
+
return seg(id, priority, text, 8);
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
/**
|
|
606
|
+
* @param {unknown} payload
|
|
607
|
+
* @param {number} nowMs
|
|
608
|
+
* @returns {RowSegment | null}
|
|
609
|
+
*/
|
|
610
|
+
export function formatFiveHourSegment(payload, nowMs) {
|
|
611
|
+
return formatQuotaWindow(
|
|
612
|
+
"five_hour",
|
|
613
|
+
"5h",
|
|
614
|
+
100,
|
|
615
|
+
pick(payload, "rate_limits", "five_hour"),
|
|
616
|
+
nowMs,
|
|
617
|
+
);
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
/**
|
|
621
|
+
* @param {unknown} payload
|
|
622
|
+
* @param {number} nowMs
|
|
623
|
+
* @returns {RowSegment | null}
|
|
624
|
+
*/
|
|
625
|
+
export function formatSevenDaySegment(payload, nowMs) {
|
|
626
|
+
return formatQuotaWindow(
|
|
627
|
+
"seven_day",
|
|
628
|
+
"7d",
|
|
629
|
+
90,
|
|
630
|
+
pick(payload, "rate_limits", "seven_day"),
|
|
631
|
+
nowMs,
|
|
632
|
+
);
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
/**
|
|
636
|
+
* @param {unknown} payload
|
|
637
|
+
* @param {number} nowMs
|
|
638
|
+
* @returns {RowSegment | null} present only behind a Claude apps gateway that
|
|
639
|
+
* sets a spend limit for the user.
|
|
640
|
+
*/
|
|
641
|
+
export function formatSpendLimitSegment(payload, nowMs) {
|
|
642
|
+
return formatQuotaWindow(
|
|
643
|
+
"spend_limit",
|
|
644
|
+
"spend",
|
|
645
|
+
70,
|
|
646
|
+
pick(payload, "rate_limits", "spend_limit"),
|
|
647
|
+
nowMs,
|
|
648
|
+
);
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
/**
|
|
652
|
+
* @param {unknown} payload
|
|
653
|
+
* @returns {RowSegment | null} the estimated session cost in USD.
|
|
654
|
+
*/
|
|
655
|
+
export function formatCostSegment(payload) {
|
|
656
|
+
const cost = pick(payload, "cost", "total_cost_usd");
|
|
657
|
+
if (!isFiniteNumber(cost) || cost < 0) return null;
|
|
658
|
+
return seg("cost", 100, `$${cost.toFixed(2)}`, 5);
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
/**
|
|
662
|
+
* @param {unknown} payload
|
|
663
|
+
* @returns {RowSegment | null} elapsed wall-clock time since the session
|
|
664
|
+
* started; hidden at zero, where `formatDuration` would read `now`.
|
|
665
|
+
*/
|
|
666
|
+
export function formatDurationSegment(payload) {
|
|
667
|
+
const ms = pick(payload, "cost", "total_duration_ms");
|
|
668
|
+
if (!isFiniteNumber(ms) || ms <= 0) return null;
|
|
669
|
+
return seg(
|
|
670
|
+
"duration",
|
|
671
|
+
90,
|
|
672
|
+
`${DIM}${formatDuration(Math.floor(ms / 1000))}${RESET}`,
|
|
673
|
+
3,
|
|
674
|
+
);
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
/**
|
|
678
|
+
* @param {unknown} payload
|
|
679
|
+
* @returns {RowSegment | null} `+156/-23`; null when nothing has changed.
|
|
680
|
+
*/
|
|
681
|
+
export function formatLinesChangedSegment(payload) {
|
|
682
|
+
const added = pick(payload, "cost", "total_lines_added");
|
|
683
|
+
const removed = pick(payload, "cost", "total_lines_removed");
|
|
684
|
+
const a = isFiniteNumber(added) ? added : 0;
|
|
685
|
+
const r = isFiniteNumber(removed) ? removed : 0;
|
|
686
|
+
if (a <= 0 && r <= 0) return null;
|
|
687
|
+
return seg(
|
|
688
|
+
"lines",
|
|
689
|
+
70,
|
|
690
|
+
`${GREEN}+${a}${RESET}${DIM}/${RESET}${RED}-${r}${RESET}`,
|
|
691
|
+
6,
|
|
692
|
+
);
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
/**
|
|
696
|
+
* @param {unknown} payload
|
|
697
|
+
* @returns {RowSegment | null} `cache 91%`: the prompt-cache hit ratio, green
|
|
698
|
+
* while the cache is warm and yellow once it has gone cold (the next request
|
|
699
|
+
* then pays to rebuild it); null before the first API response.
|
|
700
|
+
*/
|
|
701
|
+
export function formatCacheSegment(payload) {
|
|
702
|
+
const ratio = pick(payload, "prompt_cache", "hit_ratio");
|
|
703
|
+
if (!isFiniteNumber(ratio)) return null;
|
|
704
|
+
const pct = Math.round(Math.min(1, Math.max(0, ratio)) * 100);
|
|
705
|
+
const color = pick(payload, "prompt_cache", "warm") === true ? GREEN : YELLOW;
|
|
706
|
+
return seg("cache", 60, `${DIM}cache${RESET} ${color}${pct}%${RESET}`, 8);
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
/**
|
|
710
|
+
* @param {number} bytes
|
|
711
|
+
* @returns {string} gibibytes to one decimal, trailing `.0` dropped.
|
|
712
|
+
*/
|
|
713
|
+
function gigabytes(bytes) {
|
|
714
|
+
return String(Math.round((bytes / 2 ** 30) * 10) / 10);
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
/**
|
|
718
|
+
* Picks the free/total memory figures to display, identically on macOS and
|
|
719
|
+
* Linux, with no subprocess.
|
|
720
|
+
*
|
|
721
|
+
* `os.freemem()` is not portable in meaning: on Linux it is `MemAvailable`
|
|
722
|
+
* (free plus reclaimable cache), but on macOS it is only the free and
|
|
723
|
+
* speculative pages, leaving out the large inactive/purgeable share the
|
|
724
|
+
* kernel will happily reclaim -- so it understates what is available and can
|
|
725
|
+
* leave the segment needlessly red on a healthy machine. `process.availableMemory()`
|
|
726
|
+
* (Node 22+) counts that reclaimable share on both platforms, and on Linux also
|
|
727
|
+
* respects a cgroup limit, so it is preferred. When it is missing (an older
|
|
728
|
+
* Node in an adopted project) macOS shows no memory segment at all rather
|
|
729
|
+
* than a number that means something else, while Linux falls back to
|
|
730
|
+
* `os.freemem()`, whose meaning is right there.
|
|
731
|
+
*
|
|
732
|
+
* `total` is likewise capped at the cgroup limit when one applies, so a
|
|
733
|
+
* container with a small limit on a large host reads as a fraction of the
|
|
734
|
+
* container, not of the host.
|
|
735
|
+
*
|
|
736
|
+
* Pure and dependency-injected so both platforms' branches are testable from
|
|
737
|
+
* either one.
|
|
738
|
+
*
|
|
739
|
+
* @param {{
|
|
740
|
+
* platform: string;
|
|
741
|
+
* totalmem: number;
|
|
742
|
+
* freemem: number;
|
|
743
|
+
* availableMemory?: unknown;
|
|
744
|
+
* constrainedMemory?: unknown;
|
|
745
|
+
* }} sources
|
|
746
|
+
* @returns {{ freemem: number, totalmem: number } | Record<string, never>}
|
|
747
|
+
* the figures in bytes, or `{}` when no trustworthy free figure exists.
|
|
748
|
+
*/
|
|
749
|
+
export function resolveMemory(sources) {
|
|
750
|
+
const { platform, availableMemory, constrainedMemory } = sources;
|
|
751
|
+
let total = sources.totalmem;
|
|
752
|
+
if (
|
|
753
|
+
isFiniteNumber(constrainedMemory) &&
|
|
754
|
+
constrainedMemory > 0 &&
|
|
755
|
+
constrainedMemory < total
|
|
756
|
+
) {
|
|
757
|
+
total = constrainedMemory;
|
|
758
|
+
}
|
|
759
|
+
if (!isFiniteNumber(total) || total <= 0) return {};
|
|
760
|
+
|
|
761
|
+
let free;
|
|
762
|
+
if (isFiniteNumber(availableMemory) && availableMemory >= 0) {
|
|
763
|
+
free = availableMemory;
|
|
764
|
+
} else if (platform === "darwin") {
|
|
765
|
+
return {};
|
|
766
|
+
} else {
|
|
767
|
+
free = sources.freemem;
|
|
768
|
+
}
|
|
769
|
+
if (!isFiniteNumber(free)) return {};
|
|
770
|
+
|
|
771
|
+
return { freemem: Math.min(free, total), totalmem: total };
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
/**
|
|
775
|
+
* @param {{ freemem?: unknown, totalmem?: unknown } | undefined} env
|
|
776
|
+
* @returns {RowSegment | null} `9.2/32G free`, colored by how much is in use;
|
|
777
|
+
* null when either figure is absent.
|
|
778
|
+
*/
|
|
779
|
+
export function formatMemorySegment(env) {
|
|
780
|
+
const freemem = env?.freemem;
|
|
781
|
+
const totalmem = env?.totalmem;
|
|
782
|
+
if (
|
|
783
|
+
typeof freemem !== "number" ||
|
|
784
|
+
!Number.isFinite(freemem) ||
|
|
785
|
+
typeof totalmem !== "number" ||
|
|
786
|
+
!Number.isFinite(totalmem) ||
|
|
787
|
+
totalmem <= 0
|
|
788
|
+
) {
|
|
789
|
+
return null;
|
|
790
|
+
}
|
|
791
|
+
const freePct = (freemem / totalmem) * 100;
|
|
792
|
+
const zone = zoneForPercentage(100 - freePct);
|
|
793
|
+
const text = `${zoneColor(zone)}${gigabytes(freemem)}/${gigabytes(totalmem)}G free${RESET}`;
|
|
794
|
+
return seg("memory", 50, text, 10);
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
/**
|
|
798
|
+
* @param {unknown} payload
|
|
799
|
+
* @param {{ branch?: unknown, detachedSha?: unknown } | undefined} env
|
|
800
|
+
* @param {number} columns
|
|
801
|
+
* @returns {string} the session row: session name, branch, worktree, agent,
|
|
802
|
+
* origin repo.
|
|
803
|
+
*/
|
|
804
|
+
export function buildSessionRow(payload, env, columns) {
|
|
805
|
+
return buildRow(
|
|
806
|
+
"session",
|
|
807
|
+
[
|
|
808
|
+
formatSessionNameSegment(payload),
|
|
809
|
+
formatBranchSegment(
|
|
810
|
+
typeof env?.branch === "string" ? env.branch : null,
|
|
811
|
+
typeof env?.detachedSha === "string" ? env.detachedSha : null,
|
|
812
|
+
),
|
|
813
|
+
formatWorktreeSegment(payload),
|
|
814
|
+
formatAgentSegment(payload),
|
|
815
|
+
formatOriginRepoSegment(payload),
|
|
816
|
+
],
|
|
817
|
+
columns,
|
|
818
|
+
);
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
/**
|
|
822
|
+
* @param {unknown} payload
|
|
823
|
+
* @param {number} columns
|
|
824
|
+
* @returns {string} the model row: model, effort, thinking, fast mode, output
|
|
825
|
+
* style, vim mode.
|
|
826
|
+
*/
|
|
827
|
+
export function buildModelRow(payload, columns) {
|
|
828
|
+
return buildRow(
|
|
829
|
+
"model",
|
|
830
|
+
[
|
|
831
|
+
formatModelSegment(payload),
|
|
832
|
+
formatEffortSegment(payload),
|
|
833
|
+
formatThinkingSegment(payload),
|
|
834
|
+
formatFastModeSegment(payload),
|
|
835
|
+
formatOutputStyleSegment(payload),
|
|
836
|
+
formatVimModeSegment(payload),
|
|
837
|
+
],
|
|
838
|
+
columns,
|
|
839
|
+
);
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
/**
|
|
843
|
+
* @param {unknown} payload
|
|
844
|
+
* @param {number} columns
|
|
845
|
+
* @returns {string} the context row: usage bar, percent, denominator,
|
|
846
|
+
* headroom (with a compact suggestion once the window fills).
|
|
847
|
+
*/
|
|
848
|
+
export function buildContextRow(payload, columns) {
|
|
849
|
+
return buildRow(
|
|
850
|
+
"context",
|
|
851
|
+
[
|
|
852
|
+
formatContextBarSegment(payload),
|
|
853
|
+
formatContextPercentSegment(payload),
|
|
854
|
+
formatContextDenominatorSegment(payload),
|
|
855
|
+
formatContextHeadroomSegment(payload),
|
|
856
|
+
],
|
|
857
|
+
columns,
|
|
858
|
+
);
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
/**
|
|
862
|
+
* @param {unknown} payload
|
|
863
|
+
* @param {{ now?: unknown } | undefined} env
|
|
864
|
+
* @param {number} columns
|
|
865
|
+
* @returns {string} the quota row: 5-hour, 7-day, and spend-limit windows.
|
|
866
|
+
* Renders a placeholder for anyone without a subscription rate limit.
|
|
867
|
+
*/
|
|
868
|
+
export function buildQuotaRow(payload, env, columns) {
|
|
869
|
+
const nowMs = isFiniteNumber(env?.now) ? env.now : Date.now();
|
|
870
|
+
return buildRow(
|
|
871
|
+
"quota",
|
|
872
|
+
[
|
|
873
|
+
formatFiveHourSegment(payload, nowMs),
|
|
874
|
+
formatSevenDaySegment(payload, nowMs),
|
|
875
|
+
formatSpendLimitSegment(payload, nowMs),
|
|
876
|
+
],
|
|
877
|
+
columns,
|
|
878
|
+
);
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
/**
|
|
882
|
+
* @param {unknown} payload
|
|
883
|
+
* @param {{ freemem?: unknown, totalmem?: unknown } | undefined} env
|
|
884
|
+
* @param {number} columns
|
|
885
|
+
* @returns {string} the work row: cost, duration, lines changed, cache, memory.
|
|
886
|
+
*/
|
|
887
|
+
export function buildWorkRow(payload, env, columns) {
|
|
888
|
+
return buildRow(
|
|
889
|
+
"work",
|
|
890
|
+
[
|
|
891
|
+
formatCostSegment(payload),
|
|
892
|
+
formatDurationSegment(payload),
|
|
893
|
+
formatLinesChangedSegment(payload),
|
|
894
|
+
formatCacheSegment(payload),
|
|
895
|
+
formatMemorySegment(env),
|
|
896
|
+
],
|
|
897
|
+
columns,
|
|
898
|
+
);
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
/**
|
|
902
|
+
* @param {unknown} payload
|
|
903
|
+
* @param {{
|
|
904
|
+
* now?: unknown;
|
|
905
|
+
* freemem?: unknown;
|
|
906
|
+
* totalmem?: unknown;
|
|
907
|
+
* branch?: unknown;
|
|
908
|
+
* detachedSha?: unknown;
|
|
909
|
+
* COLUMNS?: unknown;
|
|
910
|
+
* }} [env] local-only, non-payload context: current time (ms), free/total
|
|
911
|
+
* memory (bytes), the resolved git branch name (or, on a detached HEAD, its short commit id), and the terminal `COLUMNS`
|
|
912
|
+
* width. Defaults to `{}` so a bare `renderStatusLine(payload)` call works.
|
|
913
|
+
* @returns {string} the full, always-five-line status-line output.
|
|
914
|
+
*/
|
|
915
|
+
export function renderStatusLine(payload, env = {}) {
|
|
916
|
+
const columns = terminalColumns(env);
|
|
917
|
+
return [
|
|
918
|
+
buildSessionRow(payload, env, columns),
|
|
919
|
+
buildModelRow(payload, columns),
|
|
920
|
+
buildContextRow(payload, columns),
|
|
921
|
+
buildQuotaRow(payload, env, columns),
|
|
922
|
+
buildWorkRow(payload, env, columns),
|
|
923
|
+
].join("\n");
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
/**
|
|
927
|
+
* @param {string} path
|
|
928
|
+
* @returns {string | null}
|
|
929
|
+
*/
|
|
930
|
+
function safeReadFile(path) {
|
|
931
|
+
try {
|
|
932
|
+
return readFileSync(path, "utf8");
|
|
933
|
+
} catch {
|
|
934
|
+
return null;
|
|
935
|
+
}
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
/**
|
|
939
|
+
* @param {string | null} headContent raw `HEAD` file content.
|
|
940
|
+
* @returns {{ branch: string | null, detachedSha: string | null }}
|
|
941
|
+
*/
|
|
942
|
+
function resolveHeadState(headContent) {
|
|
943
|
+
return {
|
|
944
|
+
branch: parseHeadRef(headContent),
|
|
945
|
+
detachedSha: parseDetachedHead(headContent),
|
|
946
|
+
};
|
|
947
|
+
}
|
|
948
|
+
|
|
949
|
+
/**
|
|
950
|
+
* Whether this module is the process's entry point (as opposed to being
|
|
951
|
+
* imported by a test). `import.meta.url` is always the symlink-resolved path,
|
|
952
|
+
* but `process.argv[1]` is kept exactly as typed, so comparing them directly
|
|
953
|
+
* is false for any project under a symlinked directory -- macOS's `/tmp` and
|
|
954
|
+
* `/var`, a symlinked home or volume, a symlinked workspace on Linux -- and the
|
|
955
|
+
* script would then exit 0 having printed nothing at all.
|
|
956
|
+
*
|
|
957
|
+
* @returns {boolean}
|
|
958
|
+
*/
|
|
959
|
+
function isEntryPoint() {
|
|
960
|
+
try {
|
|
961
|
+
return realpathSync(process.argv[1]) === fileURLToPath(import.meta.url);
|
|
962
|
+
} catch {
|
|
963
|
+
return false;
|
|
964
|
+
}
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
async function readStdin() {
|
|
968
|
+
const chunks = [];
|
|
969
|
+
for await (const chunk of process.stdin) chunks.push(chunk);
|
|
970
|
+
return Buffer.concat(chunks).toString("utf8");
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
// Only run when invoked directly, not when imported for testing.
|
|
974
|
+
if (isEntryPoint()) {
|
|
975
|
+
let output;
|
|
976
|
+
try {
|
|
977
|
+
const payload = JSON.parse(await readStdin());
|
|
978
|
+
const startDir =
|
|
979
|
+
pick(payload, "workspace", "current_dir") ?? pick(payload, "cwd");
|
|
980
|
+
output = renderStatusLine(payload, {
|
|
981
|
+
now: Date.now(),
|
|
982
|
+
...resolveHeadState(readHead(safeReadFile, startDir)),
|
|
983
|
+
COLUMNS: process.env["COLUMNS"],
|
|
984
|
+
...resolveMemory({
|
|
985
|
+
platform: process.platform,
|
|
986
|
+
totalmem: os.totalmem(),
|
|
987
|
+
freemem: os.freemem(),
|
|
988
|
+
availableMemory: process.availableMemory?.(),
|
|
989
|
+
constrainedMemory: process.constrainedMemory?.(),
|
|
990
|
+
}),
|
|
991
|
+
});
|
|
992
|
+
} catch {
|
|
993
|
+
output = "ctx --%";
|
|
994
|
+
}
|
|
995
|
+
process.stdout.write(`${output}\n`);
|
|
996
|
+
}
|