@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.
@@ -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
- ): void {
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: nextId++, type: 'open-menu', payload, sessionId })
50
+ queue.push({ id, type: 'open-menu', payload, sessionId })
30
51
  } else {
31
- queue.push({ id: nextId++, type: 'open-dialog', payload, sessionId })
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
- return queue.filter(
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
  }
@@ -132,7 +132,7 @@ import {
132
132
  isQuotaStale,
133
133
  logger,
134
134
  quotaDisplayProjection,
135
- quotaTextParts,
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 names common-auth's narrow one-line form gives them (`Fa` for Fable;
958
- * models whose short names would collide are extended until they differ, as
959
- * `Fabl` and `Fabr`), so the expanded rows and the collapsed line agree.
960
- *
961
- * common-auth exposes that short name only inside the narrow text, and the
962
- * narrow line shows just one model window (the tightest). So each window's
963
- * name is read from a narrow line of a copy of the reading in which that
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
- return scoped.map((window, index) => {
974
- const probe = quotaDisplayProjection(
975
- {
976
- scoped: scoped.map((other, at) => ({
977
- ...other,
978
- usedPercent: at === index ? 100 : 0,
979
- remainingPercent: at === index ? 0 : 100,
980
- checkedAt: now,
981
- })),
982
- },
983
- now,
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
- api.ui.toast({ message: result.text })
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
- if (refreshed) showSection(refreshed)
168
- else root()
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
- <DialogSelect
1115
- title='OAuth sign-in'
1116
- options={[
1117
- {
1118
- title: 'Copy URL to clipboard',
1119
- value: 'copy',
1120
- description: oauthUrl,
1121
- },
1122
- {
1123
- title: 'Enter sign-in code',
1124
- value: 'code',
1125
- description:
1126
- 'Open the URL in your browser, sign in, then paste the callback URL or code',
1127
- },
1128
- { title: 'Cancel', value: 'cancel' },
1129
- ]}
1130
- onSelect={(option) => {
1131
- if (option.value === 'cancel') {
1132
- buildL1()
1133
- return
1134
- }
1135
- if (option.value === 'copy') {
1136
- const ok = api.renderer.copyToClipboardOSC52(oauthUrl)
1137
- if (ok) {
1138
- api.ui.toast({ message: 'URL copied to clipboard' })
1139
- } else {
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
- openOAuthUrlScreen(oauthUrl)
1146
- return
1147
- }
1148
- openOAuthCodePrompt(oauthUrl)
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
- quotaTextParts,
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 names common-auth's narrow one-line form gives them (`Fa` for Fable;
958
- * models whose short names would collide are extended until they differ, as
959
- * `Fabl` and `Fabr`), so the expanded rows and the collapsed line agree.
960
- *
961
- * common-auth exposes that short name only inside the narrow text, and the
962
- * narrow line shows just one model window (the tightest). So each window's
963
- * name is read from a narrow line of a copy of the reading in which that
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
- return scoped.map((window, index) => {
974
- const probe = quotaDisplayProjection(
975
- {
976
- scoped: scoped.map((other, at) => ({
977
- ...other,
978
- usedPercent: at === index ? 100 : 0,
979
- remainingPercent: at === index ? 0 : 100,
980
- checkedAt: now,
981
- })),
982
- },
983
- now,
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
  /**