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).
---