From 54c0856528fbb79dd6892bb2fc9c521f2f172335 Mon Sep 17 00:00:00 2001 From: Harley Richardson Date: Wed, 14 Jan 2026 23:08:05 +0000 Subject: [PATCH 1/3] Add TUI accessibility text-only mode --- .../src/cli/cmd/tui/component/border.tsx | 16 +++ .../src/cli/cmd/tui/component/dialog-mcp.tsx | 12 +- .../cmd/tui/component/dialog-session-list.tsx | 6 +- .../cli/cmd/tui/component/dialog-status.tsx | 11 +- .../src/cli/cmd/tui/component/logo.tsx | 29 ++-- .../cmd/tui/component/prompt/autocomplete.tsx | 6 +- .../cli/cmd/tui/component/prompt/index.tsx | 45 +++--- .../src/cli/cmd/tui/component/tips.tsx | 4 +- .../src/cli/cmd/tui/component/todo-item.tsx | 12 +- .../opencode/src/cli/cmd/tui/routes/home.tsx | 37 +++-- .../src/cli/cmd/tui/routes/session/footer.tsx | 47 +++++-- .../src/cli/cmd/tui/routes/session/header.tsx | 6 +- .../src/cli/cmd/tui/routes/session/index.tsx | 56 ++++++-- .../cli/cmd/tui/routes/session/permission.tsx | 27 ++-- .../cli/cmd/tui/routes/session/question.tsx | 23 ++-- .../cli/cmd/tui/routes/session/sidebar.tsx | 22 +-- .../src/cli/cmd/tui/ui/dialog-select.tsx | 128 ++++++++++++++++-- .../opencode/src/cli/cmd/tui/ui/toast.tsx | 6 +- .../src/cli/cmd/tui/util/accessibility.ts | 18 +++ packages/opencode/src/config/config.ts | 9 ++ 20 files changed, 397 insertions(+), 123 deletions(-) create mode 100644 packages/opencode/src/cli/cmd/tui/util/accessibility.ts 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 85c174c1dcb..708174f6578 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() @@ -20,6 +21,7 @@ export function DialogSessionList() { const { theme } = useTheme() const sdk = useSDK() const kv = useKV() + const accessibility = useAccessibility() const [toDelete, setToDelete] = createSignal() const [search, setSearch] = createDebouncedSignal("", 150) @@ -33,6 +35,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) @@ -57,7 +61,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 c08fc99b6e3..32dc21f510c 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-status.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-status.tsx @@ -3,12 +3,15 @@ import { useTheme } from "../context/theme" import { useSync } from "@tui/context/sync" import { For, Match, Switch, Show, createMemo } from "solid-js" import { Installation } from "@/installation" +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)) @@ -66,7 +69,7 @@ export function DialogStatus() { )[item.status], }} > - • + {bullet()} {key}{" "} @@ -104,7 +107,7 @@ export function DialogStatus() { }[item.status], }} > - • + {bullet()} {item.id} {item.root} @@ -126,7 +129,7 @@ export function DialogStatus() { fg: theme.success, }} > - • + {bullet()} {item.name} @@ -148,7 +151,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 771962b75d1..f0293dac606 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, RGBA } from "@opentui/core" -import { For, type JSX } from "solid-js" +import { For, Show, type JSX } from "solid-js" import { useTheme, tint } from "@tui/context/theme" +import { useAccessibility } from "@tui/util/accessibility" // Shadow markers (rendered chars in parens): // _ = full shadow cell (space with bg=shadow) @@ -14,6 +15,7 @@ const LOGO_RIGHT = [` ▄ `, `█▀▀▀ █▀▀█ █▀ export function Logo() { const { theme } = useTheme() + const accessibility = useAccessibility() const renderLine = (line: string, fg: RGBA, bold: boolean): JSX.Element[] => { const shadow = tint(theme.background, fg, 0.25) @@ -75,14 +77,23 @@ export function Logo() { return ( - - {(line, index) => ( - - {renderLine(line, theme.textMuted, false)} - {renderLine(LOGO_RIGHT[index()], theme.text, true)} - - )} - + + OpenCode + + } + > + + {(line, index) => ( + + {renderLine(line, theme.textMuted, false)} + {renderLine(LOGO_RIGHT[index()], theme.text, true)} + + )} + + ) } 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 718929d445b..96dae1ecfb4 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() @@ -600,7 +602,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 ( <> @@ -756,11 +774,7 @@ export function Prompt(props: PromptProps) { {local.model.parsed().provider} - · + {separator()} {local.model.variant.current()} @@ -968,26 +982,13 @@ export function Prompt(props: PromptProps) { height={1} border={["left"]} borderColor={highlight()} - customBorderChars={{ - ...EmptyBorder, - vertical: theme.backgroundElement.a !== 0 ? "╹" : " ", - }} + customBorderChars={dividerBorderChars()} > @@ -1000,7 +1001,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 3f0318e2690..04fcc767622 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 { DEFAULT_THEMES, useTheme } from "@tui/context/theme" +import { useAccessibility } from "@tui/util/accessibility" const themeCount = Object.keys(DEFAULT_THEMES).length const themeTip = `Use {highlight}/theme{/highlight} or {highlight}Ctrl+X T{/highlight} to switch between ${themeCount} built-in themes` @@ -32,12 +33,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 afcb2c6118d..09957ed6ed4 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/header.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/header.tsx @@ -3,11 +3,12 @@ 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 { Installation } from "@/installation" +import { useAccessibility } from "@tui/util/accessibility" const Title = (props: { session: Accessor }) => { const { theme } = useTheme() @@ -63,6 +64,7 @@ export function Header() { const keybind = useKeybind() const command = useCommandDialog() const [hover, setHover] = createSignal<"parent" | "prev" | "next" | null>(null) + const accessibility = useAccessibility() return ( @@ -71,7 +73,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 1294ab849e9..8d2077d35f8 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 @@ -1009,7 +1011,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()) @@ -1166,7 +1169,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) @@ -1278,7 +1283,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} @@ -1296,15 +1301,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"} @@ -1323,6 +1328,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] @@ -1336,7 +1342,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} ) @@ -1501,6 +1511,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 @@ -1552,7 +1568,10 @@ function InlineTool(props: { > ~ {props.pending}} when={props.complete}> - {props.icon} {props.children} + + {displayIcon()}{" "} + + {props.children} @@ -1566,6 +1585,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)} @@ -1599,13 +1619,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(() => { @@ -1777,6 +1798,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")) @@ -1799,7 +1821,7 @@ function Task(props: ToolProps) { - └ {Locale.titlecase(current()!.tool)}{" "} + {accessibility() ? "->" : "└"} {Locale.titlecase(current()!.tool)}{" "} {current()!.state.status === "completed" ? current()!.state.title : ""} @@ -1829,6 +1851,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 @@ -1850,7 +1873,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 }) { @@ -302,6 +308,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)) { @@ -320,11 +328,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 @@ -373,6 +381,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], @@ -420,7 +429,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" } : { @@ -434,7 +443,7 @@ function Prompt>(props: { > - {"△"} + {accessibility() ? "!" : "△"} {props.title} {props.body} @@ -477,7 +486,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 763741f4894..f708b3255b5 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 { tint, 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] ?? []) @@ -254,7 +259,7 @@ export function QuestionPrompt(props: { request: QuestionRequest }) { backgroundColor={theme.backgroundPanel} border={["left"]} borderColor={theme.accent} - customBorderChars={SplitBorder.customBorderChars} + customBorderChars={getSplitBorderChars(accessibility())} > @@ -313,11 +318,11 @@ export function QuestionPrompt(props: { request: QuestionRequest }) { - {multi() ? `[${picked() ? "✓" : " "}] ${opt.label}` : opt.label} + {multi() ? `[${picked() ? checkMark() : " "}] ${opt.label}` : opt.label} - {picked() ? "✓" : ""} + {picked() ? checkMark() : ""} @@ -338,12 +343,14 @@ export function QuestionPrompt(props: { request: QuestionRequest }) { - {multi() ? `[${customPicked() ? "✓" : " "}] Type your own answer` : "Type your own answer"} + {multi() + ? `[${customPicked() ? checkMark() : " "}] Type your own answer` + : "Type your own answer"} - {customPicked() ? "✓" : ""} + {customPicked() ? checkMark() : ""} @@ -410,12 +417,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 4ffe91558ed..920b4713e30 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 ( @@ -106,7 +110,7 @@ export function Sidebar(props: { sessionID: string; overlay?: boolean }) { onMouseDown={() => mcpEntries().length > 2 && setExpanded("mcp", !expanded.mcp)} > 2}> - {expanded.mcp ? "▼" : "▶"} + {toggleIcon(expanded.mcp)} MCP @@ -137,7 +141,7 @@ export function Sidebar(props: { sessionID: string; overlay?: boolean }) { )[item.status], }} > - • + {bullet()} {key}{" "} @@ -166,7 +170,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 @@ -192,7 +196,7 @@ export function Sidebar(props: { sessionID: string; overlay?: boolean }) { }[item.status], }} > - • + {bullet()} {item.id} {item.root} @@ -210,7 +214,7 @@ export function Sidebar(props: { sessionID: string; overlay?: boolean }) { onMouseDown={() => todo().length > 2 && setExpanded("todo", !expanded.todo)} > 2}> - {expanded.todo ? "▼" : "▶"} + {toggleIcon(expanded.todo)} Todo @@ -229,7 +233,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 @@ -273,7 +277,7 @@ export function Sidebar(props: { sessionID: string; overlay?: boolean }) { gap={1} > - ⬖ + {accessibility() ? "!" : "⬖"} @@ -281,7 +285,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. @@ -300,7 +304,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 618bf3b3cb6..4285140d38a 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: "", @@ -70,6 +74,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) { @@ -108,6 +130,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(() => @@ -120,11 +143,11 @@ export function DialogSelect(props: DialogSelectProps) { on([() => store.filter, () => props.current], ([filter, current]) => { setTimeout(() => { if (filter.length > 0) { - moveTo(0, true) + moveTo(0, { center: true }) } else if (current) { const currentIndex = flat().findIndex((opt) => isDeepEqual(opt.value, current)) if (currentIndex >= 0) { - moveTo(currentIndex, true) + moveTo(currentIndex, { center: true }) } } }, 0) @@ -139,7 +162,10 @@ export function DialogSelect(props: DialogSelectProps) { moveTo(next) } - function moveTo(next: number, center = false) { + function moveTo(next: number, opts?: { preserveNumberBuffer?: boolean; center?: boolean }) { + if (!opts?.preserveNumberBuffer && numberBuffer().length > 0) { + setNumberBuffer("") + } setStore("selected", next) props.onMove?.(selected()!) if (!scroll) return @@ -148,7 +174,7 @@ export function DialogSelect(props: DialogSelectProps) { }) if (!target) return const y = target.y - scroll.y - if (center) { + if (opts?.center) { const centerOffset = Math.floor(scroll.height / 2) scroll.scrollBy(y - centerOffset) } else { @@ -164,9 +190,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) => { setStore("input", "keyboard") + 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) @@ -217,7 +293,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} @@ -232,9 +317,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()} /> @@ -267,6 +354,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} > @@ -301,6 +389,9 @@ export function DialogSelect(props: DialogSelectProps) { active={active()} current={current()} gutter={option.gutter} + index={index()} + numbered={numberedMenus()} + numberWidth={numberWidth()} /> ) @@ -336,16 +427,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..c47a9753ced --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/util/accessibility.ts @@ -0,0 +1,18 @@ +import { createMemo } from "solid-js" +import { useSync } from "@tui/context/sync" + +type AccessibilityConfig = { + tui?: { + accessibility?: { + numbered_menus?: boolean + } + } +} + +export function useAccessibility() { + 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 b2142e29b94..5e896c8bbcb 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -804,6 +804,15 @@ 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"), + }) + .optional() + .describe("Accessibility settings"), }) export const Server = z From eb0600f4b6c442d074dbf32579b651fe97196e72 Mon Sep 17 00:00:00 2001 From: Harley Richardson Date: Wed, 14 Jan 2026 23:22:56 +0000 Subject: [PATCH 2/3] Split TUI accessibility options --- packages/opencode/src/cli/cmd/tui/util/accessibility.ts | 9 +++++++++ packages/opencode/src/config/config.ts | 1 + 2 files changed, 10 insertions(+) diff --git a/packages/opencode/src/cli/cmd/tui/util/accessibility.ts b/packages/opencode/src/cli/cmd/tui/util/accessibility.ts index c47a9753ced..95bebf9b947 100644 --- a/packages/opencode/src/cli/cmd/tui/util/accessibility.ts +++ b/packages/opencode/src/cli/cmd/tui/util/accessibility.ts @@ -5,11 +5,20 @@ 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 diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 5e896c8bbcb..78d6b4e7443 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -810,6 +810,7 @@ export namespace Config { .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"), From b6532f22887547bcea5ba03e6d58c686a2a88dc0 Mon Sep 17 00:00:00 2001 From: Harley Richardson Date: Wed, 14 Jan 2026 23:35:43 +0000 Subject: [PATCH 3/3] Document TUI accessibility settings --- packages/web/src/content/docs/config.mdx | 8 +++++++- packages/web/src/content/docs/tui.mdx | 6 ++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/packages/web/src/content/docs/config.mdx b/packages/web/src/content/docs/config.mdx index 1474cb91558..a26c7eb338f 100644 --- a/packages/web/src/content/docs/config.mdx +++ b/packages/web/src/content/docs/config.mdx @@ -164,7 +164,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 + } } } ``` @@ -174,6 +178,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 085eb6169f8..701f6873ce5 100644 --- a/packages/web/src/content/docs/tui.mdx +++ b/packages/web/src/content/docs/tui.mdx @@ -364,6 +364,10 @@ You can customize TUI behavior through your OpenCode config file. "scroll_speed": 3, "scroll_acceleration": { "enabled": true + }, + "accessibility": { + "numbered_menus": true, + "ascii": true } } } @@ -373,6 +377,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). ---