Skip to content

Commit ea1be56

Browse files
committed
Merge main to get latest tooling
2 parents 80d5a92 + 972244d commit ea1be56

File tree

6 files changed

+213
-37
lines changed

6 files changed

+213
-37
lines changed

cli/knowledge.md

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -174,6 +174,69 @@ For columns that share space equally within a container, use the **flex trio pat
174174

175175
OpenTUI expects plain text content or the `content` prop - it does not handle JSX expressions within text elements.
176176

177+
## Interactive Clickable Elements and Text Selection
178+
179+
When building interactive UI in the CLI, text inside clickable areas should **not** be selectable. Otherwise users accidentally highlight text when clicking buttons, which creates a poor UX.
180+
181+
### Components
182+
183+
**`Button`** (`cli/src/components/button.tsx`) - Primary choice for clickable controls:
184+
- Automatically makes all nested `<text>`/`<span>` children non-selectable
185+
- Implements safe click detection via mouseDown/mouseUp tracking (prevents accidental clicks from hover events)
186+
- Use for standard button-like interactions
187+
188+
**`Clickable`** (`cli/src/components/clickable.tsx`) - For custom interactive regions:
189+
- Also makes all nested text non-selectable
190+
- Gives you direct control over mouse events (`onMouseDown`, `onMouseUp`, `onMouseOver`, `onMouseOut`)
191+
- Use when you need more control than `Button` provides
192+
193+
**`makeTextUnselectable()`** - Exported utility for edge cases:
194+
- Recursively processes React children to add `selectable={false}` to all `<text>` and `<span>` elements
195+
- Use when building custom interactive components that can't use `Button` or `Clickable`
196+
197+
### Usage Examples
198+
199+
```tsx
200+
// ✅ CORRECT: Use Button for clickable controls
201+
import { Button } from './button'
202+
203+
<Button onClick={handleClick}>
204+
<text>Click me</text>
205+
</Button>
206+
207+
// ✅ CORRECT: Use Clickable for custom mouse handling
208+
import { Clickable } from './clickable'
209+
210+
<Clickable
211+
onMouseDown={handleMouseDown}
212+
onMouseOver={() => setHovered(true)}
213+
onMouseOut={() => setHovered(false)}
214+
>
215+
<text>Hover or click me</text>
216+
</Clickable>
217+
218+
// ❌ WRONG: Raw <box> with mouse handlers (text will be selectable!)
219+
<box onMouseDown={handleClick}>
220+
<text>Click me</text> {/* Text can be accidentally selected */}
221+
</box>
222+
```
223+
224+
### When to Use Which
225+
226+
| Scenario | Use |
227+
|----------|-----|
228+
| Standard button | `Button` |
229+
| Link-like clickable text | `Button` |
230+
| Custom hover/click behavior | `Clickable` |
231+
| Building a new interactive primitive | `makeTextUnselectable()` |
232+
233+
### Why This Matters
234+
235+
These patterns:
236+
1. **Prevent accidental text selection** during clicks
237+
2. **Provide consistent behavior** across all interactive elements
238+
3. **Give future contributors clear building blocks** - no need to remember to add `selectable={false}` manually
239+
177240
## Screen Mode and TODO List Positioning
178241

179242
The CLI chat interface adapts its layout based on terminal dimensions:

cli/src/components/button.tsx

Lines changed: 19 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,8 @@
1-
import React, { cloneElement, isValidElement, memo, useRef, type ReactElement, type ReactNode } from 'react'
1+
import { memo, useRef } from 'react'
2+
3+
import { makeTextUnselectable } from './clickable'
4+
5+
import type { ReactNode } from 'react'
26

37
interface ButtonProps {
48
onClick?: (e?: unknown) => void | Promise<unknown>
@@ -10,33 +14,21 @@ interface ButtonProps {
1014
[key: string]: unknown
1115
}
1216

13-
function makeTextUnselectable(node: ReactNode): ReactNode {
14-
if (node === null || node === undefined || typeof node === 'boolean') return node
15-
if (typeof node === 'string' || typeof node === 'number') return node
16-
17-
if (Array.isArray(node)) {
18-
return node.map((child, idx) => <React.Fragment key={idx}>{makeTextUnselectable(child)}</React.Fragment>)
19-
}
20-
21-
if (!isValidElement(node)) return node
22-
23-
const el = node as ReactElement
24-
const type = el.type
25-
26-
// Ensure text nodes are not selectable
27-
if (typeof type === 'string' && type === 'text') {
28-
const nextProps = { ...el.props, selectable: false }
29-
const nextChildren = el.props?.children ? makeTextUnselectable(el.props.children) : el.props?.children
30-
return cloneElement(el, nextProps, nextChildren)
31-
}
32-
33-
// Recurse into other host elements and components' children
34-
const nextChildren = el.props?.children ? makeTextUnselectable(el.props.children) : el.props?.children
35-
return cloneElement(el, el.props, nextChildren)
36-
}
37-
38-
export const Button = memo(({ onClick, onMouseOver, onMouseOut, style, children, ...rest }: ButtonProps) => {
17+
/**
18+
* A button component with proper click detection and non-selectable text.
19+
*
20+
* Key behavior:
21+
* - All nested `<text>`/`<span>` children are made `selectable={false}` via `makeTextUnselectable`
22+
* - Uses mouseDown/mouseUp tracking so hover or stray mouse events don't trigger clicks
23+
*
24+
* When to use:
25+
* - Use `Button` for standard button-like interactions (primary choice for clickable controls)
26+
* - Use {@link Clickable} when you need direct control over mouse events but still want
27+
* non-selectable text for an interactive region.
28+
*/
29+
export const Button = memo(function Button({ onClick, onMouseOver, onMouseOut, style, children, ...rest }: ButtonProps) {
3930
const processedChildren = makeTextUnselectable(children)
31+
4032
// Track whether mouse down occurred on this element to implement proper click detection
4133
// This prevents hover from triggering clicks in some terminals
4234
const mouseDownRef = useRef(false)

cli/src/components/clickable.tsx

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
import React, { cloneElement, isValidElement, memo } from 'react'
2+
import type { ReactElement, ReactNode } from 'react'
3+
4+
/**
5+
* Makes all text content within a React node tree non-selectable.
6+
*
7+
* This is important for interactive elements (buttons, clickable boxes) because
8+
* text inside them should not be selectable when the user clicks - it creates
9+
* a poor UX where text gets highlighted during interactions.
10+
*
11+
* Handles both `<text>` and `<span>` OpenTUI elements by adding `selectable={false}`.
12+
*
13+
* @example
14+
* ```tsx
15+
* // Use this when building custom interactive components
16+
* const processedChildren = makeTextUnselectable(children)
17+
* return <box onMouseDown={handleClick}>{processedChildren}</box>
18+
* ```
19+
*/
20+
export function makeTextUnselectable(node: ReactNode): ReactNode {
21+
if (node === null || node === undefined || typeof node === 'boolean') return node
22+
if (typeof node === 'string' || typeof node === 'number') return node
23+
24+
if (Array.isArray(node)) {
25+
return node.map((child, idx) => <React.Fragment key={idx}>{makeTextUnselectable(child)}</React.Fragment>)
26+
}
27+
28+
if (!isValidElement(node)) return node
29+
30+
const el = node as ReactElement
31+
const type = el.type
32+
33+
// Ensure text and span nodes are not selectable
34+
if (typeof type === 'string' && (type === 'text' || type === 'span')) {
35+
const nextProps = { ...el.props, selectable: false }
36+
const nextChildren = el.props?.children ? makeTextUnselectable(el.props.children) : el.props?.children
37+
return cloneElement(el, nextProps, nextChildren)
38+
}
39+
40+
// Recurse into other host elements and components' children
41+
const nextChildren = el.props?.children ? makeTextUnselectable(el.props.children) : el.props?.children
42+
return cloneElement(el, el.props, nextChildren)
43+
}
44+
45+
interface ClickableProps {
46+
/** Element type to render: 'box' (default) or 'text' */
47+
as?: 'box' | 'text'
48+
onMouseDown?: (e?: unknown) => void
49+
onMouseUp?: (e?: unknown) => void
50+
onMouseOver?: () => void
51+
onMouseOut?: () => void
52+
style?: Record<string, unknown>
53+
children?: ReactNode
54+
// pass-through for host element props
55+
[key: string]: unknown
56+
}
57+
58+
/**
59+
* A wrapper component for any interactive/clickable area in the CLI.
60+
*
61+
* **Why use this instead of raw `<box>` or `<text>` with mouse handlers?**
62+
*
63+
* This component automatically makes all text content non-selectable, which is
64+
* essential for good UX - users shouldn't accidentally select text when clicking
65+
* interactive elements.
66+
*
67+
* **The `as` prop:**
68+
* - `as="box"` (default) - Renders a `<box>` element for layout containers
69+
* - `as="text"` - Renders a `<text>` element for inline clickable text
70+
*
71+
* **When to use `Clickable` vs `Button`:**
72+
* - Use `Button` for actual button-like interactions (has click-on-mouseup logic)
73+
* - Use `Clickable` for simpler interactive areas where you need direct mouse event control
74+
*
75+
* @example
76+
* ```tsx
77+
* // Default: renders <box>
78+
* <Clickable onMouseDown={handleClick}>
79+
* <text>Click me</text>
80+
* </Clickable>
81+
*
82+
* // For inline text: renders <text>
83+
* <Clickable as="text" onMouseDown={handleCopy}>
84+
* <span>⎘ copy</span>
85+
* </Clickable>
86+
* ```
87+
*/
88+
export const Clickable = memo(function Clickable({
89+
as = 'box',
90+
onMouseDown,
91+
onMouseUp,
92+
onMouseOver,
93+
onMouseOut,
94+
style,
95+
children,
96+
...rest
97+
}: ClickableProps) {
98+
const sharedProps = {
99+
...rest,
100+
style,
101+
onMouseDown,
102+
onMouseUp,
103+
onMouseOver,
104+
onMouseOut,
105+
}
106+
107+
if (as === 'text') {
108+
return (
109+
<text {...sharedProps} selectable={false}>
110+
{children}
111+
</text>
112+
)
113+
}
114+
115+
// Default: box with processed children
116+
const processedChildren = makeTextUnselectable(children)
117+
return <box {...sharedProps}>{processedChildren}</box>
118+
})

cli/src/components/copy-icon-button.tsx

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@ import { useTimeout } from '../hooks/use-timeout'
66
import { copyTextToClipboard } from '../utils/clipboard'
77

88
interface CopyButtonProps {
9-
textToCopy: string
109
isCopied?: boolean
1110
isHovered?: boolean
1211
/** Whether to include a leading space before the icon */

cli/src/components/message-block.tsx

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import React, { memo, useCallback, useState, type ReactNode } from 'react'
33

44
import { AgentBranchItem } from './agent-branch-item'
55
import { Button } from './button'
6+
import { Clickable } from './clickable'
67
import { CopyButton, useCopyButton } from './copy-icon-button'
78
import { ImageCard } from './image-card'
89
import { ImplementorGroup } from './implementor-row'
@@ -853,7 +854,8 @@ const UserTextWithInlineCopy = memo(
853854
const copyButton = useCopyButton(content)
854855

855856
return (
856-
<text
857+
<Clickable
858+
as="text"
857859
key={`message-content-${messageId}`}
858860
style={{ wrapMode: 'word', fg: textColor }}
859861
onMouseDown={copyButton.handleCopy}
@@ -868,8 +870,8 @@ const UserTextWithInlineCopy = memo(
868870
palette={palette}
869871
/>
870872
</span>
871-
<CopyButton textToCopy={content} isCopied={copyButton.isCopied} isHovered={copyButton.isHovered} />
872-
</text>
873+
<CopyButton isCopied={copyButton.isCopied} isHovered={copyButton.isHovered} />
874+
</Clickable>
873875
)
874876
},
875877
)
@@ -902,7 +904,8 @@ const UserBlockTextWithInlineCopy = memo(
902904
const copyButton = useCopyButton(contentToCopy)
903905

904906
return (
905-
<text
907+
<Clickable
908+
as="text"
906909
style={{
907910
wrapMode: 'word',
908911
fg: textColor,
@@ -921,8 +924,8 @@ const UserBlockTextWithInlineCopy = memo(
921924
palette={palette}
922925
/>
923926
</span>
924-
<CopyButton textToCopy={contentToCopy} isCopied={copyButton.isCopied} isHovered={copyButton.isHovered} />
925-
</text>
927+
<CopyButton isCopied={copyButton.isCopied} isHovered={copyButton.isHovered} />
928+
</Clickable>
926929
)
927930
},
928931
)

cli/src/components/message-footer.tsx

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { pluralize } from '@codebuff/common/util/string'
22
import { TextAttributes } from '@opentui/core'
33
import React, { useCallback, useMemo } from 'react'
44

5+
import { Clickable } from './clickable'
56
import { CopyButton, useCopyButton } from './copy-icon-button'
67
import { ElapsedTimer } from './elapsed-timer'
78
import { FeedbackIconButton } from './feedback-icon-button'
@@ -130,19 +131,19 @@ export const MessageFooter: React.FC<MessageFooterProps> = ({
130131
footerItems.push({
131132
key: 'copy',
132133
node: (
133-
<text
134+
<Clickable
135+
as="text"
134136
style={{ wrapMode: 'none' }}
135137
onMouseDown={copyButton.handleCopy}
136138
onMouseOver={copyButton.handleMouseOver}
137139
onMouseOut={copyButton.handleMouseOut}
138140
>
139141
<CopyButton
140-
textToCopy={textToCopy}
141142
isCopied={copyButton.isCopied}
142143
isHovered={copyButton.isHovered}
143144
leadingSpace={false}
144145
/>
145-
</text>
146+
</Clickable>
146147
),
147148
})
148149
}

0 commit comments

Comments
 (0)