feat(tui): autocomplete cd directories (#41870)

This commit is contained in:
James Long 2026-08-11 20:52:16 -04:00 committed by GitHub
parent 0df3070d6d
commit 7987aed8f3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 567 additions and 91 deletions

View file

@ -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 | "reference" | "command" | "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 !== "reference") return
const base = parseFileLineRange(search()).base
const slash = base.indexOf("/")
const alias = slash === -1 ? base : base.slice(0, slash)
@ -292,43 +315,89 @@ export function Autocomplete(props: {
endLine: input.lineEnd > input.lineStart ? input.lineEnd : undefined,
}
const { filename, part } = createFilePart({ path: item, type: "file" }, input.filePath, lineRange)
const index = store.visible === "@" ? store.index : props.input().cursorOffset
const index = store.visible === "reference" ? store.index : props.input().cursorOffset
setStore("visible", false)
setStore("index", index)
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 === "command")
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 !== "reference") return []
const options: AutocompleteOption[] = []
const width = props.anchor().width - 4
@ -474,23 +555,43 @@ 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 === "@" && referenceMatchValue) {
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 === "reference" && referenceMatchValue) {
return referenceAliasesValue.filter((item) => item.display === `@${referenceMatchValue.name}`)
}
// Files come from fff already fuzzy ranked and filtered
// it shouldn't be additionally sorted by fuzzysort as it will loose the results
const fileOptions: AutocompleteOption[] = store.visible === "@" ? fileSearch.options : []
const fileOptions: AutocompleteOption[] = store.visible === "reference" ? fileSearch.options : []
const nonFileOptions: AutocompleteOption[] =
store.visible === "@"
store.visible === "reference"
? [...referenceAliasesValue, ...agentsValue, ...mcpResources()]
: store.index === 0
? [...commandsValue]
@ -505,15 +606,16 @@ export function Autocomplete(props: {
keys: [
(obj) => stripFileLineRange((obj.value ?? obj.display).trimEnd()),
// Match description for slash commands only; for "@" it surfaced unrelated items.
...(store.visible === "/" ? ["description" as const] : []),
...(store.visible === "command" ? ["description" as const] : []),
(obj) => obj.aliases?.join(" ") ?? "",
],
threshold: store.visible === "@" ? 0.5 : 0,
threshold: store.visible === "reference" ? 0.5 : 0,
limit: 10,
scoreFn: (objResults) => {
const displayResult = objResults[0]
let score = objResults.score
if (displayResult && displayResult.target.startsWith(store.visible + searchValue)) {
const prefix = store.visible === "reference" ? "@" : store.visible === "command" ? "/" : ""
if (displayResult && displayResult.target.startsWith(prefix + searchValue)) {
score *= 2
}
const frecencyScore = objResults.obj.path ? frecency.getFrecency(objResults.obj.path) : 0
@ -528,6 +630,7 @@ export function Autocomplete(props: {
createEffect(() => {
filter()
setStore("selected", 0)
setConfirming(undefined)
})
function move(direction: -1 | 1) {
@ -537,6 +640,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 +655,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 +685,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 +699,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,18 +757,25 @@ 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,
})
}
function hide(removeToken = false) {
if (removeToken && store.visible === "/") {
if (removeToken && store.visible === "command") {
const input = props.input()
const cursorOffset = input.cursorOffset
input.cursorOffset = store.index
@ -653,6 +788,7 @@ export function Autocomplete(props: {
draft.text = input.plainText
})
}
setConfirming(undefined)
setStore("visible", false)
}
@ -670,6 +806,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,12 +828,11 @@ 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)
if (slash !== undefined) {
show("/")
show("command")
setStore("index", slash)
return
}
@ -696,7 +840,7 @@ export function Autocomplete(props: {
// Check for "@" trigger - find the nearest "@" before cursor with no whitespace between
const idx = mentionTriggerIndex(value, offset)
if (idx !== undefined) {
show("@")
show("reference")
setStore("index", idx)
}
},
@ -713,12 +857,18 @@ export function Autocomplete(props: {
let scroll: ScrollBoxRenderable
const scrollAcceleration = createMemo(() => getScrollAcceleration(config))
const emptyMessage = createMemo(() => {
if (store.visible === "/") return "No matching commands"
const fileSearch = visibleFiles()
if (store.visible === "command") 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 === "reference" && !files.loading && visibleFiles().failed)
return (
<box
@ -746,41 +896,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>

View file

@ -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)
}}

View file

@ -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
}

View file

@ -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))
},
}
}

View file

@ -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")
})
})