diff --git a/packages/tui/src/component/prompt/index.tsx b/packages/tui/src/component/prompt/index.tsx index 00efcbed288..fe7f4a22f75 100644 --- a/packages/tui/src/component/prompt/index.tsx +++ b/packages/tui/src/component/prompt/index.tsx @@ -251,6 +251,7 @@ export function Prompt(props: PromptProps) { if (!input || input.isDestroyed) return if (props.disabled) input.cursorColor = theme.backgroundElement if (!props.disabled) input.cursorColor = theme.text + if (tuiConfig.cursor) input.cursorStyle = tuiConfig.cursor }) const lastUserMessage = createMemo(() => { @@ -1431,11 +1432,13 @@ export function Prompt(props: PromptProps) { // setTimeout is a workaround and needs to be addressed properly if (!input || input.isDestroyed) return input.cursorColor = theme.text + if (tuiConfig.cursor) input.cursorStyle = tuiConfig.cursor }, 0) }} onMouseDown={(r: MouseEvent) => r.target?.focus()} focusedBackgroundColor={theme.backgroundElement} cursorColor={props.disabled ? theme.backgroundElement : theme.text} + cursorStyle={tuiConfig.cursor} syntaxStyle={syntax()} /> diff --git a/packages/tui/src/config/index.tsx b/packages/tui/src/config/index.tsx index df9239763a6..bde0bfb0718 100644 --- a/packages/tui/src/config/index.tsx +++ b/packages/tui/src/config/index.tsx @@ -30,6 +30,14 @@ export const ScrollAcceleration = Schema.Struct({ export const DiffStyle = Schema.Literals(["auto", "stacked"]).annotate({ description: "Control diff rendering style: 'auto' adapts to terminal width, 'stacked' always shows single column", }) +export const Cursor = Schema.Struct({ + style: Schema.optional(Schema.Literals(["block", "underline", "line", "default"])).annotate({ + description: "Cursor shape. Use 'default' to preserve the terminal setting", + }), + blinking: Schema.optional(Schema.Boolean).annotate({ + description: "Whether the cursor blinks. Has no effect when style is 'default'", + }), +}).annotate({ description: "Terminal cursor settings" }) export const AttentionSounds = Schema.Record(AttentionSoundName, Schema.optionalKey(Schema.String)) export type AttentionSoundPaths = Schema.Schema.Type @@ -62,11 +70,12 @@ export const Info = Schema.Struct({ scroll_speed: Schema.optional(ScrollSpeed).annotate({ description: "TUI scroll speed" }), scroll_acceleration: Schema.optional(ScrollAcceleration), diff_style: Schema.optional(DiffStyle), + cursor: Schema.optional(Cursor), mouse: Schema.optional(Schema.Boolean).annotate({ description: "Enable or disable mouse capture (default: true)" }), }) export type Info = Schema.Schema.Type -export type Resolved = Omit & { +export type Resolved = Omit & { attention: { enabled: boolean notifications: boolean @@ -78,6 +87,10 @@ export type Resolved = Omit void; onCancel: ( textColor={theme.text} focusedTextColor={theme.text} cursorColor={theme.primary} + cursorStyle={tuiConfig.cursor} /> diff --git a/packages/tui/src/routes/session/question.tsx b/packages/tui/src/routes/session/question.tsx index 191d0a936a9..5ab467a351d 100644 --- a/packages/tui/src/routes/session/question.tsx +++ b/packages/tui/src/routes/session/question.tsx @@ -441,6 +441,7 @@ export function QuestionPrompt(props: { request: QuestionRequest; directory?: st textColor={theme.text} focusedTextColor={theme.text} cursorColor={theme.primary} + cursorStyle={tuiConfig.cursor} /> diff --git a/packages/tui/src/ui/dialog-export-options.tsx b/packages/tui/src/ui/dialog-export-options.tsx index cab853ff2ac..f80f21a5020 100644 --- a/packages/tui/src/ui/dialog-export-options.tsx +++ b/packages/tui/src/ui/dialog-export-options.tsx @@ -3,6 +3,7 @@ import { useTheme } from "../context/theme" import { useDialog, type DialogContext } from "./dialog" import { createStore } from "solid-js/store" import { onMount, Show } from "solid-js" +import { useTuiConfig } from "../config" import { useBindings } from "../keymap" export type DialogExportOptionsProps = { @@ -24,6 +25,7 @@ export type DialogExportOptionsProps = { export function DialogExportOptions(props: DialogExportOptionsProps) { const dialog = useDialog() const { theme } = useTheme() + const tuiConfig = useTuiConfig() let textarea: TextareaRenderable const [store, setStore] = createStore({ thinking: props.defaultThinking, @@ -116,6 +118,7 @@ export function DialogExportOptions(props: DialogExportOptionsProps) { textColor={theme.text} focusedTextColor={theme.text} cursorColor={theme.text} + cursorStyle={tuiConfig.cursor} /> diff --git a/packages/tui/src/ui/dialog-prompt.tsx b/packages/tui/src/ui/dialog-prompt.tsx index f518fb2950b..892b2b0481d 100644 --- a/packages/tui/src/ui/dialog-prompt.tsx +++ b/packages/tui/src/ui/dialog-prompt.tsx @@ -96,6 +96,7 @@ export function DialogPrompt(props: DialogPromptProps) { textColor={props.busy ? theme.textMuted : theme.text} focusedTextColor={props.busy ? theme.textMuted : theme.text} cursorColor={props.busy ? theme.backgroundElement : theme.text} + cursorStyle={tuiConfig.cursor} /> {props.busyText ?? "Working..."} diff --git a/packages/tui/src/ui/dialog-select.tsx b/packages/tui/src/ui/dialog-select.tsx index dab9103d244..df2913ede08 100644 --- a/packages/tui/src/ui/dialog-select.tsx +++ b/packages/tui/src/ui/dialog-select.tsx @@ -579,6 +579,7 @@ export function DialogSelect(props: DialogSelectProps) { }} focusedBackgroundColor={theme.backgroundPanel} cursorColor={theme.primary} + cursorStyle={tuiConfig.cursor} focusedTextColor={theme.textMuted} ref={(r) => { input = r diff --git a/packages/tui/test/config.test.tsx b/packages/tui/test/config.test.tsx index 82d7cf93386..37fc0033e56 100644 --- a/packages/tui/test/config.test.tsx +++ b/packages/tui/test/config.test.tsx @@ -31,13 +31,20 @@ test("validates config constraints", () => { prompt: { max_height: 10, max_width: "auto" }, scroll_speed: 0.001, diff_style: "stacked", + cursor: { blinking: false }, plugin: ["example-plugin"], }), - ).toMatchObject({ leader_timeout: 250, attention: { volume: 1 }, diff_style: "stacked" }) + ).toMatchObject({ + leader_timeout: 250, + attention: { volume: 1 }, + diff_style: "stacked", + cursor: { blinking: false }, + }) expect(() => decodeInfo({ leader_timeout: 0 })).toThrow() expect(() => decodeInfo({ attention: { volume: 1.1 } })).toThrow() expect(() => decodeInfo({ prompt: { max_width: 0 } })).toThrow() expect(() => decodeInfo({ scroll_speed: 0 })).toThrow() + expect(() => decodeInfo({ cursor: { style: "beam" } })).toThrow() expect(decodeInfo({ attention: { sounds: { unknown: "sound.wav" } } })).toEqual({ attention: { sounds: {} } }) }) @@ -56,6 +63,7 @@ test("resolves host-neutral defaults", () => { expect(config.mouse).toBe(true) expect(config.keybinds.has("terminal.suspend")).toBe(true) expect(config.keybinds.has("session.list")).toBe(true) + expect(config.cursor).toBeUndefined() }) test("resolves overrides without mutating input", () => { @@ -72,10 +80,17 @@ test("resolves overrides without mutating input", () => { sounds: { question: "/sounds/question.wav" }, }, keybinds: { session_list: "ctrl+l" }, + cursor: { blinking: false }, } const config = resolve(input, { terminalSuspend: true }) - expect(config).toMatchObject({ theme: "custom", mouse: false, leader_timeout: 750, attention: input.attention }) + expect(config).toMatchObject({ + theme: "custom", + mouse: false, + leader_timeout: 750, + attention: input.attention, + cursor: { style: "block", blinking: false }, + }) expect(config.keybinds.get("session.list")).toHaveLength(1) expect(input.keybinds).toEqual({ session_list: "ctrl+l" }) }) diff --git a/packages/web/src/content/docs/config.mdx b/packages/web/src/content/docs/config.mdx index 8bf875b1f85..318f013b411 100644 --- a/packages/web/src/content/docs/config.mdx +++ b/packages/web/src/content/docs/config.mdx @@ -273,6 +273,10 @@ Use a dedicated `tui.json` (or `tui.jsonc`) file for TUI-specific settings. "enabled": true }, "diff_style": "auto", + "cursor": { + "style": "block", + "blinking": true + }, "mouse": true, "attention": { "enabled": true, @@ -285,6 +289,8 @@ Use a dedicated `tui.json` (or `tui.jsonc`) file for TUI-specific settings. Use `OPENCODE_TUI_CONFIG` to point to a custom TUI config file. +When `cursor.style` is `"default"`, the terminal default cursor is restored, so `cursor.blinking` has no effect. + Set `attention.enabled` to turn on TUI desktop notifications and sounds. See [TUI attention](/docs/tui#attention). Legacy `theme`, `keybinds`, and `tui` keys in `opencode.json` are deprecated and automatically migrated when possible. diff --git a/packages/web/src/content/docs/tui.mdx b/packages/web/src/content/docs/tui.mdx index a03797b302b..856ef392c74 100644 --- a/packages/web/src/content/docs/tui.mdx +++ b/packages/web/src/content/docs/tui.mdx @@ -369,6 +369,10 @@ You can customize TUI behavior through `tui.json` (or `tui.jsonc`). "enabled": false }, "diff_style": "auto", + "cursor": { + "style": "block", + "blinking": true + }, "mouse": true, "attention": { "enabled": true, @@ -395,6 +399,7 @@ 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` - Controls the terminal cursor in TUI input fields. `style` defaults to `"block"`, can be `"underline"`, `"line"`, or `"default"`; `blinking` defaults to `true`. When `style` is `"default"`, the terminal default cursor is restored, so `blinking` has no effect. - `mouse` - Enable or disable mouse capture in the TUI (default: `true`). When disabled, the terminal's native mouse selection/scrolling behavior is preserved. - `attention` - Configures TUI desktop notifications and sounds. Disabled by default.