feat(tui): add cursor style configuration (#32295)
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
This commit is contained in:
@@ -251,6 +251,7 @@ export function Prompt(props: PromptProps) {
|
|||||||
if (!input || input.isDestroyed) return
|
if (!input || input.isDestroyed) return
|
||||||
if (props.disabled) input.cursorColor = theme.backgroundElement
|
if (props.disabled) input.cursorColor = theme.backgroundElement
|
||||||
if (!props.disabled) input.cursorColor = theme.text
|
if (!props.disabled) input.cursorColor = theme.text
|
||||||
|
if (tuiConfig.cursor) input.cursorStyle = tuiConfig.cursor
|
||||||
})
|
})
|
||||||
|
|
||||||
const lastUserMessage = createMemo(() => {
|
const lastUserMessage = createMemo(() => {
|
||||||
@@ -1431,11 +1432,13 @@ export function Prompt(props: PromptProps) {
|
|||||||
// setTimeout is a workaround and needs to be addressed properly
|
// setTimeout is a workaround and needs to be addressed properly
|
||||||
if (!input || input.isDestroyed) return
|
if (!input || input.isDestroyed) return
|
||||||
input.cursorColor = theme.text
|
input.cursorColor = theme.text
|
||||||
|
if (tuiConfig.cursor) input.cursorStyle = tuiConfig.cursor
|
||||||
}, 0)
|
}, 0)
|
||||||
}}
|
}}
|
||||||
onMouseDown={(r: MouseEvent) => r.target?.focus()}
|
onMouseDown={(r: MouseEvent) => r.target?.focus()}
|
||||||
focusedBackgroundColor={theme.backgroundElement}
|
focusedBackgroundColor={theme.backgroundElement}
|
||||||
cursorColor={props.disabled ? theme.backgroundElement : theme.text}
|
cursorColor={props.disabled ? theme.backgroundElement : theme.text}
|
||||||
|
cursorStyle={tuiConfig.cursor}
|
||||||
syntaxStyle={syntax()}
|
syntaxStyle={syntax()}
|
||||||
/>
|
/>
|
||||||
<box flexDirection="row" flexShrink={0} paddingTop={1} gap={1} justifyContent="space-between">
|
<box flexDirection="row" flexShrink={0} paddingTop={1} gap={1} justifyContent="space-between">
|
||||||
|
|||||||
@@ -30,6 +30,14 @@ export const ScrollAcceleration = Schema.Struct({
|
|||||||
export const DiffStyle = Schema.Literals(["auto", "stacked"]).annotate({
|
export const DiffStyle = Schema.Literals(["auto", "stacked"]).annotate({
|
||||||
description: "Control diff rendering style: 'auto' adapts to terminal width, 'stacked' always shows single column",
|
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 const AttentionSounds = Schema.Record(AttentionSoundName, Schema.optionalKey(Schema.String))
|
||||||
export type AttentionSoundPaths = Schema.Schema.Type<typeof AttentionSounds>
|
export type AttentionSoundPaths = Schema.Schema.Type<typeof AttentionSounds>
|
||||||
@@ -62,11 +70,12 @@ export const Info = Schema.Struct({
|
|||||||
scroll_speed: Schema.optional(ScrollSpeed).annotate({ description: "TUI scroll speed" }),
|
scroll_speed: Schema.optional(ScrollSpeed).annotate({ description: "TUI scroll speed" }),
|
||||||
scroll_acceleration: Schema.optional(ScrollAcceleration),
|
scroll_acceleration: Schema.optional(ScrollAcceleration),
|
||||||
diff_style: Schema.optional(DiffStyle),
|
diff_style: Schema.optional(DiffStyle),
|
||||||
|
cursor: Schema.optional(Cursor),
|
||||||
mouse: Schema.optional(Schema.Boolean).annotate({ description: "Enable or disable mouse capture (default: true)" }),
|
mouse: Schema.optional(Schema.Boolean).annotate({ description: "Enable or disable mouse capture (default: true)" }),
|
||||||
})
|
})
|
||||||
export type Info = Schema.Schema.Type<typeof Info>
|
export type Info = Schema.Schema.Type<typeof Info>
|
||||||
|
|
||||||
export type Resolved = Omit<Info, "attention" | "keybinds" | "leader_timeout" | "mouse"> & {
|
export type Resolved = Omit<Info, "attention" | "keybinds" | "leader_timeout" | "mouse" | "cursor"> & {
|
||||||
attention: {
|
attention: {
|
||||||
enabled: boolean
|
enabled: boolean
|
||||||
notifications: boolean
|
notifications: boolean
|
||||||
@@ -78,6 +87,10 @@ export type Resolved = Omit<Info, "attention" | "keybinds" | "leader_timeout" |
|
|||||||
keybinds: TuiKeybind.BindingLookupView
|
keybinds: TuiKeybind.BindingLookupView
|
||||||
leader_timeout: number
|
leader_timeout: number
|
||||||
mouse: boolean
|
mouse: boolean
|
||||||
|
cursor?: {
|
||||||
|
style: "block" | "underline" | "line" | "default"
|
||||||
|
blinking: boolean
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ResolveOptions = Schema.Struct({
|
export const ResolveOptions = Schema.Struct({
|
||||||
@@ -113,6 +126,12 @@ export function resolve(input: Info, options: ResolveOptions): Resolved {
|
|||||||
}),
|
}),
|
||||||
leader_timeout: input.leader_timeout ?? LeaderTimeoutDefault,
|
leader_timeout: input.leader_timeout ?? LeaderTimeoutDefault,
|
||||||
mouse: input.mouse ?? true,
|
mouse: input.mouse ?? true,
|
||||||
|
cursor: input.cursor
|
||||||
|
? {
|
||||||
|
style: input.cursor.style ?? "block",
|
||||||
|
blinking: input.cursor.blinking ?? true,
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -507,6 +507,7 @@ function RejectPrompt(props: { onConfirm: (message: string) => void; onCancel: (
|
|||||||
textColor={theme.text}
|
textColor={theme.text}
|
||||||
focusedTextColor={theme.text}
|
focusedTextColor={theme.text}
|
||||||
cursorColor={theme.primary}
|
cursorColor={theme.primary}
|
||||||
|
cursorStyle={tuiConfig.cursor}
|
||||||
/>
|
/>
|
||||||
<box flexDirection="row" gap={2} flexShrink={0}>
|
<box flexDirection="row" gap={2} flexShrink={0}>
|
||||||
<text fg={theme.text}>
|
<text fg={theme.text}>
|
||||||
|
|||||||
@@ -441,6 +441,7 @@ export function QuestionPrompt(props: { request: QuestionRequest; directory?: st
|
|||||||
textColor={theme.text}
|
textColor={theme.text}
|
||||||
focusedTextColor={theme.text}
|
focusedTextColor={theme.text}
|
||||||
cursorColor={theme.primary}
|
cursorColor={theme.primary}
|
||||||
|
cursorStyle={tuiConfig.cursor}
|
||||||
/>
|
/>
|
||||||
</box>
|
</box>
|
||||||
</Show>
|
</Show>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { useTheme } from "../context/theme"
|
|||||||
import { useDialog, type DialogContext } from "./dialog"
|
import { useDialog, type DialogContext } from "./dialog"
|
||||||
import { createStore } from "solid-js/store"
|
import { createStore } from "solid-js/store"
|
||||||
import { onMount, Show } from "solid-js"
|
import { onMount, Show } from "solid-js"
|
||||||
|
import { useTuiConfig } from "../config"
|
||||||
import { useBindings } from "../keymap"
|
import { useBindings } from "../keymap"
|
||||||
|
|
||||||
export type DialogExportOptionsProps = {
|
export type DialogExportOptionsProps = {
|
||||||
@@ -24,6 +25,7 @@ export type DialogExportOptionsProps = {
|
|||||||
export function DialogExportOptions(props: DialogExportOptionsProps) {
|
export function DialogExportOptions(props: DialogExportOptionsProps) {
|
||||||
const dialog = useDialog()
|
const dialog = useDialog()
|
||||||
const { theme } = useTheme()
|
const { theme } = useTheme()
|
||||||
|
const tuiConfig = useTuiConfig()
|
||||||
let textarea: TextareaRenderable
|
let textarea: TextareaRenderable
|
||||||
const [store, setStore] = createStore({
|
const [store, setStore] = createStore({
|
||||||
thinking: props.defaultThinking,
|
thinking: props.defaultThinking,
|
||||||
@@ -116,6 +118,7 @@ export function DialogExportOptions(props: DialogExportOptionsProps) {
|
|||||||
textColor={theme.text}
|
textColor={theme.text}
|
||||||
focusedTextColor={theme.text}
|
focusedTextColor={theme.text}
|
||||||
cursorColor={theme.text}
|
cursorColor={theme.text}
|
||||||
|
cursorStyle={tuiConfig.cursor}
|
||||||
/>
|
/>
|
||||||
</box>
|
</box>
|
||||||
<box flexDirection="column">
|
<box flexDirection="column">
|
||||||
|
|||||||
@@ -96,6 +96,7 @@ export function DialogPrompt(props: DialogPromptProps) {
|
|||||||
textColor={props.busy ? theme.textMuted : theme.text}
|
textColor={props.busy ? theme.textMuted : theme.text}
|
||||||
focusedTextColor={props.busy ? theme.textMuted : theme.text}
|
focusedTextColor={props.busy ? theme.textMuted : theme.text}
|
||||||
cursorColor={props.busy ? theme.backgroundElement : theme.text}
|
cursorColor={props.busy ? theme.backgroundElement : theme.text}
|
||||||
|
cursorStyle={tuiConfig.cursor}
|
||||||
/>
|
/>
|
||||||
<Show when={props.busy}>
|
<Show when={props.busy}>
|
||||||
<Spinner color={theme.textMuted}>{props.busyText ?? "Working..."}</Spinner>
|
<Spinner color={theme.textMuted}>{props.busyText ?? "Working..."}</Spinner>
|
||||||
|
|||||||
@@ -579,6 +579,7 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
|
|||||||
}}
|
}}
|
||||||
focusedBackgroundColor={theme.backgroundPanel}
|
focusedBackgroundColor={theme.backgroundPanel}
|
||||||
cursorColor={theme.primary}
|
cursorColor={theme.primary}
|
||||||
|
cursorStyle={tuiConfig.cursor}
|
||||||
focusedTextColor={theme.textMuted}
|
focusedTextColor={theme.textMuted}
|
||||||
ref={(r) => {
|
ref={(r) => {
|
||||||
input = r
|
input = r
|
||||||
|
|||||||
@@ -31,13 +31,20 @@ test("validates config constraints", () => {
|
|||||||
prompt: { max_height: 10, max_width: "auto" },
|
prompt: { max_height: 10, max_width: "auto" },
|
||||||
scroll_speed: 0.001,
|
scroll_speed: 0.001,
|
||||||
diff_style: "stacked",
|
diff_style: "stacked",
|
||||||
|
cursor: { blinking: false },
|
||||||
plugin: ["example-plugin"],
|
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({ leader_timeout: 0 })).toThrow()
|
||||||
expect(() => decodeInfo({ attention: { volume: 1.1 } })).toThrow()
|
expect(() => decodeInfo({ attention: { volume: 1.1 } })).toThrow()
|
||||||
expect(() => decodeInfo({ prompt: { max_width: 0 } })).toThrow()
|
expect(() => decodeInfo({ prompt: { max_width: 0 } })).toThrow()
|
||||||
expect(() => decodeInfo({ scroll_speed: 0 })).toThrow()
|
expect(() => decodeInfo({ scroll_speed: 0 })).toThrow()
|
||||||
|
expect(() => decodeInfo({ cursor: { style: "beam" } })).toThrow()
|
||||||
expect(decodeInfo({ attention: { sounds: { unknown: "sound.wav" } } })).toEqual({ attention: { sounds: {} } })
|
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.mouse).toBe(true)
|
||||||
expect(config.keybinds.has("terminal.suspend")).toBe(true)
|
expect(config.keybinds.has("terminal.suspend")).toBe(true)
|
||||||
expect(config.keybinds.has("session.list")).toBe(true)
|
expect(config.keybinds.has("session.list")).toBe(true)
|
||||||
|
expect(config.cursor).toBeUndefined()
|
||||||
})
|
})
|
||||||
|
|
||||||
test("resolves overrides without mutating input", () => {
|
test("resolves overrides without mutating input", () => {
|
||||||
@@ -72,10 +80,17 @@ test("resolves overrides without mutating input", () => {
|
|||||||
sounds: { question: "/sounds/question.wav" },
|
sounds: { question: "/sounds/question.wav" },
|
||||||
},
|
},
|
||||||
keybinds: { session_list: "ctrl+l" },
|
keybinds: { session_list: "ctrl+l" },
|
||||||
|
cursor: { blinking: false },
|
||||||
}
|
}
|
||||||
const config = resolve(input, { terminalSuspend: true })
|
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(config.keybinds.get("session.list")).toHaveLength(1)
|
||||||
expect(input.keybinds).toEqual({ session_list: "ctrl+l" })
|
expect(input.keybinds).toEqual({ session_list: "ctrl+l" })
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -273,6 +273,10 @@ Use a dedicated `tui.json` (or `tui.jsonc`) file for TUI-specific settings.
|
|||||||
"enabled": true
|
"enabled": true
|
||||||
},
|
},
|
||||||
"diff_style": "auto",
|
"diff_style": "auto",
|
||||||
|
"cursor": {
|
||||||
|
"style": "block",
|
||||||
|
"blinking": true
|
||||||
|
},
|
||||||
"mouse": true,
|
"mouse": true,
|
||||||
"attention": {
|
"attention": {
|
||||||
"enabled": true,
|
"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.
|
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).
|
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.
|
Legacy `theme`, `keybinds`, and `tui` keys in `opencode.json` are deprecated and automatically migrated when possible.
|
||||||
|
|||||||
@@ -369,6 +369,10 @@ You can customize TUI behavior through `tui.json` (or `tui.jsonc`).
|
|||||||
"enabled": false
|
"enabled": false
|
||||||
},
|
},
|
||||||
"diff_style": "auto",
|
"diff_style": "auto",
|
||||||
|
"cursor": {
|
||||||
|
"style": "block",
|
||||||
|
"blinking": true
|
||||||
|
},
|
||||||
"mouse": true,
|
"mouse": true,
|
||||||
"attention": {
|
"attention": {
|
||||||
"enabled": true,
|
"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_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`.**
|
- `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.
|
- `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.
|
- `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.
|
- `attention` - Configures TUI desktop notifications and sounds. Disabled by default.
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user