diff --git a/packages/app/src/components/terminal.tsx b/packages/app/src/components/terminal.tsx index 120af0a1726..4148b40f4b6 100644 --- a/packages/app/src/components/terminal.tsx +++ b/packages/app/src/components/terminal.tsx @@ -363,11 +363,13 @@ export const Terminal = (props: TerminalProps) => { return true } - // allow for toggle terminal keybinds in parent + // Let terminal-toggle keybind bubble to parent command handlers. + // Returning false prevents xterm from consuming the key event. const config = settings.keybinds.get(TOGGLE_TERMINAL_ID) ?? DEFAULT_TOGGLE_TERMINAL_KEYBIND const keybinds = parseKeybind(config) + if (matchKeybind(keybinds, event)) return false - return matchKeybind(keybinds, event) + return true }) const fit = new mod.FitAddon() diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx index 2d99051fb97..7e150f9d3e6 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx @@ -9,6 +9,7 @@ import { EmptyBorder } from "@tui/component/border" import { useSDK } from "@tui/context/sdk" import { useRoute } from "@tui/context/route" import { useSync } from "@tui/context/sync" +import { useTuiConfig } from "@tui/context/tui-config" import { Identifier } from "@/id/id" import { createStore, produce } from "solid-js/store" import { useKeybind } from "@tui/context/keybind" @@ -34,6 +35,7 @@ import { useToast } from "../../ui/toast" import { useKV } from "../../context/kv" import { useTextareaKeybindings } from "../textarea-keybindings" import { DialogSkill } from "../dialog-skill" +import { resolveTextareaCursor } from "../../util/textarea-cursor" export type PromptProps = { sessionID?: string @@ -78,6 +80,8 @@ export function Prompt(props: PromptProps) { const renderer = useRenderer() const { theme, syntax } = useTheme() const kv = useKV() + const tuiConfig = useTuiConfig() + const textareaCursor = createMemo(() => resolveTextareaCursor(theme, tuiConfig)) function promptModelWarning() { toast.show({ @@ -110,8 +114,9 @@ export function Prompt(props: PromptProps) { }) createEffect(() => { + input.cursorStyle = textareaCursor().cursorStyle if (props.disabled) input.cursorColor = theme.backgroundElement - if (!props.disabled) input.cursorColor = theme.text + if (!props.disabled) input.cursorColor = textareaCursor().cursorColor }) const lastUserMessage = createMemo(() => { @@ -1004,12 +1009,14 @@ export function Prompt(props: PromptProps) { setTimeout(() => { // setTimeout is a workaround and needs to be addressed properly if (!input || input.isDestroyed) return - input.cursorColor = theme.text + input.cursorColor = textareaCursor().cursorColor + input.cursorStyle = textareaCursor().cursorStyle }, 0) }} onMouseDown={(r: MouseEvent) => r.target?.focus()} focusedBackgroundColor={theme.backgroundElement} - cursorColor={theme.text} + cursorColor={textareaCursor().cursorColor} + cursorStyle={textareaCursor().cursorStyle} syntaxStyle={syntax()} /> diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx index a50cd96fc84..201b1d6968a 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx @@ -16,6 +16,7 @@ import { Locale } from "@/util/locale" import { Global } from "@/global" import { useDialog } from "../../ui/dialog" import { useTuiConfig } from "../../context/tui-config" +import { resolveTextareaCursor } from "../../util/textarea-cursor" type PermissionStage = "permission" | "always" | "reject" @@ -473,6 +474,8 @@ function RejectPrompt(props: { onConfirm: (message: string) => void; onCancel: ( const dimensions = useTerminalDimensions() const narrow = createMemo(() => dimensions().width < 80) const dialog = useDialog() + const tuiConfig = useTuiConfig() + const cursor = createMemo(() => resolveTextareaCursor(theme, tuiConfig, theme.primary)) useKeyboard((evt) => { if (dialog.stack.length > 0) return @@ -521,7 +524,8 @@ function RejectPrompt(props: { onConfirm: (message: string) => void; onCancel: ( focused textColor={theme.text} focusedTextColor={theme.text} - cursorColor={theme.primary} + cursorColor={cursor().cursorColor} + cursorStyle={cursor().cursorStyle} keyBindings={textareaKeybindings()} /> 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 1565a300818..938654cf265 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/question.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/question.tsx @@ -9,12 +9,16 @@ import { useSDK } from "../../context/sdk" import { SplitBorder } from "../../component/border" import { useTextareaKeybindings } from "../../component/textarea-keybindings" import { useDialog } from "../../ui/dialog" +import { useTuiConfig } from "../../context/tui-config" +import { resolveTextareaCursor } from "../../util/textarea-cursor" export function QuestionPrompt(props: { request: QuestionRequest }) { const sdk = useSDK() const { theme } = useTheme() const keybind = useKeybind() const bindings = useTextareaKeybindings() + const tuiConfig = useTuiConfig() + const cursor = createMemo(() => resolveTextareaCursor(theme, tuiConfig, theme.primary)) const questions = createMemo(() => props.request.questions) const single = createMemo(() => questions().length === 1 && questions()[0]?.multiple !== true) @@ -391,7 +395,8 @@ export function QuestionPrompt(props: { request: QuestionRequest }) { maxHeight={6} textColor={theme.text} focusedTextColor={theme.text} - cursorColor={theme.primary} + cursorColor={cursor().cursorColor} + cursorStyle={cursor().cursorStyle} keyBindings={bindings()} /> diff --git a/packages/opencode/src/cli/cmd/tui/ui/dialog-export-options.tsx b/packages/opencode/src/cli/cmd/tui/ui/dialog-export-options.tsx index d29fe05ee90..307f533e191 100644 --- a/packages/opencode/src/cli/cmd/tui/ui/dialog-export-options.tsx +++ b/packages/opencode/src/cli/cmd/tui/ui/dialog-export-options.tsx @@ -4,6 +4,8 @@ import { useDialog, type DialogContext } from "./dialog" import { createStore } from "solid-js/store" import { onMount, Show, type JSX } from "solid-js" import { useKeyboard } from "@opentui/solid" +import { useTuiConfig } from "../context/tui-config" +import { resolveTextareaCursor } from "../util/textarea-cursor" export type DialogExportOptionsProps = { defaultFilename: string @@ -24,6 +26,8 @@ export type DialogExportOptionsProps = { export function DialogExportOptions(props: DialogExportOptionsProps) { const dialog = useDialog() const { theme } = useTheme() + const tuiConfig = useTuiConfig() + const cursor = () => resolveTextareaCursor(theme, tuiConfig) let textarea: TextareaRenderable const [store, setStore] = createStore({ thinking: props.defaultThinking, @@ -105,7 +109,8 @@ export function DialogExportOptions(props: DialogExportOptionsProps) { placeholder="Enter filename" textColor={theme.text} focusedTextColor={theme.text} - cursorColor={theme.text} + cursorColor={cursor().cursorColor} + cursorStyle={cursor().cursorStyle} /> diff --git a/packages/opencode/src/cli/cmd/tui/ui/dialog-prompt.tsx b/packages/opencode/src/cli/cmd/tui/ui/dialog-prompt.tsx index b1b05a0f1a2..12c7341d4cb 100644 --- a/packages/opencode/src/cli/cmd/tui/ui/dialog-prompt.tsx +++ b/packages/opencode/src/cli/cmd/tui/ui/dialog-prompt.tsx @@ -3,6 +3,8 @@ import { useTheme } from "../context/theme" import { useDialog, type DialogContext } from "./dialog" import { onMount, type JSX } from "solid-js" import { useKeyboard } from "@opentui/solid" +import { useTuiConfig } from "../context/tui-config" +import { resolveTextareaCursor } from "../util/textarea-cursor" export type DialogPromptProps = { title: string @@ -16,6 +18,8 @@ export type DialogPromptProps = { export function DialogPrompt(props: DialogPromptProps) { const dialog = useDialog() const { theme } = useTheme() + const tuiConfig = useTuiConfig() + const cursor = () => resolveTextareaCursor(theme, tuiConfig) let textarea: TextareaRenderable useKeyboard((evt) => { @@ -56,7 +60,8 @@ export function DialogPrompt(props: DialogPromptProps) { placeholder={props.placeholder ?? "Enter text"} textColor={theme.text} focusedTextColor={theme.text} - cursorColor={theme.text} + cursorColor={cursor().cursorColor} + cursorStyle={cursor().cursorStyle} /> diff --git a/packages/opencode/src/cli/cmd/tui/util/textarea-cursor.ts b/packages/opencode/src/cli/cmd/tui/util/textarea-cursor.ts new file mode 100644 index 00000000000..59baa89d162 --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/util/textarea-cursor.ts @@ -0,0 +1,30 @@ +import { RGBA, type CursorStyle, type CursorStyleOptions } from "@opentui/core" +import type { TuiConfig } from "@/config/tui" + +type CursorConfig = TuiConfig.Info + +type CursorTheme = { + text: RGBA + [key: string]: unknown +} + +export function resolveTextareaCursor(theme: CursorTheme, tui?: CursorConfig, fallbackColor: RGBA = theme.text): { + cursorColor: RGBA + cursorStyle: CursorStyleOptions +} { + const cursorColor = resolveCursorColor(theme, tui?.cursor_color) ?? fallbackColor + return { + cursorColor, + cursorStyle: { + style: (tui?.cursor_style ?? "block") as CursorStyle, + blinking: tui?.cursor_blink ?? true, + }, + } +} + +function resolveCursorColor(theme: CursorTheme, color?: string): RGBA | undefined { + if (!color) return + if (color.startsWith("#")) return RGBA.fromHex(color) + const maybeThemeColor = theme[color] + if (maybeThemeColor instanceof RGBA) return maybeThemeColor +} diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 2b8aa9e0301..04d2bf02918 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -934,6 +934,29 @@ export namespace Config { ref: "KeybindsConfig", }) + export const TUI = z.object({ + scroll_speed: z.number().min(0.001).optional().describe("TUI scroll speed"), + scroll_acceleration: z + .object({ + enabled: z.boolean().describe("Enable scroll acceleration"), + }) + .optional() + .describe("Scroll acceleration settings"), + diff_style: z + .enum(["auto", "stacked"]) + .optional() + .describe("Control diff rendering style: 'auto' adapts to terminal width, 'stacked' always shows single column"), + cursor_style: z + .enum(["block", "line", "underline"]) + .optional() + .describe("Cursor style for TUI textareas"), + cursor_blink: z.boolean().optional().describe("Whether TUI textarea cursor blinks"), + cursor_color: z + .string() + .regex(/^#[0-9a-fA-F]{6}$|^[a-zA-Z][a-zA-Z0-9_]*$/, "Invalid cursor color format") + .optional() + .describe("Cursor color for TUI textareas. Supports hex (#RRGGBB) or theme color name."), + }) export const Server = z .object({ port: z.number().int().positive().optional().describe("Port to listen on"), diff --git a/packages/opencode/src/config/tui-schema.ts b/packages/opencode/src/config/tui-schema.ts index f9068e3f01d..b44801ddfdd 100644 --- a/packages/opencode/src/config/tui-schema.ts +++ b/packages/opencode/src/config/tui-schema.ts @@ -22,6 +22,13 @@ export const TuiOptions = z.object({ .enum(["auto", "stacked"]) .optional() .describe("Control diff rendering style: 'auto' adapts to terminal width, 'stacked' always shows single column"), + cursor_style: z.enum(["block", "line", "underline"]).optional().describe("Cursor style for TUI textareas"), + cursor_blink: z.boolean().optional().describe("Whether TUI textarea cursor blinks"), + cursor_color: z + .string() + .regex(/^#[0-9a-fA-F]{6}$|^[a-zA-Z][a-zA-Z0-9_]*$/, "Invalid cursor color format") + .optional() + .describe("Cursor color for TUI textareas. Supports hex (#RRGGBB) or theme color name."), }) export const TuiInfo = z diff --git a/packages/web/src/content/docs/config.mdx b/packages/web/src/content/docs/config.mdx index d2770ee2094..913c23d00e6 100644 --- a/packages/web/src/content/docs/config.mdx +++ b/packages/web/src/content/docs/config.mdx @@ -170,7 +170,10 @@ Use a dedicated `tui.json` (or `tui.jsonc`) file for TUI-specific settings. "scroll_acceleration": { "enabled": true }, - "diff_style": "auto" + "diff_style": "auto", + "cursor_style": "line", + "cursor_blink": false, + "cursor_color": "primary" } ``` @@ -178,6 +181,13 @@ Use `OPENCODE_TUI_CONFIG` to point to a custom TUI config file. Legacy `theme`, `keybinds`, and `tui` keys in `opencode.json` are deprecated and automatically migrated when possible. +- `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. +- `cursor_style` - Cursor shape for TUI textareas. One of `"block"`, `"line"`, or `"underline"` (default: `"block"`). +- `cursor_blink` - Whether the TUI textarea cursor blinks (default: `true`). +- `cursor_color` - Cursor color for TUI textareas. Accepts a hex color like `"#FF5733"` or a theme color name like `"primary"`. + [Learn more about TUI configuration here](/docs/tui#configure). --- diff --git a/packages/web/src/content/docs/tui.mdx b/packages/web/src/content/docs/tui.mdx index 010e8328f41..dc0d6677dd7 100644 --- a/packages/web/src/content/docs/tui.mdx +++ b/packages/web/src/content/docs/tui.mdx @@ -368,7 +368,10 @@ You can customize TUI behavior through `tui.json` (or `tui.jsonc`). "scroll_acceleration": { "enabled": true }, - "diff_style": "auto" + "diff_style": "auto", + "cursor_style": "line", + "cursor_blink": false, + "cursor_color": "primary" } ``` @@ -381,6 +384,9 @@ This is separate from `opencode.json`, which configures server/runtime behavior. - `scroll_acceleration.enabled` - 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: `0.001`, supports decimal values). Defaults to `3`. **Note: This is ignored if `scroll_acceleration.enabled` is set to `true`.** - `diff_style` - Controls diff rendering. `"auto"` adapts to terminal width, `"stacked"` always shows a single-column layout. +- `cursor_style` - Cursor shape for TUI textareas. One of `"block"`, `"line"`, or `"underline"`. +- `cursor_blink` - Toggle cursor blinking for TUI textareas. +- `cursor_color` - Cursor color for TUI textareas. Supports hex (for example `"#FF5733"`) or theme color names (for example `"primary"`). Use `OPENCODE_TUI_CONFIG` to load a custom TUI config path.