Skip to content

Commit e4343ff

Browse files
committed
Fixes for login modal: stop clipboard errors, allow selecting text to copy
1 parent cb12e31 commit e4343ff

File tree

4 files changed

+49
-41
lines changed

4 files changed

+49
-41
lines changed

cli/src/components/login-modal.tsx

Lines changed: 22 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,9 @@
11
import { useRenderer } from '@opentui/react'
2-
import open from 'open'
32
import React, { useCallback, useEffect, useRef, useState } from 'react'
43

54
import { Button } from './button'
6-
import { TerminalLink } from './terminal-link'
75
import { useLoginMutation } from '../hooks/use-auth-query'
6+
import { useClipboard } from '../hooks/use-clipboard'
87
import { useFetchLoginUrl } from '../hooks/use-fetch-login-url'
98
import { useLoginKeyboardHandlers } from '../hooks/use-login-keyboard-handlers'
109
import { useLoginPolling } from '../hooks/use-login-polling'
@@ -216,19 +215,6 @@ export const LoginModal = ({
216215
[maxUrlWidth],
217216
)
218217

219-
// Handle login URL activation
220-
const handleActivateLoginUrl = useCallback(async () => {
221-
if (!loginUrl) {
222-
return
223-
}
224-
try {
225-
await open(loginUrl)
226-
} catch (err) {
227-
logger.error(err, 'Failed to open browser on link click')
228-
}
229-
return copyToClipboard(loginUrl)
230-
}, [loginUrl, copyToClipboard])
231-
232218
// Use custom hook for sheen animation
233219
const blockColor = getLogoBlockColor(theme.name)
234220
const accentColor = getLogoAccentColor(theme.name)
@@ -248,6 +234,10 @@ export const LoginModal = ({
248234
textColor: theme.foreground,
249235
})
250236

237+
// Enable auto-copy when user selects text (drag to select)
238+
// hasSelection provides visual feedback when text is being selected
239+
const { hasSelection } = useClipboard()
240+
251241
// Format URL for display (wrap if needed)
252242
return (
253243
<box
@@ -385,29 +375,30 @@ export const LoginModal = ({
385375
</text>
386376
<box
387377
style={{
388-
marginTop: isVerySmall ? 1 : 2,
389378
width: '100%',
390379
flexShrink: 0,
380+
flexDirection: 'column',
381+
alignItems: 'flex-start',
391382
}}
392383
>
393-
<TerminalLink
394-
text={loginUrl}
395-
maxWidth={maxUrlWidth}
396-
formatLines={formatLoginUrlLines}
397-
color={theme.primary}
398-
activeColor={theme.success}
399-
underlineOnHover={true}
400-
isActive={justCopied}
401-
onActivate={handleActivateLoginUrl}
402-
containerStyle={{
403-
alignItems: 'flex-start',
404-
flexShrink: 0,
405-
}}
406-
/>
384+
{formatLoginUrlLines(loginUrl, maxUrlWidth).map((line, index) => (
385+
<text key={index} style={{ wrapMode: 'none' }}>
386+
<span
387+
fg={
388+
justCopied
389+
? theme.success
390+
: hasSelection
391+
? theme.info
392+
: theme.primary
393+
}
394+
>
395+
{line}
396+
</span>
397+
</text>
398+
))}
407399
</box>
408400
<box
409401
style={{
410-
marginTop: isVerySmall ? 1 : 2,
411402
flexDirection: 'column',
412403
alignItems: 'center',
413404
width: '100%',

cli/src/hooks/use-clipboard.ts

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ function formatDefaultClipboardMessage(text: string): string | null {
1919
export const useClipboard = () => {
2020
const renderer = useRenderer()
2121
const [statusMessage, setStatusMessage] = useState<string | null>(null)
22+
const [hasSelection, setHasSelection] = useState(false)
2223
const pendingCopyTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(
2324
null,
2425
)
@@ -43,6 +44,7 @@ export const useClipboard = () => {
4344

4445
if (!cleanedText || cleanedText.trim().length === 0) {
4546
pendingSelectionRef.current = null
47+
setHasSelection(false)
4648
if (pendingCopyTimeoutRef.current) {
4749
clearTimeout(pendingCopyTimeoutRef.current)
4850
pendingCopyTimeoutRef.current = null
@@ -54,6 +56,9 @@ export const useClipboard = () => {
5456
return
5557
}
5658

59+
// Track that there's an active selection for visual feedback
60+
setHasSelection(true)
61+
5762
pendingSelectionRef.current = cleanedText
5863

5964
if (pendingCopyTimeoutRef.current) {
@@ -72,9 +77,14 @@ export const useClipboard = () => {
7277
void copyTextToClipboard(pending, {
7378
successMessage,
7479
durationMs: 3000,
75-
}).catch(() => {
76-
// Errors are logged within copyTextToClipboard
7780
})
81+
.then(() => {
82+
// Clear selection visual state after successful copy
83+
setHasSelection(false)
84+
})
85+
.catch(() => {
86+
// Errors are logged within copyTextToClipboard
87+
})
7888
}, 250)
7989
}
8090

@@ -98,5 +108,6 @@ export const useClipboard = () => {
98108

99109
return {
100110
statusMessage,
111+
hasSelection,
101112
}
102113
}

cli/src/hooks/use-login-keyboard-handlers.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ interface UseLoginKeyboardHandlersParams {
88
hasOpenedBrowser: boolean
99
loading: boolean
1010
onFetchLoginUrl: () => void
11-
onCopyUrl: (url: string) => void
11+
onCopyUrl: (url: string) => Promise<void> | void
1212
}
1313

1414
/**
@@ -65,7 +65,9 @@ export function useLoginKeyboardHandlers({
6565
key.preventDefault()
6666
}
6767

68-
onCopyUrl(loginUrl)
68+
// Fire-and-forget the async copy function with .catch() to prevent
69+
// unhandled promise rejections if the implementation changes
70+
void Promise.resolve(onCopyUrl(loginUrl)).catch(() => {})
6971
}
7072
},
7173
[loginUrl, hasOpenedBrowser, loading, onCopyUrl, onFetchLoginUrl],

cli/src/utils/clipboard.ts

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -86,19 +86,23 @@ export async function copyTextToClipboard(
8686
} else if (typeof process !== 'undefined' && process.platform) {
8787
// NOTE: Inline require() is used because this code path only runs in Node.js
8888
// environments, and we need to check process.platform at runtime first
89-
const { execSync } = require('child_process') as {
90-
execSync: (command: string, options: { input: string }) => void
89+
const { execSync } = require('child_process') as typeof import('child_process')
90+
// Use stdio: ['pipe', 'ignore', 'ignore'] to prevent stderr from corrupting the TUI on headless servers
91+
// stdin needs 'pipe' for input, stdout/stderr use 'ignore' to discard any output
92+
const execOptions: { input: string; stdio: ('pipe' | 'ignore')[] } = {
93+
input: text,
94+
stdio: ['pipe', 'ignore', 'ignore'],
9195
}
9296
if (process.platform === 'darwin') {
93-
execSync('pbcopy', { input: text })
97+
execSync('pbcopy', execOptions)
9498
} else if (process.platform === 'linux') {
9599
try {
96-
execSync('xclip -selection clipboard', { input: text })
100+
execSync('xclip -selection clipboard', execOptions)
97101
} catch {
98-
execSync('xsel --clipboard --input', { input: text })
102+
execSync('xsel --clipboard --input', execOptions)
99103
}
100104
} else if (process.platform === 'win32') {
101-
execSync('clip', { input: text })
105+
execSync('clip', execOptions)
102106
}
103107
} else {
104108
return

0 commit comments

Comments
 (0)