diff --git a/packages/opencode/src/cli/cmd/workspace.ts b/packages/opencode/src/cli/cmd/workspace.ts new file mode 100644 index 0000000000..2c51fcb1cd --- /dev/null +++ b/packages/opencode/src/cli/cmd/workspace.ts @@ -0,0 +1,103 @@ +import type { Argv } from "yargs" +import { cmd } from "./cmd" +import { bootstrap } from "../bootstrap" +import { Workspace } from "../../workspace" +import { UI } from "../ui" + +export const WorkspaceCommand = cmd({ + command: "workspace ", + describe: "Manage workspace directories for multi-directory access", + builder: (yargs: Argv) => { + return yargs + .command( + "add ", + "Add a directory to the workspace", + (yargs) => { + return yargs.positional("directory", { + describe: "Directory path to add (absolute or relative to worktree)", + type: "string", + demandOption: true, + }) + }, + async (args) => { + await bootstrap(process.cwd(), async () => { + const workspace = await Workspace.addDirectory(args.directory as string) + UI.println(UI.Style.TEXT_SUCCESS_BOLD + `✓ Added ${args.directory} to workspace`) + UI.println(UI.Style.TEXT_DIM + ` Workspace now includes ${workspace.directories.length} directories`) + }) + }, + ) + .command( + "remove ", + "Remove a directory from the workspace", + (yargs) => { + return yargs.positional("directory", { + describe: "Directory path to remove", + type: "string", + demandOption: true, + }) + }, + async (args) => { + await bootstrap(process.cwd(), async () => { + const workspace = await Workspace.removeDirectory(args.directory as string) + UI.println(UI.Style.TEXT_SUCCESS_BOLD + `✓ Removed ${args.directory} from workspace`) + if (workspace) { + UI.println(UI.Style.TEXT_DIM + ` Workspace now includes ${workspace.directories.length} directories`) + } else { + UI.println(UI.Style.TEXT_DIM + ` Workspace is now empty`) + } + }) + }, + ) + .command( + "list", + "List all workspace directories", + () => {}, + async () => { + await bootstrap(process.cwd(), async () => { + const directories = await Workspace.list() + const allowed = await Workspace.getAllowedDirectories() + + if (allowed.length === 0) { + UI.println(UI.Style.TEXT_WARNING + "No workspace directories configured") + return + } + + UI.println(UI.Style.TEXT_INFO_BOLD + "Workspace directories:") + UI.println() + + // Show default directories + UI.println(UI.Style.TEXT_DIM + "Default (always allowed):") + for (const dir of allowed) { + if (!directories.includes(dir)) { + UI.println(UI.Style.TEXT_NORMAL + ` ${dir}`) + } + } + + // Show configured directories + if (directories.length > 0) { + UI.println() + UI.println(UI.Style.TEXT_DIM + "Configured:") + for (const dir of directories) { + UI.println(UI.Style.TEXT_NORMAL + ` ${dir}`) + } + } + }) + }, + ) + .command( + "clear", + "Clear all workspace directories (keeps defaults)", + () => {}, + async () => { + await bootstrap(process.cwd(), async () => { + await Workspace.clear() + UI.println(UI.Style.TEXT_SUCCESS_BOLD + "✓ Cleared workspace configuration") + UI.println(UI.Style.TEXT_DIM + " Only default directories (cwd and worktree) are now allowed") + }) + }, + ) + .demandCommand(1, "You must specify an action: add, remove, list, or clear") + }, + handler: () => {}, +}) diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 40e4d90a4a..a0f6c5de12 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -409,6 +409,17 @@ export namespace Config { ignore: z.array(z.string()).optional(), }) .optional(), + workspace: z + .object({ + directories: z + .array(z.string()) + .optional() + .describe( + "Additional directories to allow access to. Can be absolute paths or relative to the project root.", + ), + }) + .optional() + .describe("Workspace configuration for multi-directory access"), plugin: z.string().array().optional(), snapshot: z.boolean().optional(), share: z diff --git a/packages/opencode/src/index.ts b/packages/opencode/src/index.ts index 7a54f0b2d6..33318f3b3b 100644 --- a/packages/opencode/src/index.ts +++ b/packages/opencode/src/index.ts @@ -19,6 +19,7 @@ import { McpCommand } from "./cli/cmd/mcp" import { GithubCommand } from "./cli/cmd/github" import { ExportCommand } from "./cli/cmd/export" import { AttachCommand } from "./cli/cmd/attach" +import { WorkspaceCommand } from "./cli/cmd/workspace" const cancel = new AbortController() @@ -81,6 +82,7 @@ const cli = yargs(hideBin(process.argv)) .command(StatsCommand) .command(ExportCommand) .command(GithubCommand) + .command(WorkspaceCommand) .fail((msg) => { if ( msg.startsWith("Unknown argument") || diff --git a/packages/opencode/src/server/server.ts b/packages/opencode/src/server/server.ts index 7020a2aaab..079e57c65e 100644 --- a/packages/opencode/src/server/server.ts +++ b/packages/opencode/src/server/server.ts @@ -30,6 +30,7 @@ import { SessionCompaction } from "../session/compaction" import { SessionRevert } from "../session/revert" import { lazy } from "../util/lazy" import { InstanceBootstrap } from "../project/bootstrap" +import { Workspace } from "../workspace" const ERRORS = { 400: { @@ -159,6 +160,75 @@ export namespace Server { return c.json(config) }, ) + .get( + "/workspace/directories", + describeRoute({ + description: "List workspace directories", + operationId: "workspace.list", + responses: { + 200: { + description: "List of allowed workspace directories", + content: { + "application/json": { + schema: resolver(z.array(z.string()).meta({ ref: "WorkspaceDirectories" })), + }, + }, + }, + }, + }), + async (c) => { + const directories = await Workspace.getAllowedDirectories() + return c.json(directories) + }, + ) + .post( + "/workspace/directories", + describeRoute({ + description: "Add directory to workspace", + operationId: "workspace.addDirectory", + responses: { + 200: { + description: "Successfully added directory", + content: { + "application/json": { + schema: resolver(Workspace.Info), + }, + }, + }, + ...ERRORS, + }, + }), + validator("json", z.object({ directory: z.string() })), + async (c) => { + const { directory } = c.req.valid("json") + const workspace = await Workspace.addDirectory(directory) + return c.json(workspace) + }, + ) + .delete( + "/workspace/directories", + describeRoute({ + description: "Remove directory from workspace", + operationId: "workspace.removeDirectory", + responses: { + 200: { + description: "Successfully removed directory", + content: { + "application/json": { + schema: resolver(Workspace.Info.nullable()), + }, + }, + }, + ...ERRORS, + }, + }), + validator("json", z.object({ directory: z.string() })), + async (c) => { + const { directory } = c.req.valid("json") + const workspace = await Workspace.removeDirectory(directory) + return c.json(workspace) + }, + ) .get( "/experimental/tool/ids", describeRoute({ diff --git a/packages/opencode/src/tool/bash.ts b/packages/opencode/src/tool/bash.ts index ddf8227e9e..0d3f1a334d 100644 --- a/packages/opencode/src/tool/bash.ts +++ b/packages/opencode/src/tool/bash.ts @@ -4,13 +4,13 @@ import { exec } from "child_process" import { Tool } from "./tool" import DESCRIPTION from "./bash.txt" import { Permission } from "../permission" -import { Filesystem } from "../util/filesystem" import { lazy } from "../util/lazy" import { Log } from "../util/log" import { Wildcard } from "../util/wildcard" import { $ } from "bun" import { Instance } from "../project/instance" import { Agent } from "../agent/agent" +import { PathValidation } from "../workspace/validate" const MAX_OUTPUT_LENGTH = 30_000 const DEFAULT_TIMEOUT = 1 * 60 * 1000 @@ -87,10 +87,13 @@ export const BashTool = Tool.define("bash", { .text() .then((x) => x.trim()) log.info("resolved path", { arg, resolved }) - if (resolved && !Filesystem.contains(Instance.directory, resolved)) { - throw new Error( - `This command references paths outside of ${Instance.directory} so it is not allowed to be executed.`, - ) + if (resolved) { + // Validate path access (checks workspace + requests permission if needed) + await PathValidation.validate(resolved, { + sessionID: ctx.sessionID, + messageID: ctx.messageID, + callID: ctx.callID, + }) } } } diff --git a/packages/opencode/src/tool/edit.ts b/packages/opencode/src/tool/edit.ts index 579f9f09d8..36e81f4dfe 100644 --- a/packages/opencode/src/tool/edit.ts +++ b/packages/opencode/src/tool/edit.ts @@ -13,9 +13,9 @@ import DESCRIPTION from "./edit.txt" import { File } from "../file" import { Bus } from "../bus" import { FileTime } from "../file/time" -import { Filesystem } from "../util/filesystem" import { Instance } from "../project/instance" import { Agent } from "../agent/agent" +import { PathValidation } from "../workspace/validate" export const EditTool = Tool.define("edit", { description: DESCRIPTION, @@ -35,9 +35,13 @@ export const EditTool = Tool.define("edit", { } const filePath = path.isAbsolute(params.filePath) ? params.filePath : path.join(Instance.directory, params.filePath) - if (!Filesystem.contains(Instance.directory, filePath)) { - throw new Error(`File ${filePath} is not in the current working directory`) - } + + // Validate path access (checks workspace + requests permission if needed) + await PathValidation.validate(filePath, { + sessionID: ctx.sessionID, + messageID: ctx.messageID, + callID: ctx.callID, + }) const agent = await Agent.get(ctx.agent) let diff = "" diff --git a/packages/opencode/src/tool/patch.ts b/packages/opencode/src/tool/patch.ts index 8f30330804..f4981facbd 100644 --- a/packages/opencode/src/tool/patch.ts +++ b/packages/opencode/src/tool/patch.ts @@ -9,15 +9,16 @@ import { FileWatcher } from "../file/watcher" import { Instance } from "../project/instance" import { Agent } from "../agent/agent" import { Patch } from "../patch" -import { Filesystem } from "../util/filesystem" import { createTwoFilesPatch } from "diff" +import { PathValidation } from "../workspace/validate" const PatchParams = z.object({ patchText: z.string().describe("The full patch text that describes all changes to be made"), }) export const PatchTool = Tool.define("patch", { - description: "Apply a patch to modify multiple files. Supports adding, updating, and deleting files with context-aware changes.", + description: + "Apply a patch to modify multiple files. Supports adding, updating, and deleting files with context-aware changes.", parameters: PatchParams, async execute(params, ctx) { if (!params.patchText) { @@ -46,15 +47,18 @@ export const PatchTool = Tool.define("patch", { type: "add" | "update" | "delete" | "move" movePath?: string }> = [] - + let totalDiff = "" for (const hunk of hunks) { const filePath = path.resolve(Instance.directory, hunk.path) - - if (!Filesystem.contains(Instance.directory, filePath)) { - throw new Error(`File ${filePath} is not in the current working directory`) - } + + // Validate path access (checks workspace + requests permission if needed) + await PathValidation.validate(filePath, { + sessionID: ctx.sessionID, + messageID: ctx.messageID, + callID: ctx.callID, + }) switch (hunk.type) { case "add": @@ -62,30 +66,30 @@ export const PatchTool = Tool.define("patch", { const oldContent = "" const newContent = hunk.contents const diff = createTwoFilesPatch(filePath, filePath, oldContent, newContent) - + fileChanges.push({ filePath, oldContent, newContent, type: "add", }) - + totalDiff += diff + "\n" } break - + case "update": // Check if file exists for update const stats = await fs.stat(filePath).catch(() => null) if (!stats || stats.isDirectory()) { throw new Error(`File not found or is directory: ${filePath}`) } - + // Read file and update time tracking (like edit tool does) await FileTime.assert(ctx.sessionID, filePath) const oldContent = await fs.readFile(filePath, "utf-8") let newContent = oldContent - + // Apply the update chunks to get new content try { const fileUpdate = Patch.deriveNewContentsFromChunks(filePath, hunk.chunks) @@ -93,9 +97,9 @@ export const PatchTool = Tool.define("patch", { } catch (error) { throw new Error(`Failed to apply update to ${filePath}: ${error}`) } - + const diff = createTwoFilesPatch(filePath, filePath, oldContent, newContent) - + fileChanges.push({ filePath, oldContent, @@ -103,23 +107,23 @@ export const PatchTool = Tool.define("patch", { type: hunk.move_path ? "move" : "update", movePath: hunk.move_path ? path.resolve(Instance.directory, hunk.move_path) : undefined, }) - + totalDiff += diff + "\n" break - + case "delete": // Check if file exists for deletion await FileTime.assert(ctx.sessionID, filePath) const contentToDelete = await fs.readFile(filePath, "utf-8") const deleteDiff = createTwoFilesPatch(filePath, filePath, contentToDelete, "") - + fileChanges.push({ filePath, oldContent: contentToDelete, newContent: "", type: "delete", }) - + totalDiff += deleteDiff + "\n" break } @@ -141,7 +145,7 @@ export const PatchTool = Tool.define("patch", { // Apply the changes const changedFiles: string[] = [] - + for (const change of fileChanges) { switch (change.type) { case "add": @@ -153,12 +157,12 @@ export const PatchTool = Tool.define("patch", { await fs.writeFile(change.filePath, change.newContent, "utf-8") changedFiles.push(change.filePath) break - + case "update": await fs.writeFile(change.filePath, change.newContent, "utf-8") changedFiles.push(change.filePath) break - + case "move": if (change.movePath) { // Create parent directories for destination @@ -173,13 +177,13 @@ export const PatchTool = Tool.define("patch", { changedFiles.push(change.movePath) } break - + case "delete": await fs.unlink(change.filePath) changedFiles.push(change.filePath) break } - + // Update file time tracking FileTime.read(ctx.sessionID, change.filePath) if (change.movePath) { @@ -193,7 +197,7 @@ export const PatchTool = Tool.define("patch", { } // Generate output summary - const relativePaths = changedFiles.map(filePath => path.relative(Instance.worktree, filePath)) + const relativePaths = changedFiles.map((filePath) => path.relative(Instance.worktree, filePath)) const summary = `${fileChanges.length} files changed` return { @@ -201,7 +205,7 @@ export const PatchTool = Tool.define("patch", { metadata: { diff: totalDiff, }, - output: `Patch applied successfully. ${summary}:\n${relativePaths.map(p => ` ${p}`).join("\n")}`, + output: `Patch applied successfully. ${summary}:\n${relativePaths.map((p) => ` ${p}`).join("\n")}`, } }, -}) \ No newline at end of file +}) diff --git a/packages/opencode/src/tool/read.ts b/packages/opencode/src/tool/read.ts index 2ed3accbd1..fc957f20c1 100644 --- a/packages/opencode/src/tool/read.ts +++ b/packages/opencode/src/tool/read.ts @@ -5,8 +5,8 @@ import { Tool } from "./tool" import { LSP } from "../lsp" import { FileTime } from "../file/time" import DESCRIPTION from "./read.txt" -import { Filesystem } from "../util/filesystem" import { Instance } from "../project/instance" +import { PathValidation } from "../workspace/validate" const DEFAULT_READ_LIMIT = 2000 const MAX_LINE_LENGTH = 2000 @@ -21,12 +21,17 @@ export const ReadTool = Tool.define("read", { async execute(params, ctx) { let filepath = params.filePath if (!path.isAbsolute(filepath)) { - filepath = path.join(process.cwd(), filepath) - } - if (!ctx.extra?.["bypassCwdCheck"] && !Filesystem.contains(Instance.directory, filepath)) { - throw new Error(`File ${filepath} is not in the current working directory`) + filepath = path.join(Instance.directory, filepath) } + // Validate path access (checks workspace + requests permission if needed) + await PathValidation.validate(filepath, { + sessionID: ctx.sessionID, + messageID: ctx.messageID, + callID: ctx.callID, + bypass: ctx.extra?.["bypassCwdCheck"], + }) + const file = Bun.file(filepath) if (!(await file.exists())) { const dir = path.dirname(filepath) diff --git a/packages/opencode/src/tool/write.ts b/packages/opencode/src/tool/write.ts index aa79c9bfb9..16a942386e 100644 --- a/packages/opencode/src/tool/write.ts +++ b/packages/opencode/src/tool/write.ts @@ -7,9 +7,9 @@ import DESCRIPTION from "./write.txt" import { Bus } from "../bus" import { File } from "../file" import { FileTime } from "../file/time" -import { Filesystem } from "../util/filesystem" import { Instance } from "../project/instance" import { Agent } from "../agent/agent" +import { PathValidation } from "../workspace/validate" export const WriteTool = Tool.define("write", { description: DESCRIPTION, @@ -19,9 +19,13 @@ export const WriteTool = Tool.define("write", { }), async execute(params, ctx) { const filepath = path.isAbsolute(params.filePath) ? params.filePath : path.join(Instance.directory, params.filePath) - if (!Filesystem.contains(Instance.directory, filepath)) { - throw new Error(`File ${filepath} is not in the current working directory`) - } + + // Validate path access (checks workspace + requests permission if needed) + await PathValidation.validate(filepath, { + sessionID: ctx.sessionID, + messageID: ctx.messageID, + callID: ctx.callID, + }) const file = Bun.file(filepath) const exists = await file.exists() diff --git a/packages/opencode/src/workspace/index.ts b/packages/opencode/src/workspace/index.ts new file mode 100644 index 0000000000..1e01c89518 --- /dev/null +++ b/packages/opencode/src/workspace/index.ts @@ -0,0 +1,149 @@ +import z from "zod/v4" +import { Instance } from "../project/instance" +import { Storage } from "../storage/storage" +import { Filesystem } from "../util/filesystem" +import { Log } from "../util/log" +import path from "path" + +export namespace Workspace { + const log = Log.create({ service: "workspace" }) + + export const Info = z + .object({ + directories: z.array(z.string()).describe("List of allowed directories in the workspace"), + time: z.object({ + created: z.number(), + updated: z.number(), + }), + }) + .meta({ + ref: "Workspace", + }) + export type Info = z.infer + + /** + * Get all allowed directories for the current project + * Always includes Instance.directory and Instance.worktree + * Also loads directories from opencode.json workspace config + */ + export async function getAllowedDirectories(): Promise { + const workspace = await Storage.read(["workspace", Instance.project.id]).catch(() => null) + const directories = new Set() + + // Always allow Instance.directory and Instance.worktree + directories.add(Instance.directory) + if (Instance.worktree !== Instance.directory) { + directories.add(Instance.worktree) + } + + // Add directories from opencode.json config + const { Config } = await import("../config/config") + const config = await Config.get() + if (config.workspace?.directories) { + for (const dir of config.workspace.directories) { + const resolved = path.isAbsolute(dir) ? dir : path.resolve(Instance.worktree, dir) + directories.add(resolved) + } + } + + // Add configured workspace directories from storage + if (workspace?.directories) { + for (const dir of workspace.directories) { + const resolved = path.isAbsolute(dir) ? dir : path.resolve(Instance.worktree, dir) + directories.add(resolved) + } + } + + return Array.from(directories) + } + + /** + * Add a directory to the workspace allowlist + */ + export async function addDirectory(directory: string) { + const resolved = path.resolve(directory) + log.info("adding directory to workspace", { directory: resolved, projectID: Instance.project.id }) + + const existing = await Storage.read(["workspace", Instance.project.id]).catch(() => null) + const directories = new Set(existing?.directories || []) + directories.add(resolved) + + const workspace: Info = { + directories: Array.from(directories), + time: { + created: existing?.time.created ?? Date.now(), + updated: Date.now(), + }, + } + + await Storage.write(["workspace", Instance.project.id], workspace) + return workspace + } + + /** + * Remove a directory from the workspace allowlist + */ + export async function removeDirectory(directory: string) { + const resolved = path.resolve(directory) + log.info("removing directory from workspace", { directory: resolved, projectID: Instance.project.id }) + + const existing = await Storage.read(["workspace", Instance.project.id]).catch(() => null) + if (!existing) return + + const directories = existing.directories.filter((d) => d !== resolved) + + if (directories.length === 0) { + const workspace: Info = { + directories: [], + time: { + created: existing.time.created, + updated: Date.now(), + }, + } + await Storage.write(["workspace", Instance.project.id], workspace) + return workspace + } + + const workspace: Info = { + directories, + time: { + created: existing.time.created, + updated: Date.now(), + }, + } + + await Storage.write(["workspace", Instance.project.id], workspace) + return workspace + } + + /** + * List all directories in the workspace + */ + export async function list() { + const workspace = await Storage.read(["workspace", Instance.project.id]).catch(() => null) + return workspace?.directories || [] + } + + /** + * Check if a path is within any allowed directory + */ + export async function contains(filepath: string): Promise { + const allowed = await getAllowedDirectories() + return allowed.some((dir) => Filesystem.contains(dir, filepath)) + } + + /** + * Clear all workspace directories (reset to defaults) + */ + export async function clear() { + log.info("clearing workspace", { projectID: Instance.project.id }) + const workspace: Info = { + directories: [], + time: { + created: Date.now(), + updated: Date.now(), + }, + } + await Storage.write(["workspace", Instance.project.id], workspace) + } +} diff --git a/packages/opencode/src/workspace/validate.ts b/packages/opencode/src/workspace/validate.ts new file mode 100644 index 0000000000..c1d4abb93d --- /dev/null +++ b/packages/opencode/src/workspace/validate.ts @@ -0,0 +1,51 @@ +import { Workspace } from "." +import { Permission } from "../permission" +import { Filesystem } from "../util/filesystem" +import path from "path" + +export namespace PathValidation { + /** + * Validate a path and request permission if needed + * Returns true if path is allowed, throws if denied + */ + export async function validate( + filepath: string, + ctx: { + sessionID: string + messageID: string + callID?: string + bypass?: boolean + }, + ): Promise { + // Bypass check if explicitly allowed (e.g., for file attachments) + if (ctx.bypass) return + + // Resolve to absolute path + const resolved = path.isAbsolute(filepath) ? filepath : path.resolve(filepath) + + // Check if path is in workspace + if (await Workspace.contains(resolved)) return + + // Path is outside workspace - request permission + await Permission.ask({ + type: "path-access", + pattern: resolved, + sessionID: ctx.sessionID, + messageID: ctx.messageID, + callID: ctx.callID, + title: `Access ${resolved}?`, + metadata: { + filepath: resolved, + }, + }) + } + + /** + * Get the first allowed directory that contains this path, or undefined + */ + export async function getAllowedParent(filepath: string): Promise { + const resolved = path.isAbsolute(filepath) ? filepath : path.resolve(filepath) + const allowed = await Workspace.getAllowedDirectories() + return allowed.find((dir) => Filesystem.contains(dir, resolved)) + } +} diff --git a/packages/tui/internal/app/app.go b/packages/tui/internal/app/app.go index 4a891f2827..59381f0e13 100644 --- a/packages/tui/internal/app/app.go +++ b/packages/tui/internal/app/app.go @@ -89,6 +89,9 @@ type SendCommand = struct { Command string Args string } +type AddWorkspaceDir = struct { + Directory string +} type SetEditorContentMsg struct { Text string } diff --git a/packages/tui/internal/commands/command.go b/packages/tui/internal/commands/command.go index d552b78ece..f49eb4138f 100644 --- a/packages/tui/internal/commands/command.go +++ b/packages/tui/internal/commands/command.go @@ -160,6 +160,7 @@ const ( MessagesUndoCommand CommandName = "messages_undo" MessagesRedoCommand CommandName = "messages_redo" AppExitCommand CommandName = "app_exit" + WorkspaceAddDirCommand CommandName = "workspace_add_dir" ) func (k Command) Matches(msg tea.KeyPressMsg, leader bool) bool { @@ -385,6 +386,12 @@ func LoadFromConfig(config *opencode.Config, customCommands []opencode.Command) Keybindings: parseBindings("r"), Trigger: []string{"redo"}, }, + { + Name: WorkspaceAddDirCommand, + Description: "add directory to workspace", + Keybindings: parseBindings(), + Trigger: []string{"add-dir"}, + }, { Name: AppExitCommand, Description: "exit the app", diff --git a/packages/tui/internal/components/chat/editor.go b/packages/tui/internal/components/chat/editor.go index 2841e2cc85..2117c06d22 100644 --- a/packages/tui/internal/components/chat/editor.go +++ b/packages/tui/internal/components/chat/editor.go @@ -230,6 +230,12 @@ func (m *editorComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil } + // Special handling for commands that need arguments + if command.Name == commands.WorkspaceAddDirCommand { + m.SetValue("/" + command.PrimaryTrigger() + " ") + return m, nil + } + updated, cmd := m.Clear() m = updated.(*editorComponent) cmds = append(cmds, cmd) @@ -522,6 +528,28 @@ func (m *editorComponent) Submit() (tea.Model, tea.Cmd) { return m, tea.Batch(cmds...) } + + // Handle workspace add-dir command with arguments + if command.Name == commands.WorkspaceAddDirCommand { + args := "" + if strings.HasPrefix(expandedValue, command.PrimaryTrigger()+" ") { + args = strings.TrimPrefix(expandedValue, command.PrimaryTrigger()+" ") + } + if args == "" { + return m, toast.NewErrorToast("Please provide a directory path") + } + + cmds = append( + cmds, + util.CmdHandler(app.AddWorkspaceDir{Directory: args}), + ) + + updated, cmd := m.Clear() + m = updated.(*editorComponent) + cmds = append(cmds, cmd) + + return m, tea.Batch(cmds...) + } } attachments := m.textarea.GetAttachments() diff --git a/packages/tui/internal/tui/tui.go b/packages/tui/internal/tui/tui.go index 3310d517c5..70a2186b88 100644 --- a/packages/tui/internal/tui/tui.go +++ b/packages/tui/internal/tui/tui.go @@ -426,6 +426,19 @@ func (a Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { a.app, cmd = a.app.SendCommand(context.Background(), msg.Command, msg.Args) cmds = append(cmds, cmd) } + case app.AddWorkspaceDir: + // TODO: Regenerate SDK to include workspace endpoints + // Expected SDK method: a.app.Client.Workspace.AddDirectory(ctx, WorkspaceAddDirectoryParams{Directory: F(msg.Directory)}) + return a, func() tea.Msg { + // PLACEHOLDER: This will be replaced once SDK is regenerated + // The SDK generator should create: + // - WorkspaceService with AddDirectory, RemoveDirectory, List methods + // - WorkspaceAddDirectoryParams struct + // Based on the /workspace/directories endpoints in server.ts + + slog.Warn("Workspace operations not yet available - SDK needs regeneration") + return toast.NewErrorToast("Workspace operations require SDK regeneration. Please run: bun run sdk:generate")() + } case app.SendShell: // If we're in a child session, switch back to parent before sending prompt if a.app.Session.ParentID != "" {