@nutthead/cc-statusline 0.1.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/.claude/settings.local.json +8 -0
- package/CLAUDE.md +33 -0
- package/LICENSE +21 -0
- package/README.md +41 -0
- package/bun.lock +56 -0
- package/fixtures/statusline-1.json +26 -0
- package/fixtures/statusline-2.json +31 -0
- package/index.ts +36 -0
- package/package.json +33 -0
- package/src/logging.ts +24 -0
- package/src/statusLineSchema.test.ts +22 -0
- package/src/statusLineSchema.ts +39 -0
- package/src/utils.test.ts +472 -0
- package/src/utils.ts +168 -0
- package/tsconfig.json +29 -0
package/CLAUDE.md
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# CLAUDE.md
|
|
2
|
+
|
|
3
|
+
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
|
4
|
+
|
|
5
|
+
Default to using Bun instead of Node.js.
|
|
6
|
+
|
|
7
|
+
- Use `bun <file>` instead of `node <file>` or `ts-node <file>`
|
|
8
|
+
- Use `bun test` instead of `jest` or `vitest`
|
|
9
|
+
- Use `bun build <file.ts>` instead of `webpack` or `esbuild`
|
|
10
|
+
- Use `bun install` instead of `npm install` or `yarn install` or `pnpm install`
|
|
11
|
+
- Use `bun run <script>` instead of `npm run <script>` or `yarn run <script>` or `pnpm run <script>`
|
|
12
|
+
- Bun automatically loads .env, so don't use dotenv.
|
|
13
|
+
|
|
14
|
+
## APIs
|
|
15
|
+
|
|
16
|
+
- `bun:sqlite` for SQLite. Don't use `better-sqlite3`.
|
|
17
|
+
- `Bun.sql` for Postgres. Don't use `pg` or `postgres.js`.
|
|
18
|
+
- Prefer `Bun.file` over `node:fs`'s readFile/writeFile
|
|
19
|
+
- Bun.$`ls` instead of execa.
|
|
20
|
+
|
|
21
|
+
## Testing
|
|
22
|
+
|
|
23
|
+
Use `bun test` to run tests.
|
|
24
|
+
|
|
25
|
+
```ts#index.test.ts
|
|
26
|
+
import { test, expect } from "bun:test";
|
|
27
|
+
|
|
28
|
+
test("hello world", () => {
|
|
29
|
+
expect(1).toBe(1);
|
|
30
|
+
});
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
For more information, read the Bun API docs in `node_modules/bun-types/docs/**.md`.
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Behrang Saeedzadeh
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# Claude Code Status Line
|
|
2
|
+
|
|
3
|
+
To install dependencies:
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
bun install
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
To build:
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
bun run build:binary
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
To copy to `~/.claude`:
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
bun run install:binary
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
## Configure your Claude Code's statusline
|
|
22
|
+
|
|
23
|
+
Edit your `~/.claude/settings.json` file to include:
|
|
24
|
+
|
|
25
|
+
```json
|
|
26
|
+
{
|
|
27
|
+
"statusLine": {
|
|
28
|
+
"type": "command",
|
|
29
|
+
"command": "/path/to/.claude/statusline",
|
|
30
|
+
"padding": 0
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
## Logs
|
|
36
|
+
|
|
37
|
+
Execution logs are stored in `~/.local/state/statusline/app.log`.
|
|
38
|
+
|
|
39
|
+
## License
|
|
40
|
+
|
|
41
|
+
MIT
|
package/bun.lock
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
{
|
|
2
|
+
"lockfileVersion": 1,
|
|
3
|
+
"configVersion": 1,
|
|
4
|
+
"workspaces": {
|
|
5
|
+
"": {
|
|
6
|
+
"name": "statusline",
|
|
7
|
+
"dependencies": {
|
|
8
|
+
"@logtape/file": "^1.3.6",
|
|
9
|
+
"@logtape/logtape": "^1.3.6",
|
|
10
|
+
"ansi-colors": "^4.1.3",
|
|
11
|
+
"simple-git": "^3.30.0",
|
|
12
|
+
"type-fest": "^5.3.1",
|
|
13
|
+
"zod": "^4.3.5",
|
|
14
|
+
},
|
|
15
|
+
"devDependencies": {
|
|
16
|
+
"@types/bun": "latest",
|
|
17
|
+
},
|
|
18
|
+
"peerDependencies": {
|
|
19
|
+
"typescript": "^5",
|
|
20
|
+
},
|
|
21
|
+
},
|
|
22
|
+
},
|
|
23
|
+
"packages": {
|
|
24
|
+
"@kwsites/file-exists": ["@kwsites/file-exists@1.1.1", "", { "dependencies": { "debug": "^4.1.1" } }, "sha512-m9/5YGR18lIwxSFDwfE3oA7bWuq9kdau6ugN4H2rJeyhFQZcG9AgSHkQtSD15a8WvTgfz9aikZMrKPHvbpqFiw=="],
|
|
25
|
+
|
|
26
|
+
"@kwsites/promise-deferred": ["@kwsites/promise-deferred@1.1.1", "", {}, "sha512-GaHYm+c0O9MjZRu0ongGBRbinu8gVAMd2UZjji6jVmqKtZluZnptXGWhz1E8j8D2HJ3f/yMxKAUC0b+57wncIw=="],
|
|
27
|
+
|
|
28
|
+
"@logtape/file": ["@logtape/file@1.3.6", "", { "peerDependencies": { "@logtape/logtape": "^1.3.6" } }, "sha512-wYh1qb8uOj2LtoAHyzCnkjvScJsJiOSYPcrPaaa3QxLdvR6ZQHo3939jRdeqkv5oQMW/rfMImmY7ma/kgW6Ogw=="],
|
|
29
|
+
|
|
30
|
+
"@logtape/logtape": ["@logtape/logtape@1.3.6", "", {}, "sha512-OaK8eal8zcjB0GZbllXKgUC2T9h/GyNLQyQXjJkf1yum7SZKTWs9gs/t8NMS0kVVaSnA7bhU0Sjws/Iy4e0/IQ=="],
|
|
31
|
+
|
|
32
|
+
"@types/bun": ["@types/bun@1.3.5", "", { "dependencies": { "bun-types": "1.3.5" } }, "sha512-RnygCqNrd3srIPEWBd5LFeUYG7plCoH2Yw9WaZGyNmdTEei+gWaHqydbaIRkIkcbXwhBT94q78QljxN0Sk838w=="],
|
|
33
|
+
|
|
34
|
+
"@types/node": ["@types/node@25.0.4", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-9L7EOcoPf2mpvBL6GbE4z6zY9oaaP389dZ5ZZ05n2K9p3e1rEUwcXqwhXIKRMbK/uV1U8MYactPf1XH0xmtZWg=="],
|
|
35
|
+
|
|
36
|
+
"ansi-colors": ["ansi-colors@4.1.3", "", {}, "sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw=="],
|
|
37
|
+
|
|
38
|
+
"bun-types": ["bun-types@1.3.5", "", { "dependencies": { "@types/node": "*" } }, "sha512-inmAYe2PFLs0SUbFOWSVD24sg1jFlMPxOjOSSCYqUgn4Hsc3rDc7dFvfVYjFPNHtov6kgUeulV4SxbuIV/stPw=="],
|
|
39
|
+
|
|
40
|
+
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
|
|
41
|
+
|
|
42
|
+
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
|
|
43
|
+
|
|
44
|
+
"simple-git": ["simple-git@3.30.0", "", { "dependencies": { "@kwsites/file-exists": "^1.1.1", "@kwsites/promise-deferred": "^1.1.1", "debug": "^4.4.0" } }, "sha512-q6lxyDsCmEal/MEGhP1aVyQ3oxnagGlBDOVSIB4XUVLl1iZh0Pah6ebC9V4xBap/RfgP2WlI8EKs0WS0rMEJHg=="],
|
|
45
|
+
|
|
46
|
+
"tagged-tag": ["tagged-tag@1.0.0", "", {}, "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng=="],
|
|
47
|
+
|
|
48
|
+
"type-fest": ["type-fest@5.3.1", "", { "dependencies": { "tagged-tag": "^1.0.0" } }, "sha512-VCn+LMHbd4t6sF3wfU/+HKT63C9OoyrSIf4b+vtWHpt2U7/4InZG467YDNMFMR70DdHjAdpPWmw2lzRdg0Xqqg=="],
|
|
49
|
+
|
|
50
|
+
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
|
|
51
|
+
|
|
52
|
+
"undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
|
|
53
|
+
|
|
54
|
+
"zod": ["zod@4.3.5", "", {}, "sha512-k7Nwx6vuWx1IJ9Bjuf4Zt1PEllcwe7cls3VNzm4CQ1/hgtFUK2bRNG3rvnpPUhFjmqJKAKtjV576KnUkHocg/g=="],
|
|
55
|
+
}
|
|
56
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
{
|
|
2
|
+
"session_id": "fc9bbbee-466c-45b8-ad86-8f3d848823f2",
|
|
3
|
+
"transcript_path": "/home/amadeus/.claude/projects/-home-amadeus-Code-behrangsa-statusline/fc9bbbee-466c-45b8-ad86-8f3d848823f2.jsonl",
|
|
4
|
+
"cwd": "/home/amadeus/Code/behrangsa/statusline",
|
|
5
|
+
"model": { "id": "claude-opus-4-5-20251101", "display_name": "Opus 4.5" },
|
|
6
|
+
"workspace": {
|
|
7
|
+
"current_dir": "/home/amadeus/Code/behrangsa/statusline",
|
|
8
|
+
"project_dir": "/home/amadeus/Code/behrangsa/statusline"
|
|
9
|
+
},
|
|
10
|
+
"version": "2.1.3",
|
|
11
|
+
"output_style": { "name": "Explanatory" },
|
|
12
|
+
"cost": {
|
|
13
|
+
"total_cost_usd": 0,
|
|
14
|
+
"total_duration_ms": 1345,
|
|
15
|
+
"total_api_duration_ms": 0,
|
|
16
|
+
"total_lines_added": 0,
|
|
17
|
+
"total_lines_removed": 0
|
|
18
|
+
},
|
|
19
|
+
"context_window": {
|
|
20
|
+
"total_input_tokens": 0,
|
|
21
|
+
"total_output_tokens": 0,
|
|
22
|
+
"context_window_size": 200000,
|
|
23
|
+
"current_usage": null
|
|
24
|
+
},
|
|
25
|
+
"exceeds_200k_tokens": false
|
|
26
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
{
|
|
2
|
+
"session_id": "09f8e582-5026-4970-9530-fdff14b0dbe9",
|
|
3
|
+
"transcript_path": "/home/amadeus/.claude/projects/-home-amadeus-Code-behrangsa-statusline/09f8e582-5026-4970-9530-fdff14b0dbe9.jsonl",
|
|
4
|
+
"cwd": "/home/amadeus/Code/behrangsa/statusline",
|
|
5
|
+
"model": { "id": "claude-opus-4-5-20251101", "display_name": "Opus 4.5" },
|
|
6
|
+
"workspace": {
|
|
7
|
+
"current_dir": "/home/amadeus/Code/behrangsa/statusline",
|
|
8
|
+
"project_dir": "/home/amadeus/Code/behrangsa/statusline"
|
|
9
|
+
},
|
|
10
|
+
"version": "2.1.3",
|
|
11
|
+
"output_style": { "name": "Explanatory" },
|
|
12
|
+
"cost": {
|
|
13
|
+
"total_cost_usd": 0.057773349999999994,
|
|
14
|
+
"total_duration_ms": 122167,
|
|
15
|
+
"total_api_duration_ms": 18488,
|
|
16
|
+
"total_lines_added": 0,
|
|
17
|
+
"total_lines_removed": 0
|
|
18
|
+
},
|
|
19
|
+
"context_window": {
|
|
20
|
+
"total_input_tokens": 753,
|
|
21
|
+
"total_output_tokens": 665,
|
|
22
|
+
"context_window_size": 200000,
|
|
23
|
+
"current_usage": {
|
|
24
|
+
"input_tokens": 10,
|
|
25
|
+
"output_tokens": 3,
|
|
26
|
+
"cache_creation_input_tokens": 212,
|
|
27
|
+
"cache_read_input_tokens": 21034
|
|
28
|
+
}
|
|
29
|
+
},
|
|
30
|
+
"exceeds_200k_tokens": false
|
|
31
|
+
}
|
package/index.ts
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { configure } from "@logtape/logtape";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { log, logtapeConfig } from "./src/logging";
|
|
4
|
+
import { statusSchema } from "./src/statusLineSchema";
|
|
5
|
+
import {
|
|
6
|
+
currentDirStatus,
|
|
7
|
+
currentGitStatus,
|
|
8
|
+
currentModelStatus,
|
|
9
|
+
} from "./src/utils";
|
|
10
|
+
|
|
11
|
+
import c from "ansi-colors";
|
|
12
|
+
|
|
13
|
+
await configure(logtapeConfig);
|
|
14
|
+
|
|
15
|
+
const input = await Bun.stdin.stream().json();
|
|
16
|
+
log.debug("stdin: {input}", input);
|
|
17
|
+
|
|
18
|
+
const result = statusSchema.safeParse(input);
|
|
19
|
+
|
|
20
|
+
let statusLine = null;
|
|
21
|
+
if (result.success) {
|
|
22
|
+
const status = result.data;
|
|
23
|
+
const dirStatus = c.blue(currentDirStatus(status));
|
|
24
|
+
const gitStatus = c.green(await currentGitStatus());
|
|
25
|
+
const modelStatus = c.magenta(currentModelStatus(status));
|
|
26
|
+
const separator = c.bold.gray("⋮");
|
|
27
|
+
|
|
28
|
+
statusLine = `${dirStatus} ${separator} ${gitStatus} ${separator} ${modelStatus}`;
|
|
29
|
+
} else {
|
|
30
|
+
log.error("Failed to parse input: {error}", {
|
|
31
|
+
error: JSON.stringify(z.treeifyError(result.error)),
|
|
32
|
+
});
|
|
33
|
+
statusLine = `[]`;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
console.log(statusLine);
|
package/package.json
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@nutthead/cc-statusline",
|
|
3
|
+
"description": "Status Line for Claude Code",
|
|
4
|
+
"version": "0.1.0",
|
|
5
|
+
"module": "index.ts",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"private": false,
|
|
8
|
+
"license": "MIT",
|
|
9
|
+
"author": {
|
|
10
|
+
"name": "Behrang Saeedzadeh",
|
|
11
|
+
"email": "hello@behrang.org",
|
|
12
|
+
"url": "https://behrang.org"
|
|
13
|
+
},
|
|
14
|
+
"scripts": {
|
|
15
|
+
"build:binary": "mkdir -p target && bun build --compile ./index.ts --outfile target/statusline",
|
|
16
|
+
"install:binary": "cp target/statusline ~/.claude/"
|
|
17
|
+
},
|
|
18
|
+
"devDependencies": {
|
|
19
|
+
"@types/bun": "latest"
|
|
20
|
+
},
|
|
21
|
+
"peerDependencies": {
|
|
22
|
+
"typescript": "^5"
|
|
23
|
+
},
|
|
24
|
+
"dependencies": {
|
|
25
|
+
"@logtape/file": "^1.3.6",
|
|
26
|
+
"@logtape/logtape": "^1.3.6",
|
|
27
|
+
"ansi-colors": "^4.1.3",
|
|
28
|
+
"simple-git": "^3.30.0",
|
|
29
|
+
"type-fest": "^5.3.1",
|
|
30
|
+
"zod": "^4.3.5"
|
|
31
|
+
},
|
|
32
|
+
"packageManager": "pnpm@10.28.0+sha512.05df71d1421f21399e053fde567cea34d446fa02c76571441bfc1c7956e98e363088982d940465fd34480d4d90a0668bc12362f8aa88000a64e83d0b0e47be48"
|
|
33
|
+
}
|
package/src/logging.ts
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { getFileSink } from "@logtape/file";
|
|
2
|
+
import { getLogger, type Config } from "@logtape/logtape";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
|
|
5
|
+
const logtapeConfig: Config<"file", string> = {
|
|
6
|
+
sinks: {
|
|
7
|
+
file: getFileSink(`${homedir()}/.local/state/statusline/app.log`),
|
|
8
|
+
},
|
|
9
|
+
loggers: [
|
|
10
|
+
{
|
|
11
|
+
category: "statusline",
|
|
12
|
+
lowestLevel: "debug",
|
|
13
|
+
sinks: ["file"],
|
|
14
|
+
},
|
|
15
|
+
{
|
|
16
|
+
category: ["logtape", "meta"],
|
|
17
|
+
sinks: ["file"],
|
|
18
|
+
},
|
|
19
|
+
],
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
const log = getLogger(["statusline"]);
|
|
23
|
+
|
|
24
|
+
export { logtapeConfig, log };
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { test, expect, describe } from "bun:test";
|
|
2
|
+
import { statusSchema } from "./statusLineSchema";
|
|
3
|
+
import { readdirSync } from "node:fs";
|
|
4
|
+
|
|
5
|
+
const fixturesDir = `${import.meta.dir}/../fixtures`;
|
|
6
|
+
const fixtureFiles = readdirSync(fixturesDir).filter((f) =>
|
|
7
|
+
f.endsWith(".json")
|
|
8
|
+
);
|
|
9
|
+
|
|
10
|
+
describe("statusLineSchema", () => {
|
|
11
|
+
describe("fixtures validation", () => {
|
|
12
|
+
test.each(fixtureFiles)(
|
|
13
|
+
"%s does not lead to an error",
|
|
14
|
+
async (filename) => {
|
|
15
|
+
const fixture = await Bun.file(`${fixturesDir}/${filename}`).json();
|
|
16
|
+
const result = statusSchema.safeParse(fixture);
|
|
17
|
+
|
|
18
|
+
expect(result.success).toBe(true);
|
|
19
|
+
}
|
|
20
|
+
);
|
|
21
|
+
});
|
|
22
|
+
});
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
|
|
3
|
+
export const statusSchema = z.object({
|
|
4
|
+
hook_event_name: z.string().optional(),
|
|
5
|
+
session_id: z.string(),
|
|
6
|
+
transcript_path: z.string(),
|
|
7
|
+
cwd: z.string(),
|
|
8
|
+
model: z.object({
|
|
9
|
+
id: z.string(),
|
|
10
|
+
display_name: z.string(),
|
|
11
|
+
}),
|
|
12
|
+
workspace: z.object({
|
|
13
|
+
current_dir: z.string(),
|
|
14
|
+
project_dir: z.string(),
|
|
15
|
+
}),
|
|
16
|
+
version: z.string(),
|
|
17
|
+
output_style: z
|
|
18
|
+
.object({
|
|
19
|
+
name: z.string(),
|
|
20
|
+
})
|
|
21
|
+
.optional(),
|
|
22
|
+
cost: z.object({
|
|
23
|
+
total_cost_usd: z.number(),
|
|
24
|
+
total_duration_ms: z.number(),
|
|
25
|
+
total_api_duration_ms: z.number(),
|
|
26
|
+
total_lines_added: z.number(),
|
|
27
|
+
total_lines_removed: z.number(),
|
|
28
|
+
}),
|
|
29
|
+
context_window: z
|
|
30
|
+
.object({
|
|
31
|
+
input_tokens: z.number().optional(),
|
|
32
|
+
output_tokens: z.number().optional(),
|
|
33
|
+
cache_creation_input_tokens: z.number().optional(),
|
|
34
|
+
cache_read_input_tokens: z.number().optional(),
|
|
35
|
+
})
|
|
36
|
+
.nullable(),
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
export type Status = z.infer<typeof statusSchema>;
|
|
@@ -0,0 +1,472 @@
|
|
|
1
|
+
import { test, expect, describe, mock, beforeAll, afterAll } from "bun:test";
|
|
2
|
+
import { mkdtemp, rm } from "node:fs/promises";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
|
|
6
|
+
// Mock homedir to return a consistent value for testing
|
|
7
|
+
mock.module("node:os", () => ({
|
|
8
|
+
homedir: () => "/home/testuser",
|
|
9
|
+
}));
|
|
10
|
+
|
|
11
|
+
// Import after mocking
|
|
12
|
+
import {
|
|
13
|
+
abbreviatePath,
|
|
14
|
+
abbreviateModelId,
|
|
15
|
+
currentBranchName,
|
|
16
|
+
currentDirStatus,
|
|
17
|
+
currentGitStatus,
|
|
18
|
+
currentModelStatus,
|
|
19
|
+
} from "./utils";
|
|
20
|
+
import type { Status } from "./statusLineSchema";
|
|
21
|
+
|
|
22
|
+
/** Default Status fixture with sensible test values */
|
|
23
|
+
const defaultStatus: Status = {
|
|
24
|
+
session_id: "test-session",
|
|
25
|
+
transcript_path: "/tmp/transcript.json",
|
|
26
|
+
cwd: "/test/cwd",
|
|
27
|
+
model: {
|
|
28
|
+
id: "claude-opus-4.5",
|
|
29
|
+
display_name: "Test Model",
|
|
30
|
+
},
|
|
31
|
+
workspace: {
|
|
32
|
+
project_dir: "/home/testuser/project",
|
|
33
|
+
current_dir: "/home/testuser/project",
|
|
34
|
+
},
|
|
35
|
+
version: "1.0.0",
|
|
36
|
+
cost: {
|
|
37
|
+
total_cost_usd: 0,
|
|
38
|
+
total_duration_ms: 0,
|
|
39
|
+
total_api_duration_ms: 0,
|
|
40
|
+
total_lines_added: 0,
|
|
41
|
+
total_lines_removed: 0,
|
|
42
|
+
},
|
|
43
|
+
context_window: null,
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
describe("abbreviatePath", () => {
|
|
47
|
+
describe("home directory replacement", () => {
|
|
48
|
+
test("replaces homedir with ~ at start of path", () => {
|
|
49
|
+
expect(abbreviatePath("/home/testuser/projects/myapp")).toBe("~/p/myapp");
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test("returns ~ for exact homedir match", () => {
|
|
53
|
+
expect(abbreviatePath("/home/testuser")).toBe("~");
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test("does not replace homedir if not at start", () => {
|
|
57
|
+
expect(abbreviatePath("/var/home/testuser/data")).toBe("/v/h/t/data");
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test("handles path immediately under homedir", () => {
|
|
61
|
+
expect(abbreviatePath("/home/testuser/file.txt")).toBe("~/file.txt");
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
describe("path abbreviation", () => {
|
|
66
|
+
test("abbreviates all segments except the last", () => {
|
|
67
|
+
expect(abbreviatePath("/foo/bar/baz/etc/last")).toBe("/f/b/b/e/last");
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test("keeps single segment paths unchanged", () => {
|
|
71
|
+
expect(abbreviatePath("filename")).toBe("filename");
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test("handles relative paths", () => {
|
|
75
|
+
expect(abbreviatePath("relative/path/here")).toBe("r/p/here");
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test("handles two segment absolute paths", () => {
|
|
79
|
+
expect(abbreviatePath("/etc/nginx")).toBe("/e/nginx");
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test("handles deep paths", () => {
|
|
83
|
+
expect(abbreviatePath("/a/b/c/d/e/f/g/target")).toBe(
|
|
84
|
+
"/a/b/c/d/e/f/g/target"
|
|
85
|
+
);
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
describe("edge cases", () => {
|
|
90
|
+
test("handles empty string", () => {
|
|
91
|
+
expect(abbreviatePath("")).toBe("");
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test("handles root path", () => {
|
|
95
|
+
expect(abbreviatePath("/")).toBe("/");
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test("handles path with only one segment after root", () => {
|
|
99
|
+
expect(abbreviatePath("/single")).toBe("/single");
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test("preserves trailing slash behavior", () => {
|
|
103
|
+
// Trailing slash creates empty last segment which is preserved
|
|
104
|
+
expect(abbreviatePath("/foo/bar/")).toBe("/f/b/");
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test("handles homedir with subdirectories", () => {
|
|
108
|
+
expect(abbreviatePath("/home/testuser/Code/project/src")).toBe(
|
|
109
|
+
"~/C/p/src"
|
|
110
|
+
);
|
|
111
|
+
});
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
describe("abbreviateModelId", () => {
|
|
116
|
+
describe("claude prefix removal", () => {
|
|
117
|
+
test("removes claude- prefix from model name", () => {
|
|
118
|
+
expect(abbreviateModelId("claude-opus-4.5")).toBe("opus-4.5");
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
test("removes claude- prefix from sonnet model", () => {
|
|
122
|
+
expect(abbreviateModelId("claude-sonnet-4")).toBe("sonnet-4");
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test("removes claude- prefix from haiku model", () => {
|
|
126
|
+
expect(abbreviateModelId("claude-haiku-3.5")).toBe("haiku-3.5");
|
|
127
|
+
});
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
describe("non-claude models", () => {
|
|
131
|
+
test("returns non-claude model unchanged", () => {
|
|
132
|
+
expect(abbreviateModelId("gpt-4")).toBe("gpt-4");
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
test("returns model without prefix unchanged", () => {
|
|
136
|
+
expect(abbreviateModelId("opus-4.5")).toBe("opus-4.5");
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
test("does not match partial claude prefix", () => {
|
|
140
|
+
expect(abbreviateModelId("claud-model")).toBe("claud-model");
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
test("does not match claude without hyphen", () => {
|
|
144
|
+
expect(abbreviateModelId("claudemodel")).toBe("claudemodel");
|
|
145
|
+
});
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
describe("edge cases", () => {
|
|
149
|
+
test("handles empty string", () => {
|
|
150
|
+
expect(abbreviateModelId("")).toBe("");
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
test("handles just the prefix", () => {
|
|
154
|
+
expect(abbreviateModelId("claude-")).toBe("");
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
test("is case-sensitive (uppercase not matched)", () => {
|
|
158
|
+
expect(abbreviateModelId("Claude-opus")).toBe("Claude-opus");
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
test("only removes prefix once", () => {
|
|
162
|
+
expect(abbreviateModelId("claude-claude-test")).toBe("claude-test");
|
|
163
|
+
});
|
|
164
|
+
});
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
describe("currentBranchName", () => {
|
|
168
|
+
describe("valid git repository", () => {
|
|
169
|
+
test("returns branch name for current repository", async () => {
|
|
170
|
+
// This test uses the actual repo we're in
|
|
171
|
+
const result = await currentBranchName(process.cwd());
|
|
172
|
+
expect(result.status).toBe("branch");
|
|
173
|
+
if (result.status === "branch") {
|
|
174
|
+
expect(result.name).toBe("master");
|
|
175
|
+
}
|
|
176
|
+
});
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
describe("non-git directory", () => {
|
|
180
|
+
let tempDir: string;
|
|
181
|
+
|
|
182
|
+
beforeAll(async () => {
|
|
183
|
+
tempDir = await mkdtemp(join(tmpdir(), "git-test-"));
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
afterAll(async () => {
|
|
187
|
+
await rm(tempDir, { recursive: true, force: true });
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
test("returns not-git status for non-git directory", async () => {
|
|
191
|
+
const result = await currentBranchName(tempDir);
|
|
192
|
+
expect(result.status).toBe("not-git");
|
|
193
|
+
});
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
describe("detached HEAD state", () => {
|
|
197
|
+
let tempDir: string;
|
|
198
|
+
|
|
199
|
+
beforeAll(async () => {
|
|
200
|
+
tempDir = await mkdtemp(join(tmpdir(), "git-detached-"));
|
|
201
|
+
// Initialize a git repo, create a commit, then detach HEAD
|
|
202
|
+
const proc = Bun.spawn(
|
|
203
|
+
[
|
|
204
|
+
"bash",
|
|
205
|
+
"-c",
|
|
206
|
+
`
|
|
207
|
+
cd "${tempDir}" &&
|
|
208
|
+
git init &&
|
|
209
|
+
git config user.email "test@test.com" &&
|
|
210
|
+
git config user.name "Test" &&
|
|
211
|
+
echo "test" > file.txt &&
|
|
212
|
+
git add file.txt &&
|
|
213
|
+
git commit -m "initial" &&
|
|
214
|
+
git checkout --detach HEAD
|
|
215
|
+
`,
|
|
216
|
+
],
|
|
217
|
+
{ stdout: "pipe", stderr: "pipe" }
|
|
218
|
+
);
|
|
219
|
+
await proc.exited;
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
afterAll(async () => {
|
|
223
|
+
await rm(tempDir, { recursive: true, force: true });
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
test("returns detached status with commit hash", async () => {
|
|
227
|
+
const result = await currentBranchName(tempDir);
|
|
228
|
+
expect(result.status).toBe("detached");
|
|
229
|
+
if (result.status === "detached") {
|
|
230
|
+
// Commit hash should be 7 characters (short hash)
|
|
231
|
+
expect(result.commit).toMatch(/^[a-f0-9]{7}$/);
|
|
232
|
+
}
|
|
233
|
+
});
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
describe("fresh git repository", () => {
|
|
237
|
+
let tempDir: string;
|
|
238
|
+
|
|
239
|
+
beforeAll(async () => {
|
|
240
|
+
tempDir = await mkdtemp(join(tmpdir(), "git-fresh-"));
|
|
241
|
+
const proc = Bun.spawn(["git", "init", tempDir], {
|
|
242
|
+
stdout: "pipe",
|
|
243
|
+
stderr: "pipe",
|
|
244
|
+
});
|
|
245
|
+
await proc.exited;
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
afterAll(async () => {
|
|
249
|
+
await rm(tempDir, { recursive: true, force: true });
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
test("returns branch name for fresh repo with no commits", async () => {
|
|
253
|
+
const result = await currentBranchName(tempDir);
|
|
254
|
+
// Fresh repos have a branch but no commits - should still work
|
|
255
|
+
expect(result.status).toBe("branch");
|
|
256
|
+
if (result.status === "branch") {
|
|
257
|
+
// Default branch is typically "master" or "main"
|
|
258
|
+
expect(["master", "main"]).toContain(result.name);
|
|
259
|
+
}
|
|
260
|
+
});
|
|
261
|
+
});
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
describe("currentGitStatus", () => {
|
|
265
|
+
describe("output format", () => {
|
|
266
|
+
test("returns branch emoji format in current repository", async () => {
|
|
267
|
+
// Since we're in a git repo on master branch
|
|
268
|
+
const result = await currentGitStatus();
|
|
269
|
+
expect(result).toBe("🌿 master");
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
test("returns a non-empty string", async () => {
|
|
273
|
+
const result = await currentGitStatus();
|
|
274
|
+
expect(typeof result).toBe("string");
|
|
275
|
+
expect(result.length).toBeGreaterThan(0);
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
test("result starts with one of the expected emojis", async () => {
|
|
279
|
+
const result = await currentGitStatus();
|
|
280
|
+
// Should start with 🌿, , 💾, or 💥
|
|
281
|
+
const validPrefixes = ["🌿", "", "💾", "💥"];
|
|
282
|
+
const startsWithValidEmoji = validPrefixes.some((emoji) =>
|
|
283
|
+
result.startsWith(emoji)
|
|
284
|
+
);
|
|
285
|
+
expect(startsWithValidEmoji).toBe(true);
|
|
286
|
+
});
|
|
287
|
+
});
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
describe("currentModelStatus", () => {
|
|
291
|
+
describe("Claude models", () => {
|
|
292
|
+
test("formats opus model with icon and strips claude- prefix", () => {
|
|
293
|
+
const status = {
|
|
294
|
+
...defaultStatus,
|
|
295
|
+
model: { ...defaultStatus.model, id: "claude-opus-4.5" },
|
|
296
|
+
};
|
|
297
|
+
expect(currentModelStatus(status)).toBe("⏣ opus-4.5");
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
test("formats sonnet model with icon and strips claude- prefix", () => {
|
|
301
|
+
const status = {
|
|
302
|
+
...defaultStatus,
|
|
303
|
+
model: { ...defaultStatus.model, id: "claude-sonnet-4" },
|
|
304
|
+
};
|
|
305
|
+
expect(currentModelStatus(status)).toBe("⏣ sonnet-4");
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
test("formats haiku model with icon and strips claude- prefix", () => {
|
|
309
|
+
const status = {
|
|
310
|
+
...defaultStatus,
|
|
311
|
+
model: { ...defaultStatus.model, id: "claude-haiku-3.5" },
|
|
312
|
+
};
|
|
313
|
+
expect(currentModelStatus(status)).toBe("⏣ haiku-3.5");
|
|
314
|
+
});
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
describe("non-Claude models", () => {
|
|
318
|
+
test("formats non-Claude model without modification", () => {
|
|
319
|
+
const status = {
|
|
320
|
+
...defaultStatus,
|
|
321
|
+
model: { ...defaultStatus.model, id: "gpt-4-turbo" },
|
|
322
|
+
};
|
|
323
|
+
expect(currentModelStatus(status)).toBe("⏣ gpt-4-turbo");
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
test("keeps model name when already without claude- prefix", () => {
|
|
327
|
+
const status = {
|
|
328
|
+
...defaultStatus,
|
|
329
|
+
model: { ...defaultStatus.model, id: "opus-4.5" },
|
|
330
|
+
};
|
|
331
|
+
expect(currentModelStatus(status)).toBe("⏣ opus-4.5");
|
|
332
|
+
});
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
describe("output format", () => {
|
|
336
|
+
test("always starts with Model icon", () => {
|
|
337
|
+
const status = {
|
|
338
|
+
...defaultStatus,
|
|
339
|
+
model: { ...defaultStatus.model, id: "any-model" },
|
|
340
|
+
};
|
|
341
|
+
expect(currentModelStatus(status)).toStartWith("⏣ ");
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
test("returns string type", () => {
|
|
345
|
+
const status = {
|
|
346
|
+
...defaultStatus,
|
|
347
|
+
model: { ...defaultStatus.model, id: "claude-opus-4.5" },
|
|
348
|
+
};
|
|
349
|
+
expect(typeof currentModelStatus(status)).toBe("string");
|
|
350
|
+
});
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
describe("edge cases", () => {
|
|
354
|
+
test("handles empty model id", () => {
|
|
355
|
+
const status = {
|
|
356
|
+
...defaultStatus,
|
|
357
|
+
model: { ...defaultStatus.model, id: "" },
|
|
358
|
+
};
|
|
359
|
+
expect(currentModelStatus(status)).toBe("⏣ ");
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
test("handles model id that is just 'claude-'", () => {
|
|
363
|
+
const status = {
|
|
364
|
+
...defaultStatus,
|
|
365
|
+
model: { ...defaultStatus.model, id: "claude-" },
|
|
366
|
+
};
|
|
367
|
+
expect(currentModelStatus(status)).toBe("⏣ ");
|
|
368
|
+
});
|
|
369
|
+
});
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
describe("currentDirStatus", () => {
|
|
373
|
+
describe("same directory", () => {
|
|
374
|
+
test("returns single path when project and current dir match", () => {
|
|
375
|
+
const status = {
|
|
376
|
+
...defaultStatus,
|
|
377
|
+
workspace: {
|
|
378
|
+
project_dir: "/home/testuser/project",
|
|
379
|
+
current_dir: "/home/testuser/project",
|
|
380
|
+
},
|
|
381
|
+
};
|
|
382
|
+
expect(currentDirStatus(status)).toBe("🗂️ ~/project");
|
|
383
|
+
});
|
|
384
|
+
|
|
385
|
+
test("abbreviates path segments except last", () => {
|
|
386
|
+
const status = {
|
|
387
|
+
...defaultStatus,
|
|
388
|
+
workspace: {
|
|
389
|
+
project_dir: "/home/testuser/Code/myapp",
|
|
390
|
+
current_dir: "/home/testuser/Code/myapp",
|
|
391
|
+
},
|
|
392
|
+
};
|
|
393
|
+
expect(currentDirStatus(status)).toBe("🗂️ ~/C/myapp");
|
|
394
|
+
});
|
|
395
|
+
});
|
|
396
|
+
|
|
397
|
+
describe("different directories", () => {
|
|
398
|
+
test("returns both paths with separator when dirs differ", () => {
|
|
399
|
+
const status = {
|
|
400
|
+
...defaultStatus,
|
|
401
|
+
workspace: {
|
|
402
|
+
project_dir: "/home/testuser/project",
|
|
403
|
+
current_dir: "/home/testuser/other",
|
|
404
|
+
},
|
|
405
|
+
};
|
|
406
|
+
expect(currentDirStatus(status)).toBe("🗂️ ~/project 📂 ~/other");
|
|
407
|
+
});
|
|
408
|
+
|
|
409
|
+
test("abbreviates both paths independently", () => {
|
|
410
|
+
const status = {
|
|
411
|
+
...defaultStatus,
|
|
412
|
+
workspace: {
|
|
413
|
+
project_dir: "/home/testuser/Code/frontend",
|
|
414
|
+
current_dir: "/home/testuser/Code/backend",
|
|
415
|
+
},
|
|
416
|
+
};
|
|
417
|
+
expect(currentDirStatus(status)).toBe("🗂️ ~/C/frontend 📂 ~/C/backend");
|
|
418
|
+
});
|
|
419
|
+
|
|
420
|
+
test("handles deeply nested current directory", () => {
|
|
421
|
+
const status = {
|
|
422
|
+
...defaultStatus,
|
|
423
|
+
workspace: {
|
|
424
|
+
project_dir: "/home/testuser/project",
|
|
425
|
+
current_dir: "/home/testuser/project/src/components",
|
|
426
|
+
},
|
|
427
|
+
};
|
|
428
|
+
expect(currentDirStatus(status)).toBe("🗂️ ~/project 📂 ~/p/s/components");
|
|
429
|
+
});
|
|
430
|
+
});
|
|
431
|
+
|
|
432
|
+
describe("path abbreviation", () => {
|
|
433
|
+
test("replaces home directory with ~", () => {
|
|
434
|
+
const status = {
|
|
435
|
+
...defaultStatus,
|
|
436
|
+
workspace: {
|
|
437
|
+
project_dir: "/home/testuser/myapp",
|
|
438
|
+
current_dir: "/home/testuser/myapp",
|
|
439
|
+
},
|
|
440
|
+
};
|
|
441
|
+
expect(currentDirStatus(status)).toStartWith("🗂️ ~");
|
|
442
|
+
});
|
|
443
|
+
|
|
444
|
+
test("handles non-home paths", () => {
|
|
445
|
+
const status = {
|
|
446
|
+
...defaultStatus,
|
|
447
|
+
workspace: {
|
|
448
|
+
project_dir: "/var/www/app",
|
|
449
|
+
current_dir: "/var/www/app",
|
|
450
|
+
},
|
|
451
|
+
};
|
|
452
|
+
expect(currentDirStatus(status)).toBe("🗂️ /v/w/app");
|
|
453
|
+
});
|
|
454
|
+
});
|
|
455
|
+
|
|
456
|
+
describe("output format", () => {
|
|
457
|
+
test("returns string type", () => {
|
|
458
|
+
expect(typeof currentDirStatus(defaultStatus)).toBe("string");
|
|
459
|
+
});
|
|
460
|
+
|
|
461
|
+
test("uses folder emoji as separator for different directories", () => {
|
|
462
|
+
const status = {
|
|
463
|
+
...defaultStatus,
|
|
464
|
+
workspace: {
|
|
465
|
+
project_dir: "/home/testuser/a",
|
|
466
|
+
current_dir: "/home/testuser/b",
|
|
467
|
+
},
|
|
468
|
+
};
|
|
469
|
+
expect(currentDirStatus(status)).toContain(" 📂 ");
|
|
470
|
+
});
|
|
471
|
+
});
|
|
472
|
+
});
|
package/src/utils.ts
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
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
|
+
export {
|
|
160
|
+
abbreviateModelId,
|
|
161
|
+
abbreviatePath,
|
|
162
|
+
currentBranchName,
|
|
163
|
+
currentDirStatus,
|
|
164
|
+
currentGitStatus,
|
|
165
|
+
currentModelStatus,
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
export type { BranchResult };
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
// Environment setup & latest features
|
|
4
|
+
"lib": ["ESNext"],
|
|
5
|
+
"target": "ESNext",
|
|
6
|
+
"module": "Preserve",
|
|
7
|
+
"moduleDetection": "force",
|
|
8
|
+
"jsx": "react-jsx",
|
|
9
|
+
"allowJs": true,
|
|
10
|
+
|
|
11
|
+
// Bundler mode
|
|
12
|
+
"moduleResolution": "bundler",
|
|
13
|
+
"allowImportingTsExtensions": true,
|
|
14
|
+
"verbatimModuleSyntax": true,
|
|
15
|
+
"noEmit": true,
|
|
16
|
+
|
|
17
|
+
// Best practices
|
|
18
|
+
"strict": true,
|
|
19
|
+
"skipLibCheck": true,
|
|
20
|
+
"noFallthroughCasesInSwitch": true,
|
|
21
|
+
"noUncheckedIndexedAccess": true,
|
|
22
|
+
"noImplicitOverride": true,
|
|
23
|
+
|
|
24
|
+
// Some stricter flags (disabled by default)
|
|
25
|
+
"noUnusedLocals": false,
|
|
26
|
+
"noUnusedParameters": false,
|
|
27
|
+
"noPropertyAccessFromIndexSignature": false
|
|
28
|
+
}
|
|
29
|
+
}
|