diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete-slash.test.ts b/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete-slash.test.ts new file mode 100644 index 000000000000..533a6fa3624b --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete-slash.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, test } from "bun:test" +import { scan, splice } from "./autocomplete-slash" + +describe("autocomplete slash", () => { + test("scans the slash token at the caret", () => { + expect(scan("/open", 5)).toEqual({ start: 0, end: 5, query: "open" }) + expect(scan("hello /ope", 10)).toEqual({ start: 6, end: 10, query: "ope" }) + expect(scan("hello/open", 10)).toEqual({ start: 5, end: 10, query: "open" }) + expect(scan("hello /nested/child tail", 19)).toEqual({ start: 6, end: 19, query: "nested/child" }) + }) + + test("ignores spaces after the slash token", () => { + expect(scan("hello /open ", 12)).toBeUndefined() + expect(scan("hello world", 11)).toBeUndefined() + expect(scan("/open", 0)).toBeUndefined() + }) + + test("splices inline slash text", () => { + expect(splice("hello /open", 6, 11, "")).toBe("hello ") + expect(splice("hello/open", 5, 10, "")).toBe("hello") + }) + + test("can move a custom command to the front", () => { + const text = splice("hello /rev", 6, 10, "") + expect(splice(text, 0, 0, "/review ")).toBe("/review hello ") + }) +}) diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete-slash.ts b/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete-slash.ts new file mode 100644 index 000000000000..3bc576690335 --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete-slash.ts @@ -0,0 +1,30 @@ +export type Slash = { + start: number + end: number + query: string +} + +const blank = (char: string) => /\s/.test(char) +const slash = /\/(\S*)$/ + +export function scan(text: string, cursor: number) { + if (cursor <= 0 || cursor > text.length) return + + const match = text.slice(0, cursor).match(slash) + if (!match) return + + const start = match.index ?? cursor - match[0].length + let end = cursor + + while (end < text.length && !blank(text[end]!)) end += 1 + + return { + start, + end, + query: text.slice(start + 1, cursor), + } satisfies Slash +} + +export function splice(text: string, start: number, end: number, next: string) { + return text.slice(0, start) + next + text.slice(end) +} diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx b/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx index 3240afab326a..2ba69839f735 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx @@ -12,6 +12,7 @@ import { useCommandDialog } from "@tui/component/dialog-command" import { useTerminalDimensions } from "@opentui/solid" import { Locale } from "@/util/locale" import type { PromptInfo } from "./history" +import { scan } from "./autocomplete-slash" import { useFrecency } from "./frecency" function removeLineRange(input: string) { @@ -140,6 +141,38 @@ export function Autocomplete(props: { setSearch(next ? next : "") }) + function write() { + props.setPrompt((draft) => { + draft.input = props.input().plainText + }) + } + + function cut() { + if (store.visible !== "/") return + + const input = props.input() + const hit = scan(input.plainText, input.cursorOffset) + if (!hit || hit.start !== store.index) return + + input.cursorOffset = hit.start + const start = input.logicalCursor + input.cursorOffset = hit.end + const end = input.logicalCursor + + input.deleteRange(start.row, start.col, end.row, end.col) + input.cursorOffset = hit.start + write() + return hit + } + + function lead(text: string) { + const input = props.input() + input.cursorOffset = 0 + input.insertText(text) + input.cursorOffset = Bun.stringWidth(input.plainText) + write() + } + // When the filter changes due to how TUI works, the mousemove might still be triggered // via a synthetic event as the layout moves underneath the cursor. This is a workaround to make sure the input mode remains keyboard so // that the mouseover event doesn't trigger when filtering. @@ -363,11 +396,7 @@ export function Autocomplete(props: { display: "/" + serverCommand.name + label, description: serverCommand.description, onSelect: () => { - const newText = "/" + serverCommand.name + " " - const cursor = props.input().logicalCursor - props.input().deleteRange(0, 0, cursor.row, cursor.col) - props.input().insertText(newText) - props.input().cursorOffset = Bun.stringWidth(newText) + lead("/" + serverCommand.name + " ") }, }) } @@ -484,15 +513,7 @@ export function Autocomplete(props: { } function hide() { - const text = props.input().plainText - if (store.visible === "/" && !text.endsWith(" ") && text.startsWith("/")) { - const cursor = props.input().logicalCursor - props.input().deleteRange(0, 0, cursor.row, cursor.col) - // Sync the prompt store immediately since onContentChange is async - props.setPrompt((draft) => { - draft.input = props.input().plainText - }) - } + cut() command.keybinds(true) setStore("visible", false) } @@ -504,13 +525,17 @@ export function Autocomplete(props: { }, onInput(value) { if (store.visible) { + if (store.visible === "/") { + const hit = scan(value, props.input().cursorOffset) + if (!hit || hit.start !== store.index) { + hide() + } + return + } + if ( - // Typed text before the trigger props.input().cursorOffset <= store.index || - // There is a space between the trigger and the cursor - props.input().getTextRange(store.index, props.input().cursorOffset).match(/\s/) || - // "/" is not the sole content - (store.visible === "/" && value.match(/^\S+\s+\S+\s*$/)) + props.input().getTextRange(store.index, props.input().cursorOffset).match(/\s/) ) { hide() } @@ -521,10 +546,10 @@ export function Autocomplete(props: { const offset = props.input().cursorOffset if (offset === 0) return - // Check for "/" at position 0 - reopen slash commands - if (value.startsWith("/") && !value.slice(0, offset).match(/\s/)) { + const hit = scan(value, offset) + if (hit) { show("/") - setStore("index", 0) + setStore("index", hit.start) return } @@ -590,7 +615,7 @@ export function Autocomplete(props: { } if (e.name === "/") { - if (props.input().cursorOffset === 0) show("/") + show("/") } } }, diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/edit.test.ts b/packages/opencode/src/cli/cmd/tui/component/prompt/edit.test.ts new file mode 100644 index 000000000000..974a30ebecc8 --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/edit.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, test } from "bun:test" +import { lead } from "./edit" + +describe("prompt edit", () => { + test("leads prompt text and shifts parts", () => { + const next = lead( + { + input: "hello @build @src/app.ts [pasted]", + parts: [ + { + type: "agent", + name: "build", + source: { start: 6, end: 12, value: "@build" }, + }, + { + type: "file", + mime: "text/plain", + filename: "src/app.ts", + url: "file:///src/app.ts", + source: { + type: "file", + path: "src/app.ts", + text: { start: 13, end: 24, value: "@src/app.ts" }, + }, + }, + { + type: "text", + text: "pasted", + source: { + text: { start: 25, end: 33, value: "[pasted]" }, + }, + }, + ], + }, + "/skill ", + ) + + expect(next.input).toBe("/skill hello @build @src/app.ts [pasted]") + expect(next.parts).toEqual([ + { + type: "agent", + name: "build", + source: { start: 13, end: 19, value: "@build" }, + }, + { + type: "file", + mime: "text/plain", + filename: "src/app.ts", + url: "file:///src/app.ts", + source: { + type: "file", + path: "src/app.ts", + text: { start: 20, end: 31, value: "@src/app.ts" }, + }, + }, + { + type: "text", + text: "pasted", + source: { + text: { start: 32, end: 40, value: "[pasted]" }, + }, + }, + ]) + }) +}) diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/edit.ts b/packages/opencode/src/cli/cmd/tui/component/prompt/edit.ts new file mode 100644 index 000000000000..a4908c8638ea --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/edit.ts @@ -0,0 +1,52 @@ +import type { PromptInfo } from "./history" + +export function lead(prompt: PromptInfo, text: string): PromptInfo { + const size = Bun.stringWidth(text) + + return { + ...prompt, + input: text + prompt.input, + parts: prompt.parts.map((part): PromptInfo["parts"][number] => { + if (part.type === "file" && part.source?.text) { + return { + ...part, + source: { + ...part.source, + text: { + ...part.source.text, + start: part.source.text.start + size, + end: part.source.text.end + size, + }, + }, + } + } + + if (part.type === "text" && part.source?.text) { + return { + ...part, + source: { + ...part.source, + text: { + ...part.source.text, + start: part.source.text.start + size, + end: part.source.text.end + size, + }, + }, + } + } + + if (part.type === "agent" && part.source) { + return { + ...part, + source: { + ...part.source, + start: part.source.start + size, + end: part.source.end + size, + }, + } + } + + return part + }), + } +} diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx index c85426cc2471..a074a0548210 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx @@ -14,6 +14,7 @@ import { createStore, produce } from "solid-js/store" import { useKeybind } from "@tui/context/keybind" import { usePromptHistory, type PromptInfo } from "./history" import { usePromptStash } from "./stash" +import { lead } from "./edit" import { DialogStash } from "../dialog-stash" import { type AutocompleteRef, Autocomplete } from "./autocomplete" import { useCommandDialog } from "../dialog-command" @@ -341,11 +342,11 @@ export function Prompt(props: PromptProps) { dialog.replace(() => ( { - input.setText(`/${skill} `) - setStore("prompt", { - input: `/${skill} `, - parts: [], - }) + syncExtmarksWithPromptParts() + const next = lead(store.prompt, `/${skill} `) + input.setText(next.input) + setStore("prompt", next) + restoreExtmarksFromParts(next.parts) input.gotoBufferEnd() }} />