From 3f4596b462658d66f33c0867e5a706a7409bb98d Mon Sep 17 00:00:00 2001 From: Robin Gagnon Date: Tue, 28 Apr 2026 21:41:24 -0500 Subject: [PATCH 1/3] feat: use theme colors for visual and copy mode selections * make visual mode a bit more vim-like * cursor doesn't change color between normal and visual modes * selected text in visual mode uses a different background than cursor * extract CopyOverlay to share overlay rendering across messages --- .../cli/cmd/tui/component/prompt/index.tsx | 50 +++--- .../cli/cmd/tui/component/vim/vim-motions.ts | 10 +- .../cli/cmd/tui/routes/session/copy-mode.ts | 56 +++++-- .../src/cli/cmd/tui/routes/session/index.tsx | 147 ++++++++---------- 4 files changed, 141 insertions(+), 122 deletions(-) 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 61235dff7b4a..e828a344d2f3 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx @@ -10,23 +10,13 @@ import { dim, fg, } from "@opentui/core" -import { - createEffect, - createMemo, - onMount, - createSignal, - onCleanup, - on, - Show, - Switch, - Match, -} from "solid-js" +import { createEffect, createMemo, onMount, createSignal, onCleanup, on, Show, Switch, Match } from "solid-js" import "opentui-spinner/solid" import path from "path" import { fileURLToPath } from "url" import { Filesystem } from "@/util/filesystem" import { useLocal } from "@tui/context/local" -import { tint, useTheme } from "@tui/context/theme" +import { selectedForeground, tint, useTheme } from "@tui/context/theme" import { EmptyBorder, SplitBorder } from "@tui/component/border" import { useSDK } from "@tui/context/sdk" import { useRoute } from "@tui/context/route" @@ -290,10 +280,13 @@ export function Prompt(props: PromptProps) { if (props.disabled || vimState.isCopy()) { input.cursorColor = theme.backgroundElement input.showCursor = false - } else { - input.cursorColor = theme.text - input.showCursor = true + return } + const visual = vimState.isVisual() + input.cursorColor = theme.text + input.showCursor = true + input.selectionBg = visual ? theme.secondary : undefined + input.selectionFg = visual ? selectedForeground(theme, theme.secondary) : undefined }) createEffect((prev: boolean | undefined) => { @@ -1755,14 +1748,25 @@ export function Prompt(props: PromptProps) { input.scrollY, input.height, ) - if (!rows.length) return - const bg = input.selectionBg ?? input.textColor - const fg = - input.selectionFg ?? - (input.backgroundColor.a > 0 ? input.backgroundColor : RGBA.fromInts(0, 0, 0)) - rows.forEach((row) => { - buffer.setCell(input.x, input.y + row, " ", fg, bg) - }) + if (rows.length) { + const bg = input.selectionBg ?? input.textColor + const fg = + input.selectionFg ?? + (input.backgroundColor.a > 0 ? input.backgroundColor : RGBA.fromInts(0, 0, 0)) + rows.forEach((row) => { + buffer.setCell(input.x, input.y + row, " ", fg, bg) + }) + } + if (input.visualCursor.visualRow < 0 || input.visualCursor.visualRow >= input.height) return + if (input.visualCursor.visualCol < 0 || input.visualCursor.visualCol >= input.width) return + // recolor the cursor cell in place; setCell would clobber the underlying glyph + const cursorOffset = + ((input.y + input.visualCursor.visualRow) * buffer.width + + input.x + + input.visualCursor.visualCol) * + 4 + buffer.buffers.fg.set(theme.text.buffer.subarray(0, 4), cursorOffset) + buffer.buffers.bg.set(theme.backgroundElement.buffer.subarray(0, 4), cursorOffset) } } props.ref?.(ref) diff --git a/packages/opencode/src/cli/cmd/tui/component/vim/vim-motions.ts b/packages/opencode/src/cli/cmd/tui/component/vim/vim-motions.ts index 99eb882adc2d..08d3b1a20319 100644 --- a/packages/opencode/src/cli/cmd/tui/component/vim/vim-motions.ts +++ b/packages/opencode/src/cli/cmd/tui/component/vim/vim-motions.ts @@ -239,10 +239,7 @@ function nextCharwiseSpan(text: string, cursor: number, c: NextClassification): return { start: cursor, end } } -export function nextParagraphOperation( - textarea: TextareaRenderable, - operation: ParagraphOperation, -): ParagraphResult { +export function nextParagraphOperation(textarea: TextareaRenderable, operation: ParagraphOperation): ParagraphResult { const text = textarea.plainText const cursor = textarea.cursorOffset if (text.length === 0) return { span: null, register: null } @@ -319,8 +316,7 @@ export function wordEnd(text: string, offset: number, big: boolean) { if (pos >= text.length) pos = text.length - 1 const startClass = wordClass(text[pos], big) - const atRunEnd = - startClass === "blank" || pos + 1 >= text.length || wordClass(text[pos + 1], big) !== startClass + const atRunEnd = startClass === "blank" || pos + 1 >= text.length || wordClass(text[pos + 1], big) !== startClass if (atRunEnd) { pos++ @@ -771,7 +767,7 @@ export function syncSelection(textarea: TextareaRenderable, anchor: number, line textarea.cursorOffset = forward ? hi : lo ta.updateSelectionForMovement(true, false) textarea.cursorOffset = cursor - textarea.editorView.setSelection(lo, hi) + textarea.editorView.setSelection(lo, hi, textarea.selectionBg, textarea.selectionFg) } export function clearSelection(textarea: TextareaRenderable) { diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/copy-mode.ts b/packages/opencode/src/cli/cmd/tui/routes/session/copy-mode.ts index d6832e79588e..da074879b93e 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/copy-mode.ts +++ b/packages/opencode/src/cli/cmd/tui/routes/session/copy-mode.ts @@ -49,6 +49,12 @@ const empty: CopyState = { const segmenter = new Intl.Segmenter() +type Endpoint = { idx: number; col: number } +function orderEndpoints(a: Endpoint, b: Endpoint): { start: Endpoint; end: Endpoint } { + const aFirst = a.idx < b.idx || (a.idx === b.idx && a.col <= b.col) + return aFirst ? { start: a, end: b } : { start: b, end: a } +} + export function createCopyMode(input: { scroll: () => ScrollBoxRenderable messages: Accessor<{ id: string; role: string }[]> @@ -449,10 +455,8 @@ export function createCopyMode(input: { .getChildren() .map((c) => [c.id, c]), ) - const a = s.anchor const h = { idx: s.idx, col: s.col } - const start = a.idx <= h.idx ? a : h - const end = a.idx <= h.idx ? h : a + const { start, end } = orderEndpoints(s.anchor, h) if (s.visual === "line") { return Array.from({ length: end.idx - start.idx + 1 }, (_, i) => list[start.idx + i]) .filter((row): row is CopyRow => !!row) @@ -601,11 +605,21 @@ export function createCopyMode(input: { .getChildren() .map((c) => [c.id, c]), ) - const a = s.anchor const h = { idx: s.idx, col: s.col } - const start = a.idx <= h.idx ? a : h - const end = a.idx <= h.idx ? h : a + const { start, end } = orderEndpoints(s.anchor, h) const out = new Map() + const addHighlight = (row: CopyRow, min: number, text: string, left: number, right: number) => { + if (left > right) return + const entry = { + line: row.line, + left, + right, + text: text.slice(Math.max(0, left - min), Math.max(0, right - min + 1)), + } + const arr = out.get(row.id) + if (arr) arr.push(entry) + else out.set(row.id, [entry]) + } for (let i = start.idx; i <= end.idx; i++) { const r = list[i] if (!r) continue @@ -616,18 +630,31 @@ export function createCopyMode(input: { s.visual === "line" ? min : i === start.idx && i === end.idx ? start.col : i === start.idx ? start.col : min const right = s.visual === "line" ? max : i === start.idx && i === end.idx ? end.col : i === end.idx ? end.col : max - const cur = out.get(r.id) ?? [] - cur.push({ - line: r.line, - left, - right, - text: text.slice(Math.max(0, left - min), Math.max(0, right - min + 1)), - }) - out.set(r.id, cur) + if (i !== h.idx) { + addHighlight(r, min, text, left, right) + continue + } + // cursor cell is painted separately by CopyOverlay so the cursor keeps its theme.text color + addHighlight(r, min, text, left, h.col - 1) + addHighlight(r, min, text, h.col + 1, right) } return out }) + const cursorText = createMemo(() => { + const s = state() + if (!s.active) return " " + const row = rows()[s.idx] + if (!row) return " " + const text = copyText() + let col = 0 + for (const seg of segmenter.segment(text)) { + if (col >= s.col) return seg.segment + col += Bun.stringWidth(seg.segment) + } + return " " + }) + return { prompt: { enter, @@ -656,5 +683,6 @@ export function createCopyMode(input: { active: () => state().active, clamp, state, + cursorText, } } 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 5524b7a1717e..7d1c0c640cc4 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -1179,7 +1179,12 @@ export function Session() { = { "application/x-directory": "dir", } +type CopyPosition = { line: number; col: number; visual: boolean; cursorText: string } +type CopyContext = CopyRow & CopyPosition + +function CopyOverlay(props: { copy?: CopyPosition; topOffset?: number; highlights?: CopyHighlight[] }) { + const { theme } = useTheme() + const top = (line: number) => line + (props.topOffset ?? 0) + const highlightFg = createMemo(() => selectedForeground(theme, theme.secondary)) + const cursorFg = createMemo(() => selectedForeground(theme, theme.text)) + return ( + <> + + + + + {(highlight) => ( + + + {highlight.text || " "} + + + )} + + + + + {props.copy!.cursorText} + + + + + ) +} + function UserMessage(props: { message: UserMessage parts: Part[] onMouseUp: () => void index: number pending?: string - copy?: { line: number; col: number; visual?: boolean } + copy?: CopyPosition highlights?: CopyHighlight[] }) { const ctx = use() @@ -1340,30 +1394,7 @@ function UserMessage(props: { backgroundColor={hover() ? theme.backgroundElement : theme.backgroundPanel} flexShrink={0} > - - - - - - - - - - {(highlight) => ( - - - {highlight.text || " "} - - - )} - + {text()} @@ -1420,7 +1451,7 @@ function AssistantMessage(props: { message: AssistantMessage parts: Part[] last: boolean - copy?: CopyRow & { visual?: boolean } + copy?: CopyContext highlights?: Map }) { const ctx = use() @@ -1566,7 +1597,7 @@ function TextPart(props: { last: boolean part: TextPart message: AssistantMessage - copy?: CopyRow & { visual?: boolean } + copy?: CopyContext highlights?: CopyHighlight[] }) { const ctx = use() @@ -1574,30 +1605,10 @@ function TextPart(props: { return ( - - - - - - - - - - {(highlight) => ( - - - {highlight.text || " "} - - - )} - + - - - - - - - - - - {(highlight) => ( - - - {highlight.text || " "} - - - )} - + From 4c13f9a4b8286a11f5eabedad380adb081d00a0e Mon Sep 17 00:00:00 2001 From: Robin Gagnon Date: Wed, 29 Apr 2026 08:55:03 -0500 Subject: [PATCH 2/3] fix visual cursor contrast on dark themes --- packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 e828a344d2f3..0ed6d35dec39 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx @@ -1766,7 +1766,7 @@ export function Prompt(props: PromptProps) { input.visualCursor.visualCol) * 4 buffer.buffers.fg.set(theme.text.buffer.subarray(0, 4), cursorOffset) - buffer.buffers.bg.set(theme.backgroundElement.buffer.subarray(0, 4), cursorOffset) + buffer.buffers.bg.set(selectedForeground(theme, theme.text).buffer.subarray(0, 4), cursorOffset) } } props.ref?.(ref) From ca479159020117aed8406bddc97bfffcb21f136c Mon Sep 17 00:00:00 2001 From: leohenon <77656081+lhenon999@users.noreply.github.com> Date: Fri, 1 May 2026 09:47:30 +0800 Subject: [PATCH 3/3] fix: visual cursor contrast --- .../opencode/src/cli/cmd/tui/component/prompt/index.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) 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 0ed6d35dec39..c39229610206 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx @@ -284,7 +284,7 @@ export function Prompt(props: PromptProps) { } const visual = vimState.isVisual() input.cursorColor = theme.text - input.showCursor = true + input.showCursor = !visual input.selectionBg = visual ? theme.secondary : undefined input.selectionFg = visual ? selectedForeground(theme, theme.secondary) : undefined }) @@ -1765,8 +1765,8 @@ export function Prompt(props: PromptProps) { input.x + input.visualCursor.visualCol) * 4 - buffer.buffers.fg.set(theme.text.buffer.subarray(0, 4), cursorOffset) - buffer.buffers.bg.set(selectedForeground(theme, theme.text).buffer.subarray(0, 4), cursorOffset) + buffer.buffers.fg.set(selectedForeground(theme, theme.text).buffer.subarray(0, 4), cursorOffset) + buffer.buffers.bg.set(theme.text.buffer.subarray(0, 4), cursorOffset) } } props.ref?.(ref)