diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/empty-selection.ts b/packages/opencode/src/cli/cmd/tui/component/prompt/empty-selection.ts new file mode 100644 index 000000000000..1721a7ebb1ad --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/empty-selection.ts @@ -0,0 +1,20 @@ +export function emptyRows( + text: string, + sel: { start: number; end: number } | null, + info: { lineSources: number[]; lineWidthCols: number[] }, + scroll: number, + height: number, +) { + if (!sel) return [] + const lines = [0] + for (let i = 0; i < text.length; i++) { + if (text[i] === "\n") lines.push(i + 1) + } + const end = Math.min(scroll + height, info.lineSources.length) + return info.lineSources.slice(scroll, end).flatMap((line, row) => { + if (info.lineWidthCols[scroll + row] !== 0) return [] + const start = lines[line] + if (start === undefined) return [] + return sel.start <= start && start < sel.end ? [row] : [] + }) +} 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 e8d071e45e9e..6b058eb23d00 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx @@ -3,6 +3,7 @@ import { TextareaRenderable, MouseEvent, PasteEvent, + RGBA, TextAttributes, decodePasteBytes, t, @@ -51,6 +52,7 @@ import { createVimHandler } from "../vim/vim-handler" import { clearSelection } from "../vim/vim-motions" import { vimScroll } from "../vim/vim-scroll" import { useVimIndicator } from "../vim/vim-indicator" +import { emptyRows } from "./empty-selection" export type PromptProps = { sessionID?: string @@ -100,6 +102,7 @@ export type PromptRef = { const PLACEHOLDERS = ["Fix a TODO in the codebase", "What is the tech stack of this project?", "Fix broken tests"] const SHELL_PLACEHOLDERS = ["ls -la", "git status", "pwd"] let lastVimMode: VimMode = "insert" +const EMPTY_RENDER = "__vim_empty_render" function randomIndex(count: number) { if (count <= 0) return 0 @@ -1272,6 +1275,29 @@ export function Prompt(props: PromptProps) { if (promptPartTypeId === 0) { promptPartTypeId = input.extmarks.registerType("prompt-part") } + if (!(input as any)[EMPTY_RENDER]) { + ;(input as any)[EMPTY_RENDER] = true + const render = input.render.bind(input) + input.render = (buffer, deltaTime) => { + render(buffer, deltaTime) + if (!vimState.isVisual()) return + const rows = emptyRows( + input.plainText, + input.editorView.getSelection(), + input.lineInfo, + 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) + }) + } + } props.ref?.(ref) setTimeout(() => { // setTimeout is a workaround and needs to be addressed properly 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 c10b8f822798..e66c3abd8b2a 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 @@ -136,11 +136,12 @@ export function wordEnd(text: string, offset: number, big: boolean) { function deleteOffsets(textarea: TextareaRenderable, startOffset: number, endOffset: number) { if (endOffset <= startOffset) return - textarea.cursorOffset = startOffset - const start = textarea.logicalCursor - textarea.cursorOffset = endOffset - const end = textarea.logicalCursor - textarea.deleteRange(start.row, start.col, end.row, end.col) + const end = Math.min(endOffset, textarea.plainText.length) + if (end <= startOffset) return + const start = textarea.editBuffer.offsetToPosition(startOffset) + const pos = textarea.editBuffer.offsetToPosition(end) + if (!start || !pos) return + textarea.deleteRange(start.row, start.col, pos.row, pos.col) textarea.cursorOffset = startOffset } @@ -456,8 +457,6 @@ export function clearSelection(textarea: TextareaRenderable) { } function selectionRange(textarea: TextareaRenderable, anchor?: number, linewise = false) { - const sel = textarea.editorView.getSelection() - if (sel) return sel if (anchor === undefined) return null let start = Math.min(anchor, textarea.cursorOffset) let end = Math.max(anchor + 1, textarea.cursorOffset + 1) @@ -489,10 +488,6 @@ export function deleteSelection(textarea: TextareaRenderable, linewise = false, } } - // clear editor selection before manual delete - if (textarea.editorView.getSelection()) { - textarea.editorView.resetSelection() - } deleteOffsets(textarea, start, end) const after = textarea.plainText diff --git a/packages/opencode/test/cli/tui/prompt-empty-selection.test.ts b/packages/opencode/test/cli/tui/prompt-empty-selection.test.ts new file mode 100644 index 000000000000..38ceea3129ab --- /dev/null +++ b/packages/opencode/test/cli/tui/prompt-empty-selection.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, test } from "bun:test" +import { emptyRows } from "../../../src/cli/cmd/tui/component/prompt/empty-selection" + +describe("prompt empty selection", () => { + test("returns selected empty rows in viewport", () => { + expect( + emptyRows( + "one\n\nthree", + { start: 0, end: 5 }, + { + lineSources: [0, 1, 2], + lineWidthCols: [3, 0, 5], + }, + 0, + 3, + ), + ).toEqual([1]) + }) + + test("ignores empty rows outside the selection", () => { + expect( + emptyRows( + "one\n\nthree", + { start: 0, end: 4 }, + { + lineSources: [0, 1, 2], + lineWidthCols: [3, 0, 5], + }, + 0, + 3, + ), + ).toEqual([]) + }) + + test("maps rows relative to scroll", () => { + expect( + emptyRows( + "one\n\nthree\n\nfive", + { start: 0, end: 11 }, + { + lineSources: [0, 1, 2, 3, 4], + lineWidthCols: [3, 0, 5, 0, 4], + }, + 1, + 3, + ), + ).toEqual([0]) + }) +}) diff --git a/packages/opencode/test/cli/tui/vim-motions.test.ts b/packages/opencode/test/cli/tui/vim-motions.test.ts index 803c2df969e6..7c636100c21c 100644 --- a/packages/opencode/test/cli/tui/vim-motions.test.ts +++ b/packages/opencode/test/cli/tui/vim-motions.test.ts @@ -6,7 +6,7 @@ import { createVimState } from "../../../src/cli/cmd/tui/component/vim/vim-state import type { VimScroll } from "../../../src/cli/cmd/tui/component/vim/vim-scroll" import { vimScroll } from "../../../src/cli/cmd/tui/component/vim/vim-scroll" import type { VimJump } from "../../../src/cli/cmd/tui/component/vim/vim-motion-jump" -import { copyWordNext, copyWordPrev } from "../../../src/cli/cmd/tui/component/vim/vim-motions" +import { copyWordNext, copyWordPrev, deleteSelection } from "../../../src/cli/cmd/tui/component/vim/vim-motions" function rowColToOffset(text: string, row: number, col: number) { let index = 0 @@ -37,7 +37,7 @@ function offsetToRowCol(text: string, offset: number) { return { row, col } } -function createTextarea(text: string) { +function createTextarea(text: string, opts?: { strict?: boolean }) { let sel: { start: number; end: number } | null = null let anchor: number | null = null const textarea = { @@ -79,6 +79,7 @@ function createTextarea(text: string) { sel = null anchor = null }, + resetLocalSelection() {}, getSelection() { return sel }, @@ -96,6 +97,12 @@ function createTextarea(text: string) { sel = null }, }, + editBuffer: { + offsetToPosition(offset: number) { + if (opts?.strict && offset > textarea.plainText.length) return null + return offsetToRowCol(textarea.plainText, offset) + }, + }, } return textarea as unknown as TextareaRenderable } @@ -122,6 +129,7 @@ function createHandler( options?: { enabled?: boolean mode?: "normal" | "insert" | "replace" | "visual" | "visual-line" | "copy" + strict?: boolean submit?: () => void autocomplete?: () => false | "@" | "/" flash?: (span: { start: number; end: number }) => void @@ -135,7 +143,7 @@ function createHandler( } }, ) { - const textarea = createTextarea(text) + const textarea = createTextarea(text, { strict: options?.strict }) const [enabled] = createSignal(options?.enabled ?? true) const [mode, setMode] = createSignal<"normal" | "insert" | "replace" | "visual" | "visual-line" | "copy">( options?.mode ?? "normal", @@ -1918,6 +1926,53 @@ describe("vim motion handler", () => { expect((ctx.textarea as any).editorView.getSelection()).toEqual({ start: 3, end: 6 }) }) + test("visual d deletes backward selection", () => { + const ctx = createHandler("abcdef") + ctx.textarea.cursorOffset = 4 + + ctx.handler.handleKey(createEvent("v").event) + ctx.handler.handleKey(createEvent("h").event) + ctx.handler.handleKey(createEvent("h").event) + + ctx.handler.handleKey(createEvent("d").event) + expect(ctx.textarea.plainText).toBe("abf") + expect(ctx.state.register()).toEqual({ text: "cde", linewise: false }) + expect(ctx.state.mode()).toBe("normal") + }) + + test("visual w d deletes selection through end of text", () => { + const ctx = createHandler("hello world", { strict: true }) + + ctx.handler.handleKey(createEvent("v").event) + ctx.handler.handleKey(createEvent("w").event) + ctx.handler.handleKey(createEvent("d").event) + + expect(ctx.textarea.plainText).toBe("orld") + expect(ctx.state.register()).toEqual({ text: "hello w", linewise: false }) + expect(ctx.state.mode()).toBe("normal") + }) + + test("deleteSelection uses anchor range for charwise delete", () => { + const textarea = createTextarea("word1 word2 word3") + textarea.cursorOffset = 10 + + const reg = deleteSelection(textarea, false, 6) + + expect(textarea.plainText).toBe("word1 word3") + expect(reg).toEqual({ text: "word2", linewise: false }) + }) + + test("deleteSelection ignores stale editor selection", () => { + const textarea = createTextarea("word1 word2 word3") + ;(textarea as any).editorView.setSelection(0, 5) + textarea.cursorOffset = 10 + + const reg = deleteSelection(textarea, false, 6) + + expect(textarea.plainText).toBe("word1 word3") + expect(reg).toEqual({ text: "word2", linewise: false }) + }) + test("visual mode $ selects to end of line", () => { const ctx = createHandler("hello world") ctx.textarea.cursorOffset = 6