usage-panel-opencode 0.0.0-stage → 0.1.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.
@@ -0,0 +1,253 @@
1
+ import { type ColorInput, TextAttributes } from '@opentui/core';
2
+ import { For, Show } from 'solid-js';
3
+ import { redactText, sanitizeText } from '../core/redact';
4
+ import type {
5
+ OriginKind,
6
+ PanelErrorKind,
7
+ PanelSnapshot,
8
+ UsageWindow,
9
+ WindowAvailability,
10
+ } from '../rpc';
11
+ import { formatPercent, formatResetTime } from './format';
12
+
13
+ export interface DetailWindowView {
14
+ label: string;
15
+ applicable: boolean;
16
+ availability: WindowAvailability;
17
+ remaining: string;
18
+ used: string;
19
+ reset: string;
20
+ }
21
+
22
+ export interface DetailViewModel {
23
+ title: string;
24
+ plan: string;
25
+ account: string;
26
+ scope: string;
27
+ origin: string;
28
+ windows: DetailWindowView[];
29
+ message: string | null;
30
+ action: string | null;
31
+ refreshMessage: string | null;
32
+ refreshing: boolean;
33
+ }
34
+
35
+ function safeText(value: unknown, fallback = 'no disponible'): string {
36
+ if (typeof value !== 'string') return fallback;
37
+ return sanitizeText(redactText(value), 120) || fallback;
38
+ }
39
+
40
+ function maskedAccount(panel: PanelSnapshot): string {
41
+ const account = panel.snapshot?.account;
42
+ if (!account) return 'Cuenta no disponible';
43
+ if (typeof account.accountId === 'string' && /^…[A-Za-z0-9_-]{4}$/.test(account.accountId)) {
44
+ return `Cuenta ${account.accountId}`;
45
+ }
46
+ const label = typeof account.label === 'string' ? safeText(account.label, '') : '';
47
+ const firstCharacter = Array.from(label)[0];
48
+ if (firstCharacter) return `Cuenta ${firstCharacter}…`;
49
+ return account.credentialKind === 'none' ? 'Sin cuenta conectada' : 'Cuenta conectada';
50
+ }
51
+
52
+ function originLabel(kind: OriginKind | undefined): string {
53
+ switch (kind) {
54
+ case 'service':
55
+ return 'servicio de ChatGPT';
56
+ case 'headers':
57
+ return 'cabeceras de OpenAI';
58
+ case 'mixed':
59
+ return 'servicio y cabeceras de OpenAI';
60
+ default:
61
+ return 'no disponible';
62
+ }
63
+ }
64
+
65
+ function safeErrorMessage(kind: PanelErrorKind | undefined): string {
66
+ switch (kind) {
67
+ case 'network':
68
+ return 'No se pudo consultar el consumo por un problema de conexión.';
69
+ case 'rate_limited':
70
+ return 'OpenAI limitó temporalmente las consultas de consumo.';
71
+ case 'unauthorized':
72
+ return 'La conexión de OpenAI necesita volver a autenticarse.';
73
+ case 'forbidden':
74
+ return 'OpenAI no permitió consultar el consumo de esta cuenta.';
75
+ case 'malformed':
76
+ return 'La respuesta de consumo no tiene un formato reconocido.';
77
+ default:
78
+ return 'No se pudo obtener el consumo de OpenAI.';
79
+ }
80
+ }
81
+
82
+ function safeErrorAction(kind: PanelErrorKind | undefined): string {
83
+ switch (kind) {
84
+ case 'unauthorized':
85
+ return '/connect → OpenAI';
86
+ case 'forbidden':
87
+ return 'Comprueba que la cuenta tenga acceso a la suscripción.';
88
+ case 'malformed':
89
+ return 'Actualiza el plugin y vuelve a intentarlo.';
90
+ default:
91
+ return 'Comprueba la conexión y vuelve a intentarlo.';
92
+ }
93
+ }
94
+
95
+ export function activateDetailRefresh(button: number, onRefresh: () => void): void {
96
+ if (button === 0) onRefresh();
97
+ }
98
+
99
+ function detailWindow(window: UsageWindow): DetailWindowView {
100
+ const availability = window.availability ?? (window.applicable ? 'applicable' : 'not_applicable');
101
+ if (availability === 'not_applicable') {
102
+ return {
103
+ label: safeText(window.label),
104
+ applicable: false,
105
+ availability,
106
+ remaining: 'no aplica',
107
+ used: 'no aplica',
108
+ reset: 'no disponible',
109
+ };
110
+ }
111
+
112
+ if (availability === 'unknown') {
113
+ return {
114
+ label: safeText(window.label),
115
+ applicable: false,
116
+ availability,
117
+ remaining: 'sin datos',
118
+ used: 'sin datos',
119
+ reset: 'no disponible',
120
+ };
121
+ }
122
+
123
+ const percent = (value: number | null) =>
124
+ typeof value === 'number' && Number.isFinite(value) && value >= 0 && value <= 100
125
+ ? formatPercent(value)
126
+ : 'no disponible';
127
+
128
+ return {
129
+ label: safeText(window.label),
130
+ applicable: true,
131
+ availability,
132
+ remaining: percent(window.remainingPercent),
133
+ used: percent(window.usedPercent),
134
+ reset: formatResetTime(window.resetsAt),
135
+ };
136
+ }
137
+
138
+ function retryCountdown(retryAt: number, now: number): string {
139
+ const remaining = Math.max(0, retryAt - now);
140
+ if (remaining < 1_000) return 'menos de 1 s';
141
+ const seconds = Math.ceil(remaining / 1_000);
142
+ if (seconds < 60) return `${seconds} s`;
143
+ return `${Math.ceil(seconds / 60)} min`;
144
+ }
145
+
146
+ /** Builds a display-safe detail model independently from the OpenTUI render tree. */
147
+ export function buildDetailViewModel(
148
+ panel: PanelSnapshot,
149
+ now = Date.now(),
150
+ refreshing = false,
151
+ ): DetailViewModel {
152
+ const snapshot = panel.snapshot;
153
+ const safeError = panel.error ? safeErrorMessage(panel.error.kind) : null;
154
+ const rawMessage =
155
+ panel.state === 'stale'
156
+ ? `Se muestra la última lectura disponible.${safeError ? ` ${safeError}` : ''}`
157
+ : (safeError ??
158
+ (panel.state === 'loading'
159
+ ? 'Cargando consumo…'
160
+ : panel.state === 'no_subscription'
161
+ ? 'Esta conexión no tiene cuotas de suscripción que mostrar.'
162
+ : panel.state === 'needs_auth'
163
+ ? 'Vuelve a conectar OpenAI para consultar el consumo.'
164
+ : panel.state === 'error'
165
+ ? 'No se pudo obtener el consumo.'
166
+ : null));
167
+ const message = rawMessage ? safeText(rawMessage) : null;
168
+ const action =
169
+ (panel.error ? safeErrorAction(panel.error.kind) : null) ??
170
+ (panel.state === 'needs_auth'
171
+ ? '/connect → OpenAI'
172
+ : panel.state === 'error'
173
+ ? 'Vuelve a intentarlo.'
174
+ : null);
175
+ const refreshNotice = panel.refreshNotice;
176
+
177
+ return {
178
+ title: 'Detalle del consumo de OpenAI',
179
+ plan: safeText(snapshot?.plan?.label ?? snapshot?.account.planHint),
180
+ account: maskedAccount(panel),
181
+ scope: 'Alcance de cuota: desconocido (no verificado para esta fuente y cuenta).',
182
+ origin: originLabel(snapshot?.origin.kind),
183
+ windows: snapshot?.windows.map(detailWindow) ?? [],
184
+ message,
185
+ action: action ? safeText(action) : null,
186
+ refreshMessage: refreshNotice
187
+ ? refreshNotice.kind === 'limited'
188
+ ? `Se conserva el dato actual. Podrás reintentar en ${retryCountdown(refreshNotice.retryAt, now)}.`
189
+ : `Ya hay una consulta en curso. Podrás reintentar en ${retryCountdown(refreshNotice.retryAt, now)}.`
190
+ : null,
191
+ refreshing,
192
+ };
193
+ }
194
+
195
+ export interface DetailProps {
196
+ panel: PanelSnapshot;
197
+ refreshing: boolean;
198
+ onRefresh: () => void;
199
+ onBack: () => void;
200
+ theme: { text?: { base?: ColorInput; muted?: ColorInput } };
201
+ }
202
+
203
+ export function Detail(props: DetailProps) {
204
+ const base = props.theme.text?.base ?? 'white';
205
+ const muted = props.theme.text?.muted ?? base;
206
+ const view = () => buildDetailViewModel(props.panel, Date.now(), props.refreshing);
207
+
208
+ return (
209
+ <box flexDirection="column" padding={1}>
210
+ <text fg={base} attributes={TextAttributes.BOLD}>
211
+ {view().title}
212
+ </text>
213
+ <Show when={view().message}>
214
+ <text fg={muted}>{view().message}</text>
215
+ </Show>
216
+ <For each={view().windows}>
217
+ {(window) => (
218
+ <box flexDirection="column" paddingTop={1}>
219
+ <text fg={base} attributes={TextAttributes.BOLD}>
220
+ {window.label}
221
+ {window.availability === 'not_applicable' ? ' · no aplica' : ''}
222
+ </text>
223
+ <text fg={muted}>
224
+ Restante: {window.remaining} · Consumido: {window.used}
225
+ </text>
226
+ <text fg={muted}>Reinicio: {window.reset}</text>
227
+ </box>
228
+ )}
229
+ </For>
230
+ <Show when={props.panel.snapshot && props.panel.snapshot.windows.length === 0}>
231
+ <text fg={muted}>Sin ventanas de cuota aplicables.</text>
232
+ </Show>
233
+ <text fg={muted}>Plan: {view().plan}</text>
234
+ <text fg={muted}>{view().account}</text>
235
+ <text fg={muted}>{view().scope}</text>
236
+ <text fg={muted}>Origen: {view().origin}</text>
237
+ <Show when={view().action}>
238
+ <text fg={muted}>Acción: {view().action}</text>
239
+ </Show>
240
+ <Show when={view().refreshMessage}>
241
+ <text fg={muted}>{view().refreshMessage}</text>
242
+ </Show>
243
+ {/* biome-ignore lint/a11y/noStaticElementInteractions: OpenTUI terminal text uses mouse input for the refresh action. */}
244
+ <text fg={base} onMouseUp={(event) => activateDetailRefresh(event.button, props.onRefresh)}>
245
+ {view().refreshing ? 'Actualizando…' : 'Actualizar consumo'}
246
+ </text>
247
+ {/* biome-ignore lint/a11y/noStaticElementInteractions: OpenTUI terminal text uses mouse input for navigation. */}
248
+ <text fg={muted} onMouseUp={(event) => activateDetailRefresh(event.button, props.onBack)}>
249
+ Volver
250
+ </text>
251
+ </box>
252
+ );
253
+ }
@@ -0,0 +1,13 @@
1
+ export function formatPercent(value: number | null | undefined): string {
2
+ if (typeof value !== 'number' || !Number.isFinite(value) || value < 0 || value > 100) return '—';
3
+ return `${Math.round(value)}%`;
4
+ }
5
+
6
+ export function formatResetTime(timestamp: number | null | undefined): string {
7
+ if (typeof timestamp !== 'number' || !Number.isFinite(timestamp) || timestamp <= 0) {
8
+ return 'no disponible';
9
+ }
10
+ const date = new Date(timestamp);
11
+ if (Number.isNaN(date.getTime())) return 'no disponible';
12
+ return new Intl.DateTimeFormat('es', { dateStyle: 'medium', timeStyle: 'short' }).format(date);
13
+ }
@@ -0,0 +1,228 @@
1
+ import { type ColorInput, TextAttributes } from '@opentui/core';
2
+ import { For, Show } from 'solid-js';
3
+ import type { PanelSnapshot, UsageWindow } from '../rpc';
4
+ import { formatPercent } from './format';
5
+ import { usageTheme } from './theme';
6
+
7
+ export interface SidebarLayoutOptions {
8
+ width?: number;
9
+ now?: number;
10
+ }
11
+
12
+ const SIDEBAR_WIDTH_CAP = 28;
13
+ const RESERVED_MAIN_CONTENT_WIDTH = 64;
14
+ const MINIMUM_USEFUL_WIDTH = 14;
15
+ const RESERVED_HOST_CONTENT_HEIGHT = 7;
16
+
17
+ /** The public slot API omits its own dimensions; keep a conservative terminal-derived cap. */
18
+ export function sidebarWidthForTerminal(
19
+ terminalWidth: number,
20
+ terminalHeight = Number.POSITIVE_INFINITY,
21
+ requiredRows = 5,
22
+ ): number | null {
23
+ if (
24
+ !Number.isFinite(terminalWidth) ||
25
+ !Number.isFinite(requiredRows) ||
26
+ requiredRows < 0 ||
27
+ (Number.isFinite(terminalHeight) &&
28
+ Math.floor(terminalHeight) - RESERVED_HOST_CONTENT_HEIGHT < requiredRows)
29
+ ) {
30
+ return null;
31
+ }
32
+ const available = Math.min(
33
+ SIDEBAR_WIDTH_CAP,
34
+ Math.floor(terminalWidth) - RESERVED_MAIN_CONTENT_WIDTH,
35
+ );
36
+ return available >= MINIMUM_USEFUL_WIDTH ? available : null;
37
+ }
38
+
39
+ function compactLabel(window: UsageWindow): string {
40
+ if (window.kind === 'five_hour') return '5h';
41
+ if (window.kind === 'weekly') return 'Sem';
42
+ return window.label;
43
+ }
44
+
45
+ function retrySuffix(panel: PanelSnapshot, now: number): string {
46
+ const retryAt = panel.retryAt ?? panel.refreshNotice?.retryAt;
47
+ if (typeof retryAt !== 'number' || !Number.isFinite(retryAt)) return '';
48
+ const seconds = Math.max(0, Math.ceil((retryAt - now) / 1_000));
49
+ return ` · reintento en ${seconds}s`;
50
+ }
51
+
52
+ function initialFailureLines(
53
+ panel: PanelSnapshot,
54
+ width: number,
55
+ now: number,
56
+ requiresAuth: boolean,
57
+ ): string[] {
58
+ const retryAt = panel.retryAt ?? panel.refreshNotice?.retryAt;
59
+ const retrySeconds =
60
+ typeof retryAt === 'number' && Number.isFinite(retryAt)
61
+ ? Math.max(0, Math.ceil((retryAt - now) / 1_000))
62
+ : null;
63
+
64
+ if (width < MINIMUM_USEFUL_WIDTH) {
65
+ const action = requiresAuth ? '/connect' : '/usage-detail';
66
+ return [`Consumo · ${action}${retrySuffix(panel, now)}`.slice(0, width)];
67
+ }
68
+
69
+ if (width < 27) {
70
+ return [
71
+ '5h · —',
72
+ 'Sem · —',
73
+ 'Sin lectura',
74
+ ...(requiresAuth ? ['Reconecta OpenAI', '/connect'] : ['/usage-detail']),
75
+ ...(!requiresAuth && retrySeconds !== null ? [`reintento en ${retrySeconds}s`] : []),
76
+ ].map((line) => line.slice(0, width));
77
+ }
78
+
79
+ return [
80
+ '5 h · —',
81
+ 'Semanal · —',
82
+ requiresAuth ? 'Sin lectura · Requiere acceso' : 'Sin lectura · Fallo inicial',
83
+ requiresAuth ? 'Reconecta OpenAI · /connect' : 'Acción: /usage-detail',
84
+ ...(!requiresAuth && retrySeconds !== null ? [`reintento en ${retrySeconds}s`] : []),
85
+ ].map((line) => line.slice(0, width));
86
+ }
87
+
88
+ function progressBar(remaining: number, size: number): string {
89
+ const filled = Math.max(0, Math.min(size, Math.round((remaining / 100) * size)));
90
+ return `[${'━'.repeat(filled)}${'─'.repeat(size - filled)}]`;
91
+ }
92
+
93
+ export function activateSidebarDetail(button: number, onOpenDetail?: () => void): void {
94
+ if (button === 0) onOpenDetail?.();
95
+ }
96
+
97
+ function buildWindowLine(window: UsageWindow, width: number, labelWidth: number): string {
98
+ const compact = width < 27;
99
+ const label = (compact ? compactLabel(window) : window.label).slice(0, labelWidth);
100
+ const alignedLabel = label.padEnd(labelWidth);
101
+ const availability = window.availability ?? (window.applicable ? 'applicable' : 'not_applicable');
102
+
103
+ if (availability === 'unknown') {
104
+ const line = compact ? `${alignedLabel} sin datos`.trim() : `${alignedLabel} sin datos`;
105
+ return line.slice(0, width);
106
+ }
107
+
108
+ if (availability === 'not_applicable') {
109
+ const line = compact ? `${alignedLabel} n/a`.trim() : `${alignedLabel} no aplica`;
110
+ return line.slice(0, width);
111
+ }
112
+
113
+ if (window.remainingPercent === null) {
114
+ const line = compact ? `${alignedLabel} sin datos`.trim() : `${alignedLabel} sin datos`;
115
+ return line.slice(0, width);
116
+ }
117
+
118
+ if (compact) {
119
+ const percent = formatPercent(window.remainingPercent);
120
+ const smallBar = width >= 14 ? progressBar(window.remainingPercent, 1) : '';
121
+ const line = `${alignedLabel}${smallBar ? ` ${smallBar}` : ''} ${percent}`;
122
+ return line.slice(0, width);
123
+ }
124
+
125
+ const percent = formatPercent(window.remainingPercent);
126
+ const fixedLength = labelWidth + 2 + 2 + 2 + 4;
127
+ const barSize = Math.max(2, Math.min(12, width - fixedLength));
128
+ const line = `${alignedLabel} ${progressBar(window.remainingPercent, barSize)} ${percent.padStart(4)}`;
129
+ return line.length <= width ? line : line.slice(0, width);
130
+ }
131
+
132
+ /** Returns ready-to-render compact rows, also used by tests to verify narrow terminal behavior. */
133
+ export function buildSidebarLines(
134
+ panel: PanelSnapshot,
135
+ options: SidebarLayoutOptions = {},
136
+ ): string[] {
137
+ if (!panel.preferences.enabled || !panel.preferences.visible) return [];
138
+
139
+ const width = Math.max(1, Math.floor(options.width ?? 80));
140
+ const now = options.now ?? Date.now();
141
+
142
+ if (panel.state === 'loading')
143
+ return ['5 h · cargando…'.slice(0, width), 'Semanal · cargando…'.slice(0, width)];
144
+ if (panel.state === 'no_subscription') return ['Sin cuota de suscripción'.slice(0, width)];
145
+ if (
146
+ panel.state === 'needs_auth' ||
147
+ (panel.state === 'error' && !panel.snapshot && panel.error?.kind === 'unauthorized')
148
+ ) {
149
+ return initialFailureLines(panel, width, now, true);
150
+ }
151
+ if (panel.state === 'error' && !panel.snapshot) {
152
+ return initialFailureLines(panel, width, now, false);
153
+ }
154
+ if (!panel.snapshot) return ['Consumo no disponible'.slice(0, width)];
155
+
156
+ const windows = panel.snapshot.windows;
157
+ if (windows.length === 0) return ['Sin ventanas de cuota'.slice(0, width)];
158
+ const compactWindows = windows.filter(
159
+ (window) => window.kind === 'five_hour' || window.kind === 'weekly',
160
+ );
161
+ if (compactWindows.length === 0) {
162
+ return ['5 h · sin datos', 'Semanal · sin datos'].map((line) => line.slice(0, width));
163
+ }
164
+ const compact = width < 27;
165
+ const labelWidth = Math.min(
166
+ compact ? 3 : 10,
167
+ Math.max(
168
+ ...compactWindows.map((window) => (compact ? compactLabel(window) : window.label).length),
169
+ ),
170
+ );
171
+ return compactWindows.map((window) => buildWindowLine(window, width, labelWidth));
172
+ }
173
+
174
+ /** Conservative row budget keeps the whole slot block from displacing existing sidebar content. */
175
+ export function sidebarRequiredRows(panel: PanelSnapshot, refreshing = false, width = 80): number {
176
+ if (!panel.preferences.enabled || !panel.preferences.visible) return 0;
177
+ const lines = buildSidebarLines(panel, { width });
178
+ if (lines.length === 0) return 0;
179
+ const contentRows = 1 + lines.length * 2;
180
+ return contentRows + (refreshing ? 2 : 0);
181
+ }
182
+
183
+ export interface SidebarProps {
184
+ panel: PanelSnapshot;
185
+ width?: number | null;
186
+ refreshing?: boolean;
187
+ theme: { text?: { base?: ColorInput; muted?: ColorInput } };
188
+ onOpenDetail?: () => void;
189
+ }
190
+
191
+ export function Sidebar(props: SidebarProps) {
192
+ const width = props.width;
193
+ if (typeof width !== 'number' || !Number.isFinite(width) || width < MINIMUM_USEFUL_WIDTH) {
194
+ return null;
195
+ }
196
+ const tokens = usageTheme(props.theme);
197
+ const lines = () => buildSidebarLines(props.panel, { width });
198
+ return (
199
+ // biome-ignore lint/a11y/noStaticElementInteractions: OpenTUI sidebar slot supports mouse input without DOM role semantics.
200
+ <box
201
+ width={width}
202
+ flexDirection="column"
203
+ onMouseUp={(event) => activateSidebarDetail(event.button, props.onOpenDetail)}
204
+ >
205
+ <Show when={props.panel.preferences.enabled && props.panel.preferences.visible}>
206
+ <text fg={tokens.text.base} attributes={TextAttributes.BOLD} marginBottom={1}>
207
+ Uso OpenAI
208
+ </text>
209
+ </Show>
210
+ <For each={lines()}>
211
+ {(line, index) => (
212
+ <text fg={tokens.text.base} marginBottom={index() < lines().length - 1 ? 1 : 0}>
213
+ {line}
214
+ </text>
215
+ )}
216
+ </For>
217
+ <Show
218
+ when={
219
+ props.panel.preferences.enabled && props.panel.preferences.visible && props.refreshing
220
+ }
221
+ >
222
+ <text fg={tokens.text.muted} marginTop={1}>
223
+ Actualizando consumo…
224
+ </text>
225
+ </Show>
226
+ </box>
227
+ );
228
+ }
@@ -0,0 +1,20 @@
1
+ import type { ColorInput } from '@opentui/core';
2
+
3
+ export interface UsageThemeTokens {
4
+ text: {
5
+ base: ColorInput;
6
+ muted: ColorInput;
7
+ };
8
+ }
9
+
10
+ /** Usage state is communicated with text and bars, never by color alone. */
11
+ export function usageTheme(theme: {
12
+ text?: { base?: ColorInput; muted?: ColorInput };
13
+ }): UsageThemeTokens {
14
+ return {
15
+ text: {
16
+ base: theme.text?.base ?? 'white',
17
+ muted: theme.text?.muted ?? theme.text?.base ?? 'white',
18
+ },
19
+ };
20
+ }