Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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] : []
})
}
26 changes: 26 additions & 0 deletions packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {
TextareaRenderable,
MouseEvent,
PasteEvent,
RGBA,
TextAttributes,
decodePasteBytes,
t,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
17 changes: 6 additions & 11 deletions packages/opencode/src/cli/cmd/tui/component/vim/vim-motions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down
49 changes: 49 additions & 0 deletions packages/opencode/test/cli/tui/prompt-empty-selection.test.ts
Original file line number Diff line number Diff line change
@@ -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])
})
})
61 changes: 58 additions & 3 deletions packages/opencode/test/cli/tui/vim-motions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 = {
Expand Down Expand Up @@ -79,6 +79,7 @@ function createTextarea(text: string) {
sel = null
anchor = null
},
resetLocalSelection() {},
getSelection() {
return sel
},
Expand All @@ -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
}
Expand All @@ -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
Expand All @@ -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",
Expand Down Expand Up @@ -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
Expand Down
Loading