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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@ Text selection from the chat session view.
| ------------------------------ | ------------------------------------------------------- |
| `<leader>v`, `Ctrl+W k` | Enter copy mode |
| `h`, `j`, `k`, `l`, arrow keys | Navigate |
| `v`, `V` | Start character-wise or line-wise selection |
| `v`, `V`, `Ctrl+V` | Start character-wise, line-wise, or block selection |
| `y`, `yy` | Yank to the vim register |
| `Enter` | Copy to the system clipboard |
| `Y` | Yank to the vim register and scroll to the bottom |
Expand Down
8 changes: 4 additions & 4 deletions packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -101,14 +101,14 @@ export type PromptProps = {
exit: (scrollToBottom?: boolean) => void
exitPreserveScroll: () => void
focusInput: () => void
visual: (mode: "char" | "line") => void
visual: (mode: "char" | "line" | "block") => void
yank: () => { text: string; linewise: boolean } | null
yankLine: () => { text: string; linewise: boolean } | null
yankMatchingBracket: () => { text: string; linewise: boolean } | null
copy: () => Promise<void> | void
isVisual: () => boolean
exitVisual: () => void
visualMode: () => undefined | "char" | "line"
visualMode: () => undefined | "char" | "line" | "block"
move: (action: "up" | "down" | "left" | "right") => void
jump: (action: "top" | "bottom" | "high" | "middle" | "low") => void
wordNext: (big: boolean) => boolean
Expand Down Expand Up @@ -1622,7 +1622,7 @@ export function Prompt(props: PromptProps) {
useBindings(() => {
return {
target: inputTarget,
enabled: inputTarget() !== undefined && !props.disabled,
enabled: inputTarget() !== undefined && !props.disabled && !vimState.isCopy(),
bindings: tuiConfig.keybinds.get("prompt.paste"),
}
})
Expand Down Expand Up @@ -2277,7 +2277,7 @@ export function Prompt(props: PromptProps) {
}

function isVisualIndicator(indicator: string) {
return ["-- VISUAL --", "-- VISUAL LINE --"].includes(indicator)
return ["-- VISUAL --", "-- VISUAL LINE --", "-- VISUAL BLOCK --"].includes(indicator)
}

function VimIndicator() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,7 @@ export function createVimHandler(input: {
jump: (action: VimJump) => void
navigate?: (action: VimWindowNavigation) => void
copy?: (action: VimCopyMove) => void
copyVisual?: (mode: "char" | "line") => void
copyVisual?: (mode: "char" | "line" | "block") => void
copyExitVisual?: () => void
copyExit?: (scrollToBottom?: boolean) => void
copyExitPreserveScroll?: () => void
Expand Down Expand Up @@ -1418,6 +1418,12 @@ export function createVimHandler(input: {
event.preventDefault()
return true
}
if (key === "v" && event.ctrl && !event.shift && !event.meta && !event.super) {
clearCopyPending()
input.copyVisual?.("block")
event.preventDefault()
return true
}
if (key === "q") {
input.state.setMode("normal")
event.preventDefault()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ export function useVimIndicator(input: {
enabled: Accessor<boolean>
active: Accessor<boolean>
state: ReturnType<typeof createVimState>
copyVisual?: Accessor<undefined | "char" | "line">
copyVisual?: Accessor<undefined | "char" | "line" | "block">
copySearch?: Accessor<string | undefined>
}) {
return createMemo(() => {
Expand All @@ -17,6 +17,7 @@ export function useVimIndicator(input: {
if (search !== undefined) return search
if (input.copyVisual?.() === "char") return "-- VISUAL --"
if (input.copyVisual?.() === "line") return "-- VISUAL LINE --"
if (input.copyVisual?.() === "block") return "-- VISUAL BLOCK --"
return "-- COPY --"
}
if (input.state.isInsert()) return "-- INSERT --"
Expand Down
131 changes: 107 additions & 24 deletions packages/opencode/src/cli/cmd/tui/routes/session/copy-mode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,12 +33,14 @@ export type CopyHighlight = {
current?: boolean
}

type CopyVisualMode = "char" | "line" | "block"

type CopyState = {
active: boolean
idx: number
col: number
stick: undefined | "start" | "first" | "end" | number
visual: undefined | "char" | "line"
visual: undefined | CopyVisualMode
anchor: undefined | { idx: number; col: number }
}

Expand Down Expand Up @@ -659,28 +661,36 @@ export function createCopyMode(input: {
if (action === "left") {
const row = rows()[s.idx]
const min = copyMin(row)
const c = Math.max(min, s.col - 1)
const c = Math.max(min, Math.min(scroll.width - 2, s.col - 1))
if (c === s.col) return
setState((prev) => ({ ...prev, col: c, stick: c - min }))
return
}
const row = rows()[s.idx]
const min = copyMin(row)
const text = copyText()
const max = text.length > 0 ? Math.min(scroll.width - 2, text.length - 1) : min
const c = Math.min(max, s.col + 1)
const c = Math.max(min, Math.min(max, s.col + 1))
if (c === s.col) return
setState((prev) => ({ ...prev, col: c, stick: c - min }))
}

function copyToggleVisualEnd() {
const anchor = state().anchor
if (!anchor) return
setState((prev) => ({
...prev,
idx: anchor.idx,
col: anchor.col,
stick: anchor.col - copyMin(rows()[anchor.idx]),
anchor: { idx: prev.idx, col: prev.col},
}))
setState((prev) => {
const list = rows()
const row = list[anchor.idx]
const blockEnd = prev.visual === "block" && prev.stick === "end" && row
const col = blockEnd ? rowEndCol(row) : anchor.col
return {
...prev,
idx: anchor.idx,
col,
stick: blockEnd ? "end" : col - copyMin(row),
anchor: { idx: prev.idx, col: blockEnd ? anchor.col : prev.visual === "block" ? blockHeadCol(prev, list) : prev.col },
}
})
}

function wordRows(list: CopyRow[], cache: Map<string, any>) {
Expand Down Expand Up @@ -774,6 +784,19 @@ export function createCopyMode(input: {
return paragraphMove(copyPreviousParagraph)
}

function rowEndCol(row: CopyRow, cache?: Map<string, any>) {
const min = copyMin(row, cache)
const text = rowText(row, cache)
return text.length > 0 ? min + text.length - 1 : min
}

function blockHeadCol(s: CopyState, list = rows(), cache?: Map<string, any>) {
const row = list[s.idx]
if (!row) return s.col
if (s.stick === "end") return rowEndCol(row, cache)
return s.col
}

// --- search ---

function childCache() {
Expand Down Expand Up @@ -912,7 +935,7 @@ export function createCopyMode(input: {

// --- visual ---

function visual(mode: "char" | "line") {
function visual(mode: CopyVisualMode) {
const s = state()
if (!s.active) return
if (s.visual === mode) {
Expand All @@ -930,7 +953,7 @@ export function createCopyMode(input: {
setState((s) => ({ ...s, visual: undefined, anchor: undefined }))
}

function rangeText(anchor: Endpoint, head: Endpoint, visual: "char" | "line"): string {
function rangeText(anchor: Endpoint, head: Endpoint, visual: CopyVisualMode): string {
const list = rows()
const cache = new Map(
input
Expand All @@ -945,6 +968,25 @@ export function createCopyMode(input: {
.map((row) => signedText(row, cache))
.join("\n")
}
if (visual === "block") {
const left = Math.min(anchor.col, head.col)
const right = Math.max(anchor.col, head.col)
const endMode = state().stick === "end"
return Array.from({ length: end.idx - start.idx + 1 }, (_, i) => list[start.idx + i])
.filter((row): row is CopyRow => !!row)
.map((row) => {
const text = rowText(row, cache)
const min = copyMin(row, cache)
const rowRight = endMode ? rowEndCol(row, cache) : right
const rowLeft = endMode && rowRight < anchor.col ? anchor.col : endMode ? Math.min(anchor.col, rowRight) : left
const selected = `${rowLeft < min ? " ".repeat(min - rowLeft) : ""}${text.slice(
Math.max(0, rowLeft - min),
Math.max(0, rowRight - min + 1),
)}`
return endMode ? selected : selected.padEnd(right - left + 1, " ")
})
.join("\n")
}
if (start.idx === end.idx) {
const row = list[start.idx]
if (!row) return ""
Expand All @@ -967,7 +1009,7 @@ export function createCopyMode(input: {
function selectionText(): string {
const s = state()
if (!s.visual || !s.anchor) return ""
return rangeText(s.anchor, { idx: s.idx, col: s.col }, s.visual)
return rangeText(s.anchor, { idx: s.idx, col: s.visual === "block" ? blockHeadCol(s) : s.col }, s.visual)
}

function yank() {
Expand Down Expand Up @@ -1131,14 +1173,17 @@ export function createCopyMode(input: {
text: string,
left: number,
right: number,
options?: Pick<CopyHighlight, "kind" | "current">,
options?: Pick<CopyHighlight, "kind" | "current"> & { placeholder?: boolean },
) => {
if (left > right) return
const selected = text.slice(Math.max(0, left - min), Math.max(0, right - min + 1))
if (!selected && !options?.placeholder) return
const start = Math.max(left, min)
const entry: CopyHighlight = {
line: row.line,
left,
right,
text: text.slice(Math.max(0, left - min), Math.max(0, right - min + 1)),
left: start,
right: start + Math.max(1, selected.length) - 1,
text: selected || " ",
...(options?.kind ? { kind: options.kind } : {}),
...(options?.current ? { current: true } : {}),
}
Expand Down Expand Up @@ -1193,8 +1238,11 @@ export function createCopyMode(input: {
}

if (!s.visual || !s.anchor) return out
const h = { idx: s.idx, col: s.col }
const h = { idx: s.idx, col: s.visual === "block" ? blockHeadCol(s, list, cache) : s.col }
const { start, end } = orderEndpoints(s.anchor, h)
const blockLeft = Math.min(s.anchor.col, h.col)
const blockRight = Math.max(s.anchor.col, h.col)
const blockEnd = s.visual === "block" && s.stick === "end"

for (let i = start.idx; i <= end.idx; i++) {
const r = list[i]
Expand All @@ -1203,29 +1251,63 @@ export function createCopyMode(input: {
const text = rowText(r, cache) || ""
const max = text.length > 0 ? min + text.length - 1 : min
const left =
s.visual === "line" ? min : i === start.idx && i === end.idx ? start.col : i === start.idx ? start.col : min
s.visual === "line"
? min
: s.visual === "block"
? blockEnd && max < s.anchor.col
? s.anchor.col
: blockEnd
? Math.min(s.anchor.col, max)
: blockLeft
: i === start.idx && i === end.idx
? start.col
: i === start.idx
? start.col
: min
const right =
s.visual === "line" ? max : i === start.idx && i === end.idx ? end.col : i === end.idx ? end.col : max
s.visual === "line"
? max
: s.visual === "block"
? blockEnd
? Math.max(s.anchor.col, max)
: blockRight
: i === start.idx && i === end.idx
? end.col
: i === end.idx
? end.col
: max
if (i !== h.idx) {
addHighlight(r, min, text, left, right)
addHighlight(r, min, text, left, right, { placeholder: true })
continue
}
// cursor cell is painted separately by CopyOverlay so the cursor keeps its theme.text color
addHighlight(r, min, text, left, h.col - 1)
addHighlight(r, min, text, h.col + 1, right)
addHighlight(r, min, text, left, h.col - 1, { placeholder: true })
addHighlight(r, min, text, h.col + 1, right, { placeholder: true })
}
return out
})

const cursorCol = createMemo(() => {
const s = state()
if (!s.active) return 0
const row = rows()[s.idx]
if (!row) return s.col
const min = copyMin(row)
const text = rowText(row)
const max = text.length > 0 ? min + text.length - 1 : min
return Math.max(min, Math.min(max, s.col))
})

const cursorText = createMemo(() => {
const s = state()
if (!s.active) return " "
const row = rows()[s.idx]
if (!row) return " "
const text = copyText()
const cursor = cursorCol()
let col = 0
for (const seg of segmenter.segment(text)) {
if (col >= s.col) return seg.segment
if (col >= cursor) return seg.segment
col += Bun.stringWidth(seg.segment)
}
return " "
Expand Down Expand Up @@ -1283,6 +1365,7 @@ export function createCopyMode(input: {
unified,
clamp,
state,
cursorCol,
cursorText,
}
}
4 changes: 2 additions & 2 deletions packages/opencode/src/cli/cmd/tui/routes/session/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1259,7 +1259,7 @@ export function Session() {
cm.row()?.kind === "user" && cm.row()?.id === message.id
? {
line: cm.row()!.line,
col: cm.state().col,
col: cm.cursorCol(),
visual: !!cm.state().visual,
cursorText: cm.cursorText(),
}
Expand Down Expand Up @@ -1288,7 +1288,7 @@ export function Session() {
cm.row()
? {
...cm.row()!,
col: cm.state().col,
col: cm.cursorCol(),
visual: !!cm.state().visual,
cursorText: cm.cursorText(),
}
Expand Down
3 changes: 2 additions & 1 deletion packages/opencode/test/cli/tui/vim-indicator.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ function label(opts?: {
mode?: VimMode
pending?: VimPending
pendingDisplay?: string
copy?: undefined | "char" | "line"
copy?: undefined | "char" | "line" | "block"
}) {
return createRoot((dispose) => {
const [enabled] = createSignal(opts?.enabled ?? true)
Expand Down Expand Up @@ -56,5 +56,6 @@ describe("vim indicator", () => {
test("shows visual labels in copy mode", () => {
expect(label({ mode: "copy", copy: "char" })).toBe("-- VISUAL --")
expect(label({ mode: "copy", copy: "line" })).toBe("-- VISUAL LINE --")
expect(label({ mode: "copy", copy: "block" })).toBe("-- VISUAL BLOCK --")
})
})
Loading
Loading