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 730da20c265..108117076dc 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx @@ -432,6 +432,20 @@ export function Prompt(props: PromptProps) { } command.register(() => [ + { + title: "Add directory to context", + value: "context.addDir", + category: "Session", + slash: { name: "add-dir" }, + onSelect: (dialog) => { + dialog.clear() + const newText = "/add-dir " + const cursor = input.logicalCursor + input.deleteRange(0, 0, cursor.row, cursor.col) + input.insertText(newText) + input.cursorOffset = Bun.stringWidth(newText) + }, + }, { title: "Stash prompt", value: "prompt.stash", @@ -543,6 +557,22 @@ export function Prompt(props: PromptProps) { command: inputText, }) setStore("mode", "normal") + } else if (inputText.startsWith("/add-dir ")) { + const dirPath = inputText.slice("/add-dir ".length).trim() + if (!dirPath) { + toast.show({ title: "Usage: /add-dir ", message: "Please provide a directory path", variant: "error" }) + return + } + const result = await sdk.client.session.addDir({ path: dirPath, sessionID }) + if (result.error) { + toast.error(result.error) + return + } + const resolved = result.data!.path + input.clear() + setStore("prompt", { input: "", parts: [] }) + toast.show({ title: "Directory added", message: resolved, variant: "success" }) + return } else if ( inputText.startsWith("/") && iife(() => { diff --git a/packages/opencode/src/permission/next.ts b/packages/opencode/src/permission/next.ts index f95aaf34525..f56a6d64d3d 100644 --- a/packages/opencode/src/permission/next.ts +++ b/packages/opencode/src/permission/next.ts @@ -266,4 +266,21 @@ export namespace PermissionNext { export async function list() { return state().then((x) => Object.values(x.pending).map((x) => x.info)) } + + export const grant = fn( + z.object({ + permission: z.string(), + patterns: z.string().array(), + }), + async (input) => { + const s = await state() + for (const pattern of input.patterns) { + s.approved.push({ + permission: input.permission, + pattern, + action: "allow", + }) + } + }, + ) } diff --git a/packages/opencode/src/server/routes/session.ts b/packages/opencode/src/server/routes/session.ts index a98624dfae2..43c734149f4 100644 --- a/packages/opencode/src/server/routes/session.ts +++ b/packages/opencode/src/server/routes/session.ts @@ -2,6 +2,8 @@ import { Hono } from "hono" import { stream } from "hono/streaming" import { describeRoute, validator, resolver } from "hono-openapi" import z from "zod" +import * as path from "node:path" +import * as fs from "node:fs/promises" import { Session } from "../../session" import { MessageV2 } from "../../session/message-v2" import { SessionPrompt } from "../../session/prompt" @@ -16,6 +18,9 @@ import { Log } from "../../util/log" import { PermissionNext } from "@/permission/next" import { errors } from "../error" import { lazy } from "../../util/lazy" +import { Instance } from "../../project/instance" +import { Ripgrep } from "../../file/ripgrep" +import { IGNORE_PATTERNS } from "../../tool/ls" const log = Log.create({ service: "server" }) @@ -931,5 +936,78 @@ export const SessionRoutes = lazy(() => }) return c.json(true) }, + ) + .post( + "/add-dir", + describeRoute({ + summary: "Add directory to context", + description: + "Add a directory to the conversation context. If the directory is outside the project, grants external_directory permission.", + operationId: "session.addDir", + responses: { + 200: { + description: "Directory added successfully", + content: { + "application/json": { + schema: resolver( + z.object({ + path: z.string(), + granted: z.boolean(), + }), + ), + }, + }, + }, + ...errors(400), + }, + }), + validator( + "json", + z.object({ + path: z.string(), + sessionID: z.string(), + }), + ), + async (c) => { + const body = c.req.valid("json") + const input = body.path + + const expanded = input.startsWith("~/") + ? path.join(process.env.HOME || process.env.USERPROFILE || "", input.slice(2)) + : input.startsWith("~") + ? path.join(process.env.HOME || process.env.USERPROFILE || "", input.slice(1)) + : input + + const absolute = path.isAbsolute(expanded) ? expanded : path.resolve(Instance.directory, expanded) + + const info = await fs.stat(absolute).catch(() => null) + if (!info) return c.json({ error: "Directory does not exist" }, 400) + if (!info.isDirectory()) return c.json({ error: "Path is not a directory" }, 400) + + const resolved = await fs.realpath(absolute) + const granted = !Instance.containsPath(resolved) + if (granted) { + await PermissionNext.grant({ + permission: "external_directory", + patterns: [path.join(resolved, "*")], + }) + } + + const globs = IGNORE_PATTERNS.map((p) => `!${p}*`) + const files: string[] = [] + for await (const file of Ripgrep.files({ cwd: resolved, glob: globs })) { + files.push(file) + if (files.length >= 100) break + } + const listing = files.sort().join("\n") + + await SessionPrompt.prompt({ + sessionID: body.sessionID, + noReply: true, + parts: [{ type: "text", text: `Added directory to context: ${resolved}\n\n${listing}` }], + }) + + return c.json({ path: resolved, granted }) + }, ), ) diff --git a/packages/sdk/js/src/v2/gen/sdk.gen.ts b/packages/sdk/js/src/v2/gen/sdk.gen.ts index 59b7f06963b..dd3a4c2adcb 100644 --- a/packages/sdk/js/src/v2/gen/sdk.gen.ts +++ b/packages/sdk/js/src/v2/gen/sdk.gen.ts @@ -93,6 +93,8 @@ import type { QuestionReplyResponses, SessionAbortErrors, SessionAbortResponses, + SessionAddDirErrors, + SessionAddDirResponses, SessionChildrenErrors, SessionChildrenResponses, SessionCommandErrors, @@ -1654,6 +1656,43 @@ export class Session extends HeyApiClient { ...params, }) } + + /** + * Add directory to context + * + * Add a directory to the conversation context. If the directory is outside the project, grants external_directory permission. + */ + public addDir( + parameters?: { + directory?: string + path?: string + sessionID?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "query", key: "directory" }, + { in: "body", key: "path" }, + { in: "body", key: "sessionID" }, + ], + }, + ], + ) + return (options?.client ?? this.client).post({ + url: "/session/add-dir", + ...options, + ...params, + headers: { + "Content-Type": "application/json", + ...options?.headers, + ...params.headers, + }, + }) + } } export class Part extends HeyApiClient { diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 75540f90724..c203c3359e2 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -3710,6 +3710,39 @@ export type PermissionListResponses = { export type PermissionListResponse = PermissionListResponses[keyof PermissionListResponses] +export type SessionAddDirData = { + body?: { + path: string + sessionID: string + } + path?: never + query?: { + directory?: string + } + url: "/session/add-dir" +} + +export type SessionAddDirErrors = { + /** + * Bad request + */ + 400: BadRequestError +} + +export type SessionAddDirError = SessionAddDirErrors[keyof SessionAddDirErrors] + +export type SessionAddDirResponses = { + /** + * Directory added successfully + */ + 200: { + path: string + granted: boolean + } +} + +export type SessionAddDirResponse = SessionAddDirResponses[keyof SessionAddDirResponses] + export type QuestionListData = { body?: never path?: never