copilot-usage-tui 1.0.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,275 @@
1
+ import {
2
+ BoxRenderable,
3
+ TextRenderable,
4
+ type CliRenderer,
5
+ } from "@opentui/core"
6
+ import { THEME, type UsageSummary, type Config } from "../../types.ts"
7
+ import { formatCurrency, formatNumber, getMonthName } from "../../utils/format.ts"
8
+ import {
9
+ predictMonthlyUsage,
10
+ predictOverageCost,
11
+ getDaysInMonth,
12
+ getCurrentDay,
13
+ } from "../../utils/prediction.ts"
14
+ import { ProgressBarComponent } from "../components/progressBar.ts"
15
+ import { ChartComponent } from "../components/chart.ts"
16
+
17
+ export interface DashboardScreenOptions {
18
+ usage: UsageSummary
19
+ config: Config
20
+ onRefresh: () => void
21
+ onSettings: () => void
22
+ }
23
+
24
+ export class DashboardScreen {
25
+ private renderer: CliRenderer
26
+ private container: BoxRenderable
27
+ private progressBar: ProgressBarComponent | null = null
28
+ private chart: ChartComponent | null = null
29
+
30
+ constructor(renderer: CliRenderer, options: DashboardScreenOptions) {
31
+ this.renderer = renderer
32
+ const { usage, config } = options
33
+
34
+ // Main container
35
+ this.container = new BoxRenderable(renderer, {
36
+ id: "dashboard-screen",
37
+ width: "100%",
38
+ height: "100%",
39
+ flexDirection: "column",
40
+ backgroundColor: THEME.bg,
41
+ padding: 1,
42
+ })
43
+
44
+ this.createUI(usage, config)
45
+ renderer.root.add(this.container)
46
+ }
47
+
48
+ private createUI(usage: UsageSummary, config: Config) {
49
+ const { quota } = config
50
+ const monthName = getMonthName(usage.month)
51
+
52
+ // Header
53
+ const headerBox = new BoxRenderable(this.renderer, {
54
+ id: "dashboard-header",
55
+ width: "100%",
56
+ flexDirection: "column",
57
+ alignItems: "center",
58
+ padding: 1,
59
+ border: ["top", "left", "right", "bottom"],
60
+ borderStyle: "rounded",
61
+ borderColor: THEME.blue,
62
+ backgroundColor: THEME.bgDark,
63
+ })
64
+
65
+ const title = new TextRenderable(this.renderer, {
66
+ id: "dashboard-title",
67
+ content: `GitHub Copilot Usage - ${monthName} ${usage.year}`,
68
+ fg: THEME.blue,
69
+ })
70
+
71
+ const userText = new TextRenderable(this.renderer, {
72
+ id: "dashboard-user",
73
+ content: `User: ${usage.user}`,
74
+ fg: THEME.fgMuted,
75
+ })
76
+
77
+ headerBox.add(title)
78
+ headerBox.add(userText)
79
+ this.container.add(headerBox)
80
+
81
+ // Spacer
82
+ this.container.add(new BoxRenderable(this.renderer, { id: "spacer-1", height: 1 }))
83
+
84
+ // Progress section
85
+ const progressSection = new BoxRenderable(this.renderer, {
86
+ id: "progress-section",
87
+ width: "100%",
88
+ flexDirection: "column",
89
+ padding: 1,
90
+ border: ["top", "left", "right", "bottom"],
91
+ borderStyle: "rounded",
92
+ borderColor: THEME.border,
93
+ backgroundColor: THEME.bgDark,
94
+ })
95
+
96
+ this.progressBar = new ProgressBarComponent(this.renderer, {
97
+ used: usage.totalRequests,
98
+ quota: quota,
99
+ width: 50,
100
+ })
101
+
102
+ progressSection.add(this.progressBar.getContainer())
103
+ this.container.add(progressSection)
104
+
105
+ // Spacer
106
+ this.container.add(new BoxRenderable(this.renderer, { id: "spacer-2", height: 1 }))
107
+
108
+ // Chart section
109
+ const chartSection = new BoxRenderable(this.renderer, {
110
+ id: "chart-section",
111
+ width: "100%",
112
+ flexDirection: "column",
113
+ padding: 1,
114
+ border: ["top", "left", "right", "bottom"],
115
+ borderStyle: "rounded",
116
+ borderColor: THEME.border,
117
+ backgroundColor: THEME.bgDark,
118
+ })
119
+
120
+ this.chart = new ChartComponent(this.renderer, {
121
+ items: usage.items,
122
+ maxRows: 6,
123
+ barWidth: 35,
124
+ })
125
+
126
+ chartSection.add(this.chart.getContainer())
127
+ this.container.add(chartSection)
128
+
129
+ // Spacer
130
+ this.container.add(new BoxRenderable(this.renderer, { id: "spacer-3", height: 1 }))
131
+
132
+ // Summary and prediction section (side by side)
133
+ const bottomSection = new BoxRenderable(this.renderer, {
134
+ id: "bottom-section",
135
+ width: "100%",
136
+ flexDirection: "row",
137
+ gap: 2,
138
+ })
139
+
140
+ // Costs summary
141
+ const costsBox = new BoxRenderable(this.renderer, {
142
+ id: "costs-box",
143
+ flexGrow: 1,
144
+ flexDirection: "column",
145
+ padding: 1,
146
+ border: ["top", "left", "right", "bottom"],
147
+ borderStyle: "rounded",
148
+ borderColor: THEME.border,
149
+ backgroundColor: THEME.bgDark,
150
+ })
151
+
152
+ const costsTitle = new TextRenderable(this.renderer, {
153
+ id: "costs-title",
154
+ content: "Costs",
155
+ fg: THEME.fgMuted,
156
+ })
157
+
158
+ const costsSeparator = new TextRenderable(this.renderer, {
159
+ id: "costs-separator",
160
+ content: "─".repeat(25),
161
+ fg: THEME.border,
162
+ })
163
+
164
+ const grossLine = new TextRenderable(this.renderer, {
165
+ id: "costs-gross",
166
+ content: `Gross Amount: ${formatCurrency(usage.grossAmount)}`,
167
+ fg: THEME.fg,
168
+ })
169
+
170
+ const discountLine = new TextRenderable(this.renderer, {
171
+ id: "costs-discount",
172
+ content: `Discount: -${formatCurrency(usage.discountAmount)}`,
173
+ fg: THEME.green,
174
+ })
175
+
176
+ const netLine = new TextRenderable(this.renderer, {
177
+ id: "costs-net",
178
+ content: `Net Amount: ${formatCurrency(usage.netAmount)}`,
179
+ fg: usage.netAmount > 0 ? THEME.yellow : THEME.green,
180
+ })
181
+
182
+ costsBox.add(costsTitle)
183
+ costsBox.add(costsSeparator)
184
+ costsBox.add(grossLine)
185
+ costsBox.add(discountLine)
186
+ costsBox.add(netLine)
187
+
188
+ // Prediction box
189
+ const predictionBox = new BoxRenderable(this.renderer, {
190
+ id: "prediction-box",
191
+ flexGrow: 1,
192
+ flexDirection: "column",
193
+ padding: 1,
194
+ border: ["top", "left", "right", "bottom"],
195
+ borderStyle: "rounded",
196
+ borderColor: THEME.border,
197
+ backgroundColor: THEME.bgDark,
198
+ })
199
+
200
+ const predictionTitle = new TextRenderable(this.renderer, {
201
+ id: "prediction-title",
202
+ content: "Prediction",
203
+ fg: THEME.fgMuted,
204
+ })
205
+
206
+ const predictionSeparator = new TextRenderable(this.renderer, {
207
+ id: "prediction-separator",
208
+ content: "─".repeat(25),
209
+ fg: THEME.border,
210
+ })
211
+
212
+ // Calculate prediction
213
+ const currentDay = getCurrentDay()
214
+ const daysInMonth = getDaysInMonth(usage.year, usage.month)
215
+ const predictedUsage = predictMonthlyUsage(usage.totalRequests, currentDay, daysInMonth)
216
+ const predictedOverage = predictOverageCost(predictedUsage, quota)
217
+
218
+ const predictionLine = new TextRenderable(this.renderer, {
219
+ id: "prediction-usage",
220
+ content: `End of month: ~${formatNumber(predictedUsage)} reqs`,
221
+ fg: predictedUsage > quota ? THEME.yellow : THEME.fg,
222
+ })
223
+
224
+ const overageLine = new TextRenderable(this.renderer, {
225
+ id: "prediction-overage",
226
+ content: `Overage cost: ${formatCurrency(predictedOverage)}`,
227
+ fg: predictedOverage > 0 ? THEME.red : THEME.green,
228
+ })
229
+
230
+ const daysLine = new TextRenderable(this.renderer, {
231
+ id: "prediction-days",
232
+ content: `Day ${currentDay} of ${daysInMonth}`,
233
+ fg: THEME.fgMuted,
234
+ })
235
+
236
+ predictionBox.add(predictionTitle)
237
+ predictionBox.add(predictionSeparator)
238
+ predictionBox.add(predictionLine)
239
+ predictionBox.add(overageLine)
240
+ predictionBox.add(daysLine)
241
+
242
+ bottomSection.add(costsBox)
243
+ bottomSection.add(predictionBox)
244
+ this.container.add(bottomSection)
245
+
246
+ // Spacer
247
+ this.container.add(new BoxRenderable(this.renderer, { id: "spacer-4", height: 1 }))
248
+
249
+ // Footer with keybindings
250
+ const footer = new BoxRenderable(this.renderer, {
251
+ id: "dashboard-footer",
252
+ width: "100%",
253
+ flexDirection: "row",
254
+ justifyContent: "center",
255
+ padding: 1,
256
+ border: ["top", "left", "right", "bottom"],
257
+ borderStyle: "rounded",
258
+ borderColor: THEME.border,
259
+ backgroundColor: THEME.bgDark,
260
+ })
261
+
262
+ const keybindings = new TextRenderable(this.renderer, {
263
+ id: "footer-keybindings",
264
+ content: "[r] Refresh [s] Settings [q] Quit",
265
+ fg: THEME.fgMuted,
266
+ })
267
+
268
+ footer.add(keybindings)
269
+ this.container.add(footer)
270
+ }
271
+
272
+ public destroy() {
273
+ this.container.destroyRecursively()
274
+ }
275
+ }
@@ -0,0 +1,188 @@
1
+ import {
2
+ BoxRenderable,
3
+ TextRenderable,
4
+ SelectRenderable,
5
+ InputRenderable,
6
+ type CliRenderer,
7
+ type KeyEvent,
8
+ SelectRenderableEvents,
9
+ InputRenderableEvents,
10
+ } from "@opentui/core"
11
+ import { THEME, PLAN_OPTIONS, type Config } from "../../types.ts"
12
+ import { saveConfig } from "../../config/config.ts"
13
+ import { formatNumber } from "../../utils/format.ts"
14
+
15
+ export interface SetupScreenOptions {
16
+ onComplete: (config: Config) => void
17
+ }
18
+
19
+ export class SetupScreen {
20
+ private renderer: CliRenderer
21
+ private container: BoxRenderable
22
+ private select: SelectRenderable | null = null
23
+ private customInput: InputRenderable | null = null
24
+ private onComplete: (config: Config) => void
25
+ private showingCustomInput = false
26
+
27
+ constructor(renderer: CliRenderer, options: SetupScreenOptions) {
28
+ this.renderer = renderer
29
+ this.onComplete = options.onComplete
30
+
31
+ // Main container
32
+ this.container = new BoxRenderable(renderer, {
33
+ id: "setup-screen",
34
+ width: "100%",
35
+ height: "100%",
36
+ flexDirection: "column",
37
+ justifyContent: "center",
38
+ alignItems: "center",
39
+ backgroundColor: THEME.bg,
40
+ })
41
+
42
+ this.createUI()
43
+ renderer.root.add(this.container)
44
+ }
45
+
46
+ private createUI() {
47
+ // Title box
48
+ const titleBox = new BoxRenderable(this.renderer, {
49
+ id: "setup-title-box",
50
+ width: 60,
51
+ flexDirection: "column",
52
+ alignItems: "center",
53
+ padding: 1,
54
+ border: ["top", "left", "right"],
55
+ borderStyle: "rounded",
56
+ borderColor: THEME.blue,
57
+ backgroundColor: THEME.bgDark,
58
+ })
59
+
60
+ const title = new TextRenderable(this.renderer, {
61
+ id: "setup-title",
62
+ content: "Copilot Usage Setup",
63
+ fg: THEME.blue,
64
+ })
65
+
66
+ titleBox.add(title)
67
+
68
+ // Content box
69
+ const contentBox = new BoxRenderable(this.renderer, {
70
+ id: "setup-content-box",
71
+ width: 60,
72
+ flexDirection: "column",
73
+ padding: 2,
74
+ border: ["bottom", "left", "right"],
75
+ borderStyle: "rounded",
76
+ borderColor: THEME.border,
77
+ backgroundColor: THEME.bgDark,
78
+ })
79
+
80
+ const description = new TextRenderable(this.renderer, {
81
+ id: "setup-description",
82
+ content: "Select your Copilot plan to set your monthly\npremium request quota:",
83
+ fg: THEME.fg,
84
+ })
85
+
86
+ const spacer = new BoxRenderable(this.renderer, {
87
+ id: "setup-spacer",
88
+ height: 1,
89
+ })
90
+
91
+ // Plan options for select
92
+ const selectOptions = PLAN_OPTIONS.map((plan) => ({
93
+ name: plan.name,
94
+ label: `${plan.label.padEnd(12)} ${plan.description}`,
95
+ }))
96
+
97
+ this.select = new SelectRenderable(this.renderer, {
98
+ id: "setup-select",
99
+ options: selectOptions,
100
+ width: 50,
101
+ height: PLAN_OPTIONS.length + 2,
102
+ selectedColor: THEME.blue,
103
+ unselectedColor: THEME.fgMuted,
104
+ })
105
+
106
+ // Custom input (hidden initially)
107
+ this.customInput = new InputRenderable(this.renderer, {
108
+ id: "setup-custom-input",
109
+ placeholder: "Enter custom quota (e.g., 500)",
110
+ width: 40,
111
+ fg: THEME.fg,
112
+ visible: false,
113
+ })
114
+
115
+ const hint = new TextRenderable(this.renderer, {
116
+ id: "setup-hint",
117
+ content: "Use ↑/↓ to navigate, Enter to select",
118
+ fg: THEME.fgMuted,
119
+ })
120
+
121
+ contentBox.add(description)
122
+ contentBox.add(spacer)
123
+ contentBox.add(this.select)
124
+ contentBox.add(this.customInput)
125
+ contentBox.add(new BoxRenderable(this.renderer, { id: "hint-spacer", height: 1 }))
126
+ contentBox.add(hint)
127
+
128
+ this.container.add(titleBox)
129
+ this.container.add(contentBox)
130
+
131
+ // Handle selection
132
+ this.select.on(SelectRenderableEvents.ITEM_SELECTED, (_index, option) => {
133
+ const plan = PLAN_OPTIONS.find((p) => p.name === option.name)
134
+ if (!plan) return
135
+
136
+ if (plan.name === "custom") {
137
+ this.showCustomInput()
138
+ } else {
139
+ this.savePlan(plan.name, plan.quota)
140
+ }
141
+ })
142
+
143
+ // Handle custom input
144
+ this.customInput.on(InputRenderableEvents.CHANGE, (value) => {
145
+ const quota = parseInt(value, 10)
146
+ if (!isNaN(quota) && quota > 0) {
147
+ this.savePlan("custom", quota)
148
+ }
149
+ })
150
+
151
+ this.select.focus()
152
+ }
153
+
154
+ private showCustomInput() {
155
+ if (this.select && this.customInput) {
156
+ this.showingCustomInput = true
157
+ this.select.visible = false
158
+ this.customInput.visible = true
159
+ this.customInput.focus()
160
+ }
161
+ }
162
+
163
+ private async savePlan(planName: string, quota: number) {
164
+ const config: Config = { plan: planName, quota }
165
+ await saveConfig(config)
166
+ this.onComplete(config)
167
+ }
168
+
169
+ public handleKey(event: KeyEvent) {
170
+ if (this.showingCustomInput && event.name === "escape") {
171
+ // Go back to plan selection
172
+ this.showingCustomInput = false
173
+ if (this.customInput) {
174
+ this.customInput.visible = false
175
+ this.customInput.content = ""
176
+ }
177
+ if (this.select) {
178
+ this.select.visible = true
179
+ this.select.focus()
180
+ }
181
+ event.preventDefault()
182
+ }
183
+ }
184
+
185
+ public destroy() {
186
+ this.container.destroyRecursively()
187
+ }
188
+ }
@@ -0,0 +1,61 @@
1
+ /**
2
+ * Format a number as currency (USD)
3
+ */
4
+ export function formatCurrency(amount: number): string {
5
+ return `$${amount.toFixed(2)}`
6
+ }
7
+
8
+ /**
9
+ * Format a number with commas as thousand separators
10
+ */
11
+ export function formatNumber(value: number): string {
12
+ return Math.round(value).toLocaleString("en-US")
13
+ }
14
+
15
+ /**
16
+ * Format a percentage value
17
+ */
18
+ export function formatPercent(value: number): string {
19
+ return `${value.toFixed(1)}%`
20
+ }
21
+
22
+ /**
23
+ * Calculate percentage with bounds checking
24
+ */
25
+ export function calculatePercent(used: number, total: number): number {
26
+ if (total <= 0) return 0
27
+ return Math.min(100, (used / total) * 100)
28
+ }
29
+
30
+ /**
31
+ * Get month name from month number (1-12)
32
+ */
33
+ export function getMonthName(month: number): string {
34
+ const months = [
35
+ "January", "February", "March", "April", "May", "June",
36
+ "July", "August", "September", "October", "November", "December"
37
+ ]
38
+ return months[month - 1] || "Unknown"
39
+ }
40
+
41
+ /**
42
+ * Pad a string to a fixed width (right-aligned for numbers)
43
+ */
44
+ export function padLeft(str: string, width: number): string {
45
+ return str.padStart(width)
46
+ }
47
+
48
+ /**
49
+ * Pad a string to a fixed width (left-aligned for text)
50
+ */
51
+ export function padRight(str: string, width: number): string {
52
+ return str.padEnd(width)
53
+ }
54
+
55
+ /**
56
+ * Truncate a string to a maximum length with ellipsis
57
+ */
58
+ export function truncate(str: string, maxLength: number): string {
59
+ if (str.length <= maxLength) return str
60
+ return str.slice(0, maxLength - 1) + "…"
61
+ }
@@ -0,0 +1,62 @@
1
+ /**
2
+ * Get the number of days in a given month
3
+ */
4
+ export function getDaysInMonth(year: number, month: number): number {
5
+ return new Date(year, month, 0).getDate()
6
+ }
7
+
8
+ /**
9
+ * Get the current day of the month (1-31)
10
+ */
11
+ export function getCurrentDay(): number {
12
+ return new Date().getDate()
13
+ }
14
+
15
+ /**
16
+ * Get current year and month
17
+ */
18
+ export function getCurrentYearMonth(): { year: number; month: number } {
19
+ const now = new Date()
20
+ return {
21
+ year: now.getFullYear(),
22
+ month: now.getMonth() + 1, // JavaScript months are 0-indexed
23
+ }
24
+ }
25
+
26
+ /**
27
+ * Predict end-of-month usage based on current usage pattern
28
+ * Uses linear extrapolation from current usage
29
+ */
30
+ export function predictMonthlyUsage(
31
+ currentUsage: number,
32
+ currentDay: number,
33
+ daysInMonth: number
34
+ ): number {
35
+ if (currentDay <= 0) return currentUsage
36
+ const dailyAverage = currentUsage / currentDay
37
+ return Math.round(dailyAverage * daysInMonth)
38
+ }
39
+
40
+ /**
41
+ * Calculate predicted overage cost
42
+ * @param predictedUsage - Predicted total usage for the month
43
+ * @param quota - Monthly quota limit
44
+ * @param pricePerRequest - Price per premium request (default $0.04)
45
+ */
46
+ export function predictOverageCost(
47
+ predictedUsage: number,
48
+ quota: number,
49
+ pricePerRequest: number = 0.04
50
+ ): number {
51
+ const overage = Math.max(0, predictedUsage - quota)
52
+ return overage * pricePerRequest
53
+ }
54
+
55
+ /**
56
+ * Get usage status level based on percentage
57
+ */
58
+ export function getUsageLevel(percent: number): "normal" | "warning" | "critical" {
59
+ if (percent >= 90) return "critical"
60
+ if (percent >= 70) return "warning"
61
+ return "normal"
62
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,19 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "ESNext",
5
+ "moduleResolution": "bundler",
6
+ "strict": true,
7
+ "esModuleInterop": true,
8
+ "skipLibCheck": true,
9
+ "forceConsistentCasingInFileNames": true,
10
+ "outDir": "./dist",
11
+ "rootDir": "./src",
12
+ "declaration": true,
13
+ "resolveJsonModule": true,
14
+ "allowImportingTsExtensions": true,
15
+ "noEmit": true
16
+ },
17
+ "include": ["src/**/*"],
18
+ "exclude": ["node_modules", "dist"]
19
+ }