diff --git a/packages/opencode/src/cli/cmd/tui/component/border.tsx b/packages/opencode/src/cli/cmd/tui/component/border.tsx index 333071020c4..6c37c80be41 100644 --- a/packages/opencode/src/cli/cmd/tui/component/border.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/border.tsx @@ -19,3 +19,19 @@ export const SplitBorder = { vertical: "┃", }, } + +export const SplitBorderAscii = { + border: ["left" as const, "right" as const], + customBorderChars: { + ...EmptyBorder, + vertical: "|", + }, +} + +export function getSplitBorder(accessible: boolean) { + return accessible ? SplitBorderAscii : SplitBorder +} + +export function getSplitBorderChars(accessible: boolean) { + return accessible ? SplitBorderAscii.customBorderChars : SplitBorder.customBorderChars +} diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-mcp.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-mcp.tsx index 9cfa30d4df9..b050d962b4d 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-mcp.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-mcp.tsx @@ -7,16 +7,22 @@ import { useTheme } from "../context/theme" import { Keybind } from "@/util/keybind" import { TextAttributes } from "@opentui/core" import { useSDK } from "@tui/context/sdk" +import { useAccessibility } from "@tui/util/accessibility" function Status(props: { enabled: boolean; loading: boolean }) { const { theme } = useTheme() + const accessibility = useAccessibility() if (props.loading) { - return ⋯ Loading + return {accessibility() ? "Loading" : "⋯ Loading"} } if (props.enabled) { - return ✓ Enabled + return ( + + {accessibility() ? "Enabled" : "✓ Enabled"} + + ) } - return ○ Disabled + return {accessibility() ? "Disabled" : "○ Disabled"} } export function DialogMcp() { diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-session-list.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-session-list.tsx index 07de4d47200..4b64286e97d 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-session-list.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-session-list.tsx @@ -11,6 +11,7 @@ import { DialogSessionRename } from "./dialog-session-rename" import { useKV } from "../context/kv" import { createDebouncedSignal } from "../util/signal" import "opentui-spinner/solid" +import { useAccessibility } from "@tui/util/accessibility" export function DialogSessionList() { const dialog = useDialog() @@ -19,6 +20,7 @@ export function DialogSessionList() { const route = useRoute() const sdk = useSDK() const kv = useKV() + const accessibility = useAccessibility() const [toDelete, setToDelete] = createSignal() const [search, setSearch] = createDebouncedSignal("", 150) @@ -34,6 +36,8 @@ export function DialogSessionList() { const currentSessionID = createMemo(() => (route.data.type === "session" ? route.data.sessionID : undefined)) const spinnerFrames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"] + const busyFallback = () => (accessibility() ? "[busy]" : "[⋯]") + const showSpinner = () => kv.get("animations_enabled", true) && !accessibility() const sessions = createMemo(() => searchResults() ?? sync.data.session) @@ -58,7 +62,7 @@ export function DialogSessionList() { category, footer: Locale.time(x.time.updated), gutter: isWorking ? ( - [⋯]}> + {busyFallback()}}> ) : undefined, diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-status.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-status.tsx index b85cd5c6542..1bb483e7d88 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-status.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-status.tsx @@ -2,12 +2,15 @@ import { TextAttributes } from "@opentui/core" import { useTheme } from "../context/theme" import { useSync } from "@tui/context/sync" import { For, Match, Switch, Show, createMemo } from "solid-js" +import { useAccessibility } from "@tui/util/accessibility" export type DialogStatusProps = {} export function DialogStatus() { const sync = useSync() const { theme } = useTheme() + const accessibility = useAccessibility() + const bullet = createMemo(() => (accessibility() ? "-" : "•")) const enabledFormatters = createMemo(() => sync.data.formatter.filter((f) => f.enabled)) @@ -64,7 +67,7 @@ export function DialogStatus() { )[item.status], }} > - • + {bullet()} {key}{" "} @@ -102,7 +105,7 @@ export function DialogStatus() { }[item.status], }} > - • + {bullet()} {item.id} {item.root} @@ -124,7 +127,7 @@ export function DialogStatus() { fg: theme.success, }} > - • + {bullet()} {item.name} @@ -146,7 +149,7 @@ export function DialogStatus() { fg: theme.success, }} > - • + {bullet()} {item.name} diff --git a/packages/opencode/src/cli/cmd/tui/component/logo.tsx b/packages/opencode/src/cli/cmd/tui/component/logo.tsx index d1be06a7f25..4f74f1092ba 100644 --- a/packages/opencode/src/cli/cmd/tui/component/logo.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/logo.tsx @@ -1,6 +1,7 @@ import { TextAttributes } from "@opentui/core" -import { For } from "solid-js" +import { For, Show } from "solid-js" import { useTheme } from "@tui/context/theme" +import { useAccessibility } from "@tui/util/accessibility" const LOGO_LEFT = [` `, `█▀▀█ █▀▀█ █▀▀█ █▀▀▄`, `█░░█ █░░█ █▀▀▀ █░░█`, `▀▀▀▀ █▀▀▀ ▀▀▀▀ ▀ ▀`] @@ -8,20 +9,30 @@ const LOGO_RIGHT = [` ▄ `, `█▀▀▀ █▀▀█ █▀ export function Logo() { const { theme } = useTheme() + const accessibility = useAccessibility() return ( - - {(line, index) => ( - - - {line} - - - {LOGO_RIGHT[index()]} - - - )} - + + OpenCode + + } + > + + {(line, index) => ( + + + {line} + + + {LOGO_RIGHT[index()]} + + + )} + + ) } diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx b/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx index 601eb82bc48..7cda7f14fa1 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx @@ -6,12 +6,13 @@ import { createStore } from "solid-js/store" import { useSDK } from "@tui/context/sdk" import { useSync } from "@tui/context/sync" import { useTheme, selectedForeground } from "@tui/context/theme" -import { SplitBorder } from "@tui/component/border" +import { getSplitBorder } from "@tui/component/border" import { useCommandDialog } from "@tui/component/dialog-command" import { useTerminalDimensions } from "@opentui/solid" import { Locale } from "@/util/locale" import type { PromptInfo } from "./history" import { useFrecency } from "./frecency" +import { useAccessibility } from "@tui/util/accessibility" function removeLineRange(input: string) { const hashIndex = input.lastIndexOf("#") @@ -78,6 +79,7 @@ export function Autocomplete(props: { const sync = useSync() const command = useCommandDialog() const { theme } = useTheme() + const accessibility = useAccessibility() const dimensions = useTerminalDimensions() const frecency = useFrecency() @@ -720,7 +722,7 @@ export function Autocomplete(props: { left={position().x} width={position().width} zIndex={100} - {...SplitBorder} + {...getSplitBorder(accessibility())} borderColor={theme.border} > kv.get("animations_enabled", true) && !accessibility()) + const busyFallback = createMemo(() => (accessibility() ? "[busy]" : "[⋯]")) + const separator = createMemo(() => (accessibility() ? "-" : "·")) + const promptBorderChars = createMemo(() => ({ + ...EmptyBorder, + vertical: accessibility() ? "|" : "┃", + bottomLeft: accessibility() ? "|" : "╹", + })) + const dividerBorderChars = createMemo(() => ({ + ...EmptyBorder, + vertical: theme.backgroundElement.a !== 0 ? (accessibility() ? "|" : "╹") : " ", + })) + const dividerHorizontalChars = createMemo(() => ({ + ...EmptyBorder, + horizontal: theme.backgroundElement.a !== 0 ? (accessibility() ? "-" : "▀") : " ", + })) return ( <> @@ -745,11 +763,7 @@ export function Prompt(props: PromptProps) { {local.model.parsed().provider} - · + {separator()} {local.model.variant.current()} @@ -957,26 +971,13 @@ export function Prompt(props: PromptProps) { height={1} border={["left"]} borderColor={highlight()} - customBorderChars={{ - ...EmptyBorder, - vertical: theme.backgroundElement.a !== 0 ? "╹" : " ", - }} + customBorderChars={dividerBorderChars()} > @@ -989,7 +990,7 @@ export function Prompt(props: PromptProps) { > - [⋯]}> + {busyFallback()}}> diff --git a/packages/opencode/src/cli/cmd/tui/component/tips.tsx b/packages/opencode/src/cli/cmd/tui/component/tips.tsx index 516d7e7e2c5..936feab1f08 100644 --- a/packages/opencode/src/cli/cmd/tui/component/tips.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/tips.tsx @@ -1,5 +1,6 @@ import { createMemo, createSignal, For } from "solid-js" import { useTheme } from "@tui/context/theme" +import { useAccessibility } from "@tui/util/accessibility" type TipPart = { text: string; highlight: boolean } @@ -29,12 +30,13 @@ function parse(tip: string): TipPart[] { export function Tips() { const theme = useTheme().theme + const accessibility = useAccessibility() const parts = parse(TIPS[Math.floor(Math.random() * TIPS.length)]) return ( - ● Tip{" "} + {accessibility() ? "Tip:" : "● Tip"}{" "} diff --git a/packages/opencode/src/cli/cmd/tui/component/todo-item.tsx b/packages/opencode/src/cli/cmd/tui/component/todo-item.tsx index b54cc463341..36070d71fd1 100644 --- a/packages/opencode/src/cli/cmd/tui/component/todo-item.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/todo-item.tsx @@ -1,4 +1,5 @@ import { useTheme } from "../context/theme" +import { useAccessibility } from "@tui/util/accessibility" export interface TodoItemProps { status: string @@ -7,6 +8,15 @@ export interface TodoItemProps { export function TodoItem(props: TodoItemProps) { const { theme } = useTheme() + const accessibility = useAccessibility() + const marker = () => { + if (!accessibility()) { + return props.status === "completed" ? "✓" : props.status === "in_progress" ? "•" : " " + } + if (props.status === "completed") return "x" + if (props.status === "in_progress") return "~" + return " " + } return ( @@ -16,7 +26,7 @@ export function TodoItem(props: TodoItemProps) { fg: props.status === "in_progress" ? theme.warning : theme.textMuted, }} > - [{props.status === "completed" ? "✓" : props.status === "in_progress" ? "•" : " "}]{" "} + [{marker()}]{" "} Object.keys(sync.data.mcp).length > 0) const mcpError = createMemo(() => { return Object.values(sync.data.mcp).some((x) => x.status === "failed") @@ -61,11 +63,16 @@ export function Home() { - mcp errors{" "} + + {" "} + + mcp errors{" "} ctrl+x s - {" "} + + {" "} + {Locale.pluralize(connectedMcpCount(), "{} mcp server", "{} mcp servers")} @@ -90,6 +97,12 @@ export function Home() { const directory = useDirectory() const keybind = useKeybind() + const mcpTextColor = createMemo(() => { + if (!accessibility()) return theme.text + if (mcpError()) return theme.error + if (connectedMcpCount() > 0) return theme.success + return theme.textMuted + }) return ( <> @@ -116,15 +129,17 @@ export function Home() { {directory()} - - - - - - - 0 ? theme.success : theme.textMuted }}>⊙ - - + + + + + + + + 0 ? theme.success : theme.textMuted }}>⊙ + + + {connectedMcpCount()} MCP /status diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/footer.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/footer.tsx index 8ace2fff372..a9b4e5cc8bb 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/footer.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/footer.tsx @@ -5,6 +5,7 @@ import { useDirectory } from "../../context/directory" import { useConnected } from "../../component/dialog-model" import { createStore } from "solid-js/store" import { useRoute } from "../../context/route" +import { useAccessibility } from "@tui/util/accessibility" export function Footer() { const { theme } = useTheme() @@ -19,12 +20,14 @@ export function Footer() { }) const directory = useDirectory() const connected = useConnected() + const accessibility = useAccessibility() const [store, setStore] = createStore({ welcome: false, }) onMount(() => { + if (accessibility()) return // Track all timeouts to ensure proper cleanup const timeouts: ReturnType[] = [] @@ -48,13 +51,25 @@ export function Footer() { timeouts.forEach(clearTimeout) }) }) + const showWelcome = createMemo(() => (accessibility() ? !connected() : store.welcome)) + const lspBullet = createMemo(() => (accessibility() ? "-" : "•")) + const permissionLabel = createMemo(() => { + if (!accessibility()) return "Permission" + return permissions().length === 1 ? "Permission" : "Permissions" + }) + const mcpTextColor = createMemo(() => { + if (!accessibility()) return theme.text + if (mcpError()) return theme.error + if (mcp() > 0) return theme.success + return theme.textMuted + }) return ( {directory()} - + Get started /connect @@ -62,23 +77,29 @@ export function Footer() { 0}> - {permissions().length} Permission - {permissions().length > 1 ? "s" : ""} + + {" "} + + {permissions().length} {permissionLabel()} + {permissions().length > 1 && !accessibility() ? "s" : ""} - 0 ? theme.success : theme.textMuted }}>• {lsp().length} LSP + 0 ? theme.success : theme.textMuted }}>{lspBullet()}{" "} + {lsp().length} LSP - - - - - - - - - + + + + + + + + + + + {mcp()} MCP diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/header.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/header.tsx index 0b690cfacbe..34517139e16 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/header.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/header.tsx @@ -3,10 +3,11 @@ import { useRouteData } from "@tui/context/route" import { useSync } from "@tui/context/sync" import { pipe, sumBy } from "remeda" import { useTheme } from "@tui/context/theme" -import { SplitBorder } from "@tui/component/border" +import { getSplitBorder } from "@tui/component/border" import type { AssistantMessage, Session } from "@opencode-ai/sdk/v2" import { useCommandDialog } from "@tui/component/dialog-command" import { useKeybind } from "../../context/keybind" +import { useAccessibility } from "@tui/util/accessibility" const Title = (props: { session: Accessor }) => { const { theme } = useTheme() @@ -62,6 +63,7 @@ export function Header() { const keybind = useKeybind() const command = useCommandDialog() const [hover, setHover] = createSignal<"parent" | "prev" | "next" | null>(null) + const accessibility = useAccessibility() return ( @@ -70,7 +72,7 @@ export function Header() { paddingBottom={1} paddingLeft={2} paddingRight={1} - {...SplitBorder} + {...getSplitBorder(accessibility())} border={["left"]} borderColor={theme.border} flexShrink={0} diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx index d91363954a1..908ce88e0b0 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -15,7 +15,7 @@ import { Dynamic } from "solid-js/web" import path from "path" import { useRoute, useRouteData } from "@tui/context/route" import { useSync } from "@tui/context/sync" -import { SplitBorder } from "@tui/component/border" +import { getSplitBorderChars } from "@tui/component/border" import { useTheme } from "@tui/context/theme" import { BoxRenderable, @@ -74,6 +74,7 @@ import { PermissionPrompt } from "./permission" import { QuestionPrompt } from "./question" import { DialogExportOptions } from "../../ui/dialog-export-options" import { formatTranscript } from "../../util/transcript" +import { useAccessibility } from "@tui/util/accessibility" addDefaultParsers(parsers.parsers) @@ -111,6 +112,7 @@ export function Session() { const kv = useKV() const { theme } = useTheme() const promptRef = usePromptRef() + const accessibility = useAccessibility() const session = createMemo(() => sync.session.get(route.sessionID)) const children = createMemo(() => { const parentID = session()?.parentID ?? session()?.id @@ -954,7 +956,7 @@ export function Session() { marginTop={1} flexShrink={0} border={["left"]} - customBorderChars={SplitBorder.customBorderChars} + customBorderChars={getSplitBorderChars(accessibility())} borderColor={theme.backgroundPanel} > props.pending && props.message.id > props.pending) const color = createMemo(() => (queued() ? theme.accent : local.agent.color(props.message.agent))) const metadataVisible = createMemo(() => queued() || ctx.showTimestamps()) @@ -1111,7 +1114,7 @@ function UserMessage(props: { id={props.message.id} border={["left"]} borderColor={color()} - customBorderChars={SplitBorder.customBorderChars} + customBorderChars={getSplitBorderChars(accessibility())} marginTop={props.index === 0 ? 0 : 1} > sync.data.message[props.message.sessionID] ?? []) + const accessibility = useAccessibility() + const separator = createMemo(() => (accessibility() ? " - " : " · ")) const final = createMemo(() => { return props.message.finish && !["tool-calls", "unknown"].includes(props.message.finish) @@ -1223,7 +1228,7 @@ function AssistantMessage(props: { message: AssistantMessage; parts: Part[]; las paddingLeft={2} marginTop={1} backgroundColor={theme.backgroundPanel} - customBorderChars={SplitBorder.customBorderChars} + customBorderChars={getSplitBorderChars(accessibility())} borderColor={theme.error} > {props.message.error?.data.message} @@ -1241,15 +1246,15 @@ function AssistantMessage(props: { message: AssistantMessage; parts: Part[]; las : local.agent.color(props.message.agent), }} > - ▣{" "} + ▣{" "} {" "} {Locale.titlecase(props.message.mode)} - · {props.message.modelID} + {separator() + props.message.modelID} - · {Locale.duration(duration())} + {separator() + Locale.duration(duration())} - · interrupted + {separator() + "interrupted"} @@ -1268,6 +1273,7 @@ const PART_MAPPING = { function ReasoningPart(props: { last: boolean; part: ReasoningPart; message: AssistantMessage }) { const { theme, subtleSyntax } = useTheme() const ctx = use() + const accessibility = useAccessibility() const content = createMemo(() => { // Filter out redacted reasoning chunks from OpenRouter // OpenRouter sends encrypted reasoning data that appears as [REDACTED] @@ -1281,7 +1287,7 @@ function ReasoningPart(props: { last: boolean; part: ReasoningPart; message: Ass marginTop={1} flexDirection="column" border={["left"]} - customBorderChars={SplitBorder.customBorderChars} + customBorderChars={getSplitBorderChars(accessibility())} borderColor={theme.backgroundElement} > ) { function ToolTitle(props: { fallback: string; when: any; icon: string; children: JSX.Element }) { const { theme } = useTheme() + const accessibility = useAccessibility() return ( ~ {props.fallback}} when={props.when}> - {props.icon} {props.children} + + {props.icon}{" "} + + {props.children} ) @@ -1446,6 +1456,12 @@ function InlineTool(props: { const { theme } = useTheme() const ctx = use() const sync = useSync() + const accessibility = useAccessibility() + const displayIcon = createMemo(() => { + if (!accessibility()) return props.icon + if (!props.icon) return "" + return props.icon.length === 1 && props.icon.charCodeAt(0) <= 127 ? props.icon : "" + }) const permission = createMemo(() => { const callID = sync.data.permission[ctx.sessionID]?.at(0)?.tool?.callID @@ -1497,7 +1513,10 @@ function InlineTool(props: { > ~ {props.pending}} when={props.complete}> - {props.icon} {props.children} + + {displayIcon()}{" "} + + {props.children} @@ -1511,6 +1530,7 @@ function BlockTool(props: { title: string; children: JSX.Element; onClick?: () = const { theme } = useTheme() const renderer = useRenderer() const [hover, setHover] = createSignal(false) + const accessibility = useAccessibility() const error = createMemo(() => (props.part?.state.status === "error" ? props.part.state.error : undefined)) return ( props.onClick && setHover(true)} onMouseOut={() => setHover(false)} @@ -1544,13 +1564,14 @@ function BlockTool(props: { title: string; children: JSX.Element; onClick?: () = function Bash(props: ToolProps) { const { theme } = useTheme() const sync = useSync() + const accessibility = useAccessibility() const output = createMemo(() => stripAnsi(props.metadata.output?.trim() ?? "")) const [expanded, setExpanded] = createSignal(false) const lines = createMemo(() => output().split("\n")) const overflow = createMemo(() => lines().length > 10) const limited = createMemo(() => { if (expanded() || !overflow()) return output() - return [...lines().slice(0, 10), "…"].join("\n") + return [...lines().slice(0, 10), accessibility() ? "..." : "…"].join("\n") }) const workdirDisplay = createMemo(() => { @@ -1722,6 +1743,7 @@ function Task(props: ToolProps) { const keybind = useKeybind() const { navigate } = useRoute() const local = useLocal() + const accessibility = useAccessibility() const current = createMemo(() => props.metadata.summary?.findLast((x) => x.state.status !== "pending")) const color = createMemo(() => local.agent.color(props.input.subagent_type ?? "unknown")) @@ -1744,7 +1766,7 @@ function Task(props: ToolProps) { - └ {Locale.titlecase(current()!.tool)}{" "} + {accessibility() ? "->" : "└"} {Locale.titlecase(current()!.tool)}{" "} {current()!.state.status === "completed" ? current()!.state.title : ""} @@ -1774,6 +1796,7 @@ function Task(props: ToolProps) { function Edit(props: ToolProps) { const ctx = use() const { theme, syntax } = useTheme() + const accessibility = useAccessibility() const view = createMemo(() => { const diffStyle = ctx.sync.data.config.tui?.diff_style @@ -1795,7 +1818,10 @@ function Edit(props: ToolProps) { return ( - + (props.request.metadata?.filepath as string) ?? "") @@ -64,7 +66,9 @@ function EditBody(props: { request: PermissionRequest }) { return ( - {"→"} + + {"→"} + Edit {normalizePath(filepath())} @@ -96,10 +100,11 @@ function EditBody(props: { request: PermissionRequest }) { function TextBody(props: { title: string; description?: string; icon?: string }) { const { theme } = useTheme() + const accessibility = useAccessibility() return ( <> - + {props.icon} @@ -118,6 +123,7 @@ function TextBody(props: { title: string; description?: string; icon?: string }) export function PermissionPrompt(props: { request: PermissionRequest }) { const sdk = useSDK() const sync = useSync() + const accessibility = useAccessibility() const [store, setStore] = createStore({ stage: "permission" as PermissionStage, }) @@ -224,7 +230,7 @@ export function PermissionPrompt(props: { request: PermissionRequest }) { @@ -301,6 +307,8 @@ function RejectPrompt(props: { onConfirm: (message: string) => void; onCancel: ( const { theme } = useTheme() const keybind = useKeybind() const textareaKeybindings = useTextareaKeybindings() + const accessibility = useAccessibility() + const warningIcon = () => (accessibility() ? "!" : "△") useKeyboard((evt) => { if (evt.name === "escape" || keybind.match("app_exit", evt)) { @@ -319,11 +327,11 @@ function RejectPrompt(props: { onConfirm: (message: string) => void; onCancel: ( backgroundColor={theme.backgroundPanel} border={["left"]} borderColor={theme.error} - customBorderChars={SplitBorder.customBorderChars} + customBorderChars={getSplitBorderChars(accessibility())} > - {"△"} + {warningIcon()} Reject permission @@ -372,6 +380,7 @@ function Prompt>(props: { const { theme } = useTheme() const keybind = useKeybind() const dimensions = useTerminalDimensions() + const accessibility = useAccessibility() const keys = Object.keys(props.options) as (keyof T)[] const [store, setStore] = createStore({ selected: keys[0], @@ -419,7 +428,7 @@ function Prompt>(props: { backgroundColor={theme.backgroundPanel} border={["left"]} borderColor={theme.warning} - customBorderChars={SplitBorder.customBorderChars} + customBorderChars={getSplitBorderChars(accessibility())} {...(store.expanded ? { top: dimensions().height * -1 + 1, bottom: 1, left: 2, right: 2, position: "absolute" } : { @@ -433,7 +442,7 @@ function Prompt>(props: { > - {"△"} + {accessibility() ? "!" : "△"} {props.title} {props.body} @@ -471,7 +480,7 @@ function Prompt>(props: { - {"⇆"} select + {accessibility() ? "left/right" : "⇆"} select enter confirm diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/question.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/question.tsx index 049e320cb99..4dd815724e3 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/question.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/question.tsx @@ -6,15 +6,17 @@ import { useKeybind } from "../../context/keybind" import { useTheme } from "../../context/theme" import type { QuestionAnswer, QuestionRequest } from "@opencode-ai/sdk/v2" import { useSDK } from "../../context/sdk" -import { SplitBorder } from "../../component/border" +import { getSplitBorderChars } from "../../component/border" import { useTextareaKeybindings } from "../../component/textarea-keybindings" import { useDialog } from "../../ui/dialog" +import { useAccessibility } from "@tui/util/accessibility" export function QuestionPrompt(props: { request: QuestionRequest }) { const sdk = useSDK() const { theme } = useTheme() const keybind = useKeybind() const bindings = useTextareaKeybindings() + const accessibility = useAccessibility() const questions = createMemo(() => props.request.questions) const single = createMemo(() => questions().length === 1 && questions()[0]?.multiple !== true) @@ -41,6 +43,9 @@ export function QuestionPrompt(props: { request: QuestionRequest }) { if (!value) return false return store.answers[store.tab]?.includes(value) ?? false }) + const checkMark = createMemo(() => (accessibility() ? "x" : "✓")) + const tabHint = createMemo(() => (accessibility() ? "tab" : "⇆")) + const navHint = createMemo(() => (accessibility() ? "up/down" : "↑↓")) function submit() { const answers = questions().map((_, i) => store.answers[i] ?? []) @@ -248,7 +253,7 @@ export function QuestionPrompt(props: { request: QuestionRequest }) { backgroundColor={theme.backgroundPanel} border={["left"]} borderColor={theme.accent} - customBorderChars={SplitBorder.customBorderChars} + customBorderChars={getSplitBorderChars(accessibility())} > @@ -303,12 +308,12 @@ export function QuestionPrompt(props: { request: QuestionRequest }) { {multi() - ? `${i() + 1}. [${picked() ? "✓" : " "}] ${opt.label}` + ? `${i() + 1}. [${picked() ? checkMark() : " "}] ${opt.label}` : `${i() + 1}. ${opt.label}`} - {picked() ? "✓" : ""} + {picked() ? checkMark() : ""} @@ -325,12 +330,12 @@ export function QuestionPrompt(props: { request: QuestionRequest }) { {multi() - ? `${options().length + 1}. [${customPicked() ? "✓" : " "}] Type your own answer` + ? `${options().length + 1}. [${customPicked() ? checkMark() : " "}] Type your own answer` : `${options().length + 1}. Type your own answer`} - {customPicked() ? "✓" : ""} + {customPicked() ? checkMark() : ""} @@ -397,12 +402,12 @@ export function QuestionPrompt(props: { request: QuestionRequest }) { - {"⇆"} tab + {tabHint()} tab - {"↑↓"} select + {navHint()} select diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx index c0f4bd74abd..2197266da6b 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx @@ -11,6 +11,7 @@ import { useKeybind } from "../../context/keybind" import { useDirectory } from "../../context/directory" import { useKV } from "../../context/kv" import { TodoItem } from "../../component/todo-item" +import { useAccessibility } from "@tui/util/accessibility" export function Sidebar(props: { sessionID: string; overlay?: boolean }) { const sync = useSync() @@ -62,11 +63,14 @@ export function Sidebar(props: { sessionID: string; overlay?: boolean }) { const directory = useDirectory() const kv = useKV() + const accessibility = useAccessibility() const hasProviders = createMemo(() => sync.data.provider.some((x) => x.id !== "opencode" || Object.values(x.models).some((y) => y.cost?.input !== 0)), ) const gettingStartedDismissed = createMemo(() => kv.get("dismissed_getting_started", false)) + const bullet = createMemo(() => (accessibility() ? "-" : "•")) + const toggleIcon = (expanded: boolean) => (accessibility() ? (expanded ? "v" : ">") : expanded ? "▼" : "▶") return ( @@ -105,7 +109,7 @@ export function Sidebar(props: { sessionID: string; overlay?: boolean }) { onMouseDown={() => mcpEntries().length > 2 && setExpanded("mcp", !expanded.mcp)} > 2}> - {expanded.mcp ? "▼" : "▶"} + {toggleIcon(expanded.mcp)} MCP @@ -136,7 +140,7 @@ export function Sidebar(props: { sessionID: string; overlay?: boolean }) { )[item.status], }} > - • + {bullet()} {key}{" "} @@ -165,7 +169,7 @@ export function Sidebar(props: { sessionID: string; overlay?: boolean }) { onMouseDown={() => sync.data.lsp.length > 2 && setExpanded("lsp", !expanded.lsp)} > 2}> - {expanded.lsp ? "▼" : "▶"} + {toggleIcon(expanded.lsp)} LSP @@ -191,7 +195,7 @@ export function Sidebar(props: { sessionID: string; overlay?: boolean }) { }[item.status], }} > - • + {bullet()} {item.id} {item.root} @@ -209,7 +213,7 @@ export function Sidebar(props: { sessionID: string; overlay?: boolean }) { onMouseDown={() => todo().length > 2 && setExpanded("todo", !expanded.todo)} > 2}> - {expanded.todo ? "▼" : "▶"} + {toggleIcon(expanded.todo)} Todo @@ -228,7 +232,7 @@ export function Sidebar(props: { sessionID: string; overlay?: boolean }) { onMouseDown={() => diff().length > 2 && setExpanded("diff", !expanded.diff)} > 2}> - {expanded.diff ? "▼" : "▶"} + {toggleIcon(expanded.diff)} Modified Files @@ -279,7 +283,7 @@ export function Sidebar(props: { sessionID: string; overlay?: boolean }) { gap={1} > - ⬖ + {accessibility() ? "!" : "⬖"} @@ -287,7 +291,7 @@ export function Sidebar(props: { sessionID: string; overlay?: boolean }) { Getting started kv.set("dismissed_getting_started", true)}> - ✕ + {accessibility() ? "x" : "✕"} OpenCode includes free models so you can start immediately. @@ -306,7 +310,7 @@ export function Sidebar(props: { sessionID: string; overlay?: boolean }) { {directory().split("/").at(-1)} - Open + {bullet()} Open Code {" "} diff --git a/packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx b/packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx index 98adcdeb135..b839ffd05ce 100644 --- a/packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx +++ b/packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx @@ -1,15 +1,16 @@ import { InputRenderable, RGBA, ScrollBoxRenderable, TextAttributes } from "@opentui/core" import { useTheme, selectedForeground } from "@tui/context/theme" import { entries, filter, flatMap, groupBy, pipe, take } from "remeda" -import { batch, createEffect, createMemo, For, Show, type JSX, on } from "solid-js" +import { batch, createEffect, createMemo, createSignal, For, Show, type JSX, on } from "solid-js" import { createStore } from "solid-js/store" -import { useKeyboard, useTerminalDimensions } from "@opentui/solid" +import { useKeyboard, useRenderer, useTerminalDimensions } from "@opentui/solid" import * as fuzzysort from "fuzzysort" import { isDeepEqual } from "remeda" import { useDialog, type DialogContext } from "@tui/ui/dialog" import { useKeybind } from "@tui/context/keybind" import { Keybind } from "@/util/keybind" import { Locale } from "@/util/locale" +import { useAccessibility, useNumberedMenus } from "@tui/util/accessibility" export interface DialogSelectProps { title: string @@ -49,6 +50,9 @@ export type DialogSelectRef = { export function DialogSelect(props: DialogSelectProps) { const dialog = useDialog() const { theme } = useTheme() + const renderer = useRenderer() + const [numberBuffer, setNumberBuffer] = createSignal("") + const numberedMenus = useNumberedMenus() const [store, setStore] = createStore({ selected: 0, filter: "", @@ -69,6 +73,24 @@ export function DialogSelect(props: DialogSelectProps) { ) let input: InputRenderable + const placeholder = createMemo(() => props.placeholder ?? (numberedMenus() ? "Search (/)" : "Search")) + const isInputFocused = () => !!input && renderer.currentFocusedRenderable === input + + createEffect( + on( + () => numberedMenus(), + (enabled) => { + if (!input) return + if (numberBuffer().length > 0) setNumberBuffer("") + if (enabled) { + input.blur() + } else { + setTimeout(() => input.focus(), 1) + } + }, + { defer: true }, + ), + ) const filtered = createMemo(() => { if (props.skipFilter) { @@ -99,6 +121,7 @@ export function DialogSelect(props: DialogSelectProps) { flatMap(([_, options]) => options), ) }) + const numberWidth = createMemo(() => String(Math.max(flat().length, 1)).length) const dimensions = useTerminalDimensions() const height = createMemo(() => @@ -129,7 +152,10 @@ export function DialogSelect(props: DialogSelectProps) { moveTo(next) } - function moveTo(next: number) { + function moveTo(next: number, opts?: { preserveNumberBuffer?: boolean }) { + if (!opts?.preserveNumberBuffer && numberBuffer().length > 0) { + setNumberBuffer("") + } setStore("selected", next) props.onMove?.(selected()!) if (!scroll) return @@ -149,8 +175,59 @@ export function DialogSelect(props: DialogSelectProps) { } } + function isDigit(value?: string) { + return value !== undefined && value.length === 1 && value >= "0" && value <= "9" + } + + function updateNumberBuffer(next: string) { + setNumberBuffer(next) + const index = Number.parseInt(next, 10) - 1 + if (!Number.isNaN(index) && index >= 0 && index < flat().length) { + moveTo(index, { preserveNumberBuffer: true }) + } + } + const keybind = useKeybind() useKeyboard((evt) => { + if (numberedMenus() && !isInputFocused()) { + if (evt.name === "/") { + if (numberBuffer().length > 0) setNumberBuffer("") + input?.focus() + evt.preventDefault() + evt.stopPropagation() + return + } + + if (evt.name === "backspace" && numberBuffer().length > 0) { + setNumberBuffer(numberBuffer().slice(0, -1)) + evt.preventDefault() + evt.stopPropagation() + return + } + + if (evt.name === "return" && numberBuffer().length > 0) { + const index = Number.parseInt(numberBuffer(), 10) - 1 + const option = flat()[index] + setNumberBuffer("") + if (option) { + evt.preventDefault() + evt.stopPropagation() + if (option.onSelect) option.onSelect(dialog) + props.onSelect?.(option) + } + return + } + + if (isDigit(evt.name)) { + const maxDigits = numberWidth() + const next = (numberBuffer() + evt.name).slice(0, maxDigits) + updateNumberBuffer(next) + evt.preventDefault() + evt.stopPropagation() + return + } + } + if (evt.name === "up" || (evt.ctrl && evt.name === "p")) move(-1) if (evt.name === "down" || (evt.ctrl && evt.name === "n")) move(1) if (evt.name === "pageup") move(-10) @@ -197,7 +274,15 @@ export function DialogSelect(props: DialogSelectProps) { {props.title} - esc + + + / search + + 0}> + select {numberBuffer()} + + esc + (props: DialogSelectProps) { batch(() => { setStore("filter", e) props.onFilter?.(e) + if (numberedMenus()) setNumberBuffer("") }) }} focusedBackgroundColor={theme.backgroundPanel} @@ -212,9 +298,11 @@ export function DialogSelect(props: DialogSelectProps) { focusedTextColor={theme.textMuted} ref={(r) => { input = r - setTimeout(() => input.focus(), 1) + if (!numberedMenus()) { + setTimeout(() => input.focus(), 1) + } }} - placeholder={props.placeholder ?? "Search"} + placeholder={placeholder()} /> @@ -247,6 +335,7 @@ export function DialogSelect(props: DialogSelectProps) { {(option) => { const active = createMemo(() => isDeepEqual(option.value, selected()?.value)) const current = createMemo(() => isDeepEqual(option.value, props.current)) + const index = createMemo(() => flat().indexOf(option)) return ( (props: DialogSelectProps) { moveTo(index) }} backgroundColor={active() ? (option.bg ?? theme.primary) : RGBA.fromInts(0, 0, 0, 0)} - paddingLeft={current() || option.gutter ? 1 : 3} + paddingLeft={numberedMenus() ? 1 : current() || option.gutter ? 1 : 3} paddingRight={3} gap={1} > @@ -272,6 +361,9 @@ export function DialogSelect(props: DialogSelectProps) { active={active()} current={current()} gutter={option.gutter} + index={index()} + numbered={numberedMenus()} + numberWidth={numberWidth()} /> ) @@ -307,16 +399,31 @@ function Option(props: { current?: boolean footer?: JSX.Element | string gutter?: JSX.Element + index?: number + numbered?: boolean + numberWidth?: number onMouseOver?: () => void }) { const { theme } = useTheme() + const accessibility = useAccessibility() const fg = selectedForeground(theme) + const currentIcon = createMemo(() => (accessibility() ? "*" : "●")) + const numberLabel = createMemo(() => { + if (props.index === undefined || props.index < 0) return "" + const width = props.numberWidth ?? String(props.index + 1).length + return `${String(props.index + 1).padStart(width, " ")}.` + }) return ( <> + = 0}> + + {numberLabel()} + + - ● + {currentIcon()} diff --git a/packages/opencode/src/cli/cmd/tui/ui/toast.tsx b/packages/opencode/src/cli/cmd/tui/ui/toast.tsx index 36095580fb0..442780a3496 100644 --- a/packages/opencode/src/cli/cmd/tui/ui/toast.tsx +++ b/packages/opencode/src/cli/cmd/tui/ui/toast.tsx @@ -2,10 +2,11 @@ import { createContext, useContext, type ParentProps, Show } from "solid-js" import { createStore } from "solid-js/store" import { useTheme } from "@tui/context/theme" import { useTerminalDimensions } from "@opentui/solid" -import { SplitBorder } from "../component/border" +import { getSplitBorderChars } from "../component/border" import { TextAttributes } from "@opentui/core" import z from "zod" import { TuiEvent } from "../event" +import { useAccessibility } from "@tui/util/accessibility" export type ToastOptions = z.infer @@ -13,6 +14,7 @@ export function Toast() { const toast = useToast() const { theme } = useTheme() const dimensions = useTerminalDimensions() + const accessibility = useAccessibility() return ( @@ -31,7 +33,7 @@ export function Toast() { backgroundColor={theme.backgroundPanel} borderColor={theme[current().variant]} border={["left", "right"]} - customBorderChars={SplitBorder.customBorderChars} + customBorderChars={getSplitBorderChars(accessibility())} > diff --git a/packages/opencode/src/cli/cmd/tui/util/accessibility.ts b/packages/opencode/src/cli/cmd/tui/util/accessibility.ts new file mode 100644 index 00000000000..95bebf9b947 --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/util/accessibility.ts @@ -0,0 +1,27 @@ +import { createMemo } from "solid-js" +import { useSync } from "@tui/context/sync" + +type AccessibilityConfig = { + tui?: { + accessibility?: { + numbered_menus?: boolean + ascii?: boolean + } + } +} + +export function useAccessibility() { + const sync = useSync() + return createMemo(() => { + const config = sync.data.config as AccessibilityConfig + return config.tui?.accessibility?.ascii === true + }) +} + +export function useNumberedMenus() { + const sync = useSync() + return createMemo(() => { + const config = sync.data.config as AccessibilityConfig + return config.tui?.accessibility?.numbered_menus === true + }) +} diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index bf4a6035bd8..4ba69676a84 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -765,6 +765,16 @@ export namespace Config { .enum(["auto", "stacked"]) .optional() .describe("Control diff rendering style: 'auto' adapts to terminal width, 'stacked' always shows single column"), + accessibility: z + .object({ + numbered_menus: z + .boolean() + .optional() + .describe("Enable numbered selection and slash-to-search in TUI dialogs"), + ascii: z.boolean().optional().describe("Use ASCII-only text mode in the TUI"), + }) + .optional() + .describe("Accessibility settings"), }) export const Server = z diff --git a/packages/web/src/content/docs/config.mdx b/packages/web/src/content/docs/config.mdx index 30edbbd2146..436769b037c 100644 --- a/packages/web/src/content/docs/config.mdx +++ b/packages/web/src/content/docs/config.mdx @@ -160,7 +160,11 @@ You can configure TUI-specific settings through the `tui` option. "scroll_acceleration": { "enabled": true }, - "diff_style": "auto" + "diff_style": "auto", + "accessibility": { + "numbered_menus": true, + "ascii": true + } } } ``` @@ -170,6 +174,8 @@ Available options: - `scroll_acceleration.enabled` - Enable macOS-style scroll acceleration. **Takes precedence over `scroll_speed`.** - `scroll_speed` - Custom scroll speed multiplier (default: `3`, minimum: `1`). Ignored if `scroll_acceleration.enabled` is `true`. - `diff_style` - Control diff rendering. `"auto"` adapts to terminal width, `"stacked"` always shows single column. +- `accessibility.numbered_menus` - Enable numbered selection and `/` to focus search in TUI dialogs. +- `accessibility.ascii` - Use ASCII-only TUI rendering (no Unicode icons/box drawing, and no spinner animations). [Learn more about using the TUI here](/docs/tui). diff --git a/packages/web/src/content/docs/tui.mdx b/packages/web/src/content/docs/tui.mdx index a7f59f21599..a3178e5355c 100644 --- a/packages/web/src/content/docs/tui.mdx +++ b/packages/web/src/content/docs/tui.mdx @@ -350,6 +350,10 @@ You can customize TUI behavior through your OpenCode config file. "scroll_speed": 3, "scroll_acceleration": { "enabled": true + }, + "accessibility": { + "numbered_menus": true, + "ascii": true } } } @@ -359,6 +363,8 @@ You can customize TUI behavior through your OpenCode config file. - `scroll_acceleration` - Enable macOS-style scroll acceleration for smooth, natural scrolling. When enabled, scroll speed increases with rapid scrolling gestures and stays precise for slower movements. **This setting takes precedence over `scroll_speed` and overrides it when enabled.** - `scroll_speed` - Controls how fast the TUI scrolls when using scroll commands (minimum: `1`). Defaults to `3`. **Note: This is ignored if `scroll_acceleration.enabled` is set to `true`.** +- `accessibility.numbered_menus` - Enable numbered selection and `/` to focus search in TUI dialogs. +- `accessibility.ascii` - Use ASCII-only TUI rendering (no Unicode icons/box drawing, and no spinner animations). ---