Skip to content
Open
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
27 changes: 9 additions & 18 deletions packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ import { DialogWorkspaceUnavailable } from "../dialog-workspace-unavailable"
import { useArgs } from "@tui/context/args"
import { Flag } from "@opencode-ai/core/flag/flag"
import { WorkspaceLabel, type WorkspaceStatus } from "../workspace-label"
import { expandPromptTextParts } from "./paste"

export type PromptProps = {
sessionID?: string
Expand Down Expand Up @@ -892,23 +893,13 @@ export function Prompt(props: PromptProps) {
}

const messageID = MessageID.ascending()
let inputText = store.prompt.input

// Expand pasted text inline before submitting
const allExtmarks = input.extmarks.getAllForTypeId(promptPartTypeId)
const sortedExtmarks = allExtmarks.sort((a: { start: number }, b: { start: number }) => b.start - a.start)

for (const extmark of sortedExtmarks) {
const partIndex = store.extmarkToPartIndex.get(extmark.id)
if (partIndex !== undefined) {
const part = store.prompt.parts[partIndex]
if (part?.type === "text" && part.text) {
const before = inputText.slice(0, extmark.start)
const after = inputText.slice(extmark.end)
inputText = before + part.text + after
}
}
}
const inputText = expandPromptTextParts(
store.prompt.input,
input.extmarks.getAllForTypeId(promptPartTypeId),
store.extmarkToPartIndex,
store.prompt.parts,
)

// Filter out text parts (pasted content) since they're now expanded inline
const nonTextParts = store.prompt.parts.filter((part) => part.type !== "text")
Expand Down Expand Up @@ -1026,7 +1017,7 @@ export function Prompt(props: PromptProps) {
function pasteText(text: string, virtualText: string) {
const currentOffset = input.visualCursor.offset
const extmarkStart = currentOffset
const extmarkEnd = extmarkStart + virtualText.length
const extmarkEnd = extmarkStart + Bun.stringWidth(virtualText)

input.insertText(virtualText + " ")

Expand Down Expand Up @@ -1067,7 +1058,7 @@ export function Prompt(props: PromptProps) {
return x.mime.startsWith("image/")
}).length
const virtualText = pdf ? `[PDF ${count + 1}]` : `[Image ${count + 1}]`
const extmarkEnd = extmarkStart + virtualText.length
const extmarkEnd = extmarkStart + Bun.stringWidth(virtualText)
const textToInsert = virtualText + " "

input.insertText(textToInsert)
Expand Down
81 changes: 81 additions & 0 deletions packages/opencode/src/cli/cmd/tui/component/prompt/paste.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import type { PromptInfo } from "./history"

export type PromptPartExtmark = {
id: number
start: number
end: number
}

const segmenter = new Intl.Segmenter(undefined, { granularity: "grapheme" })

function segmentWidth(segment: string) {
if (segment === "\n") return 1
return Bun.stringWidth(segment)
}

function stringIndexToDisplayOffset(text: string, index: number) {
let displayOffset = 0
for (const segment of segmenter.segment(text)) {
if (segment.index >= index) return displayOffset
displayOffset += segmentWidth(segment.segment)
}
return displayOffset
}

export function displayOffsetToStringIndex(text: string, offset: number) {
if (offset <= 0) return 0

let displayOffset = 0
for (const segment of segmenter.segment(text)) {
const nextDisplayOffset = displayOffset + segmentWidth(segment.segment)
if (nextDisplayOffset > offset) return segment.index
if (nextDisplayOffset === offset) return segment.index + segment.segment.length
displayOffset = nextDisplayOffset
}

return text.length
}

function virtualTextRange(text: string, extmark: PromptPartExtmark, virtualText: string) {
const start = displayOffsetToStringIndex(text, extmark.start)
const end = displayOffsetToStringIndex(text, extmark.end)
if (text.slice(start, end) === virtualText) return { start, end }

const ranges = []
let index = text.indexOf(virtualText)
while (index !== -1) {
ranges.push({ start: index, end: index + virtualText.length })
index = text.indexOf(virtualText, index + virtualText.length)
}

return (
ranges.sort(
(a, b) =>
Math.abs(stringIndexToDisplayOffset(text, a.start) - extmark.start) -
Math.abs(stringIndexToDisplayOffset(text, b.start) - extmark.start) || b.start - a.start,
)[0] ?? { start, end }
)
}

export function expandPromptTextParts(
input: string,
extmarks: readonly PromptPartExtmark[],
extmarkToPartIndex: ReadonlyMap<number, number>,
parts: PromptInfo["parts"],
) {
return [...extmarks]
.sort((a, b) => b.start - a.start)
.reduce((text, extmark) => {
const partIndex = extmarkToPartIndex.get(extmark.id)
const part = partIndex === undefined ? undefined : parts[partIndex]
if (part?.type !== "text" || !part.text) return text

const range = part.source?.text.value
? virtualTextRange(text, extmark, part.source.text.value)
: {
start: displayOffsetToStringIndex(text, extmark.start),
end: displayOffsetToStringIndex(text, extmark.end),
}
return text.slice(0, range.start) + part.text + text.slice(range.end)
}, input)
}
117 changes: 117 additions & 0 deletions packages/opencode/test/cli/cmd/tui/prompt-paste.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
import { describe, expect, test } from "bun:test"
import { displayOffsetToStringIndex, expandPromptTextParts } from "../../../../src/cli/cmd/tui/component/prompt/paste"
import type { PromptInfo } from "../../../../src/cli/cmd/tui/component/prompt/history"

describe("displayOffsetToStringIndex", () => {
test("maps display offsets across wide characters and newlines", () => {
expect(displayOffsetToStringIndex("第一行\n中文x", 11)).toBe("第一行\n中文".length)
})
})

describe("expandPromptTextParts", () => {
test("expands summarized paste text after wide characters", () => {
const virtualText = "[Pasted ~3 lines]"
const pastedText = "第一行\n第二行\n第三行"
const start = Bun.stringWidth("中文abc")
const end = start + Bun.stringWidth(virtualText)
const parts = [
{
type: "text",
text: pastedText,
source: {
text: {
start,
end,
value: virtualText,
},
},
},
] satisfies PromptInfo["parts"]

expect(
expandPromptTextParts(
`中文abc${virtualText} 后文`,
[{ id: 1, start, end }],
new Map([[1, 0]]),
parts,
),
).toBe(`中文abc${pastedText} 后文`)
})

test("expands multiple summarized paste blocks using their original visual ranges", () => {
const firstVirtualText = "[Pasted ~2 lines]"
const secondVirtualText = "[Pasted ~3 lines]"
const firstPastedText = "一\n二"
const secondPastedText = "甲\n乙\n丙"
const beforeFirst = "开头中文"
const between = " 中段中文"
const firstStart = Bun.stringWidth(beforeFirst)
const firstEnd = firstStart + Bun.stringWidth(firstVirtualText)
const secondStart = Bun.stringWidth(`${beforeFirst}${firstVirtualText}${between}`)
const secondEnd = secondStart + Bun.stringWidth(secondVirtualText)
const parts = [
{
type: "text",
text: firstPastedText,
source: {
text: {
start: firstStart,
end: firstEnd,
value: firstVirtualText,
},
},
},
{
type: "text",
text: secondPastedText,
source: {
text: {
start: secondStart,
end: secondEnd,
value: secondVirtualText,
},
},
},
] satisfies PromptInfo["parts"]

expect(
expandPromptTextParts(
`${beforeFirst}${firstVirtualText}${between}${secondVirtualText}结尾`,
[
{ id: 1, start: firstStart, end: firstEnd },
{ id: 2, start: secondStart, end: secondEnd },
],
new Map([
[1, 0],
[2, 1],
]),
parts,
),
).toBe(`${beforeFirst}${firstPastedText}${between}${secondPastedText}结尾`)
})

test("falls back to virtual text when an extmark was shifted by string length", () => {
const virtualText = "[Pasted ~2 lines]"
const pastedText = "第一行\n第二行"
const input = `abc中${virtualText}结尾`
const start = "abc中".length
const end = start + virtualText.length
const parts = [
{
type: "text",
text: pastedText,
source: {
text: {
start,
end,
value: virtualText,
},
},
},
] satisfies PromptInfo["parts"]

expect(expandPromptTextParts(input, [{ id: 1, start, end }], new Map([[1, 0]]), parts)).toBe(
`abc中${pastedText}结尾`,
)
})
})
Loading