mirror of
https://github.com/anomalyco/opencode.git
synced 2026-07-29 09:03:30 +00:00
feat(session): support directory moves from slash commands
This commit is contained in:
parent
e8964ce672
commit
c1d2d7aba3
23 changed files with 521 additions and 206 deletions
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
],
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
],
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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. */
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
},
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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" },
|
||||
],
|
||||
},
|
||||
],
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
},
|
||||
}
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ declare module "@opentui/keymap" {
|
|||
slash?: {
|
||||
name: string
|
||||
aliases?: string[]
|
||||
arguments?: true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
44
packages/tui/test/cli/tui/keymap-arguments.test.tsx
Normal file
44
packages/tui/test/cli/tui/keymap-arguments.test.tsx
Normal 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()
|
||||
}
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue