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]
|
type Endpoint5_9Request = Parameters<RawClient["server.session"]["session.move"]>[0]
|
||||||
export type Endpoint5_9Input = {
|
export type Endpoint5_9Input = {
|
||||||
readonly sessionID: Endpoint5_9Request["params"]["sessionID"]
|
readonly sessionID: Endpoint5_9Request["params"]["sessionID"]
|
||||||
readonly destination: Endpoint5_9Request["payload"]["destination"]
|
readonly directory: Endpoint5_9Request["payload"]["directory"]
|
||||||
readonly moveChanges?: Endpoint5_9Request["payload"]["moveChanges"]
|
readonly workspaceID?: Endpoint5_9Request["payload"]["workspaceID"]
|
||||||
}
|
}
|
||||||
export type Endpoint5_9Output = EffectValue<ReturnType<RawClient["server.session"]["session.move"]>>
|
export type Endpoint5_9Output = EffectValue<ReturnType<RawClient["server.session"]["session.move"]>>
|
||||||
export type SessionMoveOperation<E = never> = (input: Endpoint5_9Input) => Effect.Effect<Endpoint5_9Output, E>
|
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_9Request = Parameters<RawClient["server.session"]["session.move"]>[0]
|
||||||
type Endpoint5_9Input = {
|
type Endpoint5_9Input = {
|
||||||
readonly sessionID: Endpoint5_9Request["params"]["sessionID"]
|
readonly sessionID: Endpoint5_9Request["params"]["sessionID"]
|
||||||
readonly destination: Endpoint5_9Request["payload"]["destination"]
|
readonly directory: Endpoint5_9Request["payload"]["directory"]
|
||||||
readonly moveChanges?: Endpoint5_9Request["payload"]["moveChanges"]
|
readonly workspaceID?: Endpoint5_9Request["payload"]["workspaceID"]
|
||||||
}
|
}
|
||||||
const Endpoint5_9 = (raw: RawClient["server.session"]) => (input: Endpoint5_9Input) =>
|
const Endpoint5_9 = (raw: RawClient["server.session"]) => (input: Endpoint5_9Input) =>
|
||||||
raw["session.move"]({
|
raw["session.move"]({
|
||||||
params: { sessionID: input["sessionID"] },
|
params: { sessionID: input["sessionID"] },
|
||||||
payload: { destination: input["destination"], moveChanges: input["moveChanges"] },
|
payload: { directory: input["directory"], workspaceID: input["workspaceID"] },
|
||||||
}).pipe(Effect.mapError(mapClientError))
|
}).pipe(Effect.mapError(mapClientError))
|
||||||
|
|
||||||
type Endpoint5_10Request = Parameters<RawClient["server.session"]["session.prompt"]>[0]
|
type Endpoint5_10Request = Parameters<RawClient["server.session"]["session.prompt"]>[0]
|
||||||
|
|
|
||||||
|
|
@ -522,7 +522,7 @@ export function make(options: ClientOptions) {
|
||||||
{
|
{
|
||||||
method: "POST",
|
method: "POST",
|
||||||
path: `/api/session/${encodeURIComponent(input.sessionID)}/move`,
|
path: `/api/session/${encodeURIComponent(input.sessionID)}/move`,
|
||||||
body: { destination: input["destination"], moveChanges: input["moveChanges"] },
|
body: { directory: input["directory"], workspaceID: input["workspaceID"] },
|
||||||
successStatus: 204,
|
successStatus: 204,
|
||||||
declaredStatuses: [404, 400, 401],
|
declaredStatuses: [404, 400, 401],
|
||||||
empty: true,
|
empty: true,
|
||||||
|
|
|
||||||
|
|
@ -567,7 +567,7 @@ export type SessionMoved = {
|
||||||
type: "session.moved"
|
type: "session.moved"
|
||||||
durable: { aggregateID: string; seq: number; version: 1 }
|
durable: { aggregateID: string; seq: number; version: 1 }
|
||||||
location?: LocationRef
|
location?: LocationRef
|
||||||
data: { sessionID: string; location: LocationRef; subpath?: string }
|
data: { sessionID: string; location: LocationRef; projectID?: string; subpath?: string }
|
||||||
}
|
}
|
||||||
|
|
||||||
export type SessionRenamed = {
|
export type SessionRenamed = {
|
||||||
|
|
@ -2715,14 +2715,8 @@ export type SessionRenameOutput = void
|
||||||
|
|
||||||
export type SessionMoveInput = {
|
export type SessionMoveInput = {
|
||||||
readonly sessionID: { readonly sessionID: string }["sessionID"]
|
readonly sessionID: { readonly sessionID: string }["sessionID"]
|
||||||
readonly destination: {
|
readonly directory: { readonly directory: string; readonly workspaceID?: string }["directory"]
|
||||||
readonly destination: { readonly directory: string }
|
readonly workspaceID?: { readonly directory: string; readonly workspaceID?: string }["workspaceID"]
|
||||||
readonly moveChanges?: boolean | undefined
|
|
||||||
}["destination"]
|
|
||||||
readonly moveChanges?: {
|
|
||||||
readonly destination: { readonly directory: string }
|
|
||||||
readonly moveChanges?: boolean | undefined
|
|
||||||
}["moveChanges"]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export type SessionMoveOutput = void
|
export type SessionMoveOutput = void
|
||||||
|
|
|
||||||
|
|
@ -2,16 +2,15 @@ export * as MoveSession from "./move-session"
|
||||||
|
|
||||||
import { Context, DateTime, Effect, Layer, Schema } from "effect"
|
import { Context, DateTime, Effect, Layer, Schema } from "effect"
|
||||||
import { makeGlobalNode } from "../effect/app-node"
|
import { makeGlobalNode } from "../effect/app-node"
|
||||||
import { EventV2 } from "../event"
|
import { FSUtil } from "../fs-util"
|
||||||
import { Git } from "../git"
|
import { Git } from "../git"
|
||||||
import { Location } from "../location"
|
import { Global } from "../global"
|
||||||
import { ProjectV2 } from "../project"
|
import { ProjectV2 } from "../project"
|
||||||
import { SessionV2 } from "../session"
|
import { SessionV2 } from "../session"
|
||||||
import { SessionEvent } from "../session/event"
|
|
||||||
import { SessionExecution } from "../session/execution"
|
import { SessionExecution } from "../session/execution"
|
||||||
import { SessionSchema } from "../session/schema"
|
import { SessionSchema } from "../session/schema"
|
||||||
import { SessionStore } from "../session/store"
|
import { SessionStore } from "../session/store"
|
||||||
import { AbsolutePath, RelativePath } from "../schema"
|
import { AbsolutePath } from "../schema"
|
||||||
import path from "path"
|
import path from "path"
|
||||||
|
|
||||||
export const Destination = Schema.Struct({
|
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", {
|
export class ApplyChangesError extends Schema.TaggedErrorClass<ApplyChangesError>()("MoveSession.ApplyChangesError", {
|
||||||
message: Schema.String,
|
message: Schema.String,
|
||||||
}) {}
|
}) {}
|
||||||
|
|
@ -57,6 +66,10 @@ export class ResetSourceChangesError extends Schema.TaggedErrorClass<ResetSource
|
||||||
export type Error =
|
export type Error =
|
||||||
| SessionV2.NotFoundError
|
| SessionV2.NotFoundError
|
||||||
| DestinationProjectMismatchError
|
| DestinationProjectMismatchError
|
||||||
|
| DestinationNotFoundError
|
||||||
|
| DestinationNotDirectoryError
|
||||||
|
| SessionV2.DestinationNotFoundError
|
||||||
|
| SessionV2.DestinationNotDirectoryError
|
||||||
| CaptureChangesError
|
| CaptureChangesError
|
||||||
| ApplyChangesError
|
| ApplyChangesError
|
||||||
| ResetSourceChangesError
|
| ResetSourceChangesError
|
||||||
|
|
@ -71,23 +84,29 @@ const layer = Layer.effect(
|
||||||
Service,
|
Service,
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const git = yield* Git.Service
|
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 project = yield* ProjectV2.Service
|
||||||
const sessions = yield* SessionStore.Service
|
const sessions = yield* SessionStore.Service
|
||||||
|
const session = yield* SessionV2.Service
|
||||||
const execution = yield* SessionExecution.Service
|
const execution = yield* SessionExecution.Service
|
||||||
|
|
||||||
const moveSession = Effect.fn("MoveSession.moveSession")(function* (input: Input) {
|
const moveSession = Effect.fn("MoveSession.moveSession")(function* (input: Input) {
|
||||||
const current = yield* sessions.get(input.sessionID)
|
const current = yield* sessions.get(input.sessionID)
|
||||||
if (!current) return yield* new SessionV2.NotFoundError({ sessionID: 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
|
if (current.location.directory === directory) return
|
||||||
|
|
||||||
const source = yield* project.resolve(current.location.directory)
|
const source = yield* project.resolve(current.location.directory)
|
||||||
const destination = yield* project.resolve(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 })
|
return yield* new DestinationProjectMismatchError({ expected: current.projectID, actual: destination.id })
|
||||||
}
|
}
|
||||||
|
|
||||||
// A move must not race active execution: a mid-drain relocation would let
|
// A move must not race active execution: a mid-drain relocation would let
|
||||||
// the source Location dispatch a request assembled under stale instructions
|
// the source Location dispatch a request assembled under stale instructions
|
||||||
// and history. Serialize like removal does — stop the drain, then move.
|
// 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 })))
|
.pipe(Effect.mapError((error) => new ApplyChangesError({ message: error.message })))
|
||||||
}
|
}
|
||||||
|
|
||||||
yield* events.publish(SessionEvent.Moved, {
|
yield* session.move({
|
||||||
sessionID: input.sessionID,
|
sessionID: input.sessionID,
|
||||||
location: Location.Ref.make({ directory }),
|
directory,
|
||||||
subpath: RelativePath.make(path.relative(destination.directory, directory).replaceAll("\\", "/")),
|
|
||||||
})
|
})
|
||||||
|
|
||||||
if (patch) {
|
if (patch) {
|
||||||
|
|
@ -151,5 +169,13 @@ const layer = Layer.effect(
|
||||||
export const node = makeGlobalNode({
|
export const node = makeGlobalNode({
|
||||||
service: Service,
|
service: Service,
|
||||||
layer,
|
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 { Job } from "./job"
|
||||||
import { CommandV2 } from "./command"
|
import { CommandV2 } from "./command"
|
||||||
import { Shell } from "./shell"
|
import { Shell } from "./shell"
|
||||||
|
import { Global } from "./global"
|
||||||
import { Shell as ShellSchema } from "@opencode-ai/schema/shell"
|
import { Shell as ShellSchema } from "@opencode-ai/schema/shell"
|
||||||
import { KeyedMutex } from "./effect/keyed-mutex"
|
import { KeyedMutex } from "./effect/keyed-mutex"
|
||||||
import { fileURLToPath } from "url"
|
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", {
|
export class SkillNotFoundError extends Schema.TaggedErrorClass<SkillNotFoundError>()("Session.SkillNotFoundError", {
|
||||||
skill: SkillV2.ID,
|
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 const MessageNotFoundError = SessionRevert.MessageNotFoundError
|
||||||
export type MessageNotFoundError = SessionRevert.MessageNotFoundError
|
export type MessageNotFoundError = SessionRevert.MessageNotFoundError
|
||||||
|
|
||||||
|
|
@ -159,6 +170,8 @@ export type Error =
|
||||||
| CompactionConflictError
|
| CompactionConflictError
|
||||||
| BusyError
|
| BusyError
|
||||||
| SkillNotFoundError
|
| SkillNotFoundError
|
||||||
|
| DestinationNotFoundError
|
||||||
|
| DestinationNotDirectoryError
|
||||||
| CommandV2.NotFoundError
|
| CommandV2.NotFoundError
|
||||||
| CommandV2.EvaluationError
|
| CommandV2.EvaluationError
|
||||||
| MessageNotFoundError
|
| MessageNotFoundError
|
||||||
|
|
@ -215,6 +228,11 @@ export interface Interface {
|
||||||
model: ModelV2.Ref
|
model: ModelV2.Ref
|
||||||
}) => Effect.Effect<void, NotFoundError>
|
}) => Effect.Effect<void, NotFoundError>
|
||||||
readonly rename: (input: { sessionID: SessionSchema.ID; title: string }) => 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: {
|
readonly prompt: (input: {
|
||||||
id?: SessionMessage.ID
|
id?: SessionMessage.ID
|
||||||
sessionID: SessionSchema.ID
|
sessionID: SessionSchema.ID
|
||||||
|
|
@ -288,6 +306,7 @@ const layer = Layer.effect(
|
||||||
const db = database.db
|
const db = database.db
|
||||||
const events = yield* EventV2.Service
|
const events = yield* EventV2.Service
|
||||||
const projects = yield* ProjectV2.Service
|
const projects = yield* ProjectV2.Service
|
||||||
|
const global = yield* Global.Service
|
||||||
const execution = yield* SessionExecution.Service
|
const execution = yield* SessionExecution.Service
|
||||||
const store = yield* SessionStore.Service
|
const store = yield* SessionStore.Service
|
||||||
const locations = yield* LocationServiceMap.Service
|
const locations = yield* LocationServiceMap.Service
|
||||||
|
|
@ -673,6 +692,38 @@ const layer = Layer.effect(
|
||||||
title: input.title,
|
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) {
|
compact: Effect.fn("V2Session.compact")(function* (input) {
|
||||||
yield* result.get(input.sessionID)
|
yield* result.get(input.sessionID)
|
||||||
const inputID = input.id ?? SessionMessage.ID.create()
|
const inputID = input.id ?? SessionMessage.ID.create()
|
||||||
|
|
@ -949,5 +1000,6 @@ export const node = makeGlobalNode({
|
||||||
LocationServiceMap.node,
|
LocationServiceMap.node,
|
||||||
SessionProjector.node,
|
SessionProjector.node,
|
||||||
FSUtil.node,
|
FSUtil.node,
|
||||||
|
Global.node,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -497,6 +497,7 @@ const layer = Layer.effectDiscard(
|
||||||
.set({
|
.set({
|
||||||
directory: event.data.location.directory,
|
directory: event.data.location.directory,
|
||||||
path: event.data.subpath,
|
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,
|
workspace_id: event.data.location.workspaceID ? WorkspaceV2.ID.make(event.data.location.workspaceID) : null,
|
||||||
time_updated: DateTime.toEpochMillis(event.created),
|
time_updated: DateTime.toEpochMillis(event.created),
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -43,6 +43,7 @@ const it = testEffect(
|
||||||
EventV2.node,
|
EventV2.node,
|
||||||
ProjectDirectories.node,
|
ProjectDirectories.node,
|
||||||
Project.node,
|
Project.node,
|
||||||
|
SessionV2.node,
|
||||||
SessionProjector.node,
|
SessionProjector.node,
|
||||||
SessionStore.node,
|
SessionStore.node,
|
||||||
]),
|
]),
|
||||||
|
|
@ -137,7 +138,6 @@ describe("MoveSession", () => {
|
||||||
yield* Effect.promise(() => initRepo(root.path))
|
yield* Effect.promise(() => initRepo(root.path))
|
||||||
const source = abs(yield* Effect.promise(() => fs.realpath(root.path)))
|
const source = abs(yield* Effect.promise(() => fs.realpath(root.path)))
|
||||||
const destination = abs(path.join(source, "packages"))
|
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, "tracked.txt"), "changed\n"))
|
||||||
yield* Effect.promise(() => fs.writeFile(path.join(source, "untracked.txt"), "new\n"))
|
yield* Effect.promise(() => fs.writeFile(path.join(source, "untracked.txt"), "new\n"))
|
||||||
|
|
||||||
|
|
@ -164,8 +164,14 @@ describe("MoveSession", () => {
|
||||||
.run()
|
.run()
|
||||||
.pipe(Effect.orDie)
|
.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) =>
|
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")
|
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", () =>
|
it.live("moves nested session changes without cleaning unrelated files", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const root = yield* Effect.acquireRelease(
|
const root = yield* Effect.acquireRelease(
|
||||||
|
|
|
||||||
|
|
@ -134,11 +134,13 @@ export interface KeymapCommand {
|
||||||
readonly slash?: {
|
readonly slash?: {
|
||||||
readonly name: string
|
readonly name: string
|
||||||
readonly aliases?: 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. */
|
/** Promotes the command in discovery UI. */
|
||||||
readonly suggested?: boolean | (() => boolean)
|
readonly suggested?: boolean | (() => boolean)
|
||||||
/** Executes the command. Return false to let keymap dispatch continue. */
|
/** 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 {
|
export interface KeymapLayer {
|
||||||
|
|
@ -160,7 +162,7 @@ export interface Keymap {
|
||||||
/** Creates a reactive keymap layer owned by the calling component. */
|
/** Creates a reactive keymap layer owned by the calling component. */
|
||||||
layer(input: () => KeymapLayer): void
|
layer(input: () => KeymapLayer): void
|
||||||
/** Dispatches a reachable command by ID. */
|
/** Dispatches a reachable command by ID. */
|
||||||
dispatch(id: string): void
|
dispatch(id: string, input?: string): void
|
||||||
/** Returns the formatted shortcut for a registered command. */
|
/** Returns the formatted shortcut for a registered command. */
|
||||||
shortcut(id: string): string | undefined
|
shortcut(id: string): string | undefined
|
||||||
/** Controls mutually exclusive OpenCode input modes. */
|
/** Controls mutually exclusive OpenCode input modes. */
|
||||||
|
|
|
||||||
|
|
@ -272,10 +272,7 @@ export const makeSessionGroup = <I extends HttpApiMiddleware.AnyId, S>(sessionLo
|
||||||
.add(
|
.add(
|
||||||
HttpApiEndpoint.post("session.move", "/api/session/:sessionID/move", {
|
HttpApiEndpoint.post("session.move", "/api/session/:sessionID/move", {
|
||||||
params: { sessionID: Session.ID },
|
params: { sessionID: Session.ID },
|
||||||
payload: Schema.Struct({
|
payload: Location.Ref,
|
||||||
destination: Schema.Struct({ directory: AbsolutePath }),
|
|
||||||
moveChanges: Schema.Boolean.pipe(Schema.optional),
|
|
||||||
}),
|
|
||||||
success: HttpApiSchema.NoContent,
|
success: HttpApiSchema.NoContent,
|
||||||
error: [SessionNotFoundError, InvalidRequestError],
|
error: [SessionNotFoundError, InvalidRequestError],
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,7 @@ import { Money } from "./money.js"
|
||||||
import { Snapshot } from "./snapshot.js"
|
import { Snapshot } from "./snapshot.js"
|
||||||
import { TokenUsage } from "./token-usage.js"
|
import { TokenUsage } from "./token-usage.js"
|
||||||
import { SessionPending } from "./session-pending.js"
|
import { SessionPending } from "./session-pending.js"
|
||||||
|
import { Project } from "./project.js"
|
||||||
|
|
||||||
export { FileAttachment }
|
export { FileAttachment }
|
||||||
|
|
||||||
|
|
@ -69,6 +70,7 @@ export const Moved = Event.durable({
|
||||||
schema: {
|
schema: {
|
||||||
...Base,
|
...Base,
|
||||||
location: Location.Ref,
|
location: Location.Ref,
|
||||||
|
projectID: Project.ID.pipe(optional),
|
||||||
subpath: RelativePath.pipe(optional),
|
subpath: RelativePath.pipe(optional),
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -179,6 +179,7 @@ import type {
|
||||||
QuestionReplyErrors,
|
QuestionReplyErrors,
|
||||||
QuestionReplyResponses,
|
QuestionReplyResponses,
|
||||||
QuestionV2Reply,
|
QuestionV2Reply,
|
||||||
|
ServiceStopRequestV2,
|
||||||
SessionAbortErrors,
|
SessionAbortErrors,
|
||||||
SessionAbortResponses,
|
SessionAbortResponses,
|
||||||
SessionChildrenErrors,
|
SessionChildrenErrors,
|
||||||
|
|
@ -291,6 +292,8 @@ import type {
|
||||||
V2GenerateTextResponses,
|
V2GenerateTextResponses,
|
||||||
V2HealthGetErrors,
|
V2HealthGetErrors,
|
||||||
V2HealthGetResponses,
|
V2HealthGetResponses,
|
||||||
|
V2HealthStopErrors,
|
||||||
|
V2HealthStopResponses,
|
||||||
V2IntegrationAttemptCancelErrors,
|
V2IntegrationAttemptCancelErrors,
|
||||||
V2IntegrationAttemptCancelResponses,
|
V2IntegrationAttemptCancelResponses,
|
||||||
V2IntegrationAttemptCompleteErrors,
|
V2IntegrationAttemptCompleteErrors,
|
||||||
|
|
@ -5072,7 +5075,7 @@ export class Health extends HeyApiClient {
|
||||||
/**
|
/**
|
||||||
* Check server health
|
* 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>) {
|
public get<ThrowOnError extends boolean = false>(options?: Options<never, ThrowOnError>) {
|
||||||
return (options?.client ?? this.client).get<V2HealthGetResponses, V2HealthGetErrors, ThrowOnError>({
|
return (options?.client ?? this.client).get<V2HealthGetResponses, V2HealthGetErrors, ThrowOnError>({
|
||||||
|
|
@ -5080,6 +5083,30 @@ export class Health extends HeyApiClient {
|
||||||
...options,
|
...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 {
|
export class Server extends HeyApiClient {
|
||||||
|
|
@ -6117,10 +6144,7 @@ export class Session3 extends HeyApiClient {
|
||||||
public move<ThrowOnError extends boolean = false>(
|
public move<ThrowOnError extends boolean = false>(
|
||||||
parameters: {
|
parameters: {
|
||||||
sessionID: string
|
sessionID: string
|
||||||
destination?: {
|
locationRefV2: LocationRefV2
|
||||||
directory: string
|
|
||||||
}
|
|
||||||
moveChanges?: boolean | null
|
|
||||||
},
|
},
|
||||||
options?: Options<never, ThrowOnError>,
|
options?: Options<never, ThrowOnError>,
|
||||||
) {
|
) {
|
||||||
|
|
@ -6130,8 +6154,7 @@ export class Session3 extends HeyApiClient {
|
||||||
{
|
{
|
||||||
args: [
|
args: [
|
||||||
{ in: "path", key: "sessionID" },
|
{ in: "path", key: "sessionID" },
|
||||||
{ in: "body", key: "destination" },
|
{ key: "locationRefV2", map: "body" },
|
||||||
{ in: "body", key: "moveChanges" },
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|
|
||||||
|
|
@ -598,24 +598,6 @@ export type Part =
|
||||||
| RetryPart
|
| RetryPart
|
||||||
| CompactionPart
|
| 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 = {
|
export type Pty = {
|
||||||
id: string
|
id: string
|
||||||
title: string
|
title: string
|
||||||
|
|
@ -803,6 +785,7 @@ export type GlobalEvent = {
|
||||||
properties: {
|
properties: {
|
||||||
sessionID: string
|
sessionID: string
|
||||||
location: LocationRef
|
location: LocationRef
|
||||||
|
projectID?: string
|
||||||
subpath?: string
|
subpath?: string
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -917,7 +900,7 @@ export type GlobalEvent = {
|
||||||
type: "session.shell.started"
|
type: "session.shell.started"
|
||||||
properties: {
|
properties: {
|
||||||
sessionID: string
|
sessionID: string
|
||||||
shell: Shell
|
shell: ShellInfo
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
|
|
@ -925,7 +908,7 @@ export type GlobalEvent = {
|
||||||
type: "session.shell.ended"
|
type: "session.shell.ended"
|
||||||
properties: {
|
properties: {
|
||||||
sessionID: string
|
sessionID: string
|
||||||
shell: Shell
|
shell: ShellInfo
|
||||||
output: {
|
output: {
|
||||||
output: string
|
output: string
|
||||||
cursor: number
|
cursor: number
|
||||||
|
|
@ -1358,7 +1341,7 @@ export type GlobalEvent = {
|
||||||
id: string
|
id: string
|
||||||
type: "shell.created"
|
type: "shell.created"
|
||||||
properties: {
|
properties: {
|
||||||
info: Shell
|
info: ShellInfo
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
|
|
@ -1366,7 +1349,7 @@ export type GlobalEvent = {
|
||||||
type: "shell.exited"
|
type: "shell.exited"
|
||||||
properties: {
|
properties: {
|
||||||
id: string
|
id: string
|
||||||
exit?: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN"
|
exit?: number
|
||||||
status: "running" | "exited" | "timeout" | "killed"
|
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 = {
|
export type UnauthorizedError = {
|
||||||
_tag: "UnauthorizedError"
|
_tag: "UnauthorizedError"
|
||||||
message: string
|
message: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type ServiceStopRequest = {
|
||||||
|
instanceID: string
|
||||||
|
targetVersion?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ServiceStopResponse = {
|
||||||
|
accepted: boolean
|
||||||
|
}
|
||||||
|
|
||||||
export type SessionsResponse = {
|
export type SessionsResponse = {
|
||||||
data: Array<SessionInfo>
|
data: Array<SessionInfo>
|
||||||
cursor: {
|
cursor: {
|
||||||
|
|
@ -2890,24 +2907,6 @@ export type InstructionEntryValueTooLargeError = {
|
||||||
message: string
|
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 SessionLogItem = SessionEventDurable | EventLogSynced
|
||||||
|
|
||||||
export type SessionLogItemStream = string
|
export type SessionLogItemStream = string
|
||||||
|
|
@ -3154,24 +3153,6 @@ export type EffectHttpApiErrorForbidden = {
|
||||||
_tag: "Forbidden"
|
_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 = {
|
export type EventTuiPromptAppend2 = {
|
||||||
id: string
|
id: string
|
||||||
type: "tui.prompt.append"
|
type: "tui.prompt.append"
|
||||||
|
|
@ -3398,6 +3379,24 @@ export type SessionStructuredError = {
|
||||||
message: string
|
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 = {
|
export type SessionMessageProviderState = {
|
||||||
[key: string]: unknown
|
[key: string]: unknown
|
||||||
}
|
}
|
||||||
|
|
@ -3773,6 +3772,7 @@ export type SyncEventSessionMoved = {
|
||||||
data: {
|
data: {
|
||||||
sessionID: string
|
sessionID: string
|
||||||
location: LocationRef
|
location: LocationRef
|
||||||
|
projectID?: string
|
||||||
subpath?: string
|
subpath?: string
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -3962,7 +3962,7 @@ export type SyncEventSessionShellStarted = {
|
||||||
aggregateID: string
|
aggregateID: string
|
||||||
data: {
|
data: {
|
||||||
sessionID: string
|
sessionID: string
|
||||||
shell: Shell
|
shell: ShellInfo
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -3977,7 +3977,7 @@ export type SyncEventSessionShellEnded = {
|
||||||
aggregateID: string
|
aggregateID: string
|
||||||
data: {
|
data: {
|
||||||
sessionID: string
|
sessionID: string
|
||||||
shell: Shell
|
shell: ShellInfo
|
||||||
output: {
|
output: {
|
||||||
output: string
|
output: string
|
||||||
cursor: number
|
cursor: number
|
||||||
|
|
@ -4828,6 +4828,7 @@ export type SessionMoved = {
|
||||||
data: {
|
data: {
|
||||||
sessionID: string
|
sessionID: string
|
||||||
location: LocationRef
|
location: LocationRef
|
||||||
|
projectID?: string
|
||||||
subpath?: string
|
subpath?: string
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -5083,7 +5084,7 @@ export type SessionShellStarted = {
|
||||||
location?: LocationRef
|
location?: LocationRef
|
||||||
data: {
|
data: {
|
||||||
sessionID: string
|
sessionID: string
|
||||||
shell: Shell1
|
shell: ShellInfo
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -5102,7 +5103,7 @@ export type SessionShellEnded = {
|
||||||
location?: LocationRef
|
location?: LocationRef
|
||||||
data: {
|
data: {
|
||||||
sessionID: string
|
sessionID: string
|
||||||
shell: Shell1
|
shell: ShellInfo
|
||||||
output: {
|
output: {
|
||||||
output: string
|
output: string
|
||||||
cursor: number
|
cursor: number
|
||||||
|
|
@ -6461,7 +6462,7 @@ export type ShellCreated = {
|
||||||
type: "shell.created"
|
type: "shell.created"
|
||||||
location?: LocationRef
|
location?: LocationRef
|
||||||
data: {
|
data: {
|
||||||
info: Shell1
|
info: ShellInfo
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -6475,7 +6476,7 @@ export type ShellExited = {
|
||||||
location?: LocationRef
|
location?: LocationRef
|
||||||
data: {
|
data: {
|
||||||
id: string
|
id: string
|
||||||
exit?: number | "NaN" | "Infinity" | "-Infinity"
|
exit?: number
|
||||||
status: "running" | "exited" | "timeout" | "killed"
|
status: "running" | "exited" | "timeout" | "killed"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -7158,6 +7159,7 @@ export type EventSessionMoved = {
|
||||||
properties: {
|
properties: {
|
||||||
sessionID: string
|
sessionID: string
|
||||||
location: LocationRef
|
location: LocationRef
|
||||||
|
projectID?: string
|
||||||
subpath?: string
|
subpath?: string
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -7285,7 +7287,7 @@ export type EventSessionShellStarted = {
|
||||||
type: "session.shell.started"
|
type: "session.shell.started"
|
||||||
properties: {
|
properties: {
|
||||||
sessionID: string
|
sessionID: string
|
||||||
shell: Shell2
|
shell: ShellInfo
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -7294,7 +7296,7 @@ export type EventSessionShellEnded = {
|
||||||
type: "session.shell.ended"
|
type: "session.shell.ended"
|
||||||
properties: {
|
properties: {
|
||||||
sessionID: string
|
sessionID: string
|
||||||
shell: Shell2
|
shell: ShellInfo
|
||||||
output: {
|
output: {
|
||||||
output: string
|
output: string
|
||||||
cursor: number
|
cursor: number
|
||||||
|
|
@ -7772,7 +7774,7 @@ export type EventShellCreated = {
|
||||||
id: string
|
id: string
|
||||||
type: "shell.created"
|
type: "shell.created"
|
||||||
properties: {
|
properties: {
|
||||||
info: Shell2
|
info: ShellInfo
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -7781,7 +7783,7 @@ export type EventShellExited = {
|
||||||
type: "shell.exited"
|
type: "shell.exited"
|
||||||
properties: {
|
properties: {
|
||||||
id: string
|
id: string
|
||||||
exit?: number | "NaN" | "Infinity" | "-Infinity"
|
exit?: number
|
||||||
status: "running" | "exited" | "timeout" | "killed"
|
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 = {
|
export type InvalidRequestErrorV2 = {
|
||||||
_tag: "InvalidRequestError"
|
_tag: "InvalidRequestError"
|
||||||
message: string
|
message: string
|
||||||
|
|
@ -8146,6 +8173,11 @@ export type InvalidRequestErrorV2 = {
|
||||||
field?: string | null
|
field?: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type ServiceStopRequestV2 = {
|
||||||
|
instanceID: string
|
||||||
|
targetVersion?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
export type SessionsResponseV2 = {
|
export type SessionsResponseV2 = {
|
||||||
data: Array<SessionInfoV2>
|
data: Array<SessionInfoV2>
|
||||||
cursor: {
|
cursor: {
|
||||||
|
|
@ -8179,24 +8211,6 @@ export type UnknownErrorV2 = {
|
||||||
ref?: string | null
|
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 = {
|
export type SessionMessagesResponseV2 = {
|
||||||
data: Array<SessionMessageInfo>
|
data: Array<SessionMessageInfo>
|
||||||
cursor: {
|
cursor: {
|
||||||
|
|
@ -9064,6 +9078,7 @@ export type SessionMovedV2 = {
|
||||||
data: {
|
data: {
|
||||||
sessionID: string
|
sessionID: string
|
||||||
location: LocationRefV2
|
location: LocationRefV2
|
||||||
|
projectID?: string
|
||||||
subpath?: 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 = {
|
export type SessionShellStartedV2 = {
|
||||||
id: string
|
id: string
|
||||||
created: number
|
created: number
|
||||||
|
|
@ -9348,7 +9381,7 @@ export type SessionShellStartedV2 = {
|
||||||
location?: LocationRefV2
|
location?: LocationRefV2
|
||||||
data: {
|
data: {
|
||||||
sessionID: string
|
sessionID: string
|
||||||
shell: ShellV2
|
shell: ShellInfoV2
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -9367,7 +9400,7 @@ export type SessionShellEndedV2 = {
|
||||||
location?: LocationRefV2
|
location?: LocationRefV2
|
||||||
data: {
|
data: {
|
||||||
sessionID: string
|
sessionID: string
|
||||||
shell: ShellV2
|
shell: ShellInfoV2
|
||||||
output: {
|
output: {
|
||||||
output: string
|
output: string
|
||||||
cursor: number
|
cursor: number
|
||||||
|
|
@ -10514,7 +10547,7 @@ export type ShellCreatedV2 = {
|
||||||
type: "shell.created"
|
type: "shell.created"
|
||||||
location?: LocationRefV2
|
location?: LocationRefV2
|
||||||
data: {
|
data: {
|
||||||
info: ShellV2
|
info: ShellInfoV2
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -10528,7 +10561,7 @@ export type ShellExitedV2 = {
|
||||||
location?: LocationRefV2
|
location?: LocationRefV2
|
||||||
data: {
|
data: {
|
||||||
id: string
|
id: string
|
||||||
exit?: number | "NaN" | "Infinity" | "-Infinity"
|
exit?: number
|
||||||
status: "running" | "exited" | "timeout" | "killed"
|
status: "running" | "exited" | "timeout" | "killed"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -10986,6 +11019,24 @@ export type PtyTicketConnectTokenV2 = {
|
||||||
expires_in: number
|
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 = {
|
export type QuestionV2RequestV2 = {
|
||||||
id: string
|
id: string
|
||||||
sessionID: string
|
sessionID: string
|
||||||
|
|
@ -15124,17 +15175,42 @@ export type V2HealthGetError = V2HealthGetErrors[keyof V2HealthGetErrors]
|
||||||
|
|
||||||
export type V2HealthGetResponses = {
|
export type V2HealthGetResponses = {
|
||||||
/**
|
/**
|
||||||
* Success
|
* ServiceHealth
|
||||||
*/
|
*/
|
||||||
200: {
|
200: ServiceHealthV2
|
||||||
healthy: true
|
|
||||||
version: string
|
|
||||||
pid: number
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export type V2HealthGetResponse = V2HealthGetResponses[keyof V2HealthGetResponses]
|
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 = {
|
export type V2ServerGetData = {
|
||||||
body?: never
|
body?: never
|
||||||
path?: never
|
path?: never
|
||||||
|
|
@ -15611,12 +15687,7 @@ export type V2SessionRenameResponses = {
|
||||||
export type V2SessionRenameResponse = V2SessionRenameResponses[keyof V2SessionRenameResponses]
|
export type V2SessionRenameResponse = V2SessionRenameResponses[keyof V2SessionRenameResponses]
|
||||||
|
|
||||||
export type V2SessionMoveData = {
|
export type V2SessionMoveData = {
|
||||||
body: {
|
body: LocationRefV2
|
||||||
destination: {
|
|
||||||
directory: string
|
|
||||||
}
|
|
||||||
moveChanges?: boolean | null
|
|
||||||
}
|
|
||||||
path: {
|
path: {
|
||||||
sessionID: string
|
sessionID: string
|
||||||
}
|
}
|
||||||
|
|
@ -18318,7 +18389,7 @@ export type V2ShellListResponses = {
|
||||||
*/
|
*/
|
||||||
200: {
|
200: {
|
||||||
location: LocationInfoV2
|
location: LocationInfoV2
|
||||||
data: Array<ShellV2>
|
data: Array<ShellInfo1>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -18362,7 +18433,7 @@ export type V2ShellCreateResponses = {
|
||||||
*/
|
*/
|
||||||
200: {
|
200: {
|
||||||
location: LocationInfoV2
|
location: LocationInfoV2
|
||||||
data: ShellV2
|
data: ShellInfo1
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -18445,7 +18516,7 @@ export type V2ShellGetResponses = {
|
||||||
*/
|
*/
|
||||||
200: {
|
200: {
|
||||||
location: LocationInfoV2
|
location: LocationInfoV2
|
||||||
data: ShellV2
|
data: ShellInfo1
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -18490,7 +18561,7 @@ export type V2ShellTimeoutResponses = {
|
||||||
*/
|
*/
|
||||||
200: {
|
200: {
|
||||||
location: LocationInfoV2
|
location: LocationInfoV2
|
||||||
data: ShellV2
|
data: ShellInfo1
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
import { SessionV2 } from "@opencode-ai/core/session"
|
import { SessionV2 } from "@opencode-ai/core/session"
|
||||||
import { InstructionEntry } from "@opencode-ai/core/session/instruction-entry"
|
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 { DateTime, Effect, Stream } from "effect"
|
||||||
import { HttpApiBuilder, HttpApiSchema } from "effect/unstable/httpapi"
|
import { HttpApiBuilder, HttpApiSchema } from "effect/unstable/httpapi"
|
||||||
import { Api } from "../api"
|
import { Api } from "../api"
|
||||||
|
|
@ -25,7 +24,6 @@ const DefaultSessionsLimit = 50
|
||||||
export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handlers) =>
|
export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handlers) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const session = yield* SessionV2.Service
|
const session = yield* SessionV2.Service
|
||||||
const moveSession = yield* MoveSession.Service
|
|
||||||
|
|
||||||
return handlers
|
return handlers
|
||||||
.handle(
|
.handle(
|
||||||
|
|
@ -206,11 +204,11 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl
|
||||||
.handle(
|
.handle(
|
||||||
"session.move",
|
"session.move",
|
||||||
Effect.fn(function* (ctx) {
|
Effect.fn(function* (ctx) {
|
||||||
yield* moveSession
|
yield* session
|
||||||
.moveSession({
|
.move({
|
||||||
sessionID: ctx.params.sessionID,
|
sessionID: ctx.params.sessionID,
|
||||||
destination: ctx.payload.destination,
|
directory: ctx.payload.directory,
|
||||||
moveChanges: ctx.payload.moveChanges,
|
workspaceID: ctx.payload.workspaceID,
|
||||||
})
|
})
|
||||||
.pipe(
|
.pipe(
|
||||||
Effect.catchTag("Session.NotFoundError", (error) =>
|
Effect.catchTag("Session.NotFoundError", (error) =>
|
||||||
|
|
@ -221,22 +219,11 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
Effect.catchTag("MoveSession.DestinationProjectMismatchError", () =>
|
Effect.catchTag("Session.DestinationNotFoundError", (error) =>
|
||||||
Effect.fail(new InvalidRequestError({ message: "Destination directory belongs to another project" })),
|
Effect.fail(new InvalidRequestError({ message: `Directory does not exist: ${error.directory}` })),
|
||||||
),
|
),
|
||||||
Effect.catchTag("MoveSession.ApplyChangesError", () =>
|
Effect.catchTag("Session.DestinationNotDirectoryError", (error) =>
|
||||||
Effect.fail(
|
Effect.fail(new InvalidRequestError({ message: `Not a directory: ${error.directory}` })),
|
||||||
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 })),
|
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
return HttpApiSchema.NoContent.make()
|
return HttpApiSchema.NoContent.make()
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,6 @@ import { Observability } from "@opencode-ai/core/observability"
|
||||||
import { Credential } from "@opencode-ai/core/credential"
|
import { Credential } from "@opencode-ai/core/credential"
|
||||||
import { PermissionSaved } from "@opencode-ai/core/permission/saved"
|
import { PermissionSaved } from "@opencode-ai/core/permission/saved"
|
||||||
import { PtyTicket } from "@opencode-ai/core/pty/ticket"
|
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 { Project } from "@opencode-ai/core/project"
|
||||||
import { SessionV2 } from "@opencode-ai/core/session"
|
import { SessionV2 } from "@opencode-ai/core/session"
|
||||||
import { Job } from "@opencode-ai/core/job"
|
import { Job } from "@opencode-ai/core/job"
|
||||||
|
|
@ -38,7 +37,6 @@ const applicationServices = LayerNode.group([
|
||||||
httpClient,
|
httpClient,
|
||||||
ToolOutputStore.cleanupNode,
|
ToolOutputStore.cleanupNode,
|
||||||
Job.node,
|
Job.node,
|
||||||
MoveSession.node,
|
|
||||||
Project.node,
|
Project.node,
|
||||||
SessionV2.node,
|
SessionV2.node,
|
||||||
PluginRuntime.providerNode,
|
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 commands = createMemo((): AutocompleteOption[] => {
|
||||||
const results: AutocompleteOption[] = keymapCommands().flatMap((command) => {
|
const results: AutocompleteOption[] = keymapCommands().flatMap((command) => {
|
||||||
if (!command.slash) return []
|
const slash = command.slash
|
||||||
|
if (!slash) return []
|
||||||
return {
|
return {
|
||||||
display: `/${command.slash.name}`,
|
display: `/${slash.name}`,
|
||||||
description: command.description ?? command.title,
|
description: command.description ?? command.title,
|
||||||
aliases: command.slash.aliases?.map((alias) => `/${alias}`),
|
aliases: slash.aliases?.map((alias) => `/${alias}`),
|
||||||
onSelect: command.run,
|
onSelect: slash.arguments ? () => insertSlash(slash.name) : command.run,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
const commandNames = new Set<string>()
|
const commandNames = new Set<string>()
|
||||||
|
|
@ -444,13 +453,7 @@ export function Autocomplete(props: {
|
||||||
results.push({
|
results.push({
|
||||||
display: "/" + serverCommand.name,
|
display: "/" + serverCommand.name,
|
||||||
description: serverCommand.description,
|
description: serverCommand.description,
|
||||||
onSelect: () => {
|
onSelect: () => insertSlash(serverCommand.name),
|
||||||
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)
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -460,13 +463,7 @@ export function Autocomplete(props: {
|
||||||
results.push({
|
results.push({
|
||||||
display: "/" + skill.id,
|
display: "/" + skill.id,
|
||||||
description: skill.description,
|
description: skill.description,
|
||||||
onSelect: () => {
|
onSelect: () => insertSlash(skill.id),
|
||||||
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)
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -52,6 +52,7 @@ import { usePromptMove } from "./move"
|
||||||
import { readLocalAttachment } from "./local-attachment"
|
import { readLocalAttachment } from "./local-attachment"
|
||||||
import { useData } from "../../context/data"
|
import { useData } from "../../context/data"
|
||||||
import { useLocation } from "../../context/location"
|
import { useLocation } from "../../context/location"
|
||||||
|
import { Keymap, type KeymapCommand } from "../../context/keymap"
|
||||||
import { contextUsage } from "../../util/session"
|
import { contextUsage } from "../../util/session"
|
||||||
import { abbreviateHome } from "../../runtime"
|
import { abbreviateHome } from "../../runtime"
|
||||||
|
|
||||||
|
|
@ -137,6 +138,18 @@ function formatEditorContext(selection: EditorSelection) {
|
||||||
|
|
||||||
let stashed: { prompt: PromptInfo; cursor: number } | undefined
|
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) {
|
export function Prompt(props: PromptProps) {
|
||||||
let input: TextareaRenderable
|
let input: TextareaRenderable
|
||||||
let anchor: BoxRenderable
|
let anchor: BoxRenderable
|
||||||
|
|
@ -152,6 +165,7 @@ export function Prompt(props: PromptProps) {
|
||||||
const editor = useEditorContext()
|
const editor = useEditorContext()
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const data = useData()
|
const data = useData()
|
||||||
|
const keymapCommands = Keymap.useCommands()
|
||||||
const currentLocation = useLocation()
|
const currentLocation = useLocation()
|
||||||
const config = useConfig().data
|
const config = useConfig().data
|
||||||
const dialog = useDialog()
|
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,
|
(props.sessionID ? data.session.get(props.sessionID)?.projectID : undefined) ?? data.location.info()?.project.id,
|
||||||
sessionID: () => props.sessionID,
|
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 [cursorVersion, setCursorVersion] = createSignal(0)
|
||||||
const currentProviderLabel = createMemo(() => local.model.parsed().provider)
|
const currentProviderLabel = createMemo(() => local.model.parsed().provider)
|
||||||
const connected = useConnected()
|
const connected = useConnected()
|
||||||
|
|
@ -948,6 +986,12 @@ export function Prompt(props: PromptProps) {
|
||||||
void exit()
|
void exit()
|
||||||
return true
|
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()
|
const agent = local.agent.current()
|
||||||
if (!agent) return false
|
if (!agent) return false
|
||||||
const selectedModel = local.model.current()
|
const selectedModel = local.model.current()
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,6 @@ import { useDialog } from "../../ui/dialog"
|
||||||
import { useClient } from "../../context/client"
|
import { useClient } from "../../context/client"
|
||||||
import { useToast } from "../../ui/toast"
|
import { useToast } from "../../ui/toast"
|
||||||
import { DialogMoveSession, type MoveSessionSelection } from "../dialog-move-session"
|
import { DialogMoveSession, type MoveSessionSelection } from "../dialog-move-session"
|
||||||
import { DialogWorkspaceFileChanges } from "../dialog-workspace-file-changes"
|
|
||||||
import { useData } from "../../context/data"
|
import { useData } from "../../context/data"
|
||||||
|
|
||||||
function moveReminderText(directory: string) {
|
function moveReminderText(directory: string) {
|
||||||
|
|
@ -94,12 +93,6 @@ export function usePromptMove(input: { projectID: () => string | undefined; sess
|
||||||
}
|
}
|
||||||
|
|
||||||
async function moveExistingSession(sessionID: string, selection: MoveSessionSelection) {
|
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()
|
dialog.clear()
|
||||||
const directory = selection.type === "new" ? await create(selection.name) : selection.directory
|
const directory = selection.type === "new" ? await create(selection.name) : selection.directory
|
||||||
if (!directory) {
|
if (!directory) {
|
||||||
|
|
@ -109,7 +102,7 @@ export function usePromptMove(input: { projectID: () => string | undefined; sess
|
||||||
}
|
}
|
||||||
setProgress("Moving session")
|
setProgress("Moving session")
|
||||||
try {
|
try {
|
||||||
await client.api.session.move({ sessionID, destination: { directory }, moveChanges: choice === "yes" })
|
await client.api.session.move({ sessionID, directory })
|
||||||
await client.api.session
|
await client.api.session
|
||||||
.synthetic({ sessionID, text: moveReminderText(directory), resume: false })
|
.synthetic({ sessionID, text: moveReminderText(directory), resume: false })
|
||||||
.catch(() => undefined)
|
.catch(() => undefined)
|
||||||
|
|
|
||||||
|
|
@ -368,6 +368,8 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
||||||
case "session.moved":
|
case "session.moved":
|
||||||
if (store.session.info[event.data.sessionID]) {
|
if (store.session.info[event.data.sessionID]) {
|
||||||
setStore("session", "info", event.data.sessionID, "location", event.data.location)
|
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)
|
setStore("session", "info", event.data.sessionID, "subpath", event.data.subpath)
|
||||||
}
|
}
|
||||||
break
|
break
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,7 @@ declare module "@opentui/keymap" {
|
||||||
slash?: {
|
slash?: {
|
||||||
name: string
|
name: string
|
||||||
aliases?: 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 OpenTuiKeymap = Parameters<typeof KeymapProvider>[0]["keymap"]
|
||||||
type Mode = ReturnType<typeof createMode>
|
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) {
|
function Provider(props: ParentProps) {
|
||||||
const renderer = useRenderer()
|
const renderer = useRenderer()
|
||||||
const config = useConfig()
|
const config = useConfig()
|
||||||
const keymap = createDefaultOpenTuiKeymap(renderer)
|
const keymap = createDefaultOpenTuiKeymap(renderer)
|
||||||
const mode = createMode(keymap)
|
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 = [
|
const dispose = [
|
||||||
registerCommaBindings(keymap),
|
registerCommaBindings(keymap),
|
||||||
keymap.appendBindingExpander((context) => {
|
keymap.appendBindingExpander((context) => {
|
||||||
|
|
@ -114,7 +130,11 @@ function Provider(props: ParentProps) {
|
||||||
})
|
})
|
||||||
return (
|
return (
|
||||||
<KeymapProvider keymap={keymap}>
|
<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>
|
</KeymapProvider>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -123,7 +143,7 @@ export type { KeymapCommand, KeymapLayer } from "@opencode-ai/plugin/v2/tui/cont
|
||||||
|
|
||||||
export interface Keymap {
|
export interface Keymap {
|
||||||
/** Dispatches a reachable command by ID. */
|
/** Dispatches a reachable command by ID. */
|
||||||
dispatch(id: string): void
|
dispatch(id: string, input?: string): void
|
||||||
/** Controls mutually exclusive OpenCode input modes. */
|
/** Controls mutually exclusive OpenCode input modes. */
|
||||||
readonly mode: {
|
readonly mode: {
|
||||||
/** Returns the active mode. */
|
/** Returns the active mode. */
|
||||||
|
|
@ -136,15 +156,15 @@ export interface Keymap {
|
||||||
function use(): Keymap {
|
function use(): Keymap {
|
||||||
const value = useValue()
|
const value = useValue()
|
||||||
return {
|
return {
|
||||||
dispatch(id) {
|
dispatch(id, input) {
|
||||||
value.keymap.dispatchCommand(id)
|
value.dispatch(id, input)
|
||||||
},
|
},
|
||||||
mode: value.mode,
|
mode: value.mode,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function createLayer(input: () => KeymapLayer) {
|
function createLayer(input: () => KeymapLayer) {
|
||||||
useValue()
|
const value = useValue()
|
||||||
const config = useConfig()
|
const config = useConfig()
|
||||||
useBindings(() => {
|
useBindings(() => {
|
||||||
const layer = input()
|
const layer = input()
|
||||||
|
|
@ -174,11 +194,12 @@ function createLayer(input: () => KeymapLayer) {
|
||||||
...options,
|
...options,
|
||||||
...(mode === "global" ? {} : { mode: mode ?? MODE.base }),
|
...(mode === "global" ? {} : { mode: mode ?? MODE.base }),
|
||||||
commands: grouped.named.map((command) => {
|
commands: grouped.named.map((command) => {
|
||||||
const { id, description, group, palette, bind, ...definition } = command
|
const { id, description, group, palette, bind, run, ...definition } = command
|
||||||
return {
|
return {
|
||||||
...definition,
|
...definition,
|
||||||
name: id,
|
name: id,
|
||||||
opencode: command,
|
opencode: command,
|
||||||
|
run: () => run(value.input(id)),
|
||||||
...(description === undefined ? {} : { desc: description }),
|
...(description === undefined ? {} : { desc: description }),
|
||||||
...(group === undefined ? {} : { category: group }),
|
...(group === undefined ? {} : { category: group }),
|
||||||
...(palette === undefined ? {} : { namespace: "palette" }),
|
...(palette === undefined ? {} : { namespace: "palette" }),
|
||||||
|
|
@ -262,8 +283,8 @@ function useCommands(): Accessor<readonly KeymapCommand[]> {
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
...command,
|
...command,
|
||||||
run: () => {
|
run: (input?: string) => {
|
||||||
value.keymap.dispatchCommand(entry.command.name)
|
value.dispatch(entry.command.name, input)
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,7 @@ declare module "@opentui/keymap" {
|
||||||
slash?: {
|
slash?: {
|
||||||
name: string
|
name: string
|
||||||
aliases?: string[]
|
aliases?: string[]
|
||||||
|
arguments?: true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -495,10 +495,12 @@ test("updates session location when moved", async () => {
|
||||||
data: {
|
data: {
|
||||||
sessionID: "ses_test",
|
sessionID: "ses_test",
|
||||||
location: { directory: destination },
|
location: { directory: destination },
|
||||||
|
projectID: "project-moved",
|
||||||
subpath: "packages/cli",
|
subpath: "packages/cli",
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
await wait(() => data.session.get("ses_test")?.location.directory === destination)
|
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")
|
expect(data.session.get("ses_test")?.subpath).toBe("packages/cli")
|
||||||
} finally {
|
} finally {
|
||||||
app.renderer.destroy()
|
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