@hasna/statusline 0.0.8 → 0.0.9

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 CHANGED
@@ -22,7 +22,7 @@ That's it — Claude Code picks it up on the next status refresh (no restart nee
22
22
  ## Segments
23
23
 
24
24
  `statusline list` shows a compact, enabled-first summary of your segments (use
25
- `statusline list --all` for every row). The table below is the full catalog — all 30
25
+ `statusline list --all` for every row). The table below is the full catalog — all 31
26
26
  segment ids from `src/segments/index.ts`.
27
27
 
28
28
  `renderLine` walks your configured segment order, renders each one, and **drops**
@@ -36,6 +36,7 @@ anything that returns `null` or throws. A failed segment never breaks the host U
36
36
  | `git-branch` | off | Current Git branch | `git branch --show-current` in `cwd` | outside git or detached | `main` |
37
37
  | `commit-age` | on | Time since last commit (compact) | `git log -1` epoch vs now | no commits / not a repo | `12h` |
38
38
  | `loc` | on | Lines of code tracked by Git (compact) | `git ls-files` + `wc -l` in repo root | outside git or zero lines | `1.2k` |
39
+ | `disk-free` | off | Free space on the volume holding the working directory | one `fs.statfsSync` syscall on `StatusContext.cwd` — no subprocess, no polling, no cache | the volume cannot be read (missing cwd, unsupported filesystem) | `disk 24.1G` · `disk 8G low` |
39
40
  | `current-dir` | off | Current working directory (basename) | `StatusContext.cwd` | never (always renders basename) | `statusline` |
40
41
  | `cwd` | off | Current working directory (full path) | `StatusContext.cwd`, home directory abbreviated to `~`, head elided past 40 chars | no cwd in the payload | `~/work/statusline` |
41
42
  | `model` | off | Current model name (no context tag) | `StatusContext.model.id` via friendly formatter | no model id | `fable 5` |
@@ -126,6 +127,9 @@ separators are never coloured.
126
127
  80% and above (the same shape the rate-limit segments use);
127
128
  - the rate-limit segments escalate from yellow to red at 80%;
128
129
  - `cwd` uses a calm blue accent and elides the head of long paths;
130
+ - `disk-free` stays dim while the volume has headroom, turns yellow below 20%
131
+ free and red below 10%, and appends a `low` marker so the warning survives
132
+ `NO_COLOR`;
129
133
  - model, cost and duration render dim, so they never compete with a warning.
130
134
 
131
135
  ```bash
@@ -231,6 +235,14 @@ The `newline` segment starts a new row; empty rows are removed. Segments
231
235
  that need more than the payload offers (e.g. `context-remaining`) read the session
232
236
  transcript to compute it. A segment failure is never fatal — it's simply omitted.
233
237
 
238
+ There is no long-running statusline process, so nothing polls. `disk-free` reads
239
+ free space once per render with `fs.statfsSync` (about half a microsecond), the
240
+ same way the host re-runs the command for each refresh; shelling out to `df`
241
+ would fork a process every time and cost roughly a millisecond. That one reading
242
+ is shared by the segment's text and its colour, so they cannot disagree. The
243
+ segment adds under 2 µs to an in-process render, which is below the noise of the
244
+ ~17 ms process invocation.
245
+
234
246
  ## SDK
235
247
 
236
248
  The package root is a side-effect-free SDK surface:
package/dist/cli.js CHANGED
@@ -157,6 +157,23 @@ function compactNum(n) {
157
157
  return `${trimmed(n / 1000)}k`;
158
158
  return `${n}`;
159
159
  }
160
+ var BYTE_UNITS = ["K", "M", "G", "T", "P"];
161
+ function compactBytes(bytes) {
162
+ if (!Number.isFinite(bytes) || bytes < 0)
163
+ return "";
164
+ if (bytes < 1024)
165
+ return `${Math.floor(bytes)}B`;
166
+ let value = bytes / 1024;
167
+ let unit = 0;
168
+ while (unit < BYTE_UNITS.length - 1) {
169
+ const rounded = Number(value.toFixed(1));
170
+ if (rounded < 1024)
171
+ break;
172
+ value /= 1024;
173
+ unit += 1;
174
+ }
175
+ return `${trimmed(value)}${BYTE_UNITS[unit]}`;
176
+ }
160
177
  function money(n) {
161
178
  return `$${n.toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
162
179
  }
@@ -416,6 +433,95 @@ function windowSize(ctx) {
416
433
  return ctx.model?.id?.includes("[1m]") ? 1e6 : 200000;
417
434
  }
418
435
 
436
+ // src/disk.ts
437
+ import { statfsSync } from "fs";
438
+ var DISK_MEMO = Symbol("statusline.disk-free");
439
+ var DISK_WARN_PERCENT = 20;
440
+ var DISK_CRITICAL_PERCENT = 10;
441
+ function finiteNonNegative(value) {
442
+ return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : null;
443
+ }
444
+ function readDiskStats(path) {
445
+ if (typeof statfsSync !== "function")
446
+ return null;
447
+ try {
448
+ const stats = statfsSync(path);
449
+ if (!stats)
450
+ return null;
451
+ return {
452
+ bsize: Number(stats.bsize),
453
+ blocks: Number(stats.blocks),
454
+ bavail: Number(stats.bavail)
455
+ };
456
+ } catch {
457
+ return null;
458
+ }
459
+ }
460
+ function diskFree(path, reader = readDiskStats) {
461
+ let stats;
462
+ try {
463
+ stats = reader(path);
464
+ } catch {
465
+ return null;
466
+ }
467
+ if (!stats)
468
+ return null;
469
+ const bsize = finiteNonNegative(stats.bsize);
470
+ const blocks = finiteNonNegative(stats.blocks);
471
+ const bavail = finiteNonNegative(stats.bavail);
472
+ if (bsize === null || blocks === null || bavail === null)
473
+ return null;
474
+ if (bsize <= 0 || blocks <= 0)
475
+ return null;
476
+ const available = Math.min(bavail, blocks);
477
+ const totalBytes = bsize * blocks;
478
+ const freeBytes = bsize * available;
479
+ if (!Number.isFinite(totalBytes) || !Number.isFinite(freeBytes))
480
+ return null;
481
+ return {
482
+ freeBytes,
483
+ totalBytes,
484
+ freePercent: available / blocks * 100
485
+ };
486
+ }
487
+ function diskFreeFor(path, memo, reader = readDiskStats) {
488
+ if (!memo)
489
+ return diskFree(path, reader);
490
+ const hit = memo.get(DISK_MEMO);
491
+ if (hit && hit.path === path)
492
+ return hit.value;
493
+ const value = diskFree(path, reader);
494
+ memo.set(DISK_MEMO, { path, value });
495
+ return value;
496
+ }
497
+ function diskLevel(freePercent) {
498
+ if (!Number.isFinite(freePercent))
499
+ return null;
500
+ if (freePercent < DISK_CRITICAL_PERCENT)
501
+ return "critical";
502
+ if (freePercent < DISK_WARN_PERCENT)
503
+ return "low";
504
+ return null;
505
+ }
506
+ function formatDiskFree(disk) {
507
+ const value = compactBytes(disk.freeBytes);
508
+ if (!value)
509
+ return "";
510
+ return diskLevel(disk.freePercent) ? `disk ${value} low` : `disk ${value}`;
511
+ }
512
+ function diskColor(disk) {
513
+ if (!disk)
514
+ return null;
515
+ switch (diskLevel(disk.freePercent)) {
516
+ case "critical":
517
+ return "red";
518
+ case "low":
519
+ return "yellow";
520
+ default:
521
+ return "dim";
522
+ }
523
+ }
524
+
419
525
  // src/accounts.ts
420
526
  import { existsSync as existsSync5, readFileSync as readFileSync4, realpathSync } from "fs";
421
527
  import { homedir as homedir4 } from "os";
@@ -804,6 +910,16 @@ var segments = [
804
910
  return n ? compactNum(n) : null;
805
911
  }
806
912
  },
913
+ {
914
+ id: "disk-free",
915
+ description: "Free space on the volume holding the working directory",
916
+ defaultEnabled: false,
917
+ color: (ctx, memo) => diskColor(diskFreeFor(dir(ctx), memo)),
918
+ render(ctx, memo) {
919
+ const disk = diskFreeFor(dir(ctx), memo);
920
+ return disk ? formatDiskFree(disk) || null : null;
921
+ }
922
+ },
807
923
  {
808
924
  id: "current-dir",
809
925
  description: "Current working directory (basename)",
@@ -1051,13 +1167,14 @@ function colorsEnabled(cfg, env = process.env) {
1051
1167
  return false;
1052
1168
  return cfg.colors !== false;
1053
1169
  }
1054
- function paint(segment, text, ctx) {
1055
- const color = typeof segment.color === "function" ? segment.color(ctx) : segment.color;
1170
+ function paint(segment, text, ctx, memo) {
1171
+ const color = typeof segment.color === "function" ? segment.color(ctx, memo) : segment.color;
1056
1172
  const code = color ? ANSI[color] : undefined;
1057
1173
  return code ? `${code}${text}${RESET}` : text;
1058
1174
  }
1059
1175
  async function renderLine(ctx, cfg) {
1060
1176
  const colorize = colorsEnabled(cfg);
1177
+ const memo = new Map;
1061
1178
  const rows = [[]];
1062
1179
  for (const id of cfg.segments) {
1063
1180
  if (id === "newline") {
@@ -1068,9 +1185,9 @@ async function renderLine(ctx, cfg) {
1068
1185
  if (!segment)
1069
1186
  continue;
1070
1187
  try {
1071
- const out = await segment.render(ctx);
1188
+ const out = await segment.render(ctx, memo);
1072
1189
  if (out)
1073
- rows[rows.length - 1].push(colorize ? paint(segment, out, ctx) : out);
1190
+ rows[rows.length - 1].push(colorize ? paint(segment, out, ctx, memo) : out);
1074
1191
  } catch {}
1075
1192
  }
1076
1193
  return rows.map((parts) => parts.join(cfg.separator)).filter((row) => row.length > 0).join(`
@@ -1079,7 +1196,7 @@ async function renderLine(ctx, cfg) {
1079
1196
  // package.json
1080
1197
  var package_default = {
1081
1198
  name: "@hasna/statusline",
1082
- version: "0.0.8",
1199
+ version: "0.0.9",
1083
1200
  description: "Tiny composable statusline CLI for AI coding agents \u2014 pre-built toggleable segments for Claude Code, with a provider model for others",
1084
1201
  license: "Apache-2.0",
1085
1202
  type: "module",
package/dist/disk.d.ts ADDED
@@ -0,0 +1,51 @@
1
+ import type { RenderMemo, SegmentColor } from "./providers/types.js";
2
+ /** The statfs fields this module needs; node's StatFs carries more. */
3
+ export interface DiskStats {
4
+ bsize: number;
5
+ blocks: number;
6
+ bavail: number;
7
+ }
8
+ /** Injectable reader: tests pass a fake, the render uses the real syscall. */
9
+ export type DiskStatfsReader = (path: string) => DiskStats | null;
10
+ export interface DiskFree {
11
+ freeBytes: number;
12
+ totalBytes: number;
13
+ /** Blocks available to an unprivileged user, as a percentage. */
14
+ freePercent: number;
15
+ }
16
+ /** Below 20% free a disk is worth flagging; below 10% it is critical. */
17
+ export declare const DISK_WARN_PERCENT = 20;
18
+ export declare const DISK_CRITICAL_PERCENT = 10;
19
+ export type DiskLevel = "low" | "critical";
20
+ /**
21
+ * Read a filesystem's stats with one syscall. Returns null when the runtime
22
+ * has no `statfs` or the call fails (missing path, permission, unsupported
23
+ * filesystem), so the caller can omit the segment instead of guessing.
24
+ */
25
+ export declare function readDiskStats(path: string): DiskStats | null;
26
+ /**
27
+ * Free bytes for `path`'s volume, or null when the reading is unusable.
28
+ *
29
+ * Unreadable or missing fields are null, never zero: a rendered zero has to
30
+ * mean "the disk really is full", not "this runtime could not tell me".
31
+ */
32
+ export declare function diskFree(path: string, reader?: DiskStatfsReader): DiskFree | null;
33
+ /**
34
+ * Free space for `path`, read at most once per render.
35
+ *
36
+ * The memo belongs to a single `renderLine` call and is shared by a segment's
37
+ * text and colour, so the volume is read once instead of once per consumer. It
38
+ * is discarded when that render ends: the next invocation reads afresh, so this
39
+ * is not a cache and cannot show a figure from a previous render.
40
+ */
41
+ export declare function diskFreeFor(path: string, memo?: RenderMemo, reader?: DiskStatfsReader): DiskFree | null;
42
+ /** Severity for a free percentage; null while the volume has headroom. */
43
+ export declare function diskLevel(freePercent: number): DiskLevel | null;
44
+ /**
45
+ * `disk 24G` while healthy, `disk 8.2G low` below the warning line. The
46
+ * textual marker is deliberate: colour alone disappears under `NO_COLOR`,
47
+ * where a nearly-full disk still has to be visible at a glance.
48
+ */
49
+ export declare function formatDiskFree(disk: DiskFree): string;
50
+ /** Healthy disks stay dim; low space escalates yellow, then red. */
51
+ export declare function diskColor(disk: DiskFree | null): SegmentColor | null;
package/dist/format.d.ts CHANGED
@@ -1,4 +1,11 @@
1
1
  export declare function compactAge(seconds: number): string;
2
2
  export declare function compactNum(n: number): string;
3
+ /**
4
+ * Byte count in binary units, one decimal at most: `461k`, `24.1G`, `512B`.
5
+ * Unit letters match `df -h` (K/M/G/T are 1024-based here) so the number
6
+ * agrees with what an operator sees in a shell. An unusable value — negative,
7
+ * NaN, Infinity — returns "" rather than a plausible-looking number.
8
+ */
9
+ export declare function compactBytes(bytes: number): string;
3
10
  export declare function money(n: number): string;
4
11
  export declare function compactDuration(ms: number): string;
package/dist/index.js CHANGED
@@ -156,6 +156,23 @@ function compactNum(n) {
156
156
  return `${trimmed(n / 1000)}k`;
157
157
  return `${n}`;
158
158
  }
159
+ var BYTE_UNITS = ["K", "M", "G", "T", "P"];
160
+ function compactBytes(bytes) {
161
+ if (!Number.isFinite(bytes) || bytes < 0)
162
+ return "";
163
+ if (bytes < 1024)
164
+ return `${Math.floor(bytes)}B`;
165
+ let value = bytes / 1024;
166
+ let unit = 0;
167
+ while (unit < BYTE_UNITS.length - 1) {
168
+ const rounded = Number(value.toFixed(1));
169
+ if (rounded < 1024)
170
+ break;
171
+ value /= 1024;
172
+ unit += 1;
173
+ }
174
+ return `${trimmed(value)}${BYTE_UNITS[unit]}`;
175
+ }
159
176
  function money(n) {
160
177
  return `$${n.toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
161
178
  }
@@ -415,6 +432,95 @@ function windowSize(ctx) {
415
432
  return ctx.model?.id?.includes("[1m]") ? 1e6 : 200000;
416
433
  }
417
434
 
435
+ // src/disk.ts
436
+ import { statfsSync } from "fs";
437
+ var DISK_MEMO = Symbol("statusline.disk-free");
438
+ var DISK_WARN_PERCENT = 20;
439
+ var DISK_CRITICAL_PERCENT = 10;
440
+ function finiteNonNegative(value) {
441
+ return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : null;
442
+ }
443
+ function readDiskStats(path) {
444
+ if (typeof statfsSync !== "function")
445
+ return null;
446
+ try {
447
+ const stats = statfsSync(path);
448
+ if (!stats)
449
+ return null;
450
+ return {
451
+ bsize: Number(stats.bsize),
452
+ blocks: Number(stats.blocks),
453
+ bavail: Number(stats.bavail)
454
+ };
455
+ } catch {
456
+ return null;
457
+ }
458
+ }
459
+ function diskFree(path, reader = readDiskStats) {
460
+ let stats;
461
+ try {
462
+ stats = reader(path);
463
+ } catch {
464
+ return null;
465
+ }
466
+ if (!stats)
467
+ return null;
468
+ const bsize = finiteNonNegative(stats.bsize);
469
+ const blocks = finiteNonNegative(stats.blocks);
470
+ const bavail = finiteNonNegative(stats.bavail);
471
+ if (bsize === null || blocks === null || bavail === null)
472
+ return null;
473
+ if (bsize <= 0 || blocks <= 0)
474
+ return null;
475
+ const available = Math.min(bavail, blocks);
476
+ const totalBytes = bsize * blocks;
477
+ const freeBytes = bsize * available;
478
+ if (!Number.isFinite(totalBytes) || !Number.isFinite(freeBytes))
479
+ return null;
480
+ return {
481
+ freeBytes,
482
+ totalBytes,
483
+ freePercent: available / blocks * 100
484
+ };
485
+ }
486
+ function diskFreeFor(path, memo, reader = readDiskStats) {
487
+ if (!memo)
488
+ return diskFree(path, reader);
489
+ const hit = memo.get(DISK_MEMO);
490
+ if (hit && hit.path === path)
491
+ return hit.value;
492
+ const value = diskFree(path, reader);
493
+ memo.set(DISK_MEMO, { path, value });
494
+ return value;
495
+ }
496
+ function diskLevel(freePercent) {
497
+ if (!Number.isFinite(freePercent))
498
+ return null;
499
+ if (freePercent < DISK_CRITICAL_PERCENT)
500
+ return "critical";
501
+ if (freePercent < DISK_WARN_PERCENT)
502
+ return "low";
503
+ return null;
504
+ }
505
+ function formatDiskFree(disk) {
506
+ const value = compactBytes(disk.freeBytes);
507
+ if (!value)
508
+ return "";
509
+ return diskLevel(disk.freePercent) ? `disk ${value} low` : `disk ${value}`;
510
+ }
511
+ function diskColor(disk) {
512
+ if (!disk)
513
+ return null;
514
+ switch (diskLevel(disk.freePercent)) {
515
+ case "critical":
516
+ return "red";
517
+ case "low":
518
+ return "yellow";
519
+ default:
520
+ return "dim";
521
+ }
522
+ }
523
+
418
524
  // src/accounts.ts
419
525
  import { existsSync as existsSync5, readFileSync as readFileSync4, realpathSync } from "fs";
420
526
  import { homedir as homedir4 } from "os";
@@ -803,6 +909,16 @@ var segments = [
803
909
  return n ? compactNum(n) : null;
804
910
  }
805
911
  },
912
+ {
913
+ id: "disk-free",
914
+ description: "Free space on the volume holding the working directory",
915
+ defaultEnabled: false,
916
+ color: (ctx, memo) => diskColor(diskFreeFor(dir(ctx), memo)),
917
+ render(ctx, memo) {
918
+ const disk = diskFreeFor(dir(ctx), memo);
919
+ return disk ? formatDiskFree(disk) || null : null;
920
+ }
921
+ },
806
922
  {
807
923
  id: "current-dir",
808
924
  description: "Current working directory (basename)",
@@ -1050,13 +1166,14 @@ function colorsEnabled(cfg, env = process.env) {
1050
1166
  return false;
1051
1167
  return cfg.colors !== false;
1052
1168
  }
1053
- function paint(segment, text, ctx) {
1054
- const color = typeof segment.color === "function" ? segment.color(ctx) : segment.color;
1169
+ function paint(segment, text, ctx, memo) {
1170
+ const color = typeof segment.color === "function" ? segment.color(ctx, memo) : segment.color;
1055
1171
  const code = color ? ANSI[color] : undefined;
1056
1172
  return code ? `${code}${text}${RESET}` : text;
1057
1173
  }
1058
1174
  async function renderLine(ctx, cfg) {
1059
1175
  const colorize = colorsEnabled(cfg);
1176
+ const memo = new Map;
1060
1177
  const rows = [[]];
1061
1178
  for (const id of cfg.segments) {
1062
1179
  if (id === "newline") {
@@ -1067,9 +1184,9 @@ async function renderLine(ctx, cfg) {
1067
1184
  if (!segment)
1068
1185
  continue;
1069
1186
  try {
1070
- const out = await segment.render(ctx);
1187
+ const out = await segment.render(ctx, memo);
1071
1188
  if (out)
1072
- rows[rows.length - 1].push(colorize ? paint(segment, out, ctx) : out);
1189
+ rows[rows.length - 1].push(colorize ? paint(segment, out, ctx, memo) : out);
1073
1190
  } catch {}
1074
1191
  }
1075
1192
  return rows.map((parts) => parts.join(cfg.separator)).filter((row) => row.length > 0).join(`
@@ -1078,7 +1195,7 @@ async function renderLine(ctx, cfg) {
1078
1195
  // package.json
1079
1196
  var package_default = {
1080
1197
  name: "@hasna/statusline",
1081
- version: "0.0.8",
1198
+ version: "0.0.9",
1082
1199
  description: "Tiny composable statusline CLI for AI coding agents \u2014 pre-built toggleable segments for Claude Code, with a provider model for others",
1083
1200
  license: "Apache-2.0",
1084
1201
  type: "module",