@cortexkit/opencode-antigravity-auth 2.0.0 → 2.0.1

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.
Files changed (73) hide show
  1. package/dist/cli.js +308 -81
  2. package/dist/cli.js.map +4 -4
  3. package/dist/index.js +16634 -15416
  4. package/dist/index.js.map +4 -4
  5. package/dist/src/plugin/account-command-oauth.d.ts +34 -0
  6. package/dist/src/plugin/account-command-oauth.d.ts.map +1 -0
  7. package/dist/src/plugin/account-command-oauth.js +156 -0
  8. package/dist/src/plugin/account-command-oauth.js.map +1 -0
  9. package/dist/src/plugin/auth-loader.d.ts +16 -1
  10. package/dist/src/plugin/auth-loader.d.ts.map +1 -1
  11. package/dist/src/plugin/auth-loader.js +113 -32
  12. package/dist/src/plugin/auth-loader.js.map +1 -1
  13. package/dist/src/plugin/background-quota-refresh.d.ts +146 -0
  14. package/dist/src/plugin/background-quota-refresh.d.ts.map +1 -0
  15. package/dist/src/plugin/background-quota-refresh.js +347 -0
  16. package/dist/src/plugin/background-quota-refresh.js.map +1 -0
  17. package/dist/src/plugin/command-data.d.ts +61 -8
  18. package/dist/src/plugin/command-data.d.ts.map +1 -1
  19. package/dist/src/plugin/command-data.js +231 -39
  20. package/dist/src/plugin/command-data.js.map +1 -1
  21. package/dist/src/plugin/commands.d.ts +12 -9
  22. package/dist/src/plugin/commands.d.ts.map +1 -1
  23. package/dist/src/plugin/commands.js +140 -33
  24. package/dist/src/plugin/commands.js.map +1 -1
  25. package/dist/src/plugin/config/schema.d.ts +2 -0
  26. package/dist/src/plugin/config/schema.d.ts.map +1 -1
  27. package/dist/src/plugin/config/schema.js +24 -0
  28. package/dist/src/plugin/config/schema.js.map +1 -1
  29. package/dist/src/plugin/debug.d.ts +7 -0
  30. package/dist/src/plugin/debug.d.ts.map +1 -1
  31. package/dist/src/plugin/debug.js +25 -0
  32. package/dist/src/plugin/debug.js.map +1 -1
  33. package/dist/src/plugin/fetch-interceptor.d.ts.map +1 -1
  34. package/dist/src/plugin/fetch-interceptor.js +13 -1
  35. package/dist/src/plugin/fetch-interceptor.js.map +1 -1
  36. package/dist/src/plugin/index.d.ts +7 -0
  37. package/dist/src/plugin/index.d.ts.map +1 -1
  38. package/dist/src/plugin/index.js +127 -24
  39. package/dist/src/plugin/index.js.map +1 -1
  40. package/dist/src/plugin/killswitch.d.ts.map +1 -1
  41. package/dist/src/plugin/killswitch.js +12 -9
  42. package/dist/src/plugin/killswitch.js.map +1 -1
  43. package/dist/src/plugin/oauth-methods.d.ts.map +1 -1
  44. package/dist/src/plugin/oauth-methods.js +2 -10
  45. package/dist/src/plugin/oauth-methods.js.map +1 -1
  46. package/dist/src/plugin/quota.d.ts +42 -1
  47. package/dist/src/plugin/quota.d.ts.map +1 -1
  48. package/dist/src/plugin/quota.js +185 -41
  49. package/dist/src/plugin/quota.js.map +1 -1
  50. package/dist/src/plugin/ui/auth-menu.d.ts.map +1 -1
  51. package/dist/src/plugin/ui/auth-menu.js +2 -4
  52. package/dist/src/plugin/ui/auth-menu.js.map +1 -1
  53. package/dist/src/plugin/ui/quota-status.d.ts.map +1 -1
  54. package/dist/src/plugin/ui/quota-status.js +3 -5
  55. package/dist/src/plugin/ui/quota-status.js.map +1 -1
  56. package/dist/src/sidebar-state.d.ts +126 -9
  57. package/dist/src/sidebar-state.d.ts.map +1 -1
  58. package/dist/src/sidebar-state.js +185 -25
  59. package/dist/src/sidebar-state.js.map +1 -1
  60. package/dist/src/tui/command-dialogs.js +101 -3
  61. package/dist/src/tui/command-dialogs.js.map +1 -1
  62. package/dist/src/tui.d.ts.map +1 -1
  63. package/dist/src/tui.js +55 -20
  64. package/dist/src/tui.js.map +1 -1
  65. package/package.json +6 -6
  66. package/src/plugin/command-data.ts +331 -51
  67. package/src/sidebar-state.ts +306 -32
  68. package/src/tui/command-dialogs.tsx +136 -3
  69. package/src/tui-compiled/plugin/command-data.ts +331 -51
  70. package/src/tui-compiled/sidebar-state.ts +306 -32
  71. package/src/tui-compiled/tui/command-dialogs.tsx +189 -69
  72. package/src/tui-compiled/tui.tsx +73 -30
  73. package/src/tui.tsx +82 -28
@@ -52,15 +52,20 @@ import { xdgState } from 'xdg-basedir'
52
52
 
53
53
  export const SIDEBAR_STATE_VERSION = 1 as const
54
54
 
55
- export type SidebarQuotaKey =
56
- | 'claude'
57
- | 'gemini-pro'
58
- | 'gemini-flash'
59
- | 'gpt-oss'
55
+ export type SidebarQuotaKey = 'gemini' | 'non-gemini'
56
+
57
+ export interface SidebarQuotaWindowEntry {
58
+ window: 'weekly' | '5h'
59
+ remainingPercent: number
60
+ resetAt?: number
61
+ }
60
62
 
61
63
  export interface SidebarQuotaEntry {
64
+ /** Most-constrained window's remaining % (derived, for back-compat). */
62
65
  remainingPercent: number
63
66
  resetAt?: number
67
+ /** Per-window breakdown. Omitted in pre-windows snapshots. */
68
+ windows?: SidebarQuotaWindowEntry[]
64
69
  }
65
70
 
66
71
  export interface SidebarAccountState {
@@ -71,6 +76,11 @@ export interface SidebarAccountState {
71
76
  current: boolean
72
77
  cooldownUntil?: number
73
78
  quota: Partial<Record<SidebarQuotaKey, SidebarQuotaEntry>>
79
+ /**
80
+ * Captured plan tier. Absent when unknown — do NOT default to `free-tier`.
81
+ * `id` is the raw upstream string (e.g. `"free-tier"`); never normalised.
82
+ */
83
+ tier?: { id: string; paidId?: string; capturedAt: number }
74
84
  }
75
85
 
76
86
  export interface SidebarRoutingEntry {
@@ -294,17 +304,13 @@ function normalizeAccount(input: unknown): SidebarAccountState | null {
294
304
  const quotaRaw = record.quota
295
305
  const quota: SidebarAccountState['quota'] = {}
296
306
  if (isObject(quotaRaw)) {
297
- for (const key of [
298
- 'claude',
299
- 'gemini-pro',
300
- 'gemini-flash',
301
- 'gpt-oss',
302
- ] as const) {
307
+ for (const key of ['gemini', 'non-gemini'] as const) {
303
308
  const entry = (quotaRaw as Record<string, unknown>)[key]
304
309
  const normalized = normalizeQuota(entry)
305
310
  if (normalized) quota[key] = normalized
306
311
  }
307
312
  }
313
+ const tier = normalizeTier(record.tier)
308
314
  return {
309
315
  id,
310
316
  label,
@@ -313,18 +319,60 @@ function normalizeAccount(input: unknown): SidebarAccountState | null {
313
319
  current,
314
320
  cooldownUntil,
315
321
  quota,
322
+ ...(tier !== undefined ? { tier } : {}),
316
323
  }
317
324
  }
318
325
 
326
+ function normalizeTier(
327
+ input: unknown,
328
+ ): { id: string; paidId?: string; capturedAt: number } | undefined {
329
+ if (!isObject(input)) return undefined
330
+ const record = input as Record<string, unknown>
331
+ const id =
332
+ typeof record.id === 'string' && record.id.length > 0 ? record.id : null
333
+ const capturedAt = toFiniteNumber(record.capturedAt)
334
+ if (!id || capturedAt === null) return undefined
335
+ const paidId =
336
+ typeof record.paidId === 'string' && record.paidId.length > 0
337
+ ? record.paidId
338
+ : undefined
339
+ return { id, ...(paidId ? { paidId } : {}), capturedAt }
340
+ }
341
+
319
342
  function normalizeQuota(input: unknown): SidebarQuotaEntry | null {
320
343
  if (!isObject(input)) return null
321
344
  const record = input as Record<string, unknown>
322
345
  const remaining = toFiniteNumber(record.remainingPercent)
323
346
  if (remaining === null) return null
324
347
  const resetAt = toFiniteNumber(record.resetAt) ?? undefined
348
+
349
+ // Tolerant: deserialize windows array if present; drop malformed entries.
350
+ const windowsRaw = record.windows
351
+ let windows: SidebarQuotaEntry['windows']
352
+ if (Array.isArray(windowsRaw)) {
353
+ const parsed = windowsRaw
354
+ .filter(isObject)
355
+ .map((w) => {
356
+ const win = (w as Record<string, unknown>).window
357
+ const rp = toFiniteNumber(
358
+ (w as Record<string, unknown>).remainingPercent,
359
+ )
360
+ const ra = toFiniteNumber((w as Record<string, unknown>).resetAt)
361
+ if ((win !== 'weekly' && win !== '5h') || rp === null) return null
362
+ return {
363
+ window: win as 'weekly' | '5h',
364
+ remainingPercent: clampNumber(rp, 0, 100),
365
+ resetAt: ra ?? undefined,
366
+ }
367
+ })
368
+ .filter((v): v is NonNullable<typeof v> => v !== null)
369
+ if (parsed.length > 0) windows = parsed
370
+ }
371
+
325
372
  return {
326
373
  remainingPercent: clampNumber(remaining, 0, 100),
327
374
  resetAt,
375
+ windows,
328
376
  }
329
377
  }
330
378
 
@@ -441,13 +489,236 @@ export interface SidebarAccountRedactionInput {
441
489
  /** Health score in `[0, 100]`. Defaults to 100 when missing. */
442
490
  healthScore?: number
443
491
  cachedQuota?: {
444
- claude?: { remainingFraction?: number; resetTime?: string }
445
- 'gemini-pro'?: { remainingFraction?: number; resetTime?: string }
446
- 'gemini-flash'?: { remainingFraction?: number; resetTime?: string }
447
- 'gpt-oss'?: { remainingFraction?: number; resetTime?: string }
492
+ gemini?: {
493
+ remainingFraction?: number
494
+ resetTime?: string
495
+ windows?: Array<{
496
+ window: 'weekly' | '5h'
497
+ remainingFraction: number
498
+ resetTime: string
499
+ }>
500
+ }
501
+ 'non-gemini'?: {
502
+ remainingFraction?: number
503
+ resetTime?: string
504
+ windows?: Array<{
505
+ window: 'weekly' | '5h'
506
+ remainingFraction: number
507
+ resetTime: string
508
+ }>
509
+ }
510
+ // Legacy per-pool keys written by older versions of the plugin.
511
+ // Accepted here so the normalizer can map them to the current pool
512
+ // keys at read time without requiring a disk migration.
513
+ [legacyKey: string]:
514
+ | {
515
+ remainingFraction?: number
516
+ resetTime?: string
517
+ modelCount?: number
518
+ windows?: Array<{
519
+ window: 'weekly' | '5h'
520
+ remainingFraction: number
521
+ resetTime: string
522
+ }>
523
+ }
524
+ | undefined
525
+ }
526
+ /**
527
+ * Opaque identity stamp that was attached to the persisted quota snapshot.
528
+ * Used together with `currentQuotaAccountId` to detect a stale cache that
529
+ * landed on the wrong account after an index shift or token replacement.
530
+ * PII-safe — it is a 16-char hash, not the refresh token itself.
531
+ */
532
+ cachedQuotaAccountId?: string
533
+ /**
534
+ * Opaque identity stamp for the account that is currently at this index.
535
+ * The redactor drops `cachedQuota` when `cachedQuotaAccountId` is set
536
+ * AND does not match this value, mirroring `toCommandAccountRow` in the
537
+ * command-data service. Omitted in the persisted sidebar state.
538
+ */
539
+ currentQuotaAccountId?: string
540
+ /**
541
+ * Captured plan tier from `loadCodeAssist`. Absent when unknown. The `id`
542
+ * is the raw upstream string; `capturedAt` is epoch ms. Not PII — tier
543
+ * metadata survives the redaction boundary unchanged.
544
+ */
545
+ tier?: { id: string; paidId?: string; capturedAt: number }
546
+ }
547
+
548
+ /**
549
+ * Build a `{ id, capturedAt }` tier object from account fields, or `undefined`
550
+ * when either field is absent. Centralised so every producer (index.ts,
551
+ * background-quota-refresh.ts, command-data.ts) uses the same guard and the
552
+ * same shape -- the same pattern shipped as inline literals across three files
553
+ * and produced six missed-field bugs; one helper ends that.
554
+ */
555
+ export function toCapturedTier(account: {
556
+ capturedTierId?: string
557
+ capturedPaidTierId?: string
558
+ capturedTierAt?: number
559
+ }): { id: string; paidId?: string; capturedAt: number } | undefined {
560
+ return account.capturedTierId !== undefined &&
561
+ account.capturedTierAt !== undefined
562
+ ? {
563
+ id: account.capturedTierId,
564
+ ...(account.capturedPaidTierId !== undefined
565
+ ? { paidId: account.capturedPaidTierId }
566
+ : {}),
567
+ capturedAt: account.capturedTierAt,
568
+ }
569
+ : undefined
570
+ }
571
+
572
+ /**
573
+ * Map pre-pool legacy quota keys to the current two-pool schema at read time.
574
+ *
575
+ * Legacy mappings (empirically settled from burn tests):
576
+ * `gemini-pro` + `gemini-flash` → `gemini`
577
+ * `claude` + `gpt-oss` → `non-gemini`
578
+ *
579
+ * Where multiple legacy keys collapse into one pool the MIN remainingFraction
580
+ * and earliest resetTime are used (most-constrained-first, matching the
581
+ * window-level rule). No `windows` arrays are invented for migrated entries;
582
+ * the single aggregate bar is the correct render for pre-window snapshots.
583
+ *
584
+ * Non-legacy keys that are already canonical (`gemini`, `non-gemini`) are
585
+ * carried through unchanged. The next real quota refresh overwrites any
586
+ * migrated snapshot with authoritative data.
587
+ */
588
+ export function normalizeLegacyCachedQuota(
589
+ raw: SidebarAccountRedactionInput['cachedQuota'],
590
+ ): SidebarAccountRedactionInput['cachedQuota'] {
591
+ if (!raw) return raw
592
+
593
+ // Pool already carries current keys — fast path when no legacy keys present.
594
+ const hasLegacy =
595
+ 'gemini-pro' in raw ||
596
+ 'gemini-flash' in raw ||
597
+ 'claude' in raw ||
598
+ 'gpt-oss' in raw
599
+ if (!hasLegacy) return raw
600
+
601
+ type PoolEntry = NonNullable<
602
+ SidebarAccountRedactionInput['cachedQuota']
603
+ >['gemini']
604
+
605
+ // Pick the earlier of two ISO reset timestamps, preferring a defined
606
+ // value over undefined. Extracted so both branches of minFraction use
607
+ // the same merge rather than the previous asymmetry where the a-wins
608
+ // branch dropped b's possibly-earlier resetTime.
609
+ const earlierResetTime = (
610
+ a: string | undefined,
611
+ b: string | undefined,
612
+ ): string | undefined => {
613
+ if (!a) return b
614
+ if (!b) return a
615
+ return a < b ? a : b
616
+ }
617
+
618
+ const minFraction = (a: PoolEntry, b: PoolEntry): PoolEntry => {
619
+ if (!a) return b
620
+ if (!b) return a
621
+ const fa = a.remainingFraction ?? 1
622
+ const fb = b.remainingFraction ?? 1
623
+ const winner = fa <= fb ? a : b
624
+ const loser = fa <= fb ? b : a
625
+ // Always merge to the earliest resetTime regardless of which pool wins
626
+ // the fraction comparison — an earlier window expiry from the loser
627
+ // pool must not be silently dropped.
628
+ return {
629
+ ...winner,
630
+ resetTime: earlierResetTime(winner.resetTime, loser.resetTime),
631
+ }
632
+ }
633
+
634
+ const gemini: PoolEntry =
635
+ minFraction(
636
+ raw.gemini,
637
+ minFraction(
638
+ raw['gemini-pro'] as PoolEntry,
639
+ raw['gemini-flash'] as PoolEntry,
640
+ ),
641
+ ) ?? raw.gemini
642
+
643
+ const nonGemini: PoolEntry =
644
+ minFraction(
645
+ raw['non-gemini'],
646
+ minFraction(raw.claude as PoolEntry, raw['gpt-oss'] as PoolEntry),
647
+ ) ?? raw['non-gemini']
648
+
649
+ return {
650
+ ...(gemini !== undefined ? { gemini } : {}),
651
+ ...(nonGemini !== undefined ? { 'non-gemini': nonGemini } : {}),
448
652
  }
449
653
  }
450
654
 
655
+ /**
656
+ * Project a raw cachedQuota pool entry into the sidebar-safe shape.
657
+ *
658
+ * Centralized seam — every producer (quota.ts pushSidebarQuotaSnapshot,
659
+ * auth-loader.ts materializer, command-data.ts writeSidebar) routes
660
+ * through here so the `windows` array is never dropped by an inline
661
+ * `{ remainingFraction, resetTime }` literal.
662
+ *
663
+ * Tolerant: returns `undefined` when the source is missing or the
664
+ * `remainingFraction` is not a finite number. Legacy entries without
665
+ * `windows` produce `{ remainingPercent, resetAt }` only — the TUI's
666
+ * legacy path then renders a single bar.
667
+ */
668
+ export function projectQuotaPoolForSidebar(source: {
669
+ remainingFraction?: number
670
+ resetTime?: string
671
+ windows?: ReadonlyArray<{
672
+ window: 'weekly' | '5h'
673
+ remainingFraction: number
674
+ resetTime: string
675
+ }>
676
+ }): SidebarQuotaEntry | undefined {
677
+ const fraction = source.remainingFraction
678
+ if (typeof fraction !== 'number' || !Number.isFinite(fraction))
679
+ return undefined
680
+ const remainingPercent = clampNumber(Math.round(fraction * 100), 0, 100)
681
+ let resetAt: number | undefined
682
+ if (typeof source.resetTime === 'string' && source.resetTime.length > 0) {
683
+ const parsed = Date.parse(source.resetTime)
684
+ if (Number.isFinite(parsed)) resetAt = parsed
685
+ }
686
+
687
+ const windows: SidebarQuotaEntry['windows'] = source.windows?.length
688
+ ? source.windows.map((w) => ({
689
+ window: w.window,
690
+ remainingPercent: clampNumber(
691
+ Math.round(w.remainingFraction * 100),
692
+ 0,
693
+ 100,
694
+ ),
695
+ resetAt:
696
+ typeof w.resetTime === 'string' && w.resetTime.length > 0
697
+ ? (() => {
698
+ const parsed = Date.parse(w.resetTime)
699
+ return Number.isFinite(parsed) ? parsed : undefined
700
+ })()
701
+ : undefined,
702
+ }))
703
+ : undefined
704
+
705
+ return { remainingPercent, resetAt, windows }
706
+ }
707
+
708
+ /**
709
+ * Returns `true` when an account at `index` is the active account for
710
+ * at least one model family. Two accounts CAN both be current — one
711
+ * serving claude, one serving gemini — so this must not collapse to one.
712
+ */
713
+ export function isAccountCurrent(
714
+ index: number,
715
+ activeIndexByFamily: { claude: number; gemini: number },
716
+ ): boolean {
717
+ return (
718
+ index === activeIndexByFamily.claude || index === activeIndexByFamily.gemini
719
+ )
720
+ }
721
+
451
722
  /**
452
723
  * Convert a live account snapshot into the redacted shape the TUI renders.
453
724
  * The redacted `SidebarAccountState` carries no email, refresh token, access
@@ -467,31 +738,32 @@ export function redactAccountForSidebar(
467
738
  ? source.coolingDownUntil
468
739
  : undefined
469
740
  const health = clampNumber(
470
- typeof source.healthScore === 'number' ? source.healthScore : null,
741
+ typeof source.healthScore === 'number' ? source.healthScore : 100,
471
742
  0,
472
743
  100,
473
744
  )
474
745
 
475
746
  const quota: SidebarAccountState['quota'] = {}
476
- const cached = source.cachedQuota
747
+ // Stamp mismatch: the persisted quota snapshot was captured for a
748
+ // different account than the one currently at this index. Drop the
749
+ // stale cache rather than rendering the wrong account's quota
750
+ // percentages. Mirrors `toCommandAccountRow` in command-data.
751
+ const staleCachedQuota =
752
+ typeof source.cachedQuotaAccountId === 'string' &&
753
+ typeof source.currentQuotaAccountId === 'string' &&
754
+ source.cachedQuotaAccountId !== source.currentQuotaAccountId
755
+ // Normalize legacy per-pool keys to the current two-pool schema before
756
+ // reading. This is non-destructive: the next real quota refresh will
757
+ // overwrite the migrated snapshot with authoritative data.
758
+ const cached = staleCachedQuota
759
+ ? undefined
760
+ : normalizeLegacyCachedQuota(source.cachedQuota)
477
761
  if (cached) {
478
- for (const key of [
479
- 'claude',
480
- 'gemini-pro',
481
- 'gemini-flash',
482
- 'gpt-oss',
483
- ] as const) {
762
+ for (const key of ['gemini', 'non-gemini'] as const) {
484
763
  const entry = cached[key]
485
764
  if (!entry) continue
486
- const fraction = entry.remainingFraction
487
- if (typeof fraction !== 'number' || !Number.isFinite(fraction)) continue
488
- const remainingPercent = clampNumber(Math.round(fraction * 100), 0, 100)
489
- let resetAt: number | undefined
490
- if (typeof entry.resetTime === 'string' && entry.resetTime.length > 0) {
491
- const parsed = Date.parse(entry.resetTime)
492
- if (Number.isFinite(parsed)) resetAt = parsed
493
- }
494
- quota[key] = { remainingPercent, resetAt }
765
+ const projected = projectQuotaPoolForSidebar(entry)
766
+ if (projected) quota[key] = projected
495
767
  }
496
768
  }
497
769
 
@@ -503,6 +775,8 @@ export function redactAccountForSidebar(
503
775
  current,
504
776
  cooldownUntil,
505
777
  quota,
778
+ // Tier is plan metadata, not PII — it passes the redaction boundary.
779
+ ...(source.tier !== undefined ? { tier: source.tier } : {}),
506
780
  }
507
781
  }
508
782
 
@@ -252,6 +252,141 @@ function renderAccountDialog(
252
252
  return null
253
253
  }
254
254
 
255
+ const updateAccounts = (knobs: Record<string, unknown>): void => {
256
+ const accounts = knobs.accounts
257
+ if (!Array.isArray(accounts)) return
258
+ payload.knobs = {
259
+ ...payload.knobs,
260
+ accounts: accounts as CommandAccountRow[],
261
+ }
262
+ }
263
+
264
+ const openOAuthLabelPrompt = (code: string, oauthUrl: string): void => {
265
+ const DialogPrompt = api.ui.DialogPrompt
266
+ api.ui.dialog.setSize('xlarge')
267
+ api.ui.dialog.replace(() => (
268
+ <DialogPrompt
269
+ title='OAuth sign-in — label'
270
+ description={() => (
271
+ <text>A short name for this account (optional).</text>
272
+ )}
273
+ placeholder='e.g. work'
274
+ value=''
275
+ onConfirm={(value: string) => {
276
+ const label = value.trim()
277
+ const args = label
278
+ ? `add-oauth-finish ${code} --label ${label}`
279
+ : `add-oauth-finish ${code}`
280
+ void apply('antigravity-account', args, { timeoutMs: 120_000 })
281
+ .then((result) => {
282
+ api.ui.toast({ message: result.text })
283
+ updateAccounts(result.knobs)
284
+ renderMain()
285
+ })
286
+ .catch(() => {
287
+ api.ui.toast({ message: 'OAuth account add failed' })
288
+ renderMain()
289
+ })
290
+ }}
291
+ onCancel={() => openOAuthCodePrompt(oauthUrl)}
292
+ />
293
+ ))
294
+ }
295
+
296
+ const openOAuthCodePrompt = (oauthUrl: string): void => {
297
+ const DialogPrompt = api.ui.DialogPrompt
298
+ api.ui.dialog.setSize('xlarge')
299
+ api.ui.dialog.replace(() => (
300
+ <DialogPrompt
301
+ title='OAuth sign-in — enter code'
302
+ description={() => (
303
+ <text>
304
+ After signing in, paste the full callback URL or authorization code.
305
+ </text>
306
+ )}
307
+ placeholder='Paste callback URL or code here'
308
+ value=''
309
+ onConfirm={(value: string) => {
310
+ const code = value.trim()
311
+ if (!code) {
312
+ // Empty submit must keep the user inside the flow, not
313
+ // silently drop them back to the account list. Surface the
314
+ // validation message and reopen the same prompt so the
315
+ // user can paste again.
316
+ api.ui.toast({
317
+ message: 'Please paste the callback URL or authorization code.',
318
+ })
319
+ openOAuthCodePrompt(oauthUrl)
320
+ return
321
+ }
322
+ openOAuthLabelPrompt(code, oauthUrl)
323
+ }}
324
+ onCancel={() => openOAuthUrlScreen(oauthUrl)}
325
+ />
326
+ ))
327
+ }
328
+
329
+ const openOAuthUrlScreen = (oauthUrl: string): void => {
330
+ const DialogSelect = api.ui.DialogSelect<string>
331
+ api.ui.dialog.setSize('xlarge')
332
+ api.ui.dialog.replace(() => (
333
+ <DialogSelect
334
+ title='OAuth sign-in'
335
+ options={[
336
+ {
337
+ title: 'Copy URL to clipboard',
338
+ value: 'copy',
339
+ description: oauthUrl,
340
+ },
341
+ {
342
+ title: 'Enter sign-in code',
343
+ value: 'code',
344
+ description:
345
+ 'Open the URL in your browser, sign in, then paste the callback URL or code.',
346
+ },
347
+ { title: 'Cancel', value: 'cancel' },
348
+ ]}
349
+ onSelect={(option) => {
350
+ if (option.value === 'cancel') {
351
+ renderMain()
352
+ return
353
+ }
354
+ if (option.value === 'copy') {
355
+ const copied = api.renderer.copyToClipboardOSC52(oauthUrl)
356
+ api.ui.toast({
357
+ message: copied
358
+ ? 'URL copied to clipboard'
359
+ : 'Copy unavailable — select the URL text above to copy',
360
+ })
361
+ openOAuthUrlScreen(oauthUrl)
362
+ return
363
+ }
364
+ openOAuthCodePrompt(oauthUrl)
365
+ }}
366
+ />
367
+ ))
368
+ }
369
+
370
+ const openAddOAuthStart = (): void => {
371
+ void apply('antigravity-account', 'add-oauth-start', {
372
+ timeoutMs: 120_000,
373
+ })
374
+ .then((result) => {
375
+ updateAccounts(result.knobs)
376
+ const oauthUrl = result.knobs.oauthUrl
377
+ if (typeof oauthUrl === 'string' && oauthUrl.length > 0) {
378
+ openOAuthUrlScreen(oauthUrl)
379
+ return
380
+ }
381
+ api.ui.toast({ message: result.text })
382
+ renderMain()
383
+ })
384
+ .catch(() => {
385
+ api.ui.toast({ message: 'OAuth account add failed' })
386
+ renderMain()
387
+ })
388
+ }
389
+
255
390
  const renderManageRow = (row: CommandAccountRow): void => {
256
391
  const DialogSelect = api.ui.DialogSelect<string>
257
392
  api.ui.dialog.setSize('xlarge')
@@ -395,9 +530,7 @@ function renderAccountDialog(
395
530
  return
396
531
  }
397
532
  if (raw === 'add') {
398
- void runApply('add', { timeoutMs: 120_000 }).catch(() => {
399
- api.ui.toast({ message: 'Account add failed' })
400
- })
533
+ openAddOAuthStart()
401
534
  return
402
535
  }
403
536
  if (raw.startsWith('__manage__ ')) {