From 2d312b0d82b449a3aa0b1e3f63207adad9316da0 Mon Sep 17 00:00:00 2001 From: scarf Date: Wed, 24 Dec 2025 23:16:18 +0900 Subject: [PATCH 1/2] feat(tui): add mouse support to menus --- .../cli/cmd/tui/component/prompt/index.tsx | 161 +++++++++++++++--- 1 file changed, 133 insertions(+), 28 deletions(-) diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx index 9494b81cb10..07c76d520c1 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx @@ -1,8 +1,18 @@ -import { BoxRenderable, TextareaRenderable, MouseEvent, PasteEvent, t, dim, fg, type KeyBinding } from "@opentui/core" +import { + BoxRenderable, + TextareaRenderable, + MouseEvent, + PasteEvent, + t, + dim, + fg, + type KeyBinding, + RGBA, +} from "@opentui/core" import { createEffect, createMemo, type JSX, onMount, createSignal, onCleanup, Show, Switch, Match } from "solid-js" import "opentui-spinner/solid" import { useLocal } from "@tui/context/local" -import { useTheme } from "@tui/context/theme" +import { useTheme, selectedForeground } from "@tui/context/theme" import { EmptyBorder } from "@tui/component/border" import { useSDK } from "@tui/context/sdk" import { useRoute } from "@tui/context/route" @@ -16,7 +26,7 @@ import { usePromptStash } from "./stash" import { DialogStash } from "../dialog-stash" import { type AutocompleteRef, Autocomplete } from "./autocomplete" import { useCommandDialog } from "../dialog-command" -import { useRenderer } from "@opentui/solid" +import { useRenderer, useTerminalDimensions } from "@opentui/solid" import { Editor } from "@tui/util/editor" import { useExit } from "../../context/exit" import { Clipboard } from "../../util/clipboard" @@ -29,6 +39,8 @@ import { useDialog } from "@tui/ui/dialog" import { DialogProvider as DialogProviderConnect } from "../dialog-provider" import { DialogAlert } from "../../ui/dialog-alert" import { useToast } from "../../ui/toast" +import { DialogAgent } from "../dialog-agent" +import { DialogModel } from "../dialog-model" export type PromptProps = { sessionID?: string @@ -123,7 +135,11 @@ export function Prompt(props: PromptProps) { const stash = usePromptStash() const command = useCommandDialog() const renderer = useRenderer() + const dimensions = useTerminalDimensions() + const tall = createMemo(() => dimensions().height > 40) + const wide = createMemo(() => dimensions().width > 120) const { theme, syntax } = useTheme() + const hoverFg = createMemo(() => selectedForeground(theme)) function promptModelWarning() { toast.show({ @@ -182,6 +198,11 @@ export function Prompt(props: PromptProps) { interrupt: 0, }) + const [agentHover, setAgentHover] = createSignal(false) + const [modelNameHover, setModelNameHover] = createSignal(false) + const [agentCycleHover, setAgentCycleHover] = createSignal(false) + const [commandListHover, setCommandListHover] = createSignal(false) + command.register(() => { return [ { @@ -946,19 +967,43 @@ export function Prompt(props: PromptProps) { cursorColor={theme.text} syntaxStyle={syntax()} /> - - - {store.mode === "shell" ? "Shell" : Locale.titlecase(local.agent.current().name)}{" "} - - - - - {local.model.parsed().model} + + + setAgentHover(true)} + onMouseOut={() => setAgentHover(false)} + onMouseUp={() => { + if (store.mode === "shell") return + dialog.replace(() => ) + }} + > + + {store.mode === "shell" ? "Shell" : Locale.titlecase(local.agent.current().name)}{" "} - {local.model.parsed().provider} - - + + setModelNameHover(true)} + onMouseOut={() => setModelNameHover(false)} + onMouseUp={() => { + dialog.replace(() => ) + }} + > + + {local.model.parsed().model} + + {local.model.parsed().provider} + + + + - - - - - - {keybind.print("agent_cycle")} switch agent + + + + setAgentHover(true)} + onMouseOut={() => setAgentHover(false)} + onMouseUp={() => { + if (store.mode === "shell") return + dialog.replace(() => ) + }} + > + + {store.mode === "shell" ? "Shell" : Locale.titlecase(local.agent.current().name)}{" "} + + + setModelNameHover(true)} + onMouseOut={() => setModelNameHover(false)} + onMouseUp={() => { + dialog.replace(() => ) + }} + > + + {local.model.parsed().model} + + {local.model.parsed().provider} + + + + + + + + + + setAgentCycleHover(true)} + onMouseOut={() => setAgentCycleHover(false)} + onMouseUp={() => dialog.replace(() => )} + > + + {keybind.print("agent_cycle")}{" "} + switch agent + + + + {keybind.print("command_list")} commands - - - - esc exit shell mode + + setCommandListHover(true)} + onMouseOut={() => setCommandListHover(false)} + onMouseUp={() => command.show()} + > + + {keybind.print("command_list")}{" "} + commands - - - - + + + + + esc exit shell mode + + + + From 7664d0eca23dd80147b2f7a7941dd1b807a98394 Mon Sep 17 00:00:00 2001 From: scarf Date: Wed, 24 Dec 2025 23:36:16 +0900 Subject: [PATCH 2/2] refactor(tui): create and use `HoverableLabel` --- .../cli/cmd/tui/component/hoverable-label.tsx | 51 ++++++ .../cli/cmd/tui/component/prompt/index.tsx | 157 ++++++------------ 2 files changed, 105 insertions(+), 103 deletions(-) create mode 100644 packages/opencode/src/cli/cmd/tui/component/hoverable-label.tsx diff --git a/packages/opencode/src/cli/cmd/tui/component/hoverable-label.tsx b/packages/opencode/src/cli/cmd/tui/component/hoverable-label.tsx new file mode 100644 index 00000000000..73944c3adcf --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/component/hoverable-label.tsx @@ -0,0 +1,51 @@ +import { RGBA } from "@opentui/core" +import { createSignal, createUniqueId, onCleanup, type JSX } from "solid-js" +import { useTheme, selectedForeground } from "@tui/context/theme" + +const [activeId, setActiveId] = createSignal(null) + +type HoverableLabelProps = { + children: (hover: boolean, hoverFg: () => RGBA) => JSX.Element + onClick?: () => void + disabled?: boolean +} + +export function HoverableLabel(props: HoverableLabelProps) { + const id = createUniqueId() + const { theme } = useTheme() + const hoverFg = () => selectedForeground(theme) + + const isHovered = () => activeId() === id && !props.disabled + + onCleanup(() => { + if (activeId() !== id) return + setActiveId(null) + }) + + const handleMouseOver = () => { + if (props.disabled) return + setActiveId(id) + } + + const handleMouseOut = () => { + if (activeId() !== id) return + setActiveId(null) + } + + const handleClick = () => { + if (props.disabled) return + setActiveId(null) + props.onClick?.() + } + + return ( + + {props.children(isHovered(), hoverFg)} + + ) +} diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx index 07c76d520c1..fc9c696ef75 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx @@ -1,18 +1,8 @@ -import { - BoxRenderable, - TextareaRenderable, - MouseEvent, - PasteEvent, - t, - dim, - fg, - type KeyBinding, - RGBA, -} from "@opentui/core" +import { BoxRenderable, TextareaRenderable, MouseEvent, PasteEvent, type KeyBinding } from "@opentui/core" import { createEffect, createMemo, type JSX, onMount, createSignal, onCleanup, Show, Switch, Match } from "solid-js" import "opentui-spinner/solid" import { useLocal } from "@tui/context/local" -import { useTheme, selectedForeground } from "@tui/context/theme" +import { useTheme } from "@tui/context/theme" import { EmptyBorder } from "@tui/component/border" import { useSDK } from "@tui/context/sdk" import { useRoute } from "@tui/context/route" @@ -41,6 +31,7 @@ import { DialogAlert } from "../../ui/dialog-alert" import { useToast } from "../../ui/toast" import { DialogAgent } from "../dialog-agent" import { DialogModel } from "../dialog-model" +import { HoverableLabel } from "../hoverable-label" export type PromptProps = { sessionID?: string @@ -139,7 +130,6 @@ export function Prompt(props: PromptProps) { const tall = createMemo(() => dimensions().height > 40) const wide = createMemo(() => dimensions().width > 120) const { theme, syntax } = useTheme() - const hoverFg = createMemo(() => selectedForeground(theme)) function promptModelWarning() { toast.show({ @@ -198,11 +188,6 @@ export function Prompt(props: PromptProps) { interrupt: 0, }) - const [agentHover, setAgentHover] = createSignal(false) - const [modelNameHover, setModelNameHover] = createSignal(false) - const [agentCycleHover, setAgentCycleHover] = createSignal(false) - const [commandListHover, setCommandListHover] = createSignal(false) - command.register(() => { return [ { @@ -969,38 +954,24 @@ export function Prompt(props: PromptProps) { /> - setAgentHover(true)} - onMouseOut={() => setAgentHover(false)} - onMouseUp={() => { - if (store.mode === "shell") return - dialog.replace(() => ) - }} - > - - {store.mode === "shell" ? "Shell" : Locale.titlecase(local.agent.current().name)}{" "} - - - - setModelNameHover(true)} - onMouseOut={() => setModelNameHover(false)} - onMouseUp={() => { - dialog.replace(() => ) - }} - > - - {local.model.parsed().model} + dialog.replace(() => )}> + {(hover, hoverFg) => ( + + {store.mode === "shell" ? "Shell" : Locale.titlecase(local.agent.current().name)}{" "} - {local.model.parsed().provider} - + )} + + + dialog.replace(() => )}> + {(hover, hoverFg) => ( + + + {local.model.parsed().model} + + {local.model.parsed().provider} + + )} + @@ -1112,38 +1083,24 @@ export function Prompt(props: PromptProps) { - setAgentHover(true)} - onMouseOut={() => setAgentHover(false)} - onMouseUp={() => { - if (store.mode === "shell") return - dialog.replace(() => ) - }} - > - - {store.mode === "shell" ? "Shell" : Locale.titlecase(local.agent.current().name)}{" "} - - - - setModelNameHover(true)} - onMouseOut={() => setModelNameHover(false)} - onMouseUp={() => { - dialog.replace(() => ) - }} - > - - {local.model.parsed().model} + dialog.replace(() => )}> + {(hover, hoverFg) => ( + + {store.mode === "shell" ? "Shell" : Locale.titlecase(local.agent.current().name)}{" "} - {local.model.parsed().provider} - + )} + + + dialog.replace(() => )}> + {(hover, hoverFg) => ( + + + {local.model.parsed().model} + + {local.model.parsed().provider} + + )} + @@ -1152,34 +1109,28 @@ export function Prompt(props: PromptProps) { - setAgentCycleHover(true)} - onMouseOut={() => setAgentCycleHover(false)} - onMouseUp={() => dialog.replace(() => )} - > - - {keybind.print("agent_cycle")}{" "} - switch agent - - + dialog.replace(() => )}> + {(hover, hoverFg) => ( + + {keybind.print("agent_cycle")}{" "} + switch agent + + )} + - {keybind.print("command_list")} commands + {keybind.print("sidebar_toggle")} sidebar - setCommandListHover(true)} - onMouseOut={() => setCommandListHover(false)} - onMouseUp={() => command.show()} - > - - {keybind.print("command_list")}{" "} - commands - - + command.show()}> + {(hover, hoverFg) => ( + + {keybind.print("command_list")}{" "} + commands + + )} +