Skip to content

Commit 82d0854

Browse files
committed
Add blinking cursor and keyboard shortcuts to feedback modal
- Extract cursor to reusable InputCursor component with idle blink animation - Implement 500ms blink cycle (visible/invisible) after idle detection - Add Ctrl+C handler: clear input first, close modal if already empty - Add Escape key to immediately close modal - Refactor MultilineInput to use new InputCursor component
1 parent b77b5ce commit 82d0854

File tree

3 files changed

+128
-8
lines changed

3 files changed

+128
-8
lines changed

cli/src/components/feedback-modal.tsx

Lines changed: 36 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import React, { useCallback, useRef, useState } from 'react'
2-
import { useRenderer } from '@opentui/react'
2+
import { useRenderer, useKeyboard } from '@opentui/react'
33

44
import { MultilineInput, type MultilineInputHandle } from './multiline-input'
55
import { Button } from './button'
@@ -38,6 +38,40 @@ export const FeedbackModal: React.FC<FeedbackModalProps> = ({ open, onClose, onS
3838
setCategory('other')
3939
}, [onSubmit, feedbackText, category])
4040

41+
// Handle Ctrl+C: clear input first, then close if already empty
42+
// Handle Escape: close modal directly
43+
useKeyboard(
44+
useCallback(
45+
(key) => {
46+
if (!open) return
47+
48+
const isCtrlC = key.ctrl && key.name === 'c'
49+
const isEscape = key.name === 'escape'
50+
51+
if (!isCtrlC && !isEscape) return
52+
53+
if ('preventDefault' in key && typeof key.preventDefault === 'function') {
54+
key.preventDefault()
55+
}
56+
57+
if (isEscape) {
58+
// Escape always closes the modal
59+
onClose()
60+
} else if (isCtrlC) {
61+
if (feedbackText.length === 0) {
62+
// Input is already empty, close the modal
63+
onClose()
64+
} else {
65+
// Clear the input
66+
setFeedbackText('')
67+
setFeedbackCursor(0)
68+
}
69+
}
70+
},
71+
[open, feedbackText, onClose]
72+
)
73+
)
74+
4175
if (!open) return null
4276

4377
const categoryOptions = [
@@ -170,7 +204,7 @@ export const FeedbackModal: React.FC<FeedbackModalProps> = ({ open, onClose, onS
170204
}}
171205
>
172206
<text style={{ wrapMode: 'none' }}>
173-
<span fg={canSubmit ? theme.foreground : theme.muted}>{'< SUBMIT'}</span>
207+
<span fg={canSubmit ? theme.foreground : theme.muted}>{'SUBMIT'}</span>
174208
</text>
175209
</Button>
176210
</box>
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
import { TextAttributes } from '@opentui/core'
2+
import React, { useEffect, useRef, useState } from 'react'
3+
import { useTheme } from '../hooks/use-theme'
4+
5+
interface InputCursorProps {
6+
visible: boolean
7+
focused: boolean
8+
char?: string
9+
color?: string
10+
dimColor?: string
11+
blinkDelay?: number
12+
blinkInterval?: number
13+
bold?: boolean
14+
}
15+
16+
export const InputCursor: React.FC<InputCursorProps> = ({
17+
visible,
18+
focused,
19+
char = '▍',
20+
color,
21+
dimColor,
22+
blinkDelay = 500,
23+
blinkInterval = 500, // Faster blinking
24+
bold = true,
25+
}) => {
26+
const theme = useTheme()
27+
// false = normal/visible, true = invisible
28+
const [isInvisible, setIsInvisible] = useState(false)
29+
const blinkIntervalRef = useRef<NodeJS.Timeout | null>(null)
30+
31+
// Handle blinking (toggle visible/invisible) when idle
32+
useEffect(() => {
33+
// Clear any existing interval
34+
if (blinkIntervalRef.current) {
35+
clearInterval(blinkIntervalRef.current)
36+
blinkIntervalRef.current = null
37+
}
38+
39+
// Reset cursor to visible
40+
setIsInvisible(false)
41+
42+
if (!focused || !visible) return
43+
44+
// Set up idle detection
45+
const idleTimer = setTimeout(() => {
46+
// Start blinking interval (toggle between visible and invisible)
47+
blinkIntervalRef.current = setInterval(() => {
48+
setIsInvisible((prev) => !prev)
49+
}, blinkInterval)
50+
}, blinkDelay)
51+
52+
return () => {
53+
clearTimeout(idleTimer)
54+
if (blinkIntervalRef.current) {
55+
clearInterval(blinkIntervalRef.current)
56+
blinkIntervalRef.current = null
57+
}
58+
}
59+
}, [visible, focused, blinkDelay, blinkInterval])
60+
61+
if (!visible || !focused) {
62+
return null
63+
}
64+
65+
// When invisible, return a space to maintain layout
66+
if (isInvisible) {
67+
return <span> </span>
68+
}
69+
70+
return (
71+
<span
72+
{...(color ? { fg: color } : undefined)}
73+
{...(bold ? { attributes: TextAttributes.BOLD } : undefined)}
74+
>
75+
{char}
76+
</span>
77+
)
78+
}

cli/src/components/multiline-input.tsx

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import {
1212

1313
import { useOpentuiPaste } from '../hooks/use-opentui-paste'
1414
import { useTheme } from '../hooks/use-theme'
15+
import { InputCursor } from './input-cursor'
1516
import { clamp } from '../utils/math'
1617
import { computeInputLayoutMetrics } from '../utils/text-layout'
1718
import { calculateNewCursorPosition } from '../utils/word-wrap-utils'
@@ -126,6 +127,13 @@ export const MultilineInput = forwardRef<
126127
const theme = useTheme()
127128
const scrollBoxRef = useRef<ScrollBoxRenderable | null>(null)
128129
const [measuredCols, setMeasuredCols] = useState<number | null>(null)
130+
const [lastActivity, setLastActivity] = useState(Date.now())
131+
132+
// Update last activity on value or cursor changes
133+
useEffect(() => {
134+
setLastActivity(Date.now())
135+
}, [value, cursorPosition])
136+
129137
const getEffectiveCols = useCallback(() => {
130138
// Prefer measured viewport columns; fallback to a conservative
131139
// estimate: outer width minus border(2) minus padding(2) = 4.
@@ -787,12 +795,12 @@ export const MultilineInput = forwardRef<
787795
{activeChar === ' ' ? '\u00a0' : activeChar}
788796
</span>
789797
) : (
790-
<span
791-
{...(cursorFg ? { fg: cursorFg } : undefined)}
792-
attributes={TextAttributes.BOLD}
793-
>
794-
{CURSOR_CHAR}
795-
</span>
798+
<InputCursor
799+
visible={true}
800+
focused={focused}
801+
color={cursorFg}
802+
key={lastActivity}
803+
/>
796804
)}
797805
{shouldHighlight
798806
? afterCursor.length > 0

0 commit comments

Comments
 (0)