fmt(js): npm run fix on merge (#66834)

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
This commit is contained in:
nousbot-eng 2026-07-18 05:06:55 -04:00 committed by GitHub
parent b51fbc738b
commit 33300cdc7f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 403 additions and 153 deletions

View file

@ -107,11 +107,11 @@ describe('BillingOverlay — step-up screen (Enable terminal billing)', () => {
describe('BillingOverlay — overview (reordered, dollars)', () => {
it('leads with balance in the title, Add funds first, no "credits"', () => {
const out = render(overlay('overview'))
expect(out).toContain('Top up · balance $12.00') // balance in the title
expect(out).toContain('Add funds') // buy action, renamed
expect(out).toContain('Top up · balance $12.00') // balance in the title
expect(out).toContain('Add funds') // buy action, renamed
expect(out).toContain('Auto-reload')
expect(out).toContain('Manage on portal')
expect(out.toLowerCase()).not.toContain('credits') // dollars only
expect(out.toLowerCase()).not.toContain('credits') // dollars only
// No standalone "Enable terminal billing" item — discovered at pay time.
expect(out).not.toContain('Enable terminal billing')
})
@ -126,8 +126,22 @@ describe('BillingOverlay — overview (reordered, dollars)', () => {
status: 'healthy',
plan_name: 'Plus',
has_topup: true,
plan_bar: { kind: 'plan', remaining_display: '$14.00', total_display: '$20.00', spent_display: '$6.00', pct_used: 30, fill_fraction: 0.7 },
topup_bar: { kind: 'topup', remaining_display: '$12.00', total_display: '$12.00', spent_display: '$0.00', pct_used: null, fill_fraction: 1 }
plan_bar: {
kind: 'plan',
remaining_display: '$14.00',
total_display: '$20.00',
spent_display: '$6.00',
pct_used: 30,
fill_fraction: 0.7
},
topup_bar: {
kind: 'topup',
remaining_display: '$12.00',
total_display: '$12.00',
spent_display: '$0.00',
pct_used: null,
fill_fraction: 1
}
}
}
}

View file

@ -30,7 +30,10 @@ import { DEFAULT_THEME } from '../theme.js'
const t = DEFAULT_THEME
/** Mount a SubscriptionOverlay via renderSync + PassThrough. */
function mount(overlay: SubscriptionOverlayState, onPatch: (next: Partial<SubscriptionOverlayState>) => void = () => {}) {
function mount(
overlay: SubscriptionOverlayState,
onPatch: (next: Partial<SubscriptionOverlayState>) => void = () => {}
) {
const stdout = new PassThrough()
const stdin = new PassThrough()
const stderr = new PassThrough()
@ -46,15 +49,13 @@ function mount(overlay: SubscriptionOverlayState, onPatch: (next: Partial<Subscr
inputHarness.handler = undefined
const element = React.createElement(SubscriptionOverlay, { onClose: () => {}, onPatch, overlay, t })
const instance = renderSync(
element,
{
patchConsole: false,
stderr: stderr as NodeJS.WriteStream,
stdin: stdin as NodeJS.ReadStream,
stdout: stdout as NodeJS.WriteStream
}
)
const instance = renderSync(element, {
patchConsole: false,
stderr: stderr as NodeJS.WriteStream,
stdin: stdin as NodeJS.ReadStream,
stdout: stdout as NodeJS.WriteStream
})
return {
cleanup: () => {
@ -76,9 +77,33 @@ function render(overlay: SubscriptionOverlayState): string {
}
const TIERS = [
{ tier_id: 'free', name: 'Free', tier_order: 0, dollars_per_month_display: '$0', monthly_credits: '0', is_current: false, is_enabled: true },
{ tier_id: 'plus', name: 'Plus', tier_order: 1, dollars_per_month_display: '$20', monthly_credits: '1000', is_current: true, is_enabled: true },
{ tier_id: 'ultra', name: 'Ultra', tier_order: 2, dollars_per_month_display: '$40', monthly_credits: '3000', is_current: false, is_enabled: true }
{
tier_id: 'free',
name: 'Free',
tier_order: 0,
dollars_per_month_display: '$0',
monthly_credits: '0',
is_current: false,
is_enabled: true
},
{
tier_id: 'plus',
name: 'Plus',
tier_order: 1,
dollars_per_month_display: '$20',
monthly_credits: '1000',
is_current: true,
is_enabled: true
},
{
tier_id: 'ultra',
name: 'Ultra',
tier_order: 2,
dollars_per_month_display: '$40',
monthly_credits: '3000',
is_current: false,
is_enabled: true
}
]
const state = (overrides: Partial<SubscriptionStateResponse> = {}): SubscriptionStateResponse => ({
@ -128,7 +153,15 @@ describe('SubscriptionOverlay — overview', () => {
const out = render(
overlay(
state({
current: { tier_id: 'pro', tier_name: 'Pro', monthly_credits: '1000', credits_remaining: '700', cycle_ends_at: '2026-07-01', pending_downgrade_tier_name: null, pending_downgrade_at: null },
current: {
tier_id: 'pro',
tier_name: 'Pro',
monthly_credits: '1000',
credits_remaining: '700',
cycle_ends_at: '2026-07-01',
pending_downgrade_tier_name: null,
pending_downgrade_at: null
},
usage: {
available: true,
status: 'healthy',
@ -136,8 +169,22 @@ describe('SubscriptionOverlay — overview', () => {
renews_display: 'Jul 1, 2026',
total_spendable_display: '$26.00',
has_topup: true,
plan_bar: { kind: 'plan', remaining_display: '$14.00', total_display: '$20.00', spent_display: '$6.00', pct_used: 30, fill_fraction: 0.7 },
topup_bar: { kind: 'topup', remaining_display: '$12.00', total_display: '$12.00', spent_display: '$0.00', pct_used: null, fill_fraction: 1 }
plan_bar: {
kind: 'plan',
remaining_display: '$14.00',
total_display: '$20.00',
spent_display: '$6.00',
pct_used: 30,
fill_fraction: 0.7
},
topup_bar: {
kind: 'topup',
remaining_display: '$12.00',
total_display: '$12.00',
spent_display: '$0.00',
pct_used: null,
fill_fraction: 1
}
}
})
)
@ -155,8 +202,29 @@ describe('SubscriptionOverlay — overview', () => {
const out = render(
overlay(
state({
current: { tier_id: 'pro', tier_name: 'Pro', monthly_credits: '1000', credits_remaining: '170', cycle_ends_at: '2026-07-01', pending_downgrade_tier_name: null, pending_downgrade_at: null },
usage: { available: true, status: 'low', plan_name: 'Pro', total_spendable_display: '$3.40', plan_bar: { kind: 'plan', remaining_display: '$3.40', total_display: '$20.00', spent_display: '$16.60', pct_used: 83, fill_fraction: 0.17 } }
current: {
tier_id: 'pro',
tier_name: 'Pro',
monthly_credits: '1000',
credits_remaining: '170',
cycle_ends_at: '2026-07-01',
pending_downgrade_tier_name: null,
pending_downgrade_at: null
},
usage: {
available: true,
status: 'low',
plan_name: 'Pro',
total_spendable_display: '$3.40',
plan_bar: {
kind: 'plan',
remaining_display: '$3.40',
total_display: '$20.00',
spent_display: '$16.60',
pct_used: 83,
fill_fraction: 0.17
}
}
})
)
)
@ -172,7 +240,15 @@ describe('SubscriptionOverlay — overview', () => {
is_admin: false,
can_change_plan: false,
role: 'MEMBER',
current: { tier_id: 'pro', tier_name: 'Pro', monthly_credits: '1000', credits_remaining: '500', cycle_ends_at: '2026-07-01', pending_downgrade_tier_name: null, pending_downgrade_at: null },
current: {
tier_id: 'pro',
tier_name: 'Pro',
monthly_credits: '1000',
credits_remaining: '500',
cycle_ends_at: '2026-07-01',
pending_downgrade_tier_name: null,
pending_downgrade_at: null
},
usage: { available: true, status: 'healthy', plan_name: 'Pro' }
})
)
@ -186,7 +262,16 @@ describe('SubscriptionOverlay — overview', () => {
const out = render(
overlay(
state({
current: { tier_id: 'pro', tier_name: 'Pro', monthly_credits: '1000', credits_remaining: '500', cycle_ends_at: '2026-07-01', pending_downgrade_tier_name: 'Free', pending_downgrade_at: '2026-07-15', pending_downgrade_display: 'Jul 15, 2026' },
current: {
tier_id: 'pro',
tier_name: 'Pro',
monthly_credits: '1000',
credits_remaining: '500',
cycle_ends_at: '2026-07-01',
pending_downgrade_tier_name: 'Free',
pending_downgrade_at: '2026-07-15',
pending_downgrade_display: 'Jul 15, 2026'
},
usage: { available: true, status: 'healthy', plan_name: 'Pro' }
})
)
@ -296,7 +381,13 @@ describe('SubscriptionOverlay — confirm', () => {
pending: {
kind: 'upgrade',
targetTierId: 'ultra',
preview: { ok: true, effect: 'charge_now', target_tier_name: 'Ultra', amount_due_now_cents: 1234, monthly_credits_delta: '2000' }
preview: {
ok: true,
effect: 'charge_now',
target_tier_name: 'Ultra',
amount_due_now_cents: 1234,
monthly_credits_delta: '2000'
}
}
})
)
@ -311,7 +402,13 @@ describe('SubscriptionOverlay — confirm', () => {
pending: {
kind: 'tier_change',
targetTierId: 'plus',
preview: { ok: true, effect: 'scheduled', target_tier_name: 'Plus', effective_at: '2026-08-01T00:00:00Z', amount_due_now_cents: null }
preview: {
ok: true,
effect: 'scheduled',
target_tier_name: 'Plus',
effective_at: '2026-08-01T00:00:00Z',
amount_due_now_cents: null
}
}
})
)
@ -322,7 +419,9 @@ describe('SubscriptionOverlay — confirm', () => {
})
it('cancellation: shows cancel-at-period-end copy', () => {
const out = render(at('confirm', subscriber(), { pending: { kind: 'cancellation', targetTierId: null, preview: null } }))
const out = render(
at('confirm', subscriber(), { pending: { kind: 'cancellation', targetTierId: null, preview: null } })
)
expect(out).toContain('Confirm cancellation')
expect(out).toContain('will not renew')
@ -331,7 +430,11 @@ describe('SubscriptionOverlay — confirm', () => {
it('blocked: shows the reason + Manage on portal', () => {
const out = render(
at('confirm', subscriber(), {
pending: { kind: 'tier_change', targetTierId: 'ultra', preview: { ok: true, effect: 'blocked', reason: 'Retract the cancellation before upgrading.' } }
pending: {
kind: 'tier_change',
targetTierId: 'ultra',
preview: { ok: true, effect: 'blocked', reason: 'Retract the cancellation before upgrading.' }
}
})
)
@ -352,7 +455,11 @@ describe('SubscriptionOverlay — result', () => {
it('error with recovery: shows the message + Open the portal', () => {
const out = render(
at('result', subscriber(), {
result: { ok: false, message: 'This upgrade needs extra verification (3DS).', recoveryUrl: 'https://portal.example/x' }
result: {
ok: false,
message: 'This upgrade needs extra verification (3DS).',
recoveryUrl: 'https://portal.example/x'
}
})
)
@ -366,6 +473,7 @@ describe('SubscriptionOverlay — upgrade response mapping', () => {
const applyUpgrade = async (response: unknown) => {
const onPatch = vi.fn()
const upgrade = vi.fn(() => Promise.resolve(response))
const mounted = mount(
at('confirm', subscriber(), {
ctx: { ...ctx, upgrade } as SubscriptionOverlayState['ctx'],
@ -433,17 +541,22 @@ describe('SubscriptionOverlay — upgrade response mapping', () => {
vi.useFakeTimers()
try {
const refreshState = vi.fn(() => Promise.resolve(subscriber({
current: {
tier_id: 'ultra',
tier_name: 'Ultra',
monthly_credits: '3000',
credits_remaining: '3000',
cycle_ends_at: '2026-08-01',
pending_downgrade_tier_name: null,
pending_downgrade_at: null
}
})))
const refreshState = vi.fn(() =>
Promise.resolve(
subscriber({
current: {
tier_id: 'ultra',
tier_name: 'Ultra',
monthly_credits: '3000',
credits_remaining: '3000',
cycle_ends_at: '2026-08-01',
pending_downgrade_tier_name: null,
pending_downgrade_at: null
}
})
)
)
const result = { message: 'Upgraded to Ultra.', ok: true, pendingTierId: 'ultra' }
const mounted = mount(at('result', subscriber(), { ctx: { ...ctx, refreshState }, result }))

View file

@ -313,19 +313,28 @@ describe('/billing slash command (overlay-driven)', () => {
it.each([
['admin', 'An admin turned off terminal billing for this terminal'],
['self', 'You turned off terminal billing for this terminal']
])('ctx.charge remote_spending_revoked (%s) → clears the overlay (no zombie button) + actor copy', async (actor, copy) => {
const { run, sys } = buildCtx({
'billing.state': ownerState(),
'billing.charge': { ok: false, error: 'remote_spending_revoked', actor, recovery: 'reconnect', idempotency_key: 'k' }
})
])(
'ctx.charge remote_spending_revoked (%s) → clears the overlay (no zombie button) + actor copy',
async (actor, copy) => {
const { run, sys } = buildCtx({
'billing.state': ownerState(),
'billing.charge': {
ok: false,
error: 'remote_spending_revoked',
actor,
recovery: 'reconnect',
idempotency_key: 'k'
}
})
await run('')
getOverlayState().billing!.ctx.charge('100')
await Promise.resolve()
await Promise.resolve()
expect(printed(sys)).toContain(copy)
expect(getOverlayState().billing).toBeNull()
})
await run('')
getOverlayState().billing!.ctx.charge('100')
await Promise.resolve()
await Promise.resolve()
expect(printed(sys)).toContain(copy)
expect(getOverlayState().billing).toBeNull()
}
)
it('ctx.charge session_revoked → clears overlay + re-login (not reconnect) copy', async () => {
const { run, sys } = buildCtx({
@ -353,9 +362,7 @@ describe('/billing slash command (overlay-driven)', () => {
return Promise.reject(new Error('socket closed'))
}
return Promise.resolve(
method === 'billing.charge' ? { ok: true, charge_id: 'ch_1', idempotency_key: 'k' } : null
)
return Promise.resolve(method === 'billing.charge' ? { ok: true, charge_id: 'ch_1', idempotency_key: 'k' } : null)
})
await getOverlayState().billing!.ctx.charge('100')
await vi.waitFor(() => expect(printed(sys)).toContain('outcome is unconfirmed'))

View file

@ -35,7 +35,12 @@ describe('turnController.startMessage — flash-and-yield notices clear on next
it('leaves a sticky credits.depleted notice across a new turn', () => {
patchUiState({
notice: { key: 'credits.depleted', kind: 'sticky', level: 'error', text: '✕ Credit access paused · run /topup to top up' }
notice: {
key: 'credits.depleted',
kind: 'sticky',
level: 'error',
text: '✕ Credit access paused · run /topup to top up'
}
})
turnController.startMessage()
expect(getUiState().notice?.key).toBe('credits.depleted')

View file

@ -79,8 +79,22 @@ describe('/usage slash command', () => {
renews_display: 'Jul 1, 2026',
total_spendable_display: '$26.00',
has_topup: true,
plan_bar: { kind: 'plan', remaining_display: '$14.00', total_display: '$20.00', spent_display: '$6.00', pct_used: 30, fill_fraction: 0.7 },
topup_bar: { kind: 'topup', remaining_display: '$12.00', total_display: '$12.00', spent_display: '$0.00', pct_used: null, fill_fraction: 1 }
plan_bar: {
kind: 'plan',
remaining_display: '$14.00',
total_display: '$20.00',
spent_display: '$6.00',
pct_used: 30,
fill_fraction: 0.7
},
topup_bar: {
kind: 'topup',
remaining_display: '$12.00',
total_display: '$12.00',
spent_display: '$0.00',
pct_used: null,
fill_fraction: 1
}
}
})
})

View file

@ -3,7 +3,16 @@ import type { MutableRefObject, ReactNode, RefObject, SetStateAction } from 'rea
import type { PasteEvent } from '../components/textInput.js'
import type { GatewayClient } from '../gatewayClient.js'
import type { BillingCardInfo, BillingMutationResponse, BillingStateResponse, ImageAttachResponse, SessionCloseResponse, SubscriptionPreviewResponse, SubscriptionStateResponse, SubscriptionUpgradeResponse } from '../gatewayTypes.js'
import type {
BillingCardInfo,
BillingMutationResponse,
BillingStateResponse,
ImageAttachResponse,
SessionCloseResponse,
SubscriptionPreviewResponse,
SubscriptionStateResponse,
SubscriptionUpgradeResponse
} from '../gatewayTypes.js'
import type { ParsedVoiceRecordKey } from '../lib/platform.js'
import type { RpcResult } from '../lib/rpc.js'
import type { Theme } from '../theme.js'
@ -96,9 +105,9 @@ export type BillingScreen = 'autoreload' | 'buy' | 'confirm' | 'limit' | 'overvi
/** Outcome of a charge attempt — lets the overlay route without tearing down. */
export type BillingChargeOutcome =
| 'submitted' // 202 accepted; settlement is reported via transcript lines
| 'needs_remote_spending' // insufficient_scope → route to the stepup screen
| 'error' // any other failure (already surfaced via sys)
| 'submitted' // 202 accepted; settlement is reported via transcript lines
| 'needs_remote_spending' // insufficient_scope → route to the stepup screen
| 'error' // any other failure (already surfaced via sys)
/**
* The functions the overlay needs to talk to the gateway and emit
@ -173,10 +182,7 @@ export type SubscriptionScreen = 'confirm' | 'overview' | 'picker' | 'result' |
// The action held while the stepup screen grants terminal billing, replayed on
// grant: re-preview a tier, re-apply the confirmed pending change, or re-resume.
export type SubscriptionStepUpRetry =
| { kind: 'apply' }
| { kind: 'preview'; tierId: string }
| { kind: 'resume' }
export type SubscriptionStepUpRetry = { kind: 'apply' } | { kind: 'preview'; tierId: string } | { kind: 'resume' }
/** Outcome of a terminal-billing step-up: granted, plus the typed denial (for copy). */
export interface StepUpResult {

View file

@ -44,20 +44,20 @@ export const $isBlocked = computed(
}) =>
Boolean(
agents ||
approval ||
billing ||
clarify ||
confirm ||
journey ||
modelPicker ||
pager ||
petPicker ||
pluginsHub ||
secret ||
sessions ||
skillsHub ||
subscription ||
sudo
approval ||
billing ||
clarify ||
confirm ||
journey ||
modelPicker ||
pager ||
petPicker ||
pluginsHub ||
secret ||
sessions ||
skillsHub ||
subscription ||
sudo
)
)

View file

@ -598,7 +598,9 @@ export const sessionCommands: SlashCommand[] = [
const plan = usageModel.plan_name ?? (usageModel.status === 'free' ? 'Free' : null)
if (plan) {
sections.push({ text: `Plan: ${plan}${usageModel.renews_display ? ` · renews ${usageModel.renews_display}` : ''}` })
sections.push({
text: `Plan: ${plan}${usageModel.renews_display ? ` · renews ${usageModel.renews_display}` : ''}`
})
}
if (barLines.length) {

View file

@ -106,7 +106,10 @@ const buildSubscriptionCtx = (
// Carry the typed denial (session_revoked / remote_spending_revoked /
// rate_limited / …) so the stepup screen shows the right recovery.
.then(r => ({ error: r?.error, granted: !!(r && r.ok && r.granted), message: r?.message }))
.catch(() => ({ granted: false, message: 'Could not reach the billing service — check your connection, then retry.' })),
.catch(() => ({
granted: false,
message: 'Could not reach the billing service — check your connection, then retry.'
})),
resume: () =>
ctx.gateway
.rpc<BillingMutationResponse>('subscription.resume', {})

View file

@ -13,7 +13,8 @@ import type { SlashCommand, SlashRunCtx } from '../types.js'
// Poll cadence (plan §5, frozen): 2s interval, 5-minute cap.
const POLL_INTERVAL_MS = 2000
const POLL_CAP_MS = 5 * 60 * 1000
const UNCONFIRMED_CHARGE_MESSAGE = '🟡 Your last charges outcome is unconfirmed — check your balance/history before retrying.'
const UNCONFIRMED_CHARGE_MESSAGE =
'🟡 Your last charges outcome is unconfirmed — check your balance/history before retrying.'
type Sys = (text: string) => void
@ -74,7 +75,9 @@ const renderBillingError = (
break
case 'role_required':
sys('Adding funds needs someone with billing permissions (owner, admin, or finance admin), or manage this on the portal.')
sys(
'Adding funds needs someone with billing permissions (owner, admin, or finance admin), or manage this on the portal.'
)
break
@ -94,7 +97,9 @@ const renderBillingError = (
break
case 'auto_top_up_disabled_failures':
sys('Auto-reload was turned off after repeated charge failures. Fix the card issue, then re-enable it from /topup → Auto-reload.')
sys(
'Auto-reload was turned off after repeated charge failures. Fix the card issue, then re-enable it from /topup → Auto-reload.'
)
break
@ -199,7 +204,11 @@ const pollCharge = (sys: Sys, ctx: SlashRunCtx, chargeId: string, portalUrl?: st
ctx.guarded<BillingChargeStatusResponse>(r => {
if (!r.ok) {
// 429/503 while polling = retry-after, NOT a failure. Back off + continue.
if (r.error === 'rate_limited' || r.error === 'temporarily_unavailable' || r.error === 'stripe_unavailable') {
if (
r.error === 'rate_limited' ||
r.error === 'temporarily_unavailable' ||
r.error === 'stripe_unavailable'
) {
if (timedOut()) {
return
}

View file

@ -328,7 +328,8 @@ export function useInputHandlers(ctx: InputHandlerContext): InputHandlerResult {
// answering felt like the prompt had locked the entire UI. Explicitly
// skip the prompt-overlay early-return for scroll keys so they fall
// through to the wheel / PageUp / Shift+arrow handlers below.
const promptOverlay = overlay.approval || overlay.billing || overlay.clarify || overlay.confirm || overlay.subscription
const promptOverlay =
overlay.approval || overlay.billing || overlay.clarify || overlay.confirm || overlay.subscription
const fallThroughForScroll = promptOverlay && shouldFallThroughForScroll(key)
if (promptOverlay && !fallThroughForScroll) {

View file

@ -57,7 +57,9 @@ export function PromptZone({
const current = overlay.subscription
const onPatch = (next: Partial<typeof current>) =>
patchOverlayState(prev => (prev.subscription ? { ...prev, subscription: { ...prev.subscription, ...next } } : prev))
patchOverlayState(prev =>
prev.subscription ? { ...prev, subscription: { ...prev.subscription, ...next } } : prev
)
const onClose = () => patchOverlayState({ subscription: null })

View file

@ -157,7 +157,9 @@ function OverviewScreen({ ctx, onClose, onPatch, s, t }: ScreenProps) {
full menu members/billing-off get the portal note instead. */}
{full && (
<Text color={t.color.muted}>
{s.card ? `Card: ${s.card.display ?? s.card.masked}` : 'No saved card on file — “Add funds” walks you through adding one.'}
{s.card
? `Card: ${s.card.display ?? s.card.masked}`
: 'No saved card on file — “Add funds” walks you through adding one.'}
</Text>
)}
{note && (
@ -354,14 +356,19 @@ function BuyScreen({ ctx, onPatch, s, t }: ScreenProps) {
Add funds
</Text>
<Text color={t.color.text}>No saved card on file.</Text>
<Text color={t.color.muted}>Add a card once on the portal billing page after that you can top up right from the terminal.</Text>
<Text color={t.color.muted}>
Add a card once on the portal billing page after that you can top up right from the terminal.
</Text>
<Text />
{rows.map((label, i) => (
<MenuRow active={cSel === i} index={i + 1} key={label} label={label} t={t} />
))}
{error && <Text color={t.color.error}>{error}</Text>}
<Text />
{footer(checking ? 'Checking for a card…' : `↑/↓ select · 1-${rows.length} quick pick · Enter confirm · Esc back`, t)}
{footer(
checking ? 'Checking for a card…' : `↑/↓ select · 1-${rows.length} quick pick · Enter confirm · Esc back`,
t
)}
</Box>
)
}
@ -475,7 +482,9 @@ function ConfirmScreen({
<Text color={t.color.muted}>{payLine}</Text>
{/* Provenance-less payloads (older NAS) keep the generic line; when the
resolver says WHY this card, payLine already carries it. */}
{s.card && !s.card.resolved_via && <Text color={t.color.muted}>Your card saved on the portal will be charged.</Text>}
{s.card && !s.card.resolved_via && (
<Text color={t.color.muted}>Your card saved on the portal will be charged.</Text>
)}
<Text color={t.color.muted}>By confirming, you allow Nous Research to charge your card.</Text>
<Text />
<ActionRow active={sel === 0} color={t.color.ok} label={`Pay $${amount} now`} t={t} />
@ -677,9 +686,12 @@ function AutoReloadScreen({ ctx, onClose, onPatch, s, t }: ScreenProps) {
const ar = s.auto_reload
const enabled = Boolean(ar?.enabled)
const distinctCard = ar?.card.kind === 'distinct' ? ar.card : null
const distinctCardName = distinctCard
? [distinctCard.brand, distinctCard.last4 ? `••${distinctCard.last4}` : null].filter(Boolean).join(' ') || 'a different card'
? [distinctCard.brand, distinctCard.last4 ? `••${distinctCard.last4}` : null].filter(Boolean).join(' ') ||
'a different card'
: null
const manageCardLabel = 'Use your card on file — manage on portal'
// Prefill from state (strip the $ from the *_usd raw fields if present).
@ -690,14 +702,17 @@ function AutoReloadScreen({ ctx, onClose, onPatch, s, t }: ScreenProps) {
const [error, setError] = useState<null | string>(null)
// focusRow: 0=threshold field, 1=reloadTo field, 2=Agree, 3=Turn off (if enabled), last=Cancel
const manageCardRows = distinctCard && s.portal_url ? [manageCardLabel] : []
const actionRows = enabled
? ['Agree and turn on', 'Turn off', ...manageCardRows, 'Cancel']
: ['Agree and turn on', ...manageCardRows, 'Cancel']
const actionColors: Record<string, string> = {
'Agree and turn on': t.color.ok,
'Turn off': t.color.warn,
[manageCardLabel]: t.color.accent
}
const FIELD_ROWS = 2
const [row, setRow] = useState(0)

View file

@ -17,11 +17,7 @@ export interface MenuRowSpec {
* the key fully handled and skip the default escape/arrow/enter/number
* handling for that keypress (e.g. a screen with a text-input sub-mode).
*/
export function useMenu(
rows: MenuRowSpec[],
onEscape: () => void,
onKey?: (ch: string, key: Key) => boolean
): number {
export function useMenu(rows: MenuRowSpec[], onEscape: () => void, onKey?: (ch: string, key: Key) => boolean): number {
const [sel, setSel] = useState(0)
useInput((ch, key) => {

View file

@ -9,11 +9,7 @@ import type {
SubscriptionResult,
SubscriptionStepUpRetry
} from '../app/interfaces.js'
import type {
SubscriptionStateResponse,
SubscriptionTierOption,
SubscriptionUpgradeResponse
} from '../gatewayTypes.js'
import type { SubscriptionStateResponse, SubscriptionTierOption, SubscriptionUpgradeResponse } from '../gatewayTypes.js'
import type { Theme } from '../theme.js'
import { ActionRow, footer, MenuRow, type MenuRowSpec, UsageBars, useMenu } from './overlayPrimitives.js'
@ -173,7 +169,11 @@ function stepUpDenialResult(res: { error?: string; message?: string }): Subscrip
}
if (res.error === 'remote_spending_revoked') {
return { message: res.message || 'Terminal spending was turned off for this session — reconnect from the portal, then retry.', ok: false }
return {
message:
res.message || 'Terminal spending was turned off for this session — reconnect from the portal, then retry.',
ok: false
}
}
if (res.error === 'rate_limited') {
@ -182,7 +182,8 @@ function stepUpDenialResult(res: { error?: string; message?: string }): Subscrip
return {
message:
res.message || 'Terminal billing was not enabled — someone with billing permissions (owner, admin, or finance admin) must allow it for this org. You can also make this change on the portal.',
res.message ||
'Terminal billing was not enabled — someone with billing permissions (owner, admin, or finance admin) must allow it for this org. You can also make this change on the portal.',
ok: false
}
}
@ -259,28 +260,46 @@ function applyPendingAndRoute(
const finish = (result: SubscriptionResult) => onPatch({ result, screen: 'result' })
if (pending.kind === 'cancellation') {
return ctx.scheduleCancellation().then(r =>
isScopeDenial(r)
? toStepUp()
: finish(mutationResult(r, 'Scheduled — your plan stays active until the end of the billing period, then it cancels. Nothing changes today.'))
)
return ctx
.scheduleCancellation()
.then(r =>
isScopeDenial(r)
? toStepUp()
: finish(
mutationResult(
r,
'Scheduled — your plan stays active until the end of the billing period, then it cancels. Nothing changes today.'
)
)
)
}
if (pending.kind === 'upgrade') {
return ctx.upgrade(pending.targetTierId ?? '', pending.idempotencyKey).then(r =>
isScopeDenial(r) ? toStepUp() : finish(upgradeResult(r, pending.targetTierId))
)
return ctx
.upgrade(pending.targetTierId ?? '', pending.idempotencyKey)
.then(r => (isScopeDenial(r) ? toStepUp() : finish(upgradeResult(r, pending.targetTierId))))
}
return ctx.scheduleChange(pending.targetTierId ?? '').then(r =>
isScopeDenial(r)
? toStepUp()
: finish(mutationResult(r, 'Scheduled — your plan doesnt change today. You keep your current plan until the end of the billing period, then it switches.'))
)
return ctx
.scheduleChange(pending.targetTierId ?? '')
.then(r =>
isScopeDenial(r)
? toStepUp()
: finish(
mutationResult(
r,
'Scheduled — your plan doesnt change today. You keep your current plan until the end of the billing period, then it switches.'
)
)
)
}
/** Resume (undo the pending change) and route: result (ok/err) or stepup (scope). */
function resumeAndRoute(ctx: SubscriptionOverlayState['ctx'], onPatch: ScreenProps['onPatch'], allowStepUp = true): Promise<void> {
function resumeAndRoute(
ctx: SubscriptionOverlayState['ctx'],
onPatch: ScreenProps['onPatch'],
allowStepUp = true
): Promise<void> {
return ctx.resume().then(r => {
if (isScopeDenial(r)) {
return allowStepUp
@ -288,7 +307,10 @@ function resumeAndRoute(ctx: SubscriptionOverlayState['ctx'], onPatch: ScreenPro
: onPatch({ result: scopeStillDeniedResult, screen: 'result' })
}
return onPatch({ result: mutationResult(r, 'Your pending change was undone — you stay on your current plan.'), screen: 'result' })
return onPatch({
result: mutationResult(r, 'Your pending change was undone — you stay on your current plan.'),
screen: 'result'
})
})
}
@ -594,7 +616,11 @@ function ConfirmScreen({ onClose, onPatch, overlay, t }: ScreenProps) {
if (isCancellation) {
primary = { color: t.color.warn, label: 'Cancel subscription', run: apply }
} else if (effect === 'charge_now') {
primary = { color: t.color.ok, label: amount ? `Pay ${amount} & upgrade now` : 'Upgrade now (prorated charge)', run: apply }
primary = {
color: t.color.ok,
label: amount ? `Pay ${amount} & upgrade now` : 'Upgrade now (prorated charge)',
run: apply
}
} else if (effect === 'scheduled') {
primary = { color: t.color.ok, label: `Schedule change to ${targetName}`, run: apply }
} else if (effect === 'blocked') {
@ -604,7 +630,12 @@ function ConfirmScreen({ onClose, onPatch, overlay, t }: ScreenProps) {
const rows: MenuRowSpec[] = primary ? [primary, { label: 'Back', run: back }] : [{ label: 'Back', run: back }]
const sel = useMenu(rows, back)
// Chip contrasts an immediate charge vs a period-end schedule at a glance.
const chip = effect === 'charge_now' ? { color: t.color.ok, label: 'charged now' } : effect === 'scheduled' ? { color: t.color.warn, label: 'scheduled · not today' } : null
const chip =
effect === 'charge_now'
? { color: t.color.ok, label: 'charged now' }
: effect === 'scheduled'
? { color: t.color.warn, label: 'scheduled · not today' }
: null
return (
<Box flexDirection="column">
@ -619,10 +650,12 @@ function ConfirmScreen({ onClose, onPatch, overlay, t }: ScreenProps) {
{isCancellation && (
<>
<Text color={t.color.text}>
Cancel {s.current?.tier_name ?? 'your plan'} it stays active until {shortDate(s.current?.cycle_ends_at)}, then
will not renew.
Cancel {s.current?.tier_name ?? 'your plan'} it stays active until {shortDate(s.current?.cycle_ends_at)},
then will not renew.
</Text>
<Text color={t.color.muted}>
You keep your remaining credits for this period. You can resume before it ends.
</Text>
<Text color={t.color.muted}>You keep your remaining credits for this period. You can resume before it ends.</Text>
</>
)}
@ -636,7 +669,9 @@ function ConfirmScreen({ onClose, onPatch, overlay, t }: ScreenProps) {
<Text color={t.color.muted}>Monthly credits change: {preview.monthly_credits_delta}.</Text>
)}
<Text color={t.color.muted}>
{chargeCard ? `${chargeCard} — the card on your subscription — will be charged.` : 'The card on your subscription will be charged.'}
{chargeCard
? `${chargeCard} — the card on your subscription — will be charged.`
: 'The card on your subscription will be charged.'}
</Text>
</>
)}
@ -644,8 +679,8 @@ function ConfirmScreen({ onClose, onPatch, overlay, t }: ScreenProps) {
{effect === 'scheduled' && !isCancellation && (
<>
<Text color={t.color.text}>
Change to {targetName} takes effect {shortDate(preview?.effective_at)}. No charge now; you keep your current
plan until then.
Change to {targetName} takes effect {shortDate(preview?.effective_at)}. No charge now; you keep your
current plan until then.
</Text>
{preview?.monthly_credits_delta && (
<Text color={t.color.muted}>Monthly credits change: {preview.monthly_credits_delta}.</Text>
@ -658,7 +693,9 @@ function ConfirmScreen({ onClose, onPatch, overlay, t }: ScreenProps) {
)}
{effect === 'blocked' && !isCancellation && (
<Text color={t.color.warn}>{preview?.reason ?? 'That change cannot be made here — manage it on the portal.'}</Text>
<Text color={t.color.warn}>
{preview?.reason ?? 'That change cannot be made here — manage it on the portal.'}
</Text>
)}
<Text />
@ -678,6 +715,7 @@ function ResultScreen({ onClose, overlay, t }: Omit<ScreenProps, 'onPatch'>) {
const result = overlay.result ?? null
const recoveryUrl = result?.recoveryUrl ?? null
const pendingTierId = result?.pendingTierId ?? null
const [applyState, setApplyState] = useState<'applying' | 'confirmed' | 'timed_out'>(
pendingTierId ? 'applying' : 'confirmed'
)
@ -738,6 +776,7 @@ function ResultScreen({ onClose, overlay, t }: Omit<ScreenProps, 'onPatch'>) {
const applying = result?.ok && applyState === 'applying'
const timedOut = result?.ok && applyState === 'timed_out'
const message = timedOut
? 'Your upgrade succeeded and is still applying — refresh in a moment.'
: (result?.message ?? '')
@ -751,7 +790,10 @@ function ResultScreen({ onClose, overlay, t }: Omit<ScreenProps, 'onPatch'>) {
}
const rows: MenuRowSpec[] = recoveryUrl
? [{ color: t.color.accent, label: 'Open the portal to finish', run: openRecovery }, { label: 'Close', run: onClose }]
? [
{ color: t.color.accent, label: 'Open the portal to finish', run: openRecovery },
{ label: 'Close', run: onClose }
]
: [{ label: 'Close', run: onClose }]
const sel = useMenu(rows, onClose)
@ -762,7 +804,9 @@ function ResultScreen({ onClose, overlay, t }: Omit<ScreenProps, 'onPatch'>) {
{applying ? 'Applying…' : timedOut ? 'Still applying' : result?.ok ? 'Done' : 'Could not complete'}
</Text>
<Text color={t.color.text}>{message}</Text>
{result?.ok && !applying && !timedOut && <Text color={t.color.muted}>Re-run /subscription anytime to review it.</Text>}
{result?.ok && !applying && !timedOut && (
<Text color={t.color.muted}>Re-run /subscription anytime to review it.</Text>
)}
<Text />
{rows.map((row, i) => (
<ActionRow active={sel === i} color={row.color} key={row.label} label={row.label} t={t} />
@ -857,9 +901,15 @@ function StepUpScreen({ onPatch, overlay, t }: ScreenProps) {
const rows: MenuRowSpec[] =
phase === 'granted'
? [{ color: t.color.ok, label: retry?.kind === 'apply' ? 'Continue the change' : 'Continue', run: resume }, { label: 'Cancel', run: back }]
? [
{ color: t.color.ok, label: retry?.kind === 'apply' ? 'Continue the change' : 'Continue', run: resume },
{ label: 'Cancel', run: back }
]
: phase === 'prompt'
? [{ color: t.color.ok, label: 'Enable terminal billing', run: enable }, { label: 'Cancel', run: back }]
? [
{ color: t.color.ok, label: 'Enable terminal billing', run: enable },
{ label: 'Cancel', run: back }
]
: []
const sel = useMenu(rows, back)
@ -871,12 +921,18 @@ function StepUpScreen({ onPatch, overlay, t }: ScreenProps) {
</Text>
{phase === 'prompt' && (
<>
<Text color={t.color.text}>Changing your plan needs terminal billing enabled for this org. Enable it here, then continue.</Text>
<Text color={t.color.muted}>Someone with billing permissions (owner, admin, or finance admin) approves it once in the browser.</Text>
<Text color={t.color.text}>
Changing your plan needs terminal billing enabled for this org. Enable it here, then continue.
</Text>
<Text color={t.color.muted}>
Someone with billing permissions (owner, admin, or finance admin) approves it once in the browser.
</Text>
</>
)}
{phase === 'waiting' && (
<Text color={t.color.muted}>Opening your browser to approve finish there, then come back nothing is charged until you continue.</Text>
<Text color={t.color.muted}>
Opening your browser to approve finish there, then come back nothing is charged until you continue.
</Text>
)}
{phase === 'granted' && <Text color={t.color.ok}>Terminal billing enabled. Continue to finish your change.</Text>}
{phase === 'resuming' && <Text color={t.color.muted}>Applying your change</Text>}
@ -885,7 +941,14 @@ function StepUpScreen({ onPatch, overlay, t }: ScreenProps) {
<ActionRow active={sel === i} color={row.color} key={row.label} label={row.label} t={t} />
))}
<Text />
{footer(phase === 'waiting' ? 'Waiting for approval… · Esc to cancel' : phase === 'resuming' ? 'Working…' : '↑/↓ select · Enter · Esc back', t)}
{footer(
phase === 'waiting'
? 'Waiting for approval… · Esc to cancel'
: phase === 'resuming'
? 'Working…'
: '↑/↓ select · Enter · Esc back',
t
)}
</Box>
)
}

View file

@ -157,36 +157,36 @@ export interface BillingMutationResponse {
export interface SubscriptionTierOption {
tier_id: string
name: string
tier_order: number // sorts the picker + upgrade/downgrade hint
dollars_per_month_display: string // pre-formatted ($X / $X.YY)
tier_order: number // sorts the picker + upgrade/downgrade hint
dollars_per_month_display: string // pre-formatted ($X / $X.YY)
monthly_credits: string | null
is_current: boolean // the active plan: shown, not selectable
is_enabled: boolean // false = grandfathered current tier
is_current: boolean // the active plan: shown, not selectable
is_enabled: boolean // false = grandfathered current tier
}
export interface SubscriptionStateResponse {
ok: boolean
logged_in: boolean
is_admin: boolean
can_change_plan: boolean // role gate (ADMIN/OWNER), from NAS
can_change_plan: boolean // role gate (ADMIN/OWNER), from NAS
org_name: string | null
org_id: string | null // org.id from the NAS response
org_id: string | null // org.id from the NAS response
role: string | null
context: 'personal' | 'team' // personal account vs team/org terminal
context: 'personal' | 'team' // personal account vs team/org terminal
current: {
tier_id: string | null // null = free (no active sub)
tier_id: string | null // null = free (no active sub)
tier_name: string | null
monthly_credits: string | null
credits_remaining: string | null
cycle_ends_at: string | null // ISO
cycle_ends_at: string | null // ISO
pending_downgrade_tier_name: string | null
pending_downgrade_at: string | null
pending_downgrade_display: string | null // formatted pending_downgrade_at
pending_downgrade_display: string | null // formatted pending_downgrade_at
cancel_at_period_end: boolean // subscription scheduled to cancel at period end
cancellation_effective_at: string | null // ISO when cancellation takes effect
cancellation_effective_display: string | null // formatted cancellation_effective_at
cancellation_effective_at: string | null // ISO when cancellation takes effect
cancellation_effective_display: string | null // formatted cancellation_effective_at
} | null
tiers: SubscriptionTierOption[] // selectable catalog for the in-terminal picker
tiers: SubscriptionTierOption[] // selectable catalog for the in-terminal picker
portal_url: string | null
error?: string | null
// Shared dollar usage model (two-bar view), embedded by the gateway so the
@ -206,8 +206,8 @@ export interface SubscriptionPreviewResponse {
target_tier_id?: string | null
target_tier_name?: string | null
monthly_credits_delta?: string | null
amount_due_now_cents?: number | null // the prorated upfront charge for an upgrade
effective_at?: string | null // ISO, when a scheduled change lands
amount_due_now_cents?: number | null // the prorated upfront charge for an upgrade
effective_at?: string | null // ISO, when a scheduled change lands
// typed-error envelope (present when ok=false)
error?: string
message?: string