@cortexkit/opencode-anthropic-auth 2.2.1 → 2.2.3
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/dist/cli-failure.d.ts +2 -1
- package/dist/cli.js +47 -47
- package/dist/fable-fallback.d.ts +8 -6
- package/dist/index-20d6rmjj.js +2 -0
- package/dist/index-79frmfve.js +43 -0
- package/dist/index-jvhs8sxv.js +2 -0
- package/dist/index.js +1 -1
- package/dist/rpc/notifications.d.ts +22 -1
- package/dist/sidebar-state.d.ts +7 -10
- package/dist/sidebar-state.js +1 -1
- package/dist/tui/sign-in-link.d.ts +50 -0
- package/dist/v2/rpc.d.ts +2 -2
- package/dist/v2/server.js +4 -4
- package/dist/v2/tui.d.ts +7 -0
- package/dist/v2/tui.js +5 -2
- package/package.json +3 -3
- package/src/rpc/notifications.ts +90 -4
- package/src/sidebar-state.ts +19 -27
- package/src/tui/command-dialogs.tsx +74 -40
- package/src/tui/entry.mjs +13 -0
- package/src/tui/sign-in-link.tsx +109 -0
- package/src/tui-compiled/sidebar-state.ts +19 -27
- package/src/tui-compiled/tui/command-dialogs.tsx +152 -111
- package/src/tui-compiled/tui/sign-in-link.tsx +105 -0
- package/dist/index-88zg9ztr.js +0 -2
- package/dist/index-8ysjtqek.js +0 -42
- package/dist/index-xbwa4x53.js +0 -2
package/src/rpc/notifications.ts
CHANGED
|
@@ -11,6 +11,25 @@ const TUI_CONNECTED_WINDOW_MS = 3_000
|
|
|
11
11
|
let queue: RpcNotification[] = []
|
|
12
12
|
let nextId = 1
|
|
13
13
|
const lastDrainAtBySession = new Map<string, number>()
|
|
14
|
+
// Callbacks waiting for a TUI to drain a specific notification, keyed by
|
|
15
|
+
// notification id. See pushNotificationAwaitingPickup.
|
|
16
|
+
const pickupWaiters = new Map<
|
|
17
|
+
number,
|
|
18
|
+
{ readonly picked: () => void; readonly cancel: () => void }
|
|
19
|
+
>()
|
|
20
|
+
// Sessions whose last pickup wait ended with no TUI taking the dialog, in
|
|
21
|
+
// insertion order so the oldest can be dropped. A drain for the session
|
|
22
|
+
// removes it: a TUI is showing that session again.
|
|
23
|
+
const missedPickupSessions = new Set<string>()
|
|
24
|
+
const MISSED_PICKUP_CAP = 1_000
|
|
25
|
+
|
|
26
|
+
export interface PickupWaitOptions {
|
|
27
|
+
/** How long the server waits for a TUI to drain the notification. */
|
|
28
|
+
readonly timeoutMs: number
|
|
29
|
+
/** Injected so tests can drive the deadline without real time passing. */
|
|
30
|
+
readonly setTimeout: typeof globalThis.setTimeout
|
|
31
|
+
readonly clearTimeout: typeof globalThis.clearTimeout
|
|
32
|
+
}
|
|
14
33
|
|
|
15
34
|
function assertSessionId(sessionId: string): void {
|
|
16
35
|
if (typeof sessionId !== 'string' || sessionId.length === 0) {
|
|
@@ -18,19 +37,73 @@ function assertSessionId(sessionId: string): void {
|
|
|
18
37
|
}
|
|
19
38
|
}
|
|
20
39
|
|
|
40
|
+
/** Queue a dialog for the session's TUI and return the notification id. */
|
|
21
41
|
export function pushNotification(
|
|
22
42
|
payload: OpenDialogPayload | CommandDialogPayload,
|
|
23
43
|
sessionId: string,
|
|
24
|
-
):
|
|
44
|
+
): number {
|
|
25
45
|
assertSessionId(sessionId)
|
|
46
|
+
const id = nextId++
|
|
26
47
|
if ('menu' in payload) {
|
|
27
48
|
if (payload.command !== 'claude' || payload.menu.command !== 'claude')
|
|
28
49
|
throw new TypeError('Only the claude menu is supported')
|
|
29
|
-
queue.push({ id
|
|
50
|
+
queue.push({ id, type: 'open-menu', payload, sessionId })
|
|
30
51
|
} else {
|
|
31
|
-
queue.push({ id
|
|
52
|
+
queue.push({ id, type: 'open-dialog', payload, sessionId })
|
|
32
53
|
}
|
|
33
54
|
if (queue.length > QUEUE_CAP) queue = queue.slice(queue.length - QUEUE_CAP)
|
|
55
|
+
return id
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Queue a dialog and wait for a TUI to drain it. Resolves true as soon as a
|
|
60
|
+
* drain for the session hands the notification out, which only a running TUI
|
|
61
|
+
* does. Resolves false when the deadline passes first; the notification is
|
|
62
|
+
* then removed, so a TUI that starts later does not open a stale dialog after
|
|
63
|
+
* the caller has already answered some other way.
|
|
64
|
+
*/
|
|
65
|
+
export function pushNotificationAwaitingPickup(
|
|
66
|
+
payload: OpenDialogPayload | CommandDialogPayload,
|
|
67
|
+
sessionId: string,
|
|
68
|
+
options: PickupWaitOptions,
|
|
69
|
+
): Promise<boolean> {
|
|
70
|
+
// The push and the waiter registration happen in the same synchronous
|
|
71
|
+
// step, so no drain can hand the notification out before it is watched.
|
|
72
|
+
const id = pushNotification(payload, sessionId)
|
|
73
|
+
return new Promise((resolve) => {
|
|
74
|
+
let timer: ReturnType<typeof setTimeout> | undefined
|
|
75
|
+
pickupWaiters.set(id, {
|
|
76
|
+
picked() {
|
|
77
|
+
if (timer !== undefined) options.clearTimeout(timer)
|
|
78
|
+
resolve(true)
|
|
79
|
+
},
|
|
80
|
+
cancel() {
|
|
81
|
+
if (timer !== undefined) options.clearTimeout(timer)
|
|
82
|
+
resolve(false)
|
|
83
|
+
},
|
|
84
|
+
})
|
|
85
|
+
// Started after the waiter is registered, so a timer that fires at once
|
|
86
|
+
// still finds and removes it.
|
|
87
|
+
timer = options.setTimeout(() => {
|
|
88
|
+
if (!pickupWaiters.delete(id)) return
|
|
89
|
+
queue = queue.filter((notification) => notification.id !== id)
|
|
90
|
+
missedPickupSessions.delete(sessionId)
|
|
91
|
+
missedPickupSessions.add(sessionId)
|
|
92
|
+
if (missedPickupSessions.size > MISSED_PICKUP_CAP) {
|
|
93
|
+
const oldest = missedPickupSessions.values().next().value
|
|
94
|
+
if (oldest !== undefined) missedPickupSessions.delete(oldest)
|
|
95
|
+
}
|
|
96
|
+
resolve(false)
|
|
97
|
+
}, options.timeoutMs)
|
|
98
|
+
})
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* True when the session's last pickup wait found no TUI and no TUI has
|
|
103
|
+
* polled that session since, so a caller can answer at once without waiting.
|
|
104
|
+
*/
|
|
105
|
+
export function missedTuiPickup(sessionId: string): boolean {
|
|
106
|
+
return missedPickupSessions.has(sessionId)
|
|
34
107
|
}
|
|
35
108
|
|
|
36
109
|
export function drainNotifications(
|
|
@@ -39,6 +112,7 @@ export function drainNotifications(
|
|
|
39
112
|
): RpcNotification[] {
|
|
40
113
|
assertSessionId(sessionId)
|
|
41
114
|
lastDrainAtBySession.set(sessionId, Date.now())
|
|
115
|
+
missedPickupSessions.delete(sessionId)
|
|
42
116
|
if (lastReceivedId > 0) {
|
|
43
117
|
queue = queue.filter(
|
|
44
118
|
(notification) =>
|
|
@@ -46,10 +120,17 @@ export function drainNotifications(
|
|
|
46
120
|
notification.sessionId !== sessionId,
|
|
47
121
|
)
|
|
48
122
|
}
|
|
49
|
-
|
|
123
|
+
const pending = queue.filter(
|
|
50
124
|
(notification) =>
|
|
51
125
|
notification.id > lastReceivedId && notification.sessionId === sessionId,
|
|
52
126
|
)
|
|
127
|
+
for (const notification of pending) {
|
|
128
|
+
const waiter = pickupWaiters.get(notification.id)
|
|
129
|
+
if (!waiter) continue
|
|
130
|
+
pickupWaiters.delete(notification.id)
|
|
131
|
+
waiter.picked()
|
|
132
|
+
}
|
|
133
|
+
return pending
|
|
53
134
|
}
|
|
54
135
|
|
|
55
136
|
export function isTuiConnected(sessionId: string): boolean {
|
|
@@ -62,4 +143,9 @@ export function resetNotificationsForTest(): void {
|
|
|
62
143
|
queue = []
|
|
63
144
|
nextId = 1
|
|
64
145
|
lastDrainAtBySession.clear()
|
|
146
|
+
missedPickupSessions.clear()
|
|
147
|
+
// Cancel outstanding deadlines too: ids restart at 1, so a late deadline
|
|
148
|
+
// would otherwise withdraw a newer notification that reuses its id.
|
|
149
|
+
for (const waiter of pickupWaiters.values()) waiter.cancel()
|
|
150
|
+
pickupWaiters.clear()
|
|
65
151
|
}
|
package/src/sidebar-state.ts
CHANGED
|
@@ -132,7 +132,7 @@ import {
|
|
|
132
132
|
isQuotaStale,
|
|
133
133
|
logger,
|
|
134
134
|
quotaDisplayProjection,
|
|
135
|
-
|
|
135
|
+
quotaWindowParts,
|
|
136
136
|
} from '@cortexkit/anthropic-auth-core'
|
|
137
137
|
|
|
138
138
|
const STATE_FILE_ENV = 'OPENCODE_ANTHROPIC_AUTH_SIDEBAR_STATE_FILE'
|
|
@@ -954,38 +954,30 @@ export function formatQuotaRowLabel(name: string, width = 10): string {
|
|
|
954
954
|
|
|
955
955
|
/**
|
|
956
956
|
* The short names model-scoped rows read under, in the order of `quota.scoped`:
|
|
957
|
-
* the
|
|
958
|
-
* models whose short names would collide are
|
|
959
|
-
* `Fabl` and `Fabr`), so the expanded rows and
|
|
960
|
-
*
|
|
961
|
-
*
|
|
962
|
-
*
|
|
963
|
-
*
|
|
964
|
-
* window alone is spent: every model window stays in, so collisions resolve
|
|
965
|
-
* exactly as they do in the real line, and the one piece the line shows is
|
|
966
|
-
* `<short name> 0% left`. The figures in the copy are never displayed.
|
|
957
|
+
* each window part's `short`, the same label common-auth's narrow one-line
|
|
958
|
+
* form uses (`Fa` for Fable; models whose short names would collide are
|
|
959
|
+
* extended until they differ, as `Fabl` and `Fabr`), so the expanded rows and
|
|
960
|
+
* the collapsed line agree. Short names are worked out over every model window
|
|
961
|
+
* in the reading, so all scoped windows are projected together. A part is
|
|
962
|
+
* matched to its window by the window's title, which is the name a
|
|
963
|
+
* model-scoped part carries; a window with no part keeps its title.
|
|
967
964
|
*/
|
|
968
965
|
export function scopedQuotaShortNames(
|
|
969
966
|
quota: Pick<AccountQuota, 'scoped'> | null | undefined,
|
|
970
967
|
now = Date.now(),
|
|
971
968
|
): string[] {
|
|
972
969
|
const scoped = quota?.scoped ?? []
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
)
|
|
985
|
-
const piece = quotaTextParts(probe, { now, form: 'narrow' })[0]
|
|
986
|
-
const name = piece?.replace(/ 0% left$/, '')
|
|
987
|
-
return name && name !== piece ? name : window.title
|
|
988
|
-
})
|
|
970
|
+
if (!scoped.length) return []
|
|
971
|
+
const { windows } = quotaWindowParts(
|
|
972
|
+
quotaDisplayProjection({ scoped }, now),
|
|
973
|
+
{ now },
|
|
974
|
+
)
|
|
975
|
+
return scoped.map(
|
|
976
|
+
(window) =>
|
|
977
|
+
windows.find(
|
|
978
|
+
(part) => part.kind === 'window' && part.name === window.title,
|
|
979
|
+
)?.short || window.title,
|
|
980
|
+
)
|
|
989
981
|
}
|
|
990
982
|
|
|
991
983
|
/**
|
|
@@ -9,6 +9,7 @@ import type {
|
|
|
9
9
|
MenuAction,
|
|
10
10
|
MenuItem,
|
|
11
11
|
MenuSection,
|
|
12
|
+
NativeCommandApplyResult,
|
|
12
13
|
} from '@cortexkit/anthropic-auth-core'
|
|
13
14
|
import {
|
|
14
15
|
custodyStatusLabel,
|
|
@@ -18,6 +19,7 @@ import type { TuiPluginApi } from '@opencode-ai/plugin/tui'
|
|
|
18
19
|
import type { AccountDialogAccount } from '../rpc/protocol'
|
|
19
20
|
import type { OpenDialogPayload } from '../rpc/protocol.js'
|
|
20
21
|
import { formatPrimeCost, formatPrimeTime } from '../sidebar-state.js'
|
|
22
|
+
import { SIGN_IN_LINK_TITLE, SignInLink } from './sign-in-link.js'
|
|
21
23
|
|
|
22
24
|
/** Render menu sections, accounts and action inputs; send changes to the host instead of writing credentials here. */
|
|
23
25
|
export function openNativeMenuDialog(
|
|
@@ -128,6 +130,24 @@ export function openNativeMenuDialog(
|
|
|
128
130
|
))
|
|
129
131
|
}
|
|
130
132
|
|
|
133
|
+
/** The sign-in link stays on screen until the person goes back or closes it. */
|
|
134
|
+
const showSignIn = (url: string, back: () => void) => {
|
|
135
|
+
if (!active()) return
|
|
136
|
+
api.ui.dialog.setSize('xlarge')
|
|
137
|
+
api.ui.dialog.replace(() => (
|
|
138
|
+
<box flexDirection='column' paddingTop={1}>
|
|
139
|
+
<SignInLink url={url} title={SIGN_IN_LINK_TITLE} />
|
|
140
|
+
<DialogSelect
|
|
141
|
+
title='Continue'
|
|
142
|
+
options={[{ title: 'Back', value: '__back' }]}
|
|
143
|
+
onSelect={() => {
|
|
144
|
+
if (active()) back()
|
|
145
|
+
}}
|
|
146
|
+
/>
|
|
147
|
+
</box>
|
|
148
|
+
))
|
|
149
|
+
}
|
|
150
|
+
|
|
131
151
|
const collect = (
|
|
132
152
|
section: MenuSection,
|
|
133
153
|
action: MenuAction,
|
|
@@ -142,8 +162,12 @@ export function openNativeMenuDialog(
|
|
|
142
162
|
const submit = async () => {
|
|
143
163
|
if (!active()) return
|
|
144
164
|
inFlight = true
|
|
165
|
+
// Set after a successful OAuth start: its link is shown in a lasting
|
|
166
|
+
// dialog instead of a toast, which would vanish before it could be
|
|
167
|
+
// copied and sit behind the menu.
|
|
168
|
+
let signInUrl: string | undefined
|
|
145
169
|
try {
|
|
146
|
-
const result = await apply({
|
|
170
|
+
const result: NativeCommandApplyResult = await apply({
|
|
147
171
|
command: menu.command,
|
|
148
172
|
sectionId: section.id,
|
|
149
173
|
actionId: action.id,
|
|
@@ -153,7 +177,9 @@ export function openNativeMenuDialog(
|
|
|
153
177
|
})
|
|
154
178
|
if (!isCurrentSession()) return
|
|
155
179
|
menu = result.menu
|
|
156
|
-
|
|
180
|
+
if (typeof result.authorizationUrl === 'string')
|
|
181
|
+
signInUrl = result.authorizationUrl
|
|
182
|
+
else api.ui.toast({ message: result.text })
|
|
157
183
|
} catch {
|
|
158
184
|
if (isCurrentSession())
|
|
159
185
|
api.ui.toast({ message: 'Menu apply unavailable; outcome unknown.' })
|
|
@@ -164,8 +190,9 @@ export function openNativeMenuDialog(
|
|
|
164
190
|
const refreshed = menu.sections.find(
|
|
165
191
|
(entry) => entry.id === section.id,
|
|
166
192
|
)
|
|
167
|
-
|
|
168
|
-
|
|
193
|
+
const back = () => (refreshed ? showSection(refreshed) : root())
|
|
194
|
+
if (signInUrl !== undefined) showSignIn(signInUrl, back)
|
|
195
|
+
else back()
|
|
169
196
|
}
|
|
170
197
|
}
|
|
171
198
|
}
|
|
@@ -1111,43 +1138,50 @@ export function openCommandDialog(
|
|
|
1111
1138
|
const DialogSelect = api.ui.DialogSelect<string>
|
|
1112
1139
|
api.ui.dialog.setSize('xlarge')
|
|
1113
1140
|
api.ui.dialog.replace(() => (
|
|
1114
|
-
<
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
if (
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
api.ui.toast({
|
|
1141
|
-
message:
|
|
1142
|
-
'Copy unavailable \u2014 select the URL text above to copy',
|
|
1143
|
-
})
|
|
1141
|
+
<box flexDirection='column' paddingTop={1}>
|
|
1142
|
+
{/* The link sits above the options on rows of its own, so it can
|
|
1143
|
+
be selected and copied whole when the copy option fails. */}
|
|
1144
|
+
<SignInLink
|
|
1145
|
+
url={oauthUrl}
|
|
1146
|
+
instructions='Open this link in your browser and sign in, then choose Enter sign-in code and paste the callback URL or code.'
|
|
1147
|
+
/>
|
|
1148
|
+
<DialogSelect
|
|
1149
|
+
title='OAuth sign-in'
|
|
1150
|
+
options={[
|
|
1151
|
+
{
|
|
1152
|
+
title: 'Copy URL to clipboard',
|
|
1153
|
+
value: 'copy',
|
|
1154
|
+
},
|
|
1155
|
+
{
|
|
1156
|
+
title: 'Enter sign-in code',
|
|
1157
|
+
value: 'code',
|
|
1158
|
+
description:
|
|
1159
|
+
'Open the URL in your browser, sign in, then paste the callback URL or code',
|
|
1160
|
+
},
|
|
1161
|
+
{ title: 'Cancel', value: 'cancel' },
|
|
1162
|
+
]}
|
|
1163
|
+
onSelect={(option) => {
|
|
1164
|
+
if (option.value === 'cancel') {
|
|
1165
|
+
buildL1()
|
|
1166
|
+
return
|
|
1144
1167
|
}
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1168
|
+
if (option.value === 'copy') {
|
|
1169
|
+
const ok = api.renderer.copyToClipboardOSC52(oauthUrl)
|
|
1170
|
+
if (ok) {
|
|
1171
|
+
api.ui.toast({ message: 'URL copied to clipboard' })
|
|
1172
|
+
} else {
|
|
1173
|
+
api.ui.toast({
|
|
1174
|
+
message:
|
|
1175
|
+
'Copy unavailable \u2014 select the URL text above to copy',
|
|
1176
|
+
})
|
|
1177
|
+
}
|
|
1178
|
+
openOAuthUrlScreen(oauthUrl)
|
|
1179
|
+
return
|
|
1180
|
+
}
|
|
1181
|
+
openOAuthCodePrompt(oauthUrl)
|
|
1182
|
+
}}
|
|
1183
|
+
/>
|
|
1184
|
+
</box>
|
|
1151
1185
|
))
|
|
1152
1186
|
}
|
|
1153
1187
|
|
package/src/tui/entry.mjs
CHANGED
|
@@ -48,6 +48,17 @@ async function loadSidebar() {
|
|
|
48
48
|
return import('../tui-compiled/tui/claude-sidebar.tsx')
|
|
49
49
|
}
|
|
50
50
|
|
|
51
|
+
// The OAuth sign-in link dialog, loaded the same way as the sidebar.
|
|
52
|
+
async function loadSignInLink() {
|
|
53
|
+
try {
|
|
54
|
+
await import(runtimeProbe)
|
|
55
|
+
} catch (error) {
|
|
56
|
+
if (!isMissingRuntimeRegistry(error)) throw error
|
|
57
|
+
return import('./sign-in-link.tsx')
|
|
58
|
+
}
|
|
59
|
+
return import('../tui-compiled/tui/sign-in-link.tsx')
|
|
60
|
+
}
|
|
61
|
+
|
|
51
62
|
let legacyModule
|
|
52
63
|
|
|
53
64
|
// OpenCode 1 reads id and tui from the default definition; OpenCode 2 reads
|
|
@@ -71,6 +82,8 @@ export const setup = async (context) => {
|
|
|
71
82
|
return setupAnthropicTui(context, {
|
|
72
83
|
mountSidebar: async (ctx, read) =>
|
|
73
84
|
(await loadSidebar()).mountClaudeSidebar(ctx, read),
|
|
85
|
+
showSignInLink: async (ctx, url, signal) =>
|
|
86
|
+
(await loadSignInLink()).showSignInLinkDialog(ctx, url, signal),
|
|
74
87
|
})
|
|
75
88
|
}
|
|
76
89
|
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/** @jsxImportSource @opentui/solid */
|
|
2
|
+
|
|
3
|
+
// The OAuth sign-in link dialog, shared by the OpenCode 1 and OpenCode 2 TUIs.
|
|
4
|
+
// The link is drawn as its own text block with nothing beside it, so however
|
|
5
|
+
// narrow the dialog is, the rows it wraps onto hold only link characters and
|
|
6
|
+
// selecting those rows copies the whole link. The dialog stays open until the
|
|
7
|
+
// person closes it.
|
|
8
|
+
|
|
9
|
+
import type { RGBA } from '@opentui/core'
|
|
10
|
+
import type { JSX } from 'solid-js'
|
|
11
|
+
|
|
12
|
+
export type SignInLinkColor = RGBA | string
|
|
13
|
+
|
|
14
|
+
/** Optional text colors; without them the host's default text color is used. */
|
|
15
|
+
export interface SignInLinkColors {
|
|
16
|
+
readonly text?: SignInLinkColor
|
|
17
|
+
readonly muted?: SignInLinkColor
|
|
18
|
+
readonly link?: SignInLinkColor
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export const SIGN_IN_LINK_TITLE = 'Claude sign-in'
|
|
22
|
+
export const SIGN_IN_LINK_INSTRUCTIONS =
|
|
23
|
+
'Open this link in your browser and sign in. Then choose Finish OAuth sign-in in the Accounts menu and paste the code Claude shows you.'
|
|
24
|
+
|
|
25
|
+
/** The sign-in instructions and the link, without any dialog chrome. */
|
|
26
|
+
export function SignInLink(props: {
|
|
27
|
+
readonly url: string
|
|
28
|
+
readonly colors?: SignInLinkColors
|
|
29
|
+
/** A heading above the instructions; hosts whose dialog has one omit it. */
|
|
30
|
+
readonly title?: string
|
|
31
|
+
/** What to do with the link; defaults to the native menu's next step. */
|
|
32
|
+
readonly instructions?: string
|
|
33
|
+
/** A line under the link, such as how to close the dialog. */
|
|
34
|
+
readonly footer?: string
|
|
35
|
+
}): JSX.Element {
|
|
36
|
+
return (
|
|
37
|
+
<box flexDirection='column' paddingLeft={2} paddingRight={2}>
|
|
38
|
+
{props.title ? (
|
|
39
|
+
<box paddingBottom={1}>
|
|
40
|
+
<text fg={props.colors?.text}>{props.title}</text>
|
|
41
|
+
</box>
|
|
42
|
+
) : null}
|
|
43
|
+
<text fg={props.colors?.muted}>
|
|
44
|
+
{props.instructions ?? SIGN_IN_LINK_INSTRUCTIONS}
|
|
45
|
+
</text>
|
|
46
|
+
<box paddingTop={1} paddingBottom={1}>
|
|
47
|
+
{/* Character wrapping fills every row: a link has no spaces to
|
|
48
|
+
break at, and a word break would leave a short row. */}
|
|
49
|
+
<text fg={props.colors?.link} wrapMode='char'>
|
|
50
|
+
{props.url}
|
|
51
|
+
</text>
|
|
52
|
+
</box>
|
|
53
|
+
{props.footer ? (
|
|
54
|
+
<text fg={props.colors?.muted}>{props.footer}</text>
|
|
55
|
+
) : null}
|
|
56
|
+
</box>
|
|
57
|
+
)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// --- OpenCode 2 -------------------------------------------------------------
|
|
61
|
+
|
|
62
|
+
/** The OpenCode 2 TUI context members the sign-in dialog uses. */
|
|
63
|
+
export interface SignInLinkHost {
|
|
64
|
+
readonly theme: {
|
|
65
|
+
readonly text: {
|
|
66
|
+
readonly base: SignInLinkColor
|
|
67
|
+
readonly muted: SignInLinkColor
|
|
68
|
+
readonly action: { readonly primary: { readonly base: SignInLinkColor } }
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
readonly ui: {
|
|
72
|
+
readonly dialog: {
|
|
73
|
+
show(render: () => JSX.Element, onClose?: () => void): void
|
|
74
|
+
set(options: { readonly size?: 'medium' | 'large' | 'xlarge' }): void
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Shows the sign-in link in an OpenCode 2 dialog. The returned promise
|
|
81
|
+
* settles when the person closes the dialog, or when `signal` aborts.
|
|
82
|
+
*/
|
|
83
|
+
export function showSignInLinkDialog(
|
|
84
|
+
host: SignInLinkHost,
|
|
85
|
+
url: string,
|
|
86
|
+
signal?: AbortSignal,
|
|
87
|
+
): Promise<void> {
|
|
88
|
+
return new Promise((resolve) => {
|
|
89
|
+
if (signal?.aborted) return resolve()
|
|
90
|
+
signal?.addEventListener('abort', () => resolve(), { once: true })
|
|
91
|
+
host.ui.dialog.show(
|
|
92
|
+
() => (
|
|
93
|
+
<SignInLink
|
|
94
|
+
url={url}
|
|
95
|
+
title={SIGN_IN_LINK_TITLE}
|
|
96
|
+
colors={{
|
|
97
|
+
text: host.theme.text.base,
|
|
98
|
+
muted: host.theme.text.muted,
|
|
99
|
+
link: host.theme.text.action.primary.base,
|
|
100
|
+
}}
|
|
101
|
+
footer='Press Esc to close.'
|
|
102
|
+
/>
|
|
103
|
+
),
|
|
104
|
+
() => resolve(),
|
|
105
|
+
)
|
|
106
|
+
// The widest dialog wraps the link onto the fewest rows.
|
|
107
|
+
host.ui.dialog.set({ size: 'xlarge' })
|
|
108
|
+
})
|
|
109
|
+
}
|
|
@@ -132,7 +132,7 @@ import {
|
|
|
132
132
|
isQuotaStale,
|
|
133
133
|
logger,
|
|
134
134
|
quotaDisplayProjection,
|
|
135
|
-
|
|
135
|
+
quotaWindowParts,
|
|
136
136
|
} from '@cortexkit/anthropic-auth-core'
|
|
137
137
|
|
|
138
138
|
const STATE_FILE_ENV = 'OPENCODE_ANTHROPIC_AUTH_SIDEBAR_STATE_FILE'
|
|
@@ -954,38 +954,30 @@ export function formatQuotaRowLabel(name: string, width = 10): string {
|
|
|
954
954
|
|
|
955
955
|
/**
|
|
956
956
|
* The short names model-scoped rows read under, in the order of `quota.scoped`:
|
|
957
|
-
* the
|
|
958
|
-
* models whose short names would collide are
|
|
959
|
-
* `Fabl` and `Fabr`), so the expanded rows and
|
|
960
|
-
*
|
|
961
|
-
*
|
|
962
|
-
*
|
|
963
|
-
*
|
|
964
|
-
* window alone is spent: every model window stays in, so collisions resolve
|
|
965
|
-
* exactly as they do in the real line, and the one piece the line shows is
|
|
966
|
-
* `<short name> 0% left`. The figures in the copy are never displayed.
|
|
957
|
+
* each window part's `short`, the same label common-auth's narrow one-line
|
|
958
|
+
* form uses (`Fa` for Fable; models whose short names would collide are
|
|
959
|
+
* extended until they differ, as `Fabl` and `Fabr`), so the expanded rows and
|
|
960
|
+
* the collapsed line agree. Short names are worked out over every model window
|
|
961
|
+
* in the reading, so all scoped windows are projected together. A part is
|
|
962
|
+
* matched to its window by the window's title, which is the name a
|
|
963
|
+
* model-scoped part carries; a window with no part keeps its title.
|
|
967
964
|
*/
|
|
968
965
|
export function scopedQuotaShortNames(
|
|
969
966
|
quota: Pick<AccountQuota, 'scoped'> | null | undefined,
|
|
970
967
|
now = Date.now(),
|
|
971
968
|
): string[] {
|
|
972
969
|
const scoped = quota?.scoped ?? []
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
)
|
|
985
|
-
const piece = quotaTextParts(probe, { now, form: 'narrow' })[0]
|
|
986
|
-
const name = piece?.replace(/ 0% left$/, '')
|
|
987
|
-
return name && name !== piece ? name : window.title
|
|
988
|
-
})
|
|
970
|
+
if (!scoped.length) return []
|
|
971
|
+
const { windows } = quotaWindowParts(
|
|
972
|
+
quotaDisplayProjection({ scoped }, now),
|
|
973
|
+
{ now },
|
|
974
|
+
)
|
|
975
|
+
return scoped.map(
|
|
976
|
+
(window) =>
|
|
977
|
+
windows.find(
|
|
978
|
+
(part) => part.kind === 'window' && part.name === window.title,
|
|
979
|
+
)?.short || window.title,
|
|
980
|
+
)
|
|
989
981
|
}
|
|
990
982
|
|
|
991
983
|
/**
|