@nutthead/cc-statusline 0.2.2 → 0.3.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
4
 
5
5
  ## Preview
6
6
 
7
7
  The default theme renders a two-row status line:
8
8
 
9
9
  ```
10
- 🗂️ ~/C/b/statusline ⋮ 🌿 master
11
- opus-4-6 ⋮ 📝 fc9bbbee-...
10
+ 🤖 opus-4-5 📃 93aba123-d123-4a6b-b1b5-2f3e7d111317 🗂️ ~/a/…/statusline
11
+ 🌿 main 0.38%
12
12
  ```
13
13
 
14
- Row 1 shows the abbreviated working directory and git branch. Row 2 shows the model and session ID.
14
+ - **Row 1** (left to right):
15
+ - 🤖 Model ID (abbreviated)
16
+ - 📃 Session ID
17
+ - 🗂️ Project directory (compressed and telescoped)
18
+
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
15
22
 
16
23
  ## Install
17
24
 
@@ -64,19 +71,32 @@ Then point to it in `~/.claude/settings.json`:
64
71
 
65
72
  The JSON object passed to your theme function contains these fields:
66
73
 
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.
74
+ | Field | Example |
75
+ | ---------------------------------------------------------- | --------------------------------- |
76
+ | `session_id` | `"f9abcdef-1a2b-..."` |
77
+ | `transcript_path` | `"/home/user/.claude/transcript"` |
78
+ | `cwd` | `"/home/user/project"` |
79
+ | `model.id` | `"claude-opus-4-6"` |
80
+ | `model.display_name` | `"Claude Opus 4.6"` |
81
+ | `workspace.current_dir` | `"/home/user/project"` |
82
+ | `workspace.project_dir` | `"/home/user/project"` |
83
+ | `version` | `"2.1.39"` |
84
+ | `output_style.name` | `"Explanatory"` |
85
+ | `context_window.total_input_tokens` | `12345` |
86
+ | `context_window.total_output_tokens` | `6789` |
87
+ | `context_window.context_window_size` | `200000` |
88
+ | `context_window.current_usage` | `{ ... }` or `null` |
89
+ | `context_window.current_usage.input_tokens` | `1024` |
90
+ | `context_window.current_usage.output_tokens` | `512` |
91
+ | `context_window.current_usage.cache_creation_input_tokens` | `256` |
92
+ | `context_window.current_usage.cache_read_input_tokens` | `128` |
93
+ | `context_window.used_percentage` | `42.5` or `null` |
94
+ | `context_window.remaining_percentage` | `57.5` or `null` |
95
+ | `context_window.vim.mode` | `"INSERT"` or `"NORMAL"` |
96
+ | `context_window.agent.name` | `"claude-code"` |
97
+ | `context_window.agent.type` | `"main"` |
98
+
99
+ See [`src/schema/statusLine.ts`](src/schema/statusLine.ts) for the full schema.
80
100
 
81
101
  ## Troubleshooting
82
102
 
@@ -9417,7 +9417,7 @@ var CLAUDE_DIR = join(homedir(), ".claude");
9417
9417
  var TARGET_PATH = join(CLAUDE_DIR, BINARY_NAME);
9418
9418
  var cli = meow(`
9419
9419
  Usage
9420
- $ cc-statusline <command>
9420
+ $ bunx cc-statusline <command>
9421
9421
 
9422
9422
  Commands
9423
9423
  install Build and install statusline to ~/.claude/
@@ -9426,8 +9426,8 @@ var cli = meow(`
9426
9426
  --overwrite Overwrite existing file if it exists
9427
9427
 
9428
9428
  Examples
9429
- $ cc-statusline install
9430
- $ cc-statusline install --overwrite
9429
+ $ bunx @nutthead/cc-statusline install
9430
+ $ bunx @nutthead/cc-statusline install --overwrite
9431
9431
  `, {
9432
9432
  importMeta: import.meta,
9433
9433
  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
@@ -29,7 +29,8 @@ const cli = meow(
29
29
  },
30
30
  );
31
31
 
32
- const resolvedTheme = (cli.flags.theme && await loadTheme(cli.flags.theme)) || defaultTheme;
32
+ const resolvedTheme =
33
+ (cli.flags.theme && (await loadTheme(cli.flags.theme))) || defaultTheme;
33
34
 
34
35
  const input = await Bun.stdin.stream().json();
35
36
  log.debug("input: {input}", 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.3.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": {
@@ -43,7 +46,9 @@
43
46
  "@logtape/logtape": "^1.3.6",
44
47
  "ansi-colors": "^4.1.3",
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
@@ -10,10 +10,29 @@ 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);
@@ -32,11 +32,11 @@ const statusSchema = z.object({
32
32
  remaining_percentage: z.number().nullable(),
33
33
  vim: z.object({
34
34
  mode: z.enum(["INSERT", "NORMAL"]),
35
- }),
35
+ }).optional(),
36
36
  agent: z.object({
37
37
  name: z.string(),
38
38
  type: z.string(),
39
- }),
39
+ }).optional(),
40
40
  }),
41
41
  });
42
42
 
@@ -1,46 +1,86 @@
1
- import { z } from "zod";
1
+ import { ZodError } from "zod";
2
2
  import { log } from "../logging";
3
- import { statusSchema } from "../statusLineSchema";
4
- import {
5
- currentDirStatus,
6
- currentGitStatus,
7
- currentModelStatus,
8
- currentSessionId,
9
- } from "../utils";
3
+ import { statusSchema, type Status } from "../schema/statusLine";
4
+ import { abbreviateModelId } from "../utils/model";
5
+ import { compress, telescope } from "../utils/path";
6
+ import { getDisplayWidth } from "../utils/term";
7
+ import terminalSize from 'terminal-size';
8
+ import { currentBranchName } from "../utils/git";
9
+ import { match } from "ts-pattern";
10
10
 
11
- import c from "ansi-colors";
11
+ const HORIZONTAL_PADDING = 4;
12
12
 
13
- async function defaultTheme(input?: string): Promise<string> {
14
- let statusLine = null;
13
+ async function renderLine1(status: Status) : Promise<string> {
14
+ const modelId = abbreviateModelId(status.model.id);
15
+ const modelStatus = `🤖 ${modelId}`;
16
+
17
+ const sessionStatus = `📃 ${status.session_id}`;
18
+
19
+ const projectDir = telescope(compress(status.workspace.project_dir));
20
+ const projectStatus = `🗂️ ${projectDir}`;
21
+
22
+ const statusWidth = terminalSize().columns - HORIZONTAL_PADDING;
23
+ const modelWidth = getDisplayWidth(modelStatus);
24
+ const sessionWidth = getDisplayWidth(sessionStatus);
25
+ const projectWidth = getDisplayWidth(projectStatus);
26
+
27
+ const remainingSpace = statusWidth - modelWidth - sessionWidth - projectWidth;
28
+ const leftGap = Math.floor(remainingSpace / 2);
29
+ const rightGap = Math.ceil(remainingSpace / 2);
30
+
31
+ return modelStatus + " ".repeat(leftGap) + sessionStatus + " ".repeat(rightGap) + projectStatus;
32
+ }
33
+
34
+ async function renderLine2(status: Status) : Promise<string> {
35
+ const branch = await currentBranchName();
36
+ const branchStatus = match(branch)
37
+ .with({status: "none"}, () => {
38
+ return `💾`;
39
+ })
40
+ .with({status: "branch"}, ({name}) => {
41
+ return `🌿 ${name}`;
42
+ })
43
+ .with({status: "detached"}, ({commit}) => {
44
+ return `🪾 ${commit}`;
45
+ })
46
+ .with({status: "error"}, () => {
47
+ return `💥`;
48
+ })
49
+ .exhaustive();
15
50
 
51
+ const usedPercentage = status.context_window.used_percentage ?? 0;
52
+ const usageStatus = usedPercentage === 0 ? '' : `${usedPercentage}%`
53
+
54
+ const statusWidth = terminalSize().columns - HORIZONTAL_PADDING;
55
+ const branchWidth = getDisplayWidth(branchStatus);
56
+ const usageWidth = getDisplayWidth(usageStatus);
57
+
58
+ const gap = statusWidth - branchWidth - usageWidth;
59
+
60
+ return branchStatus + " ".repeat(gap - 1) + usageStatus;
61
+ }
62
+
63
+ async function renderTheme(status: Status): Promise<string> {
64
+ const line1 = await renderLine1(status);
65
+ const line2 = await renderLine2(status);
66
+ return [line1, line2].filter(Boolean).join("\n");
67
+ }
68
+
69
+ async function defaultTheme(input?: string): Promise<string> {
16
70
  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 {
34
- log.error("Failed to parse input: {error}", {
35
- error: JSON.stringify(z.treeifyError(result.error)),
36
- });
37
- statusLine = `[malformed status]`;
71
+ try {
72
+ const status = statusSchema.parse(input);
73
+ return renderTheme(status);
74
+ } catch (e) {
75
+ if (e instanceof ZodError) {
76
+ log.error("Failed to parse input: {error}", {
77
+ error: JSON.stringify(e.issues),
78
+ });
79
+ }
38
80
  }
39
- } else {
40
- statusLine = `[no status]`;
41
81
  }
42
82
 
43
- return statusLine;
83
+ return "";
44
84
  }
45
85
 
46
86
  export { defaultTheme };
@@ -1,45 +1,5 @@
1
- import { homedir } from "node:os";
2
1
  import { simpleGit, type SimpleGit } from "simple-git";
3
2
  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
3
 
44
4
  /**
45
5
  * Result type for currentBranchName function.
@@ -48,7 +8,7 @@ function abbreviateModelId(model: string): string {
48
8
  * - `error`: Failed to get branch info (not a git repo, etc.)
49
9
  */
50
10
  type BranchResult =
51
- | { status: "not-git" }
11
+ | { status: "none" }
52
12
  | { status: "branch"; name: string }
53
13
  | { status: "detached"; commit: string }
54
14
  | { status: "error"; message: string };
@@ -66,7 +26,7 @@ async function currentBranchName(cwd?: string): Promise<BranchResult> {
66
26
  // Check if we're in a git repository first
67
27
  const isRepo = await git.checkIsRepo();
68
28
  if (!isRepo) {
69
- return { status: "not-git" };
29
+ return { status: "none" };
70
30
  }
71
31
 
72
32
  const branchSummary = await git.branch();
@@ -118,56 +78,12 @@ async function currentGitStatus() {
118
78
  const gitStatus = match(gitBranch)
119
79
  .with({ status: "branch" }, ({ name }) => `🌿 ${name}`)
120
80
  .with({ status: "detached" }, ({ commit }) => `🪾 ${commit}`)
121
- .with({ status: "not-git" }, () => "💾")
81
+ .with({ status: "none" }, () => "💾")
122
82
  .with({ status: "error" }, () => "💥")
123
83
  .exhaustive();
124
84
 
125
85
  return gitStatus;
126
86
  }
127
87
 
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
-
88
+ export { currentBranchName, currentGitStatus };
173
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,20 @@
1
+ /**
2
+ * Calculates the display width of a string, accounting for emojis
3
+ * which occupy 2 character columns in terminal displays.
4
+ *
5
+ * @param str - The string to measure
6
+ * @returns The display width in columns
7
+ */
8
+ 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)
17
+ return charCount + emojiCount;
18
+ }
19
+
20
+ export { getDisplayWidth };