From c1d2d7aba3df526e7e43e3f026aced36206f7362 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Wed, 15 Jul 2026 00:33:59 -0400 Subject: [PATCH] feat(session): support directory moves from slash commands --- packages/client/src/effect/api/api.ts | 4 +- .../client/src/effect/generated/client.ts | 6 +- .../client/src/promise/generated/client.ts | 2 +- .../client/src/promise/generated/types.ts | 12 +- .../core/src/control-plane/move-session.ts | 50 +++- packages/core/src/session.ts | 52 ++++ packages/core/src/session/projector.ts | 1 + packages/core/test/move-session.test.ts | 62 +++- packages/plugin/src/v2/tui/context.ts | 6 +- packages/protocol/src/groups/session.ts | 5 +- packages/schema/src/session-event.ts | 2 + packages/sdk/js/src/v2/gen/sdk.gen.ts | 37 ++- packages/sdk/js/src/v2/gen/types.gen.ts | 283 +++++++++++------- packages/server/src/handlers/session.ts | 29 +- packages/server/src/routes.ts | 2 - .../tui/src/component/prompt/autocomplete.tsx | 33 +- packages/tui/src/component/prompt/index.tsx | 44 +++ packages/tui/src/component/prompt/move.tsx | 9 +- packages/tui/src/context/data.tsx | 2 + packages/tui/src/context/keymap.tsx | 39 ++- packages/tui/src/keymap.tsx | 1 + packages/tui/test/cli/tui/data.test.tsx | 2 + .../test/cli/tui/keymap-arguments.test.tsx | 44 +++ 23 files changed, 521 insertions(+), 206 deletions(-) create mode 100644 packages/tui/test/cli/tui/keymap-arguments.test.tsx diff --git a/packages/client/src/effect/api/api.ts b/packages/client/src/effect/api/api.ts index 190079e5744..a6a3c7cee04 100644 --- a/packages/client/src/effect/api/api.ts +++ b/packages/client/src/effect/api/api.ts @@ -130,8 +130,8 @@ export type SessionRenameOperation = (input: Endpoint5_8Input) => Eff type Endpoint5_9Request = Parameters[0] export type Endpoint5_9Input = { readonly sessionID: Endpoint5_9Request["params"]["sessionID"] - readonly destination: Endpoint5_9Request["payload"]["destination"] - readonly moveChanges?: Endpoint5_9Request["payload"]["moveChanges"] + readonly directory: Endpoint5_9Request["payload"]["directory"] + readonly workspaceID?: Endpoint5_9Request["payload"]["workspaceID"] } export type Endpoint5_9Output = EffectValue> export type SessionMoveOperation = (input: Endpoint5_9Input) => Effect.Effect diff --git a/packages/client/src/effect/generated/client.ts b/packages/client/src/effect/generated/client.ts index 85414eebb8a..93720e998a6 100644 --- a/packages/client/src/effect/generated/client.ts +++ b/packages/client/src/effect/generated/client.ts @@ -159,13 +159,13 @@ const Endpoint5_8 = (raw: RawClient["server.session"]) => (input: Endpoint5_8Inp type Endpoint5_9Request = Parameters[0] type Endpoint5_9Input = { readonly sessionID: Endpoint5_9Request["params"]["sessionID"] - readonly destination: Endpoint5_9Request["payload"]["destination"] - readonly moveChanges?: Endpoint5_9Request["payload"]["moveChanges"] + readonly directory: Endpoint5_9Request["payload"]["directory"] + readonly workspaceID?: Endpoint5_9Request["payload"]["workspaceID"] } const Endpoint5_9 = (raw: RawClient["server.session"]) => (input: Endpoint5_9Input) => raw["session.move"]({ params: { sessionID: input["sessionID"] }, - payload: { destination: input["destination"], moveChanges: input["moveChanges"] }, + payload: { directory: input["directory"], workspaceID: input["workspaceID"] }, }).pipe(Effect.mapError(mapClientError)) type Endpoint5_10Request = Parameters[0] diff --git a/packages/client/src/promise/generated/client.ts b/packages/client/src/promise/generated/client.ts index 97c4c0a350d..99355502590 100644 --- a/packages/client/src/promise/generated/client.ts +++ b/packages/client/src/promise/generated/client.ts @@ -522,7 +522,7 @@ export function make(options: ClientOptions) { { method: "POST", path: `/api/session/${encodeURIComponent(input.sessionID)}/move`, - body: { destination: input["destination"], moveChanges: input["moveChanges"] }, + body: { directory: input["directory"], workspaceID: input["workspaceID"] }, successStatus: 204, declaredStatuses: [404, 400, 401], empty: true, diff --git a/packages/client/src/promise/generated/types.ts b/packages/client/src/promise/generated/types.ts index 2718a0640b4..a93dbb1e42b 100644 --- a/packages/client/src/promise/generated/types.ts +++ b/packages/client/src/promise/generated/types.ts @@ -567,7 +567,7 @@ export type SessionMoved = { type: "session.moved" durable: { aggregateID: string; seq: number; version: 1 } location?: LocationRef - data: { sessionID: string; location: LocationRef; subpath?: string } + data: { sessionID: string; location: LocationRef; projectID?: string; subpath?: string } } export type SessionRenamed = { @@ -2715,14 +2715,8 @@ export type SessionRenameOutput = void export type SessionMoveInput = { readonly sessionID: { readonly sessionID: string }["sessionID"] - readonly destination: { - readonly destination: { readonly directory: string } - readonly moveChanges?: boolean | undefined - }["destination"] - readonly moveChanges?: { - readonly destination: { readonly directory: string } - readonly moveChanges?: boolean | undefined - }["moveChanges"] + readonly directory: { readonly directory: string; readonly workspaceID?: string }["directory"] + readonly workspaceID?: { readonly directory: string; readonly workspaceID?: string }["workspaceID"] } export type SessionMoveOutput = void diff --git a/packages/core/src/control-plane/move-session.ts b/packages/core/src/control-plane/move-session.ts index 84d135d3f9f..1fadfea1e2f 100644 --- a/packages/core/src/control-plane/move-session.ts +++ b/packages/core/src/control-plane/move-session.ts @@ -2,16 +2,15 @@ export * as MoveSession from "./move-session" import { Context, DateTime, Effect, Layer, Schema } from "effect" import { makeGlobalNode } from "../effect/app-node" -import { EventV2 } from "../event" +import { FSUtil } from "../fs-util" import { Git } from "../git" -import { Location } from "../location" +import { Global } from "../global" import { ProjectV2 } from "../project" import { SessionV2 } from "../session" -import { SessionEvent } from "../session/event" import { SessionExecution } from "../session/execution" import { SessionSchema } from "../session/schema" import { SessionStore } from "../session/store" -import { AbsolutePath, RelativePath } from "../schema" +import { AbsolutePath } from "../schema" import path from "path" export const Destination = Schema.Struct({ @@ -34,6 +33,16 @@ export class DestinationProjectMismatchError extends Schema.TaggedErrorClass()( + "MoveSession.DestinationNotFoundError", + { directory: AbsolutePath }, +) {} + +export class DestinationNotDirectoryError extends Schema.TaggedErrorClass()( + "MoveSession.DestinationNotDirectoryError", + { directory: AbsolutePath }, +) {} + export class ApplyChangesError extends Schema.TaggedErrorClass()("MoveSession.ApplyChangesError", { message: Schema.String, }) {} @@ -57,6 +66,10 @@ export class ResetSourceChangesError extends Schema.TaggedErrorClass Effect.succeed(undefined))) + if (!destinationInfo) return yield* new DestinationNotFoundError({ directory }) + if (destinationInfo.type !== "Directory") return yield* new DestinationNotDirectoryError({ directory }) if (current.location.directory === directory) return const source = yield* project.resolve(current.location.directory) const destination = yield* project.resolve(directory) - if (current.projectID !== destination.id) { + if (input.moveChanges && current.projectID !== destination.id) { return yield* new DestinationProjectMismatchError({ expected: current.projectID, actual: destination.id }) } - // A move must not race active execution: a mid-drain relocation would let // the source Location dispatch a request assembled under stale instructions // and history. Serialize like removal does — stop the drain, then move. @@ -111,10 +130,9 @@ const layer = Layer.effect( .pipe(Effect.mapError((error) => new ApplyChangesError({ message: error.message }))) } - yield* events.publish(SessionEvent.Moved, { + yield* session.move({ sessionID: input.sessionID, - location: Location.Ref.make({ directory }), - subpath: RelativePath.make(path.relative(destination.directory, directory).replaceAll("\\", "/")), + directory, }) if (patch) { @@ -151,5 +169,13 @@ const layer = Layer.effect( export const node = makeGlobalNode({ service: Service, layer, - deps: [Git.node, EventV2.node, ProjectV2.node, SessionStore.node, SessionExecution.node], + deps: [ + FSUtil.node, + Git.node, + Global.node, + ProjectV2.node, + SessionV2.node, + SessionStore.node, + SessionExecution.node, + ], }) diff --git a/packages/core/src/session.ts b/packages/core/src/session.ts index a888498c155..34ff7ecfa92 100644 --- a/packages/core/src/session.ts +++ b/packages/core/src/session.ts @@ -43,6 +43,7 @@ import { SkillV2 } from "./skill" import { Job } from "./job" import { CommandV2 } from "./command" import { Shell } from "./shell" +import { Global } from "./global" import { Shell as ShellSchema } from "@opencode-ai/schema/shell" import { KeyedMutex } from "./effect/keyed-mutex" import { fileURLToPath } from "url" @@ -146,6 +147,16 @@ export class BusyError extends Schema.TaggedErrorClass()("Session.Bus export class SkillNotFoundError extends Schema.TaggedErrorClass()("Session.SkillNotFoundError", { skill: SkillV2.ID, }) {} + +export class DestinationNotFoundError extends Schema.TaggedErrorClass()( + "Session.DestinationNotFoundError", + { directory: AbsolutePath }, +) {} + +export class DestinationNotDirectoryError extends Schema.TaggedErrorClass()( + "Session.DestinationNotDirectoryError", + { directory: AbsolutePath }, +) {} export const MessageNotFoundError = SessionRevert.MessageNotFoundError export type MessageNotFoundError = SessionRevert.MessageNotFoundError @@ -159,6 +170,8 @@ export type Error = | CompactionConflictError | BusyError | SkillNotFoundError + | DestinationNotFoundError + | DestinationNotDirectoryError | CommandV2.NotFoundError | CommandV2.EvaluationError | MessageNotFoundError @@ -215,6 +228,11 @@ export interface Interface { model: ModelV2.Ref }) => Effect.Effect readonly rename: (input: { sessionID: SessionSchema.ID; title: string }) => Effect.Effect + readonly move: (input: { + sessionID: SessionSchema.ID + directory: AbsolutePath + workspaceID?: Location.Ref["workspaceID"] + }) => Effect.Effect readonly prompt: (input: { id?: SessionMessage.ID sessionID: SessionSchema.ID @@ -288,6 +306,7 @@ const layer = Layer.effect( const db = database.db const events = yield* EventV2.Service const projects = yield* ProjectV2.Service + const global = yield* Global.Service const execution = yield* SessionExecution.Service const store = yield* SessionStore.Service const locations = yield* LocationServiceMap.Service @@ -673,6 +692,38 @@ const layer = Layer.effect( title: input.title, }) }), + move: Effect.fn("V2Session.move")(function* (input) { + const current = yield* result.get(input.sessionID) + const value = input.directory.trim() + const expanded = + value === "~" ? global.home : value.startsWith("~/") ? path.join(global.home, value.slice(2)) : value + const directory = AbsolutePath.make(path.resolve(current.location.directory, expanded)) + const info = yield* fs.stat(directory).pipe(Effect.catch(() => Effect.succeed(undefined))) + if (!info) return yield* new DestinationNotFoundError({ directory }) + if (info.type !== "Directory") return yield* new DestinationNotDirectoryError({ directory }) + if ( + current.location.directory === directory && + current.location.workspaceID === input.workspaceID + ) + return + const project = yield* projects.resolve(directory) + yield* db + .insert(ProjectTable) + .values({ id: project.id, worktree: project.directory, vcs: project.vcs?.type, sandboxes: [] }) + .onConflictDoNothing() + .run() + .pipe(Effect.orDie) + if ((yield* execution.active).has(input.sessionID)) { + yield* execution.interrupt(input.sessionID) + yield* execution.awaitIdle(input.sessionID) + } + yield* events.publish(SessionEvent.Moved, { + sessionID: input.sessionID, + location: Location.Ref.make({ directory, workspaceID: input.workspaceID }), + projectID: project.id, + subpath: RelativePath.make(path.relative(project.directory, directory).replaceAll("\\", "/")), + }) + }), compact: Effect.fn("V2Session.compact")(function* (input) { yield* result.get(input.sessionID) const inputID = input.id ?? SessionMessage.ID.create() @@ -949,5 +1000,6 @@ export const node = makeGlobalNode({ LocationServiceMap.node, SessionProjector.node, FSUtil.node, + Global.node, ], }) diff --git a/packages/core/src/session/projector.ts b/packages/core/src/session/projector.ts index 70362cc89b3..3c3dccee6f3 100644 --- a/packages/core/src/session/projector.ts +++ b/packages/core/src/session/projector.ts @@ -497,6 +497,7 @@ const layer = Layer.effectDiscard( .set({ directory: event.data.location.directory, path: event.data.subpath, + ...(event.data.projectID ? { project_id: event.data.projectID } : {}), workspace_id: event.data.location.workspaceID ? WorkspaceV2.ID.make(event.data.location.workspaceID) : null, time_updated: DateTime.toEpochMillis(event.created), }) diff --git a/packages/core/test/move-session.test.ts b/packages/core/test/move-session.test.ts index c550de1d3bc..77d0840756d 100644 --- a/packages/core/test/move-session.test.ts +++ b/packages/core/test/move-session.test.ts @@ -43,6 +43,7 @@ const it = testEffect( EventV2.node, ProjectDirectories.node, Project.node, + SessionV2.node, SessionProjector.node, SessionStore.node, ]), @@ -137,7 +138,6 @@ describe("MoveSession", () => { yield* Effect.promise(() => initRepo(root.path)) const source = abs(yield* Effect.promise(() => fs.realpath(root.path))) const destination = abs(path.join(source, "packages")) - yield* Effect.promise(() => fs.mkdir(destination)) yield* Effect.promise(() => fs.writeFile(path.join(source, "tracked.txt"), "changed\n")) yield* Effect.promise(() => fs.writeFile(path.join(source, "untracked.txt"), "new\n")) @@ -164,8 +164,14 @@ describe("MoveSession", () => { .run() .pipe(Effect.orDie) + const missing = yield* SessionV2.Service.use((service) => + service.move({ sessionID, directory: abs("packages") }).pipe(Effect.flip), + ) + expect(missing._tag).toBe("Session.DestinationNotFoundError") + yield* Effect.promise(() => fs.mkdir(destination)) + yield* MoveSession.Service.use((service) => - service.moveSession({ sessionID, destination: { directory: destination }, moveChanges: true }), + service.moveSession({ sessionID, destination: { directory: abs("packages") }, moveChanges: true }), ) expect(yield* Effect.promise(() => fs.readFile(path.join(source, "tracked.txt"), "utf8"))).toBe("changed\n") @@ -180,6 +186,58 @@ describe("MoveSession", () => { }), ) + it.live("moves a session to another project", () => + Effect.gen(function* () { + const root = yield* Effect.acquireRelease( + Effect.promise(() => tmpdir()), + (dir) => Effect.promise(() => dir[Symbol.asyncDispose]()), + ) + yield* Effect.promise(() => initRepo(root.path)) + const source = abs(yield* Effect.promise(() => fs.realpath(root.path))) + const destination = abs(`${root.path}-other-project`) + yield* Effect.acquireRelease( + Effect.promise(() => fs.mkdir(destination, { recursive: true })), + () => Effect.promise(() => fs.rm(destination, { recursive: true, force: true })), + ) + + const projectID = (yield* Project.Service.use((service) => service.resolve(source))).id + const destinationProjectID = (yield* Project.Service.use((service) => service.resolve(destination))).id + const sessionID = SessionV2.ID.make("ses_move_project") + const { db } = yield* Database.Service + yield* db + .insert(ProjectTable) + .values({ id: projectID, worktree: source, sandboxes: [], time_created: 1, time_updated: 1 }) + .run() + .pipe(Effect.orDie) + yield* db + .insert(SessionTable) + .values({ + id: sessionID, + project_id: projectID, + slug: "move-project", + directory: source, + title: "move project", + version: "test", + time_created: 1, + time_updated: 1, + }) + .run() + .pipe(Effect.orDie) + + yield* SessionV2.Service.use((service) => + service.move({ sessionID, directory: destination }), + ) + + expect( + yield* db + .select({ projectID: SessionTable.project_id, directory: SessionTable.directory }) + .from(SessionTable) + .where(eq(SessionTable.id, sessionID)) + .get(), + ).toEqual({ projectID: destinationProjectID, directory: destination }) + }), + ) + it.live("moves nested session changes without cleaning unrelated files", () => Effect.gen(function* () { const root = yield* Effect.acquireRelease( diff --git a/packages/plugin/src/v2/tui/context.ts b/packages/plugin/src/v2/tui/context.ts index 465f62a0180..c95636b44a8 100644 --- a/packages/plugin/src/v2/tui/context.ts +++ b/packages/plugin/src/v2/tui/context.ts @@ -134,11 +134,13 @@ export interface KeymapCommand { readonly slash?: { readonly name: string readonly aliases?: string[] + /** Keeps the slash command in the prompt and passes its raw input to run. */ + readonly arguments?: true } /** Promotes the command in discovery UI. */ readonly suggested?: boolean | (() => boolean) /** Executes the command. Return false to let keymap dispatch continue. */ - readonly run: () => void | false | Promise + readonly run: (input?: string) => void | false | Promise } export interface KeymapLayer { @@ -160,7 +162,7 @@ export interface Keymap { /** Creates a reactive keymap layer owned by the calling component. */ layer(input: () => KeymapLayer): void /** Dispatches a reachable command by ID. */ - dispatch(id: string): void + dispatch(id: string, input?: string): void /** Returns the formatted shortcut for a registered command. */ shortcut(id: string): string | undefined /** Controls mutually exclusive OpenCode input modes. */ diff --git a/packages/protocol/src/groups/session.ts b/packages/protocol/src/groups/session.ts index 2e6f2ddb8c5..753514a0504 100644 --- a/packages/protocol/src/groups/session.ts +++ b/packages/protocol/src/groups/session.ts @@ -272,10 +272,7 @@ export const makeSessionGroup = (sessionLo .add( HttpApiEndpoint.post("session.move", "/api/session/:sessionID/move", { params: { sessionID: Session.ID }, - payload: Schema.Struct({ - destination: Schema.Struct({ directory: AbsolutePath }), - moveChanges: Schema.Boolean.pipe(Schema.optional), - }), + payload: Location.Ref, success: HttpApiSchema.NoContent, error: [SessionNotFoundError, InvalidRequestError], }) diff --git a/packages/schema/src/session-event.ts b/packages/schema/src/session-event.ts index 61341168ce9..92a78273d19 100644 --- a/packages/schema/src/session-event.ts +++ b/packages/schema/src/session-event.ts @@ -21,6 +21,7 @@ import { Money } from "./money.js" import { Snapshot } from "./snapshot.js" import { TokenUsage } from "./token-usage.js" import { SessionPending } from "./session-pending.js" +import { Project } from "./project.js" export { FileAttachment } @@ -69,6 +70,7 @@ export const Moved = Event.durable({ schema: { ...Base, location: Location.Ref, + projectID: Project.ID.pipe(optional), subpath: RelativePath.pipe(optional), }, }) diff --git a/packages/sdk/js/src/v2/gen/sdk.gen.ts b/packages/sdk/js/src/v2/gen/sdk.gen.ts index 9d1c3a96e23..cab630b4566 100644 --- a/packages/sdk/js/src/v2/gen/sdk.gen.ts +++ b/packages/sdk/js/src/v2/gen/sdk.gen.ts @@ -179,6 +179,7 @@ import type { QuestionReplyErrors, QuestionReplyResponses, QuestionV2Reply, + ServiceStopRequestV2, SessionAbortErrors, SessionAbortResponses, SessionChildrenErrors, @@ -291,6 +292,8 @@ import type { V2GenerateTextResponses, V2HealthGetErrors, V2HealthGetResponses, + V2HealthStopErrors, + V2HealthStopResponses, V2IntegrationAttemptCancelErrors, V2IntegrationAttemptCancelResponses, V2IntegrationAttemptCompleteErrors, @@ -5072,7 +5075,7 @@ export class Health extends HeyApiClient { /** * Check server health * - * Check whether the API server is ready to accept requests. + * Report the owning server process and its application status. */ public get(options?: Options) { return (options?.client ?? this.client).get({ @@ -5080,6 +5083,30 @@ export class Health extends HeyApiClient { ...options, }) } + + /** + * Stop the managed server + * + * Request graceful shutdown of one exact managed server instance. + */ + public stop( + parameters: { + serviceStopRequestV2: ServiceStopRequestV2 + }, + options?: Options, + ) { + const params = buildClientParams([parameters], [{ args: [{ key: "serviceStopRequestV2", map: "body" }] }]) + return (options?.client ?? this.client).post({ + url: "/api/service/stop", + ...options, + ...params, + headers: { + "Content-Type": "application/json", + ...options?.headers, + ...params.headers, + }, + }) + } } export class Server extends HeyApiClient { @@ -6117,10 +6144,7 @@ export class Session3 extends HeyApiClient { public move( parameters: { sessionID: string - destination?: { - directory: string - } - moveChanges?: boolean | null + locationRefV2: LocationRefV2 }, options?: Options, ) { @@ -6130,8 +6154,7 @@ export class Session3 extends HeyApiClient { { args: [ { in: "path", key: "sessionID" }, - { in: "body", key: "destination" }, - { in: "body", key: "moveChanges" }, + { key: "locationRefV2", map: "body" }, ], }, ], diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index cd391aab58c..3e6645e24c0 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -598,24 +598,6 @@ export type Part = | RetryPart | CompactionPart -export type Shell = { - id: string - status: "running" | "exited" | "timeout" | "killed" - command: string - cwd: string - shell: string - file: string - pid?: number - exit?: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" - metadata: { - [key: string]: unknown - } - time: { - started: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" - completed?: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" - } -} - export type Pty = { id: string title: string @@ -803,6 +785,7 @@ export type GlobalEvent = { properties: { sessionID: string location: LocationRef + projectID?: string subpath?: string } } @@ -917,7 +900,7 @@ export type GlobalEvent = { type: "session.shell.started" properties: { sessionID: string - shell: Shell + shell: ShellInfo } } | { @@ -925,7 +908,7 @@ export type GlobalEvent = { type: "session.shell.ended" properties: { sessionID: string - shell: Shell + shell: ShellInfo output: { output: string cursor: number @@ -1358,7 +1341,7 @@ export type GlobalEvent = { id: string type: "shell.created" properties: { - info: Shell + info: ShellInfo } } | { @@ -1366,7 +1349,7 @@ export type GlobalEvent = { type: "shell.exited" properties: { id: string - exit?: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" + exit?: number status: "running" | "exited" | "timeout" | "killed" } } @@ -2812,11 +2795,45 @@ export type WorkspaceWarpError = { } } +export type ServiceStatus = + | { + type: "starting" + } + | { + type: "ready" + } + | { + type: "stopping" + targetVersion?: string + } + | { + type: "failed" + message: string + action: string + } + +export type ServiceHealth = { + healthy: true + version: string + pid: number + instanceID?: string + status?: ServiceStatus +} + export type UnauthorizedError = { _tag: "UnauthorizedError" message: string } +export type ServiceStopRequest = { + instanceID: string + targetVersion?: string +} + +export type ServiceStopResponse = { + accepted: boolean +} + export type SessionsResponse = { data: Array cursor: { @@ -2890,24 +2907,6 @@ export type InstructionEntryValueTooLargeError = { message: string } -export type Shell1 = { - id: string - status: "running" | "exited" | "timeout" | "killed" - command: string - cwd: string - shell: string - file: string - pid?: number - exit?: number | "NaN" | "Infinity" | "-Infinity" - metadata: { - [key: string]: unknown - } - time: { - started: number | "NaN" | "Infinity" | "-Infinity" - completed?: number | "NaN" | "Infinity" | "-Infinity" - } -} - export type SessionLogItem = SessionEventDurable | EventLogSynced export type SessionLogItemStream = string @@ -3154,24 +3153,6 @@ export type EffectHttpApiErrorForbidden = { _tag: "Forbidden" } -export type Shell2 = { - id: string - status: "running" | "exited" | "timeout" | "killed" - command: string - cwd: string - shell: string - file: string - pid?: number - exit?: number | "NaN" | "Infinity" | "-Infinity" - metadata: { - [key: string]: unknown - } - time: { - started: number | "NaN" | "Infinity" | "-Infinity" - completed?: number | "NaN" | "Infinity" | "-Infinity" - } -} - export type EventTuiPromptAppend2 = { id: string type: "tui.prompt.append" @@ -3398,6 +3379,24 @@ export type SessionStructuredError = { message: string } +export type ShellInfo = { + id: string + status: "running" | "exited" | "timeout" | "killed" + command: string + cwd: string + shell: string + file: string + pid?: number + exit?: number + metadata: { + [key: string]: unknown + } + time: { + started: number + completed?: number + } +} + export type SessionMessageProviderState = { [key: string]: unknown } @@ -3773,6 +3772,7 @@ export type SyncEventSessionMoved = { data: { sessionID: string location: LocationRef + projectID?: string subpath?: string } } @@ -3962,7 +3962,7 @@ export type SyncEventSessionShellStarted = { aggregateID: string data: { sessionID: string - shell: Shell + shell: ShellInfo } } } @@ -3977,7 +3977,7 @@ export type SyncEventSessionShellEnded = { aggregateID: string data: { sessionID: string - shell: Shell + shell: ShellInfo output: { output: string cursor: number @@ -4828,6 +4828,7 @@ export type SessionMoved = { data: { sessionID: string location: LocationRef + projectID?: string subpath?: string } } @@ -5083,7 +5084,7 @@ export type SessionShellStarted = { location?: LocationRef data: { sessionID: string - shell: Shell1 + shell: ShellInfo } } @@ -5102,7 +5103,7 @@ export type SessionShellEnded = { location?: LocationRef data: { sessionID: string - shell: Shell1 + shell: ShellInfo output: { output: string cursor: number @@ -6461,7 +6462,7 @@ export type ShellCreated = { type: "shell.created" location?: LocationRef data: { - info: Shell1 + info: ShellInfo } } @@ -6475,7 +6476,7 @@ export type ShellExited = { location?: LocationRef data: { id: string - exit?: number | "NaN" | "Infinity" | "-Infinity" + exit?: number status: "running" | "exited" | "timeout" | "killed" } } @@ -7158,6 +7159,7 @@ export type EventSessionMoved = { properties: { sessionID: string location: LocationRef + projectID?: string subpath?: string } } @@ -7285,7 +7287,7 @@ export type EventSessionShellStarted = { type: "session.shell.started" properties: { sessionID: string - shell: Shell2 + shell: ShellInfo } } @@ -7294,7 +7296,7 @@ export type EventSessionShellEnded = { type: "session.shell.ended" properties: { sessionID: string - shell: Shell2 + shell: ShellInfo output: { output: string cursor: number @@ -7772,7 +7774,7 @@ export type EventShellCreated = { id: string type: "shell.created" properties: { - info: Shell2 + info: ShellInfo } } @@ -7781,7 +7783,7 @@ export type EventShellExited = { type: "shell.exited" properties: { id: string - exit?: number | "NaN" | "Infinity" | "-Infinity" + exit?: number status: "running" | "exited" | "timeout" | "killed" } } @@ -8139,6 +8141,31 @@ export type BadRequestError = { } } +export type ServiceStatusV2 = + | { + type: "starting" + } + | { + type: "ready" + } + | { + type: "stopping" + targetVersion?: string | null + } + | { + type: "failed" + message: string + action: string + } + +export type ServiceHealthV2 = { + healthy: true + version: string + pid: number + instanceID?: string | null + status?: ServiceStatusV2 +} + export type InvalidRequestErrorV2 = { _tag: "InvalidRequestError" message: string @@ -8146,6 +8173,11 @@ export type InvalidRequestErrorV2 = { field?: string | null } +export type ServiceStopRequestV2 = { + instanceID: string + targetVersion?: string | null +} + export type SessionsResponseV2 = { data: Array cursor: { @@ -8179,24 +8211,6 @@ export type UnknownErrorV2 = { ref?: string | null } -export type ShellV2 = { - id: string - status: "running" | "exited" | "timeout" | "killed" - command: string - cwd: string - shell: string - file: string - pid?: number - exit?: number | "NaN" | "Infinity" | "-Infinity" - metadata: { - [key: string]: unknown - } - time: { - started: number | "NaN" | "Infinity" | "-Infinity" - completed?: number | "NaN" | "Infinity" | "-Infinity" - } -} - export type SessionMessagesResponseV2 = { data: Array cursor: { @@ -9064,6 +9078,7 @@ export type SessionMovedV2 = { data: { sessionID: string location: LocationRefV2 + projectID?: string subpath?: string } } @@ -9333,6 +9348,24 @@ export type SessionSkillActivatedV2 = { } } +export type ShellInfoV2 = { + id: string + status: "running" | "exited" | "timeout" | "killed" + command: string + cwd: string + shell: string + file: string + pid?: number + exit?: number + metadata: { + [key: string]: unknown + } + time: { + started: number + completed?: number + } +} + export type SessionShellStartedV2 = { id: string created: number @@ -9348,7 +9381,7 @@ export type SessionShellStartedV2 = { location?: LocationRefV2 data: { sessionID: string - shell: ShellV2 + shell: ShellInfoV2 } } @@ -9367,7 +9400,7 @@ export type SessionShellEndedV2 = { location?: LocationRefV2 data: { sessionID: string - shell: ShellV2 + shell: ShellInfoV2 output: { output: string cursor: number @@ -10514,7 +10547,7 @@ export type ShellCreatedV2 = { type: "shell.created" location?: LocationRefV2 data: { - info: ShellV2 + info: ShellInfoV2 } } @@ -10528,7 +10561,7 @@ export type ShellExitedV2 = { location?: LocationRefV2 data: { id: string - exit?: number | "NaN" | "Infinity" | "-Infinity" + exit?: number status: "running" | "exited" | "timeout" | "killed" } } @@ -10986,6 +11019,24 @@ export type PtyTicketConnectTokenV2 = { expires_in: number } +export type ShellInfo1 = { + id: string + status: "running" | "exited" | "timeout" | "killed" + command: string + cwd: string + shell: string + file: string + pid?: number + exit?: number + metadata: { + [key: string]: unknown + } + time: { + started: number + completed?: number + } +} + export type QuestionV2RequestV2 = { id: string sessionID: string @@ -15124,17 +15175,42 @@ export type V2HealthGetError = V2HealthGetErrors[keyof V2HealthGetErrors] export type V2HealthGetResponses = { /** - * Success + * ServiceHealth */ - 200: { - healthy: true - version: string - pid: number - } + 200: ServiceHealthV2 } export type V2HealthGetResponse = V2HealthGetResponses[keyof V2HealthGetResponses] +export type V2HealthStopData = { + body: ServiceStopRequestV2 + path?: never + query?: never + url: "/api/service/stop" +} + +export type V2HealthStopErrors = { + /** + * InvalidRequestError + */ + 400: InvalidRequestErrorV2 + /** + * UnauthorizedError + */ + 401: UnauthorizedError +} + +export type V2HealthStopError = V2HealthStopErrors[keyof V2HealthStopErrors] + +export type V2HealthStopResponses = { + /** + * ServiceStopResponse + */ + 200: ServiceStopResponse +} + +export type V2HealthStopResponse = V2HealthStopResponses[keyof V2HealthStopResponses] + export type V2ServerGetData = { body?: never path?: never @@ -15611,12 +15687,7 @@ export type V2SessionRenameResponses = { export type V2SessionRenameResponse = V2SessionRenameResponses[keyof V2SessionRenameResponses] export type V2SessionMoveData = { - body: { - destination: { - directory: string - } - moveChanges?: boolean | null - } + body: LocationRefV2 path: { sessionID: string } @@ -18318,7 +18389,7 @@ export type V2ShellListResponses = { */ 200: { location: LocationInfoV2 - data: Array + data: Array } } @@ -18362,7 +18433,7 @@ export type V2ShellCreateResponses = { */ 200: { location: LocationInfoV2 - data: ShellV2 + data: ShellInfo1 } } @@ -18445,7 +18516,7 @@ export type V2ShellGetResponses = { */ 200: { location: LocationInfoV2 - data: ShellV2 + data: ShellInfo1 } } @@ -18490,7 +18561,7 @@ export type V2ShellTimeoutResponses = { */ 200: { location: LocationInfoV2 - data: ShellV2 + data: ShellInfo1 } } diff --git a/packages/server/src/handlers/session.ts b/packages/server/src/handlers/session.ts index b2c2e7eb104..fa0b0e67fff 100644 --- a/packages/server/src/handlers/session.ts +++ b/packages/server/src/handlers/session.ts @@ -1,6 +1,5 @@ import { SessionV2 } from "@opencode-ai/core/session" import { InstructionEntry } from "@opencode-ai/core/session/instruction-entry" -import { MoveSession } from "@opencode-ai/core/control-plane/move-session" import { DateTime, Effect, Stream } from "effect" import { HttpApiBuilder, HttpApiSchema } from "effect/unstable/httpapi" import { Api } from "../api" @@ -25,7 +24,6 @@ const DefaultSessionsLimit = 50 export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handlers) => Effect.gen(function* () { const session = yield* SessionV2.Service - const moveSession = yield* MoveSession.Service return handlers .handle( @@ -206,11 +204,11 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl .handle( "session.move", Effect.fn(function* (ctx) { - yield* moveSession - .moveSession({ + yield* session + .move({ sessionID: ctx.params.sessionID, - destination: ctx.payload.destination, - moveChanges: ctx.payload.moveChanges, + directory: ctx.payload.directory, + workspaceID: ctx.payload.workspaceID, }) .pipe( Effect.catchTag("Session.NotFoundError", (error) => @@ -221,22 +219,11 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl }), ), ), - Effect.catchTag("MoveSession.DestinationProjectMismatchError", () => - Effect.fail(new InvalidRequestError({ message: "Destination directory belongs to another project" })), + Effect.catchTag("Session.DestinationNotFoundError", (error) => + Effect.fail(new InvalidRequestError({ message: `Directory does not exist: ${error.directory}` })), ), - Effect.catchTag("MoveSession.ApplyChangesError", () => - Effect.fail( - new InvalidRequestError({ - message: - "Unable to apply your changes in the destination directory. The files may conflict with existing changes.", - }), - ), - ), - Effect.catchTag("MoveSession.CaptureChangesError", (error) => - Effect.fail(new InvalidRequestError({ message: error.message })), - ), - Effect.catchTag("MoveSession.ResetSourceChangesError", (error) => - Effect.fail(new InvalidRequestError({ message: error.message })), + Effect.catchTag("Session.DestinationNotDirectoryError", (error) => + Effect.fail(new InvalidRequestError({ message: `Not a directory: ${error.directory}` })), ), ) return HttpApiSchema.NoContent.make() diff --git a/packages/server/src/routes.ts b/packages/server/src/routes.ts index f9c44d73569..05e76f0f975 100644 --- a/packages/server/src/routes.ts +++ b/packages/server/src/routes.ts @@ -8,7 +8,6 @@ import { Observability } from "@opencode-ai/core/observability" import { Credential } from "@opencode-ai/core/credential" import { PermissionSaved } from "@opencode-ai/core/permission/saved" import { PtyTicket } from "@opencode-ai/core/pty/ticket" -import { MoveSession } from "@opencode-ai/core/control-plane/move-session" import { Project } from "@opencode-ai/core/project" import { SessionV2 } from "@opencode-ai/core/session" import { Job } from "@opencode-ai/core/job" @@ -38,7 +37,6 @@ const applicationServices = LayerNode.group([ httpClient, ToolOutputStore.cleanupNode, Job.node, - MoveSession.node, Project.node, SessionV2.node, PluginRuntime.providerNode, diff --git a/packages/tui/src/component/prompt/autocomplete.tsx b/packages/tui/src/component/prompt/autocomplete.tsx index e33f87b45f9..545f8099d61 100644 --- a/packages/tui/src/component/prompt/autocomplete.tsx +++ b/packages/tui/src/component/prompt/autocomplete.tsx @@ -427,14 +427,23 @@ export function Autocomplete(props: { ), ) + function insertSlash(name: string) { + const newText = `/${name} ` + const cursor = props.input().logicalCursor + props.input().deleteRange(0, 0, cursor.row, cursor.col) + props.input().insertText(newText) + props.input().cursorOffset = Bun.stringWidth(newText) + } + const commands = createMemo((): AutocompleteOption[] => { const results: AutocompleteOption[] = keymapCommands().flatMap((command) => { - if (!command.slash) return [] + const slash = command.slash + if (!slash) return [] return { - display: `/${command.slash.name}`, + display: `/${slash.name}`, description: command.description ?? command.title, - aliases: command.slash.aliases?.map((alias) => `/${alias}`), - onSelect: command.run, + aliases: slash.aliases?.map((alias) => `/${alias}`), + onSelect: slash.arguments ? () => insertSlash(slash.name) : command.run, } }) const commandNames = new Set() @@ -444,13 +453,7 @@ export function Autocomplete(props: { results.push({ display: "/" + serverCommand.name, description: serverCommand.description, - onSelect: () => { - const newText = "/" + serverCommand.name + " " - const cursor = props.input().logicalCursor - props.input().deleteRange(0, 0, cursor.row, cursor.col) - props.input().insertText(newText) - props.input().cursorOffset = Bun.stringWidth(newText) - }, + onSelect: () => insertSlash(serverCommand.name), }) } @@ -460,13 +463,7 @@ export function Autocomplete(props: { results.push({ display: "/" + skill.id, description: skill.description, - onSelect: () => { - const newText = "/" + skill.id + " " - const cursor = props.input().logicalCursor - props.input().deleteRange(0, 0, cursor.row, cursor.col) - props.input().insertText(newText) - props.input().cursorOffset = Bun.stringWidth(newText) - }, + onSelect: () => insertSlash(skill.id), }) } diff --git a/packages/tui/src/component/prompt/index.tsx b/packages/tui/src/component/prompt/index.tsx index b1576b0bf53..fb0a6e2fca8 100644 --- a/packages/tui/src/component/prompt/index.tsx +++ b/packages/tui/src/component/prompt/index.tsx @@ -52,6 +52,7 @@ import { usePromptMove } from "./move" import { readLocalAttachment } from "./local-attachment" import { useData } from "../../context/data" import { useLocation } from "../../context/location" +import { Keymap, type KeymapCommand } from "../../context/keymap" import { contextUsage } from "../../util/session" import { abbreviateHome } from "../../runtime" @@ -137,6 +138,18 @@ function formatEditorContext(selection: EditorSelection) { let stashed: { prompt: PromptInfo; cursor: number } | undefined +function argumentSlash(input: string, commands: readonly KeymapCommand[]) { + if (!input.startsWith("/")) return + const separator = input.search(/\s/) + const name = input.slice(1, separator === -1 ? undefined : separator) + const command = commands.find( + (command) => + command.slash?.arguments && (command.slash.name === name || command.slash.aliases?.includes(name) === true), + ) + if (!command) return + return { command, input: separator === -1 ? "" : input.slice(separator + 1) } +} + export function Prompt(props: PromptProps) { let input: TextareaRenderable let anchor: BoxRenderable @@ -152,6 +165,7 @@ export function Prompt(props: PromptProps) { const editor = useEditorContext() const route = useRoute() const data = useData() + const keymapCommands = Keymap.useCommands() const currentLocation = useLocation() const config = useConfig().data const dialog = useDialog() @@ -218,6 +232,30 @@ export function Prompt(props: PromptProps) { (props.sessionID ? data.session.get(props.sessionID)?.projectID : undefined) ?? data.location.info()?.project.id, sessionID: () => props.sessionID, }) + Keymap.createLayer(() => ({ + mode: "global", + enabled: props.sessionID !== undefined, + commands: [ + { + id: "session.cd", + title: "Change working directory", + slash: { name: "cd", arguments: true }, + run: async (input) => { + const sessionID = props.sessionID + if (!sessionID) return + if (!input?.trim()) { + toast.show({ message: "Directory is required", variant: "error" }) + return + } + await client.api.session + .move({ sessionID, directory: input }) + .catch((error) => + toast.show({ title: "Failed to change directory", message: errorMessage(error), variant: "error" }), + ) + }, + }, + ], + })) const [cursorVersion, setCursorVersion] = createSignal(0) const currentProviderLabel = createMemo(() => local.model.parsed().provider) const connected = useConnected() @@ -948,6 +986,12 @@ export function Prompt(props: PromptProps) { void exit() return true } + const slash = argumentSlash(store.prompt.text, keymapCommands()) + if (slash) { + clearPrompt() + await slash.command.run(slash.input) + return true + } const agent = local.agent.current() if (!agent) return false const selectedModel = local.model.current() diff --git a/packages/tui/src/component/prompt/move.tsx b/packages/tui/src/component/prompt/move.tsx index ec9c920bca7..7b99d80167d 100644 --- a/packages/tui/src/component/prompt/move.tsx +++ b/packages/tui/src/component/prompt/move.tsx @@ -6,7 +6,6 @@ import { useDialog } from "../../ui/dialog" import { useClient } from "../../context/client" import { useToast } from "../../ui/toast" import { DialogMoveSession, type MoveSessionSelection } from "../dialog-move-session" -import { DialogWorkspaceFileChanges } from "../dialog-workspace-file-changes" import { useData } from "../../context/data" function moveReminderText(directory: string) { @@ -94,12 +93,6 @@ export function usePromptMove(input: { projectID: () => string | undefined; sess } async function moveExistingSession(sessionID: string, selection: MoveSessionSelection) { - const session = await resolveSession(sessionID) - const status = await client.api.vcs - .status({ location: session?.location.directory ? { directory: session.location.directory } : undefined }) - .catch(() => undefined) - const choice = status?.data?.length ? await DialogWorkspaceFileChanges.show(dialog, status.data) : "no" - if (!choice) return dialog.clear() const directory = selection.type === "new" ? await create(selection.name) : selection.directory if (!directory) { @@ -109,7 +102,7 @@ export function usePromptMove(input: { projectID: () => string | undefined; sess } setProgress("Moving session") try { - await client.api.session.move({ sessionID, destination: { directory }, moveChanges: choice === "yes" }) + await client.api.session.move({ sessionID, directory }) await client.api.session .synthetic({ sessionID, text: moveReminderText(directory), resume: false }) .catch(() => undefined) diff --git a/packages/tui/src/context/data.tsx b/packages/tui/src/context/data.tsx index 393ac73fb59..14990fb9337 100644 --- a/packages/tui/src/context/data.tsx +++ b/packages/tui/src/context/data.tsx @@ -368,6 +368,8 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ case "session.moved": if (store.session.info[event.data.sessionID]) { setStore("session", "info", event.data.sessionID, "location", event.data.location) + if (event.data.projectID) + setStore("session", "info", event.data.sessionID, "projectID", event.data.projectID) setStore("session", "info", event.data.sessionID, "subpath", event.data.subpath) } break diff --git a/packages/tui/src/context/keymap.tsx b/packages/tui/src/context/keymap.tsx index dce85fee13c..5e902c664e8 100644 --- a/packages/tui/src/context/keymap.tsx +++ b/packages/tui/src/context/keymap.tsx @@ -23,6 +23,7 @@ declare module "@opentui/keymap" { slash?: { name: string aliases?: string[] + arguments?: true } } } @@ -32,13 +33,28 @@ const MODE = { key: "opencode.mode", base: "base" } as const type OpenTuiKeymap = Parameters[0]["keymap"] type Mode = ReturnType -const Context = createContext<{ readonly keymap: OpenTuiKeymap; readonly mode: Mode }>() +const Context = createContext<{ + readonly keymap: OpenTuiKeymap + readonly mode: Mode + readonly dispatch: (id: string, input?: string) => void + readonly input: (id: string) => string | undefined +}>() function Provider(props: ParentProps) { const renderer = useRenderer() const config = useConfig() const keymap = createDefaultOpenTuiKeymap(renderer) const mode = createMode(keymap) + let invocation: { readonly id: string; readonly input?: string } | undefined + const dispatch = (id: string, input?: string) => { + const previous = invocation + invocation = { id, input } + try { + keymap.dispatchCommand(id) + } finally { + invocation = previous + } + } const dispose = [ registerCommaBindings(keymap), keymap.appendBindingExpander((context) => { @@ -114,7 +130,11 @@ function Provider(props: ParentProps) { }) return ( - {props.children} + (invocation?.id === id ? invocation.input : undefined) }} + > + {props.children} + ) } @@ -123,7 +143,7 @@ export type { KeymapCommand, KeymapLayer } from "@opencode-ai/plugin/v2/tui/cont export interface Keymap { /** Dispatches a reachable command by ID. */ - dispatch(id: string): void + dispatch(id: string, input?: string): void /** Controls mutually exclusive OpenCode input modes. */ readonly mode: { /** Returns the active mode. */ @@ -136,15 +156,15 @@ export interface Keymap { function use(): Keymap { const value = useValue() return { - dispatch(id) { - value.keymap.dispatchCommand(id) + dispatch(id, input) { + value.dispatch(id, input) }, mode: value.mode, } } function createLayer(input: () => KeymapLayer) { - useValue() + const value = useValue() const config = useConfig() useBindings(() => { const layer = input() @@ -174,11 +194,12 @@ function createLayer(input: () => KeymapLayer) { ...options, ...(mode === "global" ? {} : { mode: mode ?? MODE.base }), commands: grouped.named.map((command) => { - const { id, description, group, palette, bind, ...definition } = command + const { id, description, group, palette, bind, run, ...definition } = command return { ...definition, name: id, opencode: command, + run: () => run(value.input(id)), ...(description === undefined ? {} : { desc: description }), ...(group === undefined ? {} : { category: group }), ...(palette === undefined ? {} : { namespace: "palette" }), @@ -262,8 +283,8 @@ function useCommands(): Accessor { } return { ...command, - run: () => { - value.keymap.dispatchCommand(entry.command.name) + run: (input?: string) => { + value.dispatch(entry.command.name, input) }, } }), diff --git a/packages/tui/src/keymap.tsx b/packages/tui/src/keymap.tsx index 0167f9f62a6..ebf78ffc240 100644 --- a/packages/tui/src/keymap.tsx +++ b/packages/tui/src/keymap.tsx @@ -24,6 +24,7 @@ declare module "@opentui/keymap" { slash?: { name: string aliases?: string[] + arguments?: true } } } diff --git a/packages/tui/test/cli/tui/data.test.tsx b/packages/tui/test/cli/tui/data.test.tsx index d6e902afbf5..c35138676a9 100644 --- a/packages/tui/test/cli/tui/data.test.tsx +++ b/packages/tui/test/cli/tui/data.test.tsx @@ -495,10 +495,12 @@ test("updates session location when moved", async () => { data: { sessionID: "ses_test", location: { directory: destination }, + projectID: "project-moved", subpath: "packages/cli", }, }) await wait(() => data.session.get("ses_test")?.location.directory === destination) + expect(data.session.get("ses_test")?.projectID).toBe("project-moved") expect(data.session.get("ses_test")?.subpath).toBe("packages/cli") } finally { app.renderer.destroy() diff --git a/packages/tui/test/cli/tui/keymap-arguments.test.tsx b/packages/tui/test/cli/tui/keymap-arguments.test.tsx new file mode 100644 index 00000000000..a42da75fe8d --- /dev/null +++ b/packages/tui/test/cli/tui/keymap-arguments.test.tsx @@ -0,0 +1,44 @@ +/** @jsxImportSource @opentui/solid */ +import { testRender } from "@opentui/solid" +import { expect, test } from "bun:test" +import { ConfigProvider } from "../../../src/config" +import { Keymap } from "../../../src/context/keymap" +import { createTuiResolvedConfig } from "../../fixture/tui-runtime" + +test("dispatch passes slash input through the registered command", async () => { + const received: Array = [] + let dispatch: ReturnType["dispatch"] + + function Commands() { + const keymap = Keymap.use() + dispatch = keymap.dispatch + Keymap.createLayer(() => ({ + mode: "global", + commands: [ + { + id: "project.cd", + slash: { name: "cd", arguments: true }, + run: (input) => { + received.push(input) + }, + }, + ], + })) + return + } + + const app = await testRender(() => ( + + + + + + )) + try { + dispatch!("project.cd") + dispatch!("project.cd", "src/components with spaces") + expect(received).toEqual([undefined, "src/components with spaces"]) + } finally { + app.renderer.destroy() + } +})