pi-openai-usage 0.1.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.
@@ -0,0 +1,363 @@
1
+ import { SettingsList, type SettingItem, type SettingsListTheme } from "@earendil-works/pi-tui";
2
+ import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
3
+
4
+ import {
5
+ APPROVED_BAR_WIDTHS,
6
+ APPROVED_REFRESH_INTERVAL_PRESETS,
7
+ BAR_STYLE_PRESETS,
8
+ COLOR_SCHEME_PRESETS,
9
+ type ApprovedBarWidth,
10
+ type ApprovedRefreshIntervalMs,
11
+ type BarStylePreset,
12
+ type ColorSchemePreset,
13
+ type ResetWidgetConfig,
14
+ type ResetWidgetConfigPatch,
15
+ type ResetWidgetMode,
16
+ type UsageConfig,
17
+ type UsageConfigPatch,
18
+ type WindowWidgetConfig,
19
+ type WindowWidgetConfigPatch,
20
+ type WindowWidgetMode,
21
+ } from "./config";
22
+
23
+ type InteractiveSettingsMenuContext = Pick<ExtensionCommandContext, "ui">;
24
+
25
+ type PiTheme = {
26
+ fg?: (color: string, text: string) => string;
27
+ };
28
+
29
+ export type InteractiveSettingsMenuCallbacks = {
30
+ onCancel?: () => void;
31
+ onChange?: (id: string, newValue: string) => void;
32
+ onPatch?: (patch: UsageConfigPatch) => void;
33
+ theme?: SettingsListTheme;
34
+ };
35
+
36
+ type SettingPatchBuilder = (value: string) => UsageConfigPatch | undefined;
37
+
38
+ const DISPLAY_VALUES = ["On", "Off", "Always"] as const;
39
+ type DisplayValue = (typeof DISPLAY_VALUES)[number];
40
+ const WINDOW_WIDGET_VALUES = ["hidden", "percent", "bar", "bar + percent"] as const;
41
+ type WindowWidgetValue = (typeof WINDOW_WIDGET_VALUES)[number];
42
+ const RESET_WIDGET_VALUES = ["hidden", "countdown", "clock", "both"] as const;
43
+ type ResetWidgetValue = (typeof RESET_WIDGET_VALUES)[number];
44
+ const HIDE_LABEL_VALUES = ["No", "Yes"] as const;
45
+ type HideLabelValue = (typeof HIDE_LABEL_VALUES)[number];
46
+ const WINDOW_WIDGET_MODE_BY_VALUE: Record<WindowWidgetValue, WindowWidgetMode> = {
47
+ hidden: "hidden",
48
+ percent: "percent",
49
+ bar: "bar",
50
+ "bar + percent": "bar-percent",
51
+ };
52
+ const RESET_WIDGET_MODE_BY_VALUE: Record<ResetWidgetValue, ResetWidgetMode> = {
53
+ hidden: "hidden",
54
+ countdown: "countdown",
55
+ clock: "clock",
56
+ both: "both",
57
+ };
58
+ const BAR_WIDTH_VALUES = APPROVED_BAR_WIDTHS.map(String);
59
+ const BAR_WIDTH_BY_VALUE = new Map<string, ApprovedBarWidth>(
60
+ APPROVED_BAR_WIDTHS.map((width) => [String(width), width] as const),
61
+ );
62
+ const REFRESH_INTERVAL_VALUES = APPROVED_REFRESH_INTERVAL_PRESETS.map(({ label }) => label);
63
+ const REFRESH_INTERVAL_BY_VALUE = new Map<string, ApprovedRefreshIntervalMs>(
64
+ APPROVED_REFRESH_INTERVAL_PRESETS.map(({ label, value }) => [label, value] as const),
65
+ );
66
+
67
+ type WindowWidgetKey = "fiveHour" | "sevenDay";
68
+ type ResetWidgetKey = "fiveHourReset" | "sevenDayReset";
69
+
70
+ const SETTING_PATCH_BUILDERS: Record<string, SettingPatchBuilder> = {
71
+ display: displayPatchForValue,
72
+ "color-scheme": colorSchemePatchForValue,
73
+ "bar-style": barStylePatchForValue,
74
+ "bar-width": barWidthPatchForValue,
75
+ "five-hour-display": windowWidgetPatchBuilder("fiveHour"),
76
+ "seven-day-display": windowWidgetPatchBuilder("sevenDay"),
77
+ "five-hour-reset-display": resetWidgetPatchBuilder("fiveHourReset"),
78
+ "seven-day-reset-display": resetWidgetPatchBuilder("sevenDayReset"),
79
+ "refresh-interval": refreshIntervalPatchForValue,
80
+ "hide-label": hideLabelPatchForValue,
81
+ };
82
+
83
+ export async function openInteractiveSettingsMenu(
84
+ ctx: InteractiveSettingsMenuContext,
85
+ config: UsageConfig,
86
+ callbacks: InteractiveSettingsMenuCallbacks = {},
87
+ ): Promise<void> {
88
+ await ctx.ui.custom<void>((_tui, theme, _keybindings, done) =>
89
+ createInteractiveSettingsMenu(config, {
90
+ ...callbacks,
91
+ onCancel: () => {
92
+ callbacks.onCancel?.();
93
+ done(undefined);
94
+ },
95
+ theme: createSettingsListTheme(theme as PiTheme),
96
+ }),
97
+ );
98
+ }
99
+
100
+ export function createInteractiveSettingsMenu(
101
+ config: UsageConfig,
102
+ callbacks: InteractiveSettingsMenuCallbacks = {},
103
+ ): SettingsList {
104
+ const rows = buildInteractiveSettingsRows(config);
105
+ return new SettingsList(
106
+ rows,
107
+ rows.length,
108
+ callbacks.theme ?? createSettingsListTheme(),
109
+ createChangeHandler(callbacks),
110
+ callbacks.onCancel ?? (() => undefined),
111
+ );
112
+ }
113
+
114
+ export function configPatchForInteractiveSetting(
115
+ id: string,
116
+ newValue: string,
117
+ ): UsageConfigPatch | undefined {
118
+ return SETTING_PATCH_BUILDERS[id]?.(newValue);
119
+ }
120
+
121
+ export function buildInteractiveSettingsRows(config: UsageConfig): SettingItem[] {
122
+ return [
123
+ {
124
+ id: "display",
125
+ label: "Display",
126
+ currentValue: formatDisplayValue(config),
127
+ values: [...DISPLAY_VALUES],
128
+ },
129
+ {
130
+ id: "color-scheme",
131
+ label: "Color scheme",
132
+ currentValue: formatJsonOnlyCustomValue(config.colors.scheme),
133
+ values: [...COLOR_SCHEME_PRESETS],
134
+ },
135
+ {
136
+ id: "bar-style",
137
+ label: "Bar style",
138
+ currentValue: formatJsonOnlyCustomValue(config.bar.style),
139
+ values: [...BAR_STYLE_PRESETS],
140
+ },
141
+ {
142
+ id: "bar-width",
143
+ label: "Bar width",
144
+ currentValue: String(config.bar.width),
145
+ values: [...BAR_WIDTH_VALUES],
146
+ },
147
+ buildWindowWidgetRow("five-hour-display", "5h display", config.widgets.fiveHour),
148
+ buildWindowWidgetRow("seven-day-display", "7d display", config.widgets.sevenDay),
149
+ buildResetWidgetRow(
150
+ "five-hour-reset-display",
151
+ "5h reset display",
152
+ config.widgets.fiveHourReset,
153
+ ),
154
+ buildResetWidgetRow(
155
+ "seven-day-reset-display",
156
+ "7d reset display",
157
+ config.widgets.sevenDayReset,
158
+ ),
159
+ {
160
+ id: "refresh-interval",
161
+ label: "Refresh interval",
162
+ currentValue: formatRefreshInterval(config.refreshIntervalMs),
163
+ values: [...REFRESH_INTERVAL_VALUES],
164
+ },
165
+ {
166
+ id: "hide-label",
167
+ label: "Hide label",
168
+ currentValue: config.display.showLabel ? "No" : "Yes",
169
+ values: [...HIDE_LABEL_VALUES],
170
+ },
171
+ ];
172
+ }
173
+
174
+ function buildWindowWidgetRow(
175
+ id: string,
176
+ label: string,
177
+ widget: WindowWidgetConfig,
178
+ ): SettingItem {
179
+ return {
180
+ id,
181
+ label,
182
+ currentValue: formatWindowWidgetValue(widget),
183
+ values: [...WINDOW_WIDGET_VALUES],
184
+ };
185
+ }
186
+
187
+ function buildResetWidgetRow(id: string, label: string, widget: ResetWidgetConfig): SettingItem {
188
+ return {
189
+ id,
190
+ label,
191
+ currentValue: formatResetWidgetValue(widget),
192
+ values: [...RESET_WIDGET_VALUES],
193
+ };
194
+ }
195
+
196
+ function createChangeHandler(
197
+ callbacks: InteractiveSettingsMenuCallbacks,
198
+ ): (id: string, newValue: string) => void {
199
+ return (id, newValue) => {
200
+ callbacks.onChange?.(id, newValue);
201
+
202
+ const patch = configPatchForInteractiveSetting(id, newValue);
203
+ if (patch !== undefined) callbacks.onPatch?.(patch);
204
+ };
205
+ }
206
+
207
+ function displayPatchForValue(value: string): UsageConfigPatch | undefined {
208
+ if (!isDisplayValue(value)) return undefined;
209
+
210
+ switch (value) {
211
+ case "On":
212
+ return { enabled: true, display: { showAlways: false } };
213
+ case "Off":
214
+ return { enabled: false, display: { showAlways: false } };
215
+ case "Always":
216
+ return { enabled: true, display: { showAlways: true } };
217
+ }
218
+ }
219
+
220
+ function colorSchemePatchForValue(value: string): UsageConfigPatch | undefined {
221
+ if (!isColorSchemePreset(value)) return undefined;
222
+ return { colors: { scheme: value } };
223
+ }
224
+
225
+ function barStylePatchForValue(value: string): UsageConfigPatch | undefined {
226
+ if (!isBarStylePreset(value)) return undefined;
227
+ return { bar: { style: value } };
228
+ }
229
+
230
+ function barWidthPatchForValue(value: string): UsageConfigPatch | undefined {
231
+ const width = BAR_WIDTH_BY_VALUE.get(value);
232
+ if (width === undefined) return undefined;
233
+ return { bar: { width } };
234
+ }
235
+
236
+ function windowWidgetPatchBuilder(widget: WindowWidgetKey): SettingPatchBuilder {
237
+ return (value) => {
238
+ const patch = windowWidgetPatchForValue(value);
239
+ if (patch === undefined) return undefined;
240
+ return usagePatchForWindowWidget(widget, patch);
241
+ };
242
+ }
243
+
244
+ function resetWidgetPatchBuilder(widget: ResetWidgetKey): SettingPatchBuilder {
245
+ return (value) => {
246
+ const patch = resetWidgetPatchForValue(value);
247
+ if (patch === undefined) return undefined;
248
+ return usagePatchForResetWidget(widget, patch);
249
+ };
250
+ }
251
+
252
+ function windowWidgetPatchForValue(value: string): WindowWidgetConfigPatch | undefined {
253
+ if (!isWindowWidgetValue(value)) return undefined;
254
+ const mode = WINDOW_WIDGET_MODE_BY_VALUE[value];
255
+ return { enabled: mode !== "hidden", mode };
256
+ }
257
+
258
+ function resetWidgetPatchForValue(value: string): ResetWidgetConfigPatch | undefined {
259
+ if (!isResetWidgetValue(value)) return undefined;
260
+ const mode = RESET_WIDGET_MODE_BY_VALUE[value];
261
+ return { enabled: mode !== "hidden", mode };
262
+ }
263
+
264
+ function refreshIntervalPatchForValue(value: string): UsageConfigPatch | undefined {
265
+ const refreshIntervalMs = REFRESH_INTERVAL_BY_VALUE.get(value);
266
+ if (refreshIntervalMs === undefined) return undefined;
267
+ return { refreshIntervalMs };
268
+ }
269
+
270
+ function hideLabelPatchForValue(value: string): UsageConfigPatch | undefined {
271
+ if (!isHideLabelValue(value)) return undefined;
272
+ return { display: { showLabel: value === "No" } };
273
+ }
274
+
275
+ function usagePatchForWindowWidget(
276
+ widget: WindowWidgetKey,
277
+ patch: WindowWidgetConfigPatch,
278
+ ): UsageConfigPatch {
279
+ const widgets: Partial<Record<WindowWidgetKey, WindowWidgetConfigPatch>> = {};
280
+ widgets[widget] = patch;
281
+ return { widgets };
282
+ }
283
+
284
+ function usagePatchForResetWidget(
285
+ widget: ResetWidgetKey,
286
+ patch: ResetWidgetConfigPatch,
287
+ ): UsageConfigPatch {
288
+ const widgets: Partial<Record<ResetWidgetKey, ResetWidgetConfigPatch>> = {};
289
+ widgets[widget] = patch;
290
+ return { widgets };
291
+ }
292
+
293
+ function isDisplayValue(value: string): value is DisplayValue {
294
+ return includesString(DISPLAY_VALUES, value);
295
+ }
296
+
297
+ function isWindowWidgetValue(value: string): value is WindowWidgetValue {
298
+ return includesString(WINDOW_WIDGET_VALUES, value);
299
+ }
300
+
301
+ function isResetWidgetValue(value: string): value is ResetWidgetValue {
302
+ return includesString(RESET_WIDGET_VALUES, value);
303
+ }
304
+
305
+ function isHideLabelValue(value: string): value is HideLabelValue {
306
+ return includesString(HIDE_LABEL_VALUES, value);
307
+ }
308
+
309
+ function isColorSchemePreset(value: string): value is ColorSchemePreset {
310
+ return includesString(COLOR_SCHEME_PRESETS, value);
311
+ }
312
+
313
+ function isBarStylePreset(value: string): value is BarStylePreset {
314
+ return includesString(BAR_STYLE_PRESETS, value);
315
+ }
316
+
317
+ function includesString<const T extends readonly string[]>(
318
+ values: T,
319
+ value: string,
320
+ ): value is T[number] {
321
+ return values.includes(value as T[number]);
322
+ }
323
+
324
+ function createSettingsListTheme(theme: PiTheme = {}): SettingsListTheme {
325
+ const fg = (color: string, text: string) =>
326
+ typeof theme.fg === "function" ? theme.fg(color, text) : text;
327
+
328
+ return {
329
+ label: (text, selected) => (selected ? fg("accent", text) : text),
330
+ value: (text, selected) => (selected ? fg("accent", text) : fg("muted", text)),
331
+ description: (text) => fg("dim", text),
332
+ cursor: fg("accent", "→ "),
333
+ hint: (text) => fg("dim", text),
334
+ };
335
+ }
336
+
337
+ function formatDisplayValue(config: UsageConfig): string {
338
+ if (!config.enabled) return "Off";
339
+ if (config.display.showAlways) return "Always";
340
+ return "On";
341
+ }
342
+
343
+ function formatWindowWidgetValue(widget: WindowWidgetConfig): string {
344
+ if (!widget.enabled || widget.mode === "hidden") return "hidden";
345
+ if (widget.mode === "bar-percent") return "bar + percent";
346
+ return widget.mode;
347
+ }
348
+
349
+ function formatResetWidgetValue(widget: ResetWidgetConfig): string {
350
+ if (!widget.enabled || widget.mode === "hidden") return "hidden";
351
+ return widget.mode;
352
+ }
353
+
354
+ function formatRefreshInterval(refreshIntervalMs: number): string {
355
+ return (
356
+ APPROVED_REFRESH_INTERVAL_PRESETS.find((preset) => preset.value === refreshIntervalMs)?.label ??
357
+ `${refreshIntervalMs}ms`
358
+ );
359
+ }
360
+
361
+ function formatJsonOnlyCustomValue(value: string): string {
362
+ return value === "custom" ? "custom (JSON)" : value;
363
+ }
@@ -0,0 +1,163 @@
1
+ import {
2
+ MAX_BAR_WIDTH,
3
+ MIN_BAR_WIDTH,
4
+ type BarConfig,
5
+ type BarStyleName,
6
+ type CustomBarStyleConfig,
7
+ } from "./config";
8
+ import { isSingleCellGlyph } from "./display-width";
9
+
10
+ export type ProgressBarStyle = {
11
+ filled: string;
12
+ empty: string;
13
+ partials: string[];
14
+ };
15
+
16
+ export type ProgressBarSegmentKind = "filled" | "partial" | "empty";
17
+
18
+ export type ProgressBarSegment = {
19
+ text: string;
20
+ kind: ProgressBarSegmentKind;
21
+ };
22
+
23
+ type BuiltInBarStyleName = Exclude<BarStyleName, "custom">;
24
+
25
+ export const BUILT_IN_BAR_STYLES: Record<BuiltInBarStyleName, ProgressBarStyle> = {
26
+ blocks: {
27
+ filled: "█",
28
+ empty: "░",
29
+ partials: ["▏", "▎", "▍", "▌", "▋", "▊", "▉"],
30
+ },
31
+ thin: { filled: "━", empty: "─", partials: [] },
32
+ ascii: { filled: "#", empty: "-", partials: [] },
33
+ dots: { filled: "●", empty: "○", partials: [] },
34
+ squares: { filled: "■", empty: "□", partials: [] },
35
+ braille: {
36
+ filled: "⣿",
37
+ empty: "⠀",
38
+ partials: ["⣀", "⣤", "⣶"],
39
+ },
40
+ };
41
+
42
+ /**
43
+ * Renders a fixed-width text progress bar.
44
+ *
45
+ * The renderer is pure display text logic: it has no theme, color, or Pi UI knowledge.
46
+ * A null or unavailable percent renders an empty bar while preserving configured width.
47
+ */
48
+ export function renderProgressBar(percent: number | null | undefined, config: BarConfig): string {
49
+ return renderProgressBarSegments(percent, config)
50
+ .map((segment) => segment.text)
51
+ .join("");
52
+ }
53
+
54
+ /**
55
+ * Exposes fixed-width bar cells for callers that layer colors over selected cells.
56
+ *
57
+ * Segmenting remains pure progress-bar ownership: callers decide whether and how to
58
+ * color filled, partial, or empty cells.
59
+ */
60
+ export function renderProgressBarSegments(
61
+ percent: number | null | undefined,
62
+ config: BarConfig,
63
+ ): ProgressBarSegment[] {
64
+ const width = clampInteger(config.width, MIN_BAR_WIDTH, MAX_BAR_WIDTH);
65
+ const style = resolveBarStyle(config);
66
+
67
+ if (!isFiniteNumber(percent)) return emptySegments(width, style);
68
+
69
+ const filledCells = (clampNumber(percent, 0, 100) / 100) * width;
70
+ if (config.partials && style.partials.length > 0) {
71
+ return renderPartialBarSegments(filledCells, width, style);
72
+ }
73
+
74
+ return renderFullCellBarSegments(Math.round(filledCells), width, style);
75
+ }
76
+
77
+ function renderPartialBarSegments(
78
+ filledCells: number,
79
+ width: number,
80
+ style: ProgressBarStyle,
81
+ ): ProgressBarSegment[] {
82
+ const fullCells = Math.min(width, Math.floor(filledCells));
83
+ const fractionalCell = filledCells - fullCells;
84
+
85
+ if (fullCells >= width || fractionalCell <= Number.EPSILON) {
86
+ return renderFullCellBarSegments(fullCells, width, style);
87
+ }
88
+
89
+ const partialCell = partialCellForFraction(fractionalCell, style.partials);
90
+ const emptyCells = width - fullCells - 1;
91
+ return [
92
+ ...repeatSegments(style.filled, fullCells, "filled"),
93
+ { text: partialCell, kind: "partial" },
94
+ ...repeatSegments(style.empty, emptyCells, "empty"),
95
+ ];
96
+ }
97
+
98
+ function renderFullCellBarSegments(
99
+ filledCells: number,
100
+ width: number,
101
+ style: ProgressBarStyle,
102
+ ): ProgressBarSegment[] {
103
+ const clampedFilledCells = clampInteger(filledCells, 0, width);
104
+ return [
105
+ ...repeatSegments(style.filled, clampedFilledCells, "filled"),
106
+ ...repeatSegments(style.empty, width - clampedFilledCells, "empty"),
107
+ ];
108
+ }
109
+
110
+ function emptySegments(width: number, style: ProgressBarStyle): ProgressBarSegment[] {
111
+ return repeatSegments(style.empty, width, "empty");
112
+ }
113
+
114
+ function repeatSegments(
115
+ text: string,
116
+ count: number,
117
+ kind: ProgressBarSegmentKind,
118
+ ): ProgressBarSegment[] {
119
+ return Array.from({ length: Math.max(0, count) }, () => ({ text, kind }));
120
+ }
121
+
122
+ function partialCellForFraction(fraction: number, partials: readonly string[]): string {
123
+ const index = clampInteger(Math.ceil(fraction * (partials.length + 1)) - 1, 0, partials.length - 1);
124
+ return partials[index] ?? partials[partials.length - 1] ?? "";
125
+ }
126
+
127
+ function resolveBarStyle(config: BarConfig): ProgressBarStyle {
128
+ if (config.style === "custom") return customBarStyle(config.custom);
129
+ return BUILT_IN_BAR_STYLES[config.style] ?? BUILT_IN_BAR_STYLES.blocks;
130
+ }
131
+
132
+ function customBarStyle(custom: CustomBarStyleConfig): ProgressBarStyle {
133
+ return {
134
+ filled: singleCellGlyph(custom.filled, DEFAULT_CUSTOM_BAR_STYLE.filled),
135
+ empty: singleCellGlyph(custom.empty, DEFAULT_CUSTOM_BAR_STYLE.empty),
136
+ partials: custom.partials
137
+ .map((partial) => singleCellGlyph(partial, ""))
138
+ .filter((partial) => partial.length > 0),
139
+ };
140
+ }
141
+
142
+ function singleCellGlyph(value: string, fallback: string): string {
143
+ const [firstGlyph] = Array.from(value);
144
+ if (firstGlyph !== undefined && isSingleCellGlyph(firstGlyph)) return firstGlyph;
145
+ return fallback;
146
+ }
147
+
148
+ function isFiniteNumber(value: number | null | undefined): value is number {
149
+ return typeof value === "number" && Number.isFinite(value);
150
+ }
151
+
152
+ function clampInteger(value: number, min: number, max: number): number {
153
+ return clampNumber(Math.round(value), min, max);
154
+ }
155
+
156
+ function clampNumber(value: number, min: number, max: number): number {
157
+ return Math.max(min, Math.min(max, value));
158
+ }
159
+
160
+ const DEFAULT_CUSTOM_BAR_STYLE: Pick<ProgressBarStyle, "filled" | "empty"> = {
161
+ filled: "▰",
162
+ empty: "▱",
163
+ };