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` `df` `dF` `dt` `dT` `d%` `d}` `d{` `cc` `cw` `cb` `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` `df` `dF` `dt` `dT` `d%` `d}` `d{` `cc` `cw` `cb` `ciw` `caw` `cf` `cF` `ct` `cT` `C` `c%` `c}` `c{` `s` `S` `J`

**yank / put / undo / repeat**

`yy` `yw` `y%` `y}` `y{` `p` `P` `u` `ctrl+r` `.`
`yy` `yw` `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
53 changes: 53 additions & 0 deletions packages/opencode/src/cli/cmd/tui/component/vim/vim-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ import {
toggleCase,
toggleSelectionCase,
wordEnd,
wordTextObjectOperation,
yankLine,
yankLineSpan,
yankSelection,
Expand All @@ -77,6 +78,7 @@ export type VimEvent = {

export type VimCopyMove = "up" | "down" | "left" | "right"
type VimFindOperator = "f" | "F" | "t" | "T"
type VimTextObjectScope = "inner" | "around"

export function createVimHandler(input: {
enabled: Accessor<boolean>
Expand Down Expand Up @@ -120,6 +122,7 @@ export function createVimHandler(input: {
}) {
let wantedColumn: VimWantedColumn | undefined
let pendingOperatorFind: { operation: VimOperator; find: VimFindOperator } | undefined
let pendingTextObject: { operation: VimOperator; scope: VimTextObjectScope } | undefined

function hasModifier(event: VimEvent) {
return !!event.ctrl || !!event.meta || !!event.super
Expand Down Expand Up @@ -369,6 +372,46 @@ export function createVimHandler(input: {
return false
}

function startTextObject(event: VimEvent, operation: VimOperator, scope: VimTextObjectScope) {
pendingTextObject = { operation, scope }
input.state.setPending(operation, operation + (scope === "around" ? "a" : "i"))
event.preventDefault()
return true
}

function operatorTextObject(event: VimEvent, key: string, operation: VimOperator) {
if (key === "i" && !event.shift && !hasModifier(event)) return startTextObject(event, operation, "inner")
if (key === "a" && !event.shift && !hasModifier(event)) return startTextObject(event, operation, "around")
return false
}

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

function pendingTextObjectOperator(event: VimEvent, key: string): boolean {
if (!pendingTextObject) return false
if (input.state.pending() !== pendingTextObject.operation) {
pendingTextObject = undefined
return false
}

const textObject = pendingTextObject
const operation = resolveTextObject(event, key, textObject.scope)
pendingTextObject = undefined
if (operation) {
applyOperatorResult(operation, textObject.operation)
event.preventDefault()
return true
}

input.state.clearPending()
event.preventDefault()
return true
}

function pendingFindOperator(event: VimEvent): boolean {
if (!pendingOperatorFind) return false
if (input.state.pending() !== pendingOperatorFind.find) {
Expand Down Expand Up @@ -440,6 +483,7 @@ export function createVimHandler(input: {
}

if (pendingFindOperator(event)) return true
if (pendingTextObjectOperator(event, key)) return true

if (input.state.pending() === "vr" && input.state.isVisual()) {
if (hasModifier(event)) {
Expand Down Expand Up @@ -694,6 +738,8 @@ export function createVimHandler(input: {
return true
}

if (operatorTextObject(event, key, "c")) return true

if (paragraphOperator(key, "c")) {
event.preventDefault()
return true
Expand All @@ -706,6 +752,7 @@ export function createVimHandler(input: {

if (operatorFind(event, key, "c")) return true

pendingTextObject = undefined
input.state.clearPending()
}

Expand All @@ -730,6 +777,8 @@ export function createVimHandler(input: {
return true
}

if (operatorTextObject(event, key, "d")) return true

if (paragraphOperator(key, "d")) {
event.preventDefault()
return true
Expand All @@ -742,6 +791,7 @@ export function createVimHandler(input: {

if (operatorFind(event, key, "d")) return true

pendingTextObject = undefined
input.state.clearPending()
}

Expand All @@ -766,6 +816,8 @@ export function createVimHandler(input: {
return true
}

if (operatorTextObject(event, key, "y")) return true

if (paragraphOperator(key, "y")) {
event.preventDefault()
return true
Expand All @@ -776,6 +828,7 @@ export function createVimHandler(input: {
return true
}

pendingTextObject = undefined
input.state.clearPending()
}

Expand Down
60 changes: 60 additions & 0 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,6 +404,66 @@ export function wordEnd(text: string, offset: number, big: boolean) {
return wordRunEnd(text, pos, big)
}

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

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

const inner = wordTextObjectInnerSpan(text, textarea.cursorOffset)
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++
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--
return buildOperatorResult(text, { start, end: inner.end }, null, false)
}

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

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

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

function wordTextObjectBlankSpan(text: string, cursor: number): 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--

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

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

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

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

return { start: blank.start, end }
}

function deleteOffsets(textarea: TextareaRenderable, startOffset: number, endOffset: number) {
if (endOffset <= startOffset) return
const end = Math.min(endOffset, textarea.plainText.length)
Expand Down
Loading
Loading