@bastra-recall/statusline 1.0.0 → 1.0.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bastra-recall/statusline",
3
- "version": "1.0.0",
3
+ "version": "1.0.1",
4
4
  "description": "bastra-recall statusline for Claude Code. Statusline engine: owloops/claude-powerline (MIT), with the bastra-status segment built in.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -31,8 +31,8 @@
31
31
  "check:types": "tsc --noEmit"
32
32
  },
33
33
  "devDependencies": {
34
- "@types/node": "^22.20.1",
35
- "tsdown": "^0.22.14",
34
+ "@types/node": "^22.20.4",
35
+ "tsdown": "^0.23.0",
36
36
  "typescript": "^6.0.3",
37
37
  "unrun": "^0.3.1"
38
38
  },
@@ -30,7 +30,11 @@ export const DEFAULT_CONFIG: PowerlineConfig = {
30
30
  session: {
31
31
  enabled: false,
32
32
  type: "tokens",
33
- costSource: "calculated",
33
+ // Claude Code's own total_cost_usd is billed, not guessed from a
34
+ // client-side price list — prefer it. It may not cover subagent
35
+ // spend (undocumented, unverified either way); "calculated" stays
36
+ // available as an explicit opt-in for the full local recompute.
37
+ costSource: "official",
34
38
  showUnits: true,
35
39
  },
36
40
  today: { enabled: false, type: "cost", showUnits: true },
@@ -0,0 +1,174 @@
1
+ import type { PowerlineColors } from "./themes";
2
+ import type { PowerlineConfig } from "./config/loader";
3
+ import type { PowerlineSymbols } from "./segments";
4
+ import type { PowerlineTheme } from "./powerline-theme";
5
+
6
+ import { extractBgToFg } from "./utils/colors";
7
+ import { getColorSupport } from "./utils/color-support";
8
+ import { visibleLength } from "./utils/terminal";
9
+
10
+ export interface RenderedSegment {
11
+ type: string;
12
+ text: string;
13
+ bgColor: string;
14
+ fgColor: string;
15
+ bold?: boolean;
16
+ }
17
+
18
+ /**
19
+ * Line layout for PowerlineRenderer (#1039): measures rendered segments,
20
+ * packs them into lines that fit the terminal width (auto-wrap), and
21
+ * assembles each line as ANSI text with the separators of the style.
22
+ */
23
+ export class PowerlineLayout {
24
+ constructor(
25
+ private readonly config: PowerlineConfig,
26
+ private readonly symbols: PowerlineSymbols,
27
+ private readonly theme: PowerlineTheme,
28
+ ) {}
29
+
30
+ /** Splits one configured line into as many output lines as the width needs. */
31
+ wrapSegments(
32
+ renderedSegments: RenderedSegment[],
33
+ terminalWidth: number | null,
34
+ colors: PowerlineColors,
35
+ ): string[] {
36
+ const outputLines: string[] = [];
37
+
38
+ if (!terminalWidth || terminalWidth <= 0) {
39
+ outputLines.push(this.buildLineFromSegments(renderedSegments, colors));
40
+ return outputLines;
41
+ }
42
+
43
+ let currentLineSegments: RenderedSegment[] = [];
44
+ let currentLineWidth = 0;
45
+
46
+ for (const segment of renderedSegments) {
47
+ const segmentWidth = this.calculateSegmentWidth(
48
+ segment,
49
+ currentLineSegments.length === 0,
50
+ );
51
+
52
+ if (
53
+ currentLineSegments.length > 0 &&
54
+ currentLineWidth + segmentWidth > terminalWidth
55
+ ) {
56
+ outputLines.push(
57
+ this.buildLineFromSegments(currentLineSegments, colors),
58
+ );
59
+ currentLineSegments = [];
60
+ currentLineWidth = 0;
61
+ }
62
+
63
+ currentLineSegments.push(segment);
64
+ currentLineWidth += segmentWidth;
65
+ }
66
+
67
+ if (currentLineSegments.length > 0) {
68
+ outputLines.push(
69
+ this.buildLineFromSegments(currentLineSegments, colors),
70
+ );
71
+ }
72
+
73
+ return outputLines;
74
+ }
75
+
76
+ private calculateSegmentWidth(
77
+ segment: RenderedSegment,
78
+ isFirst: boolean,
79
+ ): number {
80
+ const isCapsuleStyle = this.config.display.style === "capsule";
81
+ const textWidth = visibleLength(segment.text);
82
+ const padding = this.config.display.padding ?? 1;
83
+ const paddingWidth = padding * 2;
84
+
85
+ if (isCapsuleStyle) {
86
+ const capsuleOverhead = 2 + paddingWidth + (isFirst ? 0 : 1);
87
+ return textWidth + capsuleOverhead;
88
+ }
89
+
90
+ const powerlineOverhead = 1 + paddingWidth;
91
+ return textWidth + powerlineOverhead;
92
+ }
93
+
94
+ buildLineFromSegments(
95
+ segments: RenderedSegment[],
96
+ colors: PowerlineColors,
97
+ ): string {
98
+ const isCapsuleStyle = this.config.display.style === "capsule";
99
+ let line = colors.reset;
100
+
101
+ for (let i = 0; i < segments.length; i++) {
102
+ const segment = segments[i];
103
+ if (!segment) continue;
104
+
105
+ const isFirst = i === 0;
106
+ const isLast = i === segments.length - 1;
107
+ const nextSegment = !isLast ? segments[i + 1] : null;
108
+
109
+ if (isCapsuleStyle && !isFirst) {
110
+ line += " ";
111
+ }
112
+
113
+ const bold =
114
+ segment.bold ?? this.theme.getSegmentBoldFlag(segment.type, colors);
115
+
116
+ line += this.formatSegment(
117
+ segment.bgColor,
118
+ segment.fgColor,
119
+ segment.text,
120
+ nextSegment?.bgColor,
121
+ colors,
122
+ bold,
123
+ );
124
+ }
125
+
126
+ return line;
127
+ }
128
+
129
+ private formatSegment(
130
+ bgColor: string,
131
+ fgColor: string,
132
+ text: string,
133
+ nextBgColor: string | undefined,
134
+ colors: PowerlineColors,
135
+ bold: boolean,
136
+ ): string {
137
+ const isCapsuleStyle = this.config.display.style === "capsule";
138
+ const padding = " ".repeat(this.config.display.padding ?? 1);
139
+ const useBold = bold && colors.reset !== "";
140
+ const boldOn = useBold ? "\x1b[1m" : "";
141
+ const boldOff = useBold ? "\x1b[22m" : "";
142
+
143
+ if (isCapsuleStyle) {
144
+ const colorMode = this.config.display.colorCompatibility || "auto";
145
+ const colorSupport = colorMode === "auto" ? getColorSupport() : colorMode;
146
+ const isBasicMode = colorSupport === "ansi";
147
+
148
+ const capFgColor = extractBgToFg(bgColor, isBasicMode);
149
+
150
+ const leftCap = `${capFgColor}${this.symbols.left}${colors.reset}`;
151
+
152
+ const content = `${bgColor}${fgColor}${boldOn}${padding}${text}${padding}${boldOff}${colors.reset}`;
153
+
154
+ const rightCap = `${capFgColor}${this.symbols.right}${colors.reset}`;
155
+
156
+ return `${leftCap}${content}${rightCap}`;
157
+ }
158
+
159
+ let output = `${bgColor}${fgColor}${boldOn}${padding}${text}${padding}${boldOff}`;
160
+
161
+ const colorMode = this.config.display.colorCompatibility || "auto";
162
+ const colorSupport = colorMode === "auto" ? getColorSupport() : colorMode;
163
+ const isBasicMode = colorSupport === "ansi";
164
+
165
+ if (nextBgColor) {
166
+ const arrowFgColor = extractBgToFg(bgColor, isBasicMode);
167
+ output += `${colors.reset}${nextBgColor}${arrowFgColor}${this.symbols.right}`;
168
+ } else {
169
+ output += `${colors.reset}${extractBgToFg(bgColor, isBasicMode)}${this.symbols.right}${colors.reset}`;
170
+ }
171
+
172
+ return output;
173
+ }
174
+ }
@@ -0,0 +1,374 @@
1
+ import type { ClaudeHookData } from "./utils/claude";
2
+ import type { PowerlineColors } from "./themes";
3
+ import type { PowerlineConfig, LineConfig } from "./config/loader";
4
+ import type {
5
+ UsageInfo,
6
+ ContextInfo,
7
+ MetricsInfo,
8
+ PowerlineSymbols,
9
+ AnySegmentConfig,
10
+ DirectorySegmentConfig,
11
+ GitSegmentConfig,
12
+ UsageSegmentConfig,
13
+ ContextSegmentConfig,
14
+ MetricsSegmentConfig,
15
+ BlockSegmentConfig,
16
+ TodaySegmentConfig,
17
+ VersionSegmentConfig,
18
+ SessionIdSegmentConfig,
19
+ EnvSegmentConfig,
20
+ WeeklySegmentConfig,
21
+ AgentSegmentConfig,
22
+ ThinkingSegmentConfig,
23
+ CacheTimerSegmentConfig,
24
+ } from "./segments";
25
+ import type { BlockInfo } from "./segments/block";
26
+ import type { TodayInfo } from "./segments/today";
27
+ import type { CacheTimerInfo } from "./segments/cacheTimer";
28
+
29
+ import {
30
+ UsageProvider,
31
+ ContextProvider,
32
+ GitService,
33
+ TmuxService,
34
+ MetricsProvider,
35
+ SegmentRenderer,
36
+ } from "./segments";
37
+ import { BlockProvider } from "./segments/block";
38
+ import { TodayProvider } from "./segments/today";
39
+ import { CacheTimerProvider } from "./segments/cacheTimer";
40
+ import { BastraProvider } from "./segments/bastra";
41
+ import type { BastraSegmentConfig } from "./segments/renderer";
42
+
43
+ /**
44
+ * Segment data for PowerlineRenderer (#1039): the lazily created data
45
+ * providers, and the dispatch from a configured segment to its renderer in
46
+ * SegmentRenderer, including which data each segment needs.
47
+ */
48
+ export class PowerlineSegments {
49
+ private _usageProvider?: UsageProvider;
50
+ private _blockProvider?: BlockProvider;
51
+ private _todayProvider?: TodayProvider;
52
+ private _contextProvider?: ContextProvider;
53
+ private _gitService?: GitService;
54
+ private _tmuxService?: TmuxService;
55
+ private _metricsProvider?: MetricsProvider;
56
+ private _cacheTimerProvider?: CacheTimerProvider;
57
+ private _bastraProvider?: BastraProvider;
58
+ private _segmentRenderer?: SegmentRenderer;
59
+
60
+ constructor(
61
+ private readonly config: PowerlineConfig,
62
+ private readonly symbols: PowerlineSymbols,
63
+ ) {}
64
+
65
+ get usageProvider(): UsageProvider {
66
+ if (!this._usageProvider) {
67
+ this._usageProvider = new UsageProvider();
68
+ }
69
+ return this._usageProvider;
70
+ }
71
+
72
+ get blockProvider(): BlockProvider {
73
+ if (!this._blockProvider) {
74
+ this._blockProvider = new BlockProvider();
75
+ }
76
+ return this._blockProvider;
77
+ }
78
+
79
+ get todayProvider(): TodayProvider {
80
+ if (!this._todayProvider) {
81
+ this._todayProvider = new TodayProvider();
82
+ }
83
+ return this._todayProvider;
84
+ }
85
+
86
+ get contextProvider(): ContextProvider {
87
+ if (!this._contextProvider) {
88
+ this._contextProvider = new ContextProvider(this.config);
89
+ }
90
+ return this._contextProvider;
91
+ }
92
+
93
+ get gitService(): GitService {
94
+ if (!this._gitService) {
95
+ this._gitService = new GitService();
96
+ }
97
+ return this._gitService;
98
+ }
99
+
100
+ get tmuxService(): TmuxService {
101
+ if (!this._tmuxService) {
102
+ this._tmuxService = new TmuxService();
103
+ }
104
+ return this._tmuxService;
105
+ }
106
+
107
+ get metricsProvider(): MetricsProvider {
108
+ if (!this._metricsProvider) {
109
+ this._metricsProvider = new MetricsProvider();
110
+ }
111
+ return this._metricsProvider;
112
+ }
113
+
114
+ get cacheTimerProvider(): CacheTimerProvider {
115
+ if (!this._cacheTimerProvider) {
116
+ this._cacheTimerProvider = new CacheTimerProvider();
117
+ }
118
+ return this._cacheTimerProvider;
119
+ }
120
+
121
+ get bastraProvider(): BastraProvider {
122
+ if (!this._bastraProvider) {
123
+ this._bastraProvider = new BastraProvider();
124
+ }
125
+ return this._bastraProvider;
126
+ }
127
+
128
+ private get segmentRenderer(): SegmentRenderer {
129
+ if (!this._segmentRenderer) {
130
+ this._segmentRenderer = new SegmentRenderer(this.config, this.symbols);
131
+ }
132
+ return this._segmentRenderer;
133
+ }
134
+
135
+ needsSegmentInfo(segmentType: keyof LineConfig["segments"]): boolean {
136
+ return this.config.display.lines.some(
137
+ (line) => line.segments[segmentType]?.enabled,
138
+ );
139
+ }
140
+
141
+ async renderSegment(
142
+ segment: { type: string; config: AnySegmentConfig },
143
+ hookData: ClaudeHookData,
144
+ usageInfo: UsageInfo | null,
145
+ blockInfo: BlockInfo | null,
146
+ todayInfo: TodayInfo | null,
147
+ contextInfo: ContextInfo | null,
148
+ metricsInfo: MetricsInfo | null,
149
+ cacheTimerInfo: CacheTimerInfo | null,
150
+ colors: PowerlineColors,
151
+ currentDir: string,
152
+ ) {
153
+ if (segment.type === "directory") {
154
+ return this.segmentRenderer.renderDirectory(
155
+ hookData,
156
+ colors,
157
+ segment.config as DirectorySegmentConfig,
158
+ );
159
+ }
160
+ if (segment.type === "model") {
161
+ return this.segmentRenderer.renderModel(hookData, colors, segment.config);
162
+ }
163
+
164
+ if (segment.type === "git") {
165
+ return await this.renderGitSegment(
166
+ segment.config as GitSegmentConfig,
167
+ hookData,
168
+ colors,
169
+ currentDir,
170
+ );
171
+ }
172
+
173
+ if (segment.type === "session") {
174
+ return this.renderSessionSegment(
175
+ segment.config as UsageSegmentConfig,
176
+ usageInfo,
177
+ colors,
178
+ );
179
+ }
180
+
181
+ if (segment.type === "sessionId") {
182
+ return hookData.session_id
183
+ ? this.segmentRenderer.renderSessionId(
184
+ hookData.session_id,
185
+ colors,
186
+ segment.config as SessionIdSegmentConfig,
187
+ )
188
+ : null;
189
+ }
190
+
191
+ if (segment.type === "tmux") {
192
+ return await this.renderTmuxSegment(colors);
193
+ }
194
+
195
+ if (segment.type === "context") {
196
+ return this.renderContextSegment(
197
+ segment.config as ContextSegmentConfig,
198
+ contextInfo,
199
+ colors,
200
+ );
201
+ }
202
+
203
+ if (segment.type === "metrics") {
204
+ return this.renderMetricsSegment(
205
+ segment.config as MetricsSegmentConfig,
206
+ metricsInfo,
207
+ blockInfo,
208
+ colors,
209
+ );
210
+ }
211
+
212
+ if (segment.type === "block") {
213
+ return this.renderBlockSegment(
214
+ segment.config as BlockSegmentConfig,
215
+ blockInfo,
216
+ colors,
217
+ );
218
+ }
219
+
220
+ if (segment.type === "today") {
221
+ return this.renderTodaySegment(
222
+ segment.config as TodaySegmentConfig,
223
+ todayInfo,
224
+ colors,
225
+ );
226
+ }
227
+
228
+ if (segment.type === "version") {
229
+ return this.renderVersionSegment(
230
+ segment.config as VersionSegmentConfig,
231
+ hookData,
232
+ colors,
233
+ );
234
+ }
235
+
236
+ if (segment.type === "env") {
237
+ return this.segmentRenderer.renderEnv(
238
+ colors,
239
+ segment.config as EnvSegmentConfig,
240
+ );
241
+ }
242
+
243
+ if (segment.type === "weekly") {
244
+ return this.segmentRenderer.renderWeekly(
245
+ hookData,
246
+ colors,
247
+ segment.config as WeeklySegmentConfig,
248
+ );
249
+ }
250
+
251
+ if (segment.type === "agent") {
252
+ return this.segmentRenderer.renderAgent(
253
+ hookData,
254
+ colors,
255
+ segment.config as AgentSegmentConfig,
256
+ );
257
+ }
258
+
259
+ if (segment.type === "thinking") {
260
+ return this.segmentRenderer.renderThinking(
261
+ hookData,
262
+ colors,
263
+ segment.config as ThinkingSegmentConfig,
264
+ );
265
+ }
266
+
267
+ if (segment.type === "cacheTimer") {
268
+ if (!cacheTimerInfo) return null;
269
+ return this.segmentRenderer.renderCacheTimer(
270
+ cacheTimerInfo,
271
+ colors,
272
+ segment.config as CacheTimerSegmentConfig,
273
+ );
274
+ }
275
+
276
+ if (segment.type === "bastra") {
277
+ const info = this.bastraProvider.getBastraInfo();
278
+ return this.segmentRenderer.renderBastra(
279
+ info,
280
+ colors,
281
+ segment.config as BastraSegmentConfig,
282
+ );
283
+ }
284
+
285
+ return null;
286
+ }
287
+
288
+ private async renderGitSegment(
289
+ config: GitSegmentConfig,
290
+ hookData: ClaudeHookData,
291
+ colors: PowerlineColors,
292
+ currentDir: string,
293
+ ) {
294
+ if (!this.needsSegmentInfo("git")) return null;
295
+
296
+ const gitInfo = await this.gitService.getGitInfo(
297
+ currentDir,
298
+ {
299
+ showSha: config?.showSha,
300
+ showWorkingTree: config?.showWorkingTree,
301
+ showOperation: config?.showOperation,
302
+ showTag: config?.showTag,
303
+ showTimeSinceCommit: config?.showTimeSinceCommit,
304
+ showStashCount: config?.showStashCount,
305
+ showUpstream: config?.showUpstream,
306
+ showRepoName: config?.showRepoName,
307
+ },
308
+ hookData.workspace?.project_dir,
309
+ );
310
+
311
+ return gitInfo
312
+ ? this.segmentRenderer.renderGit(gitInfo, colors, config)
313
+ : null;
314
+ }
315
+
316
+ private renderSessionSegment(
317
+ config: UsageSegmentConfig,
318
+ usageInfo: UsageInfo | null,
319
+ colors: PowerlineColors,
320
+ ) {
321
+ if (!usageInfo) return null;
322
+ return this.segmentRenderer.renderSession(usageInfo, colors, config);
323
+ }
324
+
325
+ private async renderTmuxSegment(colors: PowerlineColors) {
326
+ if (!this.needsSegmentInfo("tmux")) return null;
327
+ const tmuxSessionId = await this.tmuxService.getSessionId();
328
+ return this.segmentRenderer.renderTmux(tmuxSessionId, colors);
329
+ }
330
+
331
+ private renderContextSegment(
332
+ config: ContextSegmentConfig,
333
+ contextInfo: ContextInfo | null,
334
+ colors: PowerlineColors,
335
+ ) {
336
+ if (!this.needsSegmentInfo("context")) return null;
337
+ return this.segmentRenderer.renderContext(contextInfo, colors, config);
338
+ }
339
+
340
+ private renderMetricsSegment(
341
+ config: MetricsSegmentConfig,
342
+ metricsInfo: MetricsInfo | null,
343
+ _blockInfo: BlockInfo | null,
344
+ colors: PowerlineColors,
345
+ ) {
346
+ return this.segmentRenderer.renderMetrics(metricsInfo, colors, config);
347
+ }
348
+
349
+ private renderBlockSegment(
350
+ config: BlockSegmentConfig,
351
+ blockInfo: BlockInfo | null,
352
+ colors: PowerlineColors,
353
+ ) {
354
+ if (!blockInfo) return null;
355
+ return this.segmentRenderer.renderBlock(blockInfo, colors, config);
356
+ }
357
+
358
+ private renderTodaySegment(
359
+ config: TodaySegmentConfig,
360
+ todayInfo: TodayInfo | null,
361
+ colors: PowerlineColors,
362
+ ) {
363
+ if (!todayInfo) return null;
364
+ return this.segmentRenderer.renderToday(todayInfo, colors, config);
365
+ }
366
+
367
+ private renderVersionSegment(
368
+ config: VersionSegmentConfig,
369
+ hookData: ClaudeHookData,
370
+ colors: PowerlineColors,
371
+ ) {
372
+ return this.segmentRenderer.renderVersion(hookData, colors, config);
373
+ }
374
+ }