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: 4 additions & 0 deletions packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,7 @@ export type PromptProps = {
wordNext: (big: boolean) => boolean
wordPrev: (big: boolean) => boolean
wordEnd: (big: boolean) => boolean
matchingBracket: () => boolean
nextParagraph: () => boolean
previousParagraph: () => boolean
text: () => string
Expand Down Expand Up @@ -669,6 +670,9 @@ export function Prompt(props: PromptProps) {
copyWordEnd(big) {
return props.copy?.wordEnd(big) ?? false
},
copyMatchingBracket() {
return props.copy?.matchingBracket() ?? false
},
copyNextParagraph() {
return props.copy?.nextParagraph() ?? false
},
Expand Down
51 changes: 49 additions & 2 deletions packages/opencode/src/cli/cmd/tui/component/vim/vim-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ import {
getLineColumn,
insertLineStart,
joinLines,
matchingBracketOperation,
matchingBracketTarget,
moveBigWordEnd,
moveBigWordNext,
moveBigWordPrev,
Expand All @@ -29,6 +31,7 @@ import {
moveLineBeginning,
moveLineDown,
moveLineUp,
moveMatchingBracket,
moveNextParagraph,
movePreviousParagraph,
moveRight,
Expand Down Expand Up @@ -94,6 +97,7 @@ export function createVimHandler(input: {
copyWordNext?: (big: boolean) => boolean
copyWordPrev?: (big: boolean) => boolean
copyWordEnd?: (big: boolean) => boolean
copyMatchingBracket?: () => boolean
copyNextParagraph?: () => boolean
copyPreviousParagraph?: () => boolean
copyText?: () => string
Expand Down Expand Up @@ -242,6 +246,18 @@ export function createVimHandler(input: {
return true
}

function matchingBracketOperator(key: string, operation: ParagraphOperation): boolean {
if (key !== "%") return false

const textarea = input.textarea()
const result = matchingBracketOperation(textarea)
if (!result.span && !result.register) input.state.clearPending()
else if (operation === "y") applyParagraphYank(result)
else applyParagraphEdit(textarea, result, operation)

return true
}

function changeWord(big: boolean) {
const textarea = input.textarea()
const char = textarea.plainText[textarea.cursorOffset]
Expand Down Expand Up @@ -549,6 +565,11 @@ export function createVimHandler(input: {
return true
}

if (matchingBracketOperator(key, "c")) {
event.preventDefault()
return true
}

input.state.clearPending()
}

Expand Down Expand Up @@ -614,6 +635,11 @@ export function createVimHandler(input: {
return true
}

if (matchingBracketOperator(key, "d")) {
event.preventDefault()
return true
}

input.state.clearPending()
}

Expand Down Expand Up @@ -669,6 +695,11 @@ export function createVimHandler(input: {
return true
}

if (matchingBracketOperator(key, "y")) {
event.preventDefault()
return true
}

input.state.clearPending()
}

Expand Down Expand Up @@ -959,6 +990,12 @@ export function createVimHandler(input: {
return true
}

if (key === "%" && !hasModifier(event)) {
moveMatchingBracket(input.textarea())
event.preventDefault()
return true
}

if (key === "{" && !hasModifier(event)) {
movePreviousParagraph(input.textarea())
event.preventDefault()
Expand Down Expand Up @@ -1201,6 +1238,8 @@ export function createVimHandler(input: {
return true
}

const pos = input.copyCol?.() ?? 0

// line motions
if (key === "0") {
copyMotion(0)
Expand All @@ -1225,15 +1264,23 @@ export function createVimHandler(input: {
return true
}

if (key === "%") {
if (!input.copyMatchingBracket?.()) {
const text = input.copyText?.() ?? ""
const target = matchingBracketTarget(text, pos)
if (target !== null) copyMotion(target)
}
event.preventDefault()
return true
}

if (key === "z" && !event.shift) {
input.state.setPending("z")
event.preventDefault()
return true
}

// word motions
const pos = input.copyCol?.() ?? 0

if (key === "w" && !event.shift) {
if (input.copyWordNext?.(false)) {
event.preventDefault()
Expand Down
84 changes: 84 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 @@ -5,6 +5,13 @@ export type VimSpan = { start: number; end: number }
export type VimCopyRow = { col: number }
export type VimWantedColumn = number | "end"

const bracketPairs = new Map([
["(", ")"],
["[", "]"],
["{", "}"],
])
const bracketClosers = new Map(Array.from(bracketPairs, ([open, close]) => [close, open]))

function lineStart(text: string, offset: number) {
if (offset <= 0) return 0
const index = text.lastIndexOf("\n", offset - 1)
Expand Down Expand Up @@ -91,6 +98,46 @@ function lineColumn(text: string, offset: number) {
return offset - lineStart(text, offset)
}

function bracketAtOrAfter(text: string, offset: number) {
const end = lineEnd(text, offset)
for (let pos = offset; pos < end; pos++) {
if (bracketPairs.has(text[pos]!) || bracketClosers.has(text[pos]!)) return pos
}
return null
}

function matchingForward(text: string, offset: number, open: string, close: string) {
let depth = 1
for (let pos = offset + 1; pos < text.length; pos++) {
if (text[pos] === open) depth++
if (text[pos] === close) depth--
if (depth === 0) return pos
}
return null
}

function matchingBackward(text: string, offset: number, open: string, close: string) {
let depth = 1
for (let pos = offset - 1; pos >= 0; pos--) {
if (text[pos] === close) depth++
if (text[pos] === open) depth--
if (depth === 0) return pos
}
return null
}

export function matchingBracketTarget(text: string, offset: number) {
if (!text.length) return null
const source = bracketAtOrAfter(text, Math.max(0, Math.min(offset, text.length - 1)))
if (source === null) return null
const char = text[source]!
const close = bracketPairs.get(char)
if (close) return matchingForward(text, source, char, close)
const open = bracketClosers.get(char)
if (open) return matchingBackward(text, source, open, char)
return null
}

function moveUp(text: string, offset: number, column: VimWantedColumn = lineColumn(text, offset)) {
const targetStart = prevLineStart(text, offset)
if (targetStart === undefined) return offset
Expand Down Expand Up @@ -154,6 +201,13 @@ export function moveLineDown(textarea: TextareaRenderable, column?: VimWantedCol
textarea.cursorOffset = moveDown(text, textarea.cursorOffset, column)
}

export function moveMatchingBracket(textarea: TextareaRenderable) {
const target = matchingBracketTarget(textarea.plainText, textarea.cursorOffset)
if (target === null) return false
textarea.cursorOffset = target
return true
}

export function movePreviousParagraph(textarea: TextareaRenderable) {
textarea.cursorOffset = previousParagraphTarget(textarea.plainText, textarea.cursorOffset)
}
Expand All @@ -169,6 +223,15 @@ export type ParagraphResult = {
register: VimRegister
}

export function matchingBracketOperation(textarea: TextareaRenderable): ParagraphResult {
const text = textarea.plainText
const cursor = textarea.cursorOffset
const target = matchingBracketTarget(text, cursor)
if (target === null) return { span: null, register: null }
const span = target < cursor ? { start: target, end: cursor + 1 } : { start: cursor, end: target + 1 }
return { span, register: { text: text.slice(span.start, span.end), linewise: false } }
}

// vim linewise register convention: content ends with \n per line terminator.
function asLinewise(slice: string): string {
return slice.endsWith("\n") ? slice : slice + "\n"
Expand Down Expand Up @@ -477,6 +540,27 @@ export function copyWordEnd(rows: VimCopyRow[], get: (idx: number) => string, id
return { idx, col: min + Math.max(0, text.length - 1) }
}

export function copyMatchingBracket(rows: VimCopyRow[], get: (idx: number) => string, idx: number, col: number) {
const row = rows[idx]
if (!row) return { idx, col }
const texts = rows.map((_, i) => get(i))
let start = 0
const starts = texts.map((text) => {
const current = start
start += text.length + 1
return current
})
const text = texts.join("\n")
const local = Math.max(0, col - row.col)
const target = matchingBracketTarget(text, starts[idx]! + local)
if (target === null) return { idx, col }

const targetIdx = starts.findLastIndex((start, i) => target >= start && target < start + texts[i]!.length)
const targetRow = rows[targetIdx]
if (!targetRow) return { idx, col }
return { idx: targetIdx, col: targetRow.col + target - starts[targetIdx]! }
}

export type CopyParagraphResult = { index: number; atEnd: boolean }

// `atEnd` is true only when content runs to EOF without a trailing blank line,
Expand Down
15 changes: 15 additions & 0 deletions packages/opencode/src/cli/cmd/tui/routes/session/copy-mode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { createEffect, createMemo, createSignal, type Accessor } from "solid-js"
import type { ScrollBoxRenderable } from "@opentui/core"
import type { Part } from "@opencode-ai/sdk/v2"
import {
copyMatchingBracket,
copyNextParagraph,
copyPreviousParagraph,
copyWordEnd,
Expand Down Expand Up @@ -413,6 +414,19 @@ export function createCopyMode(input: {
return true
}

function matchingBracket() {
const s = state()
if (!s.active) return false
const list = rows()
if (!list.length) return false
const cache = new Map(input.scroll().getChildren().map((c) => [c.id, c]))
const next = copyMatchingBracket(wordRows(list, cache), (idx) => rowText(list[idx]!, cache), s.idx, s.col)
if (next.idx === s.idx && next.col === s.col) return false
if (next.idx !== s.idx) sync(next.idx)
setCol(next.col)
return true
}

function paragraphColumn(
row: CopyRow,
atEnd: boolean,
Expand Down Expand Up @@ -732,6 +746,7 @@ export function createCopyMode(input: {
wordNext,
wordPrev,
wordEnd,
matchingBracket,
nextParagraph,
previousParagraph,
text: copyText,
Expand Down
Loading
Loading