@bastra-recall/statusline 0.7.0-beta.1

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 (49) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +26 -0
  3. package/bin/claude-powerline +2 -0
  4. package/dist/browser.d.ts +796 -0
  5. package/dist/browser.js +3 -0
  6. package/dist/index.mjs +48 -0
  7. package/package.json +39 -0
  8. package/src/browser.ts +208 -0
  9. package/src/config/defaults.ts +88 -0
  10. package/src/config/loader.ts +473 -0
  11. package/src/index.ts +90 -0
  12. package/src/powerline.ts +1082 -0
  13. package/src/segments/bastra.ts +123 -0
  14. package/src/segments/block.ts +31 -0
  15. package/src/segments/cacheTimer.ts +108 -0
  16. package/src/segments/context.ts +221 -0
  17. package/src/segments/git.ts +498 -0
  18. package/src/segments/index.ts +33 -0
  19. package/src/segments/metrics.ts +175 -0
  20. package/src/segments/pricing.ts +454 -0
  21. package/src/segments/renderer.ts +1002 -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 +61 -0
  26. package/src/themes/gruvbox.ts +61 -0
  27. package/src/themes/index.ts +161 -0
  28. package/src/themes/light.ts +61 -0
  29. package/src/themes/nord.ts +61 -0
  30. package/src/themes/rose-pine.ts +61 -0
  31. package/src/themes/tokyo-night.ts +61 -0
  32. package/src/tui/grid.ts +712 -0
  33. package/src/tui/index.ts +4 -0
  34. package/src/tui/layouts.ts +321 -0
  35. package/src/tui/primitives.ts +187 -0
  36. package/src/tui/renderer.ts +206 -0
  37. package/src/tui/sections.ts +1511 -0
  38. package/src/tui/types.ts +189 -0
  39. package/src/utils/budget.ts +116 -0
  40. package/src/utils/cache.ts +247 -0
  41. package/src/utils/claude.ts +518 -0
  42. package/src/utils/color-support.ts +118 -0
  43. package/src/utils/colors.ts +120 -0
  44. package/src/utils/constants.ts +184 -0
  45. package/src/utils/formatters.ts +175 -0
  46. package/src/utils/icon-visibility.ts +31 -0
  47. package/src/utils/logger.ts +5 -0
  48. package/src/utils/terminal-width.ts +117 -0
  49. package/src/utils/terminal.ts +54 -0
@@ -0,0 +1,123 @@
1
+ import fs from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import { execFileSync } from "node:child_process";
5
+
6
+ /**
7
+ * bastra-recall statusline segment.
8
+ *
9
+ * Reads the per-session feed written by the bastra-recall MCP forwarder:
10
+ * ~/.bastra/statusline/<claude-session-pid>.json
11
+ *
12
+ * Claude Code sends no session id to MCP servers (anthropics/claude-code
13
+ * #41836), so the feed is namespaced by the shared `claude` ancestor PID
14
+ * of the session's forwarder, prompt-hook, and this statusline process.
15
+ * We walk our own parent chain up to `claude` to find the matching feed.
16
+ *
17
+ * Best-effort: any read/parse failure yields null → segment renders
18
+ * nothing (never errors, never blanks).
19
+ */
20
+
21
+ const STATUSLINE_DIR = path.join(os.homedir(), ".bastra", "statusline");
22
+
23
+ export interface BastraInfo {
24
+ state: "idle" | "running";
25
+ vaultSize: number;
26
+ recallCount: number;
27
+ totalHits: number;
28
+ totalMs: number;
29
+ currentStage: string | null;
30
+ /** Human-readable banter phrase for the running stage; preferred over the
31
+ * raw stage name when present. Null when banter is off or idle. */
32
+ currentMessage: string | null;
33
+ currentStageStartedAt: number | null;
34
+ currentRecallStartedAt: number | null;
35
+ /** Banter phrase shown in the done banner after a recall; hidden once older
36
+ * than the TTL (see renderBastra). */
37
+ lastPhrase: string | null;
38
+ lastPhraseAt: number | null;
39
+ }
40
+
41
+ /** Walk parent chain up to the `claude` session process; its PID namespaces
42
+ * the feed. One `ps` call + in-memory walk. Falls back to process.ppid. */
43
+ function claudeSessionPid(): number {
44
+ try {
45
+ const out = execFileSync("ps", ["-axo", "pid=,ppid=,comm="], {
46
+ encoding: "utf8",
47
+ });
48
+ const procs = new Map<number, { ppid: number; comm: string }>();
49
+ for (const line of out.split("\n")) {
50
+ const m = line.trim().match(/^(\d+)\s+(\d+)\s+(.*)$/);
51
+ if (m) {
52
+ procs.set(parseInt(m[1]!, 10), {
53
+ ppid: parseInt(m[2]!, 10),
54
+ comm: m[3] ?? "",
55
+ });
56
+ }
57
+ }
58
+ let pid = process.ppid;
59
+ for (let i = 0; i < 12 && pid > 1; i++) {
60
+ const e = procs.get(pid);
61
+ if (!e) break;
62
+ if (e.comm.toLowerCase().includes("claude")) return pid;
63
+ if (e.ppid === pid) break;
64
+ pid = e.ppid;
65
+ }
66
+ } catch {
67
+ // ps unavailable — fall through to ppid
68
+ }
69
+ return process.ppid;
70
+ }
71
+
72
+ export class BastraProvider {
73
+ getBastraInfo(): BastraInfo | null {
74
+ const feed = path.join(STATUSLINE_DIR, `${claudeSessionPid()}.json`);
75
+ try {
76
+ const raw = fs.readFileSync(feed, "utf8");
77
+ const d = JSON.parse(raw) as Record<string, unknown>;
78
+ const state = d.state === "running" ? "running" : "idle";
79
+ return {
80
+ state,
81
+ // Prefer the daemon-maintained shared count (always current, even when
82
+ // this session is idle); fall back to the per-session feed value.
83
+ vaultSize: readSharedVaultSize() ?? numOr(d.vault_size, 0),
84
+ recallCount: numOr(d.recall_count, 0),
85
+ totalHits: numOr(d.total_hits, 0),
86
+ totalMs: numOr(d.total_ms, 0),
87
+ currentStage:
88
+ typeof d.current_stage === "string" ? d.current_stage : null,
89
+ currentMessage:
90
+ typeof d.current_message === "string" ? d.current_message : null,
91
+ currentStageStartedAt: numOrNull(d.current_stage_started_at),
92
+ currentRecallStartedAt: numOrNull(d.current_recall_started_at),
93
+ lastPhrase: typeof d.last_phrase === "string" ? d.last_phrase : null,
94
+ lastPhraseAt: numOrNull(d.last_phrase_at),
95
+ };
96
+ } catch {
97
+ return null;
98
+ }
99
+ }
100
+ }
101
+
102
+ /**
103
+ * Reads the daemon-maintained shared vault size (~/.bastra/statusline/vault.json).
104
+ * Session-independent, so it stays current even when this session is idle.
105
+ * Null on any failure → caller falls back to the per-session feed value.
106
+ */
107
+ function readSharedVaultSize(): number | null {
108
+ try {
109
+ const raw = fs.readFileSync(path.join(STATUSLINE_DIR, "vault.json"), "utf8");
110
+ const v = (JSON.parse(raw) as { vault_size?: unknown }).vault_size;
111
+ return typeof v === "number" && Number.isFinite(v) ? v : null;
112
+ } catch {
113
+ return null;
114
+ }
115
+ }
116
+
117
+ function numOr(v: unknown, fallback: number): number {
118
+ return typeof v === "number" && Number.isFinite(v) ? v : fallback;
119
+ }
120
+
121
+ function numOrNull(v: unknown): number | null {
122
+ return typeof v === "number" && Number.isFinite(v) ? v : null;
123
+ }
@@ -0,0 +1,31 @@
1
+ import { debug } from "../utils/logger";
2
+ import { minutesUntilReset } from "../utils/formatters";
3
+ import type { ClaudeHookData } from "../utils/claude";
4
+
5
+ export interface BlockInfo {
6
+ nativeUtilization: number;
7
+ timeRemaining: number;
8
+ }
9
+
10
+ export class BlockProvider {
11
+ async getActiveBlockInfo(
12
+ hookData?: ClaudeHookData,
13
+ ): Promise<BlockInfo | null> {
14
+ const fiveHour = hookData?.rate_limits?.five_hour;
15
+ if (!fiveHour) {
16
+ debug("Block segment: No native rate_limits data available");
17
+ return null;
18
+ }
19
+
20
+ const timeRemaining = minutesUntilReset(fiveHour.resets_at);
21
+
22
+ debug(
23
+ `Block segment: Using native rate_limits: ${fiveHour.used_percentage}%, resets in ${timeRemaining}m`,
24
+ );
25
+
26
+ return {
27
+ nativeUtilization: fiveHour.used_percentage,
28
+ timeRemaining,
29
+ };
30
+ }
31
+ }
@@ -0,0 +1,108 @@
1
+ import { readFile, stat } from "node:fs/promises";
2
+ import { debug } from "../utils/logger";
3
+ import type { ClaudeHookData } from "../utils/claude";
4
+
5
+ export interface CacheTimerInfo {
6
+ elapsedSeconds: number;
7
+ detectedTtlSeconds?: number;
8
+ }
9
+
10
+ interface TranscriptEntry {
11
+ type?: string;
12
+ timestamp?: string;
13
+ message?: {
14
+ role?: string;
15
+ type?: string;
16
+ usage?: {
17
+ cache_creation?: {
18
+ ephemeral_1h_input_tokens?: number;
19
+ ephemeral_5m_input_tokens?: number;
20
+ };
21
+ };
22
+ };
23
+ }
24
+
25
+ export class CacheTimerProvider {
26
+ async getCacheTimerInfo(
27
+ hookData: ClaudeHookData,
28
+ ): Promise<CacheTimerInfo | null> {
29
+ const path = hookData?.transcript_path;
30
+ if (!path) {
31
+ debug("CacheTimer: no transcript_path in hookData");
32
+ return null;
33
+ }
34
+
35
+ const lines = await this.readTranscriptLines(path);
36
+ const anchor =
37
+ (lines && this.lastUserTimestamp(lines)) ?? (await this.fileMtime(path));
38
+ if (anchor === null) return null;
39
+
40
+ const elapsedSeconds = Math.max(
41
+ 0,
42
+ Math.floor((Date.now() - anchor) / 1000),
43
+ );
44
+ const detectedTtlSeconds = lines
45
+ ? (this.detectTtlSeconds(lines) ?? undefined)
46
+ : undefined;
47
+ return { elapsedSeconds, detectedTtlSeconds };
48
+ }
49
+
50
+ private async readTranscriptLines(path: string): Promise<string[] | null> {
51
+ try {
52
+ const content = await readFile(path, "utf-8");
53
+ return content.split("\n");
54
+ } catch (error) {
55
+ debug(`CacheTimer: readFile failed for ${path}: ${String(error)}`);
56
+ return null;
57
+ }
58
+ }
59
+
60
+ private lastUserTimestamp(lines: string[]): number | null {
61
+ for (let i = lines.length - 1; i >= 0; i--) {
62
+ const line = lines[i]?.trim();
63
+ if (!line) continue;
64
+ try {
65
+ const entry = JSON.parse(line) as TranscriptEntry;
66
+ const messageType =
67
+ entry.type || entry.message?.role || entry.message?.type;
68
+ if (messageType !== "user") continue;
69
+ if (!entry.timestamp) continue;
70
+ const t = Date.parse(entry.timestamp);
71
+ if (Number.isNaN(t)) continue;
72
+ return t;
73
+ } catch {
74
+ continue;
75
+ }
76
+ }
77
+ return null;
78
+ }
79
+
80
+ private detectTtlSeconds(lines: string[]): number | null {
81
+ for (let i = lines.length - 1; i >= 0; i--) {
82
+ const line = lines[i]?.trim();
83
+ if (!line) continue;
84
+ try {
85
+ const entry = JSON.parse(line) as TranscriptEntry;
86
+ const role = entry.type || entry.message?.role || entry.message?.type;
87
+ if (role !== "assistant") continue;
88
+ const cc = entry.message?.usage?.cache_creation;
89
+ if (!cc) continue;
90
+ if ((cc.ephemeral_1h_input_tokens ?? 0) > 0) return 3600;
91
+ if ((cc.ephemeral_5m_input_tokens ?? 0) > 0) return 300;
92
+ } catch {
93
+ continue;
94
+ }
95
+ }
96
+ return null;
97
+ }
98
+
99
+ private async fileMtime(path: string): Promise<number | null> {
100
+ try {
101
+ const { mtime } = await stat(path);
102
+ return mtime.getTime();
103
+ } catch (error) {
104
+ debug(`CacheTimer: stat failed for ${path}: ${String(error)}`);
105
+ return null;
106
+ }
107
+ }
108
+ }
@@ -0,0 +1,221 @@
1
+ import type { ParsedEntry, ClaudeHookData } from "../utils/claude";
2
+ import type { PowerlineConfig } from "../config/loader";
3
+
4
+ import { debug } from "../utils/logger";
5
+ import { parseJsonlFile } from "../utils/claude";
6
+
7
+ export interface ContextInfo {
8
+ totalTokens: number;
9
+ percentage: number;
10
+ usablePercentage: number;
11
+ contextLeftPercentage: number;
12
+ maxTokens: number;
13
+ usableTokens: number;
14
+ }
15
+
16
+ interface ContextUsageThresholds {
17
+ LOW: number;
18
+ MEDIUM: number;
19
+ }
20
+
21
+ export class ContextProvider {
22
+ private readonly thresholds: ContextUsageThresholds = {
23
+ LOW: 50,
24
+ MEDIUM: 80,
25
+ };
26
+ private readonly config: PowerlineConfig;
27
+
28
+ constructor(config: PowerlineConfig) {
29
+ this.config = config;
30
+ }
31
+
32
+ getContextUsageThresholds(): ContextUsageThresholds {
33
+ return this.thresholds;
34
+ }
35
+
36
+ private getContextLimit(modelId: string): number {
37
+ const modelLimits = this.config.modelContextLimits || { default: 200000 };
38
+ const modelType = this.getModelType(modelId);
39
+ return modelLimits[modelType] || modelLimits.default || 200000;
40
+ }
41
+
42
+ private getModelType(modelId: string): string {
43
+ const id = modelId.toLowerCase();
44
+
45
+ if (id.includes("sonnet")) {
46
+ return "sonnet";
47
+ }
48
+ if (id.includes("opus")) {
49
+ return "opus";
50
+ }
51
+
52
+ return "default";
53
+ }
54
+
55
+ private calculatePercentages(
56
+ totalTokens: number,
57
+ contextLimit: number,
58
+ autocompactBuffer: number = 33000,
59
+ ): Pick<
60
+ ContextInfo,
61
+ "percentage" | "usablePercentage" | "contextLeftPercentage" | "usableTokens"
62
+ > {
63
+ const percentage = Math.min(
64
+ 100,
65
+ Math.max(0, Math.round((totalTokens / contextLimit) * 100)),
66
+ );
67
+
68
+ const usableLimit = Math.max(1, contextLimit - autocompactBuffer);
69
+ const usablePercentage = Math.min(
70
+ 100,
71
+ Math.max(0, Math.round((totalTokens / usableLimit) * 100)),
72
+ );
73
+
74
+ const contextLeftPercentage = Math.max(0, 100 - usablePercentage);
75
+
76
+ return {
77
+ percentage,
78
+ usablePercentage,
79
+ contextLeftPercentage,
80
+ usableTokens: usableLimit,
81
+ };
82
+ }
83
+
84
+ /**
85
+ * Calculate context info from native Claude Code context_window data (preferred).
86
+ * Requires Claude Code 2.0.70+ with current_usage field.
87
+ */
88
+ calculateContextFromHookData(
89
+ hookData: ClaudeHookData,
90
+ autocompactBuffer: number = 33000,
91
+ ): ContextInfo | null {
92
+ const currentUsage = hookData.context_window?.current_usage;
93
+ if (!currentUsage) {
94
+ debug(
95
+ "No current_usage in hook data, falling back to transcript parsing",
96
+ );
97
+ return null;
98
+ }
99
+
100
+ const contextLimit = hookData.context_window?.context_window_size || 200000;
101
+ const totalTokens =
102
+ (currentUsage.input_tokens || 0) +
103
+ (currentUsage.cache_creation_input_tokens || 0) +
104
+ (currentUsage.cache_read_input_tokens || 0);
105
+
106
+ debug(
107
+ `Native current_usage: input=${currentUsage.input_tokens}, cache_create=${currentUsage.cache_creation_input_tokens}, cache_read=${currentUsage.cache_read_input_tokens}, total=${totalTokens} (limit: ${contextLimit})`,
108
+ );
109
+
110
+ const nativePct = hookData.context_window?.used_percentage;
111
+ const percentages = this.calculatePercentages(
112
+ totalTokens,
113
+ contextLimit,
114
+ autocompactBuffer,
115
+ );
116
+
117
+ if (nativePct != null) {
118
+ percentages.percentage = Math.round(nativePct);
119
+ debug(`Using native used_percentage: ${nativePct}%`);
120
+ }
121
+
122
+ return {
123
+ totalTokens,
124
+ maxTokens: contextLimit,
125
+ ...percentages,
126
+ };
127
+ }
128
+
129
+ /**
130
+ * Calculate context tokens by parsing the transcript file (fallback).
131
+ * Used for older Claude Code versions that don't provide context_window.
132
+ */
133
+ async calculateContextTokensFromTranscript(
134
+ transcriptPath: string,
135
+ modelId?: string,
136
+ autocompactBuffer: number = 33000,
137
+ ): Promise<ContextInfo | null> {
138
+ try {
139
+ debug(`Calculating context tokens from transcript: ${transcriptPath}`);
140
+
141
+ const parsedEntries = await parseJsonlFile(transcriptPath);
142
+
143
+ if (parsedEntries.length === 0) {
144
+ debug("No entries in transcript");
145
+ return null;
146
+ }
147
+
148
+ let mostRecentEntry: ParsedEntry | null = null;
149
+
150
+ for (let i = parsedEntries.length - 1; i >= 0; i--) {
151
+ const entry = parsedEntries[i];
152
+ if (!entry) continue;
153
+
154
+ if (!entry.message?.usage?.input_tokens) continue;
155
+ if (entry.isSidechain === true) continue;
156
+
157
+ mostRecentEntry = entry;
158
+ debug(
159
+ `Context segment: Found most recent entry at ${entry.timestamp.toISOString()}, stopping search`,
160
+ );
161
+ break;
162
+ }
163
+
164
+ if (mostRecentEntry?.message?.usage) {
165
+ const usage = mostRecentEntry.message.usage;
166
+ const totalTokens =
167
+ (usage.input_tokens || 0) +
168
+ (usage.cache_read_input_tokens || 0) +
169
+ (usage.cache_creation_input_tokens || 0);
170
+
171
+ const contextLimit = modelId ? this.getContextLimit(modelId) : 200000;
172
+
173
+ debug(
174
+ `Most recent main chain context: ${totalTokens} tokens (limit: ${contextLimit})`,
175
+ );
176
+
177
+ const percentages = this.calculatePercentages(
178
+ totalTokens,
179
+ contextLimit,
180
+ autocompactBuffer,
181
+ );
182
+
183
+ return {
184
+ totalTokens,
185
+ maxTokens: contextLimit,
186
+ ...percentages,
187
+ };
188
+ }
189
+
190
+ debug("No main chain entries with usage data found");
191
+ return null;
192
+ } catch (error) {
193
+ debug(
194
+ `Error reading transcript: ${error instanceof Error ? error.message : String(error)}`,
195
+ );
196
+ return null;
197
+ }
198
+ }
199
+
200
+ /**
201
+ * Get context info using native data if available, falling back to transcript parsing.
202
+ */
203
+ async getContextInfo(
204
+ hookData: ClaudeHookData,
205
+ autocompactBuffer: number = 33000,
206
+ ): Promise<ContextInfo | null> {
207
+ const nativeContext = this.calculateContextFromHookData(
208
+ hookData,
209
+ autocompactBuffer,
210
+ );
211
+ if (nativeContext) {
212
+ return nativeContext;
213
+ }
214
+
215
+ return this.calculateContextTokensFromTranscript(
216
+ hookData.transcript_path,
217
+ hookData.model?.id,
218
+ autocompactBuffer,
219
+ );
220
+ }
221
+ }