diff --git a/overlay/packages/tui/src/component/command-palette.tsx b/overlay/packages/tui/src/component/command-palette.tsx new file mode 100644 index 0000000..a3150c5 --- /dev/null +++ b/overlay/packages/tui/src/component/command-palette.tsx @@ -0,0 +1,329 @@ +// Athena command console — replaces upstream's centered "Commands" modal. +// +// The home screen and transcript are a terminal command-log (gold sigils, no +// boxes, lowercase section labels); the old palette teleported the user into a +// generic centered, scrim-backed fuzzy-list modal. This renders the same +// command set as an *anchored console*: flush to the bottom (rising from the +// prompt), no dimming scrim, a gold `AC ›` sigil header, commands grouped by +// Athena *intent* (recall · steer · weave · workspace · system) instead of +// opencode's raw categories, and a gold caret marking the selection rather than +// a filled primary block. It is still wired through `command.palette.show` and +// still dispatches the exact same commands — only the surface changed. +// +// Anchoring + no-scrim come from the Dialog container (ui/dialog.tsx anchor / +// scrim options), requested on mount; the list mechanics (fuzzy filter, +// keyboard nav, scroll) mirror ui/dialog-select.tsx so behaviour stays familiar. + +import { RGBA, TextAttributes, type ScrollBoxRenderable, type InputRenderable } from "@opentui/core" +import { createEffect, createMemo, For, on, onMount, Show } from "solid-js" +import { createStore } from "solid-js/store" +import { useTerminalDimensions } from "@opentui/solid" +import * as fuzzysort from "fuzzysort" +import { entries, groupBy, pipe } from "remeda" +import { useDialog } from "../ui/dialog" +import { useTheme } from "../context/theme" +import { useTuiConfig } from "../config" +import { athenaTerminalPrefix } from "../branding" +import { + COMMAND_PALETTE_COMMAND, + formatKeyBindings, + type OpenTuiKeymap, + useBindings, + useKeymapSelector, + useOpencodeKeymap, +} from "../keymap" + +type PaletteCommandEntry = ReturnType[number] + +function isVisiblePaletteCommand(command: PaletteCommandEntry["command"]) { + return command.hidden !== true && command.name !== COMMAND_PALETTE_COMMAND +} + +// Athena intents, ordered top-to-bottom. Recall (sessions, memory, search) leads +// because cross-session memory is what makes Athena Athena; system trails as the +// catch-all. Each upstream command category maps to one intent; anything +// unmapped falls through to "system". +const INTENT_ORDER = ["recall", "steer", "weave", "workspace", "system"] as const +type Intent = (typeof INTENT_ORDER)[number] + +const INTENT_BY_CATEGORY: Record = { + Session: "recall", + Memory: "recall", + Agent: "steer", + Model: "steer", + model: "steer", + mode: "steer", + thought_level: "steer", + Provider: "steer", + Providers: "steer", + "Popular providers": "steer", + Prompt: "weave", + Skills: "weave", + Workspace: "workspace", + "Choose workspace": "workspace", + "New workspace": "workspace", + VCS: "workspace", + System: "system", + Debug: "system", +} + +function intentFor(category: string | undefined): Intent { + if (category && category in INTENT_BY_CATEGORY) return INTENT_BY_CATEGORY[category] + return "system" +} + +const INTENT_RANK = new Map(INTENT_ORDER.map((intent, index) => [intent, index])) + +type ConsoleOption = { + title: string + description?: string + footer?: string + value: string + intent: Intent + run: () => void +} + +const TRANSPARENT = RGBA.fromInts(0, 0, 0, 0) + +export function CommandPaletteDialog() { + const dialog = useDialog() + const { theme } = useTheme() + const config = useTuiConfig() + const keymap = useOpencodeKeymap() + const dimensions = useTerminalDimensions() + + // Shed the centered-modal posture: flush to the prompt, no dimming scrim. + onMount(() => { + dialog.setSize("large") + dialog.setAnchor("bottom") + dialog.setScrim(false) + }) + + const entries$ = useKeymapSelector((keymap: OpenTuiKeymap) => { + const reachable = keymap.getCommandEntries({ + namespace: "palette", + visibility: "reachable", + filter: isVisiblePaletteCommand, + }) + const registeredBindings = keymap.getCommandBindings({ + visibility: "registered", + commands: reachable.map((entry) => entry.command.name), + }) + return reachable.map((entry) => ({ + ...entry, + bindings: registeredBindings.get(entry.command.name) ?? entry.bindings, + })) + }) + + const options = createMemo(() => + entries$().map((entry) => { + const category = typeof entry.command.category === "string" ? entry.command.category : undefined + return { + title: typeof entry.command.title === "string" ? entry.command.title : entry.command.name, + description: typeof entry.command.desc === "string" ? entry.command.desc : undefined, + footer: formatKeyBindings(entry.bindings, config), + value: entry.command.name, + intent: intentFor(category), + run: () => { + dialog.clear() + keymap.dispatchCommand(entry.command.name) + }, + } + }), + ) + + const [store, setStore] = createStore({ selected: 0, filter: "" }) + + const filtered = createMemo(() => { + const needle = store.filter.trim() + if (!needle) return options() + return fuzzysort.go(needle, options(), { keys: ["title", "description"] }).map((result) => result.obj) + }) + + // Group by intent in INTENT_ORDER. When filtering, keep the grouping but only + // surface intents that still have matches. + const grouped = createMemo<[Intent, ConsoleOption[]][]>(() => { + const byIntent = pipe( + filtered(), + groupBy((option) => option.intent), + entries(), + ) as [Intent, ConsoleOption[]][] + return byIntent.sort(([a], [b]) => (INTENT_RANK.get(a) ?? 99) - (INTENT_RANK.get(b) ?? 99)) + }) + + const flat = createMemo(() => grouped().flatMap(([, group]) => group)) + + const selected = createMemo(() => flat()[store.selected]) + + // Snap the highlight back to the top whenever the filter narrows the list. + createEffect( + on( + () => store.filter, + () => moveTo(0, true), + ), + ) + + const rows = createMemo(() => { + const headers = grouped().length + return flat().length + headers + }) + const height = createMemo(() => Math.max(1, Math.min(rows(), Math.floor(dimensions().height / 2) - 4))) + + let scroll: ScrollBoxRenderable | undefined + let input: InputRenderable | undefined + + function move(direction: number) { + const total = flat().length + if (total === 0) return + let next = store.selected + direction + if (next < 0) next = total - 1 + if (next >= total) next = 0 + moveTo(next, true) + } + + function moveTo(next: number, center = false) { + setStore("selected", next) + if (!scroll) return + const option = flat()[next] + if (!option) return + const target = scroll.getChildren().find((child: { id?: string }) => child.id === option.value) + if (!target) return + const y = target.y - scroll.y + if (center) { + scroll.scrollBy(y - Math.floor(scroll.height / 2)) + return + } + if (y >= scroll.height) scroll.scrollBy(y - scroll.height + 1) + if (y < 0) scroll.scrollBy(y) + } + + function submit() { + selected()?.run() + } + + useBindings(() => ({ + commands: [ + { name: "dialog.select.prev", title: "Previous item", category: "Dialog", run: () => move(-1) }, + { name: "dialog.select.next", title: "Next item", category: "Dialog", run: () => move(1) }, + { name: "dialog.select.page_up", title: "Page up", category: "Dialog", run: () => move(-10) }, + { name: "dialog.select.page_down", title: "Page down", category: "Dialog", run: () => move(10) }, + { name: "dialog.select.home", title: "First item", category: "Dialog", run: () => moveTo(0, true) }, + { name: "dialog.select.end", title: "Last item", category: "Dialog", run: () => moveTo(flat().length - 1, true) }, + { name: "dialog.select.submit", title: "Select item", category: "Dialog", run: submit }, + ], + bindings: config.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", + ]), + })) + + return ( + + {/* Sigil header: the gold `AC ›` command prompt, an esc affordance on the + right — a command line, not a titled modal bar. */} + + + {athenaTerminalPrefix} › + command + + dialog.clear()}> + esc + + + + + setStore("filter", value)} + focusedBackgroundColor={theme.backgroundPanel} + cursorColor={theme.primary} + focusedTextColor={theme.text} + placeholder="filter the room…" + placeholderColor={theme.textMuted} + ref={(r: InputRenderable) => { + input = r + setTimeout(() => { + if (input && !input.isDestroyed) input.focus() + }, 1) + }} + /> + + + + 0} + fallback={ + + nothing in the room matches that. + + } + > + (scroll = r)} + maxHeight={height()} + > + + {([intent, group], groupIndex) => ( + <> + 0 ? 1 : 0} paddingLeft={3}> + + {intent} + + + + {(option) => { + const active = createMemo(() => selected()?.value === option.value) + return ( + { + const index = flat().findIndex((o) => o.value === option.value) + if (index >= 0) setStore("selected", index) + }} + onMouseUp={() => option.run()} + > + {/* Gold caret marks the selection — no filled block bar. */} + + {active() ? " ❯ " : " "} + + + {option.title} + + {option.description} + + + + + {option.footer} + + + + ) + }} + + + )} + + + + + + ) +} diff --git a/patches/opencode-athena.patch b/patches/opencode-athena.patch index 7b70451..1c1611a 100644 --- a/patches/opencode-athena.patch +++ b/patches/opencode-athena.patch @@ -204,6 +204,72 @@ index 2767508..9aef9c5 100644 Invalid code +diff --git a/packages/tui/src/component/prompt/autocomplete.tsx b/packages/tui/src/component/prompt/autocomplete.tsx +index 4221f6f..40dfef2 100644 +--- a/packages/tui/src/component/prompt/autocomplete.tsx ++++ b/packages/tui/src/component/prompt/autocomplete.tsx +@@ -1,4 +1,5 @@ + import type { BoxRenderable, TextareaRenderable, ScrollBoxRenderable } from "@opentui/core" ++import { TextAttributes } from "@opentui/core" + import { pathToFileURL } from "bun" + import fuzzysort from "fuzzysort" + import path from "path" +@@ -12,8 +13,7 @@ import { useSync } from "../../context/sync" + import { getScrollAcceleration } from "../../util/scroll" + import { useTuiPaths } from "../../context/runtime" + import { useTuiConfig } from "../../config" +-import { useTheme, selectedForeground } from "../../context/theme" +-import { SplitBorder } from "../../ui/border" ++import { useTheme } from "../../context/theme" + import { useTerminalDimensions } from "@opentui/solid" + import { Locale } from "../../util/locale" + import type { PromptInfo } from "../../prompt/history" +@@ -747,12 +747,10 @@ export function Autocomplete(props: { + left={position().x} + width={position().width} + zIndex={100} +- {...SplitBorder} +- borderColor={theme.border} + > + (scroll = r)} +- backgroundColor={theme.backgroundMenu} ++ backgroundColor={theme.backgroundPanel} + height={height()} + scrollbarOptions={{ visible: false }} + scrollAcceleration={scrollAcceleration()} +@@ -769,7 +767,7 @@ export function Autocomplete(props: { + { + setStore("input", "mouse") +@@ -784,11 +782,20 @@ export function Autocomplete(props: { + }} + onMouseUp={() => select()} + > +- ++ {/* Athena: gold caret marks the selection, matching the command ++ console — no filled primary block bar. */} ++ ++ {index === store.selected ? "❯" : " "} ++ ++ + {option().display} + + +- ++ + {option().description} + + diff --git a/packages/tui/src/component/prompt/index.tsx b/packages/tui/src/component/prompt/index.tsx index 0a9f103..2d93003 100644 --- a/packages/tui/src/component/prompt/index.tsx @@ -755,6 +821,104 @@ index e8a5f2c..d530efd 100644 } const pluginThemes: Record = {} +diff --git a/packages/tui/src/ui/dialog.tsx b/packages/tui/src/ui/dialog.tsx +index b6cd705..c5fcddb 100644 +--- a/packages/tui/src/ui/dialog.tsx ++++ b/packages/tui/src/ui/dialog.tsx +@@ -11,6 +11,12 @@ import { useClipboard } from "../context/clipboard" + export function Dialog( + props: ParentProps<{ + size?: "medium" | "large" | "xlarge" ++ // Athena: an anchored dialog drops the centered-modal posture and flushes the ++ // panel to the bottom of the screen (rising from the prompt) with no dimming ++ // scrim — the command console uses this so the menu reads as part of the ++ // terminal command-log rather than a floating opencode modal. ++ anchor?: "center" | "bottom" ++ scrim?: boolean + onClose: () => void + }>, + ) { +@@ -24,6 +30,8 @@ export function Dialog( + if (props.size === "large") return 88 + return 60 + } ++ const anchored = () => props.anchor === "bottom" ++ const scrim = () => props.scrim !== false + + return ( + + { +@@ -70,6 +80,8 @@ function init() { + onClose?: () => void + }[], + size: "medium" as "medium" | "large" | "xlarge", ++ anchor: "center" as "center" | "bottom", ++ scrim: true, + }) + + const renderer = useRenderer() +@@ -140,6 +152,8 @@ function init() { + } + batch(() => { + setStore("size", "medium") ++ setStore("anchor", "center") ++ setStore("scrim", true) + setStore("stack", []) + }) + refocus() +@@ -153,6 +167,8 @@ function init() { + if (item.onClose) item.onClose() + } + setStore("size", "medium") ++ setStore("anchor", "center") ++ setStore("scrim", true) + setStore("stack", [ + { + element: input, +@@ -169,6 +185,18 @@ function init() { + setSize(size: "medium" | "large" | "xlarge") { + setStore("size", size) + }, ++ get anchor() { ++ return store.anchor ++ }, ++ setAnchor(anchor: "center" | "bottom") { ++ setStore("anchor", anchor) ++ }, ++ get scrim() { ++ return store.scrim ++ }, ++ setScrim(scrim: boolean) { ++ setStore("scrim", scrim) ++ }, + } + } + +@@ -210,7 +238,7 @@ export function DialogProvider(props: ParentProps) { + onMouseUp={!Flag.OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT ? copySelection : undefined} + > + +- value.clear()} size={value.size}> ++ value.clear()} size={value.size} anchor={value.anchor} scrim={value.scrim}> + {value.stack.at(-1)!.element} + + diff --git a/packages/tui/src/ui/link.tsx b/packages/tui/src/ui/link.tsx index cfd78bc..7729823 100644 --- a/packages/tui/src/ui/link.tsx