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
97 changes: 88 additions & 9 deletions packages/opencode/src/cli/cmd/tui/routes/session/copy-mode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ const empty: CopyState = {
}

const segmenter = new Intl.Segmenter()
type RenderableEntry = { node: any; y: number; x: number; gutter: number }

type Endpoint = { idx: number; col: number }
function orderEndpoints(a: Endpoint, b: Endpoint): { start: Endpoint; end: Endpoint } {
Expand Down Expand Up @@ -146,13 +147,13 @@ export function createCopyMode(input: {

// --- renderable tree helpers ---

function findRenderables(node: any, y = 0, gutter = 0): { node: any; y: number; gutter: number }[] {
if (node.lineInfo && node.plainText !== undefined) return [{ node, y, gutter }]
function findRenderables(node: any, y = 0, x = 0, gutter = 0): RenderableEntry[] {
if (node.lineInfo && node.plainText !== undefined) return [{ node, y, x, gutter }]
const width = gutter || ("gutter" in node && node.gutter ? node.gutter.calculateWidth() : 0)
const result: { node: any; y: number; gutter: number }[] = []
const result: RenderableEntry[] = []
for (const child of node.getChildren?.() ?? []) {
if (child._positionType === "absolute") continue
result.push(...findRenderables(child, y + Math.floor(child._y ?? 0), width))
result.push(...findRenderables(child, y + Math.floor(child._y ?? 0), x + Math.floor(child._x ?? 0), width))
}
return result
}
Expand All @@ -175,6 +176,59 @@ export function createCopyMode(input: {
return text.slice(begin, end)
}

function sourceLine(node: any, src: number): string | undefined {
let current = node
while (current) {
const content =
typeof current.content === "string"
? current.content
: typeof current._content === "string"
? current._content
: undefined
const line = content?.split("\n")[src]
if (line !== undefined) return line
current = current.parent
}
}

function markdownListPrefix(raw?: string): string {
const match = raw?.match(/^(\s{0,3}(?:[-+*]|\d{1,9}[.)])\s+)(\[[ xX]\]\s+)?/)
return match ? `${match[1] ?? ""}${match[2] ?? ""}` : ""
}

function stripPrefix(text: string, length: number) {
return { text: text.slice(length), width: Bun.stringWidth(text.slice(0, length)) }
}

function stripMatchedPrefix(text: string, pattern: RegExp) {
const match = text.match(pattern)
if (!match?.[0]) return { text, width: 0 }
return stripPrefix(text, match[0].length)
}

function stripRenderedListPrefix(text: string, prefix: string) {
const task = prefix.match(/\[[ xX]\]\s+$/)?.[0]
if (task && text.toLowerCase().startsWith(task.toLowerCase())) return stripPrefix(text, task.length)
if (task) return stripMatchedPrefix(text, /^\[[^\]]+\]\s+/)
return stripMatchedPrefix(text, /^(?:[-+*•◦‣]\s+|\d{1,9}[.)]\s+)/)
}

function prefixedText(raw: string | undefined, visiblePrefix: string, text: string) {
const prefix = markdownListPrefix(raw)
if (!prefix) return { text: visiblePrefix + text, colOffset: 0 }
if (text.startsWith(prefix)) return { text, colOffset: 0 }
const stripped = stripRenderedListPrefix(text, prefix)
return {
text: prefix + stripped.text,
colOffset: -Math.max(0, Bun.stringWidth(prefix) - stripped.width),
}
}

function copyResult(raw: string | undefined, visiblePrefix: string, text: string, col: number) {
const result = prefixedText(raw, visiblePrefix, text)
return { text: result.text, col: Math.max(0, col + result.colOffset) }
}

function childById(id: string, cache?: Map<string, any>) {
if (cache) return cache.get(id)
return input
Expand All @@ -183,6 +237,25 @@ export function createCopyMode(input: {
.find((c) => c.id === id)
}

function entryLine(entry: { node: any; y: number }, rowLine: number): string {
if (typeof entry.node.plainText !== "string") return ""
const local = rowLine - entry.y
const lines = entry.node.plainText.split("\n")
const info = entry.node.lineInfo
if (info?.lineSources && local >= 0 && local < info.lineSources.length) {
return lines[info.lineSources[local]] ?? ""
}
return lines[local] ?? ""
}

function rowPrefix(entries: RenderableEntry[], match: RenderableEntry, row: CopyRow): string {
return entries
.filter((entry) => entry !== match && entry.y === row.line && entry.x < match.x)
.toSorted((a, b) => a.x - b.x)
.map((entry) => entryLine(entry, row.line))
.join("")
}

function copyLine(row: CopyRow, child: any): { text: string; col: number } {
const entries = findRenderables(child)
if (!entries.length) return { text: "", col: 0 }
Expand All @@ -193,26 +266,32 @@ export function createCopyMode(input: {
}
if (typeof match.node.plainText !== "string") return { text: "", col: 0 }
const local = row.line - match.y
const prefix = rowPrefix(entries, match, row)
const lines = match.node.plainText.split("\n")
const info = match.node.lineInfo
if (info?.lineSources && local < info.lineSources.length) {
const src = info.lineSources[local]
const text = lines[src] ?? ""
const source = lines[src] ?? ""
const wrapped =
info.lineWraps?.[local] === 1 || info.lineSources[local - 1] === src || info.lineSources[local + 1] === src
if (!wrapped) return { text, col: match.gutter }
if (!wrapped) return copyResult(sourceLine(match.node, src), prefix, source, match.gutter)
const lineStart = info.lineStartCols?.[local] ?? 0
let base = lineStart
for (let i = local - 1; i >= 0; i--) {
if (info.lineSources[i] === src) base = info.lineStartCols?.[i] ?? base
else break
}
const offset = lineStart - base
const width = info.lineWidthCols?.[local] ?? Bun.stringWidth(text)
return { text: sliceCols(text, offset, width), col: match.gutter }
const width = info.lineWidthCols?.[local] ?? Bun.stringWidth(source)
return copyResult(
offset === 0 ? sourceLine(match.node, src) : undefined,
prefix,
sliceCols(source, offset, width),
match.gutter,
)
}
if (local >= lines.length) return { text: "", col: match.gutter }
return { text: lines[local] ?? "", col: match.gutter }
return copyResult(sourceLine(match.node, local), prefix, lines[local] ?? "", match.gutter)
}

function shift(row?: CopyRow, gutter?: number) {
Expand Down
118 changes: 116 additions & 2 deletions packages/opencode/test/cli/tui/vim-motions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6794,7 +6794,7 @@ describe("vim scroll mapping", () => {
})

describe("copy mode", () => {
function createRenderedCopyMode(lines: string[], gutter = 4) {
function createRenderedCopyMode(lines: string[], gutter = 4, options?: { content?: string }) {
const child = {
id: "text-part",
y: 0,
Expand All @@ -6804,6 +6804,8 @@ describe("copy mode", () => {
{
_y: 0,
plainText: lines.join("\n"),
content: options?.content,
_content: options?.content,
lineInfo: {
lineSources: lines.map((_, i) => i),
lineStartCols: lines.map(() => 0),
Expand All @@ -6824,7 +6826,7 @@ describe("copy mode", () => {
const cm = createCopyMode({
scroll: () => scroll,
messages: () => [{ id: "message", role: "assistant" }],
parts: () => [{ id: "part", type: "text", text: lines.join("\n") }] as Part[],
parts: () => [{ id: "part", type: "text", text: options?.content ?? lines.join("\n") }] as Part[],
thinking: () => false,
details: () => false,
session: () => "session",
Expand All @@ -6835,6 +6837,118 @@ describe("copy mode", () => {
return cm
}

test("yank line preserves markdown list markers from source", () => {
const cm = createRenderedCopyMode(["• Inspect current branch", "[x] Push visual-fix"], 4, {
content: "- Inspect current branch\n- [x] Push visual-fix",
})

expect(cm.prompt.yankLine()).toEqual({ text: "- Inspect current branch", linewise: false })
cm.prompt.move("down")
expect(cm.prompt.yankLine()).toEqual({ text: "- [x] Push visual-fix", linewise: false })
})

test("copy mode cursor starts on restored markdown list marker", () => {
const line = "If you launched OpenCode"
const leaf = {
_y: 0,
plainText: line,
parent: undefined as unknown,
lineInfo: {
lineSources: [0],
lineStartCols: [0],
lineWidthCols: [Bun.stringWidth(line)],
lineWraps: [0],
},
}
const child = {
id: "text-part",
y: 0,
height: 1,
content: `- ${line}`,
_content: `- ${line}`,
gutter: { calculateWidth: () => 4 },
getChildren: () => [leaf],
}
leaf.parent = child
const scroll = {
y: 0,
height: 10,
width: 120,
scrollHeight: 1,
getChildren: () => [child],
scrollBy() {},
} as unknown as ScrollBoxRenderable
const cm = createCopyMode({
scroll: () => scroll,
messages: () => [{ id: "message", role: "assistant" }],
parts: () => [{ id: "part", type: "text", text: `- ${line}` }] as Part[],
thinking: () => false,
details: () => false,
session: () => "session",
toBottom() {},
})

cm.prompt.enter()

expect(cm.prompt.col()).toBe(3 + 4 - Bun.stringWidth("- "))
expect(cm.cursorText()).toBe("-")
expect(cm.prompt.yankLine()).toEqual({ text: `- ${line}`, linewise: false })
})

test("yank line includes visible same-row prefixes", () => {
const line = "Inspect current branch"
const child = {
id: "text-part",
y: 0,
height: 1,
getChildren: () => [
{
_x: 0,
_y: 0,
plainText: "[✓] ",
lineInfo: {
lineSources: [0],
lineStartCols: [0],
lineWidthCols: [Bun.stringWidth("[✓] ")],
lineWraps: [0],
},
},
{
_x: 4,
_y: 0,
plainText: line,
lineInfo: {
lineSources: [0],
lineStartCols: [0],
lineWidthCols: [Bun.stringWidth(line)],
lineWraps: [0],
},
},
],
}
const scroll = {
y: 0,
height: 10,
width: 120,
scrollHeight: 1,
getChildren: () => [child],
scrollBy() {},
} as unknown as ScrollBoxRenderable
const cm = createCopyMode({
scroll: () => scroll,
messages: () => [{ id: "message", role: "assistant" }],
parts: () => [{ id: "part", type: "text", text: line }] as Part[],
thinking: () => false,
details: () => false,
session: () => "session",
toBottom() {},
})

cm.prompt.enter()

expect(cm.prompt.yankLine()).toEqual({ text: "[✓] Inspect current branch", linewise: false })
})

test("entering copy mode keeps visible row when unified layout changes", async () => {
let offset = 20
let cm: ReturnType<typeof createCopyMode> | undefined
Expand Down
Loading