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/LICENSE +21 -0
- package/README.md +83 -0
- package/package.json +27 -0
- package/src/api/auth.ts +148 -0
- package/src/api/github.ts +92 -0
- package/src/config/config.ts +70 -0
- package/src/index.ts +13 -0
- package/src/types.ts +90 -0
- package/src/ui/app.ts +215 -0
- package/src/ui/components/chart.ts +141 -0
- package/src/ui/components/progressBar.ts +114 -0
- package/src/ui/components/table.ts +136 -0
- package/src/ui/screens/auth.ts +107 -0
- package/src/ui/screens/dashboard.ts +275 -0
- package/src/ui/screens/setup.ts +188 -0
- package/src/utils/format.ts +61 -0
- package/src/utils/prediction.ts +62 -0
- package/tsconfig.json +19 -0
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
|
+
}
|