refactor(server): canonicalize service API (#31049)

This commit is contained in:
Dax 2026-06-06 23:27:28 -04:00 committed by GitHub
commit fe0c4f8c74
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
388 changed files with 7075 additions and 4064 deletions

View file

@ -0,0 +1,21 @@
export const EmptyBorder = {
topLeft: "",
bottomLeft: "",
vertical: "",
topRight: "",
bottomRight: "",
horizontal: " ",
bottomT: "",
topT: "",
cross: "",
leftT: "",
rightT: "",
}
export const SplitBorder = {
border: ["left" as const, "right" as const],
customBorderChars: {
...EmptyBorder,
vertical: "┃",
},
}

View file

@ -0,0 +1,66 @@
import { TextAttributes } from "@opentui/core"
import { useTheme } from "../context/theme"
import { useDialog, type DialogContext } from "./dialog"
import { useBindings } from "../keymap"
export type DialogAlertProps = {
title: string
message: string
onConfirm?: () => void
}
export function DialogAlert(props: DialogAlertProps) {
const dialog = useDialog()
const { theme } = useTheme()
useBindings(() => ({
bindings: [
{
key: "return",
desc: "Confirm alert",
group: "Dialog",
cmd: () => {
props.onConfirm?.()
dialog.clear()
},
},
],
}))
return (
<box paddingLeft={2} paddingRight={2} gap={1}>
<box flexDirection="row" justifyContent="space-between">
<text attributes={TextAttributes.BOLD} fg={theme.text}>
{props.title}
</text>
<text fg={theme.textMuted} onMouseUp={() => dialog.clear()}>
esc
</text>
</box>
<box paddingBottom={1}>
<text fg={theme.textMuted}>{props.message}</text>
</box>
<box flexDirection="row" justifyContent="flex-end" paddingBottom={1}>
<box
paddingLeft={3}
paddingRight={3}
backgroundColor={theme.primary}
onMouseUp={() => {
props.onConfirm?.()
dialog.clear()
}}
>
<text fg={theme.selectedListItemText}>ok</text>
</box>
</box>
</box>
)
}
DialogAlert.show = (dialog: DialogContext, title: string, message: string) => {
return new Promise<void>((resolve) => {
dialog.replace(
() => <DialogAlert title={title} message={message} onConfirm={() => resolve()} />,
() => resolve(),
)
})
}

View file

@ -0,0 +1,108 @@
import { TextAttributes } from "@opentui/core"
import { useTheme } from "../context/theme"
import { useDialog, type DialogContext } from "./dialog"
import { createStore } from "solid-js/store"
import { For } from "solid-js"
import { Locale } from "../util/locale"
import { useBindings } from "../keymap"
export type DialogConfirmProps = {
title: string
message: string
onConfirm?: () => void
onCancel?: () => void
label?: string
}
export type DialogConfirmResult = boolean | undefined
export function DialogConfirm(props: DialogConfirmProps) {
const dialog = useDialog()
const { theme } = useTheme()
const [store, setStore] = createStore({
active: "confirm" as "confirm" | "cancel",
})
useBindings(() => ({
bindings: [
{
key: "return",
desc: "Confirm dialog selection",
group: "Dialog",
cmd: () => {
if (store.active === "confirm") props.onConfirm?.()
if (store.active === "cancel") props.onCancel?.()
dialog.clear()
},
},
{
key: "left",
desc: "Previous dialog option",
group: "Dialog",
cmd: () => {
setStore("active", store.active === "confirm" ? "cancel" : "confirm")
},
},
{
key: "right",
desc: "Next dialog option",
group: "Dialog",
cmd: () => {
setStore("active", store.active === "confirm" ? "cancel" : "confirm")
},
},
],
}))
return (
<box paddingLeft={2} paddingRight={2} gap={1}>
<box flexDirection="row" justifyContent="space-between">
<text attributes={TextAttributes.BOLD} fg={theme.text}>
{props.title}
</text>
<text fg={theme.textMuted} onMouseUp={() => dialog.clear()}>
esc
</text>
</box>
<box paddingBottom={1}>
<text fg={theme.textMuted}>{props.message}</text>
</box>
<box flexDirection="row" justifyContent="flex-end" paddingBottom={1}>
<For each={["cancel", "confirm"] as const}>
{(key) => (
<box
paddingLeft={1}
paddingRight={1}
backgroundColor={key === store.active ? theme.primary : undefined}
onMouseUp={() => {
if (key === "confirm") props.onConfirm?.()
if (key === "cancel") props.onCancel?.()
dialog.clear()
}}
>
<text fg={key === store.active ? theme.selectedListItemText : theme.textMuted}>
{Locale.titlecase(key === "cancel" ? (props.label ?? key) : key)}
</text>
</box>
)}
</For>
</box>
</box>
)
}
DialogConfirm.show = (dialog: DialogContext, title: string, message: string, label?: string) => {
return new Promise<DialogConfirmResult>((resolve) => {
dialog.replace(
() => (
<DialogConfirm
title={title}
message={message}
onConfirm={() => resolve(true)}
onCancel={() => resolve(false)}
label={label}
/>
),
() => resolve(undefined),
)
})
}

View file

@ -0,0 +1,217 @@
import { TextareaRenderable, TextAttributes } from "@opentui/core"
import { useTheme } from "../context/theme"
import { useDialog, type DialogContext } from "./dialog"
import { createStore } from "solid-js/store"
import { onMount, Show } from "solid-js"
import { useBindings } from "../keymap"
export type DialogExportOptionsProps = {
defaultFilename: string
defaultThinking: boolean
defaultToolDetails: boolean
defaultAssistantMetadata: boolean
defaultOpenWithoutSaving: boolean
onConfirm?: (options: {
filename: string
thinking: boolean
toolDetails: boolean
assistantMetadata: boolean
openWithoutSaving: boolean
}) => void
onCancel?: () => void
}
export function DialogExportOptions(props: DialogExportOptionsProps) {
const dialog = useDialog()
const { theme } = useTheme()
let textarea: TextareaRenderable
const [store, setStore] = createStore({
thinking: props.defaultThinking,
toolDetails: props.defaultToolDetails,
assistantMetadata: props.defaultAssistantMetadata,
openWithoutSaving: props.defaultOpenWithoutSaving,
active: "filename" as "filename" | "thinking" | "toolDetails" | "assistantMetadata" | "openWithoutSaving",
})
useBindings(() => ({
bindings: [
{
key: "tab",
desc: "Next export option",
group: "Dialog",
cmd: () => {
const order: Array<"filename" | "thinking" | "toolDetails" | "assistantMetadata" | "openWithoutSaving"> = [
"filename",
"thinking",
"toolDetails",
"assistantMetadata",
"openWithoutSaving",
]
const currentIndex = order.indexOf(store.active)
const nextIndex = (currentIndex + 1) % order.length
setStore("active", order[nextIndex])
},
},
],
}))
useBindings(() => ({
enabled: store.active !== "filename",
bindings: [
{
key: "space",
desc: "Toggle export option",
group: "Dialog",
cmd: () => {
if (store.active === "thinking") setStore("thinking", !store.thinking)
if (store.active === "toolDetails") setStore("toolDetails", !store.toolDetails)
if (store.active === "assistantMetadata") setStore("assistantMetadata", !store.assistantMetadata)
if (store.active === "openWithoutSaving") setStore("openWithoutSaving", !store.openWithoutSaving)
},
},
],
}))
onMount(() => {
dialog.setSize("medium")
setTimeout(() => {
if (!textarea || textarea.isDestroyed) return
textarea.focus()
}, 1)
textarea.gotoLineEnd()
})
return (
<box paddingLeft={2} paddingRight={2} gap={1}>
<box flexDirection="row" justifyContent="space-between">
<text attributes={TextAttributes.BOLD} fg={theme.text}>
Export Options
</text>
<text fg={theme.textMuted} onMouseUp={() => dialog.clear()}>
esc
</text>
</box>
<box gap={1}>
<box>
<text fg={theme.text}>Filename:</text>
</box>
<textarea
onSubmit={() => {
props.onConfirm?.({
filename: textarea.plainText,
thinking: store.thinking,
toolDetails: store.toolDetails,
assistantMetadata: store.assistantMetadata,
openWithoutSaving: store.openWithoutSaving,
})
}}
height={3}
ref={(val: TextareaRenderable) => {
textarea = val
val.traits = { status: "FILENAME" }
}}
initialValue={props.defaultFilename}
placeholder="Enter filename"
placeholderColor={theme.textMuted}
textColor={theme.text}
focusedTextColor={theme.text}
cursorColor={theme.text}
/>
</box>
<box flexDirection="column">
<box
flexDirection="row"
gap={2}
paddingLeft={1}
backgroundColor={store.active === "thinking" ? theme.backgroundElement : undefined}
onMouseUp={() => setStore("active", "thinking")}
>
<text fg={store.active === "thinking" ? theme.primary : theme.textMuted}>
{store.thinking ? "[x]" : "[ ]"}
</text>
<text fg={store.active === "thinking" ? theme.primary : theme.text}>Include thinking</text>
</box>
<box
flexDirection="row"
gap={2}
paddingLeft={1}
backgroundColor={store.active === "toolDetails" ? theme.backgroundElement : undefined}
onMouseUp={() => setStore("active", "toolDetails")}
>
<text fg={store.active === "toolDetails" ? theme.primary : theme.textMuted}>
{store.toolDetails ? "[x]" : "[ ]"}
</text>
<text fg={store.active === "toolDetails" ? theme.primary : theme.text}>Include tool details</text>
</box>
<box
flexDirection="row"
gap={2}
paddingLeft={1}
backgroundColor={store.active === "assistantMetadata" ? theme.backgroundElement : undefined}
onMouseUp={() => setStore("active", "assistantMetadata")}
>
<text fg={store.active === "assistantMetadata" ? theme.primary : theme.textMuted}>
{store.assistantMetadata ? "[x]" : "[ ]"}
</text>
<text fg={store.active === "assistantMetadata" ? theme.primary : theme.text}>Include assistant metadata</text>
</box>
<box
flexDirection="row"
gap={2}
paddingLeft={1}
backgroundColor={store.active === "openWithoutSaving" ? theme.backgroundElement : undefined}
onMouseUp={() => setStore("active", "openWithoutSaving")}
>
<text fg={store.active === "openWithoutSaving" ? theme.primary : theme.textMuted}>
{store.openWithoutSaving ? "[x]" : "[ ]"}
</text>
<text fg={store.active === "openWithoutSaving" ? theme.primary : theme.text}>Open without saving</text>
</box>
</box>
<Show when={store.active !== "filename"}>
<text fg={theme.textMuted} paddingBottom={1}>
Press <span style={{ fg: theme.text }}>space</span> to toggle, <span style={{ fg: theme.text }}>return</span>{" "}
to confirm
</text>
</Show>
<Show when={store.active === "filename"}>
<text fg={theme.textMuted} paddingBottom={1}>
Press <span style={{ fg: theme.text }}>return</span> to confirm, <span style={{ fg: theme.text }}>tab</span>{" "}
for options
</text>
</Show>
</box>
)
}
DialogExportOptions.show = (
dialog: DialogContext,
defaultFilename: string,
defaultThinking: boolean,
defaultToolDetails: boolean,
defaultAssistantMetadata: boolean,
defaultOpenWithoutSaving: boolean,
) => {
return new Promise<{
filename: string
thinking: boolean
toolDetails: boolean
assistantMetadata: boolean
openWithoutSaving: boolean
} | null>((resolve) => {
dialog.replace(
() => (
<DialogExportOptions
defaultFilename={defaultFilename}
defaultThinking={defaultThinking}
defaultToolDetails={defaultToolDetails}
defaultAssistantMetadata={defaultAssistantMetadata}
defaultOpenWithoutSaving={defaultOpenWithoutSaving}
onConfirm={(options) => resolve(options)}
onCancel={() => resolve(null)}
/>
),
() => resolve(null),
)
})
}

View file

@ -0,0 +1,40 @@
import { TextAttributes } from "@opentui/core"
import { useTheme } from "../context/theme"
import { useDialog } from "./dialog"
import { useBindings, useCommandShortcut } from "../keymap"
export function DialogHelp() {
const dialog = useDialog()
const { theme } = useTheme()
const commandShortcut = useCommandShortcut("command.palette.show")
useBindings(() => ({
bindings: [
{ key: "return", desc: "Close help", group: "Dialog", cmd: () => dialog.clear() },
{ key: "escape", desc: "Close help", group: "Dialog", cmd: () => dialog.clear() },
],
}))
return (
<box paddingLeft={2} paddingRight={2} gap={1}>
<box flexDirection="row" justifyContent="space-between">
<text attributes={TextAttributes.BOLD} fg={theme.text}>
Help
</text>
<text fg={theme.textMuted} onMouseUp={() => dialog.clear()}>
esc/enter
</text>
</box>
<box paddingBottom={1}>
<text fg={theme.textMuted}>
Press {commandShortcut()} to see all available actions and commands in any context.
</text>
</box>
<box flexDirection="row" justifyContent="flex-end" paddingBottom={1}>
<box paddingLeft={3} paddingRight={3} backgroundColor={theme.primary} onMouseUp={() => dialog.clear()}>
<text fg={theme.selectedListItemText}>ok</text>
</box>
</box>
</box>
)
}

View file

@ -0,0 +1,126 @@
import { TextareaRenderable, TextAttributes } from "@opentui/core"
import { useTheme } from "../context/theme"
import { useDialog, type DialogContext } from "./dialog"
import { Show, createEffect, createSignal, onMount, type JSX } from "solid-js"
import { Spinner } from "../component/spinner"
import { useTuiConfig } from "../config"
import { useBindings, useCommandShortcut } from "../keymap"
export type DialogPromptProps = {
title: string
description?: () => JSX.Element
placeholder?: string
value?: string
busy?: boolean
busyText?: string
onConfirm?: (value: string) => void
onCancel?: () => void
}
export function DialogPrompt(props: DialogPromptProps) {
const dialog = useDialog()
const { theme } = useTheme()
const tuiConfig = useTuiConfig()
const submitShortcut = useCommandShortcut("dialog.prompt.submit")
const [textareaTarget, setTextareaTarget] = createSignal<TextareaRenderable>()
let textarea: TextareaRenderable
function confirm() {
if (props.busy) return
props.onConfirm?.(textarea.plainText)
}
useBindings(() => ({
target: textareaTarget,
enabled: textareaTarget() !== undefined && !props.busy,
// Dialog form semantics must win over the global managed textarea input layer.
priority: 1,
commands: [
{
name: "dialog.prompt.submit",
title: "Submit dialog prompt",
category: "Dialog",
run: confirm,
},
],
bindings: tuiConfig.keybinds.gather("dialog.prompt", ["dialog.prompt.submit"]),
}))
onMount(() => {
dialog.setSize("medium")
setTimeout(() => {
if (!textarea || textarea.isDestroyed) return
if (props.busy) return
textarea.focus()
}, 1)
textarea.gotoLineEnd()
})
createEffect(() => {
if (!textarea || textarea.isDestroyed) return
const traits = props.busy
? {
suspend: true,
status: "BUSY",
}
: {}
textarea.traits = traits
if (props.busy) {
textarea.blur()
return
}
textarea.focus()
})
return (
<box paddingLeft={2} paddingRight={2} gap={1}>
<box flexDirection="row" justifyContent="space-between">
<text attributes={TextAttributes.BOLD} fg={theme.text}>
{props.title}
</text>
<text fg={theme.textMuted} onMouseUp={() => dialog.clear()}>
esc
</text>
</box>
<box gap={1}>
{props.description}
<textarea
height={3}
ref={(val: TextareaRenderable) => {
textarea = val
setTextareaTarget(val)
}}
initialValue={props.value}
placeholder={props.placeholder ?? "Enter text"}
placeholderColor={theme.textMuted}
textColor={props.busy ? theme.textMuted : theme.text}
focusedTextColor={props.busy ? theme.textMuted : theme.text}
cursorColor={props.busy ? theme.backgroundElement : theme.text}
/>
<Show when={props.busy}>
<Spinner color={theme.textMuted}>{props.busyText ?? "Working..."}</Spinner>
</Show>
</box>
<box paddingBottom={1} gap={1} flexDirection="row">
<Show when={!props.busy} fallback={<text fg={theme.textMuted}>processing...</text>}>
<Show when={submitShortcut()}>
<text fg={theme.text}>
{submitShortcut()} <span style={{ fg: theme.textMuted }}>submit</span>
</text>
</Show>
</Show>
</box>
</box>
)
}
DialogPrompt.show = (dialog: DialogContext, title: string, options?: Omit<DialogPromptProps, "title">) => {
return new Promise<string | null>((resolve) => {
dialog.replace(
() => (
<DialogPrompt title={title} {...options} onConfirm={(value) => resolve(value)} onCancel={() => resolve(null)} />
),
() => resolve(null),
)
})
}

View file

@ -0,0 +1,712 @@
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 } 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
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
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>[]
selected: DialogSelectOption<T> | undefined
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)
createEffect(
on(
() => props.current,
(current) => {
if (current) {
const currentIndex = flat().findIndex((opt) => isDeepEqual(opt.value, current))
if (currentIndex >= 0) {
setStore("selected", 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([() => store.filter, () => props.current], ([filter, current]) => {
setTimeout(() => {
if (filter.length > 0) {
moveTo(0, true)
} 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) {
setFocusedAction(undefined)
setStore("selected", next)
const option = selected()
if (option) props.onMove?.(option)
if (!scroll) return
const target = scroll.getChildren().find((child: { id?: string }) => {
return child.id === JSON.stringify(selected()?.value)
})
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()
},
get selected() {
return selected()
},
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"
paddingRight={1}
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={
<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
id={JSON.stringify(option.value)}
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}>
<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>
</>
)
}

View file

@ -0,0 +1,229 @@
import { useRenderer, useTerminalDimensions } from "@opentui/solid"
import { batch, createContext, createEffect, onCleanup, Show, useContext, type JSX, type ParentProps } from "solid-js"
import { useTheme } from "../context/theme"
import { MouseButton, Renderable, RGBA } from "@opentui/core"
import { createStore } from "solid-js/store"
import { useToast } from "./toast"
import { useTuiEnvironment } from "../runtime"
import { useBindings, useOpencodeModeStack } from "../keymap"
import { useOptionalTuiPlatform } from "../platform"
export function Dialog(
props: ParentProps<{
size?: "medium" | "large" | "xlarge"
onClose: () => void
}>,
) {
const dimensions = useTerminalDimensions()
const { theme } = useTheme()
const renderer = useRenderer()
let dismiss = false
const width = () => {
if (props.size === "xlarge") return 116
if (props.size === "large") return 88
return 60
}
return (
<box
onMouseDown={() => {
dismiss = !!renderer.getSelection()
}}
onMouseUp={() => {
if (dismiss) {
dismiss = false
return
}
props.onClose?.()
}}
width={dimensions().width}
height={dimensions().height}
alignItems="center"
position="absolute"
zIndex={3000}
paddingTop={dimensions().height / 4}
left={0}
top={0}
backgroundColor={RGBA.fromInts(0, 0, 0, 150)}
>
<box
onMouseUp={(e: { stopPropagation(): void }) => {
dismiss = false
e.stopPropagation()
}}
width={width()}
maxWidth={dimensions().width - 2}
backgroundColor={theme.backgroundPanel}
paddingTop={1}
>
{props.children}
</box>
</box>
)
}
function init() {
const [store, setStore] = createStore({
stack: [] as {
element: JSX.Element
onClose?: () => void
}[],
size: "medium" as "medium" | "large" | "xlarge",
})
const renderer = useRenderer()
const modeStack = useOpencodeModeStack()
createEffect(() => {
if (store.stack.length === 0) return
const popMode = modeStack.push("modal")
onCleanup(popMode)
})
let focus: Renderable | null
function refocus() {
setTimeout(() => {
if (!focus) return
if (focus.isDestroyed) return
function find(item: Renderable) {
for (const child of item.getChildren()) {
if (child === focus) return true
if (find(child)) return true
}
return false
}
const found = find(renderer.root)
if (!found) return
focus.focus()
}, 1)
}
useBindings(() => ({
enabled: store.stack.length > 0 && !renderer.getSelection()?.getSelectedText(),
bindings: [
{
key: "escape",
desc: "Close dialog",
group: "Dialog",
cmd: () => {
if (renderer.getSelection()) {
renderer.clearSelection()
}
const current = store.stack.at(-1)
current?.onClose?.()
setStore("stack", store.stack.slice(0, -1))
refocus()
},
},
{
key: "ctrl+c",
desc: "Close dialog",
group: "Dialog",
cmd: () => {
if (renderer.getSelection()) {
renderer.clearSelection()
}
const current = store.stack.at(-1)
current?.onClose?.()
setStore("stack", store.stack.slice(0, -1))
refocus()
},
},
],
}))
return {
clear() {
for (const item of store.stack) {
if (item.onClose) item.onClose()
}
batch(() => {
setStore("size", "medium")
setStore("stack", [])
})
refocus()
},
replace(input: any, onClose?: () => void) {
if (store.stack.length === 0) {
focus = renderer.currentFocusedRenderable
focus?.blur()
}
for (const item of store.stack) {
if (item.onClose) item.onClose()
}
setStore("size", "medium")
setStore("stack", [
{
element: input,
onClose,
},
])
},
get stack() {
return store.stack
},
get size() {
return store.size
},
setSize(size: "medium" | "large" | "xlarge") {
setStore("size", size)
},
}
}
export type DialogContext = ReturnType<typeof init>
const ctx = createContext<DialogContext>()
export function DialogProvider(props: ParentProps) {
const value = init()
const renderer = useRenderer()
const toast = useToast()
const environment = useTuiEnvironment()
const platform = useOptionalTuiPlatform()
function copySelection() {
const text = renderer.getSelection()?.getSelectedText()
if (!text || !platform?.clipboard?.write) return false
void platform.clipboard.write(text).then(
() => toast.show({ message: "Copied to clipboard", variant: "info" }),
(error) => toast.error(error),
)
renderer.clearSelection()
return true
}
return (
<ctx.Provider value={value}>
{props.children}
<box
position="absolute"
zIndex={3000}
onMouseDown={(evt: { button: number; preventDefault(): void; stopPropagation(): void }) => {
if (environment.capabilities.copyOnSelect) return
if (evt.button !== MouseButton.RIGHT) return
if (!copySelection()) return
evt.preventDefault()
evt.stopPropagation()
}}
onMouseUp={environment.capabilities.copyOnSelect ? copySelection : undefined}
>
<Show when={value.stack.length}>
<Dialog onClose={() => value.clear()} size={value.size}>
{value.stack.at(-1)!.element}
</Dialog>
</Show>
</box>
</ctx.Provider>
)
}
export function useDialog() {
const value = useContext(ctx)
if (!value) {
throw new Error("useDialog must be used within a DialogProvider")
}
return value
}

View file

@ -0,0 +1,34 @@
import type { JSX } from "solid-js"
import type { RGBA } from "@opentui/core"
import open from "open"
export interface LinkProps {
href: string
children?: JSX.Element | string
fg?: RGBA
bg?: RGBA
width?: number | "auto" | `${number}%`
wrapMode?: "word" | "none"
}
/**
* Link component that renders clickable hyperlinks.
* Clicking anywhere on the link text opens the URL in the default browser.
*/
export function Link(props: LinkProps) {
const displayText = props.children ?? props.href
return (
<text
fg={props.fg}
bg={props.bg}
width={props.width}
wrapMode={props.wrapMode}
onMouseUp={() => {
open(props.href).catch(() => {})
}}
>
{displayText}
</text>
)
}

View file

@ -0,0 +1,368 @@
import type { ColorInput } from "@opentui/core"
import { RGBA } from "@opentui/core"
import type { ColorGenerator } from "opentui-spinner"
interface AdvancedGradientOptions {
colors: ColorInput[]
trailLength: number
defaultColor?: ColorInput
direction?: "forward" | "backward" | "bidirectional"
holdFrames?: { start?: number; end?: number }
enableFading?: boolean
minAlpha?: number
}
interface ScannerState {
activePosition: number
isHolding: boolean
holdProgress: number
holdTotal: number
movementProgress: number
movementTotal: number
isMovingForward: boolean
}
function getScannerState(
frameIndex: number,
totalChars: number,
options: Pick<AdvancedGradientOptions, "direction" | "holdFrames">,
): ScannerState {
const { direction = "forward", holdFrames = {} } = options
if (direction === "bidirectional") {
const forwardFrames = totalChars
const holdEndFrames = holdFrames.end ?? 0
const backwardFrames = totalChars - 1
if (frameIndex < forwardFrames) {
// Moving forward
return {
activePosition: frameIndex,
isHolding: false,
holdProgress: 0,
holdTotal: 0,
movementProgress: frameIndex,
movementTotal: forwardFrames,
isMovingForward: true,
}
} else if (frameIndex < forwardFrames + holdEndFrames) {
// Holding at end
return {
activePosition: totalChars - 1,
isHolding: true,
holdProgress: frameIndex - forwardFrames,
holdTotal: holdEndFrames,
movementProgress: 0,
movementTotal: 0,
isMovingForward: true,
}
} else if (frameIndex < forwardFrames + holdEndFrames + backwardFrames) {
// Moving backward
const backwardIndex = frameIndex - forwardFrames - holdEndFrames
return {
activePosition: totalChars - 2 - backwardIndex,
isHolding: false,
holdProgress: 0,
holdTotal: 0,
movementProgress: backwardIndex,
movementTotal: backwardFrames,
isMovingForward: false,
}
} else {
// Holding at start
return {
activePosition: 0,
isHolding: true,
holdProgress: frameIndex - forwardFrames - holdEndFrames - backwardFrames,
holdTotal: holdFrames.start ?? 0,
movementProgress: 0,
movementTotal: 0,
isMovingForward: false,
}
}
} else if (direction === "backward") {
return {
activePosition: totalChars - 1 - (frameIndex % totalChars),
isHolding: false,
holdProgress: 0,
holdTotal: 0,
movementProgress: frameIndex % totalChars,
movementTotal: totalChars,
isMovingForward: false,
}
} else {
return {
activePosition: frameIndex % totalChars,
isHolding: false,
holdProgress: 0,
holdTotal: 0,
movementProgress: frameIndex % totalChars,
movementTotal: totalChars,
isMovingForward: true,
}
}
}
function calculateColorIndex(
frameIndex: number,
charIndex: number,
totalChars: number,
options: Pick<AdvancedGradientOptions, "direction" | "holdFrames" | "trailLength">,
state?: ScannerState,
): number {
const { trailLength } = options
const { activePosition, isHolding, holdProgress, isMovingForward } =
state ?? getScannerState(frameIndex, totalChars, options)
// Calculate directional distance (positive means trailing behind)
const directionalDistance = isMovingForward
? activePosition - charIndex // For forward: trail is to the left (lower indices)
: charIndex - activePosition // For backward: trail is to the right (higher indices)
// Handle hold frame fading: keep the lead bright, fade the trail
if (isHolding) {
// Shift the color index by how long we've been holding
return directionalDistance + holdProgress
}
// Normal movement - show gradient trail only behind the movement direction
if (directionalDistance > 0 && directionalDistance < trailLength) {
return directionalDistance
}
// At the active position, show the brightest color
if (directionalDistance === 0) {
return 0
}
return -1
}
function createKnightRiderTrail(options: AdvancedGradientOptions): ColorGenerator {
const { colors, defaultColor, enableFading = true, minAlpha = 0 } = options
// Use the provided defaultColor if it's an RGBA instance, otherwise convert/default
// We use RGBA.fromHex for the fallback to ensure we have an RGBA object.
// Note: If defaultColor is a string, we convert it once here.
const defaultRgba = defaultColor instanceof RGBA ? defaultColor : RGBA.fromHex((defaultColor as string) || "#000000")
// Store the base alpha from the inactive factor
const baseInactiveAlpha = defaultRgba.a
let cachedFrameIndex = -1
let cachedState: ScannerState | null = null
return (frameIndex: number, charIndex: number, _totalFrames: number, totalChars: number) => {
if (frameIndex !== cachedFrameIndex) {
cachedFrameIndex = frameIndex
cachedState = getScannerState(frameIndex, totalChars, options)
}
const state = cachedState!
const index = calculateColorIndex(frameIndex, charIndex, totalChars, options, state)
// Calculate global fade for inactive dots during hold or movement
const { isHolding, holdProgress, holdTotal, movementProgress, movementTotal } = state
let fadeFactor = 1.0
if (enableFading) {
if (isHolding && holdTotal > 0) {
// Fade out linearly to minAlpha
const progress = Math.min(holdProgress / holdTotal, 1)
fadeFactor = Math.max(minAlpha, 1 - progress * (1 - minAlpha))
} else if (!isHolding && movementTotal > 0) {
// Fade in linearly from minAlpha during movement
const progress = Math.min(movementProgress / Math.max(1, movementTotal - 1), 1)
fadeFactor = minAlpha + progress * (1 - minAlpha)
}
}
// Combine base inactive alpha with the fade factor
// This ensures inactiveFactor is respected while still allowing fading animation
defaultRgba.a = baseInactiveAlpha * fadeFactor
if (index === -1) {
return defaultRgba
}
return colors[index] ?? defaultRgba
}
}
/**
* Derives a gradient of tail colors from a single bright color using alpha falloff
* @param brightColor The brightest color (center/head of the scanner)
* @param steps Number of gradient steps (default: 6)
* @returns Array of RGBA colors with alpha-based trail fade (background-independent)
*/
export function deriveTrailColors(brightColor: ColorInput, steps: number = 6): RGBA[] {
const baseRgba = brightColor instanceof RGBA ? brightColor : RGBA.fromHex(brightColor as string)
const colors: RGBA[] = []
for (let i = 0; i < steps; i++) {
// Alpha-based falloff with optional bloom effect
let alpha: number
let brightnessFactor: number
if (i === 0) {
// Lead position: full brightness and opacity
alpha = 1.0
brightnessFactor = 1.0
} else if (i === 1) {
// Slight bloom/glare effect: brighten color but reduce opacity slightly
alpha = 0.9
brightnessFactor = 1.15
} else {
// Exponential alpha decay for natural-looking trail fade
alpha = Math.pow(0.65, i - 1)
brightnessFactor = 1.0
}
const r = Math.min(1.0, baseRgba.r * brightnessFactor)
const g = Math.min(1.0, baseRgba.g * brightnessFactor)
const b = Math.min(1.0, baseRgba.b * brightnessFactor)
colors.push(RGBA.fromValues(r, g, b, alpha))
}
return colors
}
/**
* Derives the inactive/default color from a bright color using alpha
* @param brightColor The brightest color (center/head of the scanner)
* @param factor Alpha factor for inactive color (default: 0.2, range: 0-1)
* @returns The same color with reduced alpha for background-independent dimming
*/
export function deriveInactiveColor(brightColor: ColorInput, factor: number = 0.2): RGBA {
const baseRgba = brightColor instanceof RGBA ? brightColor : RGBA.fromHex(brightColor as string)
// Use the full color brightness but adjust alpha for background-independent dimming
return RGBA.fromValues(baseRgba.r, baseRgba.g, baseRgba.b, factor)
}
export type KnightRiderStyle = "blocks" | "diamonds"
export interface KnightRiderOptions {
width?: number
style?: KnightRiderStyle
holdStart?: number
holdEnd?: number
colors?: ColorInput[]
/** Single color to derive trail from (alternative to providing colors array) */
color?: ColorInput
/** Number of trail steps when using single color (default: 6) */
trailSteps?: number
defaultColor?: ColorInput
/** Alpha factor for inactive color when using single color (default: 0.2, range: 0-1) */
inactiveFactor?: number
/** Enable fading of inactive dots during hold and movement (default: true) */
enableFading?: boolean
/** Minimum alpha value when fading (default: 0, range: 0-1) */
minAlpha?: number
}
/**
* Creates frame strings for a Knight Rider style scanner animation
* @param options Configuration options for the Knight Rider effect
* @returns Array of frame strings
*/
export function createFrames(options: KnightRiderOptions = {}): string[] {
const width = options.width ?? 8
const style = options.style ?? "diamonds"
const holdStart = options.holdStart ?? 30
const holdEnd = options.holdEnd ?? 9
const colors =
options.colors ??
(options.color
? deriveTrailColors(options.color, options.trailSteps)
: [
RGBA.fromHex("#ff0000"), // Brightest Red (Center)
RGBA.fromHex("#ff5555"), // Glare/Bloom
RGBA.fromHex("#dd0000"), // Trail 1
RGBA.fromHex("#aa0000"), // Trail 2
RGBA.fromHex("#770000"), // Trail 3
RGBA.fromHex("#440000"), // Trail 4
])
const defaultColor =
options.defaultColor ??
(options.color ? deriveInactiveColor(options.color, options.inactiveFactor) : RGBA.fromHex("#330000"))
const trailOptions = {
colors,
trailLength: colors.length,
defaultColor,
direction: "bidirectional" as const,
holdFrames: { start: holdStart, end: holdEnd },
enableFading: options.enableFading,
minAlpha: options.minAlpha,
}
// Bidirectional cycle: Forward (width) + Hold End + Backward (width-1) + Hold Start
const totalFrames = width + holdEnd + (width - 1) + holdStart
// Generate dynamic frames where inactive pixels are dots and active ones are blocks
const frames = Array.from({ length: totalFrames }, (_, frameIndex) => {
return Array.from({ length: width }, (_, charIndex) => {
const index = calculateColorIndex(frameIndex, charIndex, width, trailOptions)
if (style === "diamonds") {
const shapes = ["⬥", "◆", "⬩", "⬪"]
if (index >= 0 && index < trailOptions.colors.length) {
return shapes[Math.min(index, shapes.length - 1)]
}
return "·"
}
// Default to blocks
// It's active if we have a valid color index that is within our colors array
const isActive = index >= 0 && index < trailOptions.colors.length
return isActive ? "■" : "⬝"
}).join("")
})
return frames
}
/**
* Creates a color generator function for Knight Rider style scanner animation
* @param options Configuration options for the Knight Rider effect
* @returns ColorGenerator function
*/
export function createColors(options: KnightRiderOptions = {}): ColorGenerator {
const holdStart = options.holdStart ?? 30
const holdEnd = options.holdEnd ?? 9
const colors =
options.colors ??
(options.color
? deriveTrailColors(options.color, options.trailSteps)
: [
RGBA.fromHex("#ff0000"), // Brightest Red (Center)
RGBA.fromHex("#ff5555"), // Glare/Bloom
RGBA.fromHex("#dd0000"), // Trail 1
RGBA.fromHex("#aa0000"), // Trail 2
RGBA.fromHex("#770000"), // Trail 3
RGBA.fromHex("#440000"), // Trail 4
])
const defaultColor =
options.defaultColor ??
(options.color ? deriveInactiveColor(options.color, options.inactiveFactor) : RGBA.fromHex("#330000"))
const trailOptions = {
colors,
trailLength: colors.length,
defaultColor,
direction: "bidirectional" as const,
holdFrames: { start: holdStart, end: holdEnd },
enableFading: options.enableFading,
minAlpha: options.minAlpha,
}
return createKnightRiderTrail(trailOptions)
}

View file

@ -0,0 +1,102 @@
import { createContext, useContext, type ParentProps, Show } from "solid-js"
import { createStore } from "solid-js/store"
import { useTheme } from "../context/theme"
import { useTerminalDimensions } from "@opentui/solid"
import { SplitBorder } from "./border"
import { TextAttributes } from "@opentui/core"
export type ToastOptions = {
title?: string
message: string
variant: "info" | "success" | "warning" | "error"
duration: number
}
type ToastInput = Omit<ToastOptions, "duration"> & { duration?: number }
export function Toast() {
const toast = useToast()
const { theme } = useTheme()
const dimensions = useTerminalDimensions()
return (
<Show when={toast.currentToast}>
{(current) => (
<box
position="absolute"
justifyContent="center"
alignItems="flex-start"
top={2}
right={2}
maxWidth={Math.min(60, dimensions().width - 6)}
paddingLeft={2}
paddingRight={2}
paddingTop={1}
paddingBottom={1}
backgroundColor={theme.backgroundPanel}
borderColor={theme[current().variant]}
border={["left", "right"]}
customBorderChars={SplitBorder.customBorderChars}
>
<Show when={current().title}>
<text attributes={TextAttributes.BOLD} marginBottom={1} fg={theme.text}>
{current().title}
</text>
</Show>
<text fg={theme.text} wrapMode="word" width="100%">
{current().message}
</text>
</box>
)}
</Show>
)
}
function init() {
const [store, setStore] = createStore({
currentToast: null as ToastOptions | null,
})
let timeoutHandle: NodeJS.Timeout | null = null
const toast = {
show(options: ToastInput) {
const toastOptions = { ...options, duration: options.duration ?? 5000 }
setStore("currentToast", toastOptions)
if (timeoutHandle) clearTimeout(timeoutHandle)
timeoutHandle = setTimeout(() => {
setStore("currentToast", null)
}, toastOptions.duration).unref()
},
error: (err: any) => {
if (err instanceof Error)
return toast.show({
variant: "error",
message: err.message,
})
toast.show({
variant: "error",
message: "An unknown error has occurred",
})
},
get currentToast(): ToastOptions | null {
return store.currentToast
},
}
return toast
}
export type ToastContext = ReturnType<typeof init>
const ctx = createContext<ToastContext>()
export function ToastProvider(props: ParentProps) {
const value = init()
return <ctx.Provider value={value}>{props.children}</ctx.Provider>
}
export function useToast() {
const value = useContext(ctx)
if (!value) {
throw new Error("useToast must be used within a ToastProvider")
}
return value
}