@nutthead/cc-statusline 0.3.0 → 0.4.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 CHANGED
@@ -2,23 +2,23 @@
2
2
 
3
3
  Themeable status line provider for Claude Code.
4
4
 
5
+ Requires [Bun](https://bun.com).
6
+
5
7
  ## Preview
6
8
 
7
- The default theme renders a two-row status line:
9
+ ### Default Theme
8
10
 
9
- ```
10
- 🤖 opus-4-5 📃 93aba123-d123-4a6b-b1b5-2f3e7d111317 🗂️ ~/a/…/statusline
11
- 🌿 main 0.38%
12
- ```
11
+ Two-row layout with model, session, project directory, git status, and context usage:
12
+
13
+ ![Default theme preview](assets/default-preview.png)
14
+
15
+ ### Powerline Theme
16
+
17
+ Single-row powerline-style segments with muted dark backgrounds and arrow separators:
13
18
 
14
- - **Row 1** (left to right):
15
- - 🤖 Model ID (abbreviated)
16
- - 📃 Session ID
17
- - 🗂️ Project directory (compressed and telescoped)
19
+ ![Powerline theme preview](assets/powerline-preview.png)
18
20
 
19
- - **Row 2** (left to right):
20
- - 🌿 Git branch name (or 🪾 commit hash if detached, 💾 if not a repo, 💥 on error)
21
- - Context window usage percentage
21
+ Wraps to multiple lines when segments exceed terminal width. Select with `--theme powerline`.
22
22
 
23
23
  ## Install
24
24
 
@@ -39,20 +39,21 @@ Then add to `~/.claude/settings.json`:
39
39
 
40
40
  Use `--overwrite` to replace an existing installation.
41
41
 
42
+ ## Themes
43
+
44
+ Built-in themes: `default` (two-row), `powerline` (single-row with powerline arrows).
45
+
46
+ Use `--theme powerline` to select a built-in theme.
47
+
42
48
  ## Custom Themes
43
49
 
44
- Create a JS file that default-exports a theme function (e.g. `~/.config/cc-statusline/theme.js`):
50
+ Create a JS file that default-exports an async theme function (e.g. `~/.config/cc-statusline/theme.js`):
45
51
 
46
52
  ```js
47
- export default function theme(input) {
53
+ export default async function theme(input) {
48
54
  if (!input) return "";
49
-
50
55
  const status = JSON.parse(input);
51
- const dir = status.workspace.current_dir;
52
- const model = status.model.display_name;
53
- const ctx = status.context_window.used_percentage ?? 0;
54
-
55
- return `${model} | ${dir} | ctx: ${Math.round(ctx)}%`;
56
+ return `${status.model.display_name} | ${status.workspace.current_dir}`;
56
57
  }
57
58
  ```
58
59
 
@@ -62,7 +63,7 @@ Then point to it in `~/.claude/settings.json`:
62
63
  {
63
64
  "statusLine": {
64
65
  "type": "command",
65
- "command": "~/.claude/statusline --theme ~/.config/cc-statusline/theme.js"
66
+ "command": "~/.claude/statusline --theme-file ~/.config/cc-statusline/theme.js"
66
67
  }
67
68
  }
68
69
  ```
Binary file
Binary file
@@ -2,6 +2,12 @@
2
2
  import { createRequire } from "node:module";
3
3
  var __require = /* @__PURE__ */ createRequire(import.meta.url);
4
4
 
5
+ // src/cli.ts
6
+ import { spawnSync } from "node:child_process";
7
+ import { access, copyFile, mkdir, unlink } from "node:fs/promises";
8
+ import { homedir } from "node:os";
9
+ import { join } from "node:path";
10
+
5
11
  // node_modules/meow/build/index.js
6
12
  import process4 from "node:process";
7
13
 
@@ -9408,10 +9414,6 @@ var meow = (helpText, options = {}) => {
9408
9414
  };
9409
9415
 
9410
9416
  // src/cli.ts
9411
- import { spawnSync } from "node:child_process";
9412
- import { access, mkdir, copyFile, unlink } from "node:fs/promises";
9413
- import { homedir } from "node:os";
9414
- import { join } from "node:path";
9415
9417
  var BINARY_NAME = "statusline";
9416
9418
  var CLAUDE_DIR = join(homedir(), ".claude");
9417
9419
  var TARGET_PATH = join(CLAUDE_DIR, BINARY_NAME);
package/index.ts CHANGED
@@ -1,37 +1,66 @@
1
- import meow from "meow";
2
1
  import { configure } from "@logtape/logtape";
2
+ import chalk from "chalk";
3
+ import meow from "meow";
3
4
  import { log, logtapeConfig } from "./src/logging";
4
- import { defaultTheme } from "./src/themes/defaultTheme";
5
5
  import { loadTheme } from "./src/theme/loadTheme";
6
+ import { defaultTheme } from "./src/themes/defaultTheme";
7
+ import { powerlineTheme } from "./src/themes/powerlineTheme";
8
+
9
+ // Force truecolor output — chalk auto-detection fails when invoked via piped stdin
10
+ chalk.level = 3;
6
11
 
7
12
  await configure(logtapeConfig);
8
13
 
14
+ const BUILTIN_THEMES: Record<string, (input?: string) => Promise<string>> = {
15
+ default: defaultTheme,
16
+ powerline: powerlineTheme,
17
+ };
18
+
9
19
  const cli = meow(
10
20
  `
11
21
  Usage
12
22
  $ cc-statusline
13
23
 
14
24
  Options
15
- --theme, -t Use a custom theme
25
+ --theme, -t Use a built-in theme (powerline)
26
+ --theme-file, -f Use a custom theme file
16
27
 
17
28
  Examples
18
29
  $ cc-statusline --theme ~/.config/cc-statusline/basic.js
19
30
  `,
20
31
  {
21
- importMeta: import.meta, // This is required
32
+ importMeta: import.meta,
22
33
  flags: {
23
34
  theme: {
24
35
  type: "string",
25
36
  shortFlag: "t",
26
37
  isRequired: false,
27
38
  },
39
+ themeFile: {
40
+ type: "string",
41
+ shortFlag: "f",
42
+ isRequired: false,
43
+ },
28
44
  },
29
45
  },
30
46
  );
31
47
 
32
- const resolvedTheme =
33
- (cli.flags.theme && (await loadTheme(cli.flags.theme))) || defaultTheme;
48
+ if (cli.flags.theme && cli.flags.themeFile) {
49
+ console.error("Error: --theme and --theme-file are mutually exclusive");
50
+ process.exit(1);
51
+ }
52
+
53
+ let resolvedTheme: (input?: string) => Promise<string>;
54
+ if (cli.flags.theme) {
55
+ const selectedTheme = cli.flags.theme;
56
+ resolvedTheme = BUILTIN_THEMES[selectedTheme] ?? defaultTheme;
57
+ } else if (cli.flags.themeFile) {
58
+ const selectedTheme = cli.flags.themeFile;
59
+ resolvedTheme = (await loadTheme(selectedTheme)) || defaultTheme;
60
+ } else {
61
+ resolvedTheme = defaultTheme;
62
+ }
34
63
 
35
- const input = await Bun.stdin.stream().json();
36
- log.debug("input: {input}", input);
64
+ const input = await Bun.stdin.stream().text();
65
+ log.debug("input: {input}", { input });
37
66
  console.log(await resolvedTheme(input));
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@nutthead/cc-statusline",
3
3
  "description": "Status Line for Claude Code",
4
- "version": "0.3.0",
4
+ "version": "0.4.0",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "cc-statusline": "bin/cc-statusline.js"
@@ -44,7 +44,7 @@
44
44
  "dependencies": {
45
45
  "@logtape/file": "^1.3.6",
46
46
  "@logtape/logtape": "^1.3.6",
47
- "ansi-colors": "^4.1.3",
47
+ "chalk": "^5.6.2",
48
48
  "meow": "^14.0.0",
49
49
  "neverthrow": "^8.2.0",
50
50
  "simple-git": "^3.30.0",
package/src/cli.ts CHANGED
@@ -1,10 +1,10 @@
1
1
  #!/usr/bin/env node
2
2
 
3
- import meow from "meow";
4
3
  import { spawnSync } from "node:child_process";
5
- import { access, mkdir, copyFile, unlink } from "node:fs/promises";
4
+ import { access, copyFile, mkdir, unlink } from "node:fs/promises";
6
5
  import { homedir } from "node:os";
7
6
  import { join } from "node:path";
7
+ import meow from "meow";
8
8
 
9
9
  const BINARY_NAME = "statusline";
10
10
  const CLAUDE_DIR = join(homedir(), ".claude");
package/src/logging.ts CHANGED
@@ -1,6 +1,6 @@
1
- import { getFileSink } from "@logtape/file";
2
- import { getLogger, type Config } from "@logtape/logtape";
3
1
  import { homedir } from "node:os";
2
+ import { getFileSink } from "@logtape/file";
3
+ import { type Config, getLogger } from "@logtape/logtape";
4
4
 
5
5
  const logtapeConfig: Config<"file", string> = {
6
6
  sinks: {
@@ -30,13 +30,17 @@ const statusSchema = z.object({
30
30
  .nullable(),
31
31
  used_percentage: z.number().nullable(),
32
32
  remaining_percentage: z.number().nullable(),
33
- vim: z.object({
34
- mode: z.enum(["INSERT", "NORMAL"]),
35
- }).optional(),
36
- agent: z.object({
37
- name: z.string(),
38
- type: z.string(),
39
- }).optional(),
33
+ vim: z
34
+ .object({
35
+ mode: z.enum(["INSERT", "NORMAL"]),
36
+ })
37
+ .optional(),
38
+ agent: z
39
+ .object({
40
+ name: z.string(),
41
+ type: z.string(),
42
+ })
43
+ .optional(),
40
44
  }),
41
45
  });
42
46
 
@@ -1,5 +1,5 @@
1
- import { join, resolve } from "node:path";
2
1
  import { homedir } from "node:os";
2
+ import { join, resolve } from "node:path";
3
3
 
4
4
  type ThemeFunction = (input?: string) => Promise<string>;
5
5
 
@@ -1,18 +1,33 @@
1
+ import chalk from "chalk";
2
+ import terminalSize from "terminal-size";
3
+ import { match } from "ts-pattern";
1
4
  import { ZodError } from "zod";
2
5
  import { log } from "../logging";
3
- import { statusSchema, type Status } from "../schema/statusLine";
6
+ import { type Status, statusSchema } from "../schema/statusLine";
7
+ import { currentBranchName } from "../utils/git";
4
8
  import { abbreviateModelId } from "../utils/model";
5
9
  import { compress, telescope } from "../utils/path";
6
10
  import { getDisplayWidth } from "../utils/term";
7
- import terminalSize from 'terminal-size';
8
- import { currentBranchName } from "../utils/git";
9
- import { match } from "ts-pattern";
10
11
 
11
12
  const HORIZONTAL_PADDING = 4;
12
13
 
13
- async function renderLine1(status: Status) : Promise<string> {
14
+ function colorizeUsageStatus(usedPercentage: number) {
15
+ if (usedPercentage === 0) {
16
+ return "";
17
+ } else if (usedPercentage <= 50) {
18
+ return chalk.green(`${usedPercentage}%`);
19
+ } else if (usedPercentage <= 75) {
20
+ return chalk.blue(`${usedPercentage}%`);
21
+ } else if (usedPercentage <= 87.5) {
22
+ return chalk.yellow(`${usedPercentage}%`);
23
+ } else {
24
+ return chalk.red(`${usedPercentage}%`);
25
+ }
26
+ }
27
+
28
+ async function renderLine1(status: Status): Promise<string> {
14
29
  const modelId = abbreviateModelId(status.model.id);
15
- const modelStatus = `🤖 ${modelId}`;
30
+ const modelStatus = `🤖 ${modelId} (${status.version})`;
16
31
 
17
32
  const sessionStatus = `📃 ${status.session_id}`;
18
33
 
@@ -28,28 +43,34 @@ async function renderLine1(status: Status) : Promise<string> {
28
43
  const leftGap = Math.floor(remainingSpace / 2);
29
44
  const rightGap = Math.ceil(remainingSpace / 2);
30
45
 
31
- return modelStatus + " ".repeat(leftGap) + sessionStatus + " ".repeat(rightGap) + projectStatus;
46
+ return (
47
+ modelStatus +
48
+ " ".repeat(leftGap) +
49
+ sessionStatus +
50
+ " ".repeat(rightGap) +
51
+ projectStatus
52
+ );
32
53
  }
33
54
 
34
- async function renderLine2(status: Status) : Promise<string> {
55
+ async function renderLine2(status: Status): Promise<string> {
35
56
  const branch = await currentBranchName();
36
57
  const branchStatus = match(branch)
37
- .with({status: "none"}, () => {
58
+ .with({ status: "none" }, () => {
38
59
  return `💾`;
39
60
  })
40
- .with({status: "branch"}, ({name}) => {
61
+ .with({ status: "branch" }, ({ name }) => {
41
62
  return `🌿 ${name}`;
42
63
  })
43
- .with({status: "detached"}, ({commit}) => {
64
+ .with({ status: "detached" }, ({ commit }) => {
44
65
  return `🪾 ${commit}`;
45
66
  })
46
- .with({status: "error"}, () => {
67
+ .with({ status: "error" }, () => {
47
68
  return `💥`;
48
69
  })
49
70
  .exhaustive();
50
71
 
51
72
  const usedPercentage = status.context_window.used_percentage ?? 0;
52
- const usageStatus = usedPercentage === 0 ? '' : `${usedPercentage}%`
73
+ const usageStatus = usedPercentage === 0 ? "" : `${usedPercentage}%`;
53
74
 
54
75
  const statusWidth = terminalSize().columns - HORIZONTAL_PADDING;
55
76
  const branchWidth = getDisplayWidth(branchStatus);
@@ -57,7 +78,9 @@ async function renderLine2(status: Status) : Promise<string> {
57
78
 
58
79
  const gap = statusWidth - branchWidth - usageWidth;
59
80
 
60
- return branchStatus + " ".repeat(gap - 1) + usageStatus;
81
+ return (
82
+ branchStatus + " ".repeat(gap - 1) + colorizeUsageStatus(usedPercentage)
83
+ );
61
84
  }
62
85
 
63
86
  async function renderTheme(status: Status): Promise<string> {
@@ -69,14 +92,20 @@ async function renderTheme(status: Status): Promise<string> {
69
92
  async function defaultTheme(input?: string): Promise<string> {
70
93
  if (input) {
71
94
  try {
72
- const status = statusSchema.parse(input);
95
+ const parsed = JSON.parse(input);
96
+ const status = statusSchema.parse(parsed);
73
97
  return renderTheme(status);
74
98
  } catch (e) {
75
- if (e instanceof ZodError) {
76
- log.error("Failed to parse input: {error}", {
77
- error: JSON.stringify(e.issues),
78
- });
79
- }
99
+ const error =
100
+ e instanceof ZodError
101
+ ? JSON.stringify(e.issues)
102
+ : e instanceof Error
103
+ ? e.message
104
+ : JSON.stringify(e);
105
+
106
+ log.error("Failed to parse input: {error}", {
107
+ error: error,
108
+ });
80
109
  }
81
110
  }
82
111
 
@@ -0,0 +1,150 @@
1
+ import chalk from "chalk";
2
+ import terminalSize from "terminal-size";
3
+ import { match } from "ts-pattern";
4
+ import { ZodError } from "zod";
5
+ import { log } from "../logging";
6
+ import { type Status, statusSchema } from "../schema/statusLine";
7
+ import { currentBranchName } from "../utils/git";
8
+ import { abbreviateModelId } from "../utils/model";
9
+ import { compress, telescope } from "../utils/path";
10
+ import { getDisplayWidth } from "../utils/term";
11
+
12
+ // Right-pointing solid triangle (filled separator)
13
+ const RPST = "\uE0B0";
14
+
15
+ type Rgb = [number, number, number];
16
+
17
+ interface Segment {
18
+ text: string;
19
+ bg: Rgb;
20
+ }
21
+
22
+ // Muted dark background colors for each segment type
23
+ const BG_MODEL: Rgb = [30, 40, 80];
24
+ const BG_SESSION: Rgb = [60, 30, 70];
25
+ const BG_PROJECT: Rgb = [25, 65, 75];
26
+ const BG_GIT: Rgb = [30, 65, 40];
27
+ const BG_USAGE: Rgb = [85, 70, 20];
28
+
29
+ /** Apply white foreground and RGB background color to text. */
30
+ function styleContent(text: string, bg: Rgb): string {
31
+ return chalk.bgRgb(...bg)(chalk.white(text));
32
+ }
33
+
34
+ /** Render a separator arrow transitioning from one bg color to another (or to default). */
35
+ function styleSep(from: Rgb, to?: Rgb): string {
36
+ const arrow = chalk.rgb(...from)(RPST);
37
+ return to ? chalk.bgRgb(...to)(arrow) : arrow;
38
+ }
39
+
40
+ /** Render an array of segments into a single powerline bar. */
41
+ function renderBar(parts: Segment[]): string {
42
+ let result = "";
43
+ let prevBg: Rgb | undefined;
44
+ for (const seg of parts) {
45
+ if (prevBg) {
46
+ result += styleSep(prevBg, seg.bg);
47
+ }
48
+ result += styleContent(` ${seg.text} `, seg.bg);
49
+ prevBg = seg.bg;
50
+ }
51
+ if (prevBg) {
52
+ result += styleSep(prevBg);
53
+ }
54
+ return result;
55
+ }
56
+
57
+ /** Lay out segments across lines, wrapping when a segment would exceed maxWidth. */
58
+ function layoutSegments(segments: Segment[], maxWidth: number): string {
59
+ const lines: string[] = [];
60
+ let currentParts: Segment[] = [];
61
+ let projectedWidth = 0;
62
+
63
+ for (const seg of segments) {
64
+ const contentWidth = getDisplayWidth(` ${seg.text} `);
65
+
66
+ if (projectedWidth === 0) {
67
+ // First segment on this line
68
+ projectedWidth = contentWidth + 1;
69
+ currentParts.push(seg);
70
+ } else if (projectedWidth + contentWidth + 1 <= maxWidth) {
71
+ // Fits on current line
72
+ projectedWidth += contentWidth + 1;
73
+ currentParts.push(seg);
74
+ } else {
75
+ // Doesn't fit — close current line and start a new one
76
+ lines.push(renderBar(currentParts));
77
+ currentParts = [seg];
78
+ projectedWidth = contentWidth + 1;
79
+ }
80
+ }
81
+
82
+ if (currentParts.length > 0) {
83
+ lines.push(renderBar(currentParts));
84
+ }
85
+
86
+ return lines.join("\n");
87
+ }
88
+
89
+ async function renderLine1(status: Status): Promise<string> {
90
+ const modelId = abbreviateModelId(status.model.id);
91
+ const modelText = `🤖 ${modelId} (${status.version})`;
92
+
93
+ const sessionText = `📃 ${status.session_id}`;
94
+
95
+ const projectDir = compress(telescope(status.workspace.project_dir));
96
+ const projectText = `🗂️ ${projectDir}`;
97
+
98
+ const branch = await currentBranchName();
99
+ const branchText = match(branch)
100
+ .with({ status: "none" }, () => `💾`)
101
+ .with({ status: "branch" }, ({ name }) => `🌿 ${name}`)
102
+ .with({ status: "detached" }, ({ commit }) => `🪾 ${commit}`)
103
+ .with({ status: "error" }, () => `💥`)
104
+ .exhaustive();
105
+
106
+ const usedPercentage = status.context_window.used_percentage ?? 0;
107
+
108
+ const segments: Segment[] = [
109
+ { text: modelText, bg: BG_MODEL },
110
+ { text: sessionText, bg: BG_SESSION },
111
+ { text: projectText, bg: BG_PROJECT },
112
+ { text: branchText, bg: BG_GIT },
113
+ ];
114
+
115
+ if (usedPercentage > 0) {
116
+ segments.push({ text: `${usedPercentage}%`, bg: BG_USAGE });
117
+ }
118
+
119
+ const maxWidth = terminalSize().columns;
120
+ return layoutSegments(segments, maxWidth);
121
+ }
122
+
123
+ async function renderTheme(status: Status): Promise<string> {
124
+ return renderLine1(status);
125
+ }
126
+
127
+ async function powerlineTheme(input?: string): Promise<string> {
128
+ if (input) {
129
+ try {
130
+ const parsed = JSON.parse(input);
131
+ const status = statusSchema.parse(parsed);
132
+ return renderTheme(status);
133
+ } catch (e) {
134
+ const error =
135
+ e instanceof ZodError
136
+ ? JSON.stringify(e.issues)
137
+ : e instanceof Error
138
+ ? e.message
139
+ : JSON.stringify(e);
140
+
141
+ log.error("Failed to parse input: {error}", {
142
+ error: error,
143
+ });
144
+ }
145
+ }
146
+
147
+ return "";
148
+ }
149
+
150
+ export { powerlineTheme };
package/src/utils/git.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { simpleGit, type SimpleGit } from "simple-git";
1
+ import { type SimpleGit, simpleGit } from "simple-git";
2
2
  import { match } from "ts-pattern";
3
3
 
4
4
  /**
package/src/utils/term.ts CHANGED
@@ -1,3 +1,5 @@
1
+ const EMOJI_REGEX = /\p{Extended_Pictographic}/gu;
2
+
1
3
  /**
2
4
  * Calculates the display width of a string, accounting for emojis
3
5
  * which occupy 2 character columns in terminal displays.
@@ -6,14 +8,13 @@
6
8
  * @returns The display width in columns
7
9
  */
8
10
  function getDisplayWidth(str: string): number {
9
- // Remove ANSI codes for width calculation
10
- const cleanStr = str.replace(/\u001b\[[0-9;]*m/g, "");
11
- // Count regular characters
12
- const charCount = Array.from(cleanStr).length;
13
- // Count emojis (each emoji counts as 2 characters)
14
- const emojiRegex = /\p{Extended_Pictographic}/gu;
15
- const emojiCount = (cleanStr.match(emojiRegex) || []).length;
16
- // Total width = characters + extra count for emojis (since each emoji is 2 wide)
11
+ // 1. Count regular characters
12
+ const charCount = Array.from(str).length;
13
+
14
+ // 2. Count emojis (each emoji counts as 2 characters)
15
+ const emojiCount = (str.match(EMOJI_REGEX) || []).length;
16
+
17
+ // 3. Total width = characters + extra count for emojis (since each emoji is 2 wide)
17
18
  return charCount + emojiCount;
18
19
  }
19
20