From aa832b9c2a27dc8c7e8371428870050251866350 Mon Sep 17 00:00:00 2001 From: Lucas Lopes Date: Mon, 9 Mar 2026 21:44:48 -0300 Subject: [PATCH 1/2] fix(prompt): support inline slash command selection Allow slash autocomplete to follow the token at the caret and keep prompt parts aligned when prepending slash commands, so skills and inline command edits do not drop existing content. --- .../prompt/autocomplete-slash.test.ts | 27 +++++++ .../component/prompt/autocomplete-slash.ts | 30 ++++++++ .../cmd/tui/component/prompt/autocomplete.tsx | 71 +++++++++++++------ .../cli/cmd/tui/component/prompt/edit.test.ts | 65 +++++++++++++++++ .../src/cli/cmd/tui/component/prompt/edit.ts | 36 ++++++++++ .../cli/cmd/tui/component/prompt/index.tsx | 11 +-- 6 files changed, 212 insertions(+), 28 deletions(-) create mode 100644 packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete-slash.test.ts create mode 100644 packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete-slash.ts create mode 100644 packages/opencode/src/cli/cmd/tui/component/prompt/edit.test.ts create mode 100644 packages/opencode/src/cli/cmd/tui/component/prompt/edit.ts 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..58dbadd028fd --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/edit.ts @@ -0,0 +1,36 @@ +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) => { + if ((part.type === "file" || 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, + }, + }, + } + } else 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() }} /> From 9172b91ad54f45423ce91ff117bbb958e8592f83 Mon Sep 17 00:00:00 2001 From: Lucas Lopes Date: Mon, 9 Mar 2026 22:48:08 -0300 Subject: [PATCH 2/2] fix(prompt): preserve prompt part types --- .../src/cli/cmd/tui/component/prompt/edit.ts | 22 ++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/edit.ts b/packages/opencode/src/cli/cmd/tui/component/prompt/edit.ts index 58dbadd028fd..a4908c8638ea 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/edit.ts +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/edit.ts @@ -6,8 +6,8 @@ export function lead(prompt: PromptInfo, text: string): PromptInfo { return { ...prompt, input: text + prompt.input, - parts: prompt.parts.map((part) => { - if ((part.type === "file" || part.type === "text") && part.source?.text) { + parts: prompt.parts.map((part): PromptInfo["parts"][number] => { + if (part.type === "file" && part.source?.text) { return { ...part, source: { @@ -19,7 +19,23 @@ export function lead(prompt: PromptInfo, text: string): PromptInfo { }, }, } - } else if (part.type === "agent" && part.source) { + } + + 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: {