@hasna/statusline 0.0.2 → 0.0.4

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
@@ -39,6 +39,12 @@ anything that returns `null` or throws. A failed segment never breaks the host U
39
39
  | `current-dir` | off | Current working directory (basename) | `StatusContext.cwd` | never (always renders basename) | `open-statusline` |
40
40
  | `model` | off | Current model name (no context tag) | `StatusContext.model.id` via friendly formatter | no model id | `fable 5` |
41
41
  | `model-context` | on | Model name with context-size tag | same formatter, keeps `[tag]` suffix | no model id | `fable 5 [1m]` |
42
+ | `model-with-reasoning` | off | Model name with its reasoning effort | `model.display_name` (or friendly id) + `effort.level`; falls back to `thinking.enabled`, which the host defaults to on | no model | `Fable (xhigh)` |
43
+ | `auth-profile` | off | Account profile this session runs as | this process's `CLAUDE_CONFIG_DIR`, resolved against the accounts registry | config dir is unmanaged and has no login | `account001` |
44
+ | `auth-email` | off | Account email this session is logged in as | the agent's own login record for this config dir, else the registry | no login recorded | `dev@example.com` |
45
+ | `five-hour-limit` | off | Percentage of the 5-hour rate limit used | `rate_limits.five_hour.used_percentage` | host reports no limit data | `5h:42%` |
46
+ | `seven-day-limit` | off | Percentage of the 7-day rate limit used | `rate_limits.seven_day.used_percentage` | host reports no limit data | `7d:12%` |
47
+ | `thread-title` | off | Thread title set with `/rename` | `session_name` | thread not renamed | `ship it` |
42
48
  | `context-used` | off | Percentage of context window used | session transcript JSONL (`contextUsage`) | transcript missing/unreadable | `10%` |
43
49
  | `context-remaining` | on | Percentage of context window remaining | transcript via `contextUsage` | transcript missing/unreadable | `90% left` |
44
50
  | `used-tokens` | off | Total tokens in the context window | transcript usage block (input + output) | transcript missing/unreadable | `102k tok` |
@@ -60,6 +66,51 @@ JSONL for the last assistant entry with a `usage` block and sums
60
66
  With the test fixture transcript (100k input-side tokens on a 1m window), expect
61
67
  `context-used` → `10%`, `context-remaining` → `90% left`, `used-tokens` → `102k tok`.
62
68
 
69
+ ### Which account am I? (`auth-profile`)
70
+
71
+ Statusline payloads carry no account or profile field, so `auth-profile` reads the
72
+ config dir **this process** was handed — `CLAUDE_CONFIG_DIR`, which multi-account
73
+ launchers set per session — and resolves it in order:
74
+
75
+ 1. the `@hasna/accounts` registry entry whose `dir` matches, preferring one that
76
+ belongs to the agent being rendered (a dir can be claimed by several tools;
77
+ another tool's entry is never adopted);
78
+ 2. the managed layout `<accountsHome>/profiles/<tool>/<name>`, for dirs the local
79
+ registry has no entry for;
80
+ 3. the account email the agent recorded in that dir, when neither names a profile.
81
+
82
+ It never consults an "active"/"current" profile pointer. Those are global — and,
83
+ against a shared registry, global across every machine: two sessions running under
84
+ different accounts would both report whichever profile was switched to last.
85
+ Resolving from the process's own config dir keeps concurrent sessions independent.
86
+
87
+ Where the agent's login record lives mirrors Claude Code's own rule: `.config.json`
88
+ in the config dir wins, otherwise `.claude<oauth-variant>.json`. Note the base dir
89
+ is the config dir **or the home dir** — with `CLAUDE_CONFIG_DIR` unset the file is
90
+ `~/.claude.json`, a sibling of `~/.claude` rather than something inside it.
91
+
92
+ Registry location comes from `@hasna/accounts` itself when it is installed
93
+ alongside this package (imported lazily, only when an auth segment renders);
94
+ otherwise from its documented overrides `ACCOUNTS_STORE_PATH`, then
95
+ `ACCOUNTS_HOME`, then `~/.hasna/accounts`. `@hasna/accounts` is not a dependency —
96
+ with none of it present the segment renders nothing rather than guessing.
97
+
98
+ **Known limitation.** Renaming a profile does not move its dir. For a profile the
99
+ local registry has no entry for, step 2 therefore reports the name the dir was
100
+ created with until the registry catches up. Step 1 is tried first so a profile the
101
+ registry knows about always wins.
102
+
103
+ ### Colours
104
+
105
+ Segments may declare a colour, applied by `renderLine` when colours are on
106
+ (the default). The rate-limit segments escalate from yellow to red past 80%.
107
+
108
+ ```bash
109
+ statusline colors off # or set "colors": false in the config
110
+ ```
111
+
112
+ `NO_COLOR` in the environment always wins.
113
+
63
114
  ### Quick reference from `statusline list`
64
115
 
65
116
  `statusline list` is compact by default so it stays friendly in agent terminals —
@@ -109,6 +160,7 @@ statusbar width.
109
160
  statusline list # compact segment summary
110
161
  statusline enable used-tokens duration # turn segments on (appended at the end)
111
162
  statusline disable loc # turn segments off
163
+ statusline colors off # drop ANSI colours
112
164
  statusline order machine project model-context cost # exact order = enabled set
113
165
  statusline separator " | " # change the separator
114
166
  statusline preview # render a sample line from the current dir
@@ -198,7 +250,17 @@ bun install -g @hasna/statusline
198
250
  statusline install claude
199
251
  ```
200
252
 
201
- `installClaude()` writes to `~/.claude/settings.json`:
253
+ `installClaude()` writes to `$CLAUDE_CONFIG_DIR/settings.json`, falling back to
254
+ `~/.claude/settings.json`. This matters for multi-account setups: when a session is
255
+ bound to an isolated config dir, `~/.claude/settings.json` is never read, so
256
+ installing there wires up a statusline that silently never runs.
257
+
258
+ Only `~/.claude` is created on demand. Every other target — an isolated config
259
+ dir, or a path passed as `installClaude(path)` — must already exist: installing
260
+ there is a request to configure a profile someone else created, not to fabricate
261
+ one. The rule follows the target, not the shell you install from, so driving a
262
+ list of profiles gives the same answer from any session. Pass the environment to
263
+ resolve against as `installClaude(undefined, env)`.
202
264
 
203
265
  ```json
204
266
  {
@@ -222,8 +284,13 @@ on every status refresh — **no restart needed**.
222
284
  | `cost.total_lines_added/removed` | `cost.totalLinesAdded/Removed` |
223
285
  | `transcript_path` | `transcriptPath` |
224
286
  | `session_id` | `sessionId` |
287
+ | `session_name` | `sessionName` |
225
288
  | `version` | `version` |
226
289
  | `output_style.name` | `outputStyle` |
290
+ | `effort.level` | `effort` |
291
+ | `thinking.enabled` | `thinking` |
292
+ | `rate_limits.five_hour.used_percentage` | `rateLimits.fiveHour.usedPercentage` |
293
+ | `rate_limits.seven_day.used_percentage` | `rateLimits.sevenDay.usedPercentage` |
227
294
 
228
295
  Every field is optional — partial or empty payloads still produce a usable context.
229
296
 
@@ -0,0 +1,50 @@
1
+ /**
2
+ * Which account is *this* session running as?
3
+ *
4
+ * The host agent's statusline payload carries no account or profile field, so
5
+ * the answer has to come from the process environment. Tools that manage
6
+ * several logins (`@hasna/accounts`, and Claude Code itself) isolate each
7
+ * login in its own config dir and point the child process at it via
8
+ * `CLAUDE_CONFIG_DIR`. That variable is per-process, so it is the only signal
9
+ * that stays correct when several sessions run under different accounts at the
10
+ * same time.
11
+ *
12
+ * Deliberately NOT used: any "active"/"current"/"applied" profile pointer.
13
+ * Those are global — every concurrent session would report whichever profile
14
+ * was switched to last, which is the staleness this module exists to avoid.
15
+ */
16
+ export interface SessionAccount {
17
+ /** Config dir this session is running against (canonicalized). */
18
+ configDir: string;
19
+ /** Managed profile name owning that dir, when it can be resolved. */
20
+ profile: string | null;
21
+ /** Tool id the profile belongs to (e.g. "claude"), when known. */
22
+ tool: string | null;
23
+ /**
24
+ * How `profile` was resolved. `"layout"` means it was read off the dir path
25
+ * rather than the registry, which is authoritative about the dir but can lag
26
+ * a rename — see `layoutProfile`. Surfaced for diagnostics, not for display.
27
+ */
28
+ source: "registry" | "layout" | null;
29
+ }
30
+ type Env = Record<string, string | undefined>;
31
+ /** Config dir the current process is bound to, for settings and registry lookup. */
32
+ export declare function sessionConfigDir(env?: Env): string;
33
+ /**
34
+ * Path of the host agent's account record for this session, or null when none
35
+ * of its known filenames are present.
36
+ */
37
+ export declare function sessionStateFile(env?: Env): string | null;
38
+ /**
39
+ * Resolve the profile owning this session. `tool` is the agent being rendered
40
+ * for, used to disambiguate a config dir claimed by several tools. Never throws.
41
+ */
42
+ export declare function sessionAccount(env?: Env, tool?: string): Promise<SessionAccount>;
43
+ /**
44
+ * Email this session is logged in as. The host agent's own record for this
45
+ * config dir wins over the registry's copy, which can lag behind a re-login.
46
+ * Read separately from `sessionAccount` because it parses the agent's state
47
+ * blob, which is large enough to only touch when a segment asks for it.
48
+ */
49
+ export declare function sessionAccountEmail(env?: Env, tool?: string): Promise<string | null>;
50
+ export {};
package/dist/cli.js CHANGED
@@ -1,36 +1,6 @@
1
1
  #!/usr/bin/env bun
2
2
  // @bun
3
- var __create = Object.create;
4
- var __getProtoOf = Object.getPrototypeOf;
5
3
  var __defProp = Object.defineProperty;
6
- var __getOwnPropNames = Object.getOwnPropertyNames;
7
- var __hasOwnProp = Object.prototype.hasOwnProperty;
8
- function __accessProp(key) {
9
- return this[key];
10
- }
11
- var __toESMCache_node;
12
- var __toESMCache_esm;
13
- var __toESM = (mod, isNodeMode, target) => {
14
- var canCache = mod != null && typeof mod === "object";
15
- if (canCache) {
16
- var cache = isNodeMode ? __toESMCache_node ??= new WeakMap : __toESMCache_esm ??= new WeakMap;
17
- var cached = cache.get(mod);
18
- if (cached)
19
- return cached;
20
- }
21
- target = mod != null ? __create(__getProtoOf(mod)) : {};
22
- const to = isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target;
23
- for (let key of __getOwnPropNames(mod))
24
- if (!__hasOwnProp.call(to, key))
25
- __defProp(to, key, {
26
- get: __accessProp.bind(mod, key),
27
- enumerable: true
28
- });
29
- if (canCache)
30
- cache.set(mod, to);
31
- return to;
32
- };
33
- var __commonJS = (cb, mod) => () => (mod || cb((mod = { exports: {} }).exports, mod), mod.exports);
34
4
  var __returnValue = (v) => v;
35
5
  function __exportSetter(name, newValue) {
36
6
  this[name] = __returnValue.bind(null, newValue);
@@ -52,6 +22,7 @@ import { dirname, join } from "path";
52
22
  function defaultConfig() {
53
23
  return {
54
24
  separator: " \xB7 ",
25
+ colors: true,
55
26
  segments: [
56
27
  "machine",
57
28
  "project",
@@ -79,7 +50,8 @@ function loadConfig(path = configPath()) {
79
50
  const fallback = defaultConfig();
80
51
  return {
81
52
  separator: typeof raw.separator === "string" ? raw.separator : fallback.separator,
82
- segments: Array.isArray(raw.segments) ? raw.segments.filter((s) => typeof s === "string") : fallback.segments
53
+ segments: Array.isArray(raw.segments) ? raw.segments.filter((s) => typeof s === "string") : fallback.segments,
54
+ colors: typeof raw.colors === "boolean" ? raw.colors : fallback.colors
83
55
  };
84
56
  } catch {
85
57
  return defaultConfig();
@@ -107,8 +79,15 @@ function parseClaudeInput(input) {
107
79
  } : undefined,
108
80
  transcriptPath: input?.transcript_path || undefined,
109
81
  sessionId: input?.session_id || undefined,
82
+ sessionName: input?.session_name || undefined,
110
83
  version: input?.version || undefined,
111
- outputStyle: input?.output_style?.name || undefined
84
+ outputStyle: input?.output_style?.name || undefined,
85
+ effort: input?.effort?.level || undefined,
86
+ thinking: typeof input?.thinking?.enabled === "boolean" ? input.thinking.enabled : undefined,
87
+ rateLimits: input?.rate_limits ? {
88
+ fiveHour: { usedPercentage: numberOr(input.rate_limits.five_hour?.used_percentage) },
89
+ sevenDay: { usedPercentage: numberOr(input.rate_limits.seven_day?.used_percentage) }
90
+ } : undefined
112
91
  };
113
92
  }
114
93
  function numberOr(v) {
@@ -253,6 +232,158 @@ function windowSize(ctx) {
253
232
  return ctx.model?.id?.includes("[1m]") ? 1e6 : 200000;
254
233
  }
255
234
 
235
+ // src/accounts.ts
236
+ import { existsSync as existsSync3, readFileSync as readFileSync3, realpathSync } from "fs";
237
+ import { homedir as homedir2 } from "os";
238
+ import { join as join2, resolve, sep } from "path";
239
+ var CONFIG_DIR_ENV = "CLAUDE_CONFIG_DIR";
240
+ var DEFAULT_CONFIG_SUBDIR = ".claude";
241
+ var STORE_PATH_ENV = "ACCOUNTS_STORE_PATH";
242
+ var ACCOUNTS_HOME_ENV = "ACCOUNTS_HOME";
243
+ var DEFAULT_ACCOUNTS_HOME = [".hasna", "accounts"];
244
+ var STORE_FILE = "accounts.json";
245
+ var PROFILES_SUBDIR = "profiles";
246
+ var STATE_FILE_PREFERRED = ".config.json";
247
+ var STATE_FILE_PREFIX = ".claude";
248
+ var STATE_FILE_SUFFIX = ".json";
249
+ var CUSTOM_OAUTH_ENV = "CLAUDE_CODE_CUSTOM_OAUTH_URL";
250
+ var OAUTH_VARIANTS = ["", "-custom-oauth", "-local-oauth", "-staging-oauth"];
251
+ function str(value) {
252
+ return typeof value === "string" && value.trim() ? value.trim() : null;
253
+ }
254
+ function home(env) {
255
+ return str(env.HOME) ?? homedir2();
256
+ }
257
+ function expandHome(path, env) {
258
+ return path === "~" || path.startsWith(`~${sep}`) ? join2(home(env), path.slice(1)) : path;
259
+ }
260
+ function canonical(path, env) {
261
+ const abs = resolve(expandHome(path, env));
262
+ try {
263
+ return realpathSync(abs);
264
+ } catch {
265
+ return abs;
266
+ }
267
+ }
268
+ function readJson(path) {
269
+ try {
270
+ return JSON.parse(readFileSync3(path, "utf8"));
271
+ } catch {
272
+ return null;
273
+ }
274
+ }
275
+ var ACCOUNTS_STORAGE_MODULE = "@hasna/accounts/storage";
276
+ var storagePromise;
277
+ function accountsStorage() {
278
+ storagePromise ??= import(ACCOUNTS_STORAGE_MODULE).then((mod) => typeof mod?.storePath === "function" && typeof mod?.profilesDir === "function" ? mod : null, () => null);
279
+ return storagePromise;
280
+ }
281
+ async function accountsPaths(env) {
282
+ const homeOverride = str(env[ACCOUNTS_HOME_ENV]);
283
+ const storeOverride = str(env[STORE_PATH_ENV]);
284
+ if (!homeOverride && !storeOverride) {
285
+ const storage = await accountsStorage();
286
+ try {
287
+ if (storage)
288
+ return { store: storage.storePath(), profiles: storage.profilesDir() };
289
+ } catch {}
290
+ }
291
+ const base = homeOverride ?? join2(home(env), ...DEFAULT_ACCOUNTS_HOME);
292
+ return {
293
+ store: storeOverride ?? join2(base, STORE_FILE),
294
+ profiles: join2(base, PROFILES_SUBDIR)
295
+ };
296
+ }
297
+ function registryEntry(store, configDir, env, tool) {
298
+ if (!existsSync3(store))
299
+ return null;
300
+ const entries = readJson(store)?.profiles;
301
+ if (!Array.isArray(entries))
302
+ return null;
303
+ let untyped = null;
304
+ let first = null;
305
+ for (const entry of entries) {
306
+ const dir = str(entry?.dir);
307
+ if (!dir || canonical(dir, env) !== configDir)
308
+ continue;
309
+ const entryTool = str(entry?.tool);
310
+ if (tool && entryTool === tool)
311
+ return entry;
312
+ if (!entryTool)
313
+ untyped ??= entry;
314
+ first ??= entry;
315
+ }
316
+ return tool ? untyped : first;
317
+ }
318
+ function layoutProfile(profilesRoot, configDir, env) {
319
+ if (!existsSync3(configDir))
320
+ return null;
321
+ const prefix = canonical(profilesRoot, env) + sep;
322
+ if (!configDir.startsWith(prefix))
323
+ return null;
324
+ const parts = configDir.slice(prefix.length).split(sep);
325
+ if (parts.length !== 2)
326
+ return null;
327
+ const [tool, profile] = parts;
328
+ if (!tool || !profile)
329
+ return null;
330
+ return { configDir, profile, tool, source: "layout" };
331
+ }
332
+ function sessionConfigDir(env = process.env) {
333
+ return canonical(str(env[CONFIG_DIR_ENV]) ?? join2(home(env), DEFAULT_CONFIG_SUBDIR), env);
334
+ }
335
+ function sessionStateFile(env = process.env) {
336
+ const base = canonical(str(env[CONFIG_DIR_ENV]) ?? home(env), env);
337
+ const preferred = join2(base, STATE_FILE_PREFERRED);
338
+ if (existsSync3(preferred))
339
+ return preferred;
340
+ const variants = str(env[CUSTOM_OAUTH_ENV]) ? ["-custom-oauth", ...OAUTH_VARIANTS] : OAUTH_VARIANTS;
341
+ for (const variant of variants) {
342
+ const candidate = join2(base, `${STATE_FILE_PREFIX}${variant}${STATE_FILE_SUFFIX}`);
343
+ if (existsSync3(candidate))
344
+ return candidate;
345
+ }
346
+ return null;
347
+ }
348
+ async function sessionAccount(env = process.env, tool) {
349
+ const configDir = sessionConfigDir(env);
350
+ const unresolved = { configDir, profile: null, tool: null, source: null };
351
+ try {
352
+ const paths = await accountsPaths(env);
353
+ const entry = registryEntry(paths.store, configDir, env, tool);
354
+ const name = str(entry?.name);
355
+ if (name)
356
+ return { configDir, profile: name, tool: str(entry?.tool), source: "registry" };
357
+ return layoutProfile(paths.profiles, configDir, env) ?? unresolved;
358
+ } catch {
359
+ return unresolved;
360
+ }
361
+ }
362
+ async function sessionAccountEmail(env = process.env, tool) {
363
+ try {
364
+ const stateFile = sessionStateFile(env);
365
+ const live = stateFile ? str(readJson(stateFile)?.oauthAccount?.emailAddress) : null;
366
+ if (live)
367
+ return live;
368
+ const paths = await accountsPaths(env);
369
+ return str(registryEntry(paths.store, sessionConfigDir(env), env, tool)?.email);
370
+ } catch {
371
+ return null;
372
+ }
373
+ }
374
+
375
+ // src/settings.ts
376
+ import { readFileSync as readFileSync4 } from "fs";
377
+ import { join as join3 } from "path";
378
+ function sessionFastMode(env = process.env) {
379
+ try {
380
+ const raw = readFileSync4(join3(sessionConfigDir(env), "settings.json"), "utf8");
381
+ return JSON.parse(raw)?.fastMode === true;
382
+ } catch {
383
+ return false;
384
+ }
385
+ }
386
+
256
387
  // src/segments/index.ts
257
388
  function friendlyModel(id) {
258
389
  let tag = null;
@@ -270,6 +401,14 @@ function friendlyModel(id) {
270
401
  return { name: name || mid, tag };
271
402
  }
272
403
  var dir = (ctx) => ctx.cwd || process.cwd();
404
+ function modelName(ctx) {
405
+ if (ctx.model?.displayName)
406
+ return ctx.model.displayName.toLowerCase();
407
+ return ctx.model?.id ? friendlyModel(ctx.model.id).name : null;
408
+ }
409
+ function limitPercent(pct) {
410
+ return typeof pct === "number" ? pct : 0;
411
+ }
273
412
  var segments = [
274
413
  {
275
414
  id: "machine",
@@ -348,6 +487,73 @@ var segments = [
348
487
  return tag ? `${name} ${tag}` : name;
349
488
  }
350
489
  },
490
+ {
491
+ id: "model-with-reasoning",
492
+ description: "Model name with its reasoning effort (or thinking, when on)",
493
+ defaultEnabled: false,
494
+ color: "cyan",
495
+ render(ctx) {
496
+ const name = modelName(ctx);
497
+ if (!name)
498
+ return null;
499
+ if (ctx.effort)
500
+ return `${name} (${ctx.effort.toLowerCase()})`;
501
+ if (ctx.thinking)
502
+ return `${name} (thinking)`;
503
+ return name;
504
+ }
505
+ },
506
+ {
507
+ id: "fast-mode",
508
+ description: `Shows "fast" when fast mode is on for this session's config dir`,
509
+ defaultEnabled: false,
510
+ color: "yellow",
511
+ render: () => sessionFastMode(process.env) ? "fast" : null
512
+ },
513
+ {
514
+ id: "auth-profile",
515
+ description: "Account profile this session runs as (omitted when unmanaged)",
516
+ defaultEnabled: false,
517
+ color: "magenta",
518
+ async render(ctx) {
519
+ const account = await sessionAccount(process.env, ctx.provider);
520
+ return account.profile ?? await sessionAccountEmail(process.env, ctx.provider);
521
+ }
522
+ },
523
+ {
524
+ id: "auth-email",
525
+ description: "Account email this session is logged in as (omitted when unknown)",
526
+ defaultEnabled: false,
527
+ color: "magenta",
528
+ render: (ctx) => sessionAccountEmail(process.env, ctx.provider)
529
+ },
530
+ {
531
+ id: "five-hour-limit",
532
+ description: "Percentage of the 5-hour rate limit used (omitted when unreported)",
533
+ defaultEnabled: false,
534
+ color: (ctx) => limitPercent(ctx.rateLimits?.fiveHour?.usedPercentage) >= 80 ? "red" : "yellow",
535
+ render(ctx) {
536
+ const pct = ctx.rateLimits?.fiveHour?.usedPercentage;
537
+ return typeof pct === "number" ? `5h:${Math.round(pct)}%` : null;
538
+ }
539
+ },
540
+ {
541
+ id: "seven-day-limit",
542
+ description: "Percentage of the 7-day rate limit used (omitted when unreported)",
543
+ defaultEnabled: false,
544
+ color: (ctx) => limitPercent(ctx.rateLimits?.sevenDay?.usedPercentage) >= 80 ? "red" : "yellow",
545
+ render(ctx) {
546
+ const pct = ctx.rateLimits?.sevenDay?.usedPercentage;
547
+ return typeof pct === "number" ? `7d:${Math.round(pct)}%` : null;
548
+ }
549
+ },
550
+ {
551
+ id: "thread-title",
552
+ description: "Thread title set with /rename (omitted until named)",
553
+ defaultEnabled: false,
554
+ color: "blue",
555
+ render: (ctx) => ctx.sessionName || null
556
+ },
351
557
  {
352
558
  id: "context-used",
353
559
  description: "Percentage of context window used (omitted when unknown)",
@@ -431,6 +637,12 @@ var segments = [
431
637
  description: "Session identifier (short)",
432
638
  defaultEnabled: false,
433
639
  render: (ctx) => ctx.sessionId ? ctx.sessionId.split("-")[0] : null
640
+ },
641
+ {
642
+ id: "newline",
643
+ description: "Row break \u2014 segments after it render on the next line",
644
+ defaultEnabled: false,
645
+ render: () => null
434
646
  }
435
647
  ];
436
648
  function getSegment(id) {
@@ -438,24 +650,51 @@ function getSegment(id) {
438
650
  }
439
651
 
440
652
  // src/render.ts
653
+ var ANSI = {
654
+ cyan: "\x1B[36m",
655
+ magenta: "\x1B[35m",
656
+ blue: "\x1B[34m",
657
+ green: "\x1B[32m",
658
+ yellow: "\x1B[33m",
659
+ red: "\x1B[31m",
660
+ bold: "\x1B[1m",
661
+ dim: "\x1B[2m"
662
+ };
663
+ var RESET = "\x1B[0m";
664
+ function colorsEnabled(cfg, env = process.env) {
665
+ if (env.NO_COLOR)
666
+ return false;
667
+ return cfg.colors !== false;
668
+ }
669
+ function paint(segment, text, ctx) {
670
+ const color = typeof segment.color === "function" ? segment.color(ctx) : segment.color;
671
+ const code = color ? ANSI[color] : undefined;
672
+ return code ? `${code}${text}${RESET}` : text;
673
+ }
441
674
  async function renderLine(ctx, cfg) {
442
- const parts = [];
675
+ const colorize = colorsEnabled(cfg);
676
+ const rows = [[]];
443
677
  for (const id of cfg.segments) {
678
+ if (id === "newline") {
679
+ rows.push([]);
680
+ continue;
681
+ }
444
682
  const segment = getSegment(id);
445
683
  if (!segment)
446
684
  continue;
447
685
  try {
448
686
  const out = await segment.render(ctx);
449
687
  if (out)
450
- parts.push(out);
688
+ rows[rows.length - 1].push(colorize ? paint(segment, out, ctx) : out);
451
689
  } catch {}
452
690
  }
453
- return parts.join(cfg.separator);
691
+ return rows.map((parts) => parts.join(cfg.separator)).filter((row) => row.length > 0).join(`
692
+ `);
454
693
  }
455
694
  // package.json
456
695
  var package_default = {
457
696
  name: "@hasna/statusline",
458
- version: "0.0.2",
697
+ version: "0.0.4",
459
698
  description: "Tiny composable statusline CLI for AI coding agents \u2014 pre-built toggleable segments for Claude Code, with a provider model for others",
460
699
  license: "Apache-2.0",
461
700
  type: "module",
@@ -515,30 +754,52 @@ var package_default = {
515
754
  bun: ">=1.0.0"
516
755
  },
517
756
  dependencies: {
518
- "@modelcontextprotocol/sdk": "^1.12.1",
519
- zod: "^3.25.76"
757
+ "@modelcontextprotocol/sdk": "1.12.1",
758
+ zod: "3.25.76"
520
759
  },
521
760
  devDependencies: {
522
761
  "@types/bun": "^1.2.4",
523
- typescript: "^5.7.3"
762
+ typescript: "5.9.3"
524
763
  }
525
764
  };
526
765
  // src/install.ts
527
- import { copyFileSync, existsSync as existsSync3, mkdirSync as mkdirSync2, readFileSync as readFileSync3, writeFileSync as writeFileSync2 } from "fs";
528
- import { homedir as homedir2 } from "os";
529
- import { dirname as dirname2, join as join2 } from "path";
530
- function installClaude(settingsPath = join2(homedir2(), ".claude", "settings.json")) {
766
+ import { copyFileSync, existsSync as existsSync4, mkdirSync as mkdirSync2, readFileSync as readFileSync5, writeFileSync as writeFileSync2 } from "fs";
767
+ import { homedir as homedir3 } from "os";
768
+ import { dirname as dirname2, join as join4, resolve as resolve2 } from "path";
769
+ function defaultSettingsPath(env) {
770
+ return join4(env.HOME || homedir3(), ".claude", "settings.json");
771
+ }
772
+ function claudeSettingsPath(env = process.env) {
773
+ const home2 = env.HOME || homedir3();
774
+ const raw = env.CLAUDE_CONFIG_DIR?.trim();
775
+ if (!raw)
776
+ return defaultSettingsPath(env);
777
+ const configDir = raw === "~" || raw.startsWith("~/") ? join4(home2, raw.slice(1)) : raw;
778
+ return join4(configDir, "settings.json");
779
+ }
780
+ function installClaude(settingsPath, env = process.env) {
781
+ const target = settingsPath ?? claudeSettingsPath(env);
531
782
  const binary = Bun.which("statusline") || "statusline";
532
783
  let settings = {};
533
- if (existsSync3(settingsPath)) {
534
- settings = JSON.parse(readFileSync3(settingsPath, "utf8"));
535
- copyFileSync(settingsPath, `${settingsPath}.bak-statusline`);
784
+ if (existsSync4(target)) {
785
+ try {
786
+ settings = JSON.parse(readFileSync5(target, "utf8"));
787
+ } catch (error) {
788
+ throw new Error(`${target} is not valid JSON \u2014 fix or move it first`);
789
+ }
790
+ copyFileSync(target, `${target}.bak-statusline`);
536
791
  }
537
- settings.statusLine = { type: "command", command: `${binary} render` };
538
- mkdirSync2(dirname2(settingsPath), { recursive: true });
539
- writeFileSync2(settingsPath, JSON.stringify(settings, null, 2) + `
792
+ settings.statusLine = { ...settings.statusLine, type: "command", command: `${binary} render` };
793
+ const configDir = dirname2(target);
794
+ if (!existsSync4(configDir)) {
795
+ if (resolve2(target) !== resolve2(defaultSettingsPath(env))) {
796
+ throw new Error(`config dir ${configDir} does not exist \u2014 create the profile first`);
797
+ }
798
+ mkdirSync2(configDir, { recursive: true });
799
+ }
800
+ writeFileSync2(target, JSON.stringify(settings, null, 2) + `
540
801
  `);
541
- return settingsPath;
802
+ return target;
542
803
  }
543
804
 
544
805
  // src/index.ts
@@ -590,6 +851,9 @@ function setSeparator(separator, config = loadConfig()) {
590
851
  throw new Error("separator must be a non-empty string");
591
852
  return { config: { ...config, separator }, changed: ["separator"] };
592
853
  }
854
+ function setColors(colors, config = loadConfig()) {
855
+ return { config: { ...config, colors }, changed: ["colors"] };
856
+ }
593
857
  function resetStatuslineConfig() {
594
858
  return defaultConfig();
595
859
  }
@@ -602,7 +866,11 @@ function sampleClaudeInput(cwd = process.cwd()) {
602
866
  model: { id: "claude-fable-5[1m]", display_name: "Fable" },
603
867
  cost: { total_cost_usd: 12.34, total_duration_ms: 5400000, total_lines_added: 42, total_lines_removed: 7 },
604
868
  version: statuslineVersion,
605
- session_id: "preview0-0000-0000-0000-000000000000"
869
+ session_id: "preview0-0000-0000-0000-000000000000",
870
+ session_name: "preview thread",
871
+ effort: { level: "high" },
872
+ thinking: { enabled: true },
873
+ rate_limits: { five_hour: { used_percentage: 42 } }
606
874
  };
607
875
  }
608
876
  async function previewStatusline(config = loadConfig(), cwd = process.cwd()) {
@@ -658,6 +926,7 @@ usage:
658
926
  statusline disable <id...> disable segments
659
927
  statusline order <id...> set the exact segment order (also defines enabled set)
660
928
  statusline separator <str> set the separator (default " \xB7 ")
929
+ statusline colors <on|off> toggle segment colours (default on)
661
930
  statusline preview render with a sample payload from the current directory
662
931
  statusline reset restore default config
663
932
  statusline install [claude] wire into Claude Code (~/.claude/settings.json)
@@ -978,6 +1247,15 @@ order: ${result.config.segments.join(", ") || "(none)"}`);
978
1247
  console.log(`separator: "${result.config.separator}"`);
979
1248
  return;
980
1249
  }
1250
+ case "colors": {
1251
+ const value = args[0];
1252
+ if (value !== "on" && value !== "off")
1253
+ throw new Error("usage: statusline colors <on|off>");
1254
+ const result = setColors(value === "on", loadConfig());
1255
+ saveConfig(result.config);
1256
+ console.log(`colors: ${value}`);
1257
+ return;
1258
+ }
981
1259
  case "reset": {
982
1260
  saveConfig(defaultConfig());
983
1261
  console.log(`reset to defaults (${configPath()})`);
package/dist/config.d.ts CHANGED
@@ -2,6 +2,8 @@ export interface StatuslineConfig {
2
2
  separator: string;
3
3
  /** Ordered list of enabled segment ids. */
4
4
  segments: string[];
5
+ /** Colourize segments that ask for it (default on; off when `NO_COLOR` is set). */
6
+ colors?: boolean;
5
7
  }
6
8
  export declare function defaultConfig(): StatuslineConfig;
7
9
  export declare function configPath(path?: string): string;
package/dist/index.d.ts CHANGED
@@ -1,11 +1,12 @@
1
1
  import { type StatuslineConfig } from "./config.js";
2
+ export { sessionAccount, sessionAccountEmail, sessionConfigDir, sessionStateFile, type SessionAccount, } from "./accounts.js";
2
3
  export { defaultConfig, loadConfig, saveConfig, configPath, type StatuslineConfig } from "./config.js";
3
4
  export { contextUsage, type ContextUsage } from "./context-window.js";
4
5
  export { compactAge, compactDuration, compactNum, money } from "./format.js";
5
6
  export { gitBranch, gitProjectName, gitRoot, lastCommitEpoch, trackedLineCount } from "./git.js";
6
- export { installClaude } from "./install.js";
7
+ export { claudeSettingsPath, installClaude } from "./install.js";
7
8
  export { parseClaudeInput } from "./providers/claude.js";
8
- export type { Segment, StatusContext } from "./providers/types.js";
9
+ export type { Segment, SegmentColor, StatusContext } from "./providers/types.js";
9
10
  export { renderLine } from "./render.js";
10
11
  export { getSegment, segments } from "./segments/index.js";
11
12
  export declare const statuslineVersion: string;
@@ -25,6 +26,7 @@ export declare function enableSegments(ids: string[], config?: StatuslineConfig)
25
26
  export declare function disableSegments(ids: string[], config?: StatuslineConfig): ConfigUpdateResult;
26
27
  export declare function orderSegments(ids: string[], config?: StatuslineConfig): ConfigUpdateResult;
27
28
  export declare function setSeparator(separator: string, config?: StatuslineConfig): ConfigUpdateResult;
29
+ export declare function setColors(colors: boolean, config?: StatuslineConfig): ConfigUpdateResult;
28
30
  export declare function resetStatuslineConfig(): StatuslineConfig;
29
31
  export declare function renderStatusline(input?: Record<string, unknown>, config?: StatuslineConfig): Promise<string>;
30
32
  export declare function sampleClaudeInput(cwd?: string): Record<string, unknown>;