Skip to content

Commit 0a1bd36

Browse files
jahoomaclaude
andcommitted
Handle Ctrl+C on freebuff waiting-room / superseded screens
Stdin is in raw mode on these screens, so SIGINT never fires — Ctrl+C had no effect and users had to kill the process. Now both screens hook Ctrl+C via OpenTUI's useKeyboard, flush analytics with a 1s cap, and exit. The waiting-room screen additionally sends a best-effort DELETE /api/v1/freebuff/session before exit so the seat frees up immediately instead of waiting on the server-side expiry sweep. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent f5f2f60 commit 0a1bd36

File tree

3 files changed

+101
-29
lines changed

3 files changed

+101
-29
lines changed

cli/src/components/freebuff-superseded-screen.tsx

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,19 @@
11
import { TextAttributes } from '@opentui/core'
2-
import React from 'react'
2+
import { useKeyboard } from '@opentui/react'
3+
import React, { useCallback } from 'react'
34

45
import { useLogo } from '../hooks/use-logo'
56
import { useTerminalDimensions } from '../hooks/use-terminal-dimensions'
67
import { useTheme } from '../hooks/use-theme'
8+
import { flushAnalytics } from '../utils/analytics'
9+
import { withTimeout } from '../utils/terminal-color-detection'
710
import { getLogoAccentColor, getLogoBlockColor } from '../utils/theme-system'
811

12+
import type { KeyEvent } from '@opentui/core'
13+
14+
/** Cap on analytics flush so a slow network doesn't block process exit. */
15+
const EXIT_CLEANUP_TIMEOUT_MS = 1000
16+
917
/**
1018
* Terminal state shown after a 409 session_superseded response. Another CLI on
1119
* the same account rotated our instance id and we've stopped polling — the
@@ -22,6 +30,22 @@ export const FreebuffSupersededScreen: React.FC = () => {
2230
blockColor,
2331
})
2432

33+
// Ctrl+C exits. Stdin is in raw mode, so SIGINT never fires — the key comes
34+
// through as a normal OpenTUI key event. No DELETE needed here: the other
35+
// CLI already rotated our instance id, so our seat (if any) belongs to them.
36+
useKeyboard(
37+
useCallback((key: KeyEvent) => {
38+
if (key.ctrl && key.name === 'c') {
39+
key.preventDefault?.()
40+
withTimeout(flushAnalytics(), EXIT_CLEANUP_TIMEOUT_MS, undefined).finally(
41+
() => {
42+
process.exit(0)
43+
},
44+
)
45+
}
46+
}, []),
47+
)
48+
2549
return (
2650
<box
2751
style={{

cli/src/components/waiting-room-screen.tsx

Lines changed: 55 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,26 @@
11
import { TextAttributes } from '@opentui/core'
2-
import { useRenderer } from '@opentui/react'
3-
import React, { useEffect, useMemo, useState } from 'react'
2+
import { useKeyboard, useRenderer } from '@opentui/react'
3+
import React, { useCallback, useEffect, useMemo, useState } from 'react'
44

55
import { AdBanner } from './ad-banner'
66
import { ChoiceAdBanner } from './choice-ad-banner'
77
import { ShimmerText } from './shimmer-text'
8+
import { endFreebuffSessionBestEffort } from '../hooks/use-freebuff-session'
89
import { useGravityAd } from '../hooks/use-gravity-ad'
910
import { useLogo } from '../hooks/use-logo'
1011
import { useSheenAnimation } from '../hooks/use-sheen-animation'
1112
import { useTerminalDimensions } from '../hooks/use-terminal-dimensions'
1213
import { useTheme } from '../hooks/use-theme'
14+
import { flushAnalytics } from '../utils/analytics'
15+
import { withTimeout } from '../utils/terminal-color-detection'
1316
import { getLogoAccentColor, getLogoBlockColor } from '../utils/theme-system'
1417

1518
import type { FreebuffSessionResponse } from '../types/freebuff-session'
19+
import type { KeyEvent } from '@opentui/core'
20+
21+
/** Cap on exit cleanup (DELETE /session + flushAnalytics) so a slow network
22+
* doesn't block process exit. */
23+
const EXIT_CLEANUP_TIMEOUT_MS = 1000
1624

1725
interface WaitingRoomScreenProps {
1826
session: FreebuffSessionResponse | null
@@ -68,6 +76,24 @@ export const WaitingRoomScreen: React.FC<WaitingRoomScreenProps> = ({
6876
// Always enable ads in the waiting room — this is where monetization lives.
6977
const { ad, adData, recordImpression } = useGravityAd({ enabled: true })
7078

79+
// Ctrl+C exits. Stdin is in raw mode, so SIGINT never fires — the key comes
80+
// through as a normal OpenTUI key event. Release the seat before exit so
81+
// the next user in line doesn't have to wait for server-side expiry.
82+
useKeyboard(
83+
useCallback((key: KeyEvent) => {
84+
if (key.ctrl && key.name === 'c') {
85+
key.preventDefault?.()
86+
const cleanup = Promise.allSettled([
87+
flushAnalytics(),
88+
endFreebuffSessionBestEffort(),
89+
])
90+
withTimeout(cleanup, EXIT_CLEANUP_TIMEOUT_MS, undefined).finally(() => {
91+
process.exit(0)
92+
})
93+
}
94+
}, []),
95+
)
96+
7197
// Elapsed-in-queue timer. Starts from `queuedAt` so it keeps ticking even if
7298
// the user wanders away and comes back.
7399
const queuedAtMs = useMemo(() => {
@@ -127,40 +153,41 @@ export const WaitingRoomScreen: React.FC<WaitingRoomScreenProps> = ({
127153

128154
{isQueued && session && (
129155
<>
130-
<text
131-
style={{ fg: theme.foreground, marginBottom: 1 }}
132-
>
133-
<ShimmerText text="You're in the waiting room" />
156+
<text style={{ fg: theme.foreground, marginBottom: 1 }}>
157+
You're in the waiting room
134158
</text>
135159

136160
<box
137161
style={{
138162
flexDirection: 'column',
139-
alignItems: 'center',
163+
alignItems: 'flex-start',
140164
gap: 0,
141165
}}
142166
>
143-
<text style={{ fg: theme.foreground }}>
144-
Position{' '}
145-
<span fg={theme.primary} attributes={TextAttributes.BOLD}>
146-
{session.position}
147-
</span>
148-
<span fg={theme.muted}> of {session.queueDepth}</span>
149-
</text>
150-
<text style={{ fg: theme.foreground }}>
151-
Estimated wait:{' '}
152-
<span fg={theme.primary}>
153-
{formatWait(session.estimatedWaitMs)}
154-
</span>
155-
</text>
156-
<text style={{ fg: theme.muted }}>
157-
Waiting for {formatElapsed(elapsedMs)}
158-
</text>
159-
</box>
160-
161-
<box style={{ marginTop: 1, alignItems: 'center' }}>
162-
<text style={{ fg: theme.muted, wrapMode: 'word' }}>
163-
Leave this window open — we'll ding when your session starts.
167+
{session.position === 1 ? (
168+
<text style={{ fg: theme.primary, alignSelf: 'flex-start' }}>
169+
<ShimmerText text="Next in line" />
170+
</text>
171+
) : (
172+
<text style={{ fg: theme.foreground, alignSelf: 'flex-start' }}>
173+
<span fg={theme.muted}>Position </span>
174+
<span fg={theme.primary} attributes={TextAttributes.BOLD}>
175+
{session.position}
176+
</span>
177+
<span fg={theme.muted}> / {session.queueDepth}</span>
178+
</text>
179+
)}
180+
{session.position !== 1 && (
181+
<text style={{ fg: theme.foreground, alignSelf: 'flex-start' }}>
182+
<span fg={theme.muted}>Wait </span>
183+
<span fg={theme.primary}>
184+
<ShimmerText text={formatWait(session.estimatedWaitMs)} />
185+
</span>
186+
</text>
187+
)}
188+
<text style={{ fg: theme.muted, alignSelf: 'flex-start' }}>
189+
<span>Elapsed </span>
190+
{formatElapsed(elapsedMs)}
164191
</text>
165192
</box>
166193
</>

cli/src/hooks/use-freebuff-session.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,27 @@ export function markFreebuffSessionSuperseded(): void {
114114
activeRefreshHandle?.markSuperseded()
115115
}
116116

117+
/**
118+
* Best-effort DELETE of the caller's session row. Used by exit paths that
119+
* skip React unmount (process.exit on Ctrl+C) so the seat frees up quickly
120+
* instead of waiting for the server-side expiry sweep. Swallows errors
121+
* because we are about to terminate anyway.
122+
*/
123+
export async function endFreebuffSessionBestEffort(): Promise<void> {
124+
if (!IS_FREEBUFF) return
125+
const current = useFreebuffSessionStore.getState().session
126+
if (!current || (current.status !== 'queued' && current.status !== 'active')) {
127+
return
128+
}
129+
const { token } = getAuthTokenDetails()
130+
if (!token) return
131+
try {
132+
await callSession('DELETE', token)
133+
} catch {
134+
// swallow — we're exiting
135+
}
136+
}
137+
117138
/**
118139
* Manages the freebuff waiting-room session lifecycle:
119140
* - POST on mount to join the queue / rotate instance id

0 commit comments

Comments
 (0)