mirror of
https://github.com/anomalyco/opencode.git
synced 2026-07-28 14:11:49 -04:00
Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 182494f885 | |||
| 2b767d8cb1 | |||
| 010133f6df | |||
| 3b0d8f0e6f | |||
| 4d59b059ee |
@@ -53,7 +53,6 @@ function setup(
|
||||
}
|
||||
|
||||
describe("createCompatibleApi", () => {
|
||||
/*
|
||||
test("routes V1 archive through the legacy session update", async () => {
|
||||
const { api, requests } = setup("v1")
|
||||
await api.session.archive({ sessionID: "ses_1", directory: "/repo" })
|
||||
@@ -64,7 +63,6 @@ describe("createCompatibleApi", () => {
|
||||
expect(requests[0]!.method).toBe("PATCH")
|
||||
expect(await requests[0]!.json()).toMatchObject({ time: { archived: expect.any(Number) } })
|
||||
})
|
||||
*/
|
||||
|
||||
test("converts current prompts to the V1 prompt contract", async () => {
|
||||
const { api, requests } = setup("v1")
|
||||
@@ -147,7 +145,6 @@ describe("createCompatibleApi", () => {
|
||||
expect(detections).toBe(1)
|
||||
})
|
||||
|
||||
/*
|
||||
test("keeps V2 session actions on the current API", async () => {
|
||||
const { api, requests } = setup("v2")
|
||||
await api.session.archive({ sessionID: "ses_1" })
|
||||
@@ -155,7 +152,6 @@ describe("createCompatibleApi", () => {
|
||||
expect(new URL(requests[0]!.url).pathname).toBe("/api/session/ses_1/archive")
|
||||
expect(requests[0]!.method).toBe("POST")
|
||||
})
|
||||
*/
|
||||
|
||||
test("uses the global V1 session search endpoint", async () => {
|
||||
const { api, requests } = setup("v1")
|
||||
|
||||
@@ -27,7 +27,7 @@ type CompatibleSessionApi = Omit<
|
||||
shell: (input: SessionShellInput & LegacyPrompt) => Promise<SessionShellOutput>
|
||||
compact: (input: SessionCompactInput & { model?: LegacyPrompt["model"] }) => Promise<SessionCompactOutput>
|
||||
rename: (input: Parameters<SessionApi["rename"]>[0] & LegacyLocation) => ReturnType<SessionApi["rename"]>
|
||||
// archive: (input: Parameters<SessionApi["archive"]>[0] & LegacyLocation) => ReturnType<SessionApi["archive"]>
|
||||
archive: (input: Parameters<SessionApi["archive"]>[0] & LegacyLocation) => ReturnType<SessionApi["archive"]>
|
||||
remove: (input: Parameters<SessionApi["remove"]>[0] & LegacyLocation) => ReturnType<SessionApi["remove"]>
|
||||
}
|
||||
type CompatiblePermissionApi = Omit<ServerApi["permission"], "reply"> & {
|
||||
@@ -183,9 +183,9 @@ function createV1Api(input: CompatibleInput): CompatibleApi {
|
||||
async rename(value: Parameters<ServerApi["session"]["rename"]>[0] & LegacyLocation) {
|
||||
await legacy(value).session.update({ sessionID: value.sessionID, title: value.title })
|
||||
},
|
||||
// async archive(value: Parameters<ServerApi["session"]["archive"]>[0] & LegacyLocation) {
|
||||
// await legacy(value).session.update({ sessionID: value.sessionID, time: { archived: Date.now() } })
|
||||
// },
|
||||
async archive(value: Parameters<ServerApi["session"]["archive"]>[0] & LegacyLocation) {
|
||||
await legacy(value).session.update({ sessionID: value.sessionID, time: { archived: Date.now() } })
|
||||
},
|
||||
async remove(value: Parameters<ServerApi["session"]["remove"]>[0] & LegacyLocation) {
|
||||
await legacy(value).session.delete(value)
|
||||
},
|
||||
|
||||
@@ -152,15 +152,19 @@ export type Endpoint5_8Input = { readonly sessionID: Session.ID; readonly title:
|
||||
export type Endpoint5_8Output = void
|
||||
export type SessionRenameOperation<E = never> = (input: Endpoint5_8Input) => Effect.Effect<Endpoint5_8Output, E>
|
||||
|
||||
export type Endpoint5_9Input = {
|
||||
export type Endpoint5_9Input = { readonly sessionID: Session.ID }
|
||||
export type Endpoint5_9Output = void
|
||||
export type SessionArchiveOperation<E = never> = (input: Endpoint5_9Input) => Effect.Effect<Endpoint5_9Output, E>
|
||||
|
||||
export type Endpoint5_10Input = {
|
||||
readonly sessionID: Session.ID
|
||||
readonly directory: AbsolutePath
|
||||
readonly workspaceID?: Workspace.ID | undefined
|
||||
}
|
||||
export type Endpoint5_9Output = void
|
||||
export type SessionMoveOperation<E = never> = (input: Endpoint5_9Input) => Effect.Effect<Endpoint5_9Output, E>
|
||||
export type Endpoint5_10Output = void
|
||||
export type SessionMoveOperation<E = never> = (input: Endpoint5_10Input) => Effect.Effect<Endpoint5_10Output, E>
|
||||
|
||||
export type Endpoint5_10Input = {
|
||||
export type Endpoint5_11Input = {
|
||||
readonly sessionID: Session.ID
|
||||
readonly id?: SessionMessage.ID | undefined
|
||||
readonly text: string
|
||||
@@ -170,10 +174,10 @@ export type Endpoint5_10Input = {
|
||||
readonly delivery?: "steer" | "queue" | undefined
|
||||
readonly resume?: boolean | undefined
|
||||
}
|
||||
export type Endpoint5_10Output = SessionPending.User
|
||||
export type SessionPromptOperation<E = never> = (input: Endpoint5_10Input) => Effect.Effect<Endpoint5_10Output, E>
|
||||
export type Endpoint5_11Output = SessionPending.User
|
||||
export type SessionPromptOperation<E = never> = (input: Endpoint5_11Input) => Effect.Effect<Endpoint5_11Output, E>
|
||||
|
||||
export type Endpoint5_11Input = {
|
||||
export type Endpoint5_12Input = {
|
||||
readonly sessionID: Session.ID
|
||||
readonly id?: SessionMessage.ID | undefined
|
||||
readonly command: string
|
||||
@@ -185,19 +189,19 @@ export type Endpoint5_11Input = {
|
||||
readonly delivery?: "steer" | "queue" | undefined
|
||||
readonly resume?: boolean | undefined
|
||||
}
|
||||
export type Endpoint5_11Output = SessionPending.User
|
||||
export type SessionCommandOperation<E = never> = (input: Endpoint5_11Input) => Effect.Effect<Endpoint5_11Output, E>
|
||||
export type Endpoint5_12Output = SessionPending.User
|
||||
export type SessionCommandOperation<E = never> = (input: Endpoint5_12Input) => Effect.Effect<Endpoint5_12Output, E>
|
||||
|
||||
export type Endpoint5_12Input = {
|
||||
export type Endpoint5_13Input = {
|
||||
readonly sessionID: Session.ID
|
||||
readonly id?: SessionMessage.ID | undefined
|
||||
readonly skill: Skill.ID
|
||||
readonly resume?: boolean | undefined
|
||||
}
|
||||
export type Endpoint5_12Output = void
|
||||
export type SessionSkillOperation<E = never> = (input: Endpoint5_12Input) => Effect.Effect<Endpoint5_12Output, E>
|
||||
export type Endpoint5_13Output = void
|
||||
export type SessionSkillOperation<E = never> = (input: Endpoint5_13Input) => Effect.Effect<Endpoint5_13Output, E>
|
||||
|
||||
export type Endpoint5_13Input = {
|
||||
export type Endpoint5_14Input = {
|
||||
readonly sessionID: Session.ID
|
||||
readonly id?: SessionMessage.ID | undefined
|
||||
readonly text: string
|
||||
@@ -206,81 +210,81 @@ export type Endpoint5_13Input = {
|
||||
readonly delivery?: "steer" | "queue" | undefined
|
||||
readonly resume?: boolean | undefined
|
||||
}
|
||||
export type Endpoint5_13Output = SessionPending.Synthetic
|
||||
export type SessionSyntheticOperation<E = never> = (input: Endpoint5_13Input) => Effect.Effect<Endpoint5_13Output, E>
|
||||
export type Endpoint5_14Output = SessionPending.Synthetic
|
||||
export type SessionSyntheticOperation<E = never> = (input: Endpoint5_14Input) => Effect.Effect<Endpoint5_14Output, E>
|
||||
|
||||
export type Endpoint5_14Input = {
|
||||
export type Endpoint5_15Input = {
|
||||
readonly sessionID: Session.ID
|
||||
readonly id?: Event.ID | undefined
|
||||
readonly command: string
|
||||
}
|
||||
export type Endpoint5_14Output = void
|
||||
export type SessionShellOperation<E = never> = (input: Endpoint5_14Input) => Effect.Effect<Endpoint5_14Output, E>
|
||||
export type Endpoint5_15Output = void
|
||||
export type SessionShellOperation<E = never> = (input: Endpoint5_15Input) => Effect.Effect<Endpoint5_15Output, E>
|
||||
|
||||
export type Endpoint5_15Input = { readonly sessionID: Session.ID; readonly id?: SessionMessage.ID | undefined }
|
||||
export type Endpoint5_15Output = SessionPending.Compaction
|
||||
export type SessionCompactOperation<E = never> = (input: Endpoint5_15Input) => Effect.Effect<Endpoint5_15Output, E>
|
||||
export type Endpoint5_16Input = { readonly sessionID: Session.ID; readonly id?: SessionMessage.ID | undefined }
|
||||
export type Endpoint5_16Output = SessionPending.Compaction
|
||||
export type SessionCompactOperation<E = never> = (input: Endpoint5_16Input) => Effect.Effect<Endpoint5_16Output, E>
|
||||
|
||||
export type Endpoint5_16Input = { readonly sessionID: Session.ID }
|
||||
export type Endpoint5_16Output = void
|
||||
export type SessionWaitOperation<E = never> = (input: Endpoint5_16Input) => Effect.Effect<Endpoint5_16Output, E>
|
||||
export type Endpoint5_17Input = { readonly sessionID: Session.ID }
|
||||
export type Endpoint5_17Output = void
|
||||
export type SessionWaitOperation<E = never> = (input: Endpoint5_17Input) => Effect.Effect<Endpoint5_17Output, E>
|
||||
|
||||
export type Endpoint5_17Input = {
|
||||
export type Endpoint5_18Input = {
|
||||
readonly sessionID: Session.ID
|
||||
readonly messageID: SessionMessage.ID
|
||||
readonly files?: boolean | undefined
|
||||
}
|
||||
export type Endpoint5_17Output = Session.Revert
|
||||
export type SessionRevertStageOperation<E = never> = (input: Endpoint5_17Input) => Effect.Effect<Endpoint5_17Output, E>
|
||||
|
||||
export type Endpoint5_18Input = { readonly sessionID: Session.ID }
|
||||
export type Endpoint5_18Output = void
|
||||
export type SessionRevertClearOperation<E = never> = (input: Endpoint5_18Input) => Effect.Effect<Endpoint5_18Output, E>
|
||||
export type Endpoint5_18Output = Session.Revert
|
||||
export type SessionRevertStageOperation<E = never> = (input: Endpoint5_18Input) => Effect.Effect<Endpoint5_18Output, E>
|
||||
|
||||
export type Endpoint5_19Input = { readonly sessionID: Session.ID }
|
||||
export type Endpoint5_19Output = void
|
||||
export type SessionRevertCommitOperation<E = never> = (input: Endpoint5_19Input) => Effect.Effect<Endpoint5_19Output, E>
|
||||
export type SessionRevertClearOperation<E = never> = (input: Endpoint5_19Input) => Effect.Effect<Endpoint5_19Output, E>
|
||||
|
||||
export type Endpoint5_20Input = { readonly sessionID: Session.ID }
|
||||
export type Endpoint5_20Output = ReadonlyArray<SessionMessage.Info>
|
||||
export type SessionContextOperation<E = never> = (input: Endpoint5_20Input) => Effect.Effect<Endpoint5_20Output, E>
|
||||
export type Endpoint5_20Output = void
|
||||
export type SessionRevertCommitOperation<E = never> = (input: Endpoint5_20Input) => Effect.Effect<Endpoint5_20Output, E>
|
||||
|
||||
export type Endpoint5_21Input = { readonly sessionID: Session.ID }
|
||||
export type Endpoint5_21Output = ReadonlyArray<SessionPending.Info>
|
||||
export type SessionPendingListOperation<E = never> = (input: Endpoint5_21Input) => Effect.Effect<Endpoint5_21Output, E>
|
||||
export type Endpoint5_21Output = ReadonlyArray<SessionMessage.Info>
|
||||
export type SessionContextOperation<E = never> = (input: Endpoint5_21Input) => Effect.Effect<Endpoint5_21Output, E>
|
||||
|
||||
export type Endpoint5_22Input = { readonly sessionID: Session.ID }
|
||||
export type Endpoint5_22Output = ReadonlyArray<InstructionEntry.Info>
|
||||
export type SessionInstructionsEntryListOperation<E = never> = (
|
||||
input: Endpoint5_22Input,
|
||||
) => Effect.Effect<Endpoint5_22Output, E>
|
||||
export type Endpoint5_22Output = ReadonlyArray<SessionPending.Info>
|
||||
export type SessionPendingListOperation<E = never> = (input: Endpoint5_22Input) => Effect.Effect<Endpoint5_22Output, E>
|
||||
|
||||
export type Endpoint5_23Input = {
|
||||
export type Endpoint5_23Input = { readonly sessionID: Session.ID }
|
||||
export type Endpoint5_23Output = ReadonlyArray<InstructionEntry.Info>
|
||||
export type SessionInstructionsEntryListOperation<E = never> = (
|
||||
input: Endpoint5_23Input,
|
||||
) => Effect.Effect<Endpoint5_23Output, E>
|
||||
|
||||
export type Endpoint5_24Input = {
|
||||
readonly sessionID: Session.ID
|
||||
readonly key: InstructionEntry.Key
|
||||
readonly value: Schema.Json
|
||||
}
|
||||
export type Endpoint5_23Output = void
|
||||
export type SessionInstructionsEntryPutOperation<E = never> = (
|
||||
input: Endpoint5_23Input,
|
||||
) => Effect.Effect<Endpoint5_23Output, E>
|
||||
|
||||
export type Endpoint5_24Input = { readonly sessionID: Session.ID; readonly key: InstructionEntry.Key }
|
||||
export type Endpoint5_24Output = void
|
||||
export type SessionInstructionsEntryRemoveOperation<E = never> = (
|
||||
export type SessionInstructionsEntryPutOperation<E = never> = (
|
||||
input: Endpoint5_24Input,
|
||||
) => Effect.Effect<Endpoint5_24Output, E>
|
||||
|
||||
export type Endpoint5_25Input = { readonly sessionID: Session.ID; readonly prompt: string }
|
||||
export type Endpoint5_25Output = { readonly text: string }
|
||||
export type SessionGenerateOperation<E = never> = (input: Endpoint5_25Input) => Effect.Effect<Endpoint5_25Output, E>
|
||||
export type Endpoint5_25Input = { readonly sessionID: Session.ID; readonly key: InstructionEntry.Key }
|
||||
export type Endpoint5_25Output = void
|
||||
export type SessionInstructionsEntryRemoveOperation<E = never> = (
|
||||
input: Endpoint5_25Input,
|
||||
) => Effect.Effect<Endpoint5_25Output, E>
|
||||
|
||||
export type Endpoint5_26Input = {
|
||||
export type Endpoint5_26Input = { readonly sessionID: Session.ID; readonly prompt: string }
|
||||
export type Endpoint5_26Output = { readonly text: string }
|
||||
export type SessionGenerateOperation<E = never> = (input: Endpoint5_26Input) => Effect.Effect<Endpoint5_26Output, E>
|
||||
|
||||
export type Endpoint5_27Input = {
|
||||
readonly sessionID: Session.ID
|
||||
readonly after?: Event.Seq | undefined
|
||||
readonly follow?: boolean | undefined
|
||||
}
|
||||
export type Endpoint5_26Output =
|
||||
export type Endpoint5_27Output =
|
||||
| (
|
||||
| {
|
||||
readonly id: Event.ID
|
||||
@@ -323,6 +327,15 @@ export type Endpoint5_26Output =
|
||||
readonly location?: Location.Ref | undefined
|
||||
readonly data: { readonly sessionID: Session.ID; readonly title: string }
|
||||
}
|
||||
| {
|
||||
readonly id: Event.ID
|
||||
readonly created: DateTime.Utc
|
||||
readonly metadata?: { readonly [x: string]: unknown } | undefined
|
||||
readonly type: "session.archived"
|
||||
readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version }
|
||||
readonly location?: Location.Ref | undefined
|
||||
readonly data: { readonly sessionID: Session.ID }
|
||||
}
|
||||
| {
|
||||
readonly id: Event.ID
|
||||
readonly created: DateTime.Utc
|
||||
@@ -825,19 +838,19 @@ export type Endpoint5_26Output =
|
||||
}
|
||||
)
|
||||
| EventLog.Synced
|
||||
export type SessionLogOperation<E = never> = (input: Endpoint5_26Input) => Stream.Stream<Endpoint5_26Output, E>
|
||||
|
||||
export type Endpoint5_27Input = { readonly sessionID: Session.ID }
|
||||
export type Endpoint5_27Output = void
|
||||
export type SessionInterruptOperation<E = never> = (input: Endpoint5_27Input) => Effect.Effect<Endpoint5_27Output, E>
|
||||
export type SessionLogOperation<E = never> = (input: Endpoint5_27Input) => Stream.Stream<Endpoint5_27Output, E>
|
||||
|
||||
export type Endpoint5_28Input = { readonly sessionID: Session.ID }
|
||||
export type Endpoint5_28Output = void
|
||||
export type SessionBackgroundOperation<E = never> = (input: Endpoint5_28Input) => Effect.Effect<Endpoint5_28Output, E>
|
||||
export type SessionInterruptOperation<E = never> = (input: Endpoint5_28Input) => Effect.Effect<Endpoint5_28Output, E>
|
||||
|
||||
export type Endpoint5_29Input = { readonly sessionID: Session.ID; readonly messageID: SessionMessage.ID }
|
||||
export type Endpoint5_29Output = SessionMessage.Info
|
||||
export type SessionMessageOperation<E = never> = (input: Endpoint5_29Input) => Effect.Effect<Endpoint5_29Output, E>
|
||||
export type Endpoint5_29Input = { readonly sessionID: Session.ID }
|
||||
export type Endpoint5_29Output = void
|
||||
export type SessionBackgroundOperation<E = never> = (input: Endpoint5_29Input) => Effect.Effect<Endpoint5_29Output, E>
|
||||
|
||||
export type Endpoint5_30Input = { readonly sessionID: Session.ID; readonly messageID: SessionMessage.ID }
|
||||
export type Endpoint5_30Output = SessionMessage.Info
|
||||
export type SessionMessageOperation<E = never> = (input: Endpoint5_30Input) => Effect.Effect<Endpoint5_30Output, E>
|
||||
|
||||
export interface SessionApi<E = never> {
|
||||
readonly list: SessionListOperation<E>
|
||||
@@ -849,6 +862,7 @@ export interface SessionApi<E = never> {
|
||||
readonly switchAgent: SessionSwitchAgentOperation<E>
|
||||
readonly switchModel: SessionSwitchModelOperation<E>
|
||||
readonly rename: SessionRenameOperation<E>
|
||||
readonly archive: SessionArchiveOperation<E>
|
||||
readonly move: SessionMoveOperation<E>
|
||||
readonly prompt: SessionPromptOperation<E>
|
||||
readonly command: SessionCommandOperation<E>
|
||||
|
||||
@@ -76,6 +76,8 @@ import type {
|
||||
Endpoint5_28Output,
|
||||
Endpoint5_29Input,
|
||||
Endpoint5_29Output,
|
||||
Endpoint5_30Input,
|
||||
Endpoint5_30Output,
|
||||
Endpoint6_0Input,
|
||||
Endpoint6_0Output,
|
||||
Endpoint7_0Input,
|
||||
@@ -358,14 +360,19 @@ const Endpoint5_8 = (raw: RawClient["server.session"]) => (input: Endpoint5_8Inp
|
||||
|
||||
const Endpoint5_9 = (raw: RawClient["server.session"]) => (input: Endpoint5_9Input) =>
|
||||
preserveEffect<Endpoint5_9Output>()(
|
||||
raw["session.archive"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)),
|
||||
)
|
||||
|
||||
const Endpoint5_10 = (raw: RawClient["server.session"]) => (input: Endpoint5_10Input) =>
|
||||
preserveEffect<Endpoint5_10Output>()(
|
||||
raw["session.move"]({
|
||||
params: { sessionID: input["sessionID"] },
|
||||
payload: { directory: input["directory"], workspaceID: input["workspaceID"] },
|
||||
}).pipe(Effect.mapError(mapClientError)),
|
||||
)
|
||||
|
||||
const Endpoint5_10 = (raw: RawClient["server.session"]) => (input: Endpoint5_10Input) =>
|
||||
preserveEffect<Endpoint5_10Output>()(
|
||||
const Endpoint5_11 = (raw: RawClient["server.session"]) => (input: Endpoint5_11Input) =>
|
||||
preserveEffect<Endpoint5_11Output>()(
|
||||
raw["session.prompt"]({
|
||||
params: { sessionID: input["sessionID"] },
|
||||
payload: {
|
||||
@@ -383,8 +390,8 @@ const Endpoint5_10 = (raw: RawClient["server.session"]) => (input: Endpoint5_10I
|
||||
),
|
||||
)
|
||||
|
||||
const Endpoint5_11 = (raw: RawClient["server.session"]) => (input: Endpoint5_11Input) =>
|
||||
preserveEffect<Endpoint5_11Output>()(
|
||||
const Endpoint5_12 = (raw: RawClient["server.session"]) => (input: Endpoint5_12Input) =>
|
||||
preserveEffect<Endpoint5_12Output>()(
|
||||
raw["session.command"]({
|
||||
params: { sessionID: input["sessionID"] },
|
||||
payload: {
|
||||
@@ -404,16 +411,16 @@ const Endpoint5_11 = (raw: RawClient["server.session"]) => (input: Endpoint5_11I
|
||||
),
|
||||
)
|
||||
|
||||
const Endpoint5_12 = (raw: RawClient["server.session"]) => (input: Endpoint5_12Input) =>
|
||||
preserveEffect<Endpoint5_12Output>()(
|
||||
const Endpoint5_13 = (raw: RawClient["server.session"]) => (input: Endpoint5_13Input) =>
|
||||
preserveEffect<Endpoint5_13Output>()(
|
||||
raw["session.skill"]({
|
||||
params: { sessionID: input["sessionID"] },
|
||||
payload: { id: input["id"], skill: input["skill"], resume: input["resume"] },
|
||||
}).pipe(Effect.mapError(mapClientError)),
|
||||
)
|
||||
|
||||
const Endpoint5_13 = (raw: RawClient["server.session"]) => (input: Endpoint5_13Input) =>
|
||||
preserveEffect<Endpoint5_13Output>()(
|
||||
const Endpoint5_14 = (raw: RawClient["server.session"]) => (input: Endpoint5_14Input) =>
|
||||
preserveEffect<Endpoint5_14Output>()(
|
||||
raw["session.synthetic"]({
|
||||
params: { sessionID: input["sessionID"] },
|
||||
payload: {
|
||||
@@ -430,29 +437,29 @@ const Endpoint5_13 = (raw: RawClient["server.session"]) => (input: Endpoint5_13I
|
||||
),
|
||||
)
|
||||
|
||||
const Endpoint5_14 = (raw: RawClient["server.session"]) => (input: Endpoint5_14Input) =>
|
||||
preserveEffect<Endpoint5_14Output>()(
|
||||
const Endpoint5_15 = (raw: RawClient["server.session"]) => (input: Endpoint5_15Input) =>
|
||||
preserveEffect<Endpoint5_15Output>()(
|
||||
raw["session.shell"]({
|
||||
params: { sessionID: input["sessionID"] },
|
||||
payload: { id: input["id"], command: input["command"] },
|
||||
}).pipe(Effect.mapError(mapClientError)),
|
||||
)
|
||||
|
||||
const Endpoint5_15 = (raw: RawClient["server.session"]) => (input: Endpoint5_15Input) =>
|
||||
preserveEffect<Endpoint5_15Output>()(
|
||||
const Endpoint5_16 = (raw: RawClient["server.session"]) => (input: Endpoint5_16Input) =>
|
||||
preserveEffect<Endpoint5_16Output>()(
|
||||
raw["session.compact"]({ params: { sessionID: input["sessionID"] }, payload: { id: input["id"] } }).pipe(
|
||||
Effect.mapError(mapClientError),
|
||||
Effect.map((value) => value.data),
|
||||
),
|
||||
)
|
||||
|
||||
const Endpoint5_16 = (raw: RawClient["server.session"]) => (input: Endpoint5_16Input) =>
|
||||
preserveEffect<Endpoint5_16Output>()(
|
||||
const Endpoint5_17 = (raw: RawClient["server.session"]) => (input: Endpoint5_17Input) =>
|
||||
preserveEffect<Endpoint5_17Output>()(
|
||||
raw["session.wait"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)),
|
||||
)
|
||||
|
||||
const Endpoint5_17 = (raw: RawClient["server.session"]) => (input: Endpoint5_17Input) =>
|
||||
preserveEffect<Endpoint5_17Output>()(
|
||||
const Endpoint5_18 = (raw: RawClient["server.session"]) => (input: Endpoint5_18Input) =>
|
||||
preserveEffect<Endpoint5_18Output>()(
|
||||
raw["session.revert.stage"]({
|
||||
params: { sessionID: input["sessionID"] },
|
||||
payload: { messageID: input["messageID"], files: input["files"] },
|
||||
@@ -462,27 +469,19 @@ const Endpoint5_17 = (raw: RawClient["server.session"]) => (input: Endpoint5_17I
|
||||
),
|
||||
)
|
||||
|
||||
const Endpoint5_18 = (raw: RawClient["server.session"]) => (input: Endpoint5_18Input) =>
|
||||
preserveEffect<Endpoint5_18Output>()(
|
||||
raw["session.revert.clear"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)),
|
||||
)
|
||||
|
||||
const Endpoint5_19 = (raw: RawClient["server.session"]) => (input: Endpoint5_19Input) =>
|
||||
preserveEffect<Endpoint5_19Output>()(
|
||||
raw["session.revert.commit"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)),
|
||||
raw["session.revert.clear"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)),
|
||||
)
|
||||
|
||||
const Endpoint5_20 = (raw: RawClient["server.session"]) => (input: Endpoint5_20Input) =>
|
||||
preserveEffect<Endpoint5_20Output>()(
|
||||
raw["session.context"]({ params: { sessionID: input["sessionID"] } }).pipe(
|
||||
Effect.mapError(mapClientError),
|
||||
Effect.map((value) => value.data),
|
||||
),
|
||||
raw["session.revert.commit"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)),
|
||||
)
|
||||
|
||||
const Endpoint5_21 = (raw: RawClient["server.session"]) => (input: Endpoint5_21Input) =>
|
||||
preserveEffect<Endpoint5_21Output>()(
|
||||
raw["session.pending.list"]({ params: { sessionID: input["sessionID"] } }).pipe(
|
||||
raw["session.context"]({ params: { sessionID: input["sessionID"] } }).pipe(
|
||||
Effect.mapError(mapClientError),
|
||||
Effect.map((value) => value.data),
|
||||
),
|
||||
@@ -490,7 +489,7 @@ const Endpoint5_21 = (raw: RawClient["server.session"]) => (input: Endpoint5_21I
|
||||
|
||||
const Endpoint5_22 = (raw: RawClient["server.session"]) => (input: Endpoint5_22Input) =>
|
||||
preserveEffect<Endpoint5_22Output>()(
|
||||
raw["session.instructions.entry.list"]({ params: { sessionID: input["sessionID"] } }).pipe(
|
||||
raw["session.pending.list"]({ params: { sessionID: input["sessionID"] } }).pipe(
|
||||
Effect.mapError(mapClientError),
|
||||
Effect.map((value) => value.data),
|
||||
),
|
||||
@@ -498,29 +497,37 @@ const Endpoint5_22 = (raw: RawClient["server.session"]) => (input: Endpoint5_22I
|
||||
|
||||
const Endpoint5_23 = (raw: RawClient["server.session"]) => (input: Endpoint5_23Input) =>
|
||||
preserveEffect<Endpoint5_23Output>()(
|
||||
raw["session.instructions.entry.list"]({ params: { sessionID: input["sessionID"] } }).pipe(
|
||||
Effect.mapError(mapClientError),
|
||||
Effect.map((value) => value.data),
|
||||
),
|
||||
)
|
||||
|
||||
const Endpoint5_24 = (raw: RawClient["server.session"]) => (input: Endpoint5_24Input) =>
|
||||
preserveEffect<Endpoint5_24Output>()(
|
||||
raw["session.instructions.entry.put"]({
|
||||
params: { sessionID: input["sessionID"], key: input["key"] },
|
||||
payload: { value: input["value"] },
|
||||
}).pipe(Effect.mapError(mapClientError)),
|
||||
)
|
||||
|
||||
const Endpoint5_24 = (raw: RawClient["server.session"]) => (input: Endpoint5_24Input) =>
|
||||
preserveEffect<Endpoint5_24Output>()(
|
||||
const Endpoint5_25 = (raw: RawClient["server.session"]) => (input: Endpoint5_25Input) =>
|
||||
preserveEffect<Endpoint5_25Output>()(
|
||||
raw["session.instructions.entry.remove"]({ params: { sessionID: input["sessionID"], key: input["key"] } }).pipe(
|
||||
Effect.mapError(mapClientError),
|
||||
),
|
||||
)
|
||||
|
||||
const Endpoint5_25 = (raw: RawClient["server.session"]) => (input: Endpoint5_25Input) =>
|
||||
preserveEffect<Endpoint5_25Output>()(
|
||||
const Endpoint5_26 = (raw: RawClient["server.session"]) => (input: Endpoint5_26Input) =>
|
||||
preserveEffect<Endpoint5_26Output>()(
|
||||
raw["session.generate"]({ params: { sessionID: input["sessionID"] }, payload: { prompt: input["prompt"] } }).pipe(
|
||||
Effect.mapError(mapClientError),
|
||||
Effect.map((value) => value.data),
|
||||
),
|
||||
)
|
||||
|
||||
const Endpoint5_26 = (raw: RawClient["server.session"]) => (input: Endpoint5_26Input) =>
|
||||
preserveStream<Endpoint5_26Output>()(
|
||||
const Endpoint5_27 = (raw: RawClient["server.session"]) => (input: Endpoint5_27Input) =>
|
||||
preserveStream<Endpoint5_27Output>()(
|
||||
Stream.unwrap(
|
||||
raw["session.log"]({
|
||||
params: { sessionID: input["sessionID"] },
|
||||
@@ -532,18 +539,18 @@ const Endpoint5_26 = (raw: RawClient["server.session"]) => (input: Endpoint5_26I
|
||||
),
|
||||
)
|
||||
|
||||
const Endpoint5_27 = (raw: RawClient["server.session"]) => (input: Endpoint5_27Input) =>
|
||||
preserveEffect<Endpoint5_27Output>()(
|
||||
raw["session.interrupt"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)),
|
||||
)
|
||||
|
||||
const Endpoint5_28 = (raw: RawClient["server.session"]) => (input: Endpoint5_28Input) =>
|
||||
preserveEffect<Endpoint5_28Output>()(
|
||||
raw["session.background"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)),
|
||||
raw["session.interrupt"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)),
|
||||
)
|
||||
|
||||
const Endpoint5_29 = (raw: RawClient["server.session"]) => (input: Endpoint5_29Input) =>
|
||||
preserveEffect<Endpoint5_29Output>()(
|
||||
raw["session.background"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)),
|
||||
)
|
||||
|
||||
const Endpoint5_30 = (raw: RawClient["server.session"]) => (input: Endpoint5_30Input) =>
|
||||
preserveEffect<Endpoint5_30Output>()(
|
||||
raw["session.message"]({ params: { sessionID: input["sessionID"], messageID: input["messageID"] } }).pipe(
|
||||
Effect.mapError(mapClientError),
|
||||
Effect.map((value) => value.data),
|
||||
@@ -560,23 +567,24 @@ const adaptGroup5 = (raw: RawClient["server.session"]) => ({
|
||||
switchAgent: Endpoint5_6(raw),
|
||||
switchModel: Endpoint5_7(raw),
|
||||
rename: Endpoint5_8(raw),
|
||||
move: Endpoint5_9(raw),
|
||||
prompt: Endpoint5_10(raw),
|
||||
command: Endpoint5_11(raw),
|
||||
skill: Endpoint5_12(raw),
|
||||
synthetic: Endpoint5_13(raw),
|
||||
shell: Endpoint5_14(raw),
|
||||
compact: Endpoint5_15(raw),
|
||||
wait: Endpoint5_16(raw),
|
||||
revert: { stage: Endpoint5_17(raw), clear: Endpoint5_18(raw), commit: Endpoint5_19(raw) },
|
||||
context: Endpoint5_20(raw),
|
||||
pending: { list: Endpoint5_21(raw) },
|
||||
instructions: { entry: { list: Endpoint5_22(raw), put: Endpoint5_23(raw), remove: Endpoint5_24(raw) } },
|
||||
generate: Endpoint5_25(raw),
|
||||
log: Endpoint5_26(raw),
|
||||
interrupt: Endpoint5_27(raw),
|
||||
background: Endpoint5_28(raw),
|
||||
message: Endpoint5_29(raw),
|
||||
archive: Endpoint5_9(raw),
|
||||
move: Endpoint5_10(raw),
|
||||
prompt: Endpoint5_11(raw),
|
||||
command: Endpoint5_12(raw),
|
||||
skill: Endpoint5_13(raw),
|
||||
synthetic: Endpoint5_14(raw),
|
||||
shell: Endpoint5_15(raw),
|
||||
compact: Endpoint5_16(raw),
|
||||
wait: Endpoint5_17(raw),
|
||||
revert: { stage: Endpoint5_18(raw), clear: Endpoint5_19(raw), commit: Endpoint5_20(raw) },
|
||||
context: Endpoint5_21(raw),
|
||||
pending: { list: Endpoint5_22(raw) },
|
||||
instructions: { entry: { list: Endpoint5_23(raw), put: Endpoint5_24(raw), remove: Endpoint5_25(raw) } },
|
||||
generate: Endpoint5_26(raw),
|
||||
log: Endpoint5_27(raw),
|
||||
interrupt: Endpoint5_28(raw),
|
||||
background: Endpoint5_29(raw),
|
||||
message: Endpoint5_30(raw),
|
||||
})
|
||||
|
||||
const Endpoint6_0 = (raw: RawClient["server.message"]) => (input: Endpoint6_0Input) =>
|
||||
|
||||
@@ -28,6 +28,8 @@ import type {
|
||||
SessionSwitchModelOutput,
|
||||
SessionRenameInput,
|
||||
SessionRenameOutput,
|
||||
SessionArchiveInput,
|
||||
SessionArchiveOutput,
|
||||
SessionMoveInput,
|
||||
SessionMoveOutput,
|
||||
SessionPromptInput,
|
||||
@@ -553,6 +555,17 @@ export function make(options: ClientOptions) {
|
||||
},
|
||||
requestOptions,
|
||||
),
|
||||
archive: (input: SessionArchiveInput, requestOptions?: RequestOptions) =>
|
||||
request<SessionArchiveOutput>(
|
||||
{
|
||||
method: "POST",
|
||||
path: `/api/session/${encodeURIComponent(input.sessionID)}/archive`,
|
||||
successStatus: 204,
|
||||
declaredStatuses: [404, 400, 401],
|
||||
empty: true,
|
||||
},
|
||||
requestOptions,
|
||||
),
|
||||
move: (input: SessionMoveInput, requestOptions?: RequestOptions) =>
|
||||
request<SessionMoveOutput>(
|
||||
{
|
||||
|
||||
@@ -611,6 +611,16 @@ export type SessionRenamed = {
|
||||
data: { sessionID: string; title: string }
|
||||
}
|
||||
|
||||
export type SessionArchived = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
type: "session.archived"
|
||||
durable: { aggregateID: string; seq: number; version: 1 }
|
||||
location?: LocationRef
|
||||
data: { sessionID: string }
|
||||
}
|
||||
|
||||
export type SessionDeleted = {
|
||||
id: string
|
||||
created: number
|
||||
@@ -2159,6 +2169,7 @@ export type SessionEventDurable =
|
||||
| SessionModelSelected
|
||||
| SessionMoved
|
||||
| SessionRenamed
|
||||
| SessionArchived
|
||||
| SessionDeleted
|
||||
| SessionForked
|
||||
| SessionInputPromoted
|
||||
@@ -2253,6 +2264,7 @@ export type V2Event =
|
||||
| SessionModelSelected
|
||||
| SessionMoved
|
||||
| SessionRenamed
|
||||
| SessionArchived
|
||||
| SessionUsageUpdated
|
||||
| SessionDeleted
|
||||
| SessionForked
|
||||
@@ -2730,6 +2742,10 @@ export type SessionRenameInput = {
|
||||
|
||||
export type SessionRenameOutput = void
|
||||
|
||||
export type SessionArchiveInput = { readonly sessionID: { readonly sessionID: string }["sessionID"] }
|
||||
|
||||
export type SessionArchiveOutput = void
|
||||
|
||||
export type SessionMoveInput = {
|
||||
readonly sessionID: { readonly sessionID: string }["sessionID"]
|
||||
readonly directory: { readonly directory: string; readonly workspaceID?: string }["directory"]
|
||||
|
||||
@@ -432,6 +432,7 @@ test("session methods use the public HTTP contract", async () => {
|
||||
sessionID: "ses_test",
|
||||
model: { id: "claude", providerID: "anthropic" },
|
||||
})
|
||||
await client.session.archive({ sessionID: "ses_test" })
|
||||
const admitted = await client.session.prompt({
|
||||
sessionID: "ses_test",
|
||||
text: "Hello",
|
||||
@@ -467,6 +468,7 @@ test("session methods use the public HTTP contract", async () => {
|
||||
["POST", "http://localhost:3000/api/session"],
|
||||
["POST", "http://localhost:3000/api/session/ses_test/agent"],
|
||||
["POST", "http://localhost:3000/api/session/ses_test/model"],
|
||||
["POST", "http://localhost:3000/api/session/ses_test/archive"],
|
||||
["POST", "http://localhost:3000/api/session/ses_test/prompt"],
|
||||
["POST", "http://localhost:3000/api/session/ses_test/generate"],
|
||||
["POST", "http://localhost:3000/api/session/ses_test/synthetic"],
|
||||
|
||||
@@ -227,6 +227,7 @@ export interface Interface {
|
||||
model: Model.Ref
|
||||
}) => Effect.Effect<void, NotFoundError>
|
||||
readonly rename: (input: { sessionID: SessionSchema.ID; title: string }) => Effect.Effect<void, NotFoundError>
|
||||
readonly archive: (sessionID: SessionSchema.ID) => Effect.Effect<void, NotFoundError>
|
||||
readonly move: (input: {
|
||||
sessionID: SessionSchema.ID
|
||||
directory: AbsolutePath
|
||||
@@ -706,6 +707,11 @@ const layer = Layer.effect(
|
||||
title: input.title,
|
||||
})
|
||||
}),
|
||||
archive: Effect.fn("Session.archive")(function* (sessionID) {
|
||||
const session = yield* result.get(sessionID)
|
||||
if (session.time.archived) return
|
||||
yield* bus.publish(SessionEvent.Archived, { sessionID })
|
||||
}),
|
||||
move: Effect.fn("Session.move")(function* (input) {
|
||||
const current = yield* result.get(input.sessionID)
|
||||
const value = input.directory.trim()
|
||||
|
||||
@@ -53,7 +53,7 @@ export function fromRow(row: typeof SessionTable.$inferSelect): SessionSchema.In
|
||||
time: {
|
||||
created: DateTime.makeUnsafe(row.time_created),
|
||||
updated: DateTime.makeUnsafe(row.time_updated),
|
||||
archived: row.time_archived ? DateTime.makeUnsafe(row.time_archived) : undefined,
|
||||
archived: row.time_archived !== null ? DateTime.makeUnsafe(row.time_archived) : undefined,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -171,6 +171,7 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
|
||||
},
|
||||
"session.moved": () => Effect.void,
|
||||
"session.renamed": () => Effect.void,
|
||||
"session.archived": () => Effect.void,
|
||||
"session.deleted": () => Effect.void,
|
||||
"session.forked": () => Effect.void,
|
||||
"session.input.promoted": () => Effect.void,
|
||||
|
||||
@@ -609,6 +609,17 @@ const layer = Layer.effectDiscard(
|
||||
.run()
|
||||
.pipe(Effect.orDie),
|
||||
)
|
||||
yield* bus.project(SessionEvent.Archived, (event) =>
|
||||
db
|
||||
.update(SessionTable)
|
||||
.set({
|
||||
time_archived: DateTime.toEpochMillis(event.created),
|
||||
time_updated: DateTime.toEpochMillis(event.created),
|
||||
})
|
||||
.where(eq(SessionTable.id, event.data.sessionID))
|
||||
.run()
|
||||
.pipe(Effect.orDie),
|
||||
)
|
||||
yield* bus.project(SessionEvent.UsageRecorded, (event) => applyUsage(db, event.data.sessionID, event.data))
|
||||
yield* bus.project(SessionEvent.Forked, (event) => projectFork(db, event))
|
||||
yield* bus.project(SessionEvent.InputPromoted, (event) =>
|
||||
|
||||
@@ -660,4 +660,32 @@ describe("Session.create", () => {
|
||||
).toBe("Session.NotFoundError")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("archives a Session through one durable event", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* Session.Service
|
||||
const created = yield* session.create({ location })
|
||||
|
||||
yield* session.archive(created.id)
|
||||
yield* session.archive(created.id)
|
||||
|
||||
expect((yield* session.get(created.id)).time.archived).toBeDefined()
|
||||
const events = Array.from(yield* logEvents(session, created.id).pipe(Stream.runCollect))
|
||||
expect(events.map((event) => event.type)).toContain("session.archived")
|
||||
expect(events.filter((event) => event.type === "session.archived")).toHaveLength(1)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("rejects archiving a missing Session", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* Session.Service
|
||||
|
||||
expect(
|
||||
yield* session.archive(Session.ID.make("ses_missing_archive")).pipe(
|
||||
Effect.flip,
|
||||
Effect.map((error) => error._tag),
|
||||
),
|
||||
).toBe("Session.NotFoundError")
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -19,7 +19,7 @@ import type {
|
||||
ShellInfo,
|
||||
SkillInfo,
|
||||
} from "@opencode-ai/client"
|
||||
import type { KeyEvent, Renderable } from "@opentui/core"
|
||||
import type { CliRenderer, KeyEvent, Renderable } from "@opentui/core"
|
||||
import type { JSX } from "@opentui/solid"
|
||||
|
||||
interface LocationCollection<Value> {
|
||||
@@ -115,6 +115,118 @@ export interface Page {
|
||||
|
||||
export type Slot = (props: Record<string, any>) => JSX.Element
|
||||
|
||||
export type ToastVariant = "info" | "success" | "warning" | "error"
|
||||
|
||||
export interface ToastOptions {
|
||||
readonly title?: string
|
||||
readonly message: string
|
||||
readonly variant?: ToastVariant
|
||||
readonly duration?: number
|
||||
}
|
||||
|
||||
export interface Toast {
|
||||
show(options: ToastOptions): void
|
||||
}
|
||||
|
||||
export type AttentionWhen = "always" | "focused" | "blurred"
|
||||
export type AttentionSoundName = "default" | "question" | "permission" | "error" | "done" | "subagent_done"
|
||||
|
||||
export type AttentionNotification =
|
||||
| boolean
|
||||
| {
|
||||
readonly when?: AttentionWhen
|
||||
}
|
||||
|
||||
export type AttentionSound =
|
||||
| boolean
|
||||
| {
|
||||
readonly name?: AttentionSoundName
|
||||
readonly volume?: number
|
||||
readonly when?: AttentionWhen
|
||||
}
|
||||
|
||||
export interface AttentionNotifyOptions {
|
||||
readonly title?: string
|
||||
readonly message: string
|
||||
readonly notification?: AttentionNotification
|
||||
readonly sound?: AttentionSound
|
||||
}
|
||||
|
||||
export type AttentionNotifySkipReason =
|
||||
| "attention_disabled"
|
||||
| "empty_message"
|
||||
| "blurred"
|
||||
| "focused"
|
||||
| "focus_unknown"
|
||||
| "renderer_destroyed"
|
||||
|
||||
export interface AttentionNotifyResult {
|
||||
readonly ok: boolean
|
||||
readonly notification: boolean
|
||||
readonly sound: boolean
|
||||
readonly skipped?: AttentionNotifySkipReason
|
||||
}
|
||||
|
||||
export interface Attention {
|
||||
notify(options: AttentionNotifyOptions): Promise<AttentionNotifyResult>
|
||||
}
|
||||
|
||||
export type DialogSize = "medium" | "large" | "xlarge"
|
||||
|
||||
export interface DialogOptions {
|
||||
readonly size?: DialogSize
|
||||
readonly centered?: boolean
|
||||
}
|
||||
|
||||
export interface DialogAlertOptions {
|
||||
readonly title: string
|
||||
readonly message: string
|
||||
}
|
||||
|
||||
export interface DialogConfirmOptions {
|
||||
readonly title: string
|
||||
readonly message: string
|
||||
readonly label?: {
|
||||
readonly confirm?: string
|
||||
readonly cancel?: string
|
||||
}
|
||||
}
|
||||
|
||||
export interface DialogPromptOptions {
|
||||
readonly title: string
|
||||
readonly description?: string
|
||||
readonly placeholder?: string
|
||||
readonly value?: string
|
||||
}
|
||||
|
||||
export interface DialogSelectOption<Value> {
|
||||
readonly title: string
|
||||
readonly value: Value
|
||||
readonly description?: string
|
||||
readonly category?: string
|
||||
readonly disabled?: boolean
|
||||
}
|
||||
|
||||
export interface DialogSelectOptions<Value> {
|
||||
readonly title: string
|
||||
readonly placeholder?: string
|
||||
readonly options: readonly DialogSelectOption<Value>[]
|
||||
readonly current?: Value
|
||||
}
|
||||
|
||||
export interface Dialog {
|
||||
/** Shows a dialog and returns a function that closes it. */
|
||||
show(render: () => JSX.Element, onClose?: () => void): () => void
|
||||
/** Sets the presentation options for this plugin's active dialog. */
|
||||
set(options: DialogOptions): void
|
||||
/** Closes this plugin's active dialog. */
|
||||
clear(): void
|
||||
alert(options: DialogAlertOptions): Promise<void>
|
||||
confirm(options: DialogConfirmOptions): Promise<boolean | undefined>
|
||||
prompt(options: DialogPromptOptions): Promise<string | undefined>
|
||||
select<Value>(options: DialogSelectOptions<Value>): Promise<Value | undefined>
|
||||
}
|
||||
|
||||
export interface KeymapCommand {
|
||||
/** Stable command and config keybind identifier. Omit for an inline command. */
|
||||
readonly id?: string
|
||||
@@ -158,13 +270,32 @@ export interface KeymapLayer {
|
||||
readonly bindings?: readonly string[]
|
||||
}
|
||||
|
||||
export interface KeymapPending {
|
||||
readonly key: string
|
||||
readonly token?: string
|
||||
}
|
||||
|
||||
export interface KeymapActive {
|
||||
readonly key: string
|
||||
readonly title?: string
|
||||
readonly description?: string
|
||||
readonly group?: string
|
||||
readonly continues: boolean
|
||||
}
|
||||
|
||||
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, input?: string): void
|
||||
/** Returns the formatted shortcut for a registered command. */
|
||||
shortcut(id: string): string | undefined
|
||||
/** Returns every formatted shortcut for a registered command. */
|
||||
shortcuts(id: string): readonly string[]
|
||||
/** Returns the currently reachable commands. Reactive when read in a Solid computation. */
|
||||
commands(): readonly KeymapCommand[]
|
||||
/** Returns the pending key sequence. Reactive when read in a Solid computation. */
|
||||
pending(): readonly KeymapPending[]
|
||||
/** Returns bindings reachable from the pending key sequence. Reactive when read in a Solid computation. */
|
||||
active(): readonly KeymapActive[]
|
||||
/** Controls mutually exclusive OpenCode input modes. */
|
||||
readonly mode: {
|
||||
/** Returns the active mode. */
|
||||
@@ -175,6 +306,8 @@ export interface Keymap {
|
||||
}
|
||||
|
||||
export interface UI {
|
||||
readonly dialog: Dialog
|
||||
readonly toast: Toast
|
||||
readonly router: {
|
||||
register(page: Page): () => void
|
||||
navigate(destination: Destination): void
|
||||
@@ -186,8 +319,11 @@ export interface UI {
|
||||
export interface Context {
|
||||
readonly options: Readonly<Record<string, any>>
|
||||
readonly location: LocationRef | undefined
|
||||
readonly renderer: CliRenderer
|
||||
readonly client: OpenCodeClient
|
||||
readonly data: Data
|
||||
readonly attention: Attention
|
||||
readonly theme: any
|
||||
readonly keymap: Keymap
|
||||
readonly ui: UI
|
||||
}
|
||||
|
||||
@@ -269,6 +269,21 @@ export const makeSessionGroup = <I extends HttpApiMiddleware.AnyId, S>(sessionLo
|
||||
}),
|
||||
),
|
||||
)
|
||||
.add(
|
||||
HttpApiEndpoint.post("session.archive", "/api/session/:sessionID/archive", {
|
||||
params: { sessionID: Session.ID },
|
||||
success: HttpApiSchema.NoContent,
|
||||
error: SessionNotFoundError,
|
||||
})
|
||||
.middleware(sessionLocationMiddleware)
|
||||
.annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "v2.session.archive",
|
||||
summary: "Archive session",
|
||||
description: "Archive a session.",
|
||||
}),
|
||||
),
|
||||
)
|
||||
.add(
|
||||
HttpApiEndpoint.post("session.move", "/api/session/:sessionID/move", {
|
||||
params: { sessionID: Session.ID },
|
||||
|
||||
@@ -86,6 +86,13 @@ export const Renamed = Event.durable({
|
||||
})
|
||||
export type Renamed = typeof Renamed.Type
|
||||
|
||||
export const Archived = Event.durable({
|
||||
type: "session.archived",
|
||||
...options,
|
||||
schema: Base,
|
||||
})
|
||||
export type Archived = typeof Archived.Type
|
||||
|
||||
export const UsageRecorded = Event.durable({
|
||||
type: "session.usage.recorded",
|
||||
...options,
|
||||
@@ -550,6 +557,7 @@ export const Definitions = Event.inventory(
|
||||
ModelSelected,
|
||||
Moved,
|
||||
Renamed,
|
||||
Archived,
|
||||
UsageUpdated,
|
||||
Deleted,
|
||||
Forked,
|
||||
|
||||
@@ -104,6 +104,7 @@ describe("public event manifest", () => {
|
||||
"session.model.selected.1",
|
||||
"session.moved.1",
|
||||
"session.renamed.1",
|
||||
"session.archived.1",
|
||||
"session.usage.recorded.1",
|
||||
"session.forked.2",
|
||||
"session.input.promoted.1",
|
||||
|
||||
@@ -201,6 +201,22 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl
|
||||
return HttpApiSchema.NoContent.make()
|
||||
}),
|
||||
)
|
||||
.handle(
|
||||
"session.archive",
|
||||
Effect.fn(function* (ctx) {
|
||||
yield* session.archive(ctx.params.sessionID).pipe(
|
||||
Effect.catchTag("Session.NotFoundError", (error) =>
|
||||
Effect.fail(
|
||||
new SessionNotFoundError({
|
||||
sessionID: error.sessionID,
|
||||
message: `Session not found: ${error.sessionID}`,
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
return HttpApiSchema.NoContent.make()
|
||||
}),
|
||||
)
|
||||
.handle(
|
||||
"session.move",
|
||||
Effect.fn(function* (ctx) {
|
||||
|
||||
+15
-12
@@ -88,6 +88,7 @@ import { DialogVariant } from "./component/dialog-variant"
|
||||
import { win32DisableProcessedInput, win32FlushInputBuffer } from "./terminal-win32"
|
||||
import { destroyRenderer } from "./util/renderer"
|
||||
import { cliErrorMessage, errorFormat } from "./util/error"
|
||||
import { AttentionProvider } from "./context/attention"
|
||||
|
||||
registerOpencodeSpinner()
|
||||
|
||||
@@ -346,18 +347,20 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
|
||||
<PromptHistoryProvider>
|
||||
<PromptRefProvider>
|
||||
<EditorContextProvider>
|
||||
<PluginProvider packages={input.packages}>
|
||||
<App
|
||||
pair={
|
||||
input.server.endpoint.auth
|
||||
? input.server.endpoint.auth
|
||||
: {
|
||||
username: "opencode",
|
||||
password: "",
|
||||
}
|
||||
}
|
||||
/>
|
||||
</PluginProvider>
|
||||
<AttentionProvider>
|
||||
<PluginProvider packages={input.packages}>
|
||||
<App
|
||||
pair={
|
||||
input.server.endpoint.auth
|
||||
? input.server.endpoint.auth
|
||||
: {
|
||||
username: "opencode",
|
||||
password: "",
|
||||
}
|
||||
}
|
||||
/>
|
||||
</PluginProvider>
|
||||
</AttentionProvider>
|
||||
</EditorContextProvider>
|
||||
</PromptRefProvider>
|
||||
</PromptHistoryProvider>
|
||||
|
||||
@@ -77,6 +77,7 @@ export function DevToolsBar() {
|
||||
const runtime = createMemo(() => runtimeStatus(frontendSamples()))
|
||||
const timing = () => config.data.debug?.timing ?? false
|
||||
const turnTokens = () => config.data.debug?.turn_tokens ?? false
|
||||
const verboseTurnTokens = () => turnTokens() === "verbose"
|
||||
|
||||
const offEscape = keymap.intercept(
|
||||
"key",
|
||||
@@ -380,6 +381,16 @@ export function DevToolsBar() {
|
||||
>
|
||||
{turnTokens() ? "[x]" : "[ ]"} Turn token usage
|
||||
</Action>
|
||||
<Action
|
||||
onClick={() =>
|
||||
void config.update((draft) => {
|
||||
draft.debug = { ...draft.debug, turn_tokens: verboseTurnTokens() ? true : "verbose" }
|
||||
})
|
||||
}
|
||||
hoverBackground
|
||||
>
|
||||
{verboseTurnTokens() ? "[x]" : "[ ]"} Turn token usage (verbose)
|
||||
</Action>
|
||||
</box>
|
||||
<For each={groups()}>
|
||||
{(group) => (
|
||||
|
||||
@@ -254,8 +254,8 @@ export const settings: Setting[] = [
|
||||
category: "Debug",
|
||||
path: ["debug", "turn_tokens"],
|
||||
default: false,
|
||||
values: [false, true],
|
||||
labels: ["off", "on"],
|
||||
values: [false, true, "verbose"],
|
||||
labels: ["off", "on", "verbose"],
|
||||
keywords: ["tokens", "usage", "debug"],
|
||||
},
|
||||
]
|
||||
|
||||
@@ -159,7 +159,9 @@ export const Info = Schema.Struct({
|
||||
Schema.Struct({
|
||||
devtools: Schema.optional(Schema.Boolean).annotate({ description: "Show the DevTools debug bar" }),
|
||||
timing: Schema.optional(Schema.Boolean).annotate({ description: "Show time-to-first-draw diagnostics" }),
|
||||
turn_tokens: Schema.optional(Schema.Boolean).annotate({ description: "Show per-turn token usage diagnostics" }),
|
||||
turn_tokens: Schema.optional(Schema.Union([Schema.Boolean, Schema.Literal("verbose")])).annotate({
|
||||
description: "Show per-turn token usage diagnostics, optionally with tool call inputs",
|
||||
}),
|
||||
}),
|
||||
).annotate({ description: "Debugging settings" }),
|
||||
animations: Schema.optional(Schema.Boolean).annotate({ description: "Enable interface animations" }),
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import type { Attention } from "@opencode-ai/plugin/tui/context"
|
||||
import { useRenderer } from "@opentui/solid"
|
||||
import { createContext, onCleanup, useContext, type ParentProps } from "solid-js"
|
||||
import { createTuiAttention } from "../attention"
|
||||
import { useConfig } from "../config"
|
||||
|
||||
const AttentionContext = createContext<Attention>()
|
||||
|
||||
export function AttentionProvider(props: ParentProps) {
|
||||
const config = useConfig()
|
||||
const attention = createTuiAttention({
|
||||
renderer: useRenderer(),
|
||||
config: config.data,
|
||||
update: config.update,
|
||||
})
|
||||
onCleanup(() => attention.dispose())
|
||||
return <AttentionContext.Provider value={attention}>{props.children}</AttentionContext.Provider>
|
||||
}
|
||||
|
||||
export function useAttention() {
|
||||
const attention = useContext(AttentionContext)
|
||||
if (!attention) throw new Error("AttentionProvider is missing")
|
||||
return attention
|
||||
}
|
||||
@@ -761,6 +761,12 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
||||
})
|
||||
break
|
||||
case "session.compaction.ended":
|
||||
setStore(
|
||||
"session",
|
||||
"pending",
|
||||
event.data.sessionID,
|
||||
(store.session.pending[event.data.sessionID] ?? []).filter((item) => item.type !== "compaction"),
|
||||
)
|
||||
message.update(event.data.sessionID, (draft, index) => {
|
||||
const position = draft.findLastIndex((item) => item.type === "compaction" && item.status === "running")
|
||||
const current = draft[position]
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { KeymapCommand, KeymapLayer } from "@opencode-ai/plugin/tui/context"
|
||||
import type { KeymapActive, KeymapCommand, KeymapLayer, KeymapPending } from "@opencode-ai/plugin/tui/context"
|
||||
import { InputRenderable, TextareaRenderable, type KeyEvent, type Renderable } from "@opentui/core"
|
||||
import { stringifyKeyStroke, type Binding, type CommandContext } from "@opentui/keymap"
|
||||
import {
|
||||
@@ -255,13 +255,19 @@ function useShortcuts() {
|
||||
const commands = keymap.getCommands({ visibility: "registered" }).map((command) => command.name)
|
||||
const bindings = keymap.getCommandBindings({ visibility: "registered", commands })
|
||||
return new Map(
|
||||
commands.map((id) => [
|
||||
id,
|
||||
{
|
||||
first: formatKeySequence(bindings.get(id)?.[0]?.sequence, formatOptions(value.config)),
|
||||
all: formatCommandBindings(bindings.get(id) ?? [], formatOptions(value.config)),
|
||||
},
|
||||
]),
|
||||
commands.map((id) => {
|
||||
const current = bindings.get(id) ?? []
|
||||
return [
|
||||
id,
|
||||
{
|
||||
first: formatKeySequence(current[0]?.sequence, formatOptions(value.config)),
|
||||
all: formatCommandBindings(current, formatOptions(value.config)),
|
||||
list: current
|
||||
.map((binding) => formatKeySequence(binding.sequence, formatOptions(value.config)))
|
||||
.filter((shortcut): shortcut is string => shortcut !== undefined),
|
||||
},
|
||||
]
|
||||
}),
|
||||
)
|
||||
})
|
||||
return {
|
||||
@@ -271,6 +277,9 @@ function useShortcuts() {
|
||||
all(id: string) {
|
||||
return shortcuts().get(id)?.all
|
||||
},
|
||||
list(id: string) {
|
||||
return shortcuts().get(id)?.list ?? []
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -328,6 +337,41 @@ function useActiveKeys() {
|
||||
return useKeymapSelector((keymap) => keymap.getActiveKeys({ includeMetadata: true }))
|
||||
}
|
||||
|
||||
function useState() {
|
||||
const value = useValue()
|
||||
const commands = useCommands()
|
||||
const pending = usePendingSequence()
|
||||
const active = useActiveKeys()
|
||||
return {
|
||||
commands,
|
||||
pending: (): readonly KeymapPending[] =>
|
||||
pending().map((item) => ({
|
||||
key: formatKeySequence([item], formatOptions(value.config)) ?? "",
|
||||
...(item.tokenName ? { token: item.tokenName } : {}),
|
||||
})),
|
||||
active: (): readonly KeymapActive[] =>
|
||||
active().map((item) => ({
|
||||
key:
|
||||
formatKeySequence(
|
||||
[{ stroke: item.stroke, display: item.display, tokenName: item.tokenName }],
|
||||
formatOptions(value.config),
|
||||
) ?? "",
|
||||
...(typeof item.commandAttrs?.title === "string" ? { title: item.commandAttrs.title } : {}),
|
||||
...(typeof item.bindingAttrs?.desc === "string"
|
||||
? { description: item.bindingAttrs.desc }
|
||||
: typeof item.commandAttrs?.desc === "string"
|
||||
? { description: item.commandAttrs.desc }
|
||||
: {}),
|
||||
...(typeof item.commandAttrs?.category === "string"
|
||||
? { group: item.commandAttrs.category }
|
||||
: typeof item.bindingAttrs?.group === "string"
|
||||
? { group: item.bindingAttrs.group }
|
||||
: {}),
|
||||
continues: item.continues,
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
function useValue() {
|
||||
const value = useContext(Context)
|
||||
if (!value) throw new Error("Keymap.Provider is missing")
|
||||
@@ -344,6 +388,7 @@ export const Keymap = {
|
||||
useCommands,
|
||||
usePendingSequence,
|
||||
useActiveKeys,
|
||||
useState,
|
||||
} as const
|
||||
|
||||
function createMode(keymap: OpenTuiKeymap) {
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import type { TuiPlugin, TuiPluginApi, TuiPluginModule } from "@opencode-ai/plugin/v1/tui"
|
||||
import type { PluginRuntime } from "../plugin/runtime"
|
||||
import Notifications from "./system/notifications"
|
||||
import PluginManager from "./system/plugins"
|
||||
import WhichKey from "./system/which-key"
|
||||
|
||||
@@ -11,7 +10,7 @@ export type BuiltinTuiPlugin = Omit<TuiPluginModule, "id"> & {
|
||||
}
|
||||
|
||||
export function createBuiltinPlugins(): BuiltinTuiPlugin[] {
|
||||
return [Notifications, PluginManager, WhichKey]
|
||||
return [PluginManager, WhichKey]
|
||||
}
|
||||
|
||||
export async function loadBuiltinPlugins(api: TuiPluginApi, runtime: PluginRuntime) {
|
||||
|
||||
@@ -2,13 +2,11 @@ import { Plugin } from "@opencode-ai/plugin/tui"
|
||||
import { createMemo, Match, Show, Switch } from "solid-js"
|
||||
import { useTerminalDimensions } from "@opentui/solid"
|
||||
import { useTuiApp, useTuiPaths } from "../../context/runtime"
|
||||
import { useTheme } from "../../context/theme"
|
||||
import { abbreviateHome } from "../../runtime"
|
||||
import { FilePath } from "../../ui/file-path"
|
||||
import { stringWidth } from "../../util/string-width"
|
||||
|
||||
function Directory(props: { context: Plugin.Context; maxWidth: number }) {
|
||||
const { themeV2 } = useTheme()
|
||||
const paths = useTuiPaths()
|
||||
const directory = createMemo(() =>
|
||||
props.context.location ? abbreviateHome(props.context.location.directory, paths.home) : undefined,
|
||||
@@ -16,13 +14,12 @@ function Directory(props: { context: Plugin.Context; maxWidth: number }) {
|
||||
|
||||
return (
|
||||
<Show when={directory()}>
|
||||
{(value) => <FilePath value={value()} maxWidth={props.maxWidth} fg={themeV2.text.subdued} />}
|
||||
{(value) => <FilePath value={value()} maxWidth={props.maxWidth} fg={props.context.theme.text.subdued} />}
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
|
||||
function Mcp(props: { context: Plugin.Context }) {
|
||||
const { themeV2 } = useTheme()
|
||||
const list = createMemo(() => props.context.data.location.mcp.server.list(props.context.location) ?? [])
|
||||
const failed = createMemo(() => list().some((item) => item.status.status === "failed"))
|
||||
const count = createMemo(() => list().filter((item) => item.status.status === "connected").length)
|
||||
@@ -30,25 +27,31 @@ function Mcp(props: { context: Plugin.Context }) {
|
||||
return (
|
||||
<Show when={list().length}>
|
||||
<box gap={1} flexDirection="row" flexShrink={0}>
|
||||
<text fg={themeV2.text.default}>
|
||||
<text fg={props.context.theme.text.default}>
|
||||
<Switch>
|
||||
<Match when={failed()}>
|
||||
<span style={{ fg: themeV2.text.feedback.error.default }}>⊙ </span>
|
||||
<span style={{ fg: props.context.theme.text.feedback.error.default }}>⊙ </span>
|
||||
</Match>
|
||||
<Match when={true}>
|
||||
<span style={{ fg: count() > 0 ? themeV2.text.feedback.success.default : themeV2.text.subdued }}>⊙ </span>
|
||||
<span
|
||||
style={{
|
||||
fg:
|
||||
count() > 0 ? props.context.theme.text.feedback.success.default : props.context.theme.text.subdued,
|
||||
}}
|
||||
>
|
||||
⊙{" "}
|
||||
</span>
|
||||
</Match>
|
||||
</Switch>
|
||||
{count()} MCP
|
||||
</text>
|
||||
<text fg={themeV2.text.subdued}>/status</text>
|
||||
<text fg={props.context.theme.text.subdued}>/status</text>
|
||||
</box>
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
|
||||
function View(props: { context: Plugin.Context }) {
|
||||
const { themeV2 } = useTheme()
|
||||
const app = useTuiApp()
|
||||
const dimensions = useTerminalDimensions()
|
||||
const mcpWidth = createMemo(() => {
|
||||
@@ -76,7 +79,7 @@ function View(props: { context: Plugin.Context }) {
|
||||
<Mcp context={props.context} />
|
||||
<box flexGrow={1} />
|
||||
<box flexShrink={0}>
|
||||
<text fg={themeV2.text.subdued}>{app.version}</text>
|
||||
<text fg={props.context.theme.text.subdued}>{app.version}</text>
|
||||
</box>
|
||||
</box>
|
||||
)
|
||||
|
||||
@@ -83,7 +83,7 @@ function diffSourceLabel(mode: DiffMode) {
|
||||
function DiffViewer(props: { context: Plugin.Context }) {
|
||||
const dimensions = useTerminalDimensions()
|
||||
const config = useConfig()
|
||||
const dialog = useDialog()
|
||||
const dialog = props.context.ui.dialog
|
||||
const themeState = useTheme()
|
||||
const themeV2 = themeState.themeV2
|
||||
const params = () => {
|
||||
@@ -141,7 +141,7 @@ function DiffViewer(props: { context: Plugin.Context }) {
|
||||
const fileRows = createMemo(() => flattenFileTree(fileTree(), expandedFileNodes()))
|
||||
const patchFileIndexes = createMemo(() => orderedPatchFileIndexes(flattenFileTree(fileTree())))
|
||||
const focusRunner = (input: Record<DiffViewerFocus, () => void>) => () => input[focus()]()
|
||||
const shortcut = (id: string) => () => props.context.keymap.shortcut(id)
|
||||
const shortcut = (id: string) => () => props.context.keymap.shortcuts(id)[0]
|
||||
const switchFocusShortcut = shortcut("diff.switch_focus")
|
||||
const nextHunkShortcut = shortcut("diff.next_hunk")
|
||||
const previousHunkShortcut = shortcut("diff.previous_hunk")
|
||||
@@ -703,7 +703,7 @@ function DiffViewer(props: { context: Plugin.Context }) {
|
||||
})
|
||||
|
||||
const openSwitchDiffDialog = () => {
|
||||
dialog.replace(() => (
|
||||
dialog.show(() => (
|
||||
<DialogSelect
|
||||
title="Switch source"
|
||||
skipFilter={true}
|
||||
@@ -711,7 +711,7 @@ function DiffViewer(props: { context: Plugin.Context }) {
|
||||
current={mode()}
|
||||
options={switchDiffOptions().map((option) => ({
|
||||
...option,
|
||||
onSelect(dialog) {
|
||||
onSelect() {
|
||||
dialog.clear()
|
||||
props.context.ui.router.navigate({
|
||||
type: "plugin",
|
||||
@@ -729,8 +729,8 @@ function DiffViewer(props: { context: Plugin.Context }) {
|
||||
}
|
||||
|
||||
const openHelpDialog = () => {
|
||||
dialog.replace(() => <DiffViewerHelpDialog context={props.context} />)
|
||||
dialog.setSize("large")
|
||||
dialog.show(() => <DiffViewerHelpDialog context={props.context} />)
|
||||
dialog.set({ size: "large" })
|
||||
}
|
||||
|
||||
props.context.keymap.layer(() => ({
|
||||
@@ -952,7 +952,7 @@ function DiffViewer(props: { context: Plugin.Context }) {
|
||||
|
||||
function DiffViewerHelpDialog(props: { context: Plugin.Context }) {
|
||||
const { themeV2 } = useTheme().contextual("elevated")
|
||||
const shortcut = (id: string) => () => props.context.keymap.shortcut(id)
|
||||
const shortcut = (id: string) => () => props.context.keymap.shortcuts(id)[0]
|
||||
const rows = [
|
||||
{
|
||||
shortcut: () => "q",
|
||||
|
||||
@@ -1,21 +1,19 @@
|
||||
import { Plugin } from "@opencode-ai/plugin/tui"
|
||||
import type { AttentionSoundName } from "@opencode-ai/plugin/tui/context"
|
||||
import type { OpenCodeEvent } from "@opencode-ai/client"
|
||||
import type { TuiAttentionSoundName, TuiPlugin, TuiPluginApi } from "@opencode-ai/plugin/v1/tui"
|
||||
import type { BuiltinTuiPlugin } from "../builtins"
|
||||
|
||||
const id = "internal:notifications"
|
||||
|
||||
type SessionError = Extract<OpenCodeEvent, { type: "session.error" }>["data"]["error"]
|
||||
|
||||
function notify(
|
||||
api: TuiPluginApi,
|
||||
context: Plugin.Context,
|
||||
sessionID: string | undefined,
|
||||
message: string,
|
||||
sound: TuiAttentionSoundName,
|
||||
sound: AttentionSoundName,
|
||||
title?: string,
|
||||
) {
|
||||
const session = sessionID ? api.state.session.get(sessionID) : undefined
|
||||
const session = sessionID ? context.data.session.get(sessionID) : undefined
|
||||
const isSubagent = session?.parentID !== undefined
|
||||
void api.attention.notify({
|
||||
void context.attention.notify({
|
||||
title: title ?? session?.title,
|
||||
message,
|
||||
notification: isSubagent ? false : { when: "blurred" },
|
||||
@@ -32,101 +30,74 @@ function sessionErrorMessage(error: SessionError) {
|
||||
return "Session error"
|
||||
}
|
||||
|
||||
const tui: TuiPlugin = async (api) => {
|
||||
const errored = new Set<string>()
|
||||
const terminal = new Set<string>()
|
||||
const forms = new Set<string>()
|
||||
const questions = new Set<string>()
|
||||
const permissions = new Set<string>()
|
||||
export default Plugin.define({
|
||||
id: "opencode.notifications",
|
||||
setup(context) {
|
||||
const errored = new Set<string>()
|
||||
const terminal = new Set<string>()
|
||||
const forms = new Set<string>()
|
||||
const questions = new Set<string>()
|
||||
const permissions = new Set<string>()
|
||||
|
||||
api.event.on("form.created", (event) => {
|
||||
if (forms.has(event.data.form.id)) return
|
||||
forms.add(event.data.form.id)
|
||||
notify(
|
||||
api,
|
||||
event.data.form.sessionID,
|
||||
"Input needs response",
|
||||
"question",
|
||||
event.data.form.title,
|
||||
)
|
||||
})
|
||||
|
||||
api.event.on("form.replied", (event) => {
|
||||
forms.delete(event.data.id)
|
||||
})
|
||||
|
||||
api.event.on("form.cancelled", (event) => {
|
||||
forms.delete(event.data.id)
|
||||
})
|
||||
|
||||
api.event.on("question.asked", (event) => {
|
||||
if (questions.has(event.data.id)) return
|
||||
questions.add(event.data.id)
|
||||
notify(api, event.data.sessionID, "Question needs input", "question")
|
||||
})
|
||||
|
||||
api.event.on("question.replied", (event) => {
|
||||
questions.delete(event.data.requestID)
|
||||
})
|
||||
|
||||
api.event.on("question.rejected", (event) => {
|
||||
questions.delete(event.data.requestID)
|
||||
})
|
||||
|
||||
api.event.on("permission.asked", (event) => {
|
||||
if (permissions.has(event.data.id)) return
|
||||
permissions.add(event.data.id)
|
||||
notify(api, event.data.sessionID, "Permission needs input", "permission")
|
||||
})
|
||||
|
||||
api.event.on("permission.replied", (event) => {
|
||||
permissions.delete(event.data.requestID)
|
||||
})
|
||||
|
||||
const started = (sessionID: string) => {
|
||||
errored.delete(sessionID)
|
||||
terminal.delete(sessionID)
|
||||
}
|
||||
|
||||
const ended = (sessionID: string) => {
|
||||
if (terminal.has(sessionID)) return
|
||||
terminal.add(sessionID)
|
||||
if (errored.has(sessionID)) {
|
||||
const started = (sessionID: string) => {
|
||||
errored.delete(sessionID)
|
||||
return
|
||||
terminal.delete(sessionID)
|
||||
}
|
||||
const ended = (sessionID: string) => {
|
||||
if (terminal.has(sessionID)) return
|
||||
terminal.add(sessionID)
|
||||
if (errored.has(sessionID)) {
|
||||
errored.delete(sessionID)
|
||||
return
|
||||
}
|
||||
const session = context.data.session.get(sessionID)
|
||||
notify(context, sessionID, "Session done", session?.parentID ? "subagent_done" : "done")
|
||||
}
|
||||
|
||||
const session = api.state.session.get(sessionID)
|
||||
notify(api, sessionID, "Session done", session?.parentID ? "subagent_done" : "done")
|
||||
}
|
||||
const dispose = [
|
||||
context.data.on("form.created", (event) => {
|
||||
if (forms.has(event.data.form.id)) return
|
||||
forms.add(event.data.form.id)
|
||||
notify(context, event.data.form.sessionID, "Input needs response", "question", event.data.form.title)
|
||||
}),
|
||||
context.data.on("form.replied", (event) => forms.delete(event.data.id)),
|
||||
context.data.on("form.cancelled", (event) => forms.delete(event.data.id)),
|
||||
context.data.on("question.asked", (event) => {
|
||||
if (questions.has(event.data.id)) return
|
||||
questions.add(event.data.id)
|
||||
notify(context, event.data.sessionID, "Question needs input", "question")
|
||||
}),
|
||||
context.data.on("question.replied", (event) => questions.delete(event.data.requestID)),
|
||||
context.data.on("question.rejected", (event) => questions.delete(event.data.requestID)),
|
||||
context.data.on("permission.asked", (event) => {
|
||||
if (permissions.has(event.data.id)) return
|
||||
permissions.add(event.data.id)
|
||||
notify(context, event.data.sessionID, "Permission needs input", "permission")
|
||||
}),
|
||||
context.data.on("permission.replied", (event) => permissions.delete(event.data.requestID)),
|
||||
context.data.on("session.execution.started", (event) => started(event.data.sessionID)),
|
||||
context.data.on("session.execution.succeeded", (event) => ended(event.data.sessionID)),
|
||||
context.data.on("session.execution.interrupted", (event) => ended(event.data.sessionID)),
|
||||
context.data.on("session.execution.failed", (event) => {
|
||||
const sessionID = event.data.sessionID
|
||||
if (errored.has(sessionID)) {
|
||||
ended(sessionID)
|
||||
return
|
||||
}
|
||||
errored.add(sessionID)
|
||||
notify(context, sessionID, event.data.error.message, "error")
|
||||
ended(sessionID)
|
||||
}),
|
||||
context.data.on("session.error", (event) => {
|
||||
const sessionID = event.data.sessionID
|
||||
if (!sessionID) return
|
||||
if (context.data.session.status(sessionID) !== "running") return
|
||||
if (errored.has(sessionID)) return
|
||||
errored.add(sessionID)
|
||||
notify(context, sessionID, sessionErrorMessage(event.data.error), "error")
|
||||
}),
|
||||
]
|
||||
|
||||
api.event.on("session.execution.started", (event) => started(event.data.sessionID))
|
||||
api.event.on("session.execution.succeeded", (event) => ended(event.data.sessionID))
|
||||
api.event.on("session.execution.interrupted", (event) => ended(event.data.sessionID))
|
||||
api.event.on("session.execution.failed", (event) => {
|
||||
const sessionID = event.data.sessionID
|
||||
if (errored.has(sessionID)) {
|
||||
ended(sessionID)
|
||||
return
|
||||
}
|
||||
errored.add(sessionID)
|
||||
notify(api, sessionID, event.data.error.message, "error")
|
||||
ended(sessionID)
|
||||
})
|
||||
|
||||
api.event.on("session.error", (event) => {
|
||||
const sessionID = event.data.sessionID
|
||||
if (!sessionID) return
|
||||
if (api.state.session.status(sessionID)?.type !== "busy") return
|
||||
if (errored.has(sessionID)) return
|
||||
errored.add(sessionID)
|
||||
notify(api, sessionID, sessionErrorMessage(event.data.error), "error")
|
||||
})
|
||||
}
|
||||
|
||||
const plugin: BuiltinTuiPlugin = {
|
||||
id,
|
||||
tui,
|
||||
}
|
||||
|
||||
export default plugin
|
||||
return () => dispose.reverse().forEach((cleanup) => cleanup())
|
||||
},
|
||||
})
|
||||
|
||||
@@ -4,6 +4,7 @@ import SidebarFooter from "../feature-plugins/sidebar/footer"
|
||||
import SidebarLsp from "../feature-plugins/sidebar/lsp"
|
||||
import SidebarMcp from "../feature-plugins/sidebar/mcp"
|
||||
import DiffViewer from "../feature-plugins/system/diff-viewer"
|
||||
import Notifications from "../feature-plugins/system/notifications"
|
||||
import Scrap from "../feature-plugins/system/scrap"
|
||||
|
||||
export const builtins = [
|
||||
@@ -12,6 +13,7 @@ export const builtins = [
|
||||
SidebarMcp,
|
||||
SidebarLsp,
|
||||
SidebarFooter,
|
||||
Notifications,
|
||||
Scrap,
|
||||
DiffViewer,
|
||||
]
|
||||
|
||||
@@ -13,8 +13,9 @@ import {
|
||||
import path from "path"
|
||||
import { stat } from "fs/promises"
|
||||
import { fileURLToPath, pathToFileURL } from "url"
|
||||
import type { Context, Page, Slot } from "@opencode-ai/plugin/tui/context"
|
||||
import type { Context, Dialog, Page, Slot, Toast } from "@opencode-ai/plugin/tui/context"
|
||||
import { createStore, produce, reconcile as reconcileStore } from "solid-js/store"
|
||||
import { useRenderer } from "@opentui/solid"
|
||||
import { useConfig } from "../config"
|
||||
import { useClient } from "../context/client"
|
||||
import { useData } from "../context/data"
|
||||
@@ -22,6 +23,14 @@ import { Keymap } from "../context/keymap"
|
||||
import { useRoute } from "../context/route"
|
||||
import { useTuiLifecycle } from "../context/runtime"
|
||||
import { useLocation } from "../context/location"
|
||||
import { useTheme } from "../context/theme"
|
||||
import { DialogAlert } from "../ui/dialog-alert"
|
||||
import { DialogConfirm } from "../ui/dialog-confirm"
|
||||
import { DialogPrompt } from "../ui/dialog-prompt"
|
||||
import { DialogSelect } from "../ui/dialog-select"
|
||||
import { useDialog } from "../ui/dialog"
|
||||
import { useToast } from "../ui/toast"
|
||||
import { useAttention } from "../context/attention"
|
||||
import { builtins } from "./builtins"
|
||||
|
||||
export interface PackageResolver {
|
||||
@@ -57,14 +66,20 @@ type Registration = {
|
||||
const PluginContext = createContext<Value>()
|
||||
|
||||
export function PluginProvider(props: ParentProps<{ packages: PackageResolver }>) {
|
||||
const renderer = useRenderer()
|
||||
const client = useClient()
|
||||
const data = useData()
|
||||
const route = useRoute()
|
||||
const config = useConfig()
|
||||
const keymap = Keymap.use()
|
||||
const shortcuts = Keymap.useShortcuts()
|
||||
const keymapState = Keymap.useState()
|
||||
const lifecycle = useTuiLifecycle()
|
||||
const location = useLocation()
|
||||
const theme = useTheme()
|
||||
const dialog = useDialog()
|
||||
const toast = useToast()
|
||||
const attention = useAttention()
|
||||
const directory = config.path ? path.dirname(config.path) : process.cwd()
|
||||
const [store, setStore] = createStore({
|
||||
ready: false,
|
||||
@@ -82,20 +97,144 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver }>
|
||||
setStore("registrations", id, "cleanups", [])
|
||||
})
|
||||
const owned: Dispose[] = []
|
||||
let activeDialog: symbol | undefined
|
||||
const dialogApi: Dialog = {
|
||||
show(render, onClose) {
|
||||
const token = Symbol()
|
||||
let closed = false
|
||||
activeDialog = token
|
||||
dialog.replace(render, () => {
|
||||
if (closed) return
|
||||
closed = true
|
||||
if (activeDialog === token) activeDialog = undefined
|
||||
onClose?.()
|
||||
})
|
||||
return () => {
|
||||
if (closed || activeDialog !== token) return
|
||||
dialog.clear()
|
||||
}
|
||||
},
|
||||
set(options) {
|
||||
if (!activeDialog) return
|
||||
dialog.setSize(options.size ?? "medium")
|
||||
dialog.setCentered(options.centered ?? false)
|
||||
},
|
||||
clear() {
|
||||
if (!activeDialog) return
|
||||
dialog.clear()
|
||||
},
|
||||
alert(options) {
|
||||
return new Promise<void>((resolve) => {
|
||||
let settled = false
|
||||
const done = () => {
|
||||
if (settled) return
|
||||
settled = true
|
||||
resolve()
|
||||
}
|
||||
dialogApi.show(() => <DialogAlert title={options.title} message={options.message} onConfirm={done} />, done)
|
||||
})
|
||||
},
|
||||
confirm(options) {
|
||||
return new Promise<boolean | undefined>((resolve) => {
|
||||
let settled = false
|
||||
const done = (result: boolean | undefined) => {
|
||||
if (settled) return
|
||||
settled = true
|
||||
resolve(result)
|
||||
}
|
||||
dialogApi.show(
|
||||
() => (
|
||||
<DialogConfirm
|
||||
title={options.title}
|
||||
message={options.message}
|
||||
label={options.label}
|
||||
onConfirm={() => done(true)}
|
||||
onCancel={() => done(false)}
|
||||
/>
|
||||
),
|
||||
() => done(undefined),
|
||||
)
|
||||
})
|
||||
},
|
||||
prompt(options) {
|
||||
return new Promise<string | undefined>((resolve) => {
|
||||
let settled = false
|
||||
const done = (result: string | undefined) => {
|
||||
if (settled) return
|
||||
settled = true
|
||||
resolve(result)
|
||||
}
|
||||
dialogApi.show(
|
||||
() => (
|
||||
<DialogPrompt
|
||||
title={options.title}
|
||||
description={options.description ? () => <text>{options.description}</text> : undefined}
|
||||
placeholder={options.placeholder}
|
||||
value={options.value}
|
||||
onConfirm={(value) => {
|
||||
done(value)
|
||||
dialogApi.clear()
|
||||
}}
|
||||
/>
|
||||
),
|
||||
() => done(undefined),
|
||||
)
|
||||
})
|
||||
},
|
||||
select(options) {
|
||||
return new Promise((resolve) => {
|
||||
let settled = false
|
||||
const done = (result: (typeof options.options)[number]["value"] | undefined) => {
|
||||
if (settled) return
|
||||
settled = true
|
||||
resolve(result)
|
||||
}
|
||||
dialogApi.show(
|
||||
() => (
|
||||
<DialogSelect
|
||||
title={options.title}
|
||||
placeholder={options.placeholder}
|
||||
options={options.options.map((option) => ({ ...option }))}
|
||||
current={options.current}
|
||||
onSelect={(option) => {
|
||||
done(option.value)
|
||||
dialogApi.clear()
|
||||
}}
|
||||
/>
|
||||
),
|
||||
() => done(undefined),
|
||||
)
|
||||
})
|
||||
},
|
||||
}
|
||||
const toastApi: Toast = {
|
||||
show(options) {
|
||||
toast.show({ ...options, variant: options.variant ?? "info" })
|
||||
},
|
||||
}
|
||||
owned.push(async () => dialogApi.clear())
|
||||
const context: Context = {
|
||||
options: item.options ?? {},
|
||||
get location() {
|
||||
return location.current
|
||||
},
|
||||
renderer,
|
||||
client: client.api,
|
||||
data,
|
||||
attention,
|
||||
theme: theme.themeV2,
|
||||
keymap: {
|
||||
layer: Keymap.createLayer,
|
||||
dispatch: keymap.dispatch,
|
||||
shortcut: shortcuts.get,
|
||||
shortcuts: shortcuts.list,
|
||||
commands: keymapState.commands,
|
||||
pending: keymapState.pending,
|
||||
active: keymapState.active,
|
||||
mode: keymap.mode,
|
||||
},
|
||||
ui: {
|
||||
dialog: dialogApi,
|
||||
toast: toastApi,
|
||||
router: {
|
||||
register(page) {
|
||||
if (store.registrations[item.plugin.id]?.routes[page.name])
|
||||
|
||||
@@ -1104,6 +1104,7 @@ function TurnTokenUsage(props: {
|
||||
}) {
|
||||
const config = useConfig()
|
||||
const { themeV2 } = useTheme()
|
||||
const verbose = () => config.data.debug?.turn_tokens === "verbose"
|
||||
const steps = createMemo(() => {
|
||||
let previousCache = props.previousCache
|
||||
return props.messageIDs.flatMap((messageID) => {
|
||||
@@ -1123,6 +1124,7 @@ function TurnTokenUsage(props: {
|
||||
return [
|
||||
{
|
||||
finish: message.finish === "tool-calls" ? "tool-call" : (message.finish ?? "unknown"),
|
||||
tools: verbose() ? message.content.filter((part) => part.type === "tool") : [],
|
||||
newTokens,
|
||||
cached: message.tokens.cache.read,
|
||||
total,
|
||||
@@ -1138,7 +1140,7 @@ function TurnTokenUsage(props: {
|
||||
total: Math.max("Total".length, ...steps().map((item) => item.total.toLocaleString().length)),
|
||||
}))
|
||||
return (
|
||||
<Show when={config.data.debug?.turn_tokens === true && steps().length > 0}>
|
||||
<Show when={Boolean(config.data.debug?.turn_tokens) && steps().length > 0}>
|
||||
<box paddingLeft={3} flexDirection="column">
|
||||
<box flexDirection="row">
|
||||
<text width={INLINE_TOOL_ICON_WIDTH} fg={themeV2.text.subdued}>
|
||||
@@ -1161,7 +1163,7 @@ function TurnTokenUsage(props: {
|
||||
<For each={steps()}>
|
||||
{(item) => (
|
||||
<box paddingLeft={INLINE_TOOL_ICON_WIDTH} flexDirection="column">
|
||||
<text fg={themeV2.text.subdued}>
|
||||
<text fg={verbose() && item.finish === "tool-call" ? undefined : themeV2.text.subdued}>
|
||||
{item.finish.padEnd(columns().step + 2)}
|
||||
<span style={{ attributes: TextAttributes.BOLD }}>
|
||||
{item.newTokens.toLocaleString().padStart(columns().newTokens)}
|
||||
@@ -1171,6 +1173,7 @@ function TurnTokenUsage(props: {
|
||||
{" "}
|
||||
{item.total.toLocaleString().padStart(columns().total)}
|
||||
</text>
|
||||
<TurnTokenToolCalls tools={item.tools} />
|
||||
<Show when={item.reuseDrop !== undefined}>
|
||||
<text fg={themeV2.text.feedback.warning.default}>
|
||||
! Likely cache bust: {item.reuseDrop?.toLocaleString()} fewer cached tokens than the previous step
|
||||
@@ -1184,6 +1187,54 @@ function TurnTokenUsage(props: {
|
||||
)
|
||||
}
|
||||
|
||||
function TurnTokenToolCalls(props: { tools: SessionMessageAssistantTool[] }) {
|
||||
const { themeV2 } = useTheme()
|
||||
const nameWidth = () => Math.max(0, ...props.tools.map((tool) => tool.name.length)) + 2
|
||||
return (
|
||||
<Show when={props.tools.length > 0}>
|
||||
<box paddingLeft={2} flexDirection="column">
|
||||
<For each={props.tools}>
|
||||
{(tool) => (
|
||||
<box flexDirection="row">
|
||||
<text
|
||||
width={nameWidth()}
|
||||
flexShrink={0}
|
||||
fg={themeV2.text.subdued}
|
||||
attributes={TextAttributes.BOLD}
|
||||
>
|
||||
{tool.name}
|
||||
</text>
|
||||
<text
|
||||
fg={themeV2.text.subdued}
|
||||
attributes={TextAttributes.DIM}
|
||||
wrapMode="word"
|
||||
flexGrow={1}
|
||||
minWidth={0}
|
||||
>
|
||||
{turnTokenToolSummary(tool)}
|
||||
</text>
|
||||
</box>
|
||||
)}
|
||||
</For>
|
||||
</box>
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
|
||||
function turnTokenToolSummary(tool: SessionMessageAssistantTool) {
|
||||
const data = tool.state.input
|
||||
if (typeof data === "string") return data
|
||||
const primaryKey = ["command", "id", "pattern", "url", "query", "path", "description", "code"].find(
|
||||
(key) => key in data,
|
||||
)
|
||||
const input = Object.entries(data).filter(([, value]) =>
|
||||
["string", "number", "boolean"].includes(typeof value),
|
||||
)
|
||||
const primary = input.find(([key]) => key === primaryKey)?.[1]
|
||||
const details = input.filter(([key]) => key !== primaryKey).map(([key, value]) => `${key}: ${String(value)}`)
|
||||
return [primary === undefined ? "" : String(primary), ...details].filter(Boolean).join(" ")
|
||||
}
|
||||
|
||||
function BackgroundToolHint(props: { messages: SessionMessageInfo[] }) {
|
||||
const { themeV2 } = useTheme()
|
||||
const shortcut = Keymap.useShortcut("session.background")
|
||||
|
||||
@@ -41,7 +41,7 @@ export function createSessionRows(sessionID: Accessor<string>) {
|
||||
const config = useConfig()
|
||||
const [rows, setRows] = createStore<SessionRow[]>([])
|
||||
const revertBoundary = () => data.session.get(sessionID())?.revert?.messageID
|
||||
const turnTokens = () => config.data.debug?.turn_tokens === true
|
||||
const turnTokens = () => Boolean(config.data.debug?.turn_tokens)
|
||||
|
||||
function reduce() {
|
||||
const messages = data.session.message.list(sessionID())
|
||||
|
||||
@@ -11,7 +11,10 @@ export type DialogConfirmProps = {
|
||||
message: string
|
||||
onConfirm?: () => void
|
||||
onCancel?: () => void
|
||||
label?: string
|
||||
label?: {
|
||||
confirm?: string
|
||||
cancel?: string
|
||||
}
|
||||
}
|
||||
|
||||
export type DialogConfirmResult = boolean | undefined
|
||||
@@ -81,7 +84,7 @@ export function DialogConfirm(props: DialogConfirmProps) {
|
||||
}}
|
||||
>
|
||||
<text fg={key === store.active ? themeV2.text.action.primary.focused : themeV2.text.subdued}>
|
||||
{Locale.titlecase(key === "cancel" ? (props.label ?? key) : key)}
|
||||
{Locale.titlecase(props.label?.[key] ?? key)}
|
||||
</text>
|
||||
</box>
|
||||
)}
|
||||
@@ -91,7 +94,7 @@ export function DialogConfirm(props: DialogConfirmProps) {
|
||||
)
|
||||
}
|
||||
|
||||
DialogConfirm.show = (dialog: DialogContext, title: string, message: string, label?: string) => {
|
||||
DialogConfirm.show = (dialog: DialogContext, title: string, message: string, label?: DialogConfirmProps["label"]) => {
|
||||
return new Promise<DialogConfirmResult>((resolve) => {
|
||||
dialog.replace(
|
||||
() => (
|
||||
|
||||
@@ -1,27 +1,17 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import Notifications from "../../../../src/feature-plugins/system/notifications"
|
||||
import type { OpenCodeEvent, PermissionAsked, QuestionAsked } from "@opencode-ai/client"
|
||||
import type { TuiAttentionNotifyInput, TuiPluginApi } from "@opencode-ai/plugin/v1/tui"
|
||||
import { createTuiPluginApi } from "../../../fixture/tui-plugin"
|
||||
import type { AttentionNotifyOptions, Context } from "@opencode-ai/plugin/tui/context"
|
||||
|
||||
type Session = NonNullable<ReturnType<TuiPluginApi["state"]["session"]["get"]>>
|
||||
type Session = { id: string; title: string; parentID?: string }
|
||||
|
||||
async function setup() {
|
||||
const notifications: TuiAttentionNotifyInput[] = []
|
||||
const notifications: AttentionNotifyOptions[] = []
|
||||
const handlers = new Map<OpenCodeEvent["type"], ((event: OpenCodeEvent) => void)[]>()
|
||||
const session = (
|
||||
id: string,
|
||||
title: string,
|
||||
parentID?: string,
|
||||
): Session => ({
|
||||
const session = (id: string, title: string, parentID?: string): Session => ({
|
||||
id,
|
||||
title,
|
||||
slug: id,
|
||||
projectID: "project",
|
||||
directory: "/workspace",
|
||||
...(parentID && { parentID }),
|
||||
version: "0.0.0-test",
|
||||
time: { created: 0, updated: 0 },
|
||||
})
|
||||
const sessions: Record<string, Session> = {
|
||||
session: session("session", "Demo session"),
|
||||
@@ -30,41 +20,35 @@ async function setup() {
|
||||
timeout: session("timeout", "Timeout session"),
|
||||
}
|
||||
|
||||
await Notifications.tui(
|
||||
createTuiPluginApi({
|
||||
attention: {
|
||||
async notify(input) {
|
||||
notifications.push(input)
|
||||
return { ok: true, notification: true, sound: true }
|
||||
},
|
||||
await Notifications.setup({
|
||||
attention: {
|
||||
async notify(input: AttentionNotifyOptions) {
|
||||
notifications.push(input)
|
||||
return { ok: true, notification: true, sound: true }
|
||||
},
|
||||
event: {
|
||||
on: <Type extends OpenCodeEvent["type"]>(
|
||||
type: Type,
|
||||
handler: (event: Extract<OpenCodeEvent, { type: Type }>) => void,
|
||||
) => {
|
||||
const list = handlers.get(type) ?? []
|
||||
const wrapped = handler as (event: OpenCodeEvent) => void
|
||||
list.push(wrapped)
|
||||
handlers.set(type, list)
|
||||
return () => {
|
||||
handlers.set(
|
||||
type,
|
||||
(handlers.get(type) ?? []).filter((item) => item !== wrapped),
|
||||
)
|
||||
}
|
||||
},
|
||||
},
|
||||
data: {
|
||||
on: <Type extends OpenCodeEvent["type"]>(
|
||||
type: Type,
|
||||
handler: (event: Extract<OpenCodeEvent, { type: Type }>) => void,
|
||||
) => {
|
||||
const list = handlers.get(type) ?? []
|
||||
const wrapped = handler as (event: OpenCodeEvent) => void
|
||||
list.push(wrapped)
|
||||
handlers.set(type, list)
|
||||
return () => {
|
||||
handlers.set(
|
||||
type,
|
||||
(handlers.get(type) ?? []).filter((item) => item !== wrapped),
|
||||
)
|
||||
}
|
||||
},
|
||||
state: {
|
||||
session: {
|
||||
get: (sessionID: string) => sessions[sessionID],
|
||||
status: () => ({ type: "busy" }),
|
||||
},
|
||||
session: {
|
||||
get: (sessionID: string) => sessions[sessionID],
|
||||
status: () => "running" as const,
|
||||
},
|
||||
}),
|
||||
undefined,
|
||||
{} as never,
|
||||
)
|
||||
},
|
||||
} as unknown as Context)
|
||||
|
||||
return {
|
||||
notifications,
|
||||
@@ -139,31 +123,31 @@ function executionFailed(id: string, sessionID = "session"): OpenCodeEvent {
|
||||
}
|
||||
}
|
||||
|
||||
const questionNotification: TuiAttentionNotifyInput = {
|
||||
const questionNotification: AttentionNotifyOptions = {
|
||||
title: "Demo session",
|
||||
message: "Question needs input",
|
||||
notification: { when: "blurred" },
|
||||
sound: { name: "question", when: "always" },
|
||||
}
|
||||
|
||||
const formNotification: TuiAttentionNotifyInput = {
|
||||
const formNotification: AttentionNotifyOptions = {
|
||||
title: "Input requested",
|
||||
message: "Input needs response",
|
||||
notification: { when: "blurred" },
|
||||
sound: { name: "question", when: "always" },
|
||||
}
|
||||
|
||||
const titledFormNotification: TuiAttentionNotifyInput = {
|
||||
const titledFormNotification: AttentionNotifyOptions = {
|
||||
...formNotification,
|
||||
title: "Confirm deployment",
|
||||
}
|
||||
|
||||
const globalFormNotification: TuiAttentionNotifyInput = {
|
||||
const globalFormNotification: AttentionNotifyOptions = {
|
||||
...formNotification,
|
||||
title: "demo-mcp is requesting input",
|
||||
}
|
||||
|
||||
const permissionNotification: TuiAttentionNotifyInput = {
|
||||
const permissionNotification: AttentionNotifyOptions = {
|
||||
title: "Demo session",
|
||||
message: "Permission needs input",
|
||||
notification: { when: "blurred" },
|
||||
|
||||
@@ -171,19 +171,25 @@ async function renderDiffViewer(vcsDiff: unknown[], height = 20, initialRoute?:
|
||||
})
|
||||
},
|
||||
dispatch() {},
|
||||
shortcut: () => undefined,
|
||||
shortcuts: () => [],
|
||||
mode: { current: () => "base", push: () => () => {} },
|
||||
},
|
||||
ui: {
|
||||
dialog: {
|
||||
show: () => () => {},
|
||||
set() {},
|
||||
clear() {},
|
||||
},
|
||||
router: {
|
||||
register(page: Page) {
|
||||
if (page.name === "diff") renderDiff = page.render
|
||||
return () => {}
|
||||
return () => {}
|
||||
},
|
||||
navigate(destination: Destination) {
|
||||
current = destination.type === "plugin" && !("id" in destination)
|
||||
? { ...destination, id: "diff-viewer" }
|
||||
: destination
|
||||
current =
|
||||
destination.type === "plugin" && !("id" in destination)
|
||||
? { ...destination, id: "diff-viewer" }
|
||||
: destination
|
||||
},
|
||||
current: () => current,
|
||||
},
|
||||
|
||||
@@ -76,6 +76,32 @@ test("formats navigation keys as arrows", async () => {
|
||||
}
|
||||
})
|
||||
|
||||
test("returns every formatted command shortcut", async () => {
|
||||
let read = () => [] as readonly string[]
|
||||
|
||||
function Harness() {
|
||||
const shortcuts = Keymap.useShortcuts()
|
||||
Keymap.createLayer(() => ({
|
||||
commands: [{ id: "demo.command", bind: "x,y", run() {} }],
|
||||
}))
|
||||
read = () => shortcuts.list("demo.command")
|
||||
return <box />
|
||||
}
|
||||
|
||||
const app = await testRender(() => (
|
||||
<ConfigProvider config={createTuiResolvedConfig()}>
|
||||
<Keymap.Provider>
|
||||
<Harness />
|
||||
</Keymap.Provider>
|
||||
</ConfigProvider>
|
||||
))
|
||||
try {
|
||||
expect(read()).toEqual(["x", "y"])
|
||||
} finally {
|
||||
app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test("global commands stay reachable when the mode changes", async () => {
|
||||
const calls: string[] = []
|
||||
let exercise = () => {}
|
||||
|
||||
Reference in New Issue
Block a user