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.
package/src/ui/app.ts ADDED
@@ -0,0 +1,215 @@
1
+ import {
2
+ createCliRenderer,
3
+ type CliRenderer,
4
+ type KeyEvent,
5
+ } from "@opentui/core"
6
+ import type { ViewState, Config, UsageSummary, AuthStatus } from "../types.ts"
7
+ import { hasConfig, loadConfig } from "../config/config.ts"
8
+ import { checkAuth } from "../api/auth.ts"
9
+ import { getUsageSummary, getUsername } from "../api/github.ts"
10
+ import { SetupScreen } from "./screens/setup.ts"
11
+ import { AuthScreen } from "./screens/auth.ts"
12
+ import { DashboardScreen } from "./screens/dashboard.ts"
13
+
14
+ export class App {
15
+ private renderer: CliRenderer
16
+ private currentView: ViewState = "loading"
17
+
18
+ // Screens
19
+ private setupScreen: SetupScreen | null = null
20
+ private authScreen: AuthScreen | null = null
21
+ private dashboardScreen: DashboardScreen | null = null
22
+
23
+ // State
24
+ private config: Config | null = null
25
+ private authStatus: AuthStatus | null = null
26
+ private usage: UsageSummary | null = null
27
+ private username: string | null = null
28
+
29
+ private constructor(renderer: CliRenderer) {
30
+ this.renderer = renderer
31
+ }
32
+
33
+ static async create(): Promise<App> {
34
+ const renderer = await createCliRenderer({
35
+ exitOnCtrlC: true,
36
+ })
37
+
38
+ const app = new App(renderer)
39
+ await app.initialize()
40
+ return app
41
+ }
42
+
43
+ private async initialize() {
44
+ // Setup keyboard handler
45
+ this.renderer.keyInput.on("keypress", (event: KeyEvent) => {
46
+ this.handleKeyPress(event)
47
+ })
48
+
49
+ // Check if config exists
50
+ if (await hasConfig()) {
51
+ this.config = await loadConfig()
52
+ }
53
+
54
+ if (!this.config) {
55
+ // First run - show setup
56
+ this.showSetup()
57
+ } else {
58
+ // Check auth
59
+ await this.checkAuthAndProceed()
60
+ }
61
+ }
62
+
63
+ private async checkAuthAndProceed() {
64
+ this.authStatus = await checkAuth()
65
+
66
+ if (!this.authStatus.hasUserScope) {
67
+ this.showAuth()
68
+ } else {
69
+ await this.loadAndShowDashboard()
70
+ }
71
+ }
72
+
73
+ private async loadAndShowDashboard() {
74
+ // Get username if not cached
75
+ if (!this.username) {
76
+ this.username = await getUsername()
77
+ }
78
+
79
+ if (!this.username) {
80
+ // Fallback to auth status username
81
+ this.username = this.authStatus?.username || "unknown"
82
+ }
83
+
84
+ // Fetch usage data
85
+ this.usage = await getUsageSummary(this.username)
86
+
87
+ if (!this.usage) {
88
+ // Handle error - for now just show auth screen
89
+ this.authStatus = {
90
+ ghInstalled: true,
91
+ authenticated: true,
92
+ hasUserScope: false,
93
+ error: "Failed to fetch usage data. Check your authentication.",
94
+ }
95
+ this.showAuth()
96
+ return
97
+ }
98
+
99
+ this.showDashboard()
100
+ }
101
+
102
+ private showSetup() {
103
+ this.clearCurrentScreen()
104
+ this.currentView = "setup"
105
+
106
+ this.setupScreen = new SetupScreen(this.renderer, {
107
+ onComplete: async (config) => {
108
+ this.config = config
109
+ await this.checkAuthAndProceed()
110
+ },
111
+ })
112
+ }
113
+
114
+ private showAuth() {
115
+ this.clearCurrentScreen()
116
+ this.currentView = "auth"
117
+
118
+ if (!this.authStatus) {
119
+ this.authStatus = {
120
+ ghInstalled: false,
121
+ authenticated: false,
122
+ hasUserScope: false,
123
+ error: "Unknown authentication state",
124
+ }
125
+ }
126
+
127
+ this.authScreen = new AuthScreen(this.renderer, {
128
+ authStatus: this.authStatus,
129
+ onRetry: async () => {
130
+ await this.checkAuthAndProceed()
131
+ },
132
+ })
133
+ }
134
+
135
+ private showDashboard() {
136
+ this.clearCurrentScreen()
137
+ this.currentView = "dashboard"
138
+
139
+ if (!this.usage || !this.config) {
140
+ return
141
+ }
142
+
143
+ this.dashboardScreen = new DashboardScreen(this.renderer, {
144
+ usage: this.usage,
145
+ config: this.config,
146
+ onRefresh: async () => {
147
+ await this.refresh()
148
+ },
149
+ onSettings: () => {
150
+ this.showSetup()
151
+ },
152
+ })
153
+ }
154
+
155
+ private clearCurrentScreen() {
156
+ if (this.setupScreen) {
157
+ this.setupScreen.destroy()
158
+ this.setupScreen = null
159
+ }
160
+ if (this.authScreen) {
161
+ this.authScreen.destroy()
162
+ this.authScreen = null
163
+ }
164
+ if (this.dashboardScreen) {
165
+ this.dashboardScreen.destroy()
166
+ this.dashboardScreen = null
167
+ }
168
+ }
169
+
170
+ private async refresh() {
171
+ if (this.username && this.config) {
172
+ this.usage = await getUsageSummary(this.username)
173
+ if (this.usage) {
174
+ this.clearCurrentScreen()
175
+ this.showDashboard()
176
+ }
177
+ }
178
+ }
179
+
180
+ private handleKeyPress(event: KeyEvent) {
181
+ // Handle setup screen key events
182
+ if (this.currentView === "setup" && this.setupScreen) {
183
+ this.setupScreen.handleKey(event)
184
+ return
185
+ }
186
+
187
+ // Global keybindings
188
+ switch (event.name) {
189
+ case "q":
190
+ if (!event.ctrl) {
191
+ this.renderer.destroy()
192
+ }
193
+ break
194
+
195
+ case "r":
196
+ if (this.currentView === "auth") {
197
+ this.checkAuthAndProceed()
198
+ } else if (this.currentView === "dashboard") {
199
+ this.refresh()
200
+ }
201
+ break
202
+
203
+ case "s":
204
+ if (this.currentView === "dashboard") {
205
+ this.showSetup()
206
+ }
207
+ break
208
+ }
209
+ }
210
+
211
+ public destroy() {
212
+ this.clearCurrentScreen()
213
+ this.renderer.destroy()
214
+ }
215
+ }
@@ -0,0 +1,141 @@
1
+ import {
2
+ BoxRenderable,
3
+ TextRenderable,
4
+ type CliRenderer,
5
+ } from "@opentui/core"
6
+ import { THEME, type UsageItem } from "../../types.ts"
7
+ import { formatNumber, padRight, truncate } from "../../utils/format.ts"
8
+
9
+ export interface ChartOptions {
10
+ items: UsageItem[]
11
+ maxRows?: number
12
+ barWidth?: number
13
+ }
14
+
15
+ // Bar characters for different fill levels
16
+ const BAR_CHARS = ["▏", "▎", "▍", "▌", "▋", "▊", "▉", "█"]
17
+
18
+ export class ChartComponent {
19
+ private renderer: CliRenderer
20
+ private container: BoxRenderable
21
+
22
+ private readonly COL_MODEL = 20
23
+ private readonly COL_COUNT = 6
24
+
25
+ constructor(renderer: CliRenderer, options: ChartOptions) {
26
+ this.renderer = renderer
27
+ const { items, maxRows = 8, barWidth = 30 } = options
28
+
29
+ this.container = new BoxRenderable(renderer, {
30
+ id: "chart-container",
31
+ flexDirection: "column",
32
+ width: "100%",
33
+ })
34
+
35
+ this.createChart(items, maxRows, barWidth)
36
+ }
37
+
38
+ private createChart(items: UsageItem[], maxRows: number, barWidth: number) {
39
+ // Title
40
+ const title = new TextRenderable(this.renderer, {
41
+ id: "chart-title",
42
+ content: "Usage by Model",
43
+ fg: THEME.fgMuted,
44
+ })
45
+ this.container.add(title)
46
+
47
+ // Separator
48
+ const separator = new TextRenderable(this.renderer, {
49
+ id: "chart-separator",
50
+ content: "─".repeat(this.COL_MODEL + barWidth + this.COL_COUNT + 2),
51
+ fg: THEME.border,
52
+ })
53
+ this.container.add(separator)
54
+
55
+ // Find max value for scaling
56
+ const maxValue = items.length > 0 ? Math.max(...items.map(i => i.grossQuantity)) : 1
57
+
58
+ // Display items
59
+ const displayItems = items.slice(0, maxRows)
60
+
61
+ // Color palette for different models
62
+ const colors = [
63
+ THEME.blue,
64
+ THEME.cyan,
65
+ THEME.green,
66
+ THEME.magenta,
67
+ THEME.yellow,
68
+ THEME.orange,
69
+ THEME.red,
70
+ THEME.fgDark,
71
+ ]
72
+
73
+ for (let i = 0; i < displayItems.length; i++) {
74
+ const item = displayItems[i]
75
+ const row = this.createBarRow(item, i, maxValue, barWidth, colors[i % colors.length])
76
+ this.container.add(row)
77
+ }
78
+ }
79
+
80
+ private createBarRow(
81
+ item: UsageItem,
82
+ index: number,
83
+ maxValue: number,
84
+ barWidth: number,
85
+ color: string
86
+ ): BoxRenderable {
87
+ const row = new BoxRenderable(this.renderer, {
88
+ id: `chart-row-${index}`,
89
+ flexDirection: "row",
90
+ })
91
+
92
+ // Model name
93
+ const modelName = truncate(item.model, this.COL_MODEL - 2)
94
+ const modelText = new TextRenderable(this.renderer, {
95
+ id: `chart-${index}-model`,
96
+ content: padRight(modelName, this.COL_MODEL),
97
+ fg: THEME.fg,
98
+ })
99
+
100
+ // Calculate bar length
101
+ const ratio = maxValue > 0 ? item.grossQuantity / maxValue : 0
102
+ const fullBlocks = Math.floor(ratio * barWidth)
103
+ const remainder = (ratio * barWidth) - fullBlocks
104
+ const partialIndex = Math.floor(remainder * BAR_CHARS.length)
105
+
106
+ let barStr = "█".repeat(fullBlocks)
107
+ if (partialIndex > 0 && fullBlocks < barWidth) {
108
+ barStr += BAR_CHARS[partialIndex - 1]
109
+ }
110
+
111
+ // Pad bar to fixed width
112
+ barStr = barStr.padEnd(barWidth, " ")
113
+
114
+ const barText = new TextRenderable(this.renderer, {
115
+ id: `chart-${index}-bar`,
116
+ content: barStr,
117
+ fg: color,
118
+ })
119
+
120
+ // Request count
121
+ const countText = new TextRenderable(this.renderer, {
122
+ id: `chart-${index}-count`,
123
+ content: " " + formatNumber(item.grossQuantity).padStart(this.COL_COUNT),
124
+ fg: THEME.fgMuted,
125
+ })
126
+
127
+ row.add(modelText)
128
+ row.add(barText)
129
+ row.add(countText)
130
+
131
+ return row
132
+ }
133
+
134
+ public getContainer(): BoxRenderable {
135
+ return this.container
136
+ }
137
+
138
+ public destroy() {
139
+ this.container.destroyRecursively()
140
+ }
141
+ }
@@ -0,0 +1,114 @@
1
+ import {
2
+ BoxRenderable,
3
+ TextRenderable,
4
+ type CliRenderer,
5
+ } from "@opentui/core"
6
+ import { THEME } from "../../types.ts"
7
+ import { formatNumber, formatPercent } from "../../utils/format.ts"
8
+ import { getUsageLevel } from "../../utils/prediction.ts"
9
+
10
+ export interface ProgressBarOptions {
11
+ used: number
12
+ quota: number
13
+ width?: number
14
+ }
15
+
16
+ export class ProgressBarComponent {
17
+ private renderer: CliRenderer
18
+ private container: BoxRenderable
19
+ private labelText: TextRenderable
20
+ private barContainer: BoxRenderable
21
+ private barFilled: TextRenderable
22
+ private barEmpty: TextRenderable
23
+
24
+ constructor(renderer: CliRenderer, options: ProgressBarOptions) {
25
+ this.renderer = renderer
26
+
27
+ const { used, quota, width = 50 } = options
28
+ const percent = quota > 0 ? (used / quota) * 100 : 0
29
+ const level = getUsageLevel(percent)
30
+
31
+ // Get color based on usage level
32
+ const barColor = this.getBarColor(level)
33
+
34
+ // Container
35
+ this.container = new BoxRenderable(renderer, {
36
+ id: "progress-container",
37
+ flexDirection: "column",
38
+ width: "100%",
39
+ gap: 0,
40
+ })
41
+
42
+ // Label: "Premium Requests: 710 / 1,500 (47.3%)"
43
+ this.labelText = new TextRenderable(renderer, {
44
+ id: "progress-label",
45
+ content: `Premium Requests: ${formatNumber(used)} / ${formatNumber(quota)} (${formatPercent(percent)})`,
46
+ fg: THEME.fg,
47
+ })
48
+
49
+ // Bar container
50
+ this.barContainer = new BoxRenderable(renderer, {
51
+ id: "progress-bar-container",
52
+ flexDirection: "row",
53
+ width: width,
54
+ })
55
+
56
+ // Calculate bar segments
57
+ const filledWidth = Math.round((percent / 100) * width)
58
+ const emptyWidth = width - filledWidth
59
+
60
+ // Filled portion
61
+ this.barFilled = new TextRenderable(renderer, {
62
+ id: "progress-bar-filled",
63
+ content: "█".repeat(Math.max(0, filledWidth)),
64
+ fg: barColor,
65
+ })
66
+
67
+ // Empty portion
68
+ this.barEmpty = new TextRenderable(renderer, {
69
+ id: "progress-bar-empty",
70
+ content: "░".repeat(Math.max(0, emptyWidth)),
71
+ fg: THEME.border,
72
+ })
73
+
74
+ this.barContainer.add(this.barFilled)
75
+ this.barContainer.add(this.barEmpty)
76
+
77
+ this.container.add(this.labelText)
78
+ this.container.add(this.barContainer)
79
+ }
80
+
81
+ private getBarColor(level: "normal" | "warning" | "critical"): string {
82
+ switch (level) {
83
+ case "critical":
84
+ return THEME.red
85
+ case "warning":
86
+ return THEME.yellow
87
+ default:
88
+ return THEME.blue
89
+ }
90
+ }
91
+
92
+ public update(used: number, quota: number, width: number = 50) {
93
+ const percent = quota > 0 ? (used / quota) * 100 : 0
94
+ const level = getUsageLevel(percent)
95
+ const barColor = this.getBarColor(level)
96
+
97
+ this.labelText.content = `Premium Requests: ${formatNumber(used)} / ${formatNumber(quota)} (${formatPercent(percent)})`
98
+
99
+ const filledWidth = Math.round((percent / 100) * width)
100
+ const emptyWidth = width - filledWidth
101
+
102
+ this.barFilled.content = "█".repeat(Math.max(0, filledWidth))
103
+ this.barFilled.fg = barColor
104
+ this.barEmpty.content = "░".repeat(Math.max(0, emptyWidth))
105
+ }
106
+
107
+ public getContainer(): BoxRenderable {
108
+ return this.container
109
+ }
110
+
111
+ public destroy() {
112
+ this.container.destroyRecursively()
113
+ }
114
+ }
@@ -0,0 +1,136 @@
1
+ import {
2
+ BoxRenderable,
3
+ TextRenderable,
4
+ type CliRenderer,
5
+ } from "@opentui/core"
6
+ import { THEME, type UsageItem } from "../../types.ts"
7
+ import { formatCurrency, formatNumber, padLeft, padRight, truncate } from "../../utils/format.ts"
8
+
9
+ export interface TableOptions {
10
+ items: UsageItem[]
11
+ maxRows?: number
12
+ }
13
+
14
+ export class TableComponent {
15
+ private renderer: CliRenderer
16
+ private container: BoxRenderable
17
+
18
+ // Column widths
19
+ private readonly COL_MODEL = 22
20
+ private readonly COL_REQUESTS = 10
21
+ private readonly COL_COST = 12
22
+
23
+ constructor(renderer: CliRenderer, options: TableOptions) {
24
+ this.renderer = renderer
25
+ const { items, maxRows = 10 } = options
26
+
27
+ this.container = new BoxRenderable(renderer, {
28
+ id: "table-container",
29
+ flexDirection: "column",
30
+ width: "100%",
31
+ })
32
+
33
+ this.createTable(items, maxRows)
34
+ }
35
+
36
+ private createTable(items: UsageItem[], maxRows: number) {
37
+ // Header
38
+ const headerRow = new BoxRenderable(this.renderer, {
39
+ id: "table-header",
40
+ flexDirection: "row",
41
+ })
42
+
43
+ const headerModel = new TextRenderable(this.renderer, {
44
+ id: "header-model",
45
+ content: padRight("Model", this.COL_MODEL),
46
+ fg: THEME.fgMuted,
47
+ })
48
+
49
+ const headerRequests = new TextRenderable(this.renderer, {
50
+ id: "header-requests",
51
+ content: padLeft("Requests", this.COL_REQUESTS),
52
+ fg: THEME.fgMuted,
53
+ })
54
+
55
+ const headerCost = new TextRenderable(this.renderer, {
56
+ id: "header-cost",
57
+ content: padLeft("Cost", this.COL_COST),
58
+ fg: THEME.fgMuted,
59
+ })
60
+
61
+ headerRow.add(headerModel)
62
+ headerRow.add(headerRequests)
63
+ headerRow.add(headerCost)
64
+ this.container.add(headerRow)
65
+
66
+ // Separator
67
+ const separator = new TextRenderable(this.renderer, {
68
+ id: "table-separator",
69
+ content: "─".repeat(this.COL_MODEL + this.COL_REQUESTS + this.COL_COST),
70
+ fg: THEME.border,
71
+ })
72
+ this.container.add(separator)
73
+
74
+ // Data rows
75
+ const displayItems = items.slice(0, maxRows)
76
+
77
+ for (let i = 0; i < displayItems.length; i++) {
78
+ const item = displayItems[i]
79
+ const row = this.createRow(item, i)
80
+ this.container.add(row)
81
+ }
82
+
83
+ // Show "and X more..." if truncated
84
+ if (items.length > maxRows) {
85
+ const moreText = new TextRenderable(this.renderer, {
86
+ id: "table-more",
87
+ content: ` ... and ${items.length - maxRows} more`,
88
+ fg: THEME.fgMuted,
89
+ })
90
+ this.container.add(moreText)
91
+ }
92
+ }
93
+
94
+ private createRow(item: UsageItem, index: number): BoxRenderable {
95
+ const row = new BoxRenderable(this.renderer, {
96
+ id: `table-row-${index}`,
97
+ flexDirection: "row",
98
+ })
99
+
100
+ // Model name (truncate if too long)
101
+ const modelName = truncate(item.model, this.COL_MODEL - 2)
102
+ const modelText = new TextRenderable(this.renderer, {
103
+ id: `row-${index}-model`,
104
+ content: padRight(modelName, this.COL_MODEL),
105
+ fg: THEME.fg,
106
+ })
107
+
108
+ // Request count
109
+ const requestsText = new TextRenderable(this.renderer, {
110
+ id: `row-${index}-requests`,
111
+ content: padLeft(formatNumber(item.grossQuantity), this.COL_REQUESTS),
112
+ fg: THEME.cyan,
113
+ })
114
+
115
+ // Cost
116
+ const costText = new TextRenderable(this.renderer, {
117
+ id: `row-${index}-cost`,
118
+ content: padLeft(formatCurrency(item.grossAmount), this.COL_COST),
119
+ fg: THEME.green,
120
+ })
121
+
122
+ row.add(modelText)
123
+ row.add(requestsText)
124
+ row.add(costText)
125
+
126
+ return row
127
+ }
128
+
129
+ public getContainer(): BoxRenderable {
130
+ return this.container
131
+ }
132
+
133
+ public destroy() {
134
+ this.container.destroyRecursively()
135
+ }
136
+ }
@@ -0,0 +1,107 @@
1
+ import {
2
+ BoxRenderable,
3
+ TextRenderable,
4
+ type CliRenderer,
5
+ } from "@opentui/core"
6
+ import { THEME, type AuthStatus } from "../../types.ts"
7
+ import { getAuthInstructions } from "../../api/auth.ts"
8
+
9
+ export interface AuthScreenOptions {
10
+ authStatus: AuthStatus
11
+ onRetry: () => void
12
+ }
13
+
14
+ export class AuthScreen {
15
+ private renderer: CliRenderer
16
+ private container: BoxRenderable
17
+
18
+ constructor(renderer: CliRenderer, options: AuthScreenOptions) {
19
+ this.renderer = renderer
20
+
21
+ // Main container
22
+ this.container = new BoxRenderable(renderer, {
23
+ id: "auth-screen",
24
+ width: "100%",
25
+ height: "100%",
26
+ flexDirection: "column",
27
+ justifyContent: "center",
28
+ alignItems: "center",
29
+ backgroundColor: THEME.bg,
30
+ })
31
+
32
+ this.createUI(options.authStatus)
33
+ renderer.root.add(this.container)
34
+ }
35
+
36
+ private createUI(authStatus: AuthStatus) {
37
+ // Title box
38
+ const titleBox = new BoxRenderable(this.renderer, {
39
+ id: "auth-title-box",
40
+ width: 60,
41
+ flexDirection: "column",
42
+ alignItems: "center",
43
+ padding: 1,
44
+ border: ["top", "left", "right"],
45
+ borderStyle: "rounded",
46
+ borderColor: THEME.yellow,
47
+ backgroundColor: THEME.bgDark,
48
+ })
49
+
50
+ const title = new TextRenderable(this.renderer, {
51
+ id: "auth-title",
52
+ content: "Authentication Required",
53
+ fg: THEME.yellow,
54
+ })
55
+
56
+ titleBox.add(title)
57
+
58
+ // Content box
59
+ const contentBox = new BoxRenderable(this.renderer, {
60
+ id: "auth-content-box",
61
+ width: 60,
62
+ flexDirection: "column",
63
+ padding: 2,
64
+ border: ["bottom", "left", "right"],
65
+ borderStyle: "rounded",
66
+ borderColor: THEME.border,
67
+ backgroundColor: THEME.bgDark,
68
+ })
69
+
70
+ // Get instructions based on auth status
71
+ const instructions = getAuthInstructions(authStatus)
72
+
73
+ for (let i = 0; i < instructions.length; i++) {
74
+ const line = instructions[i]
75
+ const isCommand = line.startsWith(" ")
76
+
77
+ const text = new TextRenderable(this.renderer, {
78
+ id: `auth-instruction-${i}`,
79
+ content: line || " ",
80
+ fg: isCommand ? THEME.cyan : THEME.fg,
81
+ })
82
+ contentBox.add(text)
83
+ }
84
+
85
+ // Spacer
86
+ const spacer = new BoxRenderable(this.renderer, {
87
+ id: "auth-spacer",
88
+ height: 2,
89
+ })
90
+ contentBox.add(spacer)
91
+
92
+ // Keybindings hint
93
+ const hint = new TextRenderable(this.renderer, {
94
+ id: "auth-hint",
95
+ content: "[r] Retry [q] Quit",
96
+ fg: THEME.fgMuted,
97
+ })
98
+ contentBox.add(hint)
99
+
100
+ this.container.add(titleBox)
101
+ this.container.add(contentBox)
102
+ }
103
+
104
+ public destroy() {
105
+ this.container.destroyRecursively()
106
+ }
107
+ }