openAgent
gitlabThe open source UNIVERSAL agent.
clone
https://gitlab.com/bitzzdev/openAgent.gitAIagentautomationopen-sourceuniversal-agent
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: stringtitleView?: JSX.Elementplaceholder?: stringfooter?: JSX.ElementemptyView?: JSX.Elementoptions: DialogSelectOption<T>[]flat?: booleanref?: (ref: DialogSelectRef<T>) => voidonMove?: (option: DialogSelectOption<T>) => voidonFilter?: (query: string) => voidonSelect?: (option: DialogSelectOption<T>) => voidskipFilter?: booleanrenderFilter?: booleanlocked?: booleanpreserveSelection?: booleanactions?: {command: stringtitle: stringside?: "left" | "right"hidden?: booleandisabled?: boolean | ((option: DialogSelectOption<T> | undefined) => boolean)onTrigger: (option: DialogSelectOption<T>) => void}[]footerHints?: {title: stringlabel: stringside?: "left" | "right"}[]bindings?: readonly Binding<Renderable, KeyEvent>[]current?: T}export interface DialogSelectOption<T = any> {title: stringtitleView?: JSX.Elementvalue: Tdescription?: stringdetails?: string[]footer?: JSX.Element | stringtitleWidth?: numbertruncateTitle?: boolean | "left"category?: stringcategoryView?: JSX.Elementdisabled?: booleanbg?: RGBAgutter?: () => JSX.Elementmargin?: JSX.ElementonSelect?: (ctx: DialogContext) => void}export type DialogSelectRef<T> = {filter: stringfiltered: 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 }) | FooterHintconst 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 } | undefinedlet resetSelection = falselet visibilityGeneration = 0createEffect(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: InputRenderableconst 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 accreturn 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) returnif (resetSelection && store.filter.length > 0) {const option = flat()[0]if (!option) returnsetStore("selected", 0)selection = optionreturn}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) returnselection = optionreturn}const previous = selectionconst index = flat().findIndex((option) => isDeepEqual(option.value, previous.value))if (index >= 0) {const option = flat()[index]const moved = index !== store.selected || option.category !== previous.categorysetStore("selected", index)selection = optionif (!moved) returnconst value = option.valueconst generation = ++visibilityGenerationrequestAnimationFrame(() => {requestAnimationFrame(() => {if (generation !== visibilityGeneration) returnif (!props.preserveSelection || store.filter.length > 0) returnif (!isDeepEqual(selected()?.value, value)) returnscrollToSelection(false)})})return}const next = Math.min(store.selected, flat().length - 1)if (next < 0) returnsetStore("selected", next)selection = flat()[next]},),)onCleanup(() => {visibilityGeneration++})createEffect(on([() => store.filter, () => props.current], ([filter, current]) => {if (filter.length > 0) resetSelection = truesetTimeout(() => {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) returnif (flat().length === 0) returnlet next = store.selected + directionif (next < 0) next = flat().length - 1if (next >= flat().length) next = 0moveTo(next, true)}function moveTo(next: number, center = false, preserve = true) {setFocusedAction(undefined)setStore("selected", next)const option = selected()if (option) {selection = optionresetSelection = !preserve}if (option) props.onMove?.(option)scrollToSelection(center)}function scrollToSelection(center: boolean) {if (!scroll) returnlet remaining = store.selectedlet 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 += remainingbreak}index += options.lengthremaining -= options.length}const target = scroll.getChildren()[index]if (!target) returnconst y = target.y - scroll.yif (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) returnsetStore("input", "keyboard")const index = focusedAction()if (index !== undefined) {triggerAction(actionItems()[index])return}const option = selected()if (!option) returnoption.onSelect?.(dialog)props.onSelect?.(option)}function moveAction(direction: 1 | -1) {if (props.locked) returnconst total = actionItems().lengthif (total === 0) returnsetFocusedAction((index) => {if (index === undefined) return direction === 1 ? 0 : total - 1const next = index + directionreturn 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) returnsetStore("input", "keyboard")moveTo(0)},},{name: "dialog.select.end",title: "Last item",category: "Dialog",run() {if (props.locked) returnsetStore("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) returnif (isActionDisabled(item)) returnsetStore("input", "keyboard")const option = selected()if (!option) returnitem.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 truereturn visible.some((item) => item.command === binding.cmd)}),],}})let scroll: ScrollBoxRenderable | undefinedconst 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) returnif (!item || !isActionItem(item) || isActionDisabled(item)) returnsetStore("input", "keyboard")const option = selected()if (!option) returnitem.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 falseif (!isActionItem(item)) return falsereturn 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.itemconst active = createMemo(() => isActionFocused(item))const disabled = createMemo(() => isActionDisabled(item))const fg = selectedForeground(theme)return (<boxflexDirection="row"backgroundColor={active() ? theme.primary : RGBA.fromInts(0, 0, 0, 0)}onMouseUp={() => triggerAction(item)}><textfg={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}><inputonInput={(e) => {if (props.locked) returnbatch(() => {setStore("filter", e)props.onFilter?.(e)})}}focusedBackgroundColor={theme.backgroundPanel}cursorColor={theme.primary}cursorStyle={tuiConfig.cursor}focusedTextColor={theme.textMuted}ref={(r) => {input = rinput.traits = { status: "FILTER" }setTimeout(() => {if (!input) returnif (input.isDestroyed) returninput.focus()}, 1)}}placeholder={props.placeholder ?? "Search"}placeholderColor={theme.textMuted}/></box></Show></box><box flexGrow={1} flexShrink={1}><Showwhen={grouped().length > 0}fallback={props.emptyView ?? (<box paddingLeft={4} paddingRight={4} paddingTop={1}><text fg={theme.textMuted}>No results found</text></box>)}><scrollboxpaddingLeft={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}><Showwhen={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 (<boxflexDirection="column"position="relative"onMouseMove={() => {if (props.locked) returnsetStore("input", "mouse")setFocusedAction(undefined)}}onMouseUp={() => {if (props.locked) returnoption.onSelect?.(dialog)props.onSelect?.(option)}}onMouseOver={() => {if (props.locked) returnif (store.input !== "mouse") returnconst index = flat().findIndex((x) => isDeepEqual(x.value, option.value))if (index === -1) returnmoveTo(index)}}onMouseDown={() => {if (props.locked) returnconst index = flat().findIndex((x) => isDeepEqual(x.value, option.value))if (index === -1) returnmoveTo(index)}}><boxflexDirection="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><Optiontitle={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: stringtitleView?: JSX.Elementdescription?: stringactive?: booleancurrent?: booleanmuted?: booleanfooter?: JSX.Element | stringtitleWidth?: numbertruncateTitle?: boolean | "left"gutter?: () => JSX.ElementonMouseOver?: () => void}) {const { theme } = useTheme()const fg = selectedForeground(theme)const text = createMemo(() => {if (props.active && !props.muted) return fgif (props.muted && (props.active || props.current)) return theme.textMutedif (props.current) return theme.primaryreturn 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><textflexGrow={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></>)}