Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
88 changes: 55 additions & 33 deletions packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@ export function Autocomplete(props: {
index: 0,
selected: 0,
visible: false as AutocompleteRef["visible"],
mouseHasMoved: false,
})

const [positionTick, setPositionTick] = createSignal(0)
Expand Down Expand Up @@ -588,9 +589,17 @@ export function Autocomplete(props: {
setStore({
visible: mode,
index: props.input().cursorOffset,
mouseHasMoved: false,
})
}

// Reset mouse movement tracking when filter changes to prevent accidental selection
// when items change position under a stationary cursor
createEffect(() => {
filter()
setStore("mouseHasMoved", false)
})

function hide() {
const text = props.input().plainText
if (store.visible === "/" && !text.endsWith(" ") && text.startsWith("/")) {
Expand Down Expand Up @@ -723,41 +732,54 @@ export function Autocomplete(props: {
{...SplitBorder}
borderColor={theme.border}
>
<scrollbox
ref={(r: ScrollBoxRenderable) => (scroll = r)}
backgroundColor={theme.backgroundMenu}
height={height()}
scrollbarOptions={{ visible: false }}
>
<Index
each={options()}
fallback={
<box paddingLeft={1} paddingRight={1}>
<text fg={theme.textMuted}>No matching items</text>
</box>
}
<box onMouseMove={() => setStore("mouseHasMoved", true)}>
<scrollbox
ref={(r: ScrollBoxRenderable) => (scroll = r)}
backgroundColor={theme.backgroundMenu}
height={height()}
scrollbarOptions={{ visible: false }}
>
{(option, index) => (
<box
paddingLeft={1}
paddingRight={1}
backgroundColor={index === store.selected ? theme.primary : undefined}
flexDirection="row"
onMouseOver={() => moveTo(index)}
onMouseUp={() => select()}
>
<text fg={index === store.selected ? selectedForeground(theme) : theme.text} flexShrink={0}>
{option().display}
</text>
<Show when={option().description}>
<text fg={index === store.selected ? selectedForeground(theme) : theme.textMuted} wrapMode="none">
{option().description}
<Index
each={options()}
fallback={
<box paddingLeft={1} paddingRight={1}>
<text fg={theme.textMuted}>No matching items</text>
</box>
}
>
{(option, index) => (
<box
paddingLeft={1}
paddingRight={1}
backgroundColor={index === store.selected ? theme.primary : undefined}
flexDirection="row"
onMouseMove={() => {
// Once mouse moves, enable hover selection and immediately select this item
if (!store.mouseHasMoved) {
setStore("mouseHasMoved", true)
moveTo(index)
}
}}
onMouseOver={() => {
// Only select on hover if mouse has moved (prevents accidental selection)
if (!store.mouseHasMoved) return
moveTo(index)
}}
onMouseUp={() => select()}
>
<text fg={index === store.selected ? selectedForeground(theme) : theme.text} flexShrink={0}>
{option().display}
</text>
</Show>
</box>
)}
</Index>
</scrollbox>
<Show when={option().description}>
<text fg={index === store.selected ? selectedForeground(theme) : theme.textMuted} wrapMode="none">
{option().description}
</text>
</Show>
</box>
)}
</Index>
</scrollbox>
</box>
</box>
)
}
129 changes: 74 additions & 55 deletions packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
const [store, setStore] = createStore({
selected: 0,
filter: "",
mouseHasMoved: false,
})

createEffect(
Expand Down Expand Up @@ -109,6 +110,10 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {

createEffect(
on([() => store.filter, () => props.current], ([filter, current]) => {
// Reset mouse movement tracking when filter changes to prevent accidental selection
// when items change position under a stationary cursor
setStore("mouseHasMoved", false)

setTimeout(() => {
if (filter.length > 0) {
moveTo(0, true)
Expand Down Expand Up @@ -248,61 +253,75 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
</box>
}
>
<scrollbox
paddingLeft={1}
paddingRight={1}
scrollbarOptions={{ visible: false }}
ref={(r: ScrollBoxRenderable) => (scroll = r)}
maxHeight={height()}
>
<For each={grouped()}>
{([category, options], index) => (
<>
<Show when={category}>
<box paddingTop={index() > 0 ? 1 : 0} paddingLeft={3}>
<text fg={theme.accent} attributes={TextAttributes.BOLD}>
{category}
</text>
</box>
</Show>
<For each={options}>
{(option) => {
const active = createMemo(() => isDeepEqual(option.value, selected()?.value))
const current = createMemo(() => isDeepEqual(option.value, props.current))
return (
<box
id={JSON.stringify(option.value)}
flexDirection="row"
onMouseUp={() => {
option.onSelect?.(dialog)
props.onSelect?.(option)
}}
onMouseOver={() => {
const index = flat().findIndex((x) => isDeepEqual(x.value, option.value))
if (index === -1) return
moveTo(index)
}}
backgroundColor={active() ? (option.bg ?? theme.primary) : RGBA.fromInts(0, 0, 0, 0)}
paddingLeft={current() || option.gutter ? 1 : 3}
paddingRight={3}
gap={1}
>
<Option
title={option.title}
footer={option.footer}
description={option.description !== category ? option.description : undefined}
active={active()}
current={current()}
gutter={option.gutter}
/>
</box>
)
}}
</For>
</>
)}
</For>
</scrollbox>
<box onMouseMove={() => setStore("mouseHasMoved", true)}>
<scrollbox
paddingLeft={1}
paddingRight={1}
scrollbarOptions={{ visible: false }}
ref={(r: ScrollBoxRenderable) => (scroll = r)}
maxHeight={height()}
>
<For each={grouped()}>
{([category, options], index) => (
<>
<Show when={category}>
<box paddingTop={index() > 0 ? 1 : 0} paddingLeft={3}>
<text fg={theme.accent} attributes={TextAttributes.BOLD}>
{category}
</text>
</box>
</Show>
<For each={options}>
{(option) => {
const active = createMemo(() => isDeepEqual(option.value, selected()?.value))
const current = createMemo(() => isDeepEqual(option.value, props.current))
return (
<box
id={JSON.stringify(option.value)}
flexDirection="row"
onMouseMove={() => {
// Once mouse moves, enable hover selection and immediately select this item
if (!store.mouseHasMoved) {
setStore("mouseHasMoved", true)
const index = flat().findIndex((x) => isDeepEqual(x.value, option.value))
if (index !== -1) moveTo(index)
}
}}
onMouseUp={() => {
option.onSelect?.(dialog)
props.onSelect?.(option)
}}
onMouseOver={() => {
// Only select on hover if mouse has moved (prevents accidental selection)
if (!store.mouseHasMoved) return

const index = flat().findIndex((x) => isDeepEqual(x.value, option.value))
if (index === -1) return

moveTo(index)
}}
backgroundColor={active() ? (option.bg ?? theme.primary) : RGBA.fromInts(0, 0, 0, 0)}
paddingLeft={current() || option.gutter ? 1 : 3}
paddingRight={3}
gap={1}
>
<Option
title={option.title}
footer={option.footer}
description={option.description !== category ? option.description : undefined}
active={active()}
current={current()}
gutter={option.gutter}
/>
</box>
)
}}
</For>
</>
)}
</For>
</scrollbox>
</box>
</Show>
<Show when={keybinds().length} fallback={<box flexShrink={0} />}>
<box paddingRight={2} paddingLeft={4} flexDirection="row" gap={2} flexShrink={0} paddingTop={1}>
Expand Down