Skip to content

Commit be59d9e

Browse files
authored
fix: treat punctuation as its own word in vim motions (#81)
* w/b/W now class punctuation, words, and blanks separately so motions land on punctuation boundaries * cw/cW change to end of word like vim instead of deleting through next word start
1 parent c5ace3e commit be59d9e

3 files changed

Lines changed: 231 additions & 22 deletions

File tree

packages/opencode/src/cli/cmd/tui/component/vim/vim-handler.ts

Lines changed: 39 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -211,6 +211,12 @@ export function createVimHandler(input: {
211211
return true
212212
}
213213

214+
function changeWord(big: boolean) {
215+
const textarea = input.textarea()
216+
const char = textarea.plainText[textarea.cursorOffset]
217+
return char && !/\s/.test(char) ? deleteWordEnd(textarea, big) : deleteWord(textarea)
218+
}
219+
214220
function undo() {
215221
if (!tracked()) return false
216222
const next = input.state.undo(snapshot())
@@ -407,7 +413,18 @@ export function createVimHandler(input: {
407413

408414
if (key === "w" && !event.shift) {
409415
begin(() => {
410-
const reg = deleteWord(input.textarea())
416+
const reg = changeWord(false)
417+
if (reg) setRegister(reg)
418+
input.state.clearPending()
419+
input.state.setMode("insert")
420+
})
421+
event.preventDefault()
422+
return true
423+
}
424+
425+
if (isShifted(event, "w") && !hasModifier(event)) {
426+
begin(() => {
427+
const reg = changeWord(true)
411428
if (reg) setRegister(reg)
412429
input.state.clearPending()
413430
input.state.setMode("insert")
@@ -473,7 +490,17 @@ export function createVimHandler(input: {
473490
return true
474491
}
475492

476-
if (key === "b" && !event.shift) {
493+
if (isShifted(event, "w") && !hasModifier(event)) {
494+
edit(() => {
495+
const reg = deleteWord(input.textarea(), true)
496+
if (reg) setRegister(reg)
497+
input.state.clearPending()
498+
})
499+
event.preventDefault()
500+
return true
501+
}
502+
503+
if (key === "b" && !event.shift && !hasModifier(event)) {
477504
edit(() => {
478505
const reg = deleteWordBackward(input.textarea())
479506
if (reg) setRegister(reg)
@@ -528,6 +555,16 @@ export function createVimHandler(input: {
528555
return true
529556
}
530557

558+
if (isShifted(event, "w") && !hasModifier(event)) {
559+
const span = yankWordSpan(input.textarea(), true)
560+
const reg = yankWord(input.textarea(), true)
561+
if (reg) setRegister(reg, true)
562+
if (span && span.end > span.start) input.flash?.(span)
563+
input.state.clearPending()
564+
event.preventDefault()
565+
return true
566+
}
567+
531568
if ((key === "e" || key === "E") && !hasModifier(event)) {
532569
const big = key === "E" || !!event.shift
533570
const span = yankWordEndSpan(input.textarea(), big)

packages/opencode/src/cli/cmd/tui/component/vim/vim-motions.ts

Lines changed: 22 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -282,20 +282,28 @@ export function isBigWord(char: string) {
282282
}
283283

284284
export function nextWordStart(text: string, offset: number, big: boolean) {
285-
const match = big ? isBigWord : isWord
286285
let pos = offset
287-
if (pos < text.length && match(text[pos])) {
288-
while (pos < text.length && match(text[pos])) pos++
286+
if (pos >= text.length) return text.length
287+
288+
const startClass = wordClass(text[pos], big)
289+
if (startClass !== "blank") {
290+
while (pos < text.length && wordClass(text[pos], big) === startClass) pos++
289291
}
290-
while (pos < text.length && !match(text[pos])) pos++
292+
293+
while (pos < text.length && wordClass(text[pos], big) === "blank") pos++
291294
return pos
292295
}
293296

294297
export function prevWordStart(text: string, offset: number, big: boolean) {
295-
const match = big ? isBigWord : isWord
296-
let pos = offset
297-
while (pos > 0 && !match(text[pos - 1])) pos--
298-
while (pos > 0 && match(text[pos - 1])) pos--
298+
let pos = Math.min(offset, text.length)
299+
if (pos <= 0) return 0
300+
pos--
301+
302+
while (pos > 0 && wordClass(text[pos], big) === "blank") pos--
303+
304+
const target = wordClass(text[pos], big)
305+
while (pos > 0 && wordClass(text[pos - 1], big) === target) pos--
306+
299307
return pos
300308
}
301309

@@ -518,10 +526,10 @@ export function deleteUnderCursor(textarea: TextareaRenderable): VimRegister {
518526
return { text: yanked, linewise: false }
519527
}
520528

521-
export function deleteWord(textarea: TextareaRenderable): VimRegister {
529+
export function deleteWord(textarea: TextareaRenderable, big = false): VimRegister {
522530
const text = textarea.plainText
523531
const startOffset = textarea.cursorOffset
524-
const endOffset = nextWordStart(text, startOffset, false)
532+
const endOffset = nextWordStart(text, startOffset, big)
525533
if (endOffset <= startOffset) return null
526534
const yanked = text.slice(startOffset, endOffset)
527535
deleteOffsets(textarea, startOffset, endOffset)
@@ -688,16 +696,16 @@ export function yankLineSpan(textarea: TextareaRenderable): VimSpan {
688696
return { start, end }
689697
}
690698

691-
export function yankWord(textarea: TextareaRenderable): VimRegister {
692-
const span = yankWordSpan(textarea)
699+
export function yankWord(textarea: TextareaRenderable, big = false): VimRegister {
700+
const span = yankWordSpan(textarea, big)
693701
if (!span) return null
694702
return { text: textarea.plainText.slice(span.start, span.end), linewise: false }
695703
}
696704

697-
export function yankWordSpan(textarea: TextareaRenderable): VimSpan | null {
705+
export function yankWordSpan(textarea: TextareaRenderable, big = false): VimSpan | null {
698706
const text = textarea.plainText
699707
const start = textarea.cursorOffset
700-
const end = nextWordStart(text, start, false)
708+
const end = nextWordStart(text, start, big)
701709
if (end <= start) return null
702710
return { start, end }
703711
}

packages/opencode/test/cli/tui/vim-motions.test.ts

Lines changed: 170 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -473,7 +473,7 @@ describe("vim motion handler", () => {
473473
const w = createEvent("w")
474474
expect(ctx.handler.handleKey(w.event)).toBe(true)
475475
expect(w.prevented()).toBe(true)
476-
expect(ctx.textarea.cursorOffset).toBe(4)
476+
expect(ctx.textarea.cursorOffset).toBe(3)
477477

478478
const upperW = createEvent("W")
479479
expect(ctx.handler.handleKey(upperW.event)).toBe(true)
@@ -574,11 +574,36 @@ describe("vim motion handler", () => {
574574
expect(ctx.textarea.cursorOffset).toBe(0)
575575
})
576576

577-
test("b skips punctuation to previous word", () => {
577+
test("b treats punctuation as its own word", () => {
578578
const ctx = createHandler("foo,bar")
579579
ctx.textarea.cursorOffset = 4
580+
580581
ctx.handler.handleKey(createEvent("b").event)
581-
expect(ctx.textarea.cursorOffset).toBe(0)
582+
expect(ctx.textarea.cursorOffset).toBe(3)
583+
})
584+
585+
test("w lands on trailing punctuation", () => {
586+
const ctx = createHandler("changed?")
587+
ctx.textarea.cursorOffset = 0
588+
589+
ctx.handler.handleKey(createEvent("w").event)
590+
expect(ctx.textarea.cursorOffset).toBe(7)
591+
})
592+
593+
test("w advances from punctuation to next word", () => {
594+
const ctx = createHandler("changed? next")
595+
ctx.textarea.cursorOffset = 7
596+
597+
ctx.handler.handleKey(createEvent("w").event)
598+
expect(ctx.textarea.cursorOffset).toBe(9)
599+
})
600+
601+
test("W treats punctuation as part of big word", () => {
602+
const ctx = createHandler("changed? next")
603+
ctx.textarea.cursorOffset = 0
604+
605+
ctx.handler.handleKey(createEvent("W").event)
606+
expect(ctx.textarea.cursorOffset).toBe(9)
582607
})
583608

584609
test("0 moves to line beginning", () => {
@@ -1258,7 +1283,7 @@ describe("vim motion handler", () => {
12581283
expect(ctx.state.pending()).toBe("")
12591284
})
12601285

1261-
test("cw deletes to next word and enters insert", () => {
1286+
test("cw changes to end of word and enters insert", () => {
12621287
const ctx = createHandler("hello world test")
12631288
ctx.textarea.cursorOffset = 0
12641289

@@ -1269,10 +1294,73 @@ describe("vim motion handler", () => {
12691294
const w = createEvent("w")
12701295
expect(ctx.handler.handleKey(w.event)).toBe(true)
12711296
expect(w.prevented()).toBe(true)
1272-
expect(ctx.textarea.plainText).toBe("world test")
1297+
expect(ctx.textarea.plainText).toBe(" world test")
12731298
expect(ctx.textarea.cursorOffset).toBe(0)
12741299
expect(ctx.state.mode()).toBe("insert")
12751300
expect(ctx.state.pending()).toBe("")
1301+
expect(ctx.state.register()).toEqual({ text: "hello", linewise: false })
1302+
})
1303+
1304+
test("cw from mid-word changes to end of word", () => {
1305+
const ctx = createHandler("hello world")
1306+
ctx.textarea.cursorOffset = 2
1307+
1308+
ctx.handler.handleKey(createEvent("c").event)
1309+
ctx.handler.handleKey(createEvent("w").event)
1310+
expect(ctx.textarea.plainText).toBe("he world")
1311+
expect(ctx.textarea.cursorOffset).toBe(2)
1312+
expect(ctx.state.mode()).toBe("insert")
1313+
expect(ctx.state.register()).toEqual({ text: "llo", linewise: false })
1314+
})
1315+
1316+
test("cw on punctuation changes punctuation word", () => {
1317+
const ctx = createHandler("foo!!!bar")
1318+
ctx.textarea.cursorOffset = 3
1319+
1320+
ctx.handler.handleKey(createEvent("c").event)
1321+
ctx.handler.handleKey(createEvent("w").event)
1322+
expect(ctx.textarea.plainText).toBe("foobar")
1323+
expect(ctx.textarea.cursorOffset).toBe(3)
1324+
expect(ctx.state.mode()).toBe("insert")
1325+
expect(ctx.state.register()).toEqual({ text: "!!!", linewise: false })
1326+
})
1327+
1328+
test("cw from whitespace changes through next word start", () => {
1329+
const ctx = createHandler("hello world")
1330+
ctx.textarea.cursorOffset = 5
1331+
1332+
ctx.handler.handleKey(createEvent("c").event)
1333+
ctx.handler.handleKey(createEvent("w").event)
1334+
expect(ctx.textarea.plainText).toBe("helloworld")
1335+
expect(ctx.textarea.cursorOffset).toBe(5)
1336+
expect(ctx.state.mode()).toBe("insert")
1337+
expect(ctx.state.register()).toEqual({ text: " ", linewise: false })
1338+
})
1339+
1340+
test("cW changes through end of big word and enters insert", () => {
1341+
const ctx = createHandler("foo.bar baz")
1342+
ctx.textarea.cursorOffset = 0
1343+
1344+
ctx.handler.handleKey(createEvent("c").event)
1345+
const w = createEvent("W")
1346+
expect(ctx.handler.handleKey(w.event)).toBe(true)
1347+
expect(w.prevented()).toBe(true)
1348+
expect(ctx.textarea.plainText).toBe(" baz")
1349+
expect(ctx.textarea.cursorOffset).toBe(0)
1350+
expect(ctx.state.mode()).toBe("insert")
1351+
expect(ctx.state.register()).toEqual({ text: "foo.bar", linewise: false })
1352+
})
1353+
1354+
test("cW from whitespace changes through next big word start", () => {
1355+
const ctx = createHandler("foo.bar baz")
1356+
ctx.textarea.cursorOffset = 7
1357+
1358+
ctx.handler.handleKey(createEvent("c").event)
1359+
ctx.handler.handleKey(createEvent("W").event)
1360+
expect(ctx.textarea.plainText).toBe("foo.barbaz")
1361+
expect(ctx.textarea.cursorOffset).toBe(7)
1362+
expect(ctx.state.mode()).toBe("insert")
1363+
expect(ctx.state.register()).toEqual({ text: " ", linewise: false })
12761364
})
12771365

12781366
test("pending c clears on escape", () => {
@@ -1630,6 +1718,42 @@ describe("vim motion handler", () => {
16301718
expect(ctx.textarea.cursorOffset).toBe(0)
16311719
})
16321720

1721+
test("dw stops before trailing punctuation", () => {
1722+
const ctx = createHandler("changed?")
1723+
ctx.textarea.cursorOffset = 0
1724+
1725+
ctx.handler.handleKey(createEvent("d").event)
1726+
ctx.handler.handleKey(createEvent("w").event)
1727+
expect(ctx.textarea.plainText).toBe("?")
1728+
expect(ctx.textarea.cursorOffset).toBe(0)
1729+
expect(ctx.state.register()).toEqual({ text: "changed", linewise: false })
1730+
})
1731+
1732+
test("dW deletes through next big word start", () => {
1733+
const ctx = createHandler("foo.bar baz")
1734+
ctx.textarea.cursorOffset = 0
1735+
1736+
ctx.handler.handleKey(createEvent("d").event)
1737+
const w = createEvent("W")
1738+
expect(ctx.handler.handleKey(w.event)).toBe(true)
1739+
expect(w.prevented()).toBe(true)
1740+
expect(ctx.textarea.plainText).toBe("baz")
1741+
expect(ctx.textarea.cursorOffset).toBe(0)
1742+
expect(ctx.state.pending()).toBe("")
1743+
expect(ctx.state.register()).toEqual({ text: "foo.bar ", linewise: false })
1744+
})
1745+
1746+
test("dW at final big word deletes to end", () => {
1747+
const ctx = createHandler("foo.bar")
1748+
ctx.textarea.cursorOffset = 0
1749+
1750+
ctx.handler.handleKey(createEvent("d").event)
1751+
ctx.handler.handleKey(createEvent("W").event)
1752+
expect(ctx.textarea.plainText).toBe("")
1753+
expect(ctx.textarea.cursorOffset).toBe(0)
1754+
expect(ctx.state.register()).toEqual({ text: "foo.bar", linewise: false })
1755+
})
1756+
16331757
test("db deletes to current word start", () => {
16341758
const ctx = createHandler("hello world test")
16351759
ctx.textarea.cursorOffset = 8
@@ -2245,6 +2369,46 @@ describe("vim motion handler", () => {
22452369
expect(ctx.textarea.plainText).toBe("hello world")
22462370
})
22472371

2372+
test("yw stops before trailing punctuation", () => {
2373+
const ctx = createHandler("changed?")
2374+
ctx.textarea.cursorOffset = 0
2375+
2376+
ctx.handler.handleKey(createEvent("y").event)
2377+
ctx.handler.handleKey(createEvent("w").event)
2378+
expect(ctx.state.register()).toEqual({ text: "changed", linewise: false })
2379+
expect(ctx.textarea.cursorOffset).toBe(0)
2380+
expect(ctx.textarea.plainText).toBe("changed?")
2381+
})
2382+
2383+
test("yW yanks through next big word start", () => {
2384+
const ctx = createHandler("foo.bar baz")
2385+
ctx.textarea.cursorOffset = 0
2386+
2387+
ctx.handler.handleKey(createEvent("y").event)
2388+
const w = createEvent("W")
2389+
expect(ctx.handler.handleKey(w.event)).toBe(true)
2390+
expect(w.prevented()).toBe(true)
2391+
expect(ctx.state.register()).toEqual({ text: "foo.bar ", linewise: false })
2392+
expect(ctx.textarea.cursorOffset).toBe(0)
2393+
expect(ctx.textarea.plainText).toBe("foo.bar baz")
2394+
expect(ctx.state.pending()).toBe("")
2395+
})
2396+
2397+
test("yW flashes yanked big word span", () => {
2398+
const spans: Array<{ start: number; end: number }> = []
2399+
const ctx = createHandler("foo.bar baz", {
2400+
flash(span) {
2401+
spans.push(span)
2402+
},
2403+
})
2404+
ctx.textarea.cursorOffset = 0
2405+
2406+
ctx.handler.handleKey(createEvent("y").event)
2407+
ctx.handler.handleKey(createEvent("W").event)
2408+
2409+
expect(spans).toEqual([{ start: 0, end: 8 }])
2410+
})
2411+
22482412
test("yw flashes yanked word span", () => {
22492413
const spans: Array<{ start: number; end: number }> = []
22502414
const ctx = createHandler("hello world", {
@@ -3821,7 +3985,7 @@ describe("vim undo redo", () => {
38213985
ctx.textarea.insertText("hi")
38223986
ctx.handler.handleKey(createEvent("escape").event)
38233987

3824-
expect(ctx.textarea.plainText).toBe("hiworld")
3988+
expect(ctx.textarea.plainText).toBe("hi world")
38253989

38263990
ctx.handler.handleKey(createEvent("u").event)
38273991
expect(ctx.textarea.plainText).toBe("hello world")

0 commit comments

Comments
 (0)