getting terminal colors working

This commit is contained in:
2026-02-05 13:46:47 -05:00
parent 9fa52d71ca
commit e239b33042
45 changed files with 1718 additions and 2055 deletions

307
src/ui/command.tsx Normal file
View File

@@ -0,0 +1,307 @@
import {
createContext,
createMemo,
createSignal,
onCleanup,
useContext,
type Accessor,
type ParentProps,
For,
Show,
} from "solid-js"
import { useKeyboard, useTerminalDimensions } from "@opentui/solid"
import { useKeybind } from "../context/KeybindContext"
import { useDialog } from "./dialog"
import { useTheme } from "../context/ThemeContext"
import type { KeybindsConfig } from "../utils/keybind"
import { TextAttributes } from "@opentui/core"
import { emit } from "../utils/event-bus"
/**
* Command option for the command palette.
*/
export type CommandOption = {
/** Display title */
title: string
/** Unique identifier */
value: string
/** Description shown below title */
description?: string
/** Category for grouping */
category?: string
/** Keybind reference */
keybind?: keyof KeybindsConfig
/** Whether this command is suggested */
suggested?: boolean
/** Slash command configuration */
slash?: {
name: string
aliases?: string[]
}
/** Whether to hide from command list */
hidden?: boolean
/** Whether command is enabled */
enabled?: boolean
/** Footer text (usually keybind display) */
footer?: string
/** Handler when command is selected */
onSelect?: (dialog: ReturnType<typeof useDialog>) => void
}
type CommandContext = ReturnType<typeof init>
const ctx = createContext<CommandContext>()
function init() {
const [registrations, setRegistrations] = createSignal<Accessor<CommandOption[]>[]>([])
const [suspendCount, setSuspendCount] = createSignal(0)
const dialog = useDialog()
const keybind = useKeybind()
const entries = createMemo(() => {
const all = registrations().flatMap((x) => x())
return all.map((x) => ({
...x,
footer: x.keybind ? keybind.print(x.keybind) : undefined,
}))
})
const isEnabled = (option: CommandOption) => option.enabled !== false
const isVisible = (option: CommandOption) => isEnabled(option) && !option.hidden
const visibleOptions = createMemo(() => entries().filter((option) => isVisible(option)))
const suggestedOptions = createMemo(() =>
visibleOptions()
.filter((option) => option.suggested)
.map((option) => ({
...option,
value: `suggested:${option.value}`,
category: "Suggested",
})),
)
const suspended = () => suspendCount() > 0
// Handle keybind shortcuts
useKeyboard((evt) => {
if (suspended()) return
if (dialog.isOpen) return
for (const option of entries()) {
if (!isEnabled(option)) continue
if (option.keybind && keybind.match(option.keybind, evt)) {
evt.preventDefault()
option.onSelect?.(dialog)
emit("command.execute", { command: option.value })
return
}
}
})
const result = {
/**
* Trigger a command by its value.
*/
trigger(name: string) {
for (const option of entries()) {
if (option.value === name) {
if (!isEnabled(option)) return
option.onSelect?.(dialog)
emit("command.execute", { command: name })
return
}
}
},
/**
* Get all slash commands.
*/
slashes() {
return visibleOptions().flatMap((option) => {
const slash = option.slash
if (!slash) return []
return {
display: "/" + slash.name,
description: option.description ?? option.title,
aliases: slash.aliases?.map((alias) => "/" + alias),
onSelect: () => result.trigger(option.value),
}
})
},
/**
* Enable/disable keybinds temporarily.
*/
keybinds(enabled: boolean) {
setSuspendCount((count) => count + (enabled ? -1 : 1))
},
suspended,
/**
* Show the command palette dialog.
*/
show() {
dialog.replace(() => <CommandDialog options={visibleOptions()} suggestedOptions={suggestedOptions()} />)
},
/**
* Register commands. Returns cleanup function.
*/
register(cb: () => CommandOption[]) {
const results = createMemo(cb)
setRegistrations((arr) => [results, ...arr])
onCleanup(() => {
setRegistrations((arr) => arr.filter((x) => x !== results))
})
},
/**
* Get all visible options.
*/
get options() {
return visibleOptions()
},
}
return result
}
export function useCommandDialog() {
const value = useContext(ctx)
if (!value) {
throw new Error("useCommandDialog must be used within a CommandProvider")
}
return value
}
export function CommandProvider(props: ParentProps) {
const value = init()
const dialog = useDialog()
const keybind = useKeybind()
// Open command palette on ctrl+p or command_list keybind
useKeyboard((evt) => {
if (value.suspended()) return
if (dialog.isOpen) return
if (evt.defaultPrevented) return
if (keybind.match("command_list", evt)) {
evt.preventDefault()
value.show()
return
}
})
return <ctx.Provider value={value}>{props.children}</ctx.Provider>
}
/**
* Command palette dialog component.
*/
function CommandDialog(props: { options: CommandOption[]; suggestedOptions: CommandOption[] }) {
const { theme } = useTheme()
const dialog = useDialog()
const dimensions = useTerminalDimensions()
const [filter, setFilter] = createSignal("")
const [selectedIndex, setSelectedIndex] = createSignal(0)
const filteredOptions = createMemo(() => {
const query = filter().toLowerCase()
if (!query) {
return [...props.suggestedOptions, ...props.options]
}
return props.options.filter(
(option) =>
option.title.toLowerCase().includes(query) ||
option.description?.toLowerCase().includes(query) ||
option.category?.toLowerCase().includes(query)
)
})
// Reset selection when filter changes
createMemo(() => {
filter()
setSelectedIndex(0)
})
useKeyboard((evt) => {
if (evt.name === "escape") {
dialog.clear()
evt.preventDefault()
return
}
if (evt.name === "return" || evt.name === "enter") {
const option = filteredOptions()[selectedIndex()]
if (option) {
option.onSelect?.(dialog)
dialog.clear()
}
evt.preventDefault()
return
}
if (evt.name === "up" || (evt.ctrl && evt.name === "p")) {
setSelectedIndex((i) => Math.max(0, i - 1))
evt.preventDefault()
return
}
if (evt.name === "down" || (evt.ctrl && evt.name === "n")) {
setSelectedIndex((i) => Math.min(filteredOptions().length - 1, i + 1))
evt.preventDefault()
return
}
// Handle text input
if (evt.name && evt.name.length === 1 && !evt.ctrl && !evt.meta) {
setFilter((f) => f + evt.name)
return
}
if (evt.name === "backspace") {
setFilter((f) => f.slice(0, -1))
return
}
})
const maxHeight = Math.floor(dimensions().height * 0.6)
return (
<box flexDirection="column" padding={1}>
{/* Search input */}
<box marginBottom={1}>
<text fg={theme.textMuted}>
{"> "}
</text>
<text fg={theme.text}>
{filter() || "Type to search commands..."}
</text>
</box>
{/* Command list */}
<box flexDirection="column" maxHeight={maxHeight}>
<For each={filteredOptions().slice(0, 10)}>
{(option, index) => (
<box
backgroundColor={index() === selectedIndex() ? theme.primary : undefined}
padding={1}
>
<box flexDirection="column" flexGrow={1}>
<box flexDirection="row" justifyContent="space-between">
<text
fg={index() === selectedIndex() ? theme.selectedListItemText : theme.text}
attributes={index() === selectedIndex() ? TextAttributes.BOLD : undefined}
>
{option.title}
</text>
<Show when={option.footer}>
<text fg={theme.textMuted}>{option.footer}</text>
</Show>
</box>
<Show when={option.description}>
<text fg={theme.textMuted}>{option.description}</text>
</Show>
</box>
</box>
)}
</For>
<Show when={filteredOptions().length === 0}>
<text fg={theme.textMuted} style={{ padding: 1 }}>
No commands found
</text>
</Show>
</box>
</box>
)
}

224
src/ui/dialog.tsx Normal file
View File

@@ -0,0 +1,224 @@
import { useKeyboard, useRenderer, useTerminalDimensions } from "@opentui/solid"
import { batch, createContext, Show, useContext, type JSX, type ParentProps } from "solid-js"
import { useTheme } from "../context/ThemeContext"
import { RGBA, Renderable } from "@opentui/core"
import { createStore } from "solid-js/store"
import { Clipboard } from "../utils/clipboard"
import { useToast } from "./toast"
import { emit } from "../utils/event-bus"
export type DialogSize = "medium" | "large"
/**
* Dialog component that renders a modal overlay with content.
*/
export function Dialog(
props: ParentProps<{
size?: DialogSize
onClose: () => void
}>,
) {
const dimensions = useTerminalDimensions()
const { theme } = useTheme()
const renderer = useRenderer()
return (
<box
onMouseUp={async () => {
if (renderer.getSelection()) return
props.onClose?.()
}}
width={dimensions().width}
height={dimensions().height}
alignItems="center"
position="absolute"
paddingTop={Math.floor(dimensions().height / 4)}
left={0}
top={0}
backgroundColor={RGBA.fromInts(0, 0, 0, 150)}
>
<box
onMouseUp={async (e) => {
if (renderer.getSelection()) return
e.stopPropagation()
}}
width={props.size === "large" ? 80 : 60}
maxWidth={dimensions().width - 2}
backgroundColor={theme.backgroundPanel}
paddingTop={1}
>
{props.children}
</box>
</box>
)
}
type DialogStackItem = {
element: JSX.Element
onClose?: () => void
}
function init() {
const [store, setStore] = createStore({
stack: [] as DialogStackItem[],
size: "medium" as DialogSize,
})
const renderer = useRenderer()
let focus: Renderable | null = null
function refocus() {
setTimeout(() => {
if (!focus) return
if (focus.isDestroyed) return
function find(item: Renderable): boolean {
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)
}
useKeyboard((evt) => {
if (evt.name === "escape" && store.stack.length > 0) {
const current = store.stack.at(-1)!
current.onClose?.()
setStore("stack", store.stack.slice(0, -1))
evt.preventDefault()
evt.stopPropagation()
refocus()
emit("dialog.close", {})
}
})
return {
/**
* Clear all dialogs from the stack.
*/
clear() {
for (const item of store.stack) {
if (item.onClose) item.onClose()
}
batch(() => {
setStore("size", "medium")
setStore("stack", [])
})
refocus()
emit("dialog.close", {})
},
/**
* Replace all dialogs with a new one.
*/
replace(input: JSX.Element | (() => JSX.Element), onClose?: () => void) {
if (store.stack.length === 0) {
focus = renderer.currentFocusedRenderable
focus?.blur()
}
for (const item of store.stack) {
if (item.onClose) item.onClose()
}
const element = typeof input === "function" ? input() : input
setStore("size", "medium")
setStore("stack", [{ element, onClose }])
emit("dialog.open", { dialogId: "dialog" })
},
/**
* Push a new dialog onto the stack.
*/
push(input: JSX.Element | (() => JSX.Element), onClose?: () => void) {
if (store.stack.length === 0) {
focus = renderer.currentFocusedRenderable
focus?.blur()
}
const element = typeof input === "function" ? input() : input
setStore("stack", [...store.stack, { element, onClose }])
emit("dialog.open", { dialogId: "dialog" })
},
/**
* Pop the top dialog from the stack.
*/
pop() {
if (store.stack.length === 0) return
const current = store.stack.at(-1)!
current.onClose?.()
setStore("stack", store.stack.slice(0, -1))
if (store.stack.length === 0) {
refocus()
}
emit("dialog.close", {})
},
get stack() {
return store.stack
},
get size() {
return store.size
},
setSize(size: DialogSize) {
setStore("size", size)
},
get isOpen() {
return store.stack.length > 0
},
}
}
export type DialogContext = ReturnType<typeof init>
const ctx = createContext<DialogContext>()
/**
* DialogProvider wraps the application and provides dialog functionality.
* Also handles clipboard copy on text selection within dialogs.
*/
export function DialogProvider(props: ParentProps) {
const value = init()
const renderer = useRenderer()
const toast = useToast()
return (
<ctx.Provider value={value}>
{props.children}
<box
position="absolute"
onMouseUp={async () => {
const text = renderer.getSelection()?.getSelectedText()
if (text && text.length > 0) {
await Clipboard.copy(text)
.then(() => toast.show({ message: "Copied to clipboard", variant: "info" }))
.catch(toast.error)
renderer.clearSelection()
}
}}
>
<Show when={value.stack.length > 0}>
<Dialog onClose={() => value.clear()} size={value.size}>
{value.stack.at(-1)!.element}
</Dialog>
</Show>
</box>
</ctx.Provider>
)
}
/**
* Hook to access the dialog context.
*/
export function useDialog() {
const value = useContext(ctx)
if (!value) {
throw new Error("useDialog must be used within a DialogProvider")
}
return value
}

153
src/ui/toast.tsx Normal file
View File

@@ -0,0 +1,153 @@
import { createContext, useContext, type ParentProps, Show } from "solid-js"
import { createStore } from "solid-js/store"
import { useTheme } from "../context/ThemeContext"
import { useTerminalDimensions } from "@opentui/solid"
import { TextAttributes } from "@opentui/core"
import { emit } from "../utils/event-bus"
export type ToastVariant = "info" | "success" | "warning" | "error"
export type ToastOptions = {
title?: string
message: string
variant: ToastVariant
duration?: number
}
const DEFAULT_DURATION = 5000
/**
* Toast component that displays at the top-right of the screen.
* NOTE: This component must be rendered INSIDE ThemeProvider since it uses useTheme().
* The ToastProvider itself can be placed outside ThemeProvider if needed.
*/
export function Toast() {
const toast = useToast()
const { theme } = useTheme()
const dimensions = useTerminalDimensions()
const getVariantColor = (variant: ToastVariant) => {
switch (variant) {
case "success":
return theme.success
case "warning":
return theme.warning
case "error":
return theme.error
case "info":
default:
return theme.info
}
}
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={getVariantColor(current().variant)}
border={["left", "right"]}
>
<box flexDirection="column">
<Show when={current().title}>
<text attributes={TextAttributes.BOLD} style={{ marginBottom: 1 }} fg={theme.text}>
{current().title}
</text>
</Show>
<text fg={theme.text} wrapMode="word" width="100%">
{current().message}
</text>
</box>
</box>
)}
</Show>
)
}
function init() {
const [store, setStore] = createStore({
currentToast: null as ToastOptions | null,
})
let timeoutHandle: NodeJS.Timeout | null = null
const toast = {
show(options: ToastOptions) {
const duration = options.duration ?? DEFAULT_DURATION
setStore("currentToast", {
title: options.title,
message: options.message,
variant: options.variant,
})
// Emit event for other listeners
emit("toast.show", options)
if (timeoutHandle) clearTimeout(timeoutHandle)
timeoutHandle = setTimeout(() => {
setStore("currentToast", null)
}, duration)
},
error: (err: unknown) => {
if (err instanceof Error) {
return toast.show({
variant: "error",
message: err.message,
})
}
toast.show({
variant: "error",
message: "An unknown error has occurred",
})
},
info: (message: string, title?: string) => {
toast.show({ variant: "info", message, title })
},
success: (message: string, title?: string) => {
toast.show({ variant: "success", message, title })
},
warning: (message: string, title?: string) => {
toast.show({ variant: "warning", message, title })
},
clear: () => {
if (timeoutHandle) clearTimeout(timeoutHandle)
setStore("currentToast", null)
},
get currentToast(): ToastOptions | null {
return store.currentToast
},
}
return toast
}
export type ToastContext = ReturnType<typeof init>
const ctx = createContext<ToastContext>()
/**
* ToastProvider provides toast functionality.
* NOTE: The Toast UI component is NOT rendered here - you must render <Toast />
* separately inside your component tree, after ThemeProvider.
*/
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
}