Skip to content
Merged
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
121 changes: 26 additions & 95 deletions packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -271,47 +271,6 @@ export function Autocomplete(props: {
}
}

function createReferenceFilePart(input: {
alias: string
root: string
item: string
lineRange?: { startLine: number; endLine?: number }
}) {
const filename = `${input.alias}/${
input.lineRange && !input.item.endsWith("/")
? `${input.item}#${input.lineRange.startLine}${input.lineRange.endLine ? `-${input.lineRange.endLine}` : ""}`
: input.item
}`
const urlObj = pathToFileURL(path.join(input.root, input.item))

if (input.lineRange && !input.item.endsWith("/")) {
urlObj.searchParams.set("start", String(input.lineRange.startLine))
if (input.lineRange.endLine !== undefined) {
urlObj.searchParams.set("end", String(input.lineRange.endLine))
}
}

return {
filename,
url: urlObj.href,
part: {
type: "file" as const,
mime: input.item.endsWith("/") ? "application/x-directory" : "text/plain",
filename,
url: urlObj.href,
source: {
type: "file" as const,
text: {
start: 0,
end: 0,
value: "",
},
path: filename,
},
},
}
}

function referencePromptText(reference: Reference.Resolved) {
const problem = reference.kind === "invalid" ? reference.message : undefined
return [
Expand All @@ -336,18 +295,12 @@ export function Autocomplete(props: {
}),
)

const referenceSearch = createMemo(() => {
const referenceMatch = createMemo(() => {
if (!store.visible || store.visible === "/") return
const { lineRange, baseQuery } = extractLineRange(search())
const { baseQuery } = extractLineRange(search())
const slash = baseQuery.indexOf("/")
if (slash === -1) return
const reference = references().find((item) => item.name === baseQuery.slice(0, slash))
if (!reference || reference.kind === "invalid") return
return {
reference,
query: baseQuery.slice(slash + 1),
lineRange,
}
const alias = slash === -1 ? baseQuery : baseQuery.slice(0, slash)
return references().find((item) => item.name === alias)
})

function normalizeMentionPath(filePath: string) {
Expand Down Expand Up @@ -380,7 +333,7 @@ export function Autocomplete(props: {
() => search(),
async (query) => {
if (!store.visible || store.visible === "/") return []
if (referenceSearch()) return []
if (referenceMatch()) return []

const { lineRange, baseQuery } = extractLineRange(query ?? "")

Expand Down Expand Up @@ -430,43 +383,6 @@ export function Autocomplete(props: {
},
)

const [referenceFiles] = createResource(
() => referenceSearch(),
async (match) => {
if (!match) return []

const result = await sdk.client.find.files({
directory: match.reference.path,
query: match.query,
limit: 50,
})

if (result.error || !result.data) return []

const width = props.anchor().width - 4
return result.data.map((item): AutocompleteOption => {
const { filename, part } = createReferenceFilePart({
alias: match.reference.name,
root: match.reference.path,
item,
lineRange: match.lineRange,
})
return {
display: Locale.truncateMiddle(filename, width),
value: filename,
isDirectory: item.endsWith("/"),
path: filename,
onSelect: () => {
insertPart(filename, part)
},
}
})
},
{
initialValue: [],
},
)

const mcpResources = createMemo(() => {
if (!store.visible || store.visible === "/") return []

Expand Down Expand Up @@ -529,8 +445,22 @@ export function Autocomplete(props: {
references().map(
(reference): AutocompleteOption => ({
display: "@" + reference.name,
description: reference.kind === "invalid" ? reference.message : " configured reference",
description: reference.kind === "invalid" ? reference.message : " dir",
onSelect: () => {
if (reference.kind !== "invalid") {
insertPart(reference.name, {
type: "file",
mime: "application/x-directory",
filename: reference.name,
url: pathToFileURL(reference.path).href,
source: {
type: "file",
text: { start: 0, end: 0, value: "" },
path: reference.name,
},
})
return
}
insertPart(reference.name, {
type: "text",
text: referencePromptText(reference),
Expand Down Expand Up @@ -572,16 +502,15 @@ export function Autocomplete(props: {

const options = createMemo((prev: AutocompleteOption[] | undefined) => {
const filesValue = files()
const referenceFilesValue = referenceFiles()
const referenceSearchValue = referenceSearch()
const referenceMatchValue = referenceMatch()
const agentsValue = agents()
const referenceAliasesValue = referenceAliases()
const commandsValue = commands()

const mixed: AutocompleteOption[] =
store.visible === "@"
? referenceSearchValue
? referenceFilesValue || []
? referenceMatchValue
? referenceAliasesValue.filter((item) => item.display === `@${referenceMatchValue.name}`)
: [...referenceAliasesValue, ...agentsValue, ...(filesValue || []), ...mcpResources()]
: [...commandsValue]

Expand All @@ -591,10 +520,12 @@ export function Autocomplete(props: {
return mixed
}

if ((files.loading || referenceFiles.loading) && prev && prev.length > 0) {
if (files.loading && prev && prev.length > 0) {
return prev
}

if (referenceMatchValue) return mixed

const result = fuzzysort.go(removeLineRange(searchValue), mixed, {
keys: [
(obj) => removeLineRange((obj.value ?? obj.display).trimEnd()),
Expand Down
147 changes: 56 additions & 91 deletions packages/opencode/src/session/prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -145,9 +145,47 @@ export const layer = Layer.effect(
yield* state.cancel(sessionID)
})

const resolveReferenceParts = Effect.fnUntraced(function* (template: string) {
const parts: Types.DeepMutable<PromptInput["parts"]> = []
const seen = new Set<string>()
yield* Effect.forEach(
ConfigMarkdown.files(template),
Effect.fnUntraced(function* (match) {
const name = match[1]
if (!name) return
const alias = name.split("/")[0]
if (!alias || seen.has(alias)) return
const reference = yield* references.get(alias)
if (!reference) return
seen.add(alias)

const start = match.index ?? 0
const source = { value: match[0], start, end: start + match[0].length }
if (reference.kind === "invalid") {
parts.push(referenceTextPart({ reference, source }))
return
}

yield* references.ensure(reference.path)
parts.push({
type: "file",
url: pathToFileURL(reference.path).href,
filename: alias,
mime: "application/x-directory",
source: { type: "file", text: source, path: alias },
})
}),
{ concurrency: 1, discard: true },
)
return parts
})

const resolvePromptParts = Effect.fn("SessionPrompt.resolvePromptParts")(function* (template: string) {
const ctx = yield* InstanceState.context
const parts: Types.DeepMutable<PromptInput["parts"]> = [{ type: "text", text: template }]
const parts: Types.DeepMutable<PromptInput["parts"]> = [
{ type: "text", text: template },
...(yield* resolveReferenceParts(template)),
]
const files = ConfigMarkdown.files(template)
const seen = new Set<string>()
yield* Effect.forEach(
Expand All @@ -160,60 +198,7 @@ export const layer = Layer.effect(

const slash = name.indexOf("/")
const alias = slash === -1 ? name : name.slice(0, slash)
const reference = yield* references.get(alias)
if (reference) {
const start = match.index ?? 0
const source = { value: match[0], start, end: start + match[0].length }
if (reference.kind === "invalid") {
parts.push(
referenceTextPart({ reference, source, target: slash === -1 ? undefined : name.slice(slash + 1) }),
)
return
}

yield* references.ensure(reference.path)
if (slash === -1) {
parts.push(referenceTextPart({ reference, source }))
return
}

const target = name.slice(slash + 1)
const targetPath = path.resolve(reference.path, target)
if (!FSUtil.contains(reference.path, targetPath)) {
parts.push(
referenceTextPart({
reference,
source,
target,
targetPath,
problem: `Path escapes configured reference @${alias}: ${target}`,
}),
)
return
}

const info = yield* fsys.stat(targetPath).pipe(Effect.option)
if (Option.isNone(info)) {
parts.push(
referenceTextPart({
reference,
source,
target,
targetPath,
problem: `Path does not exist inside configured reference @${alias}: ${target}`,
}),
)
return
}

parts.push({
type: "file",
url: pathToFileURL(targetPath).href,
filename: name,
mime: info.value.type === "Directory" ? "application/x-directory" : "text/plain",
})
return
}
if (yield* references.get(alias)) return

const filepath = name.startsWith("~/")
? path.join(os.homedir(), name.slice(2))
Expand Down Expand Up @@ -770,30 +755,6 @@ export const layer = Layer.effect(
id: part.id ? PartID.make(part.id) : PartID.ascending(),
})

const referenceContextFromFilePart = Effect.fnUntraced(function* (
part: Extract<PromptInput["parts"][number], { type: "file" }>,
filepath: string,
) {
const name = part.filename?.replace(/#\d+(?:-\d*)?$/, "")
if (!name) return
const slash = name.indexOf("/")
if (slash === -1) return

const reference = yield* references.get(name.slice(0, slash))
if (!reference || reference.kind === "invalid") return
if (!FSUtil.contains(reference.path, filepath)) return

const target = path.relative(reference.path, filepath).split(path.sep).join("/")
if (!target || target.startsWith("../") || target === "..") return

return referenceTextPart({
reference,
source: part.source?.text ?? { value: `@${name}`, start: 0, end: name.length + 1 },
target,
targetPath: filepath,
})
})

const resolvePart: (part: PromptInput["parts"][number]) => Effect.Effect<Draft<SessionV1.Part>[]> = Effect.fn(
"SessionPrompt.resolveUserPart",
)(function* (part) {
Expand Down Expand Up @@ -876,7 +837,6 @@ export const layer = Layer.effect(
case "file:": {
log.info("file", { mime: part.mime })
const filepath = fileURLToPath(part.url)
const referenceContext = yield* referenceContextFromFilePart(part, filepath)
const mime = (yield* fsys.isDir(filepath)) ? "application/x-directory" : part.mime

const { read } = yield* registry.named()
Expand Down Expand Up @@ -922,9 +882,6 @@ export const layer = Layer.effect(
}
const args = { filePath: filepath, offset, limit }
const pieces: Draft<SessionV1.Part>[] = [
...(referenceContext
? [{ ...referenceContext, messageID: info.id, sessionID: input.sessionID }]
: []),
{
messageID: info.id,
sessionID: input.sessionID,
Expand Down Expand Up @@ -990,9 +947,6 @@ export const layer = Layer.effect(
error: new NamedError.Unknown({ message }).toObject(),
})
return [
...(referenceContext
? [{ ...referenceContext, messageID: info.id, sessionID: input.sessionID }]
: []),
{
messageID: info.id,
sessionID: input.sessionID,
Expand All @@ -1003,9 +957,6 @@ export const layer = Layer.effect(
]
}
return [
...(referenceContext
? [{ ...referenceContext, messageID: info.id, sessionID: input.sessionID }]
: []),
{
messageID: info.id,
sessionID: input.sessionID,
Expand All @@ -1025,7 +976,6 @@ export const layer = Layer.effect(
}

return [
...(referenceContext ? [{ ...referenceContext, messageID: info.id, sessionID: input.sessionID }] : []),
{
messageID: info.id,
sessionID: input.sessionID,
Expand Down Expand Up @@ -1071,7 +1021,22 @@ export const layer = Layer.effect(
return [{ ...part, messageID: info.id, sessionID: input.sessionID }]
})

const resolvedParts = yield* Effect.forEach(input.parts, resolvePart, { concurrency: "unbounded" }).pipe(
const submittedParts: Types.DeepMutable<PromptInput["parts"]> = [...input.parts]
const attachedReferences = new Set(
input.parts.flatMap((part) =>
part.type === "file" && part.mime === "application/x-directory" ? [part.url] : [],
),
)
for (const part of input.parts) {
if (part.type !== "text" || part.synthetic) continue
for (const reference of yield* resolveReferenceParts(part.text)) {
if (reference.type === "file" && attachedReferences.has(reference.url)) continue
if (reference.type === "file") attachedReferences.add(reference.url)
submittedParts.push(reference)
}
}

const resolvedParts = yield* Effect.forEach(submittedParts, resolvePart, { concurrency: "unbounded" }).pipe(
Effect.map((x) => x.flat().map(assign)),
)

Expand Down
Loading
Loading