@nutthead/cc-statusline 0.2.2 → 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
@@ -1,17 +1,24 @@
1
1
  # statusline
2
2
 
3
- Custom status line for Claude Code.
3
+ Themeable status line provider for Claude Code.
4
+
5
+ Requires [Bun](https://bun.com).
4
6
 
5
7
  ## Preview
6
8
 
7
- The default theme renders a two-row status line:
9
+ ### Default Theme
8
10
 
9
- ```
10
- 🗂️ ~/C/b/statusline ⋮ 🌿 master
11
- opus-4-6 ⋮ 📝 fc9bbbee-...
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 shows the abbreviated working directory and git branch. Row 2 shows the model and session ID.
19
+ ![Powerline theme preview](assets/powerline-preview.png)
20
+
21
+ Wraps to multiple lines when segments exceed terminal width. Select with `--theme powerline`.
15
22
 
16
23
  ## Install
17
24
 
@@ -32,20 +39,21 @@ Then add to `~/.claude/settings.json`:
32
39
 
33
40
  Use `--overwrite` to replace an existing installation.
34
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
+
35
48
  ## Custom Themes
36
49
 
37
- 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`):
38
51
 
39
52
  ```js
40
- export default function theme(input) {
53
+ export default async function theme(input) {
41
54
  if (!input) return "";
42
-
43
55
  const status = JSON.parse(input);
44
- const dir = status.workspace.current_dir;
45
- const model = status.model.display_name;
46
- const ctx = status.context_window.used_percentage ?? 0;
47
-
48
- return `${model} | ${dir} | ctx: ${Math.round(ctx)}%`;
56
+ return `${status.model.display_name} | ${status.workspace.current_dir}`;
49
57
  }
50
58
  ```
51
59
 
@@ -55,7 +63,7 @@ Then point to it in `~/.claude/settings.json`:
55
63
  {
56
64
  "statusLine": {
57
65
  "type": "command",
58
- "command": "~/.claude/statusline --theme ~/.config/cc-statusline/theme.js"
66
+ "command": "~/.claude/statusline --theme-file ~/.config/cc-statusline/theme.js"
59
67
  }
60
68
  }
61
69
  ```
@@ -64,19 +72,32 @@ Then point to it in `~/.claude/settings.json`:
64
72
 
65
73
  The JSON object passed to your theme function contains these fields:
66
74
 
67
- | Field | Example |
68
- | -------------------------------- | ------------------------ |
69
- | `session_id` | `"f9abcdef-1a2b-..."` |
70
- | `version` | `"2.1.39"` |
71
- | `model.id` | `"claude-opus-4-6"` |
72
- | `model.display_name` | `"Claude Opus 4.6"` |
73
- | `workspace.current_dir` | `"/home/user/project"` |
74
- | `workspace.project_dir` | `"/home/user/project"` |
75
- | `context_window.used_percentage` | `42.5` |
76
- | `context_window.vim.mode` | `"INSERT"` or `"NORMAL"` |
77
- | `context_window.agent.name` | `"claude-code"` |
78
-
79
- See [`src/statusLineSchema.ts`](src/statusLineSchema.ts) for the full schema.
75
+ | Field | Example |
76
+ | ---------------------------------------------------------- | --------------------------------- |
77
+ | `session_id` | `"f9abcdef-1a2b-..."` |
78
+ | `transcript_path` | `"/home/user/.claude/transcript"` |
79
+ | `cwd` | `"/home/user/project"` |
80
+ | `model.id` | `"claude-opus-4-6"` |
81
+ | `model.display_name` | `"Claude Opus 4.6"` |
82
+ | `workspace.current_dir` | `"/home/user/project"` |
83
+ | `workspace.project_dir` | `"/home/user/project"` |
84
+ | `version` | `"2.1.39"` |
85
+ | `output_style.name` | `"Explanatory"` |
86
+ | `context_window.total_input_tokens` | `12345` |
87
+ | `context_window.total_output_tokens` | `6789` |
88
+ | `context_window.context_window_size` | `200000` |
89
+ | `context_window.current_usage` | `{ ... }` or `null` |
90
+ | `context_window.current_usage.input_tokens` | `1024` |
91
+ | `context_window.current_usage.output_tokens` | `512` |
92
+ | `context_window.current_usage.cache_creation_input_tokens` | `256` |
93
+ | `context_window.current_usage.cache_read_input_tokens` | `128` |
94
+ | `context_window.used_percentage` | `42.5` or `null` |
95
+ | `context_window.remaining_percentage` | `57.5` or `null` |
96
+ | `context_window.vim.mode` | `"INSERT"` or `"NORMAL"` |
97
+ | `context_window.agent.name` | `"claude-code"` |
98
+ | `context_window.agent.type` | `"main"` |
99
+
100
+ See [`src/schema/statusLine.ts`](src/schema/statusLine.ts) for the full schema.
80
101
 
81
102
  ## Troubleshooting
82
103
 
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,16 +9414,12 @@ 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);
9418
9420
  var cli = meow(`
9419
9421
  Usage
9420
- $ cc-statusline <command>
9422
+ $ bunx cc-statusline <command>
9421
9423
 
9422
9424
  Commands
9423
9425
  install Build and install statusline to ~/.claude/
@@ -9426,8 +9428,8 @@ var cli = meow(`
9426
9428
  --overwrite Overwrite existing file if it exists
9427
9429
 
9428
9430
  Examples
9429
- $ cc-statusline install
9430
- $ cc-statusline install --overwrite
9431
+ $ bunx @nutthead/cc-statusline install
9432
+ $ bunx @nutthead/cc-statusline install --overwrite
9431
9433
  `, {
9432
9434
  importMeta: import.meta,
9433
9435
  flags: {
package/biome.json ADDED
@@ -0,0 +1,34 @@
1
+ {
2
+ "$schema": "https://biomejs.dev/schemas/2.3.15/schema.json",
3
+ "vcs": {
4
+ "enabled": true,
5
+ "clientKind": "git",
6
+ "useIgnoreFile": true
7
+ },
8
+ "files": {
9
+ "ignoreUnknown": false
10
+ },
11
+ "formatter": {
12
+ "enabled": true,
13
+ "indentStyle": "space"
14
+ },
15
+ "linter": {
16
+ "enabled": true,
17
+ "rules": {
18
+ "recommended": true
19
+ }
20
+ },
21
+ "javascript": {
22
+ "formatter": {
23
+ "quoteStyle": "double"
24
+ }
25
+ },
26
+ "assist": {
27
+ "enabled": true,
28
+ "actions": {
29
+ "source": {
30
+ "organizeImports": "on"
31
+ }
32
+ }
33
+ }
34
+ }
package/index.ts CHANGED
@@ -1,36 +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 = (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
+ }
33
63
 
34
- const input = await Bun.stdin.stream().json();
35
- log.debug("input: {input}", input);
64
+ const input = await Bun.stdin.stream().text();
65
+ log.debug("input: {input}", { input });
36
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.2.2",
4
+ "version": "0.4.0",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "cc-statusline": "bin/cc-statusline.js"
@@ -30,9 +30,12 @@
30
30
  "build:binary": "mkdir -p target && bun build --compile ./index.ts --outfile target/statusline",
31
31
  "build:cli": "bun build ./src/cli.ts --outfile ./bin/cc-statusline.js --target node",
32
32
  "prepublishOnly": "bun run build:cli",
33
- "install:binary": "cp target/statusline ~/.claude/"
33
+ "install:binary": "cp target/statusline ~/.claude/",
34
+ "biome:format": "bunx biome format --write",
35
+ "biome:lint": "bunx biome lint --fix"
34
36
  },
35
37
  "devDependencies": {
38
+ "@biomejs/biome": "^2.3.15",
36
39
  "@types/bun": "latest"
37
40
  },
38
41
  "peerDependencies": {
@@ -41,9 +44,11 @@
41
44
  "dependencies": {
42
45
  "@logtape/file": "^1.3.6",
43
46
  "@logtape/logtape": "^1.3.6",
44
- "ansi-colors": "^4.1.3",
47
+ "chalk": "^5.6.2",
45
48
  "meow": "^14.0.0",
49
+ "neverthrow": "^8.2.0",
46
50
  "simple-git": "^3.30.0",
51
+ "terminal-size": "^4.0.1",
47
52
  "ts-pattern": "^5.9.0",
48
53
  "type-fest": "^5.3.1",
49
54
  "zod": "^4.3.5"
package/src/cli.ts CHANGED
@@ -1,19 +1,38 @@
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");
11
11
  const TARGET_PATH = join(CLAUDE_DIR, BINARY_NAME);
12
12
 
13
+ interface FileSystem {
14
+ exists(path: string): Promise<boolean>;
15
+ mkdir(path: string, options?: { recursive?: boolean }): Promise<void>;
16
+ copy(src: string, dest: string): Promise<void>;
17
+ remove(path: string): Promise<void>;
18
+ }
19
+
20
+ interface InstallOptions {
21
+ overwrite: boolean;
22
+ claudeDir: string;
23
+ targetPath: string;
24
+ sourcePath: string;
25
+ }
26
+
27
+ interface InstallDeps {
28
+ fs: FileSystem;
29
+ build: () => Promise<void>;
30
+ }
31
+
13
32
  const cli = meow(
14
33
  `
15
34
  Usage
16
- $ cc-statusline <command>
35
+ $ bunx cc-statusline <command>
17
36
 
18
37
  Commands
19
38
  install Build and install statusline to ~/.claude/
@@ -22,8 +41,8 @@ const cli = meow(
22
41
  --overwrite Overwrite existing file if it exists
23
42
 
24
43
  Examples
25
- $ cc-statusline install
26
- $ cc-statusline install --overwrite
44
+ $ bunx @nutthead/cc-statusline install
45
+ $ bunx @nutthead/cc-statusline install --overwrite
27
46
  `,
28
47
  {
29
48
  importMeta: import.meta,
@@ -36,25 +55,6 @@ const cli = meow(
36
55
  },
37
56
  );
38
57
 
39
- interface FileSystem {
40
- exists(path: string): Promise<boolean>;
41
- mkdir(path: string, options?: { recursive?: boolean }): Promise<void>;
42
- copy(src: string, dest: string): Promise<void>;
43
- remove(path: string): Promise<void>;
44
- }
45
-
46
- interface InstallOptions {
47
- overwrite: boolean;
48
- claudeDir: string;
49
- targetPath: string;
50
- sourcePath: string;
51
- }
52
-
53
- interface InstallDeps {
54
- fs: FileSystem;
55
- build: () => Promise<void>;
56
- }
57
-
58
58
  async function fileExists(path: string): Promise<boolean> {
59
59
  try {
60
60
  await access(path);
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
- }),
36
- agent: z.object({
37
- name: z.string(),
38
- type: z.string(),
39
- }),
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,46 +1,115 @@
1
- import { z } from "zod";
1
+ import chalk from "chalk";
2
+ import terminalSize from "terminal-size";
3
+ import { match } from "ts-pattern";
4
+ import { ZodError } from "zod";
2
5
  import { log } from "../logging";
3
- import { statusSchema } from "../statusLineSchema";
4
- import {
5
- currentDirStatus,
6
- currentGitStatus,
7
- currentModelStatus,
8
- currentSessionId,
9
- } from "../utils";
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";
10
11
 
11
- import c from "ansi-colors";
12
+ const HORIZONTAL_PADDING = 4;
12
13
 
13
- async function defaultTheme(input?: string): Promise<string> {
14
- let statusLine = null;
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> {
29
+ const modelId = abbreviateModelId(status.model.id);
30
+ const modelStatus = `🤖 ${modelId} (${status.version})`;
31
+
32
+ const sessionStatus = `📃 ${status.session_id}`;
33
+
34
+ const projectDir = telescope(compress(status.workspace.project_dir));
35
+ const projectStatus = `🗂️ ${projectDir}`;
36
+
37
+ const statusWidth = terminalSize().columns - HORIZONTAL_PADDING;
38
+ const modelWidth = getDisplayWidth(modelStatus);
39
+ const sessionWidth = getDisplayWidth(sessionStatus);
40
+ const projectWidth = getDisplayWidth(projectStatus);
41
+
42
+ const remainingSpace = statusWidth - modelWidth - sessionWidth - projectWidth;
43
+ const leftGap = Math.floor(remainingSpace / 2);
44
+ const rightGap = Math.ceil(remainingSpace / 2);
45
+
46
+ return (
47
+ modelStatus +
48
+ " ".repeat(leftGap) +
49
+ sessionStatus +
50
+ " ".repeat(rightGap) +
51
+ projectStatus
52
+ );
53
+ }
15
54
 
55
+ async function renderLine2(status: Status): Promise<string> {
56
+ const branch = await currentBranchName();
57
+ const branchStatus = match(branch)
58
+ .with({ status: "none" }, () => {
59
+ return `💾`;
60
+ })
61
+ .with({ status: "branch" }, ({ name }) => {
62
+ return `🌿 ${name}`;
63
+ })
64
+ .with({ status: "detached" }, ({ commit }) => {
65
+ return `🪾 ${commit}`;
66
+ })
67
+ .with({ status: "error" }, () => {
68
+ return `💥`;
69
+ })
70
+ .exhaustive();
71
+
72
+ const usedPercentage = status.context_window.used_percentage ?? 0;
73
+ const usageStatus = usedPercentage === 0 ? "" : `${usedPercentage}%`;
74
+
75
+ const statusWidth = terminalSize().columns - HORIZONTAL_PADDING;
76
+ const branchWidth = getDisplayWidth(branchStatus);
77
+ const usageWidth = getDisplayWidth(usageStatus);
78
+
79
+ const gap = statusWidth - branchWidth - usageWidth;
80
+
81
+ return (
82
+ branchStatus + " ".repeat(gap - 1) + colorizeUsageStatus(usedPercentage)
83
+ );
84
+ }
85
+
86
+ async function renderTheme(status: Status): Promise<string> {
87
+ const line1 = await renderLine1(status);
88
+ const line2 = await renderLine2(status);
89
+ return [line1, line2].filter(Boolean).join("\n");
90
+ }
91
+
92
+ async function defaultTheme(input?: string): Promise<string> {
16
93
  if (input) {
17
- const result = statusSchema.safeParse(input);
18
-
19
- if (result.success) {
20
- const status = result.data;
21
- const currentDir = c.blue(currentDirStatus(status));
22
- const git = c.green(await currentGitStatus());
23
- const model = c.magenta(currentModelStatus(status));
24
- const sessionId = c.blue(currentSessionId(status));
25
- const separator = c.bold.gray(" ⋮ ");
26
-
27
- statusLine = [
28
- [currentDir, git],
29
- [model, sessionId],
30
- ]
31
- .map((row) => row.join(separator))
32
- .join("\n");
33
- } else {
94
+ try {
95
+ const parsed = JSON.parse(input);
96
+ const status = statusSchema.parse(parsed);
97
+ return renderTheme(status);
98
+ } catch (e) {
99
+ const error =
100
+ e instanceof ZodError
101
+ ? JSON.stringify(e.issues)
102
+ : e instanceof Error
103
+ ? e.message
104
+ : JSON.stringify(e);
105
+
34
106
  log.error("Failed to parse input: {error}", {
35
- error: JSON.stringify(z.treeifyError(result.error)),
107
+ error: error,
36
108
  });
37
- statusLine = `[malformed status]`;
38
109
  }
39
- } else {
40
- statusLine = `[no status]`;
41
110
  }
42
111
 
43
- return statusLine;
112
+ return "";
44
113
  }
45
114
 
46
115
  export { defaultTheme };
@@ -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 };
@@ -0,0 +1,89 @@
1
+ import { type SimpleGit, simpleGit } from "simple-git";
2
+ import { match } from "ts-pattern";
3
+
4
+ /**
5
+ * Result type for currentBranchName function.
6
+ * - `branch`: The current branch name when on a branch
7
+ * - `detached`: In detached HEAD state (checked out a specific commit)
8
+ * - `error`: Failed to get branch info (not a git repo, etc.)
9
+ */
10
+ type BranchResult =
11
+ | { status: "none" }
12
+ | { status: "branch"; name: string }
13
+ | { status: "detached"; commit: string }
14
+ | { status: "error"; message: string };
15
+
16
+ /**
17
+ * Gets the current git branch name using simple-git.
18
+ * Handles edge cases like detached HEAD state and non-git directories.
19
+ * @param cwd - Optional working directory (defaults to process.cwd())
20
+ * @returns BranchResult indicating branch name, detached state, or error
21
+ */
22
+ async function currentBranchName(cwd?: string): Promise<BranchResult> {
23
+ const git: SimpleGit = simpleGit(cwd);
24
+
25
+ try {
26
+ // Check if we're in a git repository first
27
+ const isRepo = await git.checkIsRepo();
28
+ if (!isRepo) {
29
+ return { status: "none" };
30
+ }
31
+
32
+ const branchSummary = await git.branch();
33
+ const current = branchSummary.current;
34
+
35
+ // Detached HEAD: current will be a commit hash or empty
36
+ // In detached state, branchSummary.detached is true
37
+ if (branchSummary.detached) {
38
+ // Get the short commit hash for display
39
+ const shortHash = await git.revparse(["--short", "HEAD"]);
40
+ return { status: "detached", commit: shortHash.trim() };
41
+ }
42
+
43
+ // Empty current can happen in fresh repos with no commits
44
+ // Use symbolic-ref as fallback to get the intended branch name
45
+ if (!current) {
46
+ try {
47
+ const symbolicRef = await git.raw(["symbolic-ref", "--short", "HEAD"]);
48
+ const branchName = symbolicRef.trim();
49
+ if (branchName) {
50
+ return { status: "branch", name: branchName };
51
+ }
52
+ } catch {
53
+ // symbolic-ref fails in detached HEAD, but we already checked for that
54
+ }
55
+ return { status: "error", message: "Unable to determine current branch" };
56
+ }
57
+
58
+ return { status: "branch", name: current };
59
+ } catch (error) {
60
+ const message =
61
+ error instanceof Error ? error.message : "Unknown error occurred";
62
+ return { status: "error", message };
63
+ }
64
+ }
65
+
66
+ /**
67
+ * Returns a formatted git status string with emoji indicators.
68
+ * Uses the current working directory to determine git state.
69
+ *
70
+ * @returns A formatted string:
71
+ * - `🌿 <branch>` - On a branch (e.g., "🌿 main")
72
+ * - `🪾 <hash>` - Detached HEAD with short commit hash
73
+ * - `💾` - Not in a git repository
74
+ * - `💥` - Error determining git status
75
+ */
76
+ async function currentGitStatus() {
77
+ const gitBranch = await currentBranchName();
78
+ const gitStatus = match(gitBranch)
79
+ .with({ status: "branch" }, ({ name }) => `🌿 ${name}`)
80
+ .with({ status: "detached" }, ({ commit }) => `🪾 ${commit}`)
81
+ .with({ status: "none" }, () => "💾")
82
+ .with({ status: "error" }, () => "💥")
83
+ .exhaustive();
84
+
85
+ return gitStatus;
86
+ }
87
+
88
+ export { currentBranchName, currentGitStatus };
89
+ export type { BranchResult };
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Abbreviates a model ID by stripping the "claude-" prefix and truncating
3
+ * to `tail` characters (using `…` prefix when truncated).
4
+ *
5
+ * @param model - The model ID string
6
+ * @param options.tail - Maximum character length of the result (default: 12).
7
+ *
8
+ * @example abbreviateModelId("claude-opus-4.5") // "opus-4.5"
9
+ * @example abbreviateModelId("some-very-long-model-name") // "…-model-name"
10
+ */
11
+ function abbreviateModelId(model: string, options?: { tail?: number }): string {
12
+ const tail = options?.tail ?? 12;
13
+
14
+ // Step 1: Strip "claude-" prefix
15
+ const name = model.replace(/^claude-/, "");
16
+
17
+ // Step 2: Truncate if needed, keeping the last (tail - 1) chars
18
+ if (name.length <= tail) return name;
19
+ if (tail <= 1) return "…";
20
+ return `…${name.slice(-(tail - 1))}`;
21
+ }
22
+
23
+ export { abbreviateModelId };
@@ -0,0 +1,95 @@
1
+ import { homedir } from "node:os";
2
+
3
+ const HORIZONTAL_ELLIPSIS = "\u2026";
4
+
5
+ /**
6
+ * Compresses a path by converting all segments except the last to a single character.
7
+ *
8
+ * @param path - The path to compress
9
+ * @returns The compressed path
10
+ *
11
+ * @example compress("/home/username/foo/bar/baz") // "/h/u/f/b/baz"
12
+ * @example compress("/foo/bar/baz") // "/f/b/baz"
13
+ * @example compress("~/projects/myapp") // "~/p/myapp"
14
+ * @example compress("a/b/c/d") // "a/b/c/d"
15
+ */
16
+ function compress(path: string): string {
17
+ const segments = path.split("/");
18
+
19
+ if (segments.length <= 1) {
20
+ return path;
21
+ }
22
+
23
+ const compressed = segments.map((segment, index) => {
24
+ // Keep last segment full, compress others to first char
25
+ if (index === segments.length - 1) return segment;
26
+
27
+ // For empty segments (absolute path root), keep empty
28
+ if (segment === "") return segment;
29
+
30
+ return segment[0];
31
+ });
32
+
33
+ return compressed.join("/");
34
+ }
35
+
36
+ /**
37
+ * Converts a path starting with the home directory to use `~`.
38
+ *
39
+ * @param path - The path to convert
40
+ * @returns The path with home directory replaced by `~`, or the original path if it doesn't start with home
41
+ *
42
+ * @example tildify("/home/user/projects/myapp") // "~/projects/myapp"
43
+ * @example tildify("/home/user") // "~"
44
+ * @example tildify("/etc/nginx") // "/etc/nginx"
45
+ */
46
+ function tildify(path: string): string {
47
+ const home = homedir();
48
+
49
+ if (path === home) {
50
+ return "~";
51
+ }
52
+
53
+ if (path.startsWith(`${home}/`)) {
54
+ return `~${path.slice(home.length)}`;
55
+ }
56
+
57
+ return path;
58
+ }
59
+
60
+ /**
61
+ * Telescopes a path by keeping only the first and last segments,
62
+ * with a horizontal ellipsis in between.
63
+ *
64
+ * First tildifies the path, then applies the telescoping transformation.
65
+ * If the path has 2 or fewer segments, it is returned unchanged.
66
+ *
67
+ * @param path - The path to telescope
68
+ * @returns The telescoped path
69
+ *
70
+ * @example telescope("/home/user/projects/myapp") // "~/…/myapp"
71
+ * @example telescope("/a/b/c/d") // "/a/…/d"
72
+ * @example telescope("~/a/b/c") // "~/…/c"
73
+ * @example telescope("a/b/c") // "a/…/c"
74
+ * @example telescope("~/foo") // "~/foo"
75
+ */
76
+ function telescope(path: string): string {
77
+ const tildified = tildify(path);
78
+ const segments = tildified.split("/");
79
+
80
+ if (segments.length <= 2) {
81
+ return tildified;
82
+ }
83
+
84
+ const first = segments[0];
85
+ const last = segments[segments.length - 1];
86
+
87
+ // Handle absolute paths: first segment is empty string
88
+ if (first === "" && segments.length > 1) {
89
+ return `/${segments[1]}/${HORIZONTAL_ELLIPSIS}/${last}`;
90
+ }
91
+
92
+ return `${first}/${HORIZONTAL_ELLIPSIS}/${last}`;
93
+ }
94
+
95
+ export { compress, tildify, telescope, HORIZONTAL_ELLIPSIS };
@@ -0,0 +1,21 @@
1
+ const EMOJI_REGEX = /\p{Extended_Pictographic}/gu;
2
+
3
+ /**
4
+ * Calculates the display width of a string, accounting for emojis
5
+ * which occupy 2 character columns in terminal displays.
6
+ *
7
+ * @param str - The string to measure
8
+ * @returns The display width in columns
9
+ */
10
+ function getDisplayWidth(str: string): number {
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)
18
+ return charCount + emojiCount;
19
+ }
20
+
21
+ export { getDisplayWidth };
package/src/utils.ts DELETED
@@ -1,173 +0,0 @@
1
- import { homedir } from "node:os";
2
- import { simpleGit, type SimpleGit } from "simple-git";
3
- import { match } from "ts-pattern";
4
- import type { Status } from "./statusLineSchema";
5
-
6
- /**
7
- * Abbreviates a path by reducing all segments except the last to their first character.
8
- * If the path starts with the home directory, it's replaced with ~.
9
- * @example abbreviatePath("/home/user/projects/myapp") // "~/p/myapp"
10
- * @example abbreviatePath("/foo/bar/baz/etc/last") // "/f/b/b/e/last"
11
- */
12
- function abbreviatePath(path: string): string {
13
- const home = homedir();
14
-
15
- // Replace homedir with ~ if path starts with it
16
- let normalizedPath = path;
17
- let prefix = "";
18
- if (path.startsWith(home)) {
19
- normalizedPath = path.slice(home.length);
20
- prefix = "~";
21
- }
22
-
23
- const segments = normalizedPath.split("/");
24
- if (segments.length <= 1) return prefix + normalizedPath;
25
-
26
- const abbreviated = segments.map((segment, index) => {
27
- // Keep last segment full, abbreviate others to first char (if non-empty)
28
- if (index === segments.length - 1) return segment;
29
- return segment.length > 0 ? segment[0] : segment;
30
- });
31
-
32
- return prefix + abbreviated.join("/");
33
- }
34
-
35
- /**
36
- * Removes the "claude-" prefix from a model name if present.
37
- * @example abbreviateModelId("claude-opus-4.5") // "opus-4.5"
38
- * @example abbreviateModelId("opus-4.5") // "opus-4.5"
39
- */
40
- function abbreviateModelId(model: string): string {
41
- return model.startsWith("claude-") ? model.slice(7) : model;
42
- }
43
-
44
- /**
45
- * Result type for currentBranchName function.
46
- * - `branch`: The current branch name when on a branch
47
- * - `detached`: In detached HEAD state (checked out a specific commit)
48
- * - `error`: Failed to get branch info (not a git repo, etc.)
49
- */
50
- type BranchResult =
51
- | { status: "not-git" }
52
- | { status: "branch"; name: string }
53
- | { status: "detached"; commit: string }
54
- | { status: "error"; message: string };
55
-
56
- /**
57
- * Gets the current git branch name using simple-git.
58
- * Handles edge cases like detached HEAD state and non-git directories.
59
- * @param cwd - Optional working directory (defaults to process.cwd())
60
- * @returns BranchResult indicating branch name, detached state, or error
61
- */
62
- async function currentBranchName(cwd?: string): Promise<BranchResult> {
63
- const git: SimpleGit = simpleGit(cwd);
64
-
65
- try {
66
- // Check if we're in a git repository first
67
- const isRepo = await git.checkIsRepo();
68
- if (!isRepo) {
69
- return { status: "not-git" };
70
- }
71
-
72
- const branchSummary = await git.branch();
73
- const current = branchSummary.current;
74
-
75
- // Detached HEAD: current will be a commit hash or empty
76
- // In detached state, branchSummary.detached is true
77
- if (branchSummary.detached) {
78
- // Get the short commit hash for display
79
- const shortHash = await git.revparse(["--short", "HEAD"]);
80
- return { status: "detached", commit: shortHash.trim() };
81
- }
82
-
83
- // Empty current can happen in fresh repos with no commits
84
- // Use symbolic-ref as fallback to get the intended branch name
85
- if (!current) {
86
- try {
87
- const symbolicRef = await git.raw(["symbolic-ref", "--short", "HEAD"]);
88
- const branchName = symbolicRef.trim();
89
- if (branchName) {
90
- return { status: "branch", name: branchName };
91
- }
92
- } catch {
93
- // symbolic-ref fails in detached HEAD, but we already checked for that
94
- }
95
- return { status: "error", message: "Unable to determine current branch" };
96
- }
97
-
98
- return { status: "branch", name: current };
99
- } catch (error) {
100
- const message =
101
- error instanceof Error ? error.message : "Unknown error occurred";
102
- return { status: "error", message };
103
- }
104
- }
105
-
106
- /**
107
- * Returns a formatted git status string with emoji indicators.
108
- * Uses the current working directory to determine git state.
109
- *
110
- * @returns A formatted string:
111
- * - `🌿 <branch>` - On a branch (e.g., "🌿 main")
112
- * - `🪾 <hash>` - Detached HEAD with short commit hash
113
- * - `💾` - Not in a git repository
114
- * - `💥` - Error determining git status
115
- */
116
- async function currentGitStatus() {
117
- const gitBranch = await currentBranchName();
118
- const gitStatus = match(gitBranch)
119
- .with({ status: "branch" }, ({ name }) => `🌿 ${name}`)
120
- .with({ status: "detached" }, ({ commit }) => `🪾 ${commit}`)
121
- .with({ status: "not-git" }, () => "💾")
122
- .with({ status: "error" }, () => "💥")
123
- .exhaustive();
124
-
125
- return gitStatus;
126
- }
127
-
128
- /**
129
- * Returns a formatted model status string with the Claude icon.
130
- * Strips the "claude-" prefix from the model ID for brevity.
131
- *
132
- * @param status - The Status object containing model information
133
- * @returns A formatted string like "⏣ opus-4.5" or "⏣ sonnet-4"
134
- */
135
- function currentModelStatus(status: Status) {
136
- return `⏣ ${abbreviateModelId(status.model.id)}`;
137
- }
138
-
139
- /**
140
- * Returns a formatted directory status string showing workspace location.
141
- * Both paths are abbreviated (e.g., "/home/user/projects" → "~/p").
142
- *
143
- * @param status - The Status object containing workspace information
144
- * @returns Either the abbreviated project directory alone (when current dir matches),
145
- * or "projectDir/currentDir" format when projectDir/currentDir don't match
146
- * @example currentDirStatus({...}) // "🗂️ ~/p/myapp" or "🗂️ ~/p/myapp 📂 ~/s/components"
147
- */
148
- function currentDirStatus(status: Status) {
149
- const projectDir = abbreviatePath(status.workspace.project_dir);
150
- const currentDir = abbreviatePath(status.workspace.current_dir);
151
- const dirStatus =
152
- projectDir === currentDir
153
- ? `🗂️ ${projectDir}`
154
- : `🗂️ ${projectDir} 📂 ${currentDir}`;
155
-
156
- return dirStatus;
157
- }
158
-
159
- function currentSessionId(status: Status) {
160
- return `📝 ${status.session_id}`;
161
- }
162
-
163
- export {
164
- abbreviateModelId,
165
- abbreviatePath,
166
- currentBranchName,
167
- currentDirStatus,
168
- currentGitStatus,
169
- currentModelStatus,
170
- currentSessionId,
171
- };
172
-
173
- export type { BranchResult };