diff --git a/packages/app/src/context/file.tsx b/packages/app/src/context/file.tsx index 99c6d2e4219..d8de27d37bb 100644 --- a/packages/app/src/context/file.tsx +++ b/packages/app/src/context/file.tsx @@ -195,7 +195,7 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({ } const search = (query: string, dirs: "true" | "false") => - sdk.client.find.files({ query, dirs }).then( + sdk.client.find.files({ query, dirs, sessionID: params.id }).then( (x) => (x.data ?? []).map(path.normalize), () => [], ) diff --git a/packages/app/src/i18n/en.ts b/packages/app/src/i18n/en.ts index 0b7a2e2808b..aa2c96258a5 100644 --- a/packages/app/src/i18n/en.ts +++ b/packages/app/src/i18n/en.ts @@ -87,6 +87,8 @@ export const dict = { "command.session.share.description": "Share this session and copy the URL to clipboard", "command.session.unshare": "Unshare session", "command.session.unshare.description": "Stop sharing this session", + "command.session.directory.add": "Add workspace directory", + "command.session.directory.add.description": "Allow another directory for this session", "palette.search.placeholder": "Search files, commands, and sessions", "palette.empty": "No results found", @@ -428,6 +430,11 @@ export const dict = { "toast.session.unshare.success.description": "Session unshared successfully!", "toast.session.unshare.failed.title": "Failed to unshare session", "toast.session.unshare.failed.description": "An error occurred while unsharing the session", + "toast.session.directory.added.title": "Workspace directory added", + "toast.session.directory.added.description": "Added {{directory}} to this session", + "toast.session.directory.exists.title": "Workspace directory already added", + "toast.session.directory.exists.description": "{{directory}} is already available in this session", + "toast.session.directory.failed.title": "Failed to add workspace directory", "toast.session.listFailed.title": "Failed to load sessions for {{project}}", diff --git a/packages/app/src/pages/session/use-session-commands.tsx b/packages/app/src/pages/session/use-session-commands.tsx index 461351878b6..8a768aeb4e1 100644 --- a/packages/app/src/pages/session/use-session-commands.tsx +++ b/packages/app/src/pages/session/use-session-commands.tsx @@ -12,6 +12,7 @@ import { useSDK } from "@/context/sdk" import { useSync } from "@/context/sync" import { useTerminal } from "@/context/terminal" import { DialogSelectFile } from "@/components/dialog-select-file" +import { DialogSelectDirectory } from "@/components/dialog-select-directory" import { DialogSelectModel } from "@/components/dialog-select-model" import { DialogSelectMcp } from "@/components/dialog-select-mcp" import { DialogFork } from "@/components/dialog-fork" @@ -287,6 +288,75 @@ export const useSessionCommands = (actions: SessionCommandContext) => { ]) const sessionActionCommands = createMemo(() => [ + sessionCommand({ + id: "session.directory.add", + title: language.t("command.session.directory.add"), + description: language.t("command.session.directory.add.description"), + slash: "add-directory", + disabled: !params.id, + onSelect: async () => { + const sessionID = params.id + if (!sessionID) return + + const selected = await new Promise((resolve) => { + const done = (result: string | string[] | null) => { + if (Array.isArray(result)) { + resolve(result[0]) + return + } + resolve(result ?? undefined) + } + dialog.show( + () => , + () => done(null), + ) + }) + + if (!selected) return + + await sdk.client.session + .workspaceDirectory({ + sessionID, + path: selected, + }) + .then((res) => { + const data = res.data + if (!data) { + showToast({ + title: language.t("toast.session.directory.failed.title"), + description: language.t("common.requestFailed"), + variant: "error", + }) + return + } + + if (!data.added) { + showToast({ + title: language.t("toast.session.directory.exists.title"), + description: language.t("toast.session.directory.exists.description", { + directory: data.directory, + }), + }) + return + } + + showToast({ + title: language.t("toast.session.directory.added.title"), + description: language.t("toast.session.directory.added.description", { + directory: data.directory, + }), + variant: "success", + }) + }) + .catch((error: Error) => { + showToast({ + title: language.t("toast.session.directory.failed.title"), + description: error instanceof Error ? error.message : language.t("common.requestFailed"), + variant: "error", + }) + }) + }, + }), sessionCommand({ id: "session.undo", title: language.t("command.session.undo"), diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-tag.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-tag.tsx index 6d6c62450ea..bf0bb8ad4d2 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-tag.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-tag.tsx @@ -4,7 +4,7 @@ import { useDialog } from "@tui/ui/dialog" import { useSDK } from "@tui/context/sdk" import { createStore } from "solid-js/store" -export function DialogTag(props: { onSelect?: (value: string) => void }) { +export function DialogTag(props: { onSelect?: (value: string) => void; sessionID?: string }) { const sdk = useSDK() const dialog = useDialog() @@ -17,6 +17,7 @@ export function DialogTag(props: { onSelect?: (value: string) => void }) { async () => { const result = await sdk.client.find.files({ query: store.filter, + sessionID: props.sessionID, }) if (result.error) return [] const sliced = (result.data ?? []).slice(0, 5) 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 3240afab326..e290dbb546d 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx @@ -1,5 +1,7 @@ import type { BoxRenderable, TextareaRenderable, KeyEvent, ScrollBoxRenderable } from "@opentui/core" import { pathToFileURL } from "bun" +import fs from "fs/promises" +import path from "path" import fuzzysort from "fuzzysort" import { firstBy } from "remeda" import { createMemo, createResource, createEffect, onMount, onCleanup, Index, Show, createSignal } from "solid-js" @@ -13,6 +15,7 @@ import { useTerminalDimensions } from "@opentui/solid" import { Locale } from "@/util/locale" import type { PromptInfo } from "./history" import { useFrecency } from "./frecency" +import { directoryPath } from "./directory" function removeLineRange(input: string) { const hashIndex = input.lastIndexOf("#") @@ -46,6 +49,82 @@ function extractLineRange(input: string) { } } +function slashDirectory(input: string) { + if (!input.startsWith("/")) return + const value = input.slice(1) + if (value === "add-directory" || value === "workspace-add") { + return { + command: value, + query: "", + } + } + if (value.startsWith("add-directory ")) { + return { + command: "add-directory", + query: value.slice("add-directory ".length), + } + } + if (value.startsWith("workspace-add ")) { + return { + command: "workspace-add", + query: value.slice("workspace-add ".length), + } + } +} + +function fromRule(pattern: string) { + if (!pattern.endsWith("/*") && !pattern.endsWith("\\*")) return + const result = pattern.slice(0, -2) + if (!path.isAbsolute(result)) return + if (result.includes("*") || result.includes("?")) return + return path.normalize(result) +} + +function expand(input: string) { + const home = process.env.HOME + if (input === "~") return home ?? input + if (home && (input.startsWith("~/") || input.startsWith("~\\"))) { + return path.join(home, input.slice(2)) + } + return input +} + +function absolute(input: string, cwd: string) { + const value = expand(input) + if (path.isAbsolute(value)) return path.normalize(value) + return path.resolve(cwd, value) +} + +async function suggest(input: string, cwd: string) { + const query = input.trim() + const value = query ? absolute(query, cwd) : cwd + const prefix = query.endsWith(path.sep) || query.endsWith("/") ? "" : path.basename(value) + const dir = prefix ? path.dirname(value) : value + const entries = await fs.readdir(dir, { withFileTypes: true }).catch(() => []) + + return entries + .filter((entry) => entry.isDirectory()) + .filter((entry) => entry.name.toLowerCase().includes(prefix.toLowerCase())) + .map((entry) => path.join(dir, entry.name)) + .filter((entry) => entry.trim().length > 0) + .slice(0, 80) +} + +async function defaults(cwd: string) { + const parent = path.dirname(cwd) + const siblings = await fs.readdir(parent, { withFileTypes: true }).catch(() => []) + return [ + ...siblings + .filter((entry) => entry.isDirectory()) + .map((entry) => path.join(parent, entry.name)) + .filter((entry) => path.normalize(entry) !== path.normalize(cwd)), + ] +} + +function pathLike(input: string) { + return input.includes("/") || input.includes("\\") || input.startsWith(".") || input.startsWith("~") +} + export type AutocompleteRef = { onInput: (value: string) => void onKeyDown: (e: KeyEvent) => void @@ -140,6 +219,11 @@ export function Autocomplete(props: { setSearch(next ? next : "") }) + const directorySlash = createMemo(() => { + if (!store.visible || store.visible !== "/") return + return slashDirectory(props.value) + }) + // 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. @@ -228,6 +312,7 @@ export function Autocomplete(props: { // Get files from SDK const result = await sdk.client.find.files({ query: baseQuery, + sessionID: props.sessionID, }) const options: AutocompleteOption[] = [] @@ -295,6 +380,88 @@ export function Autocomplete(props: { }, ) + const [directories] = createResource( + () => ({ + input: directorySlash(), + sessionID: props.sessionID, + }), + async (source) => { + const command = source.input + if (!command) return [] + + const cwd = (sync.data.path.directory || process.cwd()).replace(/\/+$/, "") + const query = command.query.trim() + const session = source.sessionID ? sync.data.session.find((item) => item.id === source.sessionID) : undefined + const external = + session?.permission + ?.filter((item) => item.permission === "external_directory" && item.action === "allow") + .map((item) => fromRule(item.pattern)) + .filter((item): item is string => Boolean(item)) ?? [] + const externalSet = new Set(external.map((item) => path.normalize(item))) + + const sibling = await defaults(cwd) + + const fromFS = query + ? pathLike(query) + ? await suggest(query, cwd) + : [...(await suggest(query, cwd)), ...sibling] + : sibling + + const fromSearch = + query && !pathLike(query) + ? await sdk.client.find + .files({ + query, + type: "directory", + sessionID: source.sessionID, + limit: 80, + }) + .then((result) => (result.error || !result.data ? [] : result.data.map((item) => absolute(item, cwd)))) + .catch(() => []) + : [] + + const all = Array.from( + new Set( + [...fromFS, ...fromSearch].map((item) => path.normalize(item)).filter((item) => item.trim().length > 0), + ), + ).filter((item) => !externalSet.has(item)) + + const ranked = + query && !pathLike(query) + ? fuzzysort + .go(query, all, { + keys: [(item: string) => directoryPath(item, cwd, query), (item: string) => path.basename(item)], + limit: 80, + }) + .map((item) => item.obj) + : all + + const width = props.anchor().width - 4 + const result = ranked.map((item) => { + const value = directoryPath(item, cwd, query) + return { + display: Locale.truncateMiddle(value, width), + value, + path: item, + onSelect: () => { + const text = `/${command.command} ${value}` + props.input().setText(text) + props.input().cursorOffset = Bun.stringWidth(text) + props.setPrompt((draft) => { + draft.input = text + draft.parts = [] + }) + }, + } + }) + + return result + }, + { + initialValue: [], + }, + ) + const mcpResources = createMemo(() => { if (!store.visible || store.visible === "/") return [] @@ -386,9 +553,19 @@ export function Autocomplete(props: { const filesValue = files() const agentsValue = agents() const commandsValue = commands() + const directoriesValue = directories() + const slash = directorySlash() const mixed: AutocompleteOption[] = - store.visible === "@" ? [...agentsValue, ...(filesValue || []), ...mcpResources()] : [...commandsValue] + store.visible === "@" + ? [...agentsValue, ...(filesValue || []), ...mcpResources()] + : slash + ? [...directoriesValue] + : [...commandsValue] + + if (slash) { + return mixed + } const searchValue = search() @@ -485,7 +662,8 @@ export function Autocomplete(props: { function hide() { const text = props.input().plainText - if (store.visible === "/" && !text.endsWith(" ") && text.startsWith("/")) { + const slash = slashDirectory(text) + if (store.visible === "/" && !slash && !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 @@ -504,13 +682,14 @@ export function Autocomplete(props: { }, onInput(value) { if (store.visible) { + const slash = slashDirectory(value) 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/) || + (!slash && props.input().getTextRange(store.index, props.input().cursorOffset).match(/\s/)) || // "/" is not the sole content - (store.visible === "/" && value.match(/^\S+\s+\S+\s*$/)) + (store.visible === "/" && !slash && value.match(/^\S+\s+\S+\s*$/)) ) { hide() } @@ -528,6 +707,13 @@ export function Autocomplete(props: { return } + const slash = slashDirectory(value) + if (slash) { + show("/") + setStore("index", 0) + return + } + // Check for "@" trigger - find the nearest "@" before cursor with no whitespace between const text = value.slice(0, offset) const idx = text.lastIndexOf("@") @@ -565,6 +751,17 @@ export function Autocomplete(props: { return } if (name === "return") { + const slash = slashDirectory(props.input().plainText) + if (slash) { + const selected = options()[store.selected] + const before = props.input().plainText + selected?.onSelect?.() + hide() + if (props.input().plainText !== before) { + e.preventDefault() + } + return + } select() e.preventDefault() return diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/directory.ts b/packages/opencode/src/cli/cmd/tui/component/prompt/directory.ts new file mode 100644 index 00000000000..69477f849a9 --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/directory.ts @@ -0,0 +1,23 @@ +import path from "path" + +function relative(target: string, cwd: string) { + const home = process.env.HOME + if (home && target.startsWith(home + path.sep)) return "~/" + path.relative(home, target) + if (home && target === home) return "~" + const result = path.relative(cwd, target) + if (!result) return target + return result +} + +function absoluteInput(input: string) { + const query = input.trim() + if (!query) return false + if (query === "~") return false + if (query.startsWith("~/") || query.startsWith("~\\")) return false + return path.isAbsolute(query) +} + +export function directoryPath(target: string, cwd: string, query: string) { + if (absoluteInput(query)) return path.normalize(target) + return relative(target, cwd) +} 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 d63c248fb83..af2ba43b878 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx @@ -35,6 +35,29 @@ import { useKV } from "../../context/kv" import { useTextareaKeybindings } from "../textarea-keybindings" import { DialogSkill } from "../dialog-skill" +function directoryCommand(input: string) { + if (!input.startsWith("/")) return + const value = input.slice(1) + if (value === "add-directory" || value === "workspace-add") { + return { + command: value, + path: "", + } + } + if (value.startsWith("add-directory ")) { + return { + command: "add-directory", + path: value.slice("add-directory ".length), + } + } + if (value.startsWith("workspace-add ")) { + return { + command: "workspace-add", + path: value.slice("workspace-add ".length), + } + } +} + export type PromptProps = { sessionID?: string visible?: boolean @@ -242,6 +265,25 @@ export function Prompt(props: PromptProps) { dialog.clear() }, }, + { + title: "Add workspace directory", + value: "session.workspace.directory.add", + category: "Session", + slash: { + name: "add-directory", + aliases: ["workspace-add"], + }, + onSelect: (dialog) => { + const text = "/add-directory " + input.setText(text) + setStore("prompt", { + input: text, + parts: [], + }) + input.gotoBufferEnd() + dialog.clear() + }, + }, { title: "Open editor", category: "Session", @@ -534,17 +576,84 @@ export function Prompt(props: PromptProps) { exit() return } + + const directory = directoryCommand(trimmed) + const createSession = () => sdk.client.session.create({}).then((x) => x.data!.id) + + if (directory) { + const target = directory.path.trim() + if (!target) { + toast.show({ + variant: "warning", + message: "Type a directory path after /add-directory", + }) + return + } + + const sessionID = props.sessionID ? props.sessionID : await createSession() + + await sdk.client.session + .workspaceDirectory({ + sessionID, + path: target, + }) + .then((result) => { + const data = result.data + if (!data) { + const message = + result.error && typeof result.error === "object" && "message" in result.error + ? String(result.error.message) + : "Failed to add workspace directory" + toast.show({ + variant: "error", + message, + }) + return + } + + toast.show({ + variant: data.added ? "success" : "info", + message: data.added + ? `Workspace directory added: ${data.directory}` + : `Workspace directory already added: ${data.directory}`, + }) + }) + .catch((error) => { + toast.show({ + variant: "error", + message: error instanceof Error ? error.message : "Failed to add workspace directory", + }) + }) + + history.append({ + ...store.prompt, + mode: store.mode, + }) + input.extmarks.clear() + setStore("prompt", { + input: "", + parts: [], + }) + setStore("extmarkToPartIndex", new Map()) + props.onSubmit?.() + + if (!props.sessionID) + setTimeout(() => { + route.navigate({ + type: "session", + sessionID, + }) + }, 50) + input.clear() + return + } + const selectedModel = local.model.current() if (!selectedModel) { promptModelWarning() return } - const sessionID = props.sessionID - ? props.sessionID - : await (async () => { - const sessionID = await sdk.client.session.create({}).then((x) => x.data!.id) - return sessionID - })() + const sessionID = props.sessionID ? props.sessionID : await createSession() const messageID = Identifier.ascending("message") let inputText = store.prompt.input diff --git a/packages/opencode/src/file/index.ts b/packages/opencode/src/file/index.ts index b7daddc5fb8..27e1a9c4ec3 100644 --- a/packages/opencode/src/file/index.ts +++ b/packages/opencode/src/file/index.ts @@ -11,9 +11,18 @@ import { Instance } from "../project/instance" import { Ripgrep } from "./ripgrep" import fuzzysort from "fuzzysort" import { Global } from "../global" +import { Session } from "../session" export namespace File { const log = Log.create({ service: "file" }) + type SearchResult = { files: string[]; dirs: string[] } + type ExternalCache = { key: string; time: number; value: SearchResult } + const EXTERNAL_CACHE_TTL = 2_000 + const externalCache = new Map() + + function empty(): SearchResult { + return { files: [], dirs: [] } + } export const Info = z .object({ @@ -332,13 +341,12 @@ export namespace File { } const state = Instance.state(async () => { - type Entry = { files: string[]; dirs: string[] } - let cache: Entry = { files: [], dirs: [] } + let cache: SearchResult = empty() let fetching = false const isGlobalHome = Instance.directory === Global.Path.home && Instance.project.id === "global" - const fn = async (result: Entry) => { + const fn = async (result: SearchResult) => { // Disable scanning if in root of file system if (Instance.directory === path.parse(Instance.directory).root) return fetching = true @@ -400,10 +408,7 @@ export namespace File { return { async files() { if (!fetching) { - fn({ - files: [], - dirs: [], - }) + fn(empty()) } return cache }, @@ -604,17 +609,104 @@ export namespace File { }) } - export async function search(input: { query: string; limit?: number; dirs?: boolean; type?: "file" | "directory" }) { + function externalDirectory(pattern: string) { + if (!pattern.endsWith("/*") && !pattern.endsWith("\\*")) return + const result = pattern.slice(0, -2) + if (!path.isAbsolute(result)) return + if (result.includes("*") || result.includes("?")) return + return path.normalize(result) + } + + function traversalOnly(input: string) { + const parts = input.replaceAll("\\", "/").split("/").filter(Boolean) + if (parts.length === 0) return false + return parts.every((part) => part === "..") + } + + async function external(sessionID?: string) { + if (!sessionID) return empty() + + const session = await Session.get(sessionID).catch(() => undefined) + if (!session?.permission) { + externalCache.delete(sessionID) + return empty() + } + + const dirs = Array.from( + new Set( + session.permission + .filter((rule) => rule.permission === "external_directory" && rule.action === "allow") + .map((rule) => externalDirectory(rule.pattern)) + .filter((rule): rule is string => Boolean(rule)), + ), + ).toSorted() + + const key = dirs.join("\0") + const now = Date.now() + const cached = externalCache.get(sessionID) + if (cached && cached.key === key && now - cached.time < EXTERNAL_CACHE_TTL) return cached.value + + if (!dirs.length) { + const value = empty() + externalCache.set(sessionID, { key, time: now, value }) + return value + } + + const files: string[] = [] + const folders = new Set() + + for (const dir of dirs) { + const stat = await Bun.file(dir) + .stat() + .catch(() => undefined) + if (!stat?.isDirectory()) continue + + const root = path.relative(Instance.directory, dir) + if (root && root !== "." && !traversalOnly(root)) folders.add(root + "/") + + for await (const file of Ripgrep.files({ cwd: dir })) { + const absolute = path.join(dir, file) + const relative = path.relative(Instance.directory, absolute) + files.push(relative) + + let current = relative + while (true) { + const parent = path.dirname(current) + if (parent === "." || parent === current || traversalOnly(parent)) break + current = parent + folders.add(parent + "/") + } + } + } + + const value = { + files: Array.from(new Set(files)), + dirs: Array.from(folders), + } + externalCache.set(sessionID, { key, time: now, value }) + return value + } + + export async function search(input: { + query: string + limit?: number + dirs?: boolean + type?: "file" | "directory" + sessionID?: string + }) { const query = input.query.trim() const limit = input.limit ?? 100 const kind = input.type ?? (input.dirs === false ? "file" : "all") log.info("search", { query, kind }) - const result = await state().then((x) => x.files()) + const root = await state().then((x) => x.files()) + const extra = await external(input.sessionID) + const files = extra.files.length ? Array.from(new Set([...root.files, ...extra.files])) : root.files + const dirs = extra.dirs.length ? Array.from(new Set([...root.dirs, ...extra.dirs])) : root.dirs const hidden = (item: string) => { const normalized = item.replaceAll("\\", "/").replace(/\/+$/, "") - return normalized.split("/").some((p) => p.startsWith(".") && p.length > 1) + return normalized.split("/").some((p) => p !== "." && p !== ".." && p.startsWith(".") && p.length > 1) } const preferHidden = query.startsWith(".") || query.includes("/.") const sortHiddenLast = (items: string[]) => { @@ -629,12 +721,11 @@ export namespace File { return [...visible, ...hiddenItems] } if (!query) { - if (kind === "file") return result.files.slice(0, limit) - return sortHiddenLast(result.dirs.toSorted()).slice(0, limit) + if (kind === "file") return files.slice(0, limit) + return sortHiddenLast(dirs.toSorted()).slice(0, limit) } - const items = - kind === "file" ? result.files : kind === "directory" ? result.dirs : [...result.files, ...result.dirs] + const items = kind === "file" ? files : kind === "directory" ? dirs : [...files, ...dirs] const searchLimit = kind === "directory" && !preferHidden ? limit * 20 : limit const sorted = fuzzysort.go(query, items, { limit: searchLimit }).map((r) => r.target) diff --git a/packages/opencode/src/server/routes/file.ts b/packages/opencode/src/server/routes/file.ts index 60789ef4b72..61aad14d4da 100644 --- a/packages/opencode/src/server/routes/file.ts +++ b/packages/opencode/src/server/routes/file.ts @@ -66,6 +66,7 @@ export const FileRoutes = lazy(() => dirs: z.enum(["true", "false"]).optional(), type: z.enum(["file", "directory"]).optional(), limit: z.coerce.number().int().min(1).max(200).optional(), + sessionID: z.string().optional(), }), ), async (c) => { @@ -73,11 +74,13 @@ export const FileRoutes = lazy(() => const dirs = c.req.valid("query").dirs const type = c.req.valid("query").type const limit = c.req.valid("query").limit + const sessionID = c.req.valid("query").sessionID const results = await File.search({ query, limit: limit ?? 10, dirs: dirs !== "false", type, + sessionID, }) return c.json(results) }, diff --git a/packages/opencode/src/server/routes/session.ts b/packages/opencode/src/server/routes/session.ts index 12938aeaba0..24270dd2d09 100644 --- a/packages/opencode/src/server/routes/session.ts +++ b/packages/opencode/src/server/routes/session.ts @@ -351,6 +351,46 @@ export const SessionRoutes = lazy(() => return c.json(result) }, ) + .post( + "/:sessionID/workspace/directory", + describeRoute({ + summary: "Add workspace directory", + description: "Add an external directory to the current session workspace.", + operationId: "session.workspaceDirectory", + responses: { + 200: { + description: "Workspace directory added", + content: { + "application/json": { + schema: resolver(Session.WorkspaceDirectoryResult), + }, + }, + }, + ...errors(400, 404), + }, + }), + validator( + "param", + z.object({ + sessionID: Session.addWorkspaceDirectory.schema.shape.sessionID, + }), + ), + validator( + "json", + z.object({ + path: Session.addWorkspaceDirectory.schema.shape.path, + }), + ), + async (c) => { + const params = c.req.valid("param") + const body = c.req.valid("json") + const result = await Session.addWorkspaceDirectory({ + sessionID: params.sessionID, + path: body.path, + }) + return c.json(result) + }, + ) .post( "/:sessionID/abort", describeRoute({ diff --git a/packages/opencode/src/session/index.ts b/packages/opencode/src/session/index.ts index 22de477f8d1..10977c0d884 100644 --- a/packages/opencode/src/session/index.ts +++ b/packages/opencode/src/session/index.ts @@ -1,5 +1,6 @@ import { Slug } from "@opencode-ai/util/slug" import path from "path" +import fs from "fs/promises" import { BusEvent } from "@/bus/bus-event" import { Bus } from "@/bus" import { Decimal } from "decimal.js" @@ -174,6 +175,17 @@ export namespace Session { }) export type GlobalInfo = z.output + export const WorkspaceDirectoryResult = z + .object({ + added: z.boolean(), + directory: z.string(), + glob: z.string(), + session: Info, + }) + .meta({ + ref: "SessionWorkspaceDirectoryResult", + }) + export const Event = { Created: BusEvent.define( "session.created", @@ -429,6 +441,63 @@ export namespace Session { }, ) + export const addWorkspaceDirectory = fn( + z.object({ + sessionID: Identifier.schema("session"), + path: z.string(), + }), + async (input) => { + const session = await get(input.sessionID) + const expanded = + input.path === "~" + ? (process.env.HOME ?? input.path) + : (input.path.startsWith("~/") || input.path.startsWith("~\\")) && process.env.HOME + ? path.join(process.env.HOME, input.path.slice(2)) + : input.path + const target = path.isAbsolute(expanded) ? expanded : path.resolve(Instance.directory, expanded) + const stat = await Bun.file(target) + .stat() + .catch(() => undefined) + if (!stat) throw new NotFoundError({ message: `Directory not found: ${input.path}` }) + if (!stat.isDirectory()) throw new NotFoundError({ message: `Path is not a directory: ${input.path}` }) + const directory = await fs.realpath(target) + const glob = path.join(directory, "*") + + const rules = session.permission ?? [] + const exists = rules.some( + (rule) => rule.permission === "external_directory" && rule.action === "allow" && rule.pattern === glob, + ) + + if (exists) { + return { + added: false, + directory, + glob, + session, + } + } + + const next = await setPermission({ + sessionID: input.sessionID, + permission: [ + ...rules, + { + permission: "external_directory", + pattern: glob, + action: "allow", + }, + ], + }) + + return { + added: true, + directory, + glob, + session: next, + } + }, + ) + export const setRevert = fn( z.object({ sessionID: Identifier.schema("session"), diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 75bd3c9dfac..ae652f31472 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -290,7 +290,7 @@ export namespace SessionPrompt { let structuredOutput: unknown | undefined let step = 0 - const session = await Session.get(sessionID) + const initial = await Session.get(sessionID) while (true) { SessionStatus.set(sessionID, { type: "busy" }) log.info("loop", { step, sessionID }) @@ -327,7 +327,7 @@ export namespace SessionPrompt { step++ if (step === 1) ensureTitle({ - session, + session: initial, modelID: lastUser.model.modelID, providerID: lastUser.model.providerID, history: msgs, @@ -553,6 +553,8 @@ export namespace SessionPrompt { continue } + const session = await Session.get(sessionID) + // normal processing const agent = await Agent.get(lastUser.agent) const maxSteps = agent.steps ?? Infinity @@ -648,7 +650,10 @@ export namespace SessionPrompt { await Plugin.trigger("experimental.chat.messages.transform", {}, { messages: msgs }) // Build system prompt, adding structured output instruction if needed - const system = [...(await SystemPrompt.environment(model)), ...(await InstructionPrompt.system())] + const system = [ + ...(await SystemPrompt.environment(model, session.permission ?? [])), + ...(await InstructionPrompt.system()), + ] const format = lastUser.format ?? { type: "text" } if (format.type === "json_schema") { system.push(STRUCTURED_OUTPUT_SYSTEM_PROMPT) diff --git a/packages/opencode/src/session/system.ts b/packages/opencode/src/session/system.ts index a61dd8cba55..42c1a8d0409 100644 --- a/packages/opencode/src/session/system.ts +++ b/packages/opencode/src/session/system.ts @@ -1,4 +1,4 @@ -import { Ripgrep } from "../file/ripgrep" +import path from "path" import { Instance } from "../project/instance" @@ -10,8 +10,28 @@ import PROMPT_GEMINI from "./prompt/gemini.txt" import PROMPT_CODEX from "./prompt/codex_header.txt" import PROMPT_TRINITY from "./prompt/trinity.txt" import type { Provider } from "@/provider/provider" +import { PermissionNext } from "@/permission/next" export namespace SystemPrompt { + function external(permission: PermissionNext.Ruleset) { + return Array.from( + new Set( + permission + .filter((rule) => rule.permission === "external_directory" && rule.action === "allow") + .map((rule) => { + if (rule.pattern.endsWith("/*")) { + return path.normalize(rule.pattern.slice(0, -2)) + } + if (rule.pattern.endsWith("\\*")) { + return path.normalize(rule.pattern.slice(0, -2)) + } + }) + .filter((rule): rule is string => Boolean(rule)) + .filter((rule) => path.isAbsolute(rule)), + ), + ) + } + export function instructions() { return PROMPT_CODEX.trim() } @@ -26,8 +46,13 @@ export namespace SystemPrompt { return [PROMPT_ANTHROPIC_WITHOUT_TODO] } - export async function environment(model: Provider.Model) { + export async function environment(model: Provider.Model, permission: PermissionNext.Ruleset = []) { const project = Instance.project + const directories = [ + ` - ${Instance.directory} (working directory)`, + ...external(permission).map((item) => ` - ${item} (workspace directory)`), + ].join("\n") + return [ [ `You are powered by the model named ${model.api.id}. The exact model ID is ${model.providerID}/${model.api.id}`, @@ -39,14 +64,7 @@ export namespace SystemPrompt { ` Today's date: ${new Date().toDateString()}`, ``, ``, - ` ${ - project.vcs === "git" && false - ? await Ripgrep.tree({ - cwd: Instance.directory, - limit: 50, - }) - : "" - }`, + directories, ``, ].join("\n"), ] diff --git a/packages/opencode/test/cli/tui/prompt-directory.test.ts b/packages/opencode/test/cli/tui/prompt-directory.test.ts new file mode 100644 index 00000000000..52aa5fb1f39 --- /dev/null +++ b/packages/opencode/test/cli/tui/prompt-directory.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, test } from "bun:test" +import path from "path" +import { directoryPath } from "../../../src/cli/cmd/tui/component/prompt/directory" + +describe("prompt directory path formatting", () => { + test("keeps root query suggestions absolute", () => { + const cwd = path.join(path.sep, "Users", "dev", "project") + const target = path.join(path.sep, "usr") + + const result = directoryPath(target, cwd, "/") + + expect(result).toBe(path.normalize(target)) + expect(result.includes("..")).toBe(false) + }) + + test("keeps explicit absolute query suggestions absolute", () => { + const cwd = path.join(path.sep, "Users", "dev", "project") + const target = path.join(path.sep, "usr", "local") + + const result = directoryPath(target, cwd, path.join(path.sep, "usr")) + + expect(result).toBe(path.normalize(target)) + }) + + test("keeps relative query suggestions relative", () => { + const cwd = path.join(path.sep, "Users", "dev", "project") + const target = path.join(cwd, "packages") + + const result = directoryPath(target, cwd, "pack") + + expect(result).toBe("packages") + }) + + test("keeps home query suggestions tilde-based", () => { + const prior = process.env.HOME + const home = path.join(path.sep, "Users", "dev") + process.env.HOME = home + + try { + const cwd = path.join(home, "project") + const target = path.join(home, "workspace") + + const result = directoryPath(target, cwd, "~/") + + expect(result).toBe("~/workspace") + } finally { + process.env.HOME = prior + } + }) +}) diff --git a/packages/opencode/test/file/search-external-directory.test.ts b/packages/opencode/test/file/search-external-directory.test.ts new file mode 100644 index 00000000000..ad78a4393d2 --- /dev/null +++ b/packages/opencode/test/file/search-external-directory.test.ts @@ -0,0 +1,88 @@ +import { describe, expect, test } from "bun:test" +import path from "path" +import { File } from "../../src/file" +import { Session } from "../../src/session" +import { Instance } from "../../src/project/instance" +import { tmpdir } from "../fixture/fixture" + +describe("file.search external directories", () => { + test("does not include external files without sessionID", async () => { + await using outside = await tmpdir({ + init: async (dir) => { + await Bun.write(path.join(dir, "external-only.txt"), "outside") + }, + }) + await using project = await tmpdir({ git: true }) + + await Instance.provide({ + directory: project.path, + fn: async () => { + const result = await File.search({ + query: "external-only", + type: "file", + limit: 20, + }) + + expect(result.some((item) => item.includes("external-only.txt"))).toBe(false) + }, + }) + }) + + test("includes external files with sessionID permission", async () => { + await using outside = await tmpdir({ + init: async (dir) => { + await Bun.write(path.join(dir, "external-match.txt"), "outside") + }, + }) + await using project = await tmpdir({ git: true }) + + await Instance.provide({ + directory: project.path, + fn: async () => { + const session = await Session.create({}) + await Session.addWorkspaceDirectory({ + sessionID: session.id, + path: outside.path, + }) + + const result = await File.search({ + query: "external-match", + type: "file", + sessionID: session.id, + limit: 20, + }) + + expect(result.some((item) => item.includes("external-match.txt"))).toBe(true) + }, + }) + }) + + test("ignores broad external_directory patterns", async () => { + await using outside = await tmpdir({ + init: async (dir) => { + await Bun.write(path.join(dir, "broad-pattern.txt"), "outside") + }, + }) + await using project = await tmpdir({ git: true }) + + await Instance.provide({ + directory: project.path, + fn: async () => { + const session = await Session.create({}) + await Session.setPermission({ + sessionID: session.id, + permission: [{ permission: "external_directory", pattern: "*", action: "allow" }], + }) + + const result = await File.search({ + query: "broad-pattern", + type: "file", + sessionID: session.id, + limit: 20, + }) + + expect(result.some((item) => item.includes("broad-pattern.txt"))).toBe(false) + }, + }) + }) +}) diff --git a/packages/opencode/test/server/session-workspace-directory.test.ts b/packages/opencode/test/server/session-workspace-directory.test.ts new file mode 100644 index 00000000000..8d3855dc598 --- /dev/null +++ b/packages/opencode/test/server/session-workspace-directory.test.ts @@ -0,0 +1,99 @@ +import { describe, expect, test } from "bun:test" +import path from "node:path" +import { Session } from "../../src/session" +import { Instance } from "../../src/project/instance" +import { Server } from "../../src/server/server" +import { tmpdir } from "../fixture/fixture" + +describe("session.workspaceDirectory endpoint", () => { + test("adds external directory for a session", async () => { + await using outside = await tmpdir({}) + await using project = await tmpdir({ git: true }) + + await Instance.provide({ + directory: project.path, + fn: async () => { + const session = await Session.create({}) + const app = Server.App() + const response = await app.request( + `/session/${session.id}/workspace/directory?directory=${encodeURIComponent(project.path)}`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ path: outside.path }), + }, + ) + + expect(response.status).toBe(200) + + const body = (await response.json()) as { + added: boolean + directory: string + glob: string + session: { id: string } + } + expect(body.added).toBe(true) + expect(body.directory).toBe(outside.path) + expect(body.glob).toBe(path.join(outside.path, "*")) + expect(body.session.id).toBe(session.id) + + const second = await app.request( + `/session/${session.id}/workspace/directory?directory=${encodeURIComponent(project.path)}`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ path: outside.path }), + }, + ) + + expect(second.status).toBe(200) + const duplicate = (await second.json()) as { added: boolean } + expect(duplicate.added).toBe(false) + }, + }) + }) + + test("validates request body", async () => { + await using project = await tmpdir({ git: true }) + + await Instance.provide({ + directory: project.path, + fn: async () => { + const session = await Session.create({}) + const app = Server.App() + const response = await app.request( + `/session/${session.id}/workspace/directory?directory=${encodeURIComponent(project.path)}`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({}), + }, + ) + + expect(response.status).toBe(400) + }, + }) + }) + + test("returns not found for missing directory", async () => { + await using project = await tmpdir({ git: true }) + + await Instance.provide({ + directory: project.path, + fn: async () => { + const session = await Session.create({}) + const app = Server.App() + const response = await app.request( + `/session/${session.id}/workspace/directory?directory=${encodeURIComponent(project.path)}`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ path: `${project.path}/missing` }), + }, + ) + + expect(response.status).toBe(404) + }, + }) + }) +}) diff --git a/packages/opencode/test/session/workspace-directory.test.ts b/packages/opencode/test/session/workspace-directory.test.ts new file mode 100644 index 00000000000..b80c59d3ce4 --- /dev/null +++ b/packages/opencode/test/session/workspace-directory.test.ts @@ -0,0 +1,166 @@ +import { describe, expect, test } from "bun:test" +import fs from "fs/promises" +import path from "path" +import { Session } from "../../src/session" +import { Instance } from "../../src/project/instance" +import { NotFoundError } from "../../src/storage/db" +import { tmpdir } from "../fixture/fixture" + +describe("session.addWorkspaceDirectory", () => { + test("adds external directory permission for session", async () => { + await using outside = await tmpdir({ + init: async (dir) => { + await fs.mkdir(path.join(dir, "docs"), { recursive: true }) + }, + }) + await using project = await tmpdir({ git: true }) + + await Instance.provide({ + directory: project.path, + fn: async () => { + const session = await Session.create({}) + const directory = path.join(outside.path, "docs") + const real = await fs.realpath(directory) + const result = await Session.addWorkspaceDirectory({ + sessionID: session.id, + path: directory, + }) + + expect(result.added).toBe(true) + expect(result.directory).toBe(real) + expect(result.glob).toBe(path.join(real, "*")) + expect(result.session.permission?.some((rule) => rule.permission === "external_directory")).toBe(true) + }, + }) + }) + + test("expands tilde path", async () => { + await using home = await tmpdir({ + init: async (dir) => { + await fs.mkdir(path.join(dir, "workspace"), { recursive: true }) + }, + }) + await using project = await tmpdir({ git: true }) + + const prior = process.env.HOME + process.env.HOME = home.path + + try { + await Instance.provide({ + directory: project.path, + fn: async () => { + const session = await Session.create({}) + const result = await Session.addWorkspaceDirectory({ + sessionID: session.id, + path: "~/workspace", + }) + + expect(result.added).toBe(true) + expect(result.directory).toBe(path.join(home.path, "workspace")) + }, + }) + } finally { + process.env.HOME = prior + } + }) + + test("returns added=false when directory already exists", async () => { + await using outside = await tmpdir({ + init: async (dir) => { + await fs.mkdir(path.join(dir, "outside"), { recursive: true }) + }, + }) + await using project = await tmpdir({ git: true }) + + await Instance.provide({ + directory: project.path, + fn: async () => { + const session = await Session.create({}) + const target = path.join(outside.path, "outside") + + await Session.addWorkspaceDirectory({ + sessionID: session.id, + path: target, + }) + const result = await Session.addWorkspaceDirectory({ + sessionID: session.id, + path: target, + }) + + expect(result.added).toBe(false) + }, + }) + }) + + test("rejects missing directory", async () => { + await using project = await tmpdir({ git: true }) + + await Instance.provide({ + directory: project.path, + fn: async () => { + const session = await Session.create({}) + await expect( + Session.addWorkspaceDirectory({ + sessionID: session.id, + path: path.join(project.path, "missing-dir"), + }), + ).rejects.toBeInstanceOf(NotFoundError) + }, + }) + }) + + test("rejects file path", async () => { + await using project = await tmpdir({ + git: true, + init: async (dir) => { + await Bun.write(path.join(dir, "file.txt"), "hello") + }, + }) + + await Instance.provide({ + directory: project.path, + fn: async () => { + const session = await Session.create({}) + await expect( + Session.addWorkspaceDirectory({ + sessionID: session.id, + path: path.join(project.path, "file.txt"), + }), + ).rejects.toBeInstanceOf(NotFoundError) + }, + }) + }) + + test("canonicalizes symlinked directories", async () => { + await using outside = await tmpdir({ + init: async (dir) => { + await fs.mkdir(path.join(dir, "target"), { recursive: true }) + await fs.symlink(path.join(dir, "target"), path.join(dir, "link")) + }, + }) + await using project = await tmpdir({ git: true }) + + await Instance.provide({ + directory: project.path, + fn: async () => { + const session = await Session.create({}) + const link = path.join(outside.path, "link") + const target = path.join(outside.path, "target") + const real = await fs.realpath(target) + + const first = await Session.addWorkspaceDirectory({ + sessionID: session.id, + path: link, + }) + const second = await Session.addWorkspaceDirectory({ + sessionID: session.id, + path: target, + }) + + expect(first.added).toBe(true) + expect(first.directory).toBe(real) + expect(second.added).toBe(false) + }, + }) + }) +}) diff --git a/packages/sdk/js/src/v2/gen/sdk.gen.ts b/packages/sdk/js/src/v2/gen/sdk.gen.ts index 6165c0f7b09..b1391ace133 100644 --- a/packages/sdk/js/src/v2/gen/sdk.gen.ts +++ b/packages/sdk/js/src/v2/gen/sdk.gen.ts @@ -143,6 +143,8 @@ import type { SessionUnshareResponses, SessionUpdateErrors, SessionUpdateResponses, + SessionWorkspaceDirectoryErrors, + SessionWorkspaceDirectoryResponses, SubtaskPartInput, TextPartInput, ToolIdsErrors, @@ -1310,6 +1312,47 @@ export class Session2 extends HeyApiClient { }) } + /** + * Add workspace directory + * + * Add an external directory to the current session workspace. + */ + public workspaceDirectory( + parameters: { + sessionID: string + directory?: string + path?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "path", key: "sessionID" }, + { in: "query", key: "directory" }, + { in: "body", key: "path" }, + ], + }, + ], + ) + return (options?.client ?? this.client).post< + SessionWorkspaceDirectoryResponses, + SessionWorkspaceDirectoryErrors, + ThrowOnError + >({ + url: "/session/{sessionID}/workspace/directory", + ...options, + ...params, + headers: { + "Content-Type": "application/json", + ...options?.headers, + ...params.headers, + }, + }) + } + /** * Abort session * @@ -2295,6 +2338,7 @@ export class Find extends HeyApiClient { dirs?: "true" | "false" type?: "file" | "directory" limit?: number + sessionID?: string }, options?: Options, ) { @@ -2308,6 +2352,7 @@ export class Find extends HeyApiClient { { in: "query", key: "dirs" }, { in: "query", key: "type" }, { in: "query", key: "limit" }, + { in: "query", key: "sessionID" }, ], }, ], diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index be6c00cf445..8fd90303e57 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -83,13 +83,6 @@ export type EventLspUpdated = { } } -export type EventFileEdited = { - type: "file.edited" - properties: { - file: string - } -} - export type OutputFormatText = { type: "text" } @@ -880,6 +873,13 @@ export type EventSessionError = { } } +export type EventFileEdited = { + type: "file.edited" + properties: { + file: string + } +} + export type EventVcsBranchUpdated = { type: "vcs.branch.updated" properties: { @@ -950,7 +950,6 @@ export type Event = | EventGlobalDisposed | EventLspClientDiagnostics | EventLspUpdated - | EventFileEdited | EventMessageUpdated | EventMessageRemoved | EventMessagePartUpdated @@ -978,6 +977,7 @@ export type Event = | EventSessionDeleted | EventSessionDiff | EventSessionError + | EventFileEdited | EventVcsBranchUpdated | EventPtyCreated | EventPtyUpdated @@ -1682,6 +1682,13 @@ export type McpResource = { client: string } +export type SessionWorkspaceDirectoryResult = { + added: boolean + directory: string + glob: string + session: Session +} + export type TextPartInput = { id?: string type: "text" @@ -2898,6 +2905,42 @@ export type SessionForkResponses = { export type SessionForkResponse = SessionForkResponses[keyof SessionForkResponses] +export type SessionWorkspaceDirectoryData = { + body?: { + path: string + } + path: { + sessionID: string + } + query?: { + directory?: string + } + url: "/session/{sessionID}/workspace/directory" +} + +export type SessionWorkspaceDirectoryErrors = { + /** + * Bad request + */ + 400: BadRequestError + /** + * Not found + */ + 404: NotFoundError +} + +export type SessionWorkspaceDirectoryError = SessionWorkspaceDirectoryErrors[keyof SessionWorkspaceDirectoryErrors] + +export type SessionWorkspaceDirectoryResponses = { + /** + * Workspace directory added + */ + 200: SessionWorkspaceDirectoryResult +} + +export type SessionWorkspaceDirectoryResponse = + SessionWorkspaceDirectoryResponses[keyof SessionWorkspaceDirectoryResponses] + export type SessionAbortData = { body?: never path: { @@ -3952,6 +3995,7 @@ export type FindFilesData = { dirs?: "true" | "false" type?: "file" | "directory" limit?: number + sessionID?: string } url: "/find/file" } diff --git a/packages/sdk/js/src/v2/index.ts b/packages/sdk/js/src/v2/index.ts index d044f5ad66e..2e9d48b4ba7 100644 --- a/packages/sdk/js/src/v2/index.ts +++ b/packages/sdk/js/src/v2/index.ts @@ -1,5 +1,6 @@ export * from "./client.js" export * from "./server.js" +export type * from "./gen/types.gen.js" import { createOpencodeClient } from "./client.js" import { createOpencodeServer } from "./server.js"