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 packages/opencode/src/cli/cmd/prompt-display.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
const graphemes = new Intl.Segmenter(undefined, { granularity: "grapheme" })

function promptOffsetWidth(value: string) {
export function promptOffsetWidth(value: string) {
let width = 0
for (const part of graphemes.segment(value)) {
// Textarea offsets count newlines as one position; Bun.stringWidth counts them as zero.
Expand Down
5 changes: 4 additions & 1 deletion packages/opencode/src/cli/cmd/run/footer.command.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -329,7 +329,10 @@ export function RunCommandMenuBody(props: {
category: "Suggested",
display: "Manage queued prompts",
footer: `${props.queued().length} queued`,
keywords: props.queued().map((item) => item.prompt.text).join(" "),
keywords: props
.queued()
.map((item) => item.prompt.text)
.join(" "),
},
]
: []),
Expand Down
8 changes: 6 additions & 2 deletions packages/opencode/src/cli/cmd/run/footer.view.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -203,7 +203,9 @@ export function RunFooterView(props: RunFooterViewProps) {
const subagentShortcut = useKeymapSelector(
(keymap: OpenTuiKeymap) =>
formatKeyBindings(
keymap.getCommandBindings({ visibility: "registered", commands: ["session.child.first"] }).get("session.child.first"),
keymap
.getCommandBindings({ visibility: "registered", commands: ["session.child.first"] })
.get("session.child.first"),
props.tuiConfig,
) ?? "",
)
Expand Down Expand Up @@ -697,7 +699,9 @@ export function RunFooterView(props: RunFooterViewProps) {
gap={1}
flexShrink={0}
>
<Show when={busy() || exiting() || duration().length > 0 || queuedIndicator() || subagentIndicator()}>
<Show
when={busy() || exiting() || duration().length > 0 || queuedIndicator() || subagentIndicator()}
>
<box id="run-direct-footer-hint-left" flexDirection="row" gap={1} flexShrink={0} marginLeft={1}>
<Show when={exiting()}>
<text id="run-direct-footer-hint-exit" fg={theme().highlight} wrapMode="none" truncate>
Expand Down
1 change: 0 additions & 1 deletion packages/opencode/src/cli/cmd/run/runtime.queue.ts
Original file line number Diff line number Diff line change
Expand Up @@ -210,7 +210,6 @@ export async function runPromptQueue(input: QueueInput): Promise<void> {
if (next.type === "error") {
throw next.error
}

} finally {
if (state.ctrl === ctrl) {
state.ctrl = undefined
Expand Down
35 changes: 14 additions & 21 deletions packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,10 +25,11 @@ import { useSync } from "@tui/context/sync"
import { useEvent } from "@tui/context/event"
import { editorSelectionKey, useEditorContext, type EditorSelection } from "@tui/context/editor"
import { MessageID, PartID } from "@/session/schema"
import { promptOffsetWidth } from "@/cli/cmd/prompt-display"
import { createStore, produce, unwrap } from "solid-js/store"
import { usePromptHistory, type PromptInfo } from "./history"
import { computePromptTraits } from "./traits"
import { assign, expandPastedTextPlaceholders } from "./part"
import { assign, expandPastedTextPlaceholders, expandTrackedPastedText } from "./part"
import { usePromptStash } from "./stash"
import { DialogStash } from "../dialog-stash"
import { type AutocompleteRef, Autocomplete } from "./autocomplete"
Expand Down Expand Up @@ -1109,23 +1110,15 @@ 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 = expandTrackedPastedText(
store.prompt.input,
input.extmarks.getAllForTypeId(promptPartTypeId).flatMap((extmark) => {
const partIndex = store.extmarkToPartIndex.get(extmark.id)
const part = partIndex === undefined ? undefined : store.prompt.parts[partIndex]
if (part?.type !== "text") return []
return [{ start: extmark.start, end: extmark.end, text: part.text }]
}),
)

// 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 @@ -1242,9 +1235,9 @@ export function Prompt(props: PromptProps) {
const exit = useExit()

function pasteText(text: string, virtualText: string) {
const currentOffset = input.visualCursor.offset
const currentOffset = input.cursorOffset
const extmarkStart = currentOffset
const extmarkEnd = extmarkStart + virtualText.length
const extmarkEnd = extmarkStart + promptOffsetWidth(virtualText)

input.insertText(virtualText + " ")

Expand Down Expand Up @@ -1336,7 +1329,7 @@ export function Prompt(props: PromptProps) {
}

async function pasteAttachment(file: { filename?: string; filepath?: string; content: string; mime: string }) {
const currentOffset = input.visualCursor.offset
const currentOffset = input.cursorOffset
const extmarkStart = currentOffset
const pdf = file.mime === "application/pdf"
const count = store.prompt.parts.filter((x) => {
Expand Down
11 changes: 11 additions & 0 deletions packages/opencode/src/cli/cmd/tui/component/prompt/part.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { PartID } from "@/session/schema"
import { displaySlice } from "@/cli/cmd/prompt-display"
import type { PromptInfo } from "./history"

type Item = PromptInfo["parts"][number]
Expand All @@ -21,3 +22,13 @@ export function expandPastedTextPlaceholders(text: string, parts: PromptInfo["pa
return result.replace(part.source.text.value, part.text)
}, text)
}

export function expandTrackedPastedText(text: string, ranges: { start: number; end: number; text: string }[]) {
return ranges
.slice()
.sort((a, b) => b.start - a.start)
.reduce(
(result, part) => displaySlice(result, 0, part.start) + part.text + displaySlice(result, part.end),
text,
)
}
34 changes: 33 additions & 1 deletion packages/opencode/test/cli/cmd/tui/prompt-part.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { describe, expect, test } from "bun:test"
import type { PromptInfo } from "../../../../src/cli/cmd/tui/component/prompt/history"
import { assign, strip } from "../../../../src/cli/cmd/tui/component/prompt/part"
import { assign, expandTrackedPastedText, strip } from "../../../../src/cli/cmd/tui/component/prompt/part"

describe("prompt part", () => {
test("strip removes persisted ids from reused file parts", () => {
Expand Down Expand Up @@ -44,4 +44,36 @@ describe("prompt part", () => {
url: "data:image/png;base64,abc",
})
})

test("expandTrackedPastedText preserves wide characters around pasted text", () => {
const marker = "[Pasted ~3 lines]"
const prefix = "你好你好\n"

expect(
expandTrackedPastedText(prefix + marker + "\n阿斯顿法国红酒看来", [
{
start: Bun.stringWidth("你好你好") + 1,
end: Bun.stringWidth("你好你好") + 1 + Bun.stringWidth(marker),
text: "public:\n\tvoid ExecuteTask();\nprivate:",
},
]),
).toBe(
"你好你好\npublic:\n\tvoid ExecuteTask();\nprivate:\n阿斯顿法国红酒看来",
)
})

test("expandTrackedPastedText only expands the tracked placeholder occurrence", () => {
const marker = "[Pasted ~3 lines]"
const prefix = `keep ${marker} then `

expect(
expandTrackedPastedText(prefix + marker + " tail", [
{
start: Bun.stringWidth(prefix),
end: Bun.stringWidth(prefix + marker),
text: "alpha\nbeta\ngamma",
},
]),
).toBe(`keep ${marker} then alpha\nbeta\ngamma tail`)
})
})
4 changes: 2 additions & 2 deletions packages/opencode/test/cli/run/footer.view.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -198,8 +198,8 @@ async function renderFooter(
onVariantSelect={() => {}}
onRows={() => {}}
onLayout={() => {}}
onStatus={() => {}}
onQueuedRemove={async () => true}
onStatus={() => {}}
onQueuedRemove={async () => true}
/>
</OpencodeKeymapProvider>
)
Expand Down
6 changes: 3 additions & 3 deletions packages/opencode/test/cli/run/runtime.queue.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -326,9 +326,9 @@ describe("run runtime queue", () => {
const first = ui.events.find((item) => item.type === "queued.prompts")
const event = ui.events.findLast((item) => item.type === "queued.prompts")
expect(first?.type === "queued.prompts" ? first.prompts : []).toEqual([])
expect(first?.type === "queued.prompts" && event?.type === "queued.prompts" ? first.prompts === event.prompts : true).toBe(
false,
)
expect(
first?.type === "queued.prompts" && event?.type === "queued.prompts" ? first.prompts === event.prompts : true,
).toBe(false)
expect(ui.events.findLast((item) => item.type === "queue")).toEqual({ type: "queue", queue: 1 })
expect(event?.type === "queued.prompts" ? event.prompts.map((item) => item.prompt.text) : []).toEqual(["two"])
if (event?.type === "queued.prompts") ui.removeQueued(event.prompts[0]!.messageID)
Expand Down
Loading