Skip to content
Merged
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ Toggle via command palette (`Ctrl+p` -> `Toggle vim mode`).

**Editing**

`i` `I` `a` `A` `o` `O` `R` `r` `x` `~` `dd` `dw` `db` `d%` `d}` `d{` `cc` `cw` `cb` `C` `c%` `c}` `c{` `S` `J`
`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` `J`

**yank / put / undo / repeat**

Expand Down
70 changes: 70 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 @@ -16,6 +16,7 @@ import {
deleteUnderCursor,
findChar,
findCharInLine,
findCharTargetInLine,
firstNonWhitespace,
getLineColumn,
insertLineStart,
Expand Down Expand Up @@ -75,6 +76,7 @@ export type VimEvent = {
}

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

export function createVimHandler(input: {
enabled: Accessor<boolean>
Expand Down Expand Up @@ -117,6 +119,7 @@ export function createVimHandler(input: {
setRegister?: (register: VimRegister, notify?: boolean) => void
}) {
let wantedColumn: VimWantedColumn | undefined
let pendingOperatorFind: { operation: VimOperator; find: VimFindOperator } | undefined

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

function findOperation(char: string, forward: boolean, till: boolean) {
const textarea = input.textarea()
const start = textarea.cursorOffset
const lineStart = textarea.plainText.lastIndexOf("\n", start - 1) + 1
const lineEnd = textarea.plainText.indexOf("\n", start)
const target = findCharTargetInLine(
textarea.plainText.slice(lineStart, lineEnd === -1 ? textarea.plainText.length : lineEnd),
start - lineStart,
char,
forward,
)
if (target === null) return charwiseOperation(null)

const offset = lineStart + target
if (forward) {
const spanEnd = till ? offset : offset + 1
return charwiseOperation(spanEnd > start ? { start, end: spanEnd } : null)
}

const spanStart = till ? offset + 1 : offset
return charwiseOperation(spanStart < start ? { start: spanStart, end: start } : null)
}

function startOperatorFind(event: VimEvent, operation: VimOperator, find: VimFindOperator) {
pendingOperatorFind = { operation, find }
input.state.setPending(find, operation + find)
event.preventDefault()
return true
}

function operatorFind(event: VimEvent, key: string, operation: VimOperator) {
if (key === "f" && !event.shift && !hasModifier(event)) return startOperatorFind(event, operation, "f")
if (isShifted(event, "f") && !hasModifier(event)) return startOperatorFind(event, operation, "F")
if (key === "t" && !event.shift && !hasModifier(event)) return startOperatorFind(event, operation, "t")
if (isShifted(event, "t") && !hasModifier(event)) return startOperatorFind(event, operation, "T")
return false
}

function pendingFindOperator(event: VimEvent): boolean {
if (!pendingOperatorFind) return false
if (input.state.pending() !== pendingOperatorFind.find) {
pendingOperatorFind = undefined
return false
}
if (isPrintable(event) && !hasModifier(event)) {
const forward = pendingOperatorFind.find === "f" || pendingOperatorFind.find === "t"
const till = pendingOperatorFind.find === "t" || pendingOperatorFind.find === "T"
const char = value(event)
const operation = pendingOperatorFind.operation
pendingOperatorFind = undefined
applyOperatorResult(() => findOperation(char, forward, till), operation)
input.state.setLastFind({ char, forward, till })
event.preventDefault()
return true
}
pendingOperatorFind = undefined
input.state.clearPending()
event.preventDefault()
return true
}

function undo() {
if (!tracked()) return false
const next = input.state.undo(snapshot())
Expand Down Expand Up @@ -373,6 +437,8 @@ export function createVimHandler(input: {
return true
}

if (pendingFindOperator(event)) return true

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

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

input.state.clearPending()
}

Expand Down Expand Up @@ -653,6 +721,8 @@ export function createVimHandler(input: {
return true
}

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

input.state.clearPending()
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ export function useVimIndicator(input: {
return createMemo(() => {
if (!input.enabled() || !input.active()) return
const key = input.state.pending()
if (key && key !== "w") return key + ".."
if (key && key !== "w") return (input.state.pendingDisplay() || key) + ".."
if (input.state.isCopy()) {
if (input.copyVisual?.() === "char") return "-- V-COPY --"
if (input.copyVisual?.() === "line") return "-- VL-COPY --"
Expand Down
55 changes: 26 additions & 29 deletions packages/opencode/src/cli/cmd/tui/component/vim/vim-motions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -461,6 +461,19 @@ export function firstNonWhitespace(text: string, offset: number) {
return pos
}

export function findCharTargetInLine(text: string, offset: number, char: string, forward: boolean, skip = 1) {
if (forward) {
for (let i = offset + skip; i < text.length; i++) {
if (text[i] === char) return i
}
return null
}
for (let i = offset - skip; i >= 0; i--) {
if (text[i] === char) return i
}
return null
}

export function findCharInLine(
text: string,
offset: number,
Expand All @@ -469,17 +482,9 @@ export function findCharInLine(
till = false,
repeat = false,
) {
const skip = till && repeat ? 2 : 1
if (forward) {
for (let i = offset + skip; i < text.length; i++) {
if (text[i] === char) return till ? i - 1 : i
}
} else {
for (let i = offset - skip; i >= 0; i--) {
if (text[i] === char) return till ? i + 1 : i
}
}
return offset
const target = findCharTargetInLine(text, offset, char, forward, till && repeat ? 2 : 1)
if (target === null) return offset
return till ? target + (forward ? -1 : 1) : target
}

export function copyWordNext(rows: VimCopyRow[], get: (idx: number) => string, idx: number, col: number, big: boolean) {
Expand Down Expand Up @@ -714,24 +719,16 @@ export function deleteSpan(textarea: TextareaRenderable, span: VimSpan | null):
export function findChar(textarea: TextareaRenderable, char: string, forward: boolean, till = false, repeat = false) {
const text = textarea.plainText
const offset = textarea.cursorOffset
const skip = till && repeat ? 2 : 1
if (forward) {
const end = lineEnd(text, offset)
for (let i = offset + skip; i < end; i++) {
if (text[i] === char) {
textarea.cursorOffset = till ? i - 1 : i
return
}
}
} else {
const start = lineStart(text, offset)
for (let i = offset - skip; i >= start; i--) {
if (text[i] === char) {
textarea.cursorOffset = till ? i + 1 : i
return
}
}
}
const start = lineStart(text, offset)
const target = findCharTargetInLine(
text.slice(start, lineEnd(text, offset)),
offset - start,
char,
forward,
till && repeat ? 2 : 1,
)
if (target === null) return
textarea.cursorOffset = start + target + (till ? (forward ? -1 : 1) : 0)
}

export function joinLines(textarea: TextareaRenderable) {
Expand Down
12 changes: 10 additions & 2 deletions packages/opencode/src/cli/cmd/tui/component/vim/vim-state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,8 @@ type VimHistory = {

export function createVimState(input: { enabled: Accessor<boolean>; initial?: Accessor<VimMode | undefined> }) {
const [mode, setMode] = createSignal<VimMode>(input.initial?.() ?? "insert")
const [pending, setPending] = createSignal<VimPending>("")
const [pending, setPendingValue] = createSignal<VimPending>("")
const [pendingDisplay, setPendingDisplay] = createSignal("")
const [lastFind, setLastFind] = createSignal<VimFind>(null)
const [register, setRegister] = createSignal<VimRegister>(null)
const [anchor, setAnchor] = createSignal<number | null>(null)
Expand All @@ -29,8 +30,14 @@ export function createVimState(input: { enabled: Accessor<boolean>; initial?: Ac
const [exitScrollToBottom, setExitScrollToBottom] = createSignal(true)
const cancelEditCallbacks = new Set<() => void>()

function setPending(next: VimPending, display = "") {
setPendingValue(next)
setPendingDisplay(display)
}

function clearPending() {
if (pending()) setPending("")
if (pending()) setPendingValue("")
if (pendingDisplay()) setPendingDisplay("")
}

function clearEdit() {
Expand Down Expand Up @@ -81,6 +88,7 @@ export function createVimState(input: { enabled: Accessor<boolean>; initial?: Ac
mode,
setMode: changeMode,
pending,
pendingDisplay,
setPending,
clearPending,
lastFind,
Expand Down
7 changes: 6 additions & 1 deletion packages/opencode/test/cli/tui/vim-indicator.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ function label(opts?: {
active?: boolean
mode?: VimMode
pending?: VimPending
pendingDisplay?: string
copy?: undefined | "char" | "line"
}) {
return createRoot((dispose) => {
Expand All @@ -20,7 +21,7 @@ function label(opts?: {
})

if (opts?.mode && opts.mode !== "normal") state.setMode(opts.mode)
if (opts?.pending) state.setPending(opts.pending)
if (opts?.pending) state.setPending(opts.pending, opts.pendingDisplay)

const result = useVimIndicator({
enabled,
Expand All @@ -44,6 +45,10 @@ describe("vim indicator", () => {
expect(label({ mode: "copy", pending: "z" })).toBe("z..")
})

test("shows pending display when present", () => {
expect(label({ pending: "f", pendingDisplay: "df" })).toBe("df..")
})

test("shows copy label when no key is pending", () => {
expect(label({ mode: "copy" })).toBe("COPY")
})
Expand Down
Loading