@owloops/claude-powerline 1.24.3 → 1.25.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.
Files changed (48) hide show
  1. package/README.md +5 -43
  2. package/dist/browser.d.ts +676 -0
  3. package/dist/browser.js +3 -0
  4. package/dist/index.mjs +12 -12
  5. package/package.json +9 -1
  6. package/plugin/templates/config-full.json +1 -1
  7. package/plugin/templates/config-tui-compact.json +3 -3
  8. package/plugin/templates/config-tui-full.json +4 -4
  9. package/plugin/templates/config-tui-standard.json +4 -4
  10. package/src/browser.ts +203 -0
  11. package/src/config/defaults.ts +79 -0
  12. package/src/config/loader.ts +462 -0
  13. package/src/index.ts +90 -0
  14. package/src/powerline.ts +904 -0
  15. package/src/segments/block.ts +31 -0
  16. package/src/segments/context.ts +221 -0
  17. package/src/segments/git.ts +492 -0
  18. package/src/segments/index.ts +25 -0
  19. package/src/segments/metrics.ts +175 -0
  20. package/src/segments/pricing.ts +454 -0
  21. package/src/segments/renderer.ts +796 -0
  22. package/src/segments/session.ts +207 -0
  23. package/src/segments/tmux.ts +35 -0
  24. package/src/segments/today.ts +191 -0
  25. package/src/themes/dark.ts +52 -0
  26. package/src/themes/gruvbox.ts +52 -0
  27. package/src/themes/index.ts +131 -0
  28. package/src/themes/light.ts +52 -0
  29. package/src/themes/nord.ts +52 -0
  30. package/src/themes/rose-pine.ts +52 -0
  31. package/src/themes/tokyo-night.ts +52 -0
  32. package/src/tui/grid.ts +712 -0
  33. package/src/tui/index.ts +4 -0
  34. package/src/tui/layouts.ts +285 -0
  35. package/src/tui/primitives.ts +175 -0
  36. package/src/tui/renderer.ts +206 -0
  37. package/src/tui/sections.ts +1080 -0
  38. package/src/tui/types.ts +181 -0
  39. package/src/utils/budget.ts +47 -0
  40. package/src/utils/cache.ts +247 -0
  41. package/src/utils/claude.ts +489 -0
  42. package/src/utils/color-support.ts +118 -0
  43. package/src/utils/colors.ts +120 -0
  44. package/src/utils/constants.ts +176 -0
  45. package/src/utils/formatters.ts +160 -0
  46. package/src/utils/logger.ts +5 -0
  47. package/src/utils/terminal-width.ts +117 -0
  48. package/src/utils/terminal.ts +11 -0
@@ -0,0 +1,462 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import os from "node:os";
4
+ import { DEFAULT_CONFIG } from "./defaults";
5
+ import type { ColorTheme } from "../themes";
6
+ import type { TuiGridConfig } from "../tui/types";
7
+ import { isValidSegmentRef } from "../tui/types";
8
+ import { BOX_PRESETS } from "../utils/constants";
9
+ import type {
10
+ SegmentConfig,
11
+ DirectorySegmentConfig,
12
+ GitSegmentConfig,
13
+ UsageSegmentConfig,
14
+ TmuxSegmentConfig,
15
+ ContextSegmentConfig,
16
+ MetricsSegmentConfig,
17
+ BlockSegmentConfig,
18
+ TodaySegmentConfig,
19
+ VersionSegmentConfig,
20
+ SessionIdSegmentConfig,
21
+ EnvSegmentConfig,
22
+ WeeklySegmentConfig,
23
+ } from "../segments/renderer";
24
+
25
+ export interface LineConfig {
26
+ segments: {
27
+ directory?: DirectorySegmentConfig;
28
+ git?: GitSegmentConfig;
29
+ model?: SegmentConfig;
30
+ session?: UsageSegmentConfig;
31
+ block?: BlockSegmentConfig;
32
+ today?: TodaySegmentConfig;
33
+ tmux?: TmuxSegmentConfig;
34
+ context?: ContextSegmentConfig;
35
+ metrics?: MetricsSegmentConfig;
36
+ version?: VersionSegmentConfig;
37
+ sessionId?: SessionIdSegmentConfig;
38
+ env?: EnvSegmentConfig;
39
+ weekly?: WeeklySegmentConfig;
40
+ };
41
+ }
42
+
43
+ export interface DisplayConfig {
44
+ lines: LineConfig[];
45
+ style?: "minimal" | "powerline" | "capsule" | "tui";
46
+ charset?: "unicode" | "text";
47
+ colorCompatibility?: "auto" | "ansi" | "ansi256" | "truecolor";
48
+ autoWrap?: boolean;
49
+ padding?: number;
50
+ tui?: TuiGridConfig;
51
+ }
52
+
53
+ export interface BudgetItemConfig {
54
+ amount?: number;
55
+ warningThreshold?: number;
56
+ type?: "cost" | "tokens";
57
+ }
58
+
59
+ export interface BudgetConfig {
60
+ session?: BudgetItemConfig;
61
+ today?: BudgetItemConfig;
62
+ block?: BudgetItemConfig;
63
+ }
64
+
65
+ export interface PowerlineConfig {
66
+ theme:
67
+ | "light"
68
+ | "dark"
69
+ | "nord"
70
+ | "tokyo-night"
71
+ | "rose-pine"
72
+ | "gruvbox"
73
+ | "custom";
74
+ display: DisplayConfig;
75
+ colors?: {
76
+ custom: ColorTheme;
77
+ };
78
+ budget?: BudgetConfig;
79
+ modelContextLimits?: Record<string, number>;
80
+ }
81
+
82
+ function isValidTheme(theme: string): theme is PowerlineConfig["theme"] {
83
+ return [
84
+ "light",
85
+ "dark",
86
+ "nord",
87
+ "tokyo-night",
88
+ "rose-pine",
89
+ "gruvbox",
90
+ "custom",
91
+ ].includes(theme);
92
+ }
93
+
94
+ function isValidStyle(
95
+ style: string,
96
+ ): style is "minimal" | "powerline" | "capsule" | "tui" {
97
+ return (
98
+ style === "minimal" ||
99
+ style === "powerline" ||
100
+ style === "capsule" ||
101
+ style === "tui"
102
+ );
103
+ }
104
+
105
+ function isValidCharset(charset: string): charset is "unicode" | "text" {
106
+ return charset === "unicode" || charset === "text";
107
+ }
108
+
109
+ function getArgValue(args: string[], argName: string): string | undefined {
110
+ for (let i = 0; i < args.length; i++) {
111
+ const arg = args[i];
112
+ if (arg === argName && i + 1 < args.length) {
113
+ return args[i + 1];
114
+ }
115
+ if (arg?.startsWith(`${argName}=`)) {
116
+ return arg.split("=")[1];
117
+ }
118
+ }
119
+ return undefined;
120
+ }
121
+
122
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
123
+ function deepMerge<T extends Record<string, any>>(
124
+ target: T,
125
+ source: Partial<T>,
126
+ ): T {
127
+ const result = { ...target };
128
+
129
+ for (const key in source) {
130
+ const sourceValue = source[key];
131
+ if (sourceValue !== undefined) {
132
+ if (
133
+ typeof sourceValue === "object" &&
134
+ sourceValue !== null &&
135
+ !Array.isArray(sourceValue)
136
+ ) {
137
+ const targetValue = result[key] || {};
138
+ result[key] = deepMerge(
139
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
140
+ targetValue as Record<string, any>,
141
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
142
+ sourceValue as Record<string, any>,
143
+ ) as T[Extract<keyof T, string>];
144
+ } else {
145
+ result[key] = sourceValue as T[Extract<keyof T, string>];
146
+ }
147
+ }
148
+ }
149
+
150
+ return result;
151
+ }
152
+
153
+ function findConfigFile(
154
+ customPath?: string,
155
+ projectDir?: string,
156
+ ): string | null {
157
+ if (customPath) {
158
+ return fs.existsSync(customPath) ? customPath : null;
159
+ }
160
+
161
+ const locations = [
162
+ ...(projectDir ? [path.join(projectDir, ".claude-powerline.json")] : []),
163
+ path.join(process.cwd(), ".claude-powerline.json"),
164
+ path.join(os.homedir(), ".claude", "claude-powerline.json"),
165
+ path.join(os.homedir(), ".config", "claude-powerline", "config.json"),
166
+ ];
167
+
168
+ return locations.find(fs.existsSync) || null;
169
+ }
170
+
171
+ function loadConfigFile(filePath: string): Partial<PowerlineConfig> {
172
+ try {
173
+ const content = fs.readFileSync(filePath, "utf-8");
174
+ return JSON.parse(content);
175
+ } catch (error) {
176
+ throw new Error(
177
+ `Failed to load config file ${filePath}: ${error instanceof Error ? error.message : String(error)}`,
178
+ );
179
+ }
180
+ }
181
+
182
+ function loadEnvConfig(): Partial<PowerlineConfig> {
183
+ const config: Partial<PowerlineConfig> = {};
184
+ const display: Partial<DisplayConfig> = {};
185
+
186
+ const theme = process.env.CLAUDE_POWERLINE_THEME;
187
+ if (theme && isValidTheme(theme)) {
188
+ config.theme = theme;
189
+ }
190
+
191
+ const style = process.env.CLAUDE_POWERLINE_STYLE;
192
+ if (style) {
193
+ if (isValidStyle(style)) {
194
+ display.style = style;
195
+ } else {
196
+ console.warn(
197
+ `Invalid display style '${style}' from environment variable, falling back to 'minimal'`,
198
+ );
199
+ display.style = "minimal";
200
+ }
201
+ }
202
+
203
+ if (Object.keys(display).length > 0) {
204
+ config.display = display as DisplayConfig;
205
+ }
206
+
207
+ return config;
208
+ }
209
+
210
+ function getConfigPathFromEnv(): string | undefined {
211
+ return process.env.CLAUDE_POWERLINE_CONFIG;
212
+ }
213
+
214
+ function parseCLIOverrides(args: string[]): Partial<PowerlineConfig> {
215
+ const config: Partial<PowerlineConfig> = {};
216
+ const display: Partial<DisplayConfig> = {};
217
+
218
+ const theme = getArgValue(args, "--theme");
219
+ if (theme && isValidTheme(theme)) {
220
+ config.theme = theme;
221
+ }
222
+
223
+ const style = getArgValue(args, "--style");
224
+ if (style) {
225
+ if (isValidStyle(style)) {
226
+ display.style = style;
227
+ } else {
228
+ console.warn(
229
+ `Invalid display style '${style}' from CLI argument, falling back to 'minimal'`,
230
+ );
231
+ display.style = "minimal";
232
+ }
233
+ }
234
+
235
+ const charset = getArgValue(args, "--charset");
236
+ if (charset) {
237
+ if (isValidCharset(charset)) {
238
+ display.charset = charset;
239
+ } else {
240
+ console.warn(
241
+ `Invalid charset '${charset}' from CLI argument, falling back to 'unicode'`,
242
+ );
243
+ display.charset = "unicode";
244
+ }
245
+ }
246
+
247
+ if (Object.keys(display).length > 0) {
248
+ config.display = display as DisplayConfig;
249
+ }
250
+
251
+ return config;
252
+ }
253
+
254
+ function validateGridConfig(tui: TuiGridConfig): string | null {
255
+ if (typeof tui.box === "string" && !BOX_PRESETS[tui.box]) {
256
+ const valid = Object.keys(BOX_PRESETS).join(", ");
257
+ return `unknown box preset "${tui.box}" (valid: ${valid})`;
258
+ }
259
+
260
+ if (
261
+ !tui.breakpoints ||
262
+ !Array.isArray(tui.breakpoints) ||
263
+ tui.breakpoints.length === 0
264
+ ) {
265
+ return "grid config must have at least one breakpoint";
266
+ }
267
+
268
+ const seenMinWidths = new Set<number>();
269
+ for (let bpIdx = 0; bpIdx < tui.breakpoints.length; bpIdx++) {
270
+ const bp = tui.breakpoints[bpIdx]!;
271
+ const prefix = `breakpoint[${bpIdx}]`;
272
+
273
+ if (typeof bp.minWidth !== "number" || bp.minWidth < 0) {
274
+ return `${prefix}: minWidth must be a non-negative number`;
275
+ }
276
+
277
+ if (seenMinWidths.has(bp.minWidth)) {
278
+ return `${prefix}: duplicate minWidth ${bp.minWidth} (each breakpoint must have a unique minWidth)`;
279
+ }
280
+ seenMinWidths.add(bp.minWidth);
281
+
282
+ if (!bp.areas || !Array.isArray(bp.areas) || bp.areas.length === 0) {
283
+ return `${prefix}: areas must be a non-empty array of strings`;
284
+ }
285
+
286
+ if (!bp.columns || !Array.isArray(bp.columns) || bp.columns.length === 0) {
287
+ return `${prefix}: columns must be a non-empty array`;
288
+ }
289
+
290
+ const colCount = bp.columns.length;
291
+
292
+ // Validate column definitions
293
+ for (const col of bp.columns) {
294
+ if (typeof col !== "string") {
295
+ return `${prefix}: column definition must be a string`;
296
+ }
297
+ if (!/^(\d+fr|\d+|auto)$/.test(col)) {
298
+ return `${prefix}: invalid column definition "${col}" (use "auto", "Nfr", or a fixed integer)`;
299
+ }
300
+ }
301
+
302
+ // Validate align array
303
+ if (bp.align !== undefined) {
304
+ if (!Array.isArray(bp.align)) {
305
+ return `${prefix}: align must be an array`;
306
+ }
307
+ if (bp.align.length !== colCount) {
308
+ return `${prefix}: align length (${bp.align.length}) must match columns length (${colCount})`;
309
+ }
310
+ for (const a of bp.align) {
311
+ if (a !== "left" && a !== "center" && a !== "right") {
312
+ return `${prefix}: invalid align value "${a}"`;
313
+ }
314
+ }
315
+ }
316
+
317
+ // Validate areas rows
318
+ const seenSegments = new Set<string>();
319
+ for (let rowIdx = 0; rowIdx < bp.areas.length; rowIdx++) {
320
+ const row = bp.areas[rowIdx]!;
321
+
322
+ // Divider row
323
+ if (row.trim() === "---") continue;
324
+
325
+ const cells = row.trim().split(/\s+/);
326
+ if (cells.length !== colCount) {
327
+ return `${prefix}: row "${row}" has ${cells.length} cells but expected ${colCount} columns`;
328
+ }
329
+
330
+ // Check segment names and contiguity
331
+ const templateNames = tui.segments
332
+ ? new Set(Object.keys(tui.segments))
333
+ : new Set<string>();
334
+ let prevCell = "";
335
+ let spanName = "";
336
+ for (const cell of cells) {
337
+ if (cell !== ".") {
338
+ if (!isValidSegmentRef(cell) && !templateNames.has(cell)) {
339
+ return `${prefix}: unknown segment name "${cell}"`;
340
+ }
341
+ // Check for non-contiguous spans
342
+ if (cell === spanName) {
343
+ // still in the same span, ok
344
+ } else if (seenSegments.has(cell)) {
345
+ return `${prefix}: segment "${cell}" appears on multiple rows`;
346
+ }
347
+ }
348
+
349
+ // Track span contiguity
350
+ if (cell !== prevCell) {
351
+ if (spanName && prevCell !== spanName && prevCell !== ".") {
352
+ // finished a span
353
+ }
354
+ spanName = cell;
355
+ }
356
+ prevCell = cell;
357
+ }
358
+
359
+ // Check for non-contiguous spans within this row
360
+ const seen = new Map<string, number>();
361
+ for (let i = 0; i < cells.length; i++) {
362
+ const cell = cells[i]!;
363
+ if (cell === "." || cell === "---") continue;
364
+ const lastIdx = seen.get(cell);
365
+ if (lastIdx !== undefined && lastIdx !== i - 1) {
366
+ return `${prefix}: segment "${cell}" has non-contiguous span in row "${row}"`;
367
+ }
368
+ seen.set(cell, i);
369
+ }
370
+
371
+ // Record segments from this row
372
+ for (const cell of cells) {
373
+ if (cell !== "." && cell !== "---") {
374
+ seenSegments.add(cell);
375
+ }
376
+ }
377
+ }
378
+ }
379
+
380
+ if (tui.segments) {
381
+ for (const [segRef, tmpl] of Object.entries(tui.segments)) {
382
+ if (!tmpl.items || !Array.isArray(tmpl.items)) {
383
+ return `segments["${segRef}"]: items must be an array`;
384
+ }
385
+ if (
386
+ tmpl.justify !== undefined &&
387
+ tmpl.justify !== "start" &&
388
+ tmpl.justify !== "between"
389
+ ) {
390
+ return `segments["${segRef}"]: invalid justify value "${tmpl.justify}" (use "start" or "between")`;
391
+ }
392
+ }
393
+ }
394
+
395
+ return null; // valid
396
+ }
397
+
398
+ export function loadConfig(
399
+ args: string[] = process.argv,
400
+ projectDir?: string,
401
+ ): PowerlineConfig {
402
+ let config: PowerlineConfig = JSON.parse(JSON.stringify(DEFAULT_CONFIG));
403
+
404
+ const rawConfigPath = getArgValue(args, "--config") || getConfigPathFromEnv();
405
+ const configPath = rawConfigPath?.startsWith("~")
406
+ ? rawConfigPath.replace("~", os.homedir())
407
+ : rawConfigPath;
408
+
409
+ const configFile = findConfigFile(configPath, projectDir);
410
+ if (configFile) {
411
+ try {
412
+ const fileConfig = loadConfigFile(configFile);
413
+ config = deepMerge(config, fileConfig);
414
+ } catch (err) {
415
+ console.warn(
416
+ `Warning: ${err instanceof Error ? err.message : String(err)}`,
417
+ );
418
+ }
419
+ }
420
+
421
+ if (config.display?.style && !isValidStyle(config.display.style)) {
422
+ console.warn(
423
+ `Invalid display style '${config.display.style}' in config file, falling back to 'minimal'`,
424
+ );
425
+ config.display.style = "minimal";
426
+ }
427
+
428
+ if (config.display?.charset && !isValidCharset(config.display.charset)) {
429
+ console.warn(
430
+ `Invalid charset '${config.display.charset}' in config file, falling back to 'unicode'`,
431
+ );
432
+ config.display.charset = "unicode";
433
+ }
434
+
435
+ if (config.theme && !isValidTheme(config.theme)) {
436
+ console.warn(
437
+ `Invalid theme '${config.theme}' in config file, falling back to 'dark'`,
438
+ );
439
+ config.theme = "dark";
440
+ }
441
+
442
+ const envConfig = loadEnvConfig();
443
+ config = deepMerge(config, envConfig);
444
+
445
+ const cliOverrides = parseCLIOverrides(args);
446
+ config = deepMerge(config, cliOverrides);
447
+
448
+ // Validate grid config if present
449
+ if (config.display?.tui) {
450
+ const error = validateGridConfig(config.display.tui);
451
+ if (error) {
452
+ process.stderr.write(
453
+ `Warning: invalid grid config: ${error}. Falling back to hardcoded layout.\n`,
454
+ );
455
+ delete config.display.tui;
456
+ }
457
+ }
458
+
459
+ return config;
460
+ }
461
+
462
+ export const loadConfigFromCLI = loadConfig;
package/src/index.ts ADDED
@@ -0,0 +1,90 @@
1
+ #!/usr/bin/env node
2
+
3
+ import type { ClaudeHookData } from "./utils/claude";
4
+
5
+ import process from "node:process";
6
+ import { json } from "node:stream/consumers";
7
+ import { PowerlineRenderer } from "./powerline";
8
+ import { loadConfigFromCLI } from "./config/loader";
9
+ import { debug } from "./utils/logger";
10
+
11
+ function showHelpText(): void {
12
+ console.log(`
13
+ claude-powerline - Beautiful powerline statusline for Claude Code
14
+
15
+ Usage: claude-powerline [options]
16
+
17
+ Standalone Commands:
18
+ -h, --help Show this help
19
+
20
+ Debugging:
21
+ CLAUDE_POWERLINE_DEBUG=1 Enable debug logging for troubleshooting
22
+
23
+ Claude Code Options (for settings.json):
24
+ --theme=THEME Set theme: dark, light, nord, tokyo-night, rose-pine, custom
25
+ --style=STYLE Set separator style: minimal, powerline, capsule, tui
26
+ --charset=CHARSET Set character set: unicode (default), text
27
+ --config=PATH Use custom config file path
28
+
29
+ See example config at: https://github.com/Owloops/claude-powerline/blob/main/.claude-powerline.json
30
+
31
+ `);
32
+ }
33
+
34
+ async function main(): Promise<void> {
35
+ try {
36
+ const showHelp =
37
+ process.argv.includes("--help") || process.argv.includes("-h");
38
+
39
+ if (showHelp) {
40
+ showHelpText();
41
+ process.exit(0);
42
+ }
43
+
44
+ if (process.stdin.isTTY === true) {
45
+ console.error(`Error: This tool requires input from Claude Code
46
+
47
+ claude-powerline is designed to be used as a Claude Code statusLine command.
48
+ It reads hook data from stdin and outputs formatted statusline.
49
+
50
+ Add to ~/.claude/settings.json:
51
+ {
52
+ "statusLine": {
53
+ "type": "command",
54
+ "command": "claude-powerline --style=powerline"
55
+ }
56
+ }
57
+
58
+ Run with --help for more options.
59
+
60
+ To test output manually:
61
+ echo '{"session_id":"test-session","workspace":{"project_dir":"/path/to/project"},"model":{"id":"claude-sonnet-4-5","display_name":"Claude"}}' | claude-powerline --style=powerline`);
62
+ process.exit(1);
63
+ }
64
+
65
+ debug(`Working directory: ${process.cwd()}`);
66
+ debug(`Process args:`, process.argv);
67
+
68
+ const hookData = (await json(process.stdin)) as ClaudeHookData;
69
+ debug(`Received hook data:`, JSON.stringify(hookData, null, 2));
70
+
71
+ if (!hookData) {
72
+ console.error("Error: No input data received from stdin");
73
+ showHelpText();
74
+ process.exit(1);
75
+ }
76
+
77
+ const projectDir = hookData.workspace?.project_dir;
78
+ const config = loadConfigFromCLI(process.argv, projectDir);
79
+ const renderer = new PowerlineRenderer(config);
80
+ const statusline = await renderer.generateStatusline(hookData);
81
+
82
+ console.log(statusline);
83
+ } catch (error) {
84
+ const errorMessage = error instanceof Error ? error.message : String(error);
85
+ console.error("Error generating statusline:", errorMessage);
86
+ process.exit(1);
87
+ }
88
+ }
89
+
90
+ main();