@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,181 @@
1
+ import type { UsageInfo } from "../segments/session";
2
+ import type { BlockInfo } from "../segments/block";
3
+ import type { TodayInfo } from "../segments/today";
4
+ import type { ContextInfo } from "../segments/context";
5
+ import type { MetricsInfo } from "../segments/metrics";
6
+ import type { GitInfo } from "../segments/git";
7
+ import type { ClaudeHookData } from "../utils/claude";
8
+ import type { PowerlineColors } from "../themes";
9
+ import type { PowerlineConfig } from "../config/loader";
10
+
11
+ import type { SYMBOLS, TEXT_SYMBOLS } from "../utils/constants";
12
+
13
+ export interface BoxChars {
14
+ readonly topLeft: string;
15
+ readonly topRight: string;
16
+ readonly bottomLeft: string;
17
+ readonly bottomRight: string;
18
+ readonly horizontal: string;
19
+ readonly vertical: string;
20
+ readonly teeLeft: string;
21
+ readonly teeRight: string;
22
+ }
23
+
24
+ export interface TuiData {
25
+ hookData: ClaudeHookData;
26
+ usageInfo: UsageInfo | null;
27
+ blockInfo: BlockInfo | null;
28
+ todayInfo: TodayInfo | null;
29
+ contextInfo: ContextInfo | null;
30
+ metricsInfo: MetricsInfo | null;
31
+ gitInfo: GitInfo | null;
32
+ tmuxSessionId: string | null;
33
+ colors: PowerlineColors;
34
+ }
35
+
36
+ export type SymbolSet = typeof SYMBOLS | typeof TEXT_SYMBOLS;
37
+
38
+ export type LayoutMode = "wide" | "medium" | "narrow";
39
+
40
+ const SEGMENT_NAME_LIST = [
41
+ "context",
42
+ "block",
43
+ "session",
44
+ "today",
45
+ "weekly",
46
+ "git",
47
+ "dir",
48
+ "model",
49
+ "version",
50
+ "tmux",
51
+ "metrics",
52
+ "activity",
53
+ "env",
54
+ ] as const;
55
+
56
+ export type SegmentName = (typeof SEGMENT_NAME_LIST)[number];
57
+
58
+ export const VALID_SEGMENT_NAMES: ReadonlySet<string> = new Set<SegmentName>(
59
+ SEGMENT_NAME_LIST,
60
+ );
61
+
62
+ export const SEGMENT_PARTS: Record<SegmentName, readonly string[]> = {
63
+ session: ["icon", "label", "cost", "tokens", "budget"],
64
+ block: ["icon", "label", "value", "time", "budget", "bar"],
65
+ today: ["icon", "cost", "label", "budget"],
66
+ weekly: ["icon", "label", "pct", "time", "bar"],
67
+ git: [
68
+ "icon",
69
+ "info",
70
+ "branch",
71
+ "status",
72
+ "ahead",
73
+ "behind",
74
+ "working",
75
+ "head",
76
+ ],
77
+ context: ["icon", "label", "bar", "pct", "tokens"],
78
+ metrics: [
79
+ "response",
80
+ "responseIcon",
81
+ "responseVal",
82
+ "lastResponse",
83
+ "lastResponseIcon",
84
+ "lastResponseVal",
85
+ "added",
86
+ "addedIcon",
87
+ "addedVal",
88
+ "removed",
89
+ "removedIcon",
90
+ "removedVal",
91
+ ],
92
+ activity: [
93
+ "icon",
94
+ "duration",
95
+ "durationIcon",
96
+ "durationVal",
97
+ "messages",
98
+ "messagesIcon",
99
+ "messagesVal",
100
+ ],
101
+ model: ["icon", "value"],
102
+ version: ["icon", "value"],
103
+ tmux: ["label", "value"],
104
+ dir: ["icon", "value"],
105
+ env: ["prefix", "value"],
106
+ } as const;
107
+
108
+ export function isValidSegmentRef(name: string): boolean {
109
+ if (name === "." || name === "---") return true;
110
+ if (VALID_SEGMENT_NAMES.has(name)) return true;
111
+ const dotIdx = name.indexOf(".");
112
+ if (dotIdx === -1) return false;
113
+ const seg = name.slice(0, dotIdx);
114
+ const part = name.slice(dotIdx + 1);
115
+ if (!seg || !part) return false;
116
+ const parts = SEGMENT_PARTS[seg as SegmentName];
117
+ return parts ? parts.includes(part) : false;
118
+ }
119
+
120
+ export type AlignValue = "left" | "center" | "right";
121
+
122
+ export interface GridCell {
123
+ segment: string; // segment name, "." for empty, "---" for divider
124
+ spanStart: boolean; // true if this is the first cell of a span
125
+ spanSize: number; // number of columns this cell spans (1 if no span)
126
+ }
127
+
128
+ export interface TuiGridBreakpoint {
129
+ minWidth: number;
130
+ areas: string[];
131
+ columns: string[];
132
+ align?: AlignValue[];
133
+ }
134
+
135
+ export type JustifyValue = "start" | "between";
136
+
137
+ export interface SegmentTemplate {
138
+ items: string[];
139
+ gap?: number;
140
+ justify?: JustifyValue;
141
+ }
142
+
143
+ export interface TuiTitleConfig {
144
+ left?: string;
145
+ right?: string;
146
+ }
147
+
148
+ export interface TuiFooterConfig {
149
+ left?: string;
150
+ right?: string;
151
+ }
152
+
153
+ export interface TuiGridConfig {
154
+ terminalWidth?: number;
155
+ widthReserve?: number;
156
+ minWidth?: number;
157
+ maxWidth?: number;
158
+ fitContent?: boolean;
159
+ padding?: { horizontal?: number };
160
+ segments?: Record<string, SegmentTemplate>;
161
+ separator?: {
162
+ column?: string;
163
+ divider?: string;
164
+ };
165
+ box?: string | Partial<BoxChars>;
166
+ title?: TuiTitleConfig;
167
+ footer?: TuiFooterConfig;
168
+ breakpoints: TuiGridBreakpoint[];
169
+ }
170
+
171
+ export interface RenderCtx {
172
+ lines: string[];
173
+ data: TuiData;
174
+ box: BoxChars;
175
+ contentWidth: number;
176
+ innerWidth: number;
177
+ sym: SymbolSet;
178
+ config: PowerlineConfig;
179
+ reset: string;
180
+ colors: PowerlineColors;
181
+ }
@@ -0,0 +1,47 @@
1
+ export interface BudgetStatus {
2
+ percentage: number | null;
3
+ isWarning: boolean;
4
+ displayText: string;
5
+ }
6
+
7
+ export function calculateBudgetPercentage(
8
+ cost: number,
9
+ budget: number | undefined,
10
+ ): number | null {
11
+ if (!budget || budget <= 0 || cost < 0) return null;
12
+ return Math.min(100, (cost / budget) * 100);
13
+ }
14
+
15
+ export function getBudgetStatus(
16
+ cost: number,
17
+ budget: number | undefined,
18
+ warningThreshold = 80,
19
+ ): BudgetStatus {
20
+ const percentage = calculateBudgetPercentage(cost, budget);
21
+
22
+ if (percentage === null) {
23
+ return {
24
+ percentage: null,
25
+ isWarning: false,
26
+ displayText: "",
27
+ };
28
+ }
29
+
30
+ const percentStr = `${percentage.toFixed(0)}%`;
31
+ const isWarning = percentage >= warningThreshold;
32
+
33
+ let displayText = "";
34
+ if (isWarning) {
35
+ displayText = ` !${percentStr}`;
36
+ } else if (percentage >= 50) {
37
+ displayText = ` +${percentStr}`;
38
+ } else {
39
+ displayText = ` ${percentStr}`;
40
+ }
41
+
42
+ return {
43
+ percentage,
44
+ isWarning,
45
+ displayText,
46
+ };
47
+ }
@@ -0,0 +1,247 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { homedir } from "node:os";
4
+ import { createHash } from "node:crypto";
5
+ import { setTimeout } from "node:timers/promises";
6
+ import { debug } from "./logger";
7
+ import {
8
+ getClaudePaths,
9
+ findProjectPaths,
10
+ getFileModificationDate,
11
+ } from "./claude";
12
+
13
+ interface ErrnoError extends Error {
14
+ code?: string;
15
+ }
16
+
17
+ export interface CacheEntry<T> {
18
+ data: T;
19
+ timestamp: number;
20
+ }
21
+
22
+ export class CacheManager {
23
+ private static readonly CACHE_DIR = path.join(
24
+ homedir(),
25
+ ".claude",
26
+ "powerline",
27
+ );
28
+ private static readonly USAGE_CACHE_DIR = path.join(this.CACHE_DIR, "usage");
29
+ private static readonly LOCKS_DIR = path.join(this.CACHE_DIR, "locks");
30
+
31
+ private static isLocked(name: string): boolean {
32
+ const lockFile = path.join(this.LOCKS_DIR, name);
33
+ if (!fs.existsSync(lockFile)) {
34
+ return false;
35
+ }
36
+
37
+ try {
38
+ const lockContent = fs.readFileSync(lockFile, "utf-8");
39
+ const pid = parseInt(lockContent.trim(), 10);
40
+
41
+ if (isNaN(pid)) {
42
+ debug(`Invalid PID in lock file ${name}, removing stale lock`);
43
+ fs.unlinkSync(lockFile);
44
+ return false;
45
+ }
46
+
47
+ try {
48
+ process.kill(pid, 0);
49
+ return true;
50
+ } catch (error) {
51
+ if ((error as ErrnoError).code === "ESRCH") {
52
+ debug(`Removing stale lock file ${name} for dead process ${pid}`);
53
+ fs.unlinkSync(lockFile);
54
+ return false;
55
+ }
56
+ debug(`Error checking process ${pid} for lock ${name}:`, error);
57
+ return true;
58
+ }
59
+ } catch (error) {
60
+ debug(`Error reading lock file ${name}:`, error);
61
+ return true;
62
+ }
63
+ }
64
+
65
+ private static async acquireLock(
66
+ name: string,
67
+ timeout = 5000,
68
+ ): Promise<boolean> {
69
+ const RETRY_DELAY_MS = 50;
70
+ const FILE_CREATE_FLAG = "wx";
71
+
72
+ await this.ensureCacheDirectories();
73
+ const lockFile = path.join(this.LOCKS_DIR, name);
74
+ const startTime = Date.now();
75
+ const lockContent = String(process.pid);
76
+
77
+ while (Date.now() - startTime < timeout) {
78
+ try {
79
+ await fs.promises.writeFile(lockFile, lockContent, {
80
+ flag: FILE_CREATE_FLAG,
81
+ });
82
+ debug(`Lock acquired for ${name}`);
83
+ return true;
84
+ } catch (error) {
85
+ if ((error as ErrnoError).code === "EEXIST") {
86
+ await setTimeout(RETRY_DELAY_MS);
87
+ } else {
88
+ throw error;
89
+ }
90
+ }
91
+ }
92
+ debug(`Failed to acquire lock for ${name} within ${timeout}ms`);
93
+ return false;
94
+ }
95
+
96
+ private static async releaseLock(name: string): Promise<void> {
97
+ const lockFile = path.join(this.LOCKS_DIR, name);
98
+ try {
99
+ await fs.promises.unlink(lockFile);
100
+ debug(`Lock released for ${name}`);
101
+ } catch (error) {
102
+ if ((error as ErrnoError).code !== "ENOENT") {
103
+ debug(`Error releasing lock for ${name}:`, error);
104
+ }
105
+ }
106
+ }
107
+
108
+ static async ensureCacheDirectories(): Promise<void> {
109
+ try {
110
+ await Promise.all([
111
+ fs.promises.mkdir(this.CACHE_DIR, { recursive: true }),
112
+ fs.promises.mkdir(this.USAGE_CACHE_DIR, { recursive: true }),
113
+ fs.promises.mkdir(this.LOCKS_DIR, { recursive: true }),
114
+ ]);
115
+ } catch (error) {
116
+ debug("Failed to create cache directories:", error);
117
+ }
118
+ }
119
+
120
+ static createProjectHash(projectPath: string): string {
121
+ return createHash("md5").update(projectPath).digest("hex").substring(0, 8);
122
+ }
123
+
124
+ static async getUsageCache(
125
+ cacheType: "today" | "block" | "pricing",
126
+ latestMtime?: number,
127
+ ): Promise<unknown> {
128
+ const MAX_RETRIES = 3;
129
+ const RETRY_DELAY_MS = 75;
130
+ const FILE_ENCODING = "utf-8";
131
+
132
+ await this.ensureCacheDirectories();
133
+ const cachePath = path.join(this.USAGE_CACHE_DIR, `${cacheType}.json`);
134
+ const lockName = `${cacheType}.usage.lock`;
135
+
136
+ for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {
137
+ const isCurrentlyLocked = this.isLocked(lockName);
138
+ if (isCurrentlyLocked) {
139
+ debug(`Cache for ${cacheType} is locked, waiting...`);
140
+ await setTimeout(RETRY_DELAY_MS);
141
+ continue;
142
+ }
143
+
144
+ try {
145
+ const content = await fs.promises.readFile(cachePath, FILE_ENCODING);
146
+ const cached: CacheEntry<unknown> = JSON.parse(content);
147
+ const cacheIsValid = !latestMtime || cached.timestamp >= latestMtime;
148
+
149
+ if (cacheIsValid) {
150
+ debug(`[CACHE-HIT] ${cacheType} disk cache: found`);
151
+ return this.deserializeDates(cached.data);
152
+ } else {
153
+ debug(
154
+ `${cacheType} cache outdated: cache=${cached.timestamp}, latest=${latestMtime}`,
155
+ );
156
+ return null;
157
+ }
158
+ } catch (error) {
159
+ if ((error as ErrnoError).code === "ENOENT") {
160
+ debug(`No shared ${cacheType} usage cache found`);
161
+ return null;
162
+ }
163
+ const attemptNumber = attempt + 1;
164
+ debug(
165
+ `Attempt ${attemptNumber} failed to read ${cacheType} cache: ${(error as Error).message}. Retrying...`,
166
+ );
167
+ await setTimeout(RETRY_DELAY_MS);
168
+ }
169
+ }
170
+
171
+ debug(`Failed to read ${cacheType} cache after ${MAX_RETRIES} attempts.`);
172
+ return null;
173
+ }
174
+
175
+ private static deserializeDates(data: unknown): unknown {
176
+ if (Array.isArray(data)) {
177
+ return data.map((entry) => ({
178
+ ...entry,
179
+ timestamp: new Date(entry.timestamp),
180
+ }));
181
+ }
182
+ return data;
183
+ }
184
+
185
+ static async setUsageCache(
186
+ cacheType: "today" | "block" | "pricing",
187
+ data: unknown,
188
+ latestMtime?: number,
189
+ ): Promise<void> {
190
+ const lockName = `${cacheType}.usage.lock`;
191
+ const lockAcquired = await this.acquireLock(lockName);
192
+ if (!lockAcquired) {
193
+ debug(`Could not acquire lock to set usage cache for ${cacheType}`);
194
+ return;
195
+ }
196
+
197
+ try {
198
+ await this.ensureCacheDirectories();
199
+ const cachePath = path.join(this.USAGE_CACHE_DIR, `${cacheType}.json`);
200
+ const cacheTimestamp = latestMtime || Date.now();
201
+ const cacheEntry: CacheEntry<unknown> = {
202
+ data,
203
+ timestamp: cacheTimestamp,
204
+ };
205
+ const cacheContent = JSON.stringify(cacheEntry);
206
+
207
+ await fs.promises.writeFile(cachePath, cacheContent, "utf-8");
208
+ debug(`[CACHE-SET] ${cacheType} disk cache stored`);
209
+ } catch (error) {
210
+ debug(`Failed to save ${cacheType} usage cache:`, error);
211
+ } finally {
212
+ await this.releaseLock(lockName);
213
+ }
214
+ }
215
+
216
+ static async getLatestTranscriptMtime(): Promise<number> {
217
+ try {
218
+ const claudePaths = getClaudePaths();
219
+ const projectPaths = await findProjectPaths(claudePaths);
220
+
221
+ let latestMtime = 0;
222
+
223
+ for (const projectPath of projectPaths) {
224
+ try {
225
+ const files = await fs.promises.readdir(projectPath);
226
+ const jsonlFiles = files.filter((file) => file.endsWith(".jsonl"));
227
+
228
+ for (const file of jsonlFiles) {
229
+ const filePath = path.join(projectPath, file);
230
+ const mtime = await getFileModificationDate(filePath);
231
+ if (mtime && mtime.getTime() > latestMtime) {
232
+ latestMtime = mtime.getTime();
233
+ }
234
+ }
235
+ } catch (error) {
236
+ debug(`Failed to read project directory ${projectPath}:`, error);
237
+ continue;
238
+ }
239
+ }
240
+
241
+ return latestMtime;
242
+ } catch (error) {
243
+ debug("Failed to get latest transcript mtime:", error);
244
+ return Date.now();
245
+ }
246
+ }
247
+ }