oc2token 0.1.4 → 0.1.6

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 (45) hide show
  1. package/README.md +15 -15
  2. package/dist/src/application.js +91 -7
  3. package/dist/src/application.js.map +1 -1
  4. package/dist/src/cache/schema.js +8 -2
  5. package/dist/src/cache/schema.js.map +1 -1
  6. package/dist/src/cli.d.ts +2 -0
  7. package/dist/src/cli.js +218 -39
  8. package/dist/src/cli.js.map +1 -1
  9. package/dist/src/dashboard/render/ansi.d.ts +11 -0
  10. package/dist/src/dashboard/render/ansi.js +19 -0
  11. package/dist/src/dashboard/render/ansi.js.map +1 -1
  12. package/dist/src/dashboard/render/dashboard.d.ts +17 -0
  13. package/dist/src/dashboard/render/dashboard.js +268 -156
  14. package/dist/src/dashboard/render/dashboard.js.map +1 -1
  15. package/dist/src/dashboard/render/format.js +3 -3
  16. package/dist/src/dashboard/render/format.js.map +1 -1
  17. package/dist/src/dashboard/render/types.d.ts +4 -0
  18. package/dist/src/dashboard/render/types.js +12 -10
  19. package/dist/src/dashboard/render/types.js.map +1 -1
  20. package/dist/src/dashboard/settings/index.d.ts +4 -0
  21. package/dist/src/dashboard/settings/index.js +15 -2
  22. package/dist/src/dashboard/settings/index.js.map +1 -1
  23. package/dist/src/domain/contracts.d.ts +2 -0
  24. package/dist/src/domain/contracts.js.map +1 -1
  25. package/dist/src/domain/windows.d.ts +11 -5
  26. package/dist/src/domain/windows.js +24 -7
  27. package/dist/src/domain/windows.js.map +1 -1
  28. package/dist/src/opencode/client.d.ts +1 -0
  29. package/dist/src/opencode/client.js +49 -4
  30. package/dist/src/opencode/client.js.map +1 -1
  31. package/dist/src/opencode/index.d.ts +1 -0
  32. package/dist/src/opencode/index.js +1 -0
  33. package/dist/src/opencode/index.js.map +1 -1
  34. package/dist/src/opencode/native.d.ts +24 -0
  35. package/dist/src/opencode/native.js +215 -0
  36. package/dist/src/opencode/native.js.map +1 -0
  37. package/dist/src/opencode/stats.js +52 -7
  38. package/dist/src/opencode/stats.js.map +1 -1
  39. package/dist/src/opencode/transport.js +4 -1
  40. package/dist/src/opencode/transport.js.map +1 -1
  41. package/dist/src/output/json.d.ts +1 -1
  42. package/dist/src/output/json.js +1 -1
  43. package/dist/src/output/table.js +31 -15
  44. package/dist/src/output/table.js.map +1 -1
  45. package/package.json +1 -1
@@ -1,9 +1,15 @@
1
- import { ANSI, colorEnabled, emphasis, paint, renderInPlace, statusColor, themeCache, themeCacheUnderline, themeCost, themeCyan, themeIn, themeInUnderline, themeOrange, themeOut, themeOutUnderline, themePurple, } from "./ansi.js";
1
+ import { ANSI, colorEnabled, emphasis, paint, renderInPlace, statusColor, themeCache, themeCacheUnderline, themeCost, themeCyan, themeIn, themeInUnderline, themeOrange, themeOut, themeOutUnderline, themePurple, themeWhite, zebraRow, } from "./ansi.js";
2
2
  import { coverageState, DASHBOARD_ROW_CAP, formatCost, formatStatusFooter, formatTokenBreakdown, formatTokenCount, safeIdentifier, safeLabel, stripAnsi, } from "./format.js";
3
3
  import { allWindowKinds, normalizeDashboardSnapshot, } from "./types.js";
4
4
  import { ALL_PROVIDER_KINDS } from "../settings/index.js";
5
5
  const CARD_MIN_WIDTH = 27;
6
6
  const WIDE_LAYOUT_MIN_WIDTH = 90;
7
+ export const APP_PADDING = 2;
8
+ export const GITHUB_URL = "https://github.com/kaanyaren/OC2Token";
9
+ // ANSI/OSC sequences are intentionally matched here so outer padding can
10
+ // truncate styled lines by visible width without removing their escapes.
11
+ // eslint-disable-next-line no-control-regex
12
+ const ANSI_SEQUENCE = /\u001b(?:\[[0-?]*[ -/]*[@-~]|\][^\u0007]*(?:\u0007|\u001b\\))/y;
7
13
  function lineWidth(value) {
8
14
  return [...stripAnsi(value)].length;
9
15
  }
@@ -23,51 +29,58 @@ function truncateExact(value, width) {
23
29
  return value;
24
30
  return [...value].slice(0, Math.max(0, width - 1)).join("") + "…";
25
31
  }
32
+ /** Truncate visible terminal width without stripping ANSI styling. */
33
+ function truncateVisible(value, width) {
34
+ if (lineWidth(value) <= width)
35
+ return value;
36
+ const target = Math.max(0, width - 1);
37
+ let result = "";
38
+ let index = 0;
39
+ let visible = 0;
40
+ while (index < value.length && visible < target) {
41
+ if (value[index] === "\u001b") {
42
+ ANSI_SEQUENCE.lastIndex = index;
43
+ const escape = ANSI_SEQUENCE.exec(value);
44
+ if (escape?.index === index) {
45
+ result += escape[0];
46
+ index += escape[0].length;
47
+ continue;
48
+ }
49
+ }
50
+ const codePoint = value.codePointAt(index);
51
+ if (codePoint === undefined)
52
+ break;
53
+ const character = String.fromCodePoint(codePoint);
54
+ result += character;
55
+ index += character.length;
56
+ visible += 1;
57
+ }
58
+ return `${result}…${value.includes("\u001b") ? ANSI.reset : ""}`;
59
+ }
26
60
  function colorizeBreakdown(value, enabled, underline = false) {
27
61
  if (!enabled || value.length === 0)
28
62
  return value;
29
- // Breakdown arrives as single-space separated tokens like "I 100 O 20 C 0"
30
- // (legacy "In 100 Out 23 Cache 9" is also handled for back-compat)
31
- const inFn = underline ? themeInUnderline : themeIn;
32
- const outFn = underline ? themeOutUnderline : themeOut;
33
- const cacheFn = underline ? themeCacheUnderline : themeCache;
63
+ // Breakdown arrives as gap-separated tokens like "IN 100 OUT 20 CACHE 0".
64
+ // truncateExact can cut the string mid-token ("IN 100 OU…"), so color each
65
+ // segment from its label to the next multi-space gap (or end of string).
66
+ // Alternatives are longest-first with a trailing-letter guard so truncated
67
+ // fragments ("OU", "CAC") still match without false positives inside words.
68
+ // Legacy short forms ("I", "In", …) are kept as fallbacks.
69
+ const specs = [
70
+ { labels: ["IN", "In", "I"], fn: underline ? themeInUnderline : themeIn },
71
+ { labels: ["OUT", "Out", "OU", "O"], fn: underline ? themeOutUnderline : themeOut },
72
+ { labels: ["CACHE", "Cache", "CACH", "CAC", "CA", "C"], fn: underline ? themeCacheUnderline : themeCache },
73
+ ];
34
74
  let result = value;
35
- if (/\bI\s+[^\s]+/.test(result)) {
36
- result = result.replace(/\bI\s+[^\s]+/, (match) => inFn(match, enabled));
37
- }
38
- else {
39
- result = result.replace(/\bIn\s+[^\s]+/, (match) => inFn(match, enabled));
40
- }
41
- if (/\bO\s+[^\s]+/.test(result)) {
42
- result = result.replace(/\bO\s+[^\s]+/, (match) => outFn(match, enabled));
43
- }
44
- else {
45
- result = result.replace(/\bOut\s+[^\s]+/, (match) => outFn(match, enabled));
46
- }
47
- const hasShortCache = /\bC\s+[^\s]+/.test(result);
48
- const hasLongCache = /\bCache\s+[^\s]+/.test(result);
49
- if (hasShortCache) {
50
- result = result.replace(/\bC\s+[^\s]+/, (match) => cacheFn(match, enabled));
51
- }
52
- else if (hasLongCache) {
53
- result = result.replace(/\bCache\s+[^\s]+/, (match) => cacheFn(match, enabled));
54
- }
55
- else if (result.includes("C")) {
56
- // Truncated cache token (e.g. "I 100 O 20 C" or "Ca…")
57
- const idxC = result.lastIndexOf(" C");
58
- const idxCa = result.indexOf("Ca");
59
- const index = idxC !== -1 ? idxC + 1 : idxCa;
60
- if (index !== -1) {
61
- const before = result.slice(0, index);
62
- const tail = result.slice(index);
63
- if (!tail.includes(ANSI.cyan) && !tail.includes(ANSI.orange) && !tail.includes(ANSI.green)) {
64
- result = before + cacheFn(tail, enabled);
65
- }
66
- }
67
- else if (result.trimEnd().endsWith("C")) {
68
- // Single trailing C without value (heavily truncated)
69
- result = result.replace(/C\s*$/, (m) => cacheFn(m, enabled));
70
- }
75
+ for (const spec of specs) {
76
+ const match = new RegExp(`\\b(?:${spec.labels.join("|")})(?![A-Za-z])`).exec(result);
77
+ if (!match || match.index === undefined)
78
+ continue;
79
+ const start = match.index;
80
+ const tail = result.slice(start);
81
+ const gap = tail.search(/\s{2,}/);
82
+ const end = gap === -1 ? result.length : start + gap;
83
+ result = result.slice(0, start) + spec.fn(result.slice(start, end), enabled) + result.slice(end);
71
84
  }
72
85
  return result;
73
86
  }
@@ -75,7 +88,7 @@ function border(width, left, fill, right) {
75
88
  return left + fill.repeat(Math.max(0, width - 2)) + right;
76
89
  }
77
90
  function cardTitle(kind) {
78
- return kind === "hour" ? "LAST 60 MINUTES" : kind === "day" ? "TODAY" : "THIS WEEK";
91
+ return kind === "hour" ? "LAST 60 MINUTES" : kind === "day" ? "TODAY" : kind === "week" ? "THIS WEEK" : "LAST MONTH";
79
92
  }
80
93
  function panelHeading(title, width, color, accent = "purple") {
81
94
  const label = truncate(title, Math.max(4, width - 6));
@@ -96,42 +109,6 @@ function providerAccent(name, color, bright = false) {
96
109
  return (value) => themeCyan(value, color, bright);
97
110
  return (value) => themePurple(value, color, bright);
98
111
  }
99
- function renderProviderStack(providers, color, width) {
100
- if (providers.length === 0)
101
- return [];
102
- const total = providers.reduce((sum, entry) => sum + entry.totals.recorded_total, 0);
103
- const plainEntries = providers.slice(0, 3).map((entry) => {
104
- const name = safeIdentifier(entry.name);
105
- const count = formatTokenCount(entry.totals.recorded_total);
106
- const pct = total > 0 ? Math.round((entry.totals.recorded_total / total) * 100) : 0;
107
- return `${name} ${count} (${pct}%)`;
108
- });
109
- const coloredEntries = plainEntries.map((text, index) => {
110
- const safe = truncate(text, Math.max(4, width - 4));
111
- if (index === 0)
112
- return themePurple(safe, color, true);
113
- if (index === 1)
114
- return themeOrange(safe, color, true);
115
- return themeCyan(safe, color, true);
116
- });
117
- if (width < 78) {
118
- const lines = [panelHeading("Providers", width, color, "orange")];
119
- for (const part of coloredEntries)
120
- lines.push(` ${part}`);
121
- if (providers.length > 3)
122
- lines.push(` +${providers.length - 3} more`);
123
- return lines;
124
- }
125
- const heading = themePurple("Providers", color, true);
126
- const joined = coloredEntries.join(themePurple(" · ", color));
127
- const extra = providers.length > 3 ? themePurple(` · +${providers.length - 3} more`, color) : "";
128
- const line = `${heading} · ${joined}${extra}`;
129
- // Ensure the inline stack never exceeds terminal width
130
- if ([...stripAnsi(line)].length > width) {
131
- return [truncate(line, width)];
132
- }
133
- return [line];
134
- }
135
112
  function cardLines(kind, window, width, color, selected) {
136
113
  const inner = Math.max(1, width - 2);
137
114
  const borderColor = selected ? ANSI.orange : ANSI.purpleDeep;
@@ -156,13 +133,10 @@ function cardLines(kind, window, width, color, selected) {
156
133
  const costLine = `${themePurple(costLabel, color, true)} ${themeCost(costValue, color)}`;
157
134
  const lines = [
158
135
  paint(border(width, "╭", "─", "╮"), borderColor, color),
159
- frame(selected
160
- ? themeOrange(truncate(title, inner), color, true)
161
- : themePurple(truncate(title, inner), color, true)),
136
+ frame(emphasis(themeWhite(truncate(title, inner), color, true), color)),
162
137
  frame(first),
163
138
  frame(colorizeBreakdown(truncateExact(displayComponents, inner), color, true)),
164
139
  frame(pad(costLine, inner)),
165
- frame(themeOrange(truncate(`Range ${safeLabel(window.window.label)}`, inner), color)),
166
140
  paint(border(width, "╰", "─", "╯"), borderColor, color),
167
141
  ];
168
142
  return lines;
@@ -172,29 +146,41 @@ function composeCards(cards, gap = 2) {
172
146
  const widths = cards.map((card) => lineWidth(card[0] ?? ""));
173
147
  return Array.from({ length: maxLines }, (_, row) => cards.map((card, index) => pad(card[row] ?? "", widths[index])).join(" ".repeat(gap)).trimEnd());
174
148
  }
175
- const HEADER_LINES = 4;
176
- const CARD_LINES = 7;
149
+ const HEADER_LINES = 3;
150
+ const CARD_LINES = 6;
177
151
  const CARD_GAP = 2;
152
+ const FOUR_CARD_LAYOUT_MIN_WIDTH = CARD_MIN_WIDTH * 4 + CARD_GAP * 3;
178
153
  export function getCardHitRegions(width) {
179
154
  const clamped = Math.max(20, width);
155
+ const contentWidth = Math.max(1, clamped - APP_PADDING * 2);
180
156
  const headerHeight = HEADER_LINES;
181
- const isWide = clamped >= WIDE_LAYOUT_MIN_WIDTH;
182
- if (isWide) {
183
- const cardWidth = Math.max(CARD_MIN_WIDTH, Math.floor((clamped - 4) / 3));
157
+ if (contentWidth >= FOUR_CARD_LAYOUT_MIN_WIDTH) {
158
+ const cardWidth = Math.max(CARD_MIN_WIDTH, Math.floor((contentWidth - CARD_GAP * 3) / 4));
184
159
  const y1 = headerHeight + 1;
185
160
  const y2 = y1 + CARD_LINES - 1;
186
161
  return allWindowKinds().map((kind, idx) => {
187
- const x1 = idx * (cardWidth + CARD_GAP) + 1;
162
+ const x1 = APP_PADDING + idx * (cardWidth + CARD_GAP) + 1;
188
163
  const x2 = x1 + cardWidth - 1;
189
164
  return { kind, x1, y1, x2, y2 };
190
165
  });
191
166
  }
192
- const cardWidth = Math.max(12, Math.min(CARD_MIN_WIDTH, clamped - 2));
193
- const yGap = 1;
167
+ if (contentWidth >= WIDE_LAYOUT_MIN_WIDTH) {
168
+ const cardWidth = Math.max(CARD_MIN_WIDTH, Math.floor((contentWidth - CARD_GAP) / 2));
169
+ const yGap = 0; // stacked rows touch — no blank separator (see renderDashboard)
170
+ return allWindowKinds().map((kind, idx) => {
171
+ const column = idx % 2;
172
+ const row = Math.floor(idx / 2);
173
+ const x1 = APP_PADDING + column * (cardWidth + CARD_GAP) + 1;
174
+ const y1 = headerHeight + 1 + row * (CARD_LINES + yGap);
175
+ return { kind, x1, y1, x2: x1 + cardWidth - 1, y2: y1 + CARD_LINES - 1 };
176
+ });
177
+ }
178
+ const cardWidth = Math.max(12, Math.min(CARD_MIN_WIDTH, contentWidth - 2));
179
+ const yGap = 0; // stacked cards touch — no blank separator (see renderDashboard)
194
180
  return allWindowKinds().map((kind, idx) => {
195
181
  const y1 = headerHeight + 1 + idx * (CARD_LINES + yGap);
196
182
  const y2 = y1 + CARD_LINES - 1;
197
- const x1 = 1;
183
+ const x1 = APP_PADDING + 1;
198
184
  const x2 = x1 + cardWidth - 1;
199
185
  return { kind, x1, y1, x2, y2 };
200
186
  });
@@ -205,31 +191,43 @@ function renderHeader(color, width) {
205
191
  ? `${themeOrange("◈", color, true)} ${emphasis(themePurple("OC2", color, true), color)} ${themePurple("// TOKENS", color)}`
206
192
  : `${themeOrange("◈", color, true)} ${emphasis(themePurple("OC2TOKEN", color, true), color)} ${themePurple("// USAGE CONSOLE", color)}`;
207
193
  const title = compact ? "OC2Token Usage" : "OpenCode 2 Token Usage";
194
+ // No trailing blank line: the top cards start directly under the rule to
195
+ // keep vertical spacing tight (HEADER_LINES must match this length).
208
196
  return [
209
197
  identity,
210
198
  emphasis(themePurple(truncate(title, width), color, true), color),
211
- themePurple("─".repeat(Math.max(20, width)), color),
212
- "",
199
+ themePurple("─".repeat(width), color),
213
200
  ];
214
201
  }
215
202
  /**
216
203
  * Single canonical TTY status footer. Uses the shared formatStatusFooter
217
204
  * helper (`Status: <STATE · sessions…>` once) — do not duplicate the coverage
218
- * string as `Status: X · Coverage: X`. Colored by coverageState; truncated to
219
- * width to preserve redraw geometry.
205
+ * string as `Status: X · Coverage: X`. Colored by coverageState, centered
206
+ * horizontally inside a box. Truncated to preserve redraw geometry.
220
207
  */
221
- function renderStatusFooter(snapshot, color, width) {
208
+ function renderStatusBox(snapshot, color, width) {
222
209
  const state = coverageState(snapshot.coverage, snapshot.stale);
223
- return statusColor(truncate(formatStatusFooter(snapshot.coverage, snapshot.stale), width), state, color);
210
+ const maxContent = Math.max(1, width - 4);
211
+ const plain = truncate(formatStatusFooter(snapshot.coverage, snapshot.stale), maxContent);
212
+ const contentLen = [...plain].length;
213
+ const boxWidth = Math.min(width, contentLen + 4);
214
+ const leftPad = Math.max(0, Math.floor((width - boxWidth) / 2));
215
+ const prefix = " ".repeat(leftPad);
216
+ const colored = statusColor(plain, state, color);
217
+ return [
218
+ prefix + paint(border(boxWidth, "╭", "─", "╮"), ANSI.purpleDeep, color),
219
+ prefix + paint("│", ANSI.purpleDeep, color) + ` ${colored} ` + paint("│", ANSI.purpleDeep, color),
220
+ prefix + paint(border(boxWidth, "╰", "─", "╯"), ANSI.purpleDeep, color),
221
+ ];
224
222
  }
225
223
  function renderTrend(snapshot, selected, color, width) {
226
224
  const trend = snapshot.windows[selected].trends;
227
225
  const title = `Trend · ${cardTitle(selected).toLowerCase()}`;
228
226
  if (trend.length === 0)
229
- return [panelHeading(title, width, color), " No trend data recorded."];
227
+ return [panelHeading(title, width, color), truncate(" No trend data recorded.", width)];
230
228
  const max = Math.max(...trend.map((bucket) => bucket.totals.recorded_total), 0);
231
229
  if (max === 0)
232
- return [panelHeading(title, width, color), " No trend data recorded."];
230
+ return [panelHeading(title, width, color), truncate(" No trend data recorded.", width)];
233
231
  const graphWidth = Math.max(8, width - 4);
234
232
  const points = trend.length <= graphWidth
235
233
  ? Array.from({ length: graphWidth }, (_, index) => {
@@ -297,7 +295,21 @@ function graphLabels(points, width) {
297
295
  }
298
296
  return characters.join("").trimEnd();
299
297
  }
300
- function renderBreakdown(title, values, color, width, accent) {
298
+ function breakdownColumnWidths(values) {
299
+ const recorded = values.map((item) => formatTokenCount(item.totals.recorded_total));
300
+ const input = values.map((item) => formatTokenCount(item.totals.input));
301
+ const output = values.map((item) => formatTokenCount(item.totals.output + item.totals.reasoning));
302
+ const cache = values.map((item) => formatTokenCount(item.totals.cacheRead + item.totals.cacheWrite));
303
+ const cost = values.map((item) => formatCost(item.cost));
304
+ return {
305
+ recorded: Math.max("RECORDED".length, ...recorded.map((value) => [...value].length)),
306
+ input: Math.max("In".length, ...input.map((value) => [...value].length)),
307
+ output: Math.max("Out".length, ...output.map((value) => [...value].length)),
308
+ cache: Math.max("Cache".length, ...cache.map((value) => [...value].length)),
309
+ cost: Math.max("Cost".length, ...cost.map((value) => [...value].length)),
310
+ };
311
+ }
312
+ function renderBreakdown(title, values, color, width, accent, columnWidths) {
301
313
  // Token columns intentionally merge sub-components for TTY width:
302
314
  // Out = output + reasoning, Cache = cacheRead + cacheWrite (same as
303
315
  // formatTokenBreakdown and the table view). Raw splits remain available in
@@ -311,23 +323,22 @@ function renderBreakdown(title, values, color, width, accent) {
311
323
  }
312
324
  const visible = values.slice(0, DASHBOARD_ROW_CAP);
313
325
  const isProvider = title.toLowerCase().includes("provider");
314
- const nameWidth = Math.max(8, Math.min(42, Math.floor(width * 0.42)));
315
326
  const showTokenDetails = width >= 70;
316
327
  const showCost = width >= 32;
328
+ // Keep the complete numeric block against the right edge of the terminal.
329
+ // The name column gets the remaining space instead of using a fixed 42%
330
+ // width, which previously left all numeric columns beside the names.
331
+ const numericWidth = columnWidths.recorded
332
+ + (showTokenDetails ? columnWidths.input + columnWidths.output + columnWidths.cache : 0)
333
+ + (showCost ? columnWidths.cost : 0);
334
+ const separatorWidth = showTokenDetails ? 9 : showCost ? 5 : 3;
335
+ const nameWidth = Math.max(1, width - numericWidth - separatorWidth);
317
336
  // Table-aligned In/Out/Cache/Cost columns: header already shows labels, rows show only numbers.
318
- const inStrs = visible.map((item) => formatTokenCount(item.totals.input));
319
- const outStrs = visible.map((item) => formatTokenCount(item.totals.output + item.totals.reasoning));
320
- const cacheStrs = visible.map((item) => formatTokenCount(item.totals.cacheRead + item.totals.cacheWrite));
321
- const costStrs = visible.map((item) => formatCost(item.cost));
322
- const inWidth = Math.max("In".length, ...inStrs.map((s) => [...s].length));
323
- const outWidth = Math.max("Out".length, ...outStrs.map((s) => [...s].length));
324
- const cacheWidth = Math.max("Cache".length, ...cacheStrs.map((s) => [...s].length));
325
- const costWidth = Math.max("Cost".length, ...costStrs.map((s) => [...s].length));
326
337
  // Header uses same column widths so values line up vertically (right-aligned numbers).
327
- const headerIn = themeIn("In".padStart(inWidth), color);
328
- const headerOut = themeOut("Out".padStart(outWidth), color);
329
- const headerCache = themeCache("Cache".padStart(cacheWidth), color);
330
- const headerCost = themeCost("Cost".padStart(costWidth), color);
338
+ const headerIn = themeIn("In".padStart(columnWidths.input), color);
339
+ const headerOut = themeOut("Out".padStart(columnWidths.output), color);
340
+ const headerCache = themeCache("Cache".padStart(columnWidths.cache), color);
341
+ const headerCost = themeCost("Cost".padStart(columnWidths.cost), color);
331
342
  const details = showTokenDetails
332
343
  ? showCost
333
344
  ? ` ${headerIn} ${headerOut} ${headerCache} ${headerCost}`
@@ -335,21 +346,21 @@ function renderBreakdown(title, values, color, width, accent) {
335
346
  : showCost
336
347
  ? ` ${headerCost}`
337
348
  : "";
338
- lines.push(` ${themePurple("NAME".padEnd(nameWidth), color, true)} ${themeOrange("RECORDED".padStart(8), color, true)}${details}`);
339
- for (const item of visible) {
349
+ lines.push(` ${themePurple("NAME".padEnd(nameWidth), color, true)} ${themeOrange("RECORDED".padStart(columnWidths.recorded), color, true)}${details}`);
350
+ for (const [rowIndex, item] of visible.entries()) {
340
351
  const safeProv = item.provider ? safeIdentifier(item.provider) : "";
341
352
  const safeNm = safeIdentifier(item.name);
342
353
  const rawName = safeProv && safeProv !== safeNm ? `${safeProv}/${safeNm}` : safeNm;
343
354
  const safeName = truncate(rawName, nameWidth);
344
- const countText = formatTokenCount(item.totals.recorded_total).padStart(8);
355
+ const countText = formatTokenCount(item.totals.recorded_total).padStart(columnWidths.recorded);
345
356
  const inPlain = formatTokenCount(item.totals.input);
346
357
  const outPlain = formatTokenCount(item.totals.output + item.totals.reasoning);
347
358
  const cachePlain = formatTokenCount(item.totals.cacheRead + item.totals.cacheWrite);
348
359
  const costPlain = formatCost(item.cost);
349
- const inCol = themeIn(inPlain.padStart(inWidth), color);
350
- const outCol = themeOut(outPlain.padStart(outWidth), color);
351
- const cacheCol = themeCache(cachePlain.padStart(cacheWidth), color);
352
- const costCol = themeCost(costPlain.padStart(costWidth), color);
360
+ const inCol = themeIn(inPlain.padStart(columnWidths.input), color);
361
+ const outCol = themeOut(outPlain.padStart(columnWidths.output), color);
362
+ const cacheCol = themeCache(cachePlain.padStart(columnWidths.cache), color);
363
+ const costCol = themeCost(costPlain.padStart(columnWidths.cost), color);
353
364
  const componentDetails = showTokenDetails
354
365
  ? showCost
355
366
  ? ` ${inCol} ${outCol} ${cacheCol} ${costCol}`
@@ -359,13 +370,13 @@ function renderBreakdown(title, values, color, width, accent) {
359
370
  : "";
360
371
  if (isProvider) {
361
372
  const accentForRow = providerAccent(item.name, color, true);
362
- const countColored = accentForRow(countText.trimStart().padStart(8));
373
+ const countColored = accentForRow(countText);
363
374
  // Keep provider name in its accent but retain table alignment for token columns.
364
375
  const nameColored = accentForRow(safeName.padEnd(nameWidth));
365
- lines.push(` ${nameColored} ${countColored}${componentDetails}`);
376
+ lines.push(zebraRow(` ${nameColored} ${countColored}${componentDetails}`, rowIndex % 2 === 1, color));
366
377
  }
367
378
  else {
368
- lines.push(` ${themePurple(safeName.padEnd(nameWidth), color)} ${themeOrange(countText, color)}${componentDetails}`);
379
+ lines.push(zebraRow(` ${themeWhite(safeName.padEnd(nameWidth), color)} ${themeOrange(countText, color)}${componentDetails}`, rowIndex % 2 === 1, color));
369
380
  }
370
381
  }
371
382
  if (values.length > visible.length) {
@@ -395,8 +406,9 @@ function formatRefreshInterval(seconds) {
395
406
  return `${s}s`;
396
407
  return `${m}m ${s}s`;
397
408
  }
409
+ /** Refresh interval presets (seconds). Shared by the settings panel and mouse clicks. */
410
+ export const REFRESH_PRESETS = [60, 120, 300, 600, 900, 1800, 3600, 7200, 14400];
398
411
  function renderSettingsPanel(settings, width, color) {
399
- const REFRESH_PRESETS = [60, 120, 300, 600, 900, 1800, 3600, 7200, 14400];
400
412
  function presetIndex(value) {
401
413
  const clamped = Math.max(60, Math.min(14400, Math.floor(value)));
402
414
  let best = 0;
@@ -452,8 +464,30 @@ function renderSettingsPanel(settings, width, color) {
452
464
  lines.push(padPrefix + `│${pad(full, inner)}│`);
453
465
  }
454
466
  lines.push(padPrefix + `│${" ".repeat(inner)}│`);
467
+ // Breakdown tables heading (focused indices 3-4; interval moves to 5)
468
+ const tablesHeading = truncate("Tables — space to toggle", inner);
469
+ lines.push(padPrefix + `│${pad(themePurple(tablesHeading, color, true), inner)}│`);
470
+ const tableRows = [
471
+ { label: "Providers table", shown: settings.showProvidersTable !== false, index: 3 },
472
+ { label: "Projects table", shown: settings.showProjectsTable !== false, index: 4 },
473
+ ];
474
+ for (const row of tableRows) {
475
+ const focused = settings.focusedIndex === row.index;
476
+ const checkbox = row.shown ? "◉" : "○";
477
+ const prefix = focused ? themeOrange("▶ ", color, true) : " ";
478
+ const checkColored = row.shown ? themeOrange(checkbox, color, true) : themePurple(checkbox, color);
479
+ const suffix = focused ? " ← toggle" : "";
480
+ const suffixLen = focused ? 10 : 0; // " ← toggle" visible length
481
+ const nameAvail = Math.max(4, inner - 4 - suffixLen);
482
+ const nameTrunc = truncate(row.label, nameAvail);
483
+ const nameColored = row.shown ? themeWhite(nameTrunc, color, true) : themePurple(nameTrunc, color);
484
+ const lineContent = `${prefix}${checkColored} ${nameColored}`;
485
+ const full = focused ? `${lineContent}${themeOrange(suffix, color)}` : lineContent;
486
+ lines.push(padPrefix + `│${pad(full, inner)}│`);
487
+ }
488
+ lines.push(padPrefix + `│${" ".repeat(inner)}│`);
455
489
  // Refresh interval heading with value
456
- const intervalFocused = settings.focusedIndex === 3;
490
+ const intervalFocused = settings.focusedIndex === 5;
457
491
  const intervalHeadingPlain = "Refresh interval";
458
492
  const headingAvail = Math.max(6, inner - intervalStr.length - 5 - (intervalFocused ? 2 : 0));
459
493
  const headingTrunc = truncate(intervalHeadingPlain, headingAvail);
@@ -538,7 +572,7 @@ function renderProjectsPanel(snapshot, selected, width, color) {
538
572
  const inner = Math.max(1, panelWidth - 2);
539
573
  const leftPad = Math.max(0, Math.floor((width - panelWidth) / 2));
540
574
  const padPrefix = " ".repeat(leftPad);
541
- const titleKind = selected === "hour" ? "Last 60 Minutes" : selected === "day" ? "Today" : "This Week";
575
+ const titleKind = selected === "hour" ? "Last 60 Minutes" : selected === "day" ? "Today" : selected === "week" ? "This Week" : "Last Month";
542
576
  const totalForWindow = snapshot.windows[selected]?.totals.recorded_total ?? 0;
543
577
  const lines = [];
544
578
  lines.push(padPrefix + paint(border(panelWidth, "╭", "─", "╮"), ANSI.purpleDeep, color));
@@ -639,12 +673,12 @@ function renderProjectsPanel(snapshot, selected, width, color) {
639
673
  lines.push(padPrefix + `│${pad(themePurple(truncate(totalLine, inner), color), inner)}│`);
640
674
  }
641
675
  lines.push(padPrefix + `│${" ".repeat(inner)}│`);
642
- lines.push(padPrefix + `│${pad(themePurple(truncate("p / esc to close • 1/2/3 switch period", inner), color), inner)}│`);
676
+ lines.push(padPrefix + `│${pad(themePurple(truncate("p / esc to close • 1/2/3/4 switch period", inner), color), inner)}│`);
643
677
  lines.push(padPrefix + paint(border(panelWidth, "╰", "─", "╯"), ANSI.purpleDeep, color));
644
678
  return lines;
645
679
  }
646
680
  function renderCredit(width, color) {
647
- const repo = "github.com/kaanyaren/OC2Token";
681
+ const repo = GITHUB_URL;
648
682
  const full = `Kaan Yaren · ${repo}`;
649
683
  const plain = width < 45 ? "Kaan Yaren" : full;
650
684
  const truncated = truncate(plain, width);
@@ -667,7 +701,7 @@ function renderFooter(help, color, width) {
667
701
  return [
668
702
  "",
669
703
  panelHeading("Help", width, color, "orange"),
670
- ` ${key("r")} refresh ${key("1/2/3")} periods ${key("p")} projects`,
704
+ ` ${key("r")} refresh ${key("1/2/3/4")} periods ${key("p")} projects`,
671
705
  ` ${key("q")} quit ${key("?")} close`,
672
706
  ];
673
707
  }
@@ -675,65 +709,140 @@ function renderFooter(help, color, width) {
675
709
  return [
676
710
  "",
677
711
  panelHeading("Help", width, color, "orange"),
678
- ` ${key("r/R")} Refresh ${key("1/2/3")} Periods ${key("p")} Projects`,
712
+ ` ${key("r/R")} Refresh ${key("1/2/3/4")} Periods ${key("p")} Projects`,
679
713
  ` ${key("q")} Quit ${key("?")} Toggle help ${key("s")} Settings`,
680
714
  ];
681
715
  }
682
716
  return [
683
717
  "",
684
718
  panelHeading("Help", width, color, "orange"),
685
- ` ${key("r/R")} Refresh now ${key("1")} Hour ${key("2")} Today ${key("3")} Week ${key("Tab/Arrows")} Navigate`,
719
+ ` ${key("r/R")} Refresh now ${key("1")} Hour ${key("2")} Today ${key("3")} Week ${key("4")} Month ${key("Tab/Arrows")} Navigate`,
686
720
  ` ${key("p")} Projects ${key("s")} Settings ${key("q")} Quit ${key("?")} Toggle help`,
687
721
  ];
688
722
  }
689
723
  if (width < 50) {
690
- return [` ${key("r")} ${key("1/2/3")} ${key("p")} ${key("s")} ${key("q")} ${key("?")}`];
724
+ return [` ${key("r")} ${key("1/2/3/4")} ${key("p")} ${key("s")} ${key("q")} ${key("?")}`];
691
725
  }
692
726
  if (width < 70) {
693
727
  // Keep footer under 50 chars so width=50 still truncates correctly
694
728
  if (width < 60) {
695
- return [` ${key("r")} Refresh ${key("1/2/3")} ${key("p")} Proj ${key("s")} Settings ${key("q")} Quit ${key("?")} Help`];
729
+ return [` ${key("r")} Refresh ${key("1/2/3/4")} ${key("p")} Proj ${key("s")} ${key("q")} Quit ${key("?")} Help`];
696
730
  }
697
- return [` ${key("r")} Refresh ${key("1/2/3")} Periods ${key("p")} Projects ${key("s")} Settings ${key("q")} Quit ${key("?")} Help`];
731
+ return [` ${key("r")} Refresh ${key("1/2/3/4")} Periods ${key("p")} Proj ${key("s")} ${key("q")} Quit ${key("?")} Help`];
732
+ }
733
+ if (width < 78) {
734
+ return [` ${key("r")} ${key("1")} Hour ${key("2")} Today ${key("3")} Week ${key("4")} Month ${key("p")} ${key("s")} ${key("q")} ${key("?")}`];
735
+ }
736
+ if (width < 90) {
737
+ return [` ${key("r")} ${key("1")} Hour ${key("2")} Today ${key("3")} Week ${key("4")} Month ${key("p")} Proj ${key("s")} Settings ${key("q")} Quit ${key("?")} Help`];
738
+ }
739
+ if (width < 120) {
740
+ return [
741
+ ` ${key("r")} Refresh ${key("1")} Hour ${key("2")} Today ${key("3")} Week ${key("4")} Month ${key("p")} Projects ${key("s")} Settings ${key("q")} Quit ${key("?")} Help`,
742
+ ];
698
743
  }
699
744
  return [
700
745
  "",
701
- ` ${key("r")} Refresh ${key("1")} Hour ${key("2")} Today ${key("3")} Week ${key("Tab/Arrows")} Navigate ${key("p")} Projects ${key("s")} Settings ${key("q")} Quit ${key("?")} Help`,
746
+ ` ${key("r")} Refresh ${key("1")} Hour ${key("2")} Today ${key("3")} Week ${key("4")} Month ${key("Tab/Arrows")} Navigate ${key("p")} Projects ${key("s")} Settings ${key("q")} Quit ${key("?")} Help`,
702
747
  ];
703
748
  }
749
+ /**
750
+ * Map a footer token (whitespace-delimited word under the cursor) to its
751
+ * dashboard action. Returns undefined for decorative tokens ("Periods",
752
+ * "Navigate", "1/2/3/4", …). Pure helper for mouse click handling.
753
+ */
754
+ export function footerClickAction(token) {
755
+ switch (token) {
756
+ case "r":
757
+ case "Refresh":
758
+ return "refresh";
759
+ case "1":
760
+ case "Hour":
761
+ return "hour";
762
+ case "2":
763
+ case "Today":
764
+ return "day";
765
+ case "3":
766
+ case "Week":
767
+ return "week";
768
+ case "4":
769
+ case "Month":
770
+ return "month";
771
+ case "p":
772
+ case "Proj":
773
+ case "Projects":
774
+ return "projects";
775
+ case "s":
776
+ case "Settings":
777
+ return "settings";
778
+ case "q":
779
+ case "Quit":
780
+ return "quit";
781
+ case "?":
782
+ case "Help":
783
+ return "help";
784
+ default:
785
+ return undefined;
786
+ }
787
+ }
788
+ /**
789
+ * Find the whitespace-delimited token under 1-based column `cx` in an
790
+ * already ANSI-stripped line. Returns undefined when the column is blank.
791
+ */
792
+ export function footerTokenAtX(line, cx) {
793
+ const re = /\S+/g;
794
+ let match;
795
+ while ((match = re.exec(line)) !== null) {
796
+ const start = match.index + 1;
797
+ if (cx >= start && cx < start + match[0].length)
798
+ return match[0];
799
+ }
800
+ return undefined;
801
+ }
704
802
  export function renderDashboard(input, options = {}) {
705
803
  const snapshot = normalizeDashboardSnapshot(input);
706
- // Keep a small floor for borders, but never force a 40-column frame on a
707
- // narrower terminal. Content is truncated/stacked rather than relying on
708
- // terminal wrapping to preserve redraw geometry.
709
- const width = Math.max(20, options.width ?? 100);
804
+ // Reserve two columns on each side for a consistent application gutter.
805
+ // Content is rendered at the inner width so borders, tables, and overlays
806
+ // all share the same outer padding without relying on terminal wrapping.
807
+ const terminalWidth = Math.max(20, options.width ?? 100);
808
+ const width = Math.max(1, terminalWidth - APP_PADDING * 2);
710
809
  const color = colorEnabled(options);
711
810
  const selected = options.selectedWindow ?? "day";
712
811
  const lines = renderHeader(color, width);
713
- const cardWidth = width >= WIDE_LAYOUT_MIN_WIDTH
714
- ? Math.max(CARD_MIN_WIDTH, Math.floor((width - 4) / 3))
715
- : Math.max(12, Math.min(CARD_MIN_WIDTH, width - 2));
812
+ const cardWidth = width >= FOUR_CARD_LAYOUT_MIN_WIDTH
813
+ ? Math.max(CARD_MIN_WIDTH, Math.floor((width - CARD_GAP * 3) / 4))
814
+ : width >= WIDE_LAYOUT_MIN_WIDTH
815
+ ? Math.max(CARD_MIN_WIDTH, Math.floor((width - CARD_GAP) / 2))
816
+ : Math.max(12, Math.min(CARD_MIN_WIDTH, width - 2));
716
817
  const cards = allWindowKinds().map((kind) => cardLines(kind, snapshot.windows[kind], cardWidth, color, selected === kind));
717
- if (width >= WIDE_LAYOUT_MIN_WIDTH && cards.length === 3) {
818
+ if (width >= FOUR_CARD_LAYOUT_MIN_WIDTH) {
718
819
  lines.push(...composeCards(cards));
719
820
  }
821
+ else if (width >= WIDE_LAYOUT_MIN_WIDTH) {
822
+ for (let index = 0; index < cards.length; index += 2) {
823
+ lines.push(...composeCards(cards.slice(index, index + 2)));
824
+ }
825
+ }
720
826
  else {
721
827
  for (const card of cards)
722
- lines.push(...card, "");
723
- }
724
- // Below cards render unified provider stack - cards are unified totals, stack shows per-provider split with 3 accents
725
- const stackProviders = snapshot.windows[selected]?.providers ?? snapshot.providers;
726
- if (stackProviders.length > 0) {
727
- lines.push("");
728
- lines.push(...renderProviderStack(stackProviders, color, width));
828
+ lines.push(...card);
729
829
  }
730
830
  lines.push("");
731
831
  lines.push(...renderTrend(snapshot, selected, color, width));
732
832
  lines.push("");
733
833
  const selectedWindow = snapshot.windows[selected];
734
- lines.push(...renderBreakdown(`Models · ${cardTitle(selected)}`, selectedWindow.models, color, width, "purple"));
735
- lines.push(...renderBreakdown(`Providers · ${cardTitle(selected)}`, selectedWindow.providers, color, width, "orange"));
736
- lines.push(...renderBreakdown(`Projects · ${cardTitle(selected)}`, selectedWindow.projects, color, width, "cyan"));
834
+ const breakdownWidths = breakdownColumnWidths([
835
+ ...selectedWindow.models,
836
+ ...selectedWindow.providers,
837
+ ...selectedWindow.projects,
838
+ ]);
839
+ lines.push(...renderBreakdown(`Models · ${cardTitle(selected)}`, selectedWindow.models, color, width, "purple", breakdownWidths));
840
+ if (options.settings?.showProvidersTable !== false) {
841
+ lines.push(...renderBreakdown(`Providers · ${cardTitle(selected)}`, selectedWindow.providers, color, width, "orange", breakdownWidths));
842
+ }
843
+ if (options.settings?.showProjectsTable !== false) {
844
+ lines.push(...renderBreakdown(`Projects · ${cardTitle(selected)}`, selectedWindow.projects, color, width, "cyan", breakdownWidths));
845
+ }
737
846
  if (snapshot.coverage.errors.length > 0) {
738
847
  lines.push("");
739
848
  lines.push(emphasis("Coverage errors", color));
@@ -743,7 +852,7 @@ export function renderDashboard(input, options = {}) {
743
852
  lines.push(` ${label}${error.sessionID ? ` (${safeIdentifier(error.sessionID)})` : ""}`);
744
853
  }
745
854
  }
746
- lines.push(renderStatusFooter(snapshot, color, width));
855
+ lines.push(...renderStatusBox(snapshot, color, width));
747
856
  lines.push(...renderFooter(options.help === true, color, width));
748
857
  if (options.settings?.visible === true) {
749
858
  lines.push("");
@@ -755,7 +864,10 @@ export function renderDashboard(input, options = {}) {
755
864
  }
756
865
  lines.push("");
757
866
  lines.push(renderCredit(width, color));
758
- const content = lines.join("\n");
867
+ const outerPadding = " ".repeat(APP_PADDING);
868
+ const content = lines
869
+ .map((line) => `${outerPadding}${pad(truncateVisible(line, width), width)}${outerPadding}`)
870
+ .join("\n");
759
871
  return options.previousLineCount === undefined
760
872
  ? content
761
873
  : renderInPlace(content, options.previousLineCount, options);