@robhowley/pi-openrouter 0.1.0 → 0.3.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.
@@ -1,12 +1,46 @@
1
1
  import { matchesKey, truncateToWidth } from '@mariozechner/pi-tui';
2
2
  import type { Theme } from '@mariozechner/pi-coding-agent';
3
- import type { UsageSummary } from './types.js';
3
+ import type { ModelStats, ProviderStats, UsageSummary } from './types.js';
4
+ import { renderSpendBarChart } from './chart.js';
4
5
  import { usageCache } from './cache.js';
5
6
 
6
7
  const MIN_WIDTH = 44;
7
- const MAX_WIDTH = 80;
8
+
9
+ // Formatting utilities (shared, not class methods)
10
+ function fmtTokens(n: number): string {
11
+ if (n >= 1_000_000_000) return `${(n / 1_000_000_000).toFixed(1)}B`;
12
+ if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
13
+ if (n >= 1_000) return `${(n / 1_000).toFixed(1)}k`;
14
+ return n.toString();
15
+ }
16
+
17
+ function fmtCount(n: number): string {
18
+ if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
19
+ if (n >= 1_000) return `${(n / 1_000).toFixed(1)}k`;
20
+ return n.toString();
21
+ }
8
22
 
9
23
  export class UsageOverlayComponent {
24
+ // Column width constants (shared across all tables for alignment)
25
+ private static readonly COLS = {
26
+ model: 30,
27
+ spend: 9,
28
+ tokens: 9,
29
+ costPerM: 8,
30
+ reqs: 7,
31
+ };
32
+
33
+ private static readonly TABLE_INNER_WIDTH =
34
+ UsageOverlayComponent.COLS.model +
35
+ 2 +
36
+ UsageOverlayComponent.COLS.spend +
37
+ 2 +
38
+ UsageOverlayComponent.COLS.tokens +
39
+ 2 +
40
+ UsageOverlayComponent.COLS.costPerM +
41
+ 2 +
42
+ UsageOverlayComponent.COLS.reqs;
43
+
10
44
  private lines: string[];
11
45
  private theme: Theme;
12
46
  private onClose: () => void;
@@ -60,6 +94,7 @@ export class UsageOverlayComponent {
60
94
 
61
95
  render(width: number): string[] {
62
96
  // Center the overlay if terminal is wider
97
+ // Width includes 2 extra characters for visual padding (1 space on each side)
63
98
  const padding = Math.max(0, Math.floor((width - this.width) / 2));
64
99
  const pad = ' '.repeat(padding);
65
100
 
@@ -71,50 +106,16 @@ export class UsageOverlayComponent {
71
106
  // Rebuild lines to update "last refreshed" time from fresh cached data
72
107
  const freshSummary = usageCache.get('usage');
73
108
  this.lines = this.buildLines(freshSummary || this.summary, this.error, this.cachedMinutesAgo);
74
- this.requestRender();
75
- }
76
-
77
- private calculateWidth(summary: UsageSummary | null): number {
78
- if (!summary) return MIN_WIDTH;
79
-
80
- let maxWidth = MIN_WIDTH;
81
-
82
- // Calculate width needed for top models table
83
- if (summary.topModels7d.length > 0 || summary.topModels30d.length > 0) {
84
- const allModelNames = [
85
- ...summary.topModels7d.map((m) => m.name),
86
- ...summary.topModels30d.map((m) => m.name),
87
- ];
88
- const maxModelNameLen = allModelNames.reduce((max, name) => Math.max(max, name.length), 0);
89
- const amountWidth = 8; // "$X.XX" + padding
90
- const rowWidth = 2 + maxModelNameLen + 2 + amountWidth + 2 + amountWidth + 2;
91
- maxWidth = Math.max(maxWidth, rowWidth);
92
- }
93
-
94
- // Calculate width needed for provider table
95
- if (summary.byKey && Object.keys(summary.byKey).length > 0) {
96
- const maxProviderLen = Object.keys(summary.byKey).reduce(
97
- (max, name) => Math.max(max, name.length),
98
- 0,
99
- );
100
- const amountWidth = 8;
101
- const rowWidth = 2 + maxProviderLen + 2 + amountWidth + 2;
102
- maxWidth = Math.max(maxWidth, rowWidth);
103
- }
104
-
105
- // Calculate width needed for by-day table
106
- if (summary.byDay && Object.keys(summary.byDay).length > 0) {
107
- maxWidth = Math.max(maxWidth, 21);
109
+ // Only request render if still not disposed after potential async work
110
+ if (!this.isDisposed) {
111
+ this.requestRender();
108
112
  }
113
+ }
109
114
 
110
- // Ensure we have room for main stats
111
- // "Month $X.XX / $X.XX cap (XX%)" - max ~35 chars
112
- // "burn ~$X.XX" = 13 chars + space + "Today $X.XX" = 11
113
- // Need: 35 + 1 + 13 + 2 (borders) = 51, or 35 + 1 + 11 + 2 = 49
114
- // Use 46 as it fits both cases with proper padding
115
- maxWidth = Math.max(maxWidth, 46);
116
-
117
- return Math.min(maxWidth, MAX_WIDTH);
115
+ private calculateWidth(_summary: UsageSummary | null): number {
116
+ // Use fixed table width for consistent layout across all views
117
+ const innerWidth = UsageOverlayComponent.TABLE_INNER_WIDTH;
118
+ return Math.max(MIN_WIDTH, innerWidth + 4) + 6; // +4 for borders, +6 for visual padding (3 on each side)
118
119
  }
119
120
 
120
121
  private buildLines(
@@ -127,7 +128,9 @@ export class UsageOverlayComponent {
127
128
 
128
129
  if (error) {
129
130
  lines.push(boxTop(this.width));
130
- lines.push(row('OpenRouter Usage', this.width));
131
+ lines.push(
132
+ row(th.fg('accent', th.bold(' ◈ OpenRouter Usage · /openrouter-usage')), this.width),
133
+ );
131
134
  lines.push(emptyRow(this.width));
132
135
  lines.push(row(th.fg('error', error), this.width));
133
136
  if (cachedMinutesAgo !== null) {
@@ -142,7 +145,9 @@ export class UsageOverlayComponent {
142
145
 
143
146
  if (!summary) {
144
147
  lines.push(boxTop(this.width));
145
- lines.push(row('OpenRouter Usage', this.width));
148
+ lines.push(
149
+ row(th.fg('accent', th.bold(' ◈ OpenRouter Usage · /openrouter-usage')), this.width),
150
+ );
146
151
  lines.push(emptyRow(this.width));
147
152
  lines.push(row(th.fg('dim', 'No usage data available.'), this.width));
148
153
  lines.push(boxBottom(this.width));
@@ -152,183 +157,305 @@ export class UsageOverlayComponent {
152
157
 
153
158
  // Summary view (subcommand views TODO)
154
159
  lines.push(boxTop(this.width));
155
- lines.push(row('OpenRouter Usage', this.width));
160
+ lines.push(
161
+ row(th.fg('accent', th.bold(' ◈ OpenRouter Usage · /openrouter-usage')), this.width),
162
+ );
156
163
  lines.push(emptyRow(this.width));
157
164
 
158
165
  // Month row: amount stays with label, cap percentage right-aligned
159
- const monthLeftBase = `Month $${fmt(summary.month)} / $${fmt(summary.cap)}`;
160
- const monthRight = `cap (${summary.cap > 0 ? Math.round((summary.month / summary.cap) * 100) : 0}%)`;
161
- lines.push(rowRightAligned(monthLeftBase, monthRight, this.width));
162
-
163
- // 7d row: amount stays with label, burn rate right-aligned
164
- const weekLeftBase = `7d $${fmt(summary.week)}`;
165
- const weekRight = `burn ~$${fmt(summary.burnRate)}`;
166
- lines.push(rowRightAligned(weekLeftBase, weekRight, this.width));
166
+ const monthLeftBase = ` Month $${fmt(summary.month)} / $${fmt(summary.cap)}`;
167
+ const monthPercent = summary.cap > 0 ? Math.round((summary.month / summary.cap) * 100) : 0;
168
+ const monthRightText = `cap (${monthPercent}%)`;
169
+ const monthColor = monthPercent < 60 ? 'success' : monthPercent < 100 ? 'warning' : 'error';
170
+ const monthRight =
171
+ monthPercent >= 100
172
+ ? th.bold(th.fg('error', monthRightText))
173
+ : th.fg(monthColor, monthRightText);
174
+ lines.push(rowRightAligned(monthLeftBase, monthRight + ' ', this.width));
175
+
176
+ // 7d row: amount stays with label, burn rate right-aligned with color coding
177
+ const weekLeftBase = ` 7d $${fmt(summary.week)}`;
178
+ const burnRatio = summary.cap > 0 ? summary.burnRate / summary.cap : 0;
179
+ let weekRight: string;
180
+ if (burnRatio < 0.9) {
181
+ weekRight = th.fg('success', `burn ~$${fmt(summary.burnRate)}`);
182
+ } else if (burnRatio < 1.5) {
183
+ weekRight = th.fg('warning', `burn ~$${fmt(summary.burnRate)}`);
184
+ } else if (burnRatio < 2.0) {
185
+ weekRight = th.fg('error', `burn ~$${fmt(summary.burnRate)}`);
186
+ } else {
187
+ weekRight = th.bold(th.fg('error', `burn ~$${fmt(summary.burnRate)}`));
188
+ }
189
+ lines.push(rowRightAligned(weekLeftBase, weekRight + ' ', this.width));
167
190
 
168
191
  // Today row on its own line
169
- const todayContent = `Today $${fmt(summary.today)}`;
170
- lines.push(rowRightAligned(todayContent, '', this.width));
192
+ const todayContent = ` Today $${fmt(summary.today)}`;
193
+ lines.push(rowRightAligned(todayContent, ' ', this.width));
171
194
  lines.push(emptyRow(this.width));
172
195
 
173
- // Top models - 7d and 30d as columns
174
- if (summary.topModels7d.length > 0 || summary.topModels30d.length > 0) {
175
- // Calculate column widths
176
- const allModelNames = [
177
- ...summary.topModels7d.map((m) => m.name),
178
- ...summary.topModels30d.map((m) => m.name),
179
- ];
180
- const maxModelNameLen = allModelNames.reduce((max, name) => Math.max(max, name.length), 0);
181
- const headerModelWidth = Math.max(7, maxModelNameLen);
182
- const amountWidth = 8; // "$X.XX" + padding
183
-
184
- lines.push(row('Top models', this.width));
185
- lines.push(
186
- row(
187
- ` Model${' '.repeat(headerModelWidth - 5)} ${'7d'.padStart(amountWidth)} ${'30d'.padStart(amountWidth)}`,
188
- this.width,
189
- ),
190
- );
191
- lines.push(
192
- row(
193
- ` ${'-'.repeat(headerModelWidth)} ${'-'.repeat(amountWidth)} ${'-'.repeat(amountWidth)}`,
194
- this.width,
195
- ),
196
- );
196
+ // Top models (7d table)
197
+ if (summary.topModels.length > 0) {
198
+ lines.push(...this.buildModelTableHeader('7d', th));
199
+ lines.push(...this.buildModelTableRows(summary.topModels, '7d'));
200
+ lines.push(emptyRow(this.width));
201
+ }
197
202
 
198
- // Build spend map from 7d data
199
- const spendMap = new Map<string, { spend7d: number; spend30d: number }>();
200
- for (const m of summary.topModels7d) {
201
- spendMap.set(m.name, { spend7d: m.spend, spend30d: 0 });
202
- }
203
- // Add/update with 30d data
204
- for (const m of summary.topModels30d) {
205
- const existing = spendMap.get(m.name);
206
- spendMap.set(m.name, {
207
- spend7d: existing?.spend7d ?? 0,
208
- spend30d: m.spend,
209
- });
210
- }
203
+ // Top models (30d table)
204
+ if (summary.topModels.length > 0) {
205
+ lines.push(...this.buildModelTableHeader('30d', th));
206
+ lines.push(...this.buildModelTableRows(summary.topModels, '30d'));
207
+ lines.push(emptyRow(this.width));
208
+ }
211
209
 
212
- // Sort by 30d spend (primary), then 7d (secondary)
213
- const sortedModels = Array.from(spendMap.entries())
214
- .sort((a, b) => b[1].spend30d - a[1].spend30d || b[1].spend7d - a[1].spend7d)
215
- .slice(0, 6); // Show top 6 models
210
+ // By provider (30d table with tokens)
211
+ if (summary.byProvider.length > 0) {
212
+ lines.push(...this.buildProviderTable(summary.byProvider, th));
213
+ lines.push(emptyRow(this.width));
214
+ }
216
215
 
217
- for (const [name, spends] of sortedModels) {
218
- const spend7dStr = spends.spend7d > 0 ? `$${fmt(spends.spend7d)}` : '-';
219
- const spend30dStr = spends.spend30d > 0 ? `$${fmt(spends.spend30d)}` : '-';
220
- const modelLabel = name; // Don't truncate - let the row function handle it
221
- lines.push(
222
- row(
223
- ` ${modelLabel}${' '.repeat(Math.max(0, headerModelWidth - name.length))} ${spend7dStr.padStart(amountWidth)} ${spend30dStr.padStart(amountWidth)}`,
224
- this.width,
225
- ),
226
- );
216
+ // Usage by Day (30d bar chart)
217
+ if (summary.byDay && Object.keys(summary.byDay).length > 0) {
218
+ const chartOutput = renderSpendBarChart(summary.byDay, this.width);
219
+ lines.push(row(` Daily spend (30d)`, this.width));
220
+ // Split multi-line chart output and add each line
221
+ for (const chartLine of chartOutput.split('\n')) {
222
+ lines.push(row(chartLine, this.width));
227
223
  }
228
224
  lines.push(emptyRow(this.width));
229
225
  }
230
226
 
231
- // Usage by Provider
232
- if (summary.byKey && Object.keys(summary.byKey).length > 0) {
233
- const sortedProviders = Object.entries(summary.byKey)
234
- .sort((a, b) => b[1] - a[1])
235
- .slice(0, 6);
236
- const maxProviderLen = sortedProviders.reduce((max, [name]) => Math.max(max, name.length), 0);
237
-
238
- lines.push(row('By provider', this.width));
239
- lines.push(row(` Provider${' '.repeat(maxProviderLen - 8)} 30d`, this.width));
240
- lines.push(row(` ${'-'.repeat(maxProviderLen)} ------`, this.width));
227
+ // Last refresh time at the bottom
228
+ if (summary?.timestamp) {
229
+ const refreshDate = new Date(summary.timestamp);
230
+ const timestampStr = refreshDate.toLocaleTimeString();
231
+ lines.push(row(` Last refreshed: ${timestampStr}`, this.width));
232
+ lines.push(emptyRow(this.width));
241
233
 
242
- for (const [provider, spend] of sortedProviders) {
234
+ // Warning if data is limited due to missing management key
235
+ if (!summary.hasActivityData) {
243
236
  lines.push(
244
237
  row(
245
- ` ${provider}${' '.repeat(maxProviderLen - provider.length)} $${fmt(spend)}`,
238
+ th.fg('warning', ' Data limited - use management key for model breakdowns'),
246
239
  this.width,
247
240
  ),
248
241
  );
242
+ lines.push(emptyRow(this.width));
249
243
  }
250
- lines.push(emptyRow(this.width));
251
244
  }
252
245
 
253
- // Usage by Day (7d)
254
- if (summary.byDay && Object.keys(summary.byDay).length > 0) {
255
- const sortedDays = Object.entries(summary.byDay)
256
- .sort((a, b) => a[0].localeCompare(b[0]))
257
- .slice(-7); // Last 7 days
258
- const maxDateLen = sortedDays.reduce((max, [date]) => Math.max(max, date.length), 0);
246
+ lines.push(boxBottom(this.width));
247
+ lines.push(plainRow(th.fg('dim', 'Esc to close'), this.width));
248
+ return lines;
249
+ }
259
250
 
260
- lines.push(row('By day', this.width));
261
- lines.push(row(` Date${' '.repeat(maxDateLen - 4)} Amount`, this.width));
262
- lines.push(row(` ${'-'.repeat(maxDateLen)} ------`, this.width));
251
+ // Formatting utilities
263
252
 
264
- for (const [day, spend] of sortedDays) {
265
- lines.push(
266
- row(` ${day}${' '.repeat(maxDateLen - day.length)} $${fmt(spend)}`, this.width),
267
- );
268
- }
269
- lines.push(emptyRow(this.width));
253
+ private fmtCostPerM(spend: number, tokens: number): string {
254
+ if (tokens === 0) return '-';
255
+ return `$${((spend / tokens) * 1_000_000).toFixed(2)}`;
256
+ }
257
+
258
+ // Shared table row builder
259
+ private buildTableRow<T>(data: T[], renderRow: (item: T) => string, theme?: Theme): string[] {
260
+ const { COLS } = UsageOverlayComponent;
261
+ const lines: string[] = [];
262
+
263
+ if (theme) {
264
+ lines.push(
265
+ row(
266
+ ` ${theme.fg('dim', '-'.repeat(COLS.model))} ${theme.fg('dim', '-'.repeat(COLS.spend))} ` +
267
+ `${theme.fg('dim', '-'.repeat(COLS.tokens))} ${theme.fg('dim', '-'.repeat(COLS.costPerM))} ` +
268
+ `${theme.fg('dim', '-'.repeat(COLS.reqs))}`,
269
+ this.width,
270
+ ),
271
+ );
270
272
  }
271
273
 
272
- // Last refresh time at the bottom
273
- if (summary?.timestamp) {
274
- const refreshDate = new Date(summary.timestamp);
275
- const timestampStr = refreshDate.toLocaleTimeString();
276
- lines.push(row(`Last refreshed: ${timestampStr}`, this.width));
277
- lines.push(emptyRow(this.width));
274
+ // Data rows
275
+ for (const item of data) {
276
+ lines.push(row(renderRow(item), this.width));
278
277
  }
279
278
 
280
- lines.push(boxBottom(this.width));
281
- lines.push(plainRow(th.fg('dim', 'Esc to close'), this.width));
282
279
  return lines;
283
280
  }
281
+
282
+ // Model table header builder
283
+ private buildModelTableHeader(label: '7d' | '30d', theme: Theme): string[] {
284
+ const { COLS } = UsageOverlayComponent;
285
+ const lines: string[] = [];
286
+
287
+ lines.push(row(` Top models (${label})`, this.width));
288
+ lines.push(
289
+ row(
290
+ ` ${'Model'.padEnd(COLS.model)} ${`${label} $`.padStart(COLS.spend)} ` +
291
+ `${`${label} tok`.padStart(COLS.tokens)} ${'$/M'.padStart(COLS.costPerM)} ` +
292
+ `${'reqs'.padStart(COLS.reqs)}`,
293
+ this.width,
294
+ ),
295
+ );
296
+ lines.push(
297
+ row(
298
+ ` ${theme.fg('dim', '-'.repeat(COLS.model))} ${theme.fg('dim', '-'.repeat(COLS.spend))} ` +
299
+ `${theme.fg('dim', '-'.repeat(COLS.tokens))} ${theme.fg('dim', '-'.repeat(COLS.costPerM))} ` +
300
+ `${theme.fg('dim', '-'.repeat(COLS.reqs))}`,
301
+ this.width,
302
+ ),
303
+ );
304
+
305
+ return lines;
306
+ }
307
+
308
+ // Model table row builder
309
+ private buildModelTableRows(models: ModelStats[], period: '7d' | '30d'): string[] {
310
+ const { COLS } = UsageOverlayComponent;
311
+ const is7d = period === '7d';
312
+
313
+ // Filter to models with spend in this period, sort by spend desc, limit to 4
314
+ const sorted = models
315
+ .filter((m) => (is7d ? m.spend7d > 0 : m.spend30d > 0))
316
+ .sort((a, b) => (is7d ? b.spend7d - a.spend7d : b.spend30d - a.spend30d))
317
+ .slice(0, 4);
318
+
319
+ const renderRow = (m: ModelStats) => {
320
+ const spend = is7d ? m.spend7d : m.spend30d;
321
+ const tokens = is7d ? m.tokens7d.total : m.tokens30d.total;
322
+ const reqs = is7d ? m.requests7d : m.requests30d;
323
+
324
+ return (
325
+ ` ${truncate(m.name, COLS.model).padEnd(COLS.model)} ` +
326
+ `${`$${fmt(spend)}`.padStart(COLS.spend)} ` +
327
+ `${fmtTokens(tokens).padStart(COLS.tokens)} ` +
328
+ `${this.fmtCostPerM(spend, tokens).padStart(COLS.costPerM)} ` +
329
+ `${fmtCount(reqs).padStart(COLS.reqs)}`
330
+ );
331
+ };
332
+
333
+ return this.buildTableRow(sorted, renderRow);
334
+ }
335
+
336
+ // Provider table builder
337
+ private buildProviderTable(providers: ProviderStats[], theme: Theme): string[] {
338
+ const { COLS } = UsageOverlayComponent;
339
+ const lines: string[] = [];
340
+
341
+ // Header
342
+ lines.push(row(` By provider (30d)`, this.width));
343
+ lines.push(
344
+ row(
345
+ ` ${'Provider'.padEnd(COLS.model)} ${'$'.padStart(COLS.spend)} ` +
346
+ `${'tok'.padStart(COLS.tokens)} ${'$/M'.padStart(COLS.costPerM)} ` +
347
+ `${'reqs'.padStart(COLS.reqs)}`,
348
+ this.width,
349
+ ),
350
+ );
351
+
352
+ // Data rows - top 4 providers
353
+ const sorted = providers.filter((p) => p.spend > 0).slice(0, 4);
354
+
355
+ const renderRow = (p: ProviderStats) => {
356
+ return (
357
+ ` ${truncate(p.name, COLS.model).padEnd(COLS.model)} ` +
358
+ `${`$${fmt(p.spend)}`.padStart(COLS.spend)} ` +
359
+ `${fmtTokens(p.tokens.total).padStart(COLS.tokens)} ` +
360
+ `${this.fmtCostPerM(p.spend, p.tokens.total).padStart(COLS.costPerM)} ` +
361
+ `${fmtCount(p.requests).padStart(COLS.reqs)}`
362
+ );
363
+ };
364
+
365
+ return [...lines, ...this.buildTableRow(sorted, renderRow, theme)];
366
+ }
284
367
  }
285
368
 
286
369
  // Helper functions
287
370
  function boxTop(width: number): string {
288
- return `┌${'─'.repeat(width - 2)}┐`;
371
+ return `┌─${'─'.repeat(width - 4)}─┐`;
289
372
  }
290
373
 
291
374
  function boxBottom(width: number): string {
292
- return `└${'─'.repeat(width - 2)}┘`;
375
+ return `└─${'─'.repeat(width - 4)}─┘`;
293
376
  }
294
377
 
295
378
  function emptyRow(width: number): string {
296
- return `│${' '.repeat(width - 2)}│`;
379
+ return `│ ${' '.repeat(width - 4)} │`;
380
+ }
381
+
382
+ // Truncate string to visible width, skipping ANSI escape codes
383
+ function truncateToVisibleWidth(str: string, maxVisibleWidth: number): string {
384
+ let visibleSoFar = 0;
385
+ let i = 0;
386
+
387
+ while (i < str.length && visibleSoFar < maxVisibleWidth) {
388
+ const char = str[i];
389
+
390
+ if (char === '\x1b') {
391
+ // Skip ANSI escape sequence
392
+ // eslint-disable-next-line no-control-regex
393
+ const ansiMatch = str.slice(i).match(/^\x1b\[[0-9;]*m/);
394
+ if (ansiMatch) {
395
+ i += ansiMatch[0].length;
396
+ continue;
397
+ }
398
+ }
399
+ visibleSoFar++;
400
+ i++;
401
+ }
402
+ return str.slice(0, i);
297
403
  }
298
404
 
299
405
  function row(content: string, width: number): string {
300
- const truncated = content.length > width - 2 ? content.slice(0, width - 2) : content;
301
- return `│${truncated}${' '.repeat(width - 2 - truncated.length)}│`;
406
+ const innerWidth = width - 4; // -4 for box borders + padding spaces
407
+ const truncated = truncateToVisibleWidth(content, innerWidth);
408
+ return `│ ${truncated}${' '.repeat(innerWidth - getVisibleWidth(truncated))} │`;
302
409
  }
303
410
 
304
411
  function plainRow(content: string, width: number): string {
305
- const truncated = content.length > width - 2 ? content.slice(0, width - 2) : content;
306
- return ` ${truncated}${' '.repeat(width - 1 - truncated.length)} `;
412
+ const innerWidth = width - 2; // -2 for outer spaces
413
+ const truncated = truncateToVisibleWidth(content, innerWidth);
414
+ return ` ${truncated}${' '.repeat(innerWidth - getVisibleWidth(truncated))} `;
307
415
  }
308
416
 
309
417
  // Helper to create a row with left content padded to align right content
310
418
  function rowRightAligned(leftContent: string, rightContent: string, width: number): string {
311
- const boxInnerWidth = width - 2; // -2 for box borders
312
- const rightWidth = rightContent.length;
419
+ const innerWidth = width - 4; // -4 for box borders + padding spaces
420
+ const rightVisibleWidth = getVisibleWidth(rightContent);
313
421
 
314
- if (rightWidth === 0) {
422
+ if (rightVisibleWidth === 0) {
315
423
  // No right content - just pad left to full width
316
- const leftPadded = leftContent.padEnd(boxInnerWidth, ' ');
424
+ const leftPadded = leftContent.padEnd(innerWidth, ' ');
317
425
  return row(leftPadded, width);
318
426
  }
319
427
 
320
428
  // Account for the space between left and right content
321
- const remainingWidth = boxInnerWidth - rightWidth - 1;
429
+ const remainingWidth = innerWidth - rightVisibleWidth - 1;
430
+
431
+ // Get visible version of left content for truncation check
432
+ const leftVisible = getVisibleWidth(leftContent);
322
433
 
323
434
  // Pad left content to align right content
324
435
  const leftPadded =
325
- leftContent.length > remainingWidth
436
+ leftVisible > remainingWidth
326
437
  ? leftContent.slice(0, remainingWidth - 3) + '...'
327
438
  : leftContent.padEnd(remainingWidth, ' ');
328
439
 
329
440
  return row(`${leftPadded} ${rightContent}`, width);
330
441
  }
331
442
 
443
+ // Calculate visible width of a string, excluding ANSI escape codes
444
+ function getVisibleWidth(str: string): number {
445
+ // Remove ANSI escape codes - handles CSI sequences (ESC [ ... m)
446
+ // eslint-disable-next-line no-control-regex
447
+ const ansiRegex = /\x1b\[[0-9;]*m/g;
448
+ const cleanStr = str.replace(ansiRegex, '');
449
+ return cleanStr.length;
450
+ }
451
+
332
452
  function fmt(value: number): string {
333
453
  return value.toFixed(2);
334
454
  }
455
+
456
+ // Truncate string to max length, adding ellipsis if needed
457
+ function truncate(str: string, maxLen: number): string {
458
+ if (str.length <= maxLen) return str;
459
+ if (maxLen <= 3) return str.slice(0, maxLen);
460
+ return str.slice(0, maxLen - 3) + '...';
461
+ }
@@ -1,15 +1,38 @@
1
+ export interface TokenStats {
2
+ input: number;
3
+ output: number;
4
+ reasoning: number;
5
+ total: number;
6
+ }
7
+
8
+ export interface ModelStats {
9
+ name: string;
10
+ spend7d: number;
11
+ spend30d: number;
12
+ tokens7d: TokenStats;
13
+ tokens30d: TokenStats;
14
+ requests7d: number;
15
+ requests30d: number;
16
+ }
17
+
18
+ export interface ProviderStats {
19
+ name: string;
20
+ spend: number;
21
+ tokens: TokenStats;
22
+ requests: number;
23
+ }
24
+
1
25
  export interface UsageSummary {
2
26
  today: number;
3
27
  week: number;
4
28
  month: number;
5
29
  cap: number;
6
30
  burnRate: number;
7
- topModels7d: { name: string; spend: number }[];
8
- topModels30d: { name: string; spend: number }[];
9
- byModel?: Record<string, number>;
10
- byKey?: Record<string, number>;
11
- byDay?: Record<string, number>;
31
+ topModels: ModelStats[];
32
+ byProvider: ProviderStats[];
33
+ byDay: Record<string, number>;
12
34
  timestamp: number;
35
+ hasActivityData: boolean;
13
36
  }
14
37
 
15
38
  export interface CacheEntry<T> {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@robhowley/pi-openrouter",
3
- "version": "0.1.0",
3
+ "version": "0.3.0",
4
4
  "type": "module",
5
5
  "description": "Terminal overlay for OpenRouter usage: caps, spend, burn rate, and model breakdowns.",
6
6
  "files": [
@@ -18,7 +18,8 @@
18
18
  "pi": {
19
19
  "extensions": [
20
20
  "./extensions/openrouter"
21
- ]
21
+ ],
22
+ "image": "https://raw.githubusercontent.com/roberthowley/pi-userland/main/packages/pi-openrouter/img/openrouter-usage-tui.png"
22
23
  },
23
24
  "repository": {
24
25
  "type": "git",