mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-11 20:19:53 -04:00
Compare commits
8 Commits
beta
...
cd-autocomplete
| Author | SHA1 | Date | |
|---|---|---|---|
| 4f08d14c98 | |||
| 7833f92514 | |||
| d0f75776ca | |||
| 527aa6fa4d | |||
| edc667c650 | |||
| 021dc17ad7 | |||
| 1ff43c8d8a | |||
| 56a70de106 |
@@ -18,17 +18,24 @@ import { useTerminalDimensions } from "@opentui/solid"
|
||||
import { Locale } from "../../util/locale"
|
||||
import type { PromptInfo, PromptPartRef } from "../../prompt/history"
|
||||
import { useFrecency } from "../../prompt/frecency"
|
||||
import { Keymap } from "../../context/keymap"
|
||||
import { Keymap, type KeymapCommand } from "../../context/keymap"
|
||||
import { displayCharAt, mentionTriggerIndex, slashTriggerIndex } from "../../prompt/display"
|
||||
import type { FileSystemEntry } from "@opencode-ai/client"
|
||||
import { Skill } from "@opencode-ai/schema/skill"
|
||||
import { stringWidth } from "../../util/string-width"
|
||||
import { parseFileLineRange, stripFileLineRange } from "../../prompt/parse"
|
||||
import { moveSelection, revealSelectionOffset } from "../../ui/select-controller"
|
||||
import {
|
||||
directoryAutocompleteExactValue,
|
||||
directoryAutocompleteMatches,
|
||||
directoryAutocompleteResultValue,
|
||||
directoryAutocompleteSearch,
|
||||
slashArgumentAutocomplete,
|
||||
} from "../../prompt/directory-completion"
|
||||
|
||||
export type AutocompleteRef = {
|
||||
onInput: (value: string) => void
|
||||
visible: false | "@" | "/"
|
||||
visible: false | "@" | "/" | "directory"
|
||||
}
|
||||
|
||||
export type AutocompleteOption = {
|
||||
@@ -40,12 +47,24 @@ export type AutocompleteOption = {
|
||||
isDirectory?: boolean
|
||||
onSelect?: () => void
|
||||
path?: string
|
||||
absolute?: string
|
||||
destructive?: { id: string; confirm: string; run: () => void }
|
||||
kind?: "skill"
|
||||
}
|
||||
|
||||
type AutocompleteResults = {
|
||||
options: AutocompleteOption[]
|
||||
failed: boolean
|
||||
mode: AutocompleteRef["visible"]
|
||||
query: string
|
||||
resolved: boolean
|
||||
}
|
||||
|
||||
export function Autocomplete(props: {
|
||||
value: string
|
||||
sessionID?: string
|
||||
argumentAutocomplete?: (command: KeymapCommand) => "directory" | undefined
|
||||
directoryOptions?: (query: string) => AutocompleteOption[]
|
||||
setPrompt: (input: (prompt: PromptInfo) => void) => void
|
||||
setExtmark: (part: PromptPartRef, extmarkId: number) => void
|
||||
anchor: () => BoxRenderable
|
||||
@@ -76,6 +95,8 @@ export function Autocomplete(props: {
|
||||
})
|
||||
|
||||
const [positionTick, setPositionTick] = createSignal(0)
|
||||
const [dismissedValue, setDismissedValue] = createSignal<string>()
|
||||
const [confirming, setConfirming] = createSignal<string>()
|
||||
|
||||
createEffect(() => {
|
||||
if (!store.visible) return
|
||||
@@ -119,7 +140,9 @@ export function Autocomplete(props: {
|
||||
// Track props.value to make memo reactive to text changes
|
||||
props.value // <- there surely is a better way to do this, like making .input() reactive
|
||||
|
||||
return props.input().getTextRange(store.index + 1, props.input().cursorOffset)
|
||||
return props
|
||||
.input()
|
||||
.getTextRange(store.visible === "directory" ? store.index : store.index + 1, props.input().cursorOffset)
|
||||
})
|
||||
|
||||
// filter() reads reactive props.value plus non-reactive cursor/text state.
|
||||
@@ -266,7 +289,7 @@ export function Autocomplete(props: {
|
||||
const references = createMemo(() => data.location.reference.list() ?? [])
|
||||
|
||||
const referenceMatch = createMemo(() => {
|
||||
if (!store.visible || store.visible === "/") return
|
||||
if (store.visible !== "@") return
|
||||
const base = parseFileLineRange(search()).base
|
||||
const slash = base.indexOf("/")
|
||||
const alias = slash === -1 ? base : base.slice(0, slash)
|
||||
@@ -299,36 +322,82 @@ export function Autocomplete(props: {
|
||||
insertPart(filename, part)
|
||||
}
|
||||
|
||||
function insertDirectory(directory: string) {
|
||||
const input = props.input()
|
||||
const cursorOffset = input.cursorOffset
|
||||
input.cursorOffset = store.index
|
||||
const start = input.logicalCursor
|
||||
input.cursorOffset = cursorOffset
|
||||
const end = input.logicalCursor
|
||||
input.deleteRange(start.row, start.col, end.row, end.col)
|
||||
input.insertText(directory)
|
||||
}
|
||||
|
||||
const [files] = createResource(
|
||||
() => ({ query: search(), location: location.current, visible: store.visible }),
|
||||
async (input) => {
|
||||
if (!input.visible || input.visible === "/") return { options: [], failed: false }
|
||||
if (referenceMatch()) return { options: [], failed: false }
|
||||
async (input, info): Promise<AutocompleteResults> => {
|
||||
if (!input.visible || input.visible === "/")
|
||||
return { options: [], failed: false, mode: input.visible, query: input.query, resolved: true }
|
||||
if (referenceMatch())
|
||||
return { options: [], failed: false, mode: input.visible, query: input.query, resolved: true }
|
||||
const { lineRange, base } = parseFileLineRange(input.query ?? "")
|
||||
const directorySearch =
|
||||
input.visible === "directory"
|
||||
? directoryAutocompleteSearch(base, input.location?.directory ?? paths.cwd, paths.home)
|
||||
: undefined
|
||||
|
||||
const result = await client.api.file
|
||||
.find({
|
||||
query: base,
|
||||
limit: 20,
|
||||
location: {
|
||||
directory: input.location?.directory,
|
||||
workspace: input.location?.workspaceID ?? data.location.default().workspaceID,
|
||||
},
|
||||
})
|
||||
.then(
|
||||
(result) => result,
|
||||
() => undefined,
|
||||
)
|
||||
const requestLocation = {
|
||||
directory: directorySearch?.directory ?? input.location?.directory,
|
||||
workspace: input.location?.workspaceID ?? data.location.default().workspaceID,
|
||||
}
|
||||
const result = await (
|
||||
input.visible === "directory"
|
||||
? client.api.file.list({ location: requestLocation })
|
||||
: client.api.file.find({ query: base, limit: 20, location: requestLocation })
|
||||
).then(
|
||||
(result) => result,
|
||||
() => undefined,
|
||||
)
|
||||
|
||||
if (!result) return { options: [], failed: true }
|
||||
if (!result)
|
||||
return info.value?.mode === input.visible
|
||||
? { ...info.value, failed: true }
|
||||
: { options: [], failed: true, mode: input.visible, query: input.query, resolved: false }
|
||||
|
||||
const options: AutocompleteOption[] = []
|
||||
|
||||
// Add file options. Trust the order returned by fff (frecency, fuzzy
|
||||
// score, filename bonus, etc. are already factored in).
|
||||
const width = props.anchor().width - 4
|
||||
const exact = directorySearch ? directoryAutocompleteExactValue(base, directorySearch) : undefined
|
||||
if (exact) {
|
||||
options.push({
|
||||
display: Locale.truncateMiddle(exact, width),
|
||||
value: exact,
|
||||
isDirectory: true,
|
||||
path: exact,
|
||||
absolute: result.location.directory,
|
||||
onSelect: () => insertDirectory(exact),
|
||||
})
|
||||
}
|
||||
const entries =
|
||||
input.visible === "directory"
|
||||
? result.data.filter(
|
||||
(item) =>
|
||||
item.type === "directory" && directoryAutocompleteMatches(item.path, directorySearch?.query ?? ""),
|
||||
)
|
||||
: result.data
|
||||
options.push(
|
||||
...result.data.map((item): AutocompleteOption => {
|
||||
...entries.map((item): AutocompleteOption => {
|
||||
if (input.visible === "directory") {
|
||||
const directory = directorySearch ? directoryAutocompleteResultValue(item.path, directorySearch) : item.path
|
||||
return {
|
||||
display: Locale.truncateMiddle(directory, width),
|
||||
value: directory,
|
||||
isDirectory: true,
|
||||
path: directory,
|
||||
absolute: path.resolve(result.location.directory, item.path),
|
||||
onSelect: () => insertDirectory(directory),
|
||||
}
|
||||
}
|
||||
const { filename, part } = createFilePart(item, path.join(result.location.directory, item.path), lineRange)
|
||||
return {
|
||||
display: Locale.truncateMiddle(filename, width),
|
||||
@@ -342,15 +411,27 @@ export function Autocomplete(props: {
|
||||
}),
|
||||
)
|
||||
|
||||
return { options, failed: false }
|
||||
return { options, failed: false, mode: input.visible, query: input.query, resolved: true }
|
||||
},
|
||||
{
|
||||
initialValue: { options: [], failed: false },
|
||||
initialValue: {
|
||||
options: [],
|
||||
failed: false,
|
||||
mode: false as AutocompleteRef["visible"],
|
||||
query: "",
|
||||
resolved: false,
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
const visibleFiles = createMemo(() => {
|
||||
const value = files.loading ? files.latest : files()
|
||||
if (value?.mode === store.visible) return value
|
||||
return { options: [], failed: false, query: "", resolved: false }
|
||||
})
|
||||
|
||||
const mcpResources = createMemo(() => {
|
||||
if (!store.visible || store.visible === "/") return []
|
||||
if (store.visible !== "@") return []
|
||||
|
||||
const options: AutocompleteOption[] = []
|
||||
const width = props.anchor().width - 4
|
||||
@@ -474,14 +555,34 @@ export function Autocomplete(props: {
|
||||
}))
|
||||
})
|
||||
|
||||
const supplementalDirectoryOptions = createMemo((): AutocompleteOption[] => {
|
||||
const results = visibleFiles()
|
||||
if (store.visible !== "directory" || !results.resolved) return []
|
||||
const width = props.anchor().width - 4
|
||||
return (props.directoryOptions?.(results.query) ?? []).map((item) => {
|
||||
const value = item.value
|
||||
return {
|
||||
...item,
|
||||
display: Locale.truncateMiddle(item.display, width),
|
||||
onSelect: item.onSelect ?? (value ? () => insertDirectory(value) : undefined),
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
const options = createMemo(() => {
|
||||
const fileSearch = files()
|
||||
const fileSearch = visibleFiles()
|
||||
const referenceMatchValue = referenceMatch()
|
||||
const agentsValue = agents()
|
||||
const referenceAliasesValue = referenceAliases()
|
||||
const commandsValue = commands()
|
||||
const searchValue = search()
|
||||
|
||||
if (store.visible === "directory") {
|
||||
const supplemental = supplementalDirectoryOptions()
|
||||
const paths = new Set(supplemental.map((item) => item.absolute))
|
||||
return [...supplemental, ...fileSearch.options.filter((item) => !paths.has(item.absolute))]
|
||||
}
|
||||
|
||||
if (store.visible === "@" && referenceMatchValue) {
|
||||
return referenceAliasesValue.filter((item) => item.display === `@${referenceMatchValue.name}`)
|
||||
}
|
||||
@@ -528,6 +629,7 @@ export function Autocomplete(props: {
|
||||
createEffect(() => {
|
||||
filter()
|
||||
setStore("selected", 0)
|
||||
setConfirming(undefined)
|
||||
})
|
||||
|
||||
function move(direction: -1 | 1) {
|
||||
@@ -537,6 +639,7 @@ export function Autocomplete(props: {
|
||||
}
|
||||
|
||||
function moveTo(next: number) {
|
||||
if (next !== store.selected) setConfirming(undefined)
|
||||
setStore("selected", next)
|
||||
if (!scroll) return
|
||||
const offset = revealSelectionOffset(scroll.scrollTop, {
|
||||
@@ -551,8 +654,26 @@ export function Autocomplete(props: {
|
||||
function select() {
|
||||
const selected = options()[store.selected]
|
||||
if (!selected) return
|
||||
hide(true)
|
||||
if (store.visible !== "directory") {
|
||||
hide(true)
|
||||
selected.onSelect?.()
|
||||
return
|
||||
}
|
||||
selected.onSelect?.()
|
||||
setDismissedValue(props.input().plainText)
|
||||
hide(true)
|
||||
}
|
||||
|
||||
function triggerDestructive() {
|
||||
const action = options()[store.selected]?.destructive
|
||||
if (!action) return false
|
||||
if (confirming() !== action.id) {
|
||||
setConfirming(action.id)
|
||||
return
|
||||
}
|
||||
action.run()
|
||||
setStore("selected", Math.max(0, Math.min(store.selected, options().length - 2)))
|
||||
setConfirming(undefined)
|
||||
}
|
||||
|
||||
function expandDirectory() {
|
||||
@@ -563,7 +684,13 @@ export function Autocomplete(props: {
|
||||
const currentCursorOffset = input.cursorOffset
|
||||
|
||||
const displayText = (selected.value ?? selected.display).trimEnd()
|
||||
const path = displayText.startsWith("@") ? displayText.slice(1) : displayText
|
||||
const selectedPath = displayText.startsWith("@") ? displayText.slice(1) : displayText
|
||||
|
||||
if (store.visible === "directory") {
|
||||
insertDirectory(selectedPath.endsWith(path.sep) ? selectedPath : selectedPath + path.sep)
|
||||
setStore("selected", 0)
|
||||
return
|
||||
}
|
||||
|
||||
input.cursorOffset = store.index
|
||||
const startCursor = input.logicalCursor
|
||||
@@ -571,7 +698,7 @@ export function Autocomplete(props: {
|
||||
const endCursor = input.logicalCursor
|
||||
|
||||
input.deleteRange(startCursor.row, startCursor.col, endCursor.row, endCursor.col)
|
||||
input.insertText("@" + path + "/")
|
||||
input.insertText("@" + selectedPath + "/")
|
||||
|
||||
setStore("selected", 0)
|
||||
}
|
||||
@@ -629,13 +756,20 @@ export function Autocomplete(props: {
|
||||
select()
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "prompt.autocomplete.destructive",
|
||||
title: "Confirm autocomplete action",
|
||||
group: "Autocomplete",
|
||||
bind: "ctrl+d",
|
||||
run: triggerDestructive,
|
||||
},
|
||||
],
|
||||
}))
|
||||
|
||||
function show(mode: "@" | "/") {
|
||||
function show(mode: Exclude<AutocompleteRef["visible"], false>, index = props.input().cursorOffset) {
|
||||
setStore({
|
||||
visible: mode,
|
||||
index: props.input().cursorOffset,
|
||||
index,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -653,6 +787,7 @@ export function Autocomplete(props: {
|
||||
draft.text = input.plainText
|
||||
})
|
||||
}
|
||||
setConfirming(undefined)
|
||||
setStore("visible", false)
|
||||
}
|
||||
|
||||
@@ -670,6 +805,15 @@ export function Autocomplete(props: {
|
||||
return store.visible
|
||||
},
|
||||
onInput(value) {
|
||||
if (dismissedValue() === value) return
|
||||
setDismissedValue(undefined)
|
||||
const offset = props.input().cursorOffset
|
||||
const argument = slashArgumentAutocomplete(value, offset, keymapCommands(), props.argumentAutocomplete)
|
||||
if (argument?.type === "directory") {
|
||||
show("directory", argument.index)
|
||||
return
|
||||
}
|
||||
|
||||
if (store.visible) {
|
||||
if (
|
||||
// Typed text before the trigger
|
||||
@@ -683,7 +827,6 @@ export function Autocomplete(props: {
|
||||
}
|
||||
|
||||
// Check if autocomplete should reopen (e.g., after backspace deleted a space)
|
||||
const offset = props.input().cursorOffset
|
||||
if (offset === 0) return
|
||||
|
||||
const slash = slashTriggerIndex(value, offset)
|
||||
@@ -713,12 +856,18 @@ export function Autocomplete(props: {
|
||||
let scroll: ScrollBoxRenderable
|
||||
const scrollAcceleration = createMemo(() => getScrollAcceleration(config))
|
||||
const emptyMessage = createMemo(() => {
|
||||
const fileSearch = visibleFiles()
|
||||
if (store.visible === "/") return "No matching commands"
|
||||
if (store.visible === "directory") {
|
||||
if (files.loading) return "Searching…"
|
||||
if (fileSearch.failed) return "Could not search directories. Keep typing to try again."
|
||||
return "No matching directories"
|
||||
}
|
||||
if (files.loading) return "Searching…"
|
||||
if (files().failed) return "Could not search files. Keep typing to try again."
|
||||
if (fileSearch.failed) return "Could not search files. Keep typing to try again."
|
||||
return "No matching files, agents, or references"
|
||||
})
|
||||
const emptyError = createMemo(() => store.visible === "@" && !files.loading && files().failed)
|
||||
const emptyError = createMemo(() => store.visible === "@" && !files.loading && visibleFiles().failed)
|
||||
|
||||
return (
|
||||
<box
|
||||
@@ -746,41 +895,60 @@ export function Autocomplete(props: {
|
||||
</box>
|
||||
}
|
||||
>
|
||||
{(option, index) => (
|
||||
<box
|
||||
paddingLeft={1}
|
||||
paddingRight={1}
|
||||
backgroundColor={index === store.selected ? theme.background.action.primary.focused : undefined}
|
||||
flexDirection="row"
|
||||
onMouseMove={() => {
|
||||
setStore("input", "mouse")
|
||||
}}
|
||||
onMouseOver={() => {
|
||||
if (store.input !== "mouse") return
|
||||
moveTo(index)
|
||||
}}
|
||||
onMouseDown={() => {
|
||||
setStore("input", "mouse")
|
||||
moveTo(index)
|
||||
}}
|
||||
onMouseUp={() => select()}
|
||||
>
|
||||
<text
|
||||
fg={index === store.selected ? theme.text.action.primary.focused : theme.text.default}
|
||||
flexShrink={0}
|
||||
{(option, index) => {
|
||||
const destructive = () => option().destructive
|
||||
const confirmingAction = () => {
|
||||
const action = destructive()
|
||||
return action !== undefined && action.id === confirming()
|
||||
}
|
||||
return (
|
||||
<box
|
||||
paddingLeft={1}
|
||||
paddingRight={1}
|
||||
backgroundColor={
|
||||
confirmingAction()
|
||||
? theme.background.action.destructive.focused
|
||||
: index === store.selected
|
||||
? theme.background.action.primary.focused
|
||||
: undefined
|
||||
}
|
||||
flexDirection="row"
|
||||
onMouseMove={() => {
|
||||
setStore("input", "mouse")
|
||||
}}
|
||||
onMouseOver={() => {
|
||||
if (store.input !== "mouse") return
|
||||
moveTo(index)
|
||||
}}
|
||||
onMouseDown={() => {
|
||||
setStore("input", "mouse")
|
||||
moveTo(index)
|
||||
}}
|
||||
onMouseUp={() => select()}
|
||||
>
|
||||
{option().display}
|
||||
</text>
|
||||
<Show when={option().description}>
|
||||
<text
|
||||
fg={index === store.selected ? theme.text.action.primary.focused : theme.text.subdued}
|
||||
wrapMode="none"
|
||||
fg={
|
||||
confirmingAction()
|
||||
? theme.text.action.destructive.focused
|
||||
: index === store.selected
|
||||
? theme.text.action.primary.focused
|
||||
: theme.text.default
|
||||
}
|
||||
flexShrink={0}
|
||||
>
|
||||
{" " + option().description?.trimStart()}
|
||||
{confirmingAction() ? destructive()?.confirm : option().display}
|
||||
</text>
|
||||
</Show>
|
||||
</box>
|
||||
)}
|
||||
<Show when={!confirmingAction() && option().description}>
|
||||
<text
|
||||
fg={index === store.selected ? theme.text.action.primary.focused : theme.text.subdued}
|
||||
wrapMode="none"
|
||||
>
|
||||
{" " + option().description?.trimStart()}
|
||||
</text>
|
||||
</Show>
|
||||
</box>
|
||||
)
|
||||
}}
|
||||
</Index>
|
||||
</scrollbox>
|
||||
</box>
|
||||
|
||||
@@ -33,7 +33,7 @@ import { computePromptTraits } from "../../prompt/traits"
|
||||
import { expandPastedTextPlaceholders, expandTrackedPastedText } from "../../prompt/part"
|
||||
import { usePromptStash } from "../../prompt/stash"
|
||||
import { DialogStash } from "../dialog-stash"
|
||||
import { type AutocompleteRef, Autocomplete } from "./autocomplete"
|
||||
import { type AutocompleteOption, type AutocompleteRef, Autocomplete } from "./autocomplete"
|
||||
import { useRenderer, useTerminalDimensions, type JSX } from "@opentui/solid"
|
||||
import { Locale } from "../../util/locale"
|
||||
import { errorMessage } from "../../util/error"
|
||||
@@ -66,6 +66,8 @@ import {
|
||||
promptAttachmentLabel,
|
||||
} from "../../prompt/attachment"
|
||||
import { DialogImagePreview } from "../dialog-image-preview"
|
||||
import { useDirectoryRecents } from "../../prompt/directory-recents"
|
||||
import { directoryRecentValue } from "../../prompt/directory-completion"
|
||||
|
||||
export type PromptProps = {
|
||||
sessionID?: string
|
||||
@@ -159,6 +161,7 @@ export function Prompt(props: PromptProps) {
|
||||
const editor = useEditorContext()
|
||||
const route = useRoute()
|
||||
const data = useData()
|
||||
const directoryRecents = useDirectoryRecents()
|
||||
const keymapCommands = Keymap.useCommands()
|
||||
const currentLocation = useLocation()
|
||||
const config = useConfig().data
|
||||
@@ -227,27 +230,34 @@ export function Prompt(props: PromptProps) {
|
||||
return
|
||||
}
|
||||
const sessionID = props.sessionID
|
||||
const session = sessionID ? data.session.get(sessionID) : undefined
|
||||
const sourceProjectID = session?.projectID ?? data.location.info()?.project.id
|
||||
const value = input.trim()
|
||||
const expanded =
|
||||
value === "~" ? paths.home : value.startsWith("~/") ? path.join(paths.home, value.slice(2)) : value
|
||||
const directory = path.resolve(
|
||||
session?.location.directory ?? currentLocation.current?.directory ?? data.location.default().directory,
|
||||
expanded,
|
||||
)
|
||||
if (!sessionID) {
|
||||
const value = input.trim()
|
||||
const expanded =
|
||||
value === "~" ? paths.home : value.startsWith("~/") ? path.join(paths.home, value.slice(2)) : value
|
||||
const directory = path.resolve(
|
||||
currentLocation.current?.directory ?? data.location.default().directory,
|
||||
expanded,
|
||||
)
|
||||
const location = await client.api.location.get({ location: { directory } }).catch((error) => {
|
||||
toast.show({ title: "Failed to change directory", message: errorMessage(error), variant: "error" })
|
||||
return undefined
|
||||
})
|
||||
if (!location) return
|
||||
if (sourceProjectID) directoryRecents.touch(sourceProjectID, location.directory)
|
||||
currentLocation.set(location)
|
||||
return
|
||||
}
|
||||
await client.api.session
|
||||
.move({ sessionID, directory: input })
|
||||
.catch((error) =>
|
||||
toast.show({ title: "Failed to change directory", message: errorMessage(error), variant: "error" }),
|
||||
)
|
||||
const error = await client.api.session.move({ sessionID, directory: input }).then(
|
||||
() => undefined,
|
||||
(error) => error,
|
||||
)
|
||||
if (error) {
|
||||
toast.show({ title: "Failed to change directory", message: errorMessage(error), variant: "error" })
|
||||
return
|
||||
}
|
||||
if (sourceProjectID) directoryRecents.touch(sourceProjectID, directory)
|
||||
},
|
||||
},
|
||||
],
|
||||
@@ -1855,6 +1865,30 @@ export function Prompt(props: PromptProps) {
|
||||
</box>
|
||||
<Autocomplete
|
||||
sessionID={props.sessionID}
|
||||
argumentAutocomplete={(command) => (command.id === "session.cd" ? "directory" : undefined)}
|
||||
directoryOptions={(query): AutocompleteOption[] => {
|
||||
if (query !== "") return []
|
||||
const projectID =
|
||||
(props.sessionID ? data.session.get(props.sessionID)?.projectID : undefined) ??
|
||||
data.location.info()?.project.id
|
||||
if (!projectID) return []
|
||||
return directoryRecents.list(projectID).map((item) => {
|
||||
const value = directoryRecentValue(item.directory, paths.home)
|
||||
return {
|
||||
display: value,
|
||||
value,
|
||||
description: "recent",
|
||||
isDirectory: true,
|
||||
path: value,
|
||||
absolute: item.directory,
|
||||
destructive: {
|
||||
id: item.directory,
|
||||
confirm: "Press ctrl+d to confirm",
|
||||
run: () => directoryRecents.remove(projectID, item.directory),
|
||||
},
|
||||
}
|
||||
})
|
||||
}}
|
||||
ref={(r) => {
|
||||
setAuto(() => r)
|
||||
}}
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import type { KeymapCommand } from "@opencode-ai/plugin/tui/context"
|
||||
import path from "path"
|
||||
import { displaySlice, promptOffsetWidth } from "./display"
|
||||
import { parseSlashHead } from "./parse"
|
||||
|
||||
export function slashArgumentAutocomplete(
|
||||
value: string,
|
||||
offset: number,
|
||||
commands: readonly KeymapCommand[],
|
||||
autocomplete: ((command: KeymapCommand) => "directory" | undefined) | undefined,
|
||||
) {
|
||||
const beforeCursor = displaySlice(value, 0, offset)
|
||||
const head = parseSlashHead(beforeCursor, /\s/)
|
||||
if (!head || head.end === beforeCursor.length) return
|
||||
|
||||
const command = commands.find(
|
||||
(command) =>
|
||||
command.slash?.arguments &&
|
||||
(command.slash.name === head.name || command.slash.aliases?.includes(head.name) === true),
|
||||
)
|
||||
if (!command) return
|
||||
const type = autocomplete?.(command)
|
||||
if (!type) return
|
||||
|
||||
return {
|
||||
type,
|
||||
index: promptOffsetWidth(beforeCursor.slice(0, head.end + 1)),
|
||||
}
|
||||
}
|
||||
|
||||
export function directoryAutocompleteSearch(query: string, directory: string, home: string) {
|
||||
if (query === "~") return { directory: home, prefix: "~/", query: "" }
|
||||
if (query.startsWith("~/")) return directorySearch(query.slice(2), home, "~/")
|
||||
if (/^(?:\.\.\/)*\.\.$/.test(query))
|
||||
return { directory: path.resolve(directory, query), prefix: query + "/", query: "" }
|
||||
if (query.startsWith("/")) return directorySearch(query.slice(1), path.parse(directory).root, "/")
|
||||
return directorySearch(query, directory, "")
|
||||
}
|
||||
|
||||
function directorySearch(query: string, root: string, prefix: string) {
|
||||
const separator = query.lastIndexOf("/")
|
||||
if (separator === -1) return { directory: root, prefix, query }
|
||||
const parent = query.slice(0, separator + 1)
|
||||
return {
|
||||
directory: path.resolve(root, parent),
|
||||
prefix: prefix + parent,
|
||||
query: query.slice(separator + 1),
|
||||
}
|
||||
}
|
||||
|
||||
export function directoryAutocompleteResultValue(
|
||||
directory: string,
|
||||
search: ReturnType<typeof directoryAutocompleteSearch>,
|
||||
) {
|
||||
return (search.prefix || "./") + directory.replace(/^[\\/]+/, "")
|
||||
}
|
||||
|
||||
export function directoryAutocompleteExactValue(value: string, search: ReturnType<typeof directoryAutocompleteSearch>) {
|
||||
if (!value || !search.prefix || search.query) return
|
||||
return value
|
||||
}
|
||||
|
||||
export function directoryAutocompleteMatches(directory: string, query: string) {
|
||||
const value = directory.replace(/^[\\/]+/, "")
|
||||
if (!query && value.startsWith(".")) return false
|
||||
return value.toLowerCase().startsWith(query.toLowerCase())
|
||||
}
|
||||
|
||||
export function directoryRecentValue(directory: string, home: string) {
|
||||
const relative = path.relative(home, directory)
|
||||
if (!relative) return "~"
|
||||
if (relative !== ".." && !relative.startsWith(".." + path.sep) && !path.isAbsolute(relative))
|
||||
return "~/" + relative.split(path.sep).join("/")
|
||||
return directory
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { useStorage } from "../context/storage"
|
||||
|
||||
type RecentDirectory = {
|
||||
directory: string
|
||||
usedAt: number
|
||||
}
|
||||
|
||||
type PersistedState = {
|
||||
projects: Record<string, RecentDirectory[]>
|
||||
}
|
||||
|
||||
export function useDirectoryRecents() {
|
||||
const [store, updateStore] = useStorage().store<PersistedState>("directory-recents", {
|
||||
initial: { projects: {} },
|
||||
key: "directory",
|
||||
})
|
||||
|
||||
return {
|
||||
list(projectID: string) {
|
||||
return (store.projects[projectID] ?? []).toSorted((a, b) => b.usedAt - a.usedAt)
|
||||
},
|
||||
touch(projectID: string, directory: string) {
|
||||
void updateStore((draft) => {
|
||||
draft.projects[projectID] = [
|
||||
{ directory, usedAt: Date.now() },
|
||||
...(draft.projects[projectID] ?? []).filter((item) => item.directory !== directory),
|
||||
].slice(0, 10)
|
||||
}).catch((error) => console.error("Failed to persist directory recents", error))
|
||||
},
|
||||
remove(projectID: string, directory: string) {
|
||||
void updateStore((draft) => {
|
||||
draft.projects[projectID] = (draft.projects[projectID] ?? []).filter((item) => item.directory !== directory)
|
||||
}).catch((error) => console.error("Failed to remove directory recent", error))
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { KeymapCommand } from "@opencode-ai/plugin/tui/context"
|
||||
import {
|
||||
directoryAutocompleteExactValue,
|
||||
directoryAutocompleteMatches,
|
||||
directoryAutocompleteResultValue,
|
||||
directoryAutocompleteSearch,
|
||||
directoryRecentValue,
|
||||
slashArgumentAutocomplete,
|
||||
} from "../../src/prompt/directory-completion"
|
||||
|
||||
const commands = [
|
||||
{
|
||||
id: "session.cd",
|
||||
slash: { name: "cd", aliases: ["chdir"], arguments: true },
|
||||
run: () => undefined,
|
||||
},
|
||||
] satisfies KeymapCommand[]
|
||||
|
||||
const argumentAutocomplete = (command: KeymapCommand) =>
|
||||
command.id === "session.cd" ? ("directory" as const) : undefined
|
||||
|
||||
describe("slashArgumentAutocomplete", () => {
|
||||
test("starts after the command separator", () => {
|
||||
expect(slashArgumentAutocomplete("/cd ", 4, commands, argumentAutocomplete)).toEqual({
|
||||
type: "directory",
|
||||
index: 4,
|
||||
})
|
||||
expect(slashArgumentAutocomplete("/cd src", 7, commands, argumentAutocomplete)).toEqual({
|
||||
type: "directory",
|
||||
index: 4,
|
||||
})
|
||||
})
|
||||
|
||||
test("supports aliases", () => {
|
||||
expect(slashArgumentAutocomplete("/chdir src", 10, commands, argumentAutocomplete)).toEqual({
|
||||
type: "directory",
|
||||
index: 7,
|
||||
})
|
||||
})
|
||||
|
||||
test("does not complete the command token", () => {
|
||||
expect(slashArgumentAutocomplete("/cd", 3, commands, argumentAutocomplete)).toBeUndefined()
|
||||
expect(slashArgumentAutocomplete("/other ", 7, commands, argumentAutocomplete)).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe("directoryAutocompleteSearch", () => {
|
||||
test("searches from home after a home prefix", () => {
|
||||
expect(directoryAutocompleteSearch("~", "/project", "/home/user")).toEqual({
|
||||
directory: "/home/user",
|
||||
prefix: "~/",
|
||||
query: "",
|
||||
})
|
||||
expect(directoryAutocompleteSearch("~/pro", "/project", "/home/user")).toEqual({
|
||||
directory: "/home/user",
|
||||
prefix: "~/",
|
||||
query: "pro",
|
||||
})
|
||||
expect(directoryAutocompleteSearch("~/projects/open", "/project", "/home/user")).toEqual({
|
||||
directory: "/home/user/projects",
|
||||
prefix: "~/projects/",
|
||||
query: "open",
|
||||
})
|
||||
})
|
||||
|
||||
test("searches from parent prefixes", () => {
|
||||
expect(directoryAutocompleteSearch("..", "/project/src", "/home/user")).toEqual({
|
||||
directory: "/project",
|
||||
prefix: "../",
|
||||
query: "",
|
||||
})
|
||||
expect(directoryAutocompleteSearch("../../pac", "/project/src/lib", "/home/user")).toEqual({
|
||||
directory: "/project",
|
||||
prefix: "../../",
|
||||
query: "pac",
|
||||
})
|
||||
expect(directoryAutocompleteSearch("../../..", "/project/src/lib", "/home/user")).toEqual({
|
||||
directory: "/",
|
||||
prefix: "../../../",
|
||||
query: "",
|
||||
})
|
||||
})
|
||||
|
||||
test("keeps ordinary searches rooted at the current directory", () => {
|
||||
expect(directoryAutocompleteSearch("src", "/project", "/home/user")).toEqual({
|
||||
directory: "/project",
|
||||
prefix: "",
|
||||
query: "src",
|
||||
})
|
||||
expect(directoryAutocompleteSearch("packages/core", "/project", "/home/user")).toEqual({
|
||||
directory: "/project/packages",
|
||||
prefix: "packages/",
|
||||
query: "core",
|
||||
})
|
||||
expect(directoryAutocompleteSearch("/root/pro", "/project", "/home/user")).toEqual({
|
||||
directory: "/root",
|
||||
prefix: "/root/",
|
||||
query: "pro",
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("directoryAutocompleteResultValue", () => {
|
||||
test("marks current-directory results as relative", () => {
|
||||
const search = directoryAutocompleteSearch("", "/project", "/home/user")
|
||||
expect(directoryAutocompleteResultValue("src/", search)).toBe("./src/")
|
||||
expect(directoryAutocompleteResultValue("/src/", search)).toBe("./src/")
|
||||
expect(directoryAutocompleteResultValue("/", search)).toBe("./")
|
||||
})
|
||||
|
||||
test("preserves explicit roots", () => {
|
||||
expect(
|
||||
directoryAutocompleteResultValue("projects/", directoryAutocompleteSearch("~/", "/project", "/home/user")),
|
||||
).toBe("~/projects/")
|
||||
expect(
|
||||
directoryAutocompleteResultValue("src/", directoryAutocompleteSearch("../", "/project/pkg", "/home/user")),
|
||||
).toBe("../src/")
|
||||
})
|
||||
})
|
||||
|
||||
describe("directoryAutocompleteExactValue", () => {
|
||||
test("includes complete explicit roots", () => {
|
||||
expect(
|
||||
directoryAutocompleteExactValue("../..", directoryAutocompleteSearch("../..", "/project/pkg", "/home/user")),
|
||||
).toBe("../..")
|
||||
expect(directoryAutocompleteExactValue("~", directoryAutocompleteSearch("~", "/project", "/home/user"))).toBe("~")
|
||||
})
|
||||
|
||||
test("omits incomplete and implicit roots", () => {
|
||||
expect(
|
||||
directoryAutocompleteExactValue("../../src", directoryAutocompleteSearch("../../src", "/project", "/home/user")),
|
||||
).toBeUndefined()
|
||||
expect(
|
||||
directoryAutocompleteExactValue("", directoryAutocompleteSearch("", "/project", "/home/user")),
|
||||
).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe("directoryAutocompleteMatches", () => {
|
||||
test("hides dot directories for an empty component", () => {
|
||||
expect(directoryAutocompleteMatches("src/", "")).toBe(true)
|
||||
expect(directoryAutocompleteMatches(".git/", "")).toBe(false)
|
||||
})
|
||||
|
||||
test("shows dot directories when explicitly filtered", () => {
|
||||
expect(directoryAutocompleteMatches(".git/", ".")).toBe(true)
|
||||
expect(directoryAutocompleteMatches(".github/", ".gi")).toBe(true)
|
||||
expect(directoryAutocompleteMatches(".zed/", ".gi")).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("directoryRecentValue", () => {
|
||||
test("abbreviates home paths", () => {
|
||||
expect(directoryRecentValue("/home/user", "/home/user")).toBe("~")
|
||||
expect(directoryRecentValue("/home/user/projects/opencode", "/home/user")).toBe("~/projects/opencode")
|
||||
})
|
||||
|
||||
test("keeps paths outside home absolute", () => {
|
||||
expect(directoryRecentValue("/project/recent", "/home/user")).toBe("/project/recent")
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user