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
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,11 +63,11 @@ Toggle via command palette (`Ctrl+p` -> `Toggle vim mode`).

**Editing**

`i` `I` `a` `A` `o` `O` `R` `r` `x` `~` `dd` `dw` `db` `diw` `daw` `df` `dF` `dt` `dT` `d%` `d}` `d{` `cc` `cw` `cb` `ciw` `caw` `cf` `cF` `ct` `cT` `C` `c%` `c}` `c{` `s` `S` `J`
`i` `I` `a` `A` `o` `O` `R` `r` `x` `~` `dd` `dw` `db` `diw` `daw` `diW` `daW` `df` `dF` `dt` `dT` `d%` `d}` `d{` `cc` `cw` `cb` `ciw` `caw` `ciW` `caW` `cf` `cF` `ct` `cT` `C` `c%` `c}` `c{` `s` `S` `J`

**yank / put / undo / repeat**

`yy` `yw` `yiw` `yaw` `y%` `y}` `y{` `p` `P` `u` `ctrl+r` `.`
`yy` `yw` `yiw` `yaw` `yiW` `yaW` `y%` `y}` `y{` `p` `P` `u` `ctrl+r` `.`

- Copy the current prompt selection with `<leader>y` (default: `ctrl+x` then `y`).
- Configure it with `keybinds.prompt_copy_selection`.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -386,8 +386,9 @@ export function createVimHandler(input: {
}

function resolveTextObject(event: VimEvent, key: string, scope: VimTextObjectScope) {
if (key === "w" && !event.shift && !hasModifier(event)) {
return () => wordTextObjectOperation(input.textarea(), scope === "around")
if ((key === "w" || isShifted(event, "w")) && !hasModifier(event)) {
const big = isShifted(event, "w")
return () => wordTextObjectOperation(input.textarea(), scope === "around", big)
}
}

Expand Down
36 changes: 18 additions & 18 deletions packages/opencode/src/cli/cmd/tui/component/vim/vim-motions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -404,62 +404,62 @@ export function wordEnd(text: string, offset: number, big: boolean) {
return wordRunEnd(text, pos, big)
}

export function wordTextObjectOperation(textarea: TextareaRenderable, around: boolean): VimOperatorResult {
export function wordTextObjectOperation(textarea: TextareaRenderable, around: boolean, big = false): VimOperatorResult {
const text = textarea.plainText
if (!text.length) return { span: null, register: null }

const blank = wordTextObjectBlankSpan(text, textarea.cursorOffset)
const blank = wordTextObjectBlankSpan(text, textarea.cursorOffset, big)
if (blank) {
if (!around) return buildOperatorResult(text, blank, null, false)
return buildOperatorResult(text, wordTextObjectAroundBlankSpan(text, blank), null, false)
return buildOperatorResult(text, wordTextObjectAroundBlankSpan(text, blank, big), null, false)
}

const inner = wordTextObjectInnerSpan(text, textarea.cursorOffset)
const inner = wordTextObjectInnerSpan(text, textarea.cursorOffset, big)
if (!inner) return { span: null, register: null }
if (!around) return buildOperatorResult(text, inner, null, false)

let end = inner.end
while (end < text.length && text[end] !== "\n" && wordClass(text[end], false) === "blank") end++
while (end < text.length && text[end] !== "\n" && wordClass(text[end], big) === "blank") end++
if (end > inner.end) return buildOperatorResult(text, { start: inner.start, end }, null, false)

let start = inner.start
while (start > 0 && text[start - 1] !== "\n" && wordClass(text[start - 1], false) === "blank") start--
while (start > 0 && text[start - 1] !== "\n" && wordClass(text[start - 1], big) === "blank") start--
return buildOperatorResult(text, { start, end: inner.end }, null, false)
}

function wordTextObjectInnerSpan(text: string, cursor: number): VimSpan | null {
function wordTextObjectInnerSpan(text: string, cursor: number, big: boolean): VimSpan | null {
const pos = Math.min(cursor, text.length - 1)
if (text[pos] === "\n") return null
const target = wordClass(text[pos], false)
const target = wordClass(text[pos], big)
let start = pos
while (start > 0 && wordClass(text[start - 1], false) === target) start--
while (start > 0 && wordClass(text[start - 1], big) === target) start--

let end = pos + 1
while (end < text.length && wordClass(text[end], false) === target) end++
while (end < text.length && wordClass(text[end], big) === target) end++

return start < end ? { start, end } : null
}

function wordTextObjectBlankSpan(text: string, cursor: number): VimSpan | null {
function wordTextObjectBlankSpan(text: string, cursor: number, big: boolean): VimSpan | null {
let start = Math.min(cursor, text.length - 1)
if (wordClass(text[start], false) !== "blank" || text[start] === "\n") return null
while (start > 0 && text[start - 1] !== "\n" && wordClass(text[start - 1], false) === "blank") start--
if (wordClass(text[start], big) !== "blank" || text[start] === "\n") return null
while (start > 0 && text[start - 1] !== "\n" && wordClass(text[start - 1], big) === "blank") start--

let end = start
while (end < text.length && text[end] !== "\n" && wordClass(text[end], false) === "blank") end++
while (end < text.length && text[end] !== "\n" && wordClass(text[end], big) === "blank") end++

return start < end ? { start, end } : null
}

function wordTextObjectAroundBlankSpan(text: string, blank: VimSpan): VimSpan | null {
function wordTextObjectAroundBlankSpan(text: string, blank: VimSpan, big: boolean): VimSpan | null {
let end = blank.end
while (end < text.length && text[end] !== "\n" && wordClass(text[end], false) === "blank") end++
while (end < text.length && text[end] !== "\n" && wordClass(text[end], big) === "blank") end++
if (end >= text.length || text[end] === "\n") return null

const inner = wordTextObjectInnerSpan(text, end)
const inner = wordTextObjectInnerSpan(text, end, big)
if (!inner) return null
end = inner.end
while (end < text.length && text[end] !== "\n" && wordClass(text[end], false) === "blank") end++
while (end < text.length && text[end] !== "\n" && wordClass(text[end], big) === "blank") end++

return { start: blank.start, end }
}
Expand Down
92 changes: 92 additions & 0 deletions packages/opencode/test/cli/tui/vim-motions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2674,6 +2674,83 @@ describe("vim motion handler", () => {
expect(ctx.state.register()).toEqual({ text: " world", linewise: false })
})

test("diW deletes inner big word", () => {
const ctx = createHandler("foo.bar baz")
ctx.textarea.cursorOffset = 2

ctx.handler.handleKey(createEvent("d").event)
ctx.handler.handleKey(createEvent("i").event)
ctx.handler.handleKey(createEvent("W").event)

expect(ctx.textarea.plainText).toBe(" baz")
expect(ctx.textarea.cursorOffset).toBe(0)
expect(ctx.state.register()).toEqual({ text: "foo.bar", linewise: false })
})

test("ciW changes inner big word", () => {
const ctx = createHandler("foo.bar baz")
ctx.textarea.cursorOffset = 2

ctx.handler.handleKey(createEvent("c").event)
ctx.handler.handleKey(createEvent("i").event)
ctx.handler.handleKey(createEvent("W").event)

expect(ctx.textarea.plainText).toBe(" baz")
expect(ctx.textarea.cursorOffset).toBe(0)
expect(ctx.state.mode()).toBe("insert")
expect(ctx.state.register()).toEqual({ text: "foo.bar", linewise: false })
})

test("daW deletes big word and following whitespace", () => {
const ctx = createHandler("foo.bar baz")
ctx.textarea.cursorOffset = 2

ctx.handler.handleKey(createEvent("d").event)
ctx.handler.handleKey(createEvent("a").event)
ctx.handler.handleKey(createEvent("W").event)

expect(ctx.textarea.plainText).toBe("baz")
expect(ctx.textarea.cursorOffset).toBe(0)
expect(ctx.state.register()).toEqual({ text: "foo.bar ", linewise: false })
})

test("yiW yanks inner big word", () => {
const ctx = createHandler("foo.bar baz")
ctx.textarea.cursorOffset = 2

ctx.handler.handleKey(createEvent("y").event)
ctx.handler.handleKey(createEvent("i").event)
ctx.handler.handleKey(createEvent("W").event)

expect(ctx.textarea.plainText).toBe("foo.bar baz")
expect(ctx.state.register()).toEqual({ text: "foo.bar", linewise: false })
})

test("yaW yanks big word and following whitespace", () => {
const ctx = createHandler("foo.bar baz")
ctx.textarea.cursorOffset = 2

ctx.handler.handleKey(createEvent("y").event)
ctx.handler.handleKey(createEvent("a").event)
ctx.handler.handleKey(createEvent("W").event)

expect(ctx.textarea.plainText).toBe("foo.bar baz")
expect(ctx.state.register()).toEqual({ text: "foo.bar ", linewise: false })
})

test("diW handles lowercase shifted key events", () => {
const ctx = createHandler("foo.bar baz")
ctx.textarea.cursorOffset = 2

ctx.handler.handleKey(createEvent("d").event)
ctx.handler.handleKey(createEvent("i").event)
ctx.handler.handleKey(createEvent("w", { shift: true }).event)

expect(ctx.textarea.plainText).toBe(" baz")
expect(ctx.textarea.cursorOffset).toBe(0)
expect(ctx.state.register()).toEqual({ text: "foo.bar", linewise: false })
})

test("text object pending display shows operator and object scope", () => {
const ctx = createHandler("hello world")

Expand Down Expand Up @@ -5430,6 +5507,21 @@ describe("vim dot repeat", () => {
expect(ctx.textarea.plainText).toBe("hi hi")
})

test("dot repeats ciW inserted text", () => {
const ctx = createHandler("foo.bar baz.qux")

press(ctx, "c")
press(ctx, "i")
press(ctx, "W")
ctx.textarea.insertText("hi")
press(ctx, "escape")
expect(ctx.textarea.plainText).toBe("hi baz.qux")

ctx.textarea.cursorOffset = 3
press(ctx, ".")
expect(ctx.textarea.plainText).toBe("hi hi")
})

test("dot repeats s inserted text", () => {
const ctx = createHandler("abc def")

Expand Down
Loading