/** @jsxImportSource @opentui/solid */ import { TextAttributes, type InputRenderable, type KeyEvent } from "@opentui/core" import { useKeyboard, type JSX } from "@opentui/solid" import fuzzysort from "fuzzysort" import { createEffect, createMemo, createSignal, type Accessor } from "solid-js" import { RunFooterMenu, createFooterMenuState, type RunFooterMenuItem } from "./footer.menu" import { formatBindings } from "./keymap.shared" import type { RunFooterTheme } from "./theme" import type { FooterKeybinds, FooterSubagentTab, RunCommand, RunInput, RunProvider } from "./types" type PanelEntry = RunFooterMenuItem & { category: string keywords?: string } type CommandEntry = | (PanelEntry & { action: "model" }) | (PanelEntry & { action: "subagent" }) | (PanelEntry & { action: "variant.cycle" }) | (PanelEntry & { action: "variant.list" }) | (PanelEntry & { action: "slash"; name: string }) | (PanelEntry & { action: "exit" }) type ModelEntry = PanelEntry & { providerID: string modelID: string providerName: string current: boolean } type VariantEntry = PanelEntry & { variant: string | undefined current: boolean } type SubagentEntry = PanelEntry & { sessionID: string current: boolean } type MenuState = ReturnType const PANEL_PAD = 2 const PANEL_LIST_ROWS = 10 const PANEL_FRAME_ROWS = 6 export const RUN_COMMAND_PANEL_ROWS = PANEL_LIST_ROWS + PANEL_FRAME_ROWS const SUBAGENT_LIST_ROWS = 12 export const RUN_SUBAGENT_PANEL_ROWS = SUBAGENT_LIST_ROWS + PANEL_FRAME_ROWS const PANEL_PAGE = PANEL_LIST_ROWS - 1 const PANEL_BORDER = { topLeft: "", bottomLeft: "", vertical: "┃", topRight: "", bottomRight: "", horizontal: " ", bottomT: "", topT: "", cross: "", leftT: "", rightT: "", } const PANEL_BOTTOM_BORDER = { ...PANEL_BORDER, vertical: "╹", } const HALF_BLOCK_BORDER = { topLeft: "", bottomLeft: "", vertical: "", topRight: "", bottomRight: "", horizontal: "▀", bottomT: "", topT: "", cross: "", leftT: "", rightT: "", } function countLabel(count: number, total: number, query: string) { if (!query.trim()) { return `${total}` } return `${count}/${total}` } function categoryRank(category: string) { if (category === "Project Commands") { return 0 } if (category === "MCP Commands") { return 1 } return 2 } function subagentStatusLabel(status: FooterSubagentTab["status"]) { if (status === "completed") { return "done" } if (status === "error") { return "error" } return "running" } function handleKey(input: { event: KeyEvent menu: MenuState field: () => InputRenderable | undefined setQuery: (value: string) => void select: () => void close: () => void }) { const name = input.event.name.toLowerCase() const ctrl = input.event.ctrl && !input.event.meta && !input.event.shift && !input.event.super if (name === "escape" || (ctrl && name === "c")) { input.event.preventDefault() input.close() return } if (name === "up" || (ctrl && name === "p")) { input.event.preventDefault() input.menu.move(-1) return } if (name === "down" || (ctrl && name === "n")) { input.event.preventDefault() input.menu.move(1) return } if (name === "pageup") { input.event.preventDefault() input.menu.reveal(input.menu.selected() - PANEL_PAGE) return } if (name === "pagedown") { input.event.preventDefault() input.menu.reveal(input.menu.selected() + PANEL_PAGE) return } if (name === "home") { input.event.preventDefault() input.menu.reveal(0) return } if (name === "end") { input.event.preventDefault() input.menu.reveal(Number.POSITIVE_INFINITY) return } if (name === "return") { input.event.preventDefault() input.select() return } if (ctrl && name === "u") { input.event.preventDefault() input.setQuery("") input.field()?.setText("") } } function match(query: string, entries: T[]) { const text = query.trim() if (!text) { return entries } return fuzzysort .go(text, entries, { keys: ["display", "category", "description", "keywords"] }) .map((item) => item.obj) } function PanelShell(props: { id: string title: string countVisible?: boolean query: string count: number total: number placeholder: string theme: Accessor inputRef: (input: InputRenderable) => void onQuery: (query: string) => void children: JSX.Element }) { return ( {props.title} {props.countVisible !== false ? ( {countLabel(props.count, props.total, props.query)} ) : null} esc { props.inputRef(input) input.traits = { status: "FILTER" } queueMicrotask(() => { if (!input.isDestroyed) { input.focus() } }) }} /> {props.children} ) } export function RunCommandMenuBody(props: { theme: Accessor commands: Accessor subagents: Accessor variants: Accessor keybinds: FooterKeybinds onClose: () => void onModel: () => void onSubagent: () => void onVariant: () => void onVariantCycle: () => void onCommand: (name: string) => void onNew: () => void onExit: () => void }) { let field: InputRenderable | undefined const [query, setQuery] = createSignal("") const entries = createMemo(() => { const builtins = ["new"] return [ { action: "model", category: "Suggested", display: "Switch model", }, ...(props.subagents().length > 0 ? [ { action: "subagent" as const, category: "Suggested", display: "View subagents", footer: `${props.subagents().length} active`, keywords: props .subagents() .map((item) => `${item.label} ${item.description} ${item.title ?? ""}`) .join(" "), }, ] : []), { action: "variant.cycle", category: "Suggested", display: "Variant cycle", footer: formatBindings(props.keybinds.variantCycle, props.keybinds.leader), keywords: "variant cycle", }, ...(props.variants().length > 0 ? [ { action: "variant.list" as const, category: "Suggested", display: "Switch model variant", keywords: `variant variants ${props.variants().join(" ")}`, }, ] : []), { action: "slash", category: "Session", name: "new", display: "New session", footer: "/new", keywords: "new session clear", }, ...(props.commands() ?? []) .filter((item) => item.source !== "skill" && !builtins.includes(item.name)) .map( (item) => ({ action: "slash", category: item.source === "mcp" ? "MCP Commands" : "Project Commands", name: item.name, display: item.name, footer: `/${item.name}`, keywords: item.source === "mcp" ? `/${item.name} ${item.name} mcp ${item.description ?? ""}` : `/${item.name} ${item.name} ${item.description ?? ""}`, }) satisfies CommandEntry, ) .sort((a, b) => categoryRank(a.category) - categoryRank(b.category) || a.display.localeCompare(b.display)), { action: "exit", category: "System", display: "Exit", footer: "/exit", keywords: "/exit exit" }, ] }) const items = createMemo(() => match(query(), entries())) const menu = createFooterMenuState({ count: () => items().length, limit: PANEL_LIST_ROWS }) const pick = (item: CommandEntry) => { if (item.action === "model") { props.onModel() return } if (item.action === "subagent") { props.onSubagent() return } if (item.action === "variant.cycle") { props.onVariantCycle() return } if (item.action === "variant.list") { props.onVariant() return } if (item.action === "exit") { props.onExit() return } if (item.name === "new") { props.onNew() return } props.onCommand(item.name) } const select = () => { const item = items()[menu.selected()] if (!item) { return } pick(item) } createEffect(() => { query() menu.reset() }) useKeyboard((event) => { if (event.defaultPrevented) { return } handleKey({ event, menu, field: () => field, setQuery, select, close: props.onClose }) }) return ( { field = input }} onQuery={setQuery} > PANEL_LIST_ROWS} limit={PANEL_LIST_ROWS} empty="No results found" border={false} paddingLeft={PANEL_PAD} paddingRight={PANEL_PAD} grouped={!query().trim()} /> ) } export function RunSubagentSelectBody(props: { theme: Accessor tabs: Accessor current: Accessor onClose: () => void onSelect: (sessionID: string) => void onRows?: (rows: number) => void }) { let field: InputRenderable | undefined const [query, setQuery] = createSignal("") const entries = createMemo(() => props.tabs().map((item) => { const title = item.description || item.title || item.label return { category: "", display: title, description: title === item.label ? undefined : item.label, footer: subagentStatusLabel(item.status), keywords: `${item.label} ${item.description} ${item.title ?? ""} ${item.status}`, sessionID: item.sessionID, current: props.current() === item.sessionID, } }), ) const items = createMemo(() => match(query(), entries())) const menu = createFooterMenuState({ count: () => items().length, limit: SUBAGENT_LIST_ROWS }) const select = () => { const item = items()[menu.selected()] if (!item) { return } props.onSelect(item.sessionID) } createEffect(() => { query() menu.reset() }) createEffect(() => { if (query().trim()) { return } const index = items().findIndex((item) => item.current) if (index !== -1) { menu.reveal(index) } }) createEffect(() => { props.onRows?.(menu.rows() + PANEL_FRAME_ROWS) }) useKeyboard((event) => { if (event.defaultPrevented) { return } handleKey({ event, menu, field: () => field, setQuery, select, close: props.onClose }) }) return ( { field = input }} onQuery={setQuery} > ) } export function RunVariantSelectBody(props: { theme: Accessor variants: Accessor current: Accessor onClose: () => void onSelect: (variant: string | undefined) => void }) { let field: InputRenderable | undefined const [query, setQuery] = createSignal("") const entries = createMemo(() => [ { category: "", display: "Default", description: props.current() === undefined ? "current" : undefined, keywords: "default", variant: undefined, current: props.current() === undefined, }, ...props.variants().map((variant) => ({ category: "", display: variant, description: props.current() === variant ? "current" : undefined, keywords: variant, variant, current: props.current() === variant, })), ]) const items = createMemo(() => match(query(), entries())) const menu = createFooterMenuState({ count: () => items().length, limit: PANEL_LIST_ROWS }) const pick = (item: VariantEntry) => { props.onSelect(item.variant) } const select = () => { const item = items()[menu.selected()] if (!item) { return } pick(item) } createEffect(() => { query() menu.reset() }) createEffect(() => { if (query().trim()) { return } const index = items().findIndex((item) => item.current) if (index !== -1) { menu.reveal(index) } }) useKeyboard((event) => { if (event.defaultPrevented) { return } handleKey({ event, menu, field: () => field, setQuery, select, close: props.onClose }) }) return ( { field = input }} onQuery={setQuery} > PANEL_LIST_ROWS} limit={PANEL_LIST_ROWS} empty="No results found" border={false} paddingLeft={PANEL_PAD} paddingRight={PANEL_PAD} grouped={false} /> ) } export function RunModelSelectBody(props: { theme: Accessor providers: Accessor current: Accessor onClose: () => void onSelect: (model: NonNullable) => void }) { let field: InputRenderable | undefined const [query, setQuery] = createSignal("") const entries = createMemo(() => (props.providers() ?? []) .flatMap((provider) => Object.entries(provider.models) .filter(([, model]) => model.status !== "deprecated") .map(([modelID, model]) => { const title = model.name ?? modelID const current = props.current()?.providerID === provider.id && props.current()?.modelID === modelID const footer = current ? "current" : model.cost?.input === 0 && provider.id === "opencode" ? "Free" : title !== modelID ? modelID : undefined return { providerID: provider.id, modelID, providerName: provider.name, category: provider.name, display: title, footer, keywords: `${provider.id} ${provider.name} ${modelID} ${title} ${footer ?? ""}`, current, } }), ) .sort((a, b) => { const provider = Number(a.providerID !== "opencode") - Number(b.providerID !== "opencode") if (provider !== 0) { return provider } const name = a.providerName.localeCompare(b.providerName) if (name !== 0) { return name } return a.display.localeCompare(b.display) }), ) const items = createMemo(() => match(query(), entries())) const menu = createFooterMenuState({ count: () => items().length, limit: PANEL_LIST_ROWS }) const pick = (item: ModelEntry) => { props.onSelect({ providerID: item.providerID, modelID: item.modelID }) } const select = () => { const item = items()[menu.selected()] if (!item) { return } pick(item) } createEffect(() => { query() menu.reset() }) createEffect(() => { if (query().trim()) { return } const index = items().findIndex((item) => item.current) if (index !== -1) { menu.reveal(index) } }) useKeyboard((event) => { if (event.defaultPrevented) { return } handleKey({ event, menu, field: () => field, setQuery, select, close: props.onClose }) }) return ( { field = input }} onQuery={setQuery} > PANEL_LIST_ROWS} limit={PANEL_LIST_ROWS} empty={props.providers() ? "No results found" : "Models loading"} border={false} paddingLeft={PANEL_PAD} paddingRight={PANEL_PAD} grouped={!query().trim()} /> ) }