feat(session): support directory moves from slash commands

This commit is contained in:
Dax Raad 2026-07-15 00:33:59 -04:00
parent e8964ce672
commit c1d2d7aba3
23 changed files with 521 additions and 206 deletions

View file

@ -130,8 +130,8 @@ export type SessionRenameOperation<E = never> = (input: Endpoint5_8Input) => Eff
type Endpoint5_9Request = Parameters<RawClient["server.session"]["session.move"]>[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<ReturnType<RawClient["server.session"]["session.move"]>>
export type SessionMoveOperation<E = never> = (input: Endpoint5_9Input) => Effect.Effect<Endpoint5_9Output, E>

View file

@ -159,13 +159,13 @@ const Endpoint5_8 = (raw: RawClient["server.session"]) => (input: Endpoint5_8Inp
type Endpoint5_9Request = Parameters<RawClient["server.session"]["session.move"]>[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<RawClient["server.session"]["session.prompt"]>[0]

View file

@ -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,

View file

@ -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

View file

@ -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<Des
},
) {}
export class DestinationNotFoundError extends Schema.TaggedErrorClass<DestinationNotFoundError>()(
"MoveSession.DestinationNotFoundError",
{ directory: AbsolutePath },
) {}
export class DestinationNotDirectoryError extends Schema.TaggedErrorClass<DestinationNotDirectoryError>()(
"MoveSession.DestinationNotDirectoryError",
{ directory: AbsolutePath },
) {}
export class ApplyChangesError extends Schema.TaggedErrorClass<ApplyChangesError>()("MoveSession.ApplyChangesError", {
message: Schema.String,
}) {}
@ -57,6 +66,10 @@ export class ResetSourceChangesError extends Schema.TaggedErrorClass<ResetSource
export type Error =
| SessionV2.NotFoundError
| DestinationProjectMismatchError
| DestinationNotFoundError
| DestinationNotDirectoryError
| SessionV2.DestinationNotFoundError
| SessionV2.DestinationNotDirectoryError
| CaptureChangesError
| ApplyChangesError
| ResetSourceChangesError
@ -71,23 +84,29 @@ const layer = Layer.effect(
Service,
Effect.gen(function* () {
const git = yield* Git.Service
const events = yield* EventV2.Service
const fs = yield* FSUtil.Service
const global = yield* Global.Service
const project = yield* ProjectV2.Service
const sessions = yield* SessionStore.Service
const session = yield* SessionV2.Service
const execution = yield* SessionExecution.Service
const moveSession = Effect.fn("MoveSession.moveSession")(function* (input: Input) {
const current = yield* sessions.get(input.sessionID)
if (!current) return yield* new SessionV2.NotFoundError({ sessionID: input.sessionID })
const directory = AbsolutePath.make(input.destination.directory)
const value = input.destination.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 destinationInfo = yield* fs.stat(directory).pipe(Effect.catch(() => 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,
],
})

View file

@ -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<BusyError>()("Session.Bus
export class SkillNotFoundError extends Schema.TaggedErrorClass<SkillNotFoundError>()("Session.SkillNotFoundError", {
skill: SkillV2.ID,
}) {}
export class DestinationNotFoundError extends Schema.TaggedErrorClass<DestinationNotFoundError>()(
"Session.DestinationNotFoundError",
{ directory: AbsolutePath },
) {}
export class DestinationNotDirectoryError extends Schema.TaggedErrorClass<DestinationNotDirectoryError>()(
"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<void, NotFoundError>
readonly rename: (input: { sessionID: SessionSchema.ID; title: string }) => Effect.Effect<void, NotFoundError>
readonly move: (input: {
sessionID: SessionSchema.ID
directory: AbsolutePath
workspaceID?: Location.Ref["workspaceID"]
}) => Effect.Effect<void, NotFoundError | DestinationNotFoundError | DestinationNotDirectoryError>
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,
],
})

View file

@ -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),
})

View file

@ -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(

View file

@ -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<void>
readonly run: (input?: string) => void | false | Promise<void>
}
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. */

View file

@ -272,10 +272,7 @@ export const makeSessionGroup = <I extends HttpApiMiddleware.AnyId, S>(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],
})

View file

@ -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),
},
})

View file

@ -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<ThrowOnError extends boolean = false>(options?: Options<never, ThrowOnError>) {
return (options?.client ?? this.client).get<V2HealthGetResponses, V2HealthGetErrors, ThrowOnError>({
@ -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<ThrowOnError extends boolean = false>(
parameters: {
serviceStopRequestV2: ServiceStopRequestV2
},
options?: Options<never, ThrowOnError>,
) {
const params = buildClientParams([parameters], [{ args: [{ key: "serviceStopRequestV2", map: "body" }] }])
return (options?.client ?? this.client).post<V2HealthStopResponses, V2HealthStopErrors, ThrowOnError>({
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<ThrowOnError extends boolean = false>(
parameters: {
sessionID: string
destination?: {
directory: string
}
moveChanges?: boolean | null
locationRefV2: LocationRefV2
},
options?: Options<never, ThrowOnError>,
) {
@ -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" },
],
},
],

View file

@ -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<SessionInfo>
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<SessionInfoV2>
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<SessionMessageInfo>
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<ShellV2>
data: Array<ShellInfo1>
}
}
@ -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
}
}

View file

@ -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()

View file

@ -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,

View file

@ -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<string>()
@ -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),
})
}

View file

@ -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()

View file

@ -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)

View file

@ -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

View file

@ -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<typeof KeymapProvider>[0]["keymap"]
type Mode = ReturnType<typeof createMode>
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 (
<KeymapProvider keymap={keymap}>
<Context.Provider value={{ keymap, mode }}>{props.children}</Context.Provider>
<Context.Provider
value={{ keymap, mode, dispatch, input: (id) => (invocation?.id === id ? invocation.input : undefined) }}
>
{props.children}
</Context.Provider>
</KeymapProvider>
)
}
@ -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<readonly KeymapCommand[]> {
}
return {
...command,
run: () => {
value.keymap.dispatchCommand(entry.command.name)
run: (input?: string) => {
value.dispatch(entry.command.name, input)
},
}
}),

View file

@ -24,6 +24,7 @@ declare module "@opentui/keymap" {
slash?: {
name: string
aliases?: string[]
arguments?: true
}
}
}

View file

@ -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()

View file

@ -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<string | undefined> = []
let dispatch: ReturnType<typeof Keymap.use>["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 <box />
}
const app = await testRender(() => (
<ConfigProvider config={createTuiResolvedConfig()}>
<Keymap.Provider>
<Commands />
</Keymap.Provider>
</ConfigProvider>
))
try {
dispatch!("project.cd")
dispatch!("project.cd", "src/components with spaces")
expect(received).toEqual([undefined, "src/components with spaces"])
} finally {
app.renderer.destroy()
}
})