Files
ClosedCode/packages/tui/src/ui/dialog-select.tsx
T
Simon Klee e7c59b17a8 fix(tui): load root sessions in session switcher (#33931)
Request root sessions before applying the session list limit so child
sessions cannot crowd roots out of the switcher.

Keep the synchronized cache available while requests are pending or fail,
reconcile results with live updates, and retain current and pinned sessions.
Preserve selection by session ID when asynchronous results reorder the list.

Closes #16270
Closes #32725
2026-06-25 18:36:08 +02:00

791 lines
25 KiB
TypeScript

import {
InputRenderable,
RGBA,
ScrollBoxRenderable,
TextAttributes,
type KeyEvent,
type Renderable,
} from "@opentui/core"
import type { Binding } from "@opentui/keymap"
import { useTheme, selectedForeground } from "../context/theme"
import { entries, filter, flatMap, groupBy, pipe } from "remeda"
import { batch, createEffect, createMemo, createSignal, For, Show, type JSX, on, onCleanup } from "solid-js"
import { createStore } from "solid-js/store"
import { useTerminalDimensions } from "@opentui/solid"
import * as fuzzysort from "fuzzysort"
import { isDeepEqual } from "remeda"
import { useDialog, type DialogContext } from "./dialog"
import { Locale } from "../util/locale"
import { getScrollAcceleration } from "../util/scroll"
import { useTuiConfig } from "../config"
import { formatKeyBindings, useBindings, useKeymapSelector } from "../keymap"
export interface DialogSelectProps<T> {
title: string
titleView?: JSX.Element
placeholder?: string
footer?: JSX.Element
emptyView?: JSX.Element
options: DialogSelectOption<T>[]
flat?: boolean
ref?: (ref: DialogSelectRef<T>) => void
onMove?: (option: DialogSelectOption<T>) => void
onFilter?: (query: string) => void
onSelect?: (option: DialogSelectOption<T>) => void
skipFilter?: boolean
renderFilter?: boolean
locked?: boolean
preserveSelection?: boolean
actions?: {
command: string
title: string
side?: "left" | "right"
hidden?: boolean
disabled?: boolean | ((option: DialogSelectOption<T> | undefined) => boolean)
onTrigger: (option: DialogSelectOption<T>) => void
}[]
footerHints?: {
title: string
label: string
side?: "left" | "right"
}[]
bindings?: readonly Binding<Renderable, KeyEvent>[]
current?: T
}
export interface DialogSelectOption<T = any> {
title: string
titleView?: JSX.Element
value: T
description?: string
details?: string[]
footer?: JSX.Element | string
titleWidth?: number
truncateTitle?: boolean | "left"
category?: string
categoryView?: JSX.Element
disabled?: boolean
bg?: RGBA
gutter?: () => JSX.Element
margin?: JSX.Element
onSelect?: (ctx: DialogContext) => void
}
export type DialogSelectRef<T> = {
filter: string
filtered: DialogSelectOption<T>[]
moveTo(value: T): void
}
export function DialogSelect<T>(props: DialogSelectProps<T>) {
type Action = NonNullable<DialogSelectProps<T>["actions"]>[number]
type FooterHint = NonNullable<DialogSelectProps<T>["footerHints"]>[number]
type VisibleAction = (Action & { label: string }) | FooterHint
const dialog = useDialog()
const { theme } = useTheme()
const tuiConfig = useTuiConfig()
const scrollAcceleration = createMemo(() => getScrollAcceleration(tuiConfig))
const [store, setStore] = createStore({
selected: 0,
filter: "",
input: "keyboard" as "keyboard" | "mouse",
})
const [focusedAction, setFocusedAction] = createSignal<number>()
const actionFocused = createMemo(() => focusedAction() !== undefined)
let selection: { value: T; category?: string } | undefined
let resetSelection = false
let visibilityGeneration = 0
createEffect(
on(
() => props.current,
(current) => {
if (current) {
const currentIndex = flat().findIndex((opt) => isDeepEqual(opt.value, current))
if (currentIndex >= 0) {
setStore("selected", currentIndex)
selection = flat()[currentIndex]
}
}
},
),
)
let input: InputRenderable
const actions = createMemo(() => props.actions ?? [])
const shownActions = createMemo(() => actions().filter((item) => !item.hidden))
const actionBindings = useKeymapSelector((keymap) =>
keymap.getCommandBindings({
visibility: "registered",
commands: shownActions().map((item) => item.command),
}),
)
const actionLabels = createMemo(() => {
const labels = new Map<string, string>()
for (const action of shownActions()) {
const label = formatKeyBindings(actionBindings().get(action.command), tuiConfig)
if (label) labels.set(action.command, label)
}
return labels
})
const visibleActions = createMemo(() => [
...shownActions()
.map((item) => ({ ...item, label: actionLabels().get(item.command) ?? "" }))
.filter((item) => item.label),
...(props.footerHints ?? []),
])
const actionItems = createMemo(() =>
visibleActions()
.filter(isActionItem)
.filter((item) => !isActionDisabled(item)),
)
createEffect(() => {
const index = focusedAction()
if (index !== undefined && index >= actionItems().length) setFocusedAction(undefined)
})
const filtered = createMemo(() => {
if (props.skipFilter || props.renderFilter === false) return props.options.filter((x) => x.disabled !== true)
const needle = store.filter.toLowerCase()
const options = pipe(
props.options,
filter((x) => x.disabled !== true),
)
if (!needle) return options
// prioritize title matches (weight: 2) over category matches (weight: 1).
// users typically search by the item name, and not its category.
const result = fuzzysort
.go(needle, options, {
keys: ["title", "category"],
scoreFn: (r) => r[0].score * 2 + r[1].score,
})
.map((x) => x.obj)
return result
})
// When the filter changes due to how TUI works, the mousemove might still be triggered
// via a synthetic event as the layout moves underneath the cursor. This is a workaround to make sure the input mode remains keyboard
// that the mouseover event doesn't trigger when filtering.
createEffect(() => {
filtered()
setStore("input", "keyboard")
setFocusedAction(undefined)
})
const flatten = createMemo(() => props.flat && store.filter.length > 0)
const grouped = createMemo<[string, DialogSelectOption<T>[]][]>(() => {
if (flatten()) return [["", filtered()]]
const result = pipe(
filtered(),
groupBy((x) => x.category ?? ""),
// mapValues((x) => x.sort((a, b) => a.title.localeCompare(b.title))),
entries(),
)
return result
})
const flat = createMemo(() => {
return pipe(
grouped(),
flatMap(([_, options]) => options),
)
})
const rows = createMemo(() => {
const headers = grouped().reduce((acc, [category], i) => {
if (!category) return acc
return acc + (i > 0 ? 2 : 1)
}, 0)
return flat().reduce((acc, option) => acc + 1 + (option.details?.length ?? 0), headers)
})
const dimensions = useTerminalDimensions()
const height = createMemo(() => Math.min(rows(), Math.floor(dimensions().height / 2) - 6))
const selected = createMemo(() => flat()[store.selected])
createEffect(
on(
() => props.options,
() => {
if (!props.preserveSelection) return
if (resetSelection && store.filter.length > 0) {
const option = flat()[0]
if (!option) return
setStore("selected", 0)
selection = option
return
}
if (!selection) {
if (props.current !== undefined) {
const index = flat().findIndex((option) => isDeepEqual(option.value, props.current))
if (index >= 0) {
setStore("selected", index)
selection = flat()[index]
return
}
}
const option = selected()
if (!option) return
selection = option
return
}
const previous = selection
const index = flat().findIndex((option) => isDeepEqual(option.value, previous.value))
if (index >= 0) {
const option = flat()[index]
const moved = index !== store.selected || option.category !== previous.category
setStore("selected", index)
selection = option
if (!moved) return
const value = option.value
const generation = ++visibilityGeneration
requestAnimationFrame(() => {
requestAnimationFrame(() => {
if (generation !== visibilityGeneration) return
if (!props.preserveSelection || store.filter.length > 0) return
if (!isDeepEqual(selected()?.value, value)) return
scrollToSelection(false)
})
})
return
}
const next = Math.min(store.selected, flat().length - 1)
if (next < 0) return
setStore("selected", next)
selection = flat()[next]
},
),
)
onCleanup(() => {
visibilityGeneration++
})
createEffect(
on([() => store.filter, () => props.current], ([filter, current]) => {
if (filter.length > 0) resetSelection = true
setTimeout(() => {
if (filter.length > 0) {
moveTo(0, true, false)
} else if (current) {
const currentIndex = flat().findIndex((opt) => isDeepEqual(opt.value, current))
if (currentIndex >= 0) {
moveTo(currentIndex, true)
}
}
}, 0)
}),
)
function move(direction: number) {
if (props.locked) return
if (flat().length === 0) return
let next = store.selected + direction
if (next < 0) next = flat().length - 1
if (next >= flat().length) next = 0
moveTo(next, true)
}
function moveTo(next: number, center = false, preserve = true) {
setFocusedAction(undefined)
setStore("selected", next)
const option = selected()
if (option) {
selection = option
resetSelection = !preserve
}
if (option) props.onMove?.(option)
scrollToSelection(center)
}
function scrollToSelection(center: boolean) {
if (!scroll) return
let remaining = store.selected
let index = 0
// Locate the row by position because a unique renderable ID cannot currently be ensured.
for (const [category, options] of grouped()) {
if (category) index++
if (remaining < options.length) {
index += remaining
break
}
index += options.length
remaining -= options.length
}
const target = scroll.getChildren()[index]
if (!target) return
const y = target.y - scroll.y
if (center) {
const centerOffset = Math.floor(scroll.height / 2)
scroll.scrollBy(y - centerOffset)
} else {
if (y >= scroll.height) {
scroll.scrollBy(y - scroll.height + 1)
}
if (y < 0) {
scroll.scrollBy(y)
if (isDeepEqual(flat()[0].value, selected()?.value)) {
scroll.scrollTo(0)
}
}
}
}
function submit() {
if (props.locked) return
setStore("input", "keyboard")
const index = focusedAction()
if (index !== undefined) {
triggerAction(actionItems()[index])
return
}
const option = selected()
if (!option) return
option.onSelect?.(dialog)
props.onSelect?.(option)
}
function moveAction(direction: 1 | -1) {
if (props.locked) return
const total = actionItems().length
if (total === 0) return
setFocusedAction((index) => {
if (index === undefined) return direction === 1 ? 0 : total - 1
const next = index + direction
return next < 0 || next >= total ? undefined : next
})
}
useBindings(() => {
const visible = shownActions()
return {
commands: [
{
name: "dialog.select.prev",
title: "Previous item",
category: "Dialog",
run() {
setStore("input", "keyboard")
move(-1)
},
},
{
name: "dialog.select.next",
title: "Next item",
category: "Dialog",
run() {
setStore("input", "keyboard")
move(1)
},
},
{
name: "dialog.select.page_up",
title: "Page up",
category: "Dialog",
run() {
setStore("input", "keyboard")
move(-10)
},
},
{
name: "dialog.select.page_down",
title: "Page down",
category: "Dialog",
run() {
setStore("input", "keyboard")
move(10)
},
},
{
name: "dialog.select.home",
title: "First item",
category: "Dialog",
run() {
if (props.locked) return
setStore("input", "keyboard")
moveTo(0)
},
},
{
name: "dialog.select.end",
title: "Last item",
category: "Dialog",
run() {
if (props.locked) return
setStore("input", "keyboard")
moveTo(flat().length - 1)
},
},
{
name: "dialog.select.submit",
title: "Select item",
category: "Dialog",
run: submit,
},
...visible.map((item) => ({
name: item.command,
title: item.title,
category: "Dialog",
run() {
if (props.locked) return
if (isActionDisabled(item)) return
setStore("input", "keyboard")
const option = selected()
if (!option) return
item.onTrigger(option)
},
})),
],
bindings: [
...tuiConfig.keybinds.gather("dialog.select", [
"dialog.select.prev",
"dialog.select.next",
"dialog.select.page_up",
"dialog.select.page_down",
"dialog.select.home",
"dialog.select.end",
"dialog.select.submit",
]),
...visible.flatMap((item) => tuiConfig.keybinds.get(item.command)),
...(visible.length
? [
{
key: "tab",
desc: "Next dialog action",
group: "Dialog",
cmd: () => moveAction(1),
},
{
key: "shift+tab",
desc: "Previous dialog action",
group: "Dialog",
cmd: () => moveAction(-1),
},
]
: []),
...(props.bindings ?? []).filter((binding) => {
if (typeof binding.cmd !== "string") return true
return visible.some((item) => item.command === binding.cmd)
}),
],
}
})
let scroll: ScrollBoxRenderable | undefined
const ref: DialogSelectRef<T> = {
get filter() {
return store.filter
},
get filtered() {
return filtered()
},
moveTo(value) {
const index = flat().findIndex((option) => isDeepEqual(option.value, value))
if (index >= 0) moveTo(index, true)
},
}
props.ref?.(ref)
const left = createMemo(() => visibleActions().filter((item) => item.side !== "right"))
const right = createMemo(() => visibleActions().filter((item) => item.side === "right"))
function triggerAction(item: VisibleAction | undefined) {
if (props.locked) return
if (!item || !isActionItem(item) || isActionDisabled(item)) return
setStore("input", "keyboard")
const option = selected()
if (!option) return
item.onTrigger(option)
}
function isActionItem(item: VisibleAction): item is Action & { label: string } {
return "onTrigger" in item
}
function isActionDisabled(item: Action) {
return typeof item.disabled === "function" ? item.disabled(selected()) : item.disabled
}
function isActionFocused(item: VisibleAction) {
if (props.locked) return false
if (!isActionItem(item)) return false
return actionItems().indexOf(item) === focusedAction()
}
function FooterAction(action: { item: VisibleAction }) {
if (!isActionItem(action.item))
return (
<text>
<span style={{ fg: theme.text }}>
<b>{action.item.title}</b>{" "}
</span>
<span style={{ fg: theme.textMuted }}>{action.item.label}</span>
</text>
)
const item = action.item
const active = createMemo(() => isActionFocused(item))
const disabled = createMemo(() => isActionDisabled(item))
const fg = selectedForeground(theme)
return (
<box
flexDirection="row"
backgroundColor={active() ? theme.primary : RGBA.fromInts(0, 0, 0, 0)}
onMouseUp={() => triggerAction(item)}
>
<text
fg={disabled() ? theme.textMuted : active() ? fg : theme.text}
attributes={active() ? TextAttributes.BOLD : undefined}
>
{item.title}
</text>
<text fg={disabled() ? theme.textMuted : active() ? fg : theme.textMuted}> {item.label}</text>
</box>
)
}
return (
<box gap={1} paddingBottom={1} flexGrow={1}>
<box paddingLeft={4} paddingRight={4}>
<box flexDirection="row" justifyContent="space-between">
{props.titleView ?? (
<text fg={theme.text} attributes={TextAttributes.BOLD}>
{props.title}
</text>
)}
<text fg={theme.textMuted} onMouseUp={() => dialog.clear()}>
esc
</text>
</box>
<Show when={props.renderFilter !== false}>
<box paddingTop={1}>
<input
onInput={(e) => {
if (props.locked) return
batch(() => {
setStore("filter", e)
props.onFilter?.(e)
})
}}
focusedBackgroundColor={theme.backgroundPanel}
cursorColor={theme.primary}
focusedTextColor={theme.textMuted}
ref={(r) => {
input = r
input.traits = { status: "FILTER" }
setTimeout(() => {
if (!input) return
if (input.isDestroyed) return
input.focus()
}, 1)
}}
placeholder={props.placeholder ?? "Search"}
placeholderColor={theme.textMuted}
/>
</box>
</Show>
</box>
<box flexGrow={1} flexShrink={1}>
<Show
when={grouped().length > 0}
fallback={
props.emptyView ?? (
<box paddingLeft={4} paddingRight={4} paddingTop={1}>
<text fg={theme.textMuted}>No results found</text>
</box>
)
}
>
<scrollbox
paddingLeft={1}
paddingRight={1}
scrollbarOptions={{ visible: false }}
scrollAcceleration={scrollAcceleration()}
ref={(r: ScrollBoxRenderable) => (scroll = r)}
maxHeight={height()}
>
<For each={grouped()}>
{([category, options], index) => (
<>
<Show when={category}>
<box paddingTop={index() > 0 ? 1 : 0} paddingLeft={3}>
<Show
when={options[0]?.categoryView}
fallback={
<text fg={theme.accent} attributes={TextAttributes.BOLD}>
{category}
</text>
}
>
{options[0]?.categoryView}
</Show>
</box>
</Show>
<For each={options}>
{(option) => {
const active = createMemo(() => !props.locked && isDeepEqual(option.value, selected()?.value))
const current = createMemo(() => isDeepEqual(option.value, props.current))
return (
<box
flexDirection="column"
position="relative"
onMouseMove={() => {
if (props.locked) return
setStore("input", "mouse")
setFocusedAction(undefined)
}}
onMouseUp={() => {
if (props.locked) return
option.onSelect?.(dialog)
props.onSelect?.(option)
}}
onMouseOver={() => {
if (props.locked) return
if (store.input !== "mouse") return
const index = flat().findIndex((x) => isDeepEqual(x.value, option.value))
if (index === -1) return
moveTo(index)
}}
onMouseDown={() => {
if (props.locked) return
const index = flat().findIndex((x) => isDeepEqual(x.value, option.value))
if (index === -1) return
moveTo(index)
}}
>
<box
flexDirection="row"
paddingLeft={current() || option.gutter ? 1 : 3}
paddingRight={3}
gap={1}
backgroundColor={
active()
? actionFocused()
? theme.backgroundElement
: (option.bg ?? theme.primary)
: RGBA.fromInts(0, 0, 0, 0)
}
>
<Show when={!current() && option.margin}>
<box position="absolute" left={1} flexShrink={0}>
{option.margin}
</box>
</Show>
<Option
title={option.title}
titleView={option.titleView}
footer={flatten() ? (option.category ?? option.footer) : option.footer}
titleWidth={option.titleWidth}
truncateTitle={option.truncateTitle}
description={option.description !== category ? option.description : undefined}
active={active()}
current={current()}
muted={actionFocused()}
gutter={option.gutter}
/>
</box>
<For each={option.details}>
{(detail) => (
<box paddingLeft={3} paddingRight={3}>
<text fg={theme.textMuted} wrapMode="none">
{Locale.truncateMiddle(detail, Math.max(1, Math.min(76, dimensions().width - 12)))}
</text>
</box>
)}
</For>
</box>
)
}}
</For>
</>
)}
</For>
</scrollbox>
</Show>
</box>
<Show when={props.footer || visibleActions().length} fallback={<box flexShrink={0} />}>
<box paddingRight={2} paddingLeft={4} flexDirection="row" justifyContent="space-between" flexShrink={0}>
<box flexDirection="row" gap={2}>
{props.footer}
<For each={left()}>{(item) => <FooterAction item={item} />}</For>
</box>
<box flexDirection="row" gap={2}>
<For each={right()}>{(item) => <FooterAction item={item} />}</For>
</box>
</box>
</Show>
</box>
)
}
function Option(props: {
title: string
titleView?: JSX.Element
description?: string
active?: boolean
current?: boolean
muted?: boolean
footer?: JSX.Element | string
titleWidth?: number
truncateTitle?: boolean | "left"
gutter?: () => JSX.Element
onMouseOver?: () => void
}) {
const { theme } = useTheme()
const fg = selectedForeground(theme)
const text = createMemo(() => {
if (props.active && !props.muted) return fg
if (props.muted && (props.active || props.current)) return theme.textMuted
if (props.current) return theme.primary
return theme.text
})
return (
<>
<Show when={props.current && !props.gutter}>
<text flexShrink={0} fg={text()} marginRight={0}>
●
</text>
</Show>
<Show when={props.gutter}>
<box flexShrink={0} marginRight={0}>
{props.gutter?.()}
</box>
</Show>
<text
flexGrow={1}
fg={text()}
attributes={props.active && !props.muted ? TextAttributes.BOLD : undefined}
overflow="hidden"
wrapMode="none"
paddingLeft={3}
>
{props.titleView ??
(props.truncateTitle === false
? props.title
: props.truncateTitle === "left"
? Locale.truncateLeft(props.title, props.titleWidth ?? 61)
: Locale.truncate(props.title, props.titleWidth ?? 61))}
<Show when={props.description}>
<span style={{ fg: props.active && !props.muted ? fg : theme.textMuted }}> {props.description}</span>
</Show>
</text>
<Show when={props.footer}>
<box flexShrink={0}>
<text fg={props.active && !props.muted ? fg : theme.textMuted}>{props.footer}</text>
</box>
</Show>
</>
)
}