Skip to content

Commit d16760f

Browse files
authored
fix: use copy row offsets for word motions (#91)
Co-authored-by: leohenon <[email protected]>
1 parent 1c43e4b commit d16760f

2 files changed

Lines changed: 89 additions & 3 deletions

File tree

packages/opencode/src/cli/cmd/tui/routes/session/copy-mode.ts

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -369,12 +369,17 @@ export function createCopyMode(input: {
369369
setState((prev) => ({ ...prev, col: c, stick: c - min }))
370370
}
371371

372+
function wordRows(list: CopyRow[], cache: Map<string, any>) {
373+
return list.map((row) => ({ col: copyMin(row, cache) }))
374+
}
375+
372376
function wordNext(big: boolean) {
373377
const s = state()
374378
if (!s.active) return false
375379
const list = rows()
376380
if (!list.length) return false
377-
const next = copyWordNext(list, (idx) => rowText(list[idx]!), s.idx, s.col, big)
381+
const cache = new Map(input.scroll().getChildren().map((c) => [c.id, c]))
382+
const next = copyWordNext(wordRows(list, cache), (idx) => rowText(list[idx]!, cache), s.idx, s.col, big)
378383
if (next.idx === s.idx && next.col === s.col) return false
379384
if (next.idx !== s.idx) sync(next.idx)
380385
setCol(next.col)
@@ -386,7 +391,8 @@ export function createCopyMode(input: {
386391
if (!s.active) return false
387392
const list = rows()
388393
if (!list.length) return false
389-
const prev = copyWordPrev(list, (idx) => rowText(list[idx]!), s.idx, s.col, big)
394+
const cache = new Map(input.scroll().getChildren().map((c) => [c.id, c]))
395+
const prev = copyWordPrev(wordRows(list, cache), (idx) => rowText(list[idx]!, cache), s.idx, s.col, big)
390396
if (prev.idx === s.idx && prev.col === s.col) return false
391397
if (prev.idx !== s.idx) sync(prev.idx)
392398
setCol(prev.col)
@@ -398,7 +404,8 @@ export function createCopyMode(input: {
398404
if (!s.active) return false
399405
const list = rows()
400406
if (!list.length) return false
401-
const next = copyWordEnd(list, (idx) => rowText(list[idx]!), s.idx, s.col, big)
407+
const cache = new Map(input.scroll().getChildren().map((c) => [c.id, c]))
408+
const next = copyWordEnd(wordRows(list, cache), (idx) => rowText(list[idx]!, cache), s.idx, s.col, big)
402409
if (next.idx === s.idx && next.col === s.col) return false
403410
if (next.idx !== s.idx) sync(next.idx)
404411
setCol(next.col)

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

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4250,6 +4250,47 @@ describe("vim scroll mapping", () => {
42504250
})
42514251

42524252
describe("copy mode", () => {
4253+
function createRenderedCopyMode(lines: string[], gutter = 4) {
4254+
const child = {
4255+
id: "text-part",
4256+
y: 0,
4257+
height: lines.length,
4258+
gutter: { calculateWidth: () => gutter },
4259+
getChildren: () => [
4260+
{
4261+
_y: 0,
4262+
plainText: lines.join("\n"),
4263+
lineInfo: {
4264+
lineSources: lines.map((_, i) => i),
4265+
lineStartCols: lines.map(() => 0),
4266+
lineWidthCols: lines.map((line) => Bun.stringWidth(line)),
4267+
lineWraps: lines.map(() => 0),
4268+
},
4269+
},
4270+
],
4271+
}
4272+
const scroll = {
4273+
y: 0,
4274+
height: 10,
4275+
width: 120,
4276+
scrollHeight: lines.length,
4277+
getChildren: () => [child],
4278+
scrollBy() {},
4279+
} as unknown as ScrollBoxRenderable
4280+
const cm = createCopyMode({
4281+
scroll: () => scroll,
4282+
messages: () => [{ id: "message", role: "assistant" }],
4283+
parts: () => [{ id: "part", type: "text", text: lines.join("\n") }] as Part[],
4284+
thinking: () => false,
4285+
details: () => false,
4286+
session: () => "session",
4287+
toBottom() {},
4288+
})
4289+
cm.prompt.enter()
4290+
cm.prompt.jump("top")
4291+
return cm
4292+
}
4293+
42534294
test("highlights final wrapped row using its visual slice", () => {
42544295
const child = {
42554296
id: "text-part",
@@ -4289,6 +4330,44 @@ describe("copy mode", () => {
42894330
expect(cm.prompt.yank()).toEqual({ text: "abcdefghij\nklmnopqrst\nuvwxyz", linewise: false })
42904331
})
42914332

4333+
test("word motions use copy row minimum columns", () => {
4334+
const cm = createRenderedCopyMode(["alpha beta", " gamma delta"])
4335+
4336+
expect(cm.state().col).toBe(7)
4337+
expect(cm.prompt.wordNext(false)).toBe(true)
4338+
expect(cm.state().col).toBe(13)
4339+
cm.prompt.setCol(13)
4340+
expect(cm.prompt.wordPrev(false)).toBe(true)
4341+
expect(cm.state().col).toBe(7)
4342+
expect(cm.prompt.wordEnd(false)).toBe(true)
4343+
expect(cm.state().col).toBe(11)
4344+
})
4345+
4346+
test("word motions use target row minimum columns across rows", () => {
4347+
const cm = createRenderedCopyMode(["alpha beta", " gamma delta"])
4348+
4349+
cm.prompt.setCol(16)
4350+
expect(cm.prompt.wordNext(false)).toBe(true)
4351+
expect(cm.state().idx).toBe(1)
4352+
expect(cm.state().col).toBe(9)
4353+
4354+
cm.prompt.jump("top")
4355+
cm.prompt.setCol(16)
4356+
expect(cm.prompt.wordEnd(false)).toBe(true)
4357+
expect(cm.state().idx).toBe(1)
4358+
expect(cm.state().col).toBe(13)
4359+
})
4360+
4361+
test("b uses previous row minimum columns across rows", () => {
4362+
const cm = createRenderedCopyMode(["alpha beta", "gamma"])
4363+
cm.prompt.jump("bottom")
4364+
4365+
expect(cm.state().col).toBe(7)
4366+
expect(cm.prompt.wordPrev(false)).toBe(true)
4367+
expect(cm.state().idx).toBe(0)
4368+
expect(cm.state().col).toBe(13)
4369+
})
4370+
42924371
test("copyWordNext advances to next row when next word is on following line", () => {
42934372
const next = copyWordNext([{ col: 0 }, { col: 0 }], (idx) => ["alpha", "beta gamma"][idx]!, 0, 4, false)
42944373
expect(next).toEqual({ idx: 1, col: 5 })

0 commit comments

Comments
 (0)