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
Original file line number Diff line number Diff line change
@@ -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 ")
})
})
Original file line number Diff line number Diff line change
@@ -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)
}
71 changes: 48 additions & 23 deletions packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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 + " ")
},
})
}
Expand Down Expand Up @@ -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)
}
Expand All @@ -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/) ||
// "/<command>" is not the sole content
(store.visible === "/" && value.match(/^\S+\s+\S+\s*$/))
props.input().getTextRange(store.index, props.input().cursorOffset).match(/\s/)
) {
hide()
}
Expand All @@ -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
}

Expand Down Expand Up @@ -590,7 +615,7 @@ export function Autocomplete(props: {
}

if (e.name === "/") {
if (props.input().cursorOffset === 0) show("/")
show("/")
}
}
},
Expand Down
65 changes: 65 additions & 0 deletions packages/opencode/src/cli/cmd/tui/component/prompt/edit.test.ts
Original file line number Diff line number Diff line change
@@ -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]" },
},
},
])
})
})
52 changes: 52 additions & 0 deletions packages/opencode/src/cli/cmd/tui/component/prompt/edit.ts
Original file line number Diff line number Diff line change
@@ -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
}),
}
}
11 changes: 6 additions & 5 deletions packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -341,11 +342,11 @@ export function Prompt(props: PromptProps) {
dialog.replace(() => (
<DialogSkill
onSelect={(skill) => {
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()
}}
/>
Expand Down
Loading