mirror of
https://gitee.com/zhangsken/opencode.git
synced 2026-10-16 10:30:01 +08:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c2631c5465 |
@@ -1384,6 +1384,26 @@ export type SessionViewInput = { readonly sessionID: Session.ID; readonly idle:
|
||||
export type SessionViewOutput = void
|
||||
export type SessionViewOperation<E = never> = (input: SessionViewInput) => Effect.Effect<SessionViewOutput, E>
|
||||
|
||||
export type SessionActivateInput = { readonly sessionID: Session.ID }
|
||||
export type SessionActivateOutput = {
|
||||
readonly outcome: "activated" | "none"
|
||||
readonly client?:
|
||||
| {
|
||||
readonly id: string & Brand.Brand<"Client.ID">
|
||||
readonly kind: "tui" | "desktop" | "web" | "other"
|
||||
readonly name?: string | undefined
|
||||
readonly sessions: ReadonlyArray<Session.ID>
|
||||
readonly focused: boolean
|
||||
readonly focusedAt: number
|
||||
readonly updatedAt: number
|
||||
readonly pid?: number | undefined
|
||||
}
|
||||
| undefined
|
||||
}
|
||||
export type SessionActivateOperation<E = never> = (
|
||||
input: SessionActivateInput,
|
||||
) => Effect.Effect<SessionActivateOutput, E>
|
||||
|
||||
export interface SessionApi<E = never> {
|
||||
readonly list: SessionListOperation<E>
|
||||
readonly stats: SessionStatsOperation<E>
|
||||
@@ -1438,6 +1458,7 @@ export interface SessionApi<E = never> {
|
||||
}
|
||||
readonly environment: SessionEnvironmentOperation<E>
|
||||
readonly view: SessionViewOperation<E>
|
||||
readonly activate: SessionActivateOperation<E>
|
||||
}
|
||||
|
||||
export type MessageListInput = {
|
||||
@@ -1718,6 +1739,81 @@ export interface CredentialApi<E = never> {
|
||||
readonly remove: CredentialRemoveOperation<E>
|
||||
}
|
||||
|
||||
export type ClientListOutput = ReadonlyArray<{
|
||||
readonly id: string & Brand.Brand<"Client.ID">
|
||||
readonly kind: "tui" | "desktop" | "web" | "other"
|
||||
readonly name?: string | undefined
|
||||
readonly sessions: ReadonlyArray<Session.ID>
|
||||
readonly focused: boolean
|
||||
readonly focusedAt: number
|
||||
readonly updatedAt: number
|
||||
readonly pid?: number | undefined
|
||||
}>
|
||||
export type ClientListOperation<E = never> = () => Effect.Effect<ClientListOutput, E>
|
||||
|
||||
export type ClientRegisterInput = {
|
||||
readonly kind: "tui" | "desktop" | "web" | "other"
|
||||
readonly name?: string | undefined
|
||||
readonly sessions?: ReadonlyArray<Session.ID> | undefined
|
||||
readonly focused?: boolean | undefined
|
||||
readonly pid?: number | undefined
|
||||
}
|
||||
export type ClientRegisterOutput = {
|
||||
readonly id: string & Brand.Brand<"Client.ID">
|
||||
readonly kind: "tui" | "desktop" | "web" | "other"
|
||||
readonly name?: string | undefined
|
||||
readonly sessions: ReadonlyArray<Session.ID>
|
||||
readonly focused: boolean
|
||||
readonly focusedAt: number
|
||||
readonly updatedAt: number
|
||||
readonly pid?: number | undefined
|
||||
}
|
||||
export type ClientRegisterOperation<E = never> = (input: ClientRegisterInput) => Effect.Effect<ClientRegisterOutput, E>
|
||||
|
||||
export type ClientGetInput = { readonly clientID: string & Brand.Brand<"Client.ID"> }
|
||||
export type ClientGetOutput = {
|
||||
readonly id: string & Brand.Brand<"Client.ID">
|
||||
readonly kind: "tui" | "desktop" | "web" | "other"
|
||||
readonly name?: string | undefined
|
||||
readonly sessions: ReadonlyArray<Session.ID>
|
||||
readonly focused: boolean
|
||||
readonly focusedAt: number
|
||||
readonly updatedAt: number
|
||||
readonly pid?: number | undefined
|
||||
}
|
||||
export type ClientGetOperation<E = never> = (input: ClientGetInput) => Effect.Effect<ClientGetOutput, E>
|
||||
|
||||
export type ClientUpdateInput = {
|
||||
readonly clientID: string & Brand.Brand<"Client.ID">
|
||||
readonly name?: string | undefined
|
||||
readonly sessions?: ReadonlyArray<Session.ID> | undefined
|
||||
readonly focused?: boolean | undefined
|
||||
readonly pid?: number | undefined
|
||||
}
|
||||
export type ClientUpdateOutput = {
|
||||
readonly id: string & Brand.Brand<"Client.ID">
|
||||
readonly kind: "tui" | "desktop" | "web" | "other"
|
||||
readonly name?: string | undefined
|
||||
readonly sessions: ReadonlyArray<Session.ID>
|
||||
readonly focused: boolean
|
||||
readonly focusedAt: number
|
||||
readonly updatedAt: number
|
||||
readonly pid?: number | undefined
|
||||
}
|
||||
export type ClientUpdateOperation<E = never> = (input: ClientUpdateInput) => Effect.Effect<ClientUpdateOutput, E>
|
||||
|
||||
export type ClientRemoveInput = { readonly clientID: string & Brand.Brand<"Client.ID"> }
|
||||
export type ClientRemoveOutput = void
|
||||
export type ClientRemoveOperation<E = never> = (input: ClientRemoveInput) => Effect.Effect<ClientRemoveOutput, E>
|
||||
|
||||
export interface ClientApi<E = never> {
|
||||
readonly list: ClientListOperation<E>
|
||||
readonly register: ClientRegisterOperation<E>
|
||||
readonly get: ClientGetOperation<E>
|
||||
readonly update: ClientUpdateOperation<E>
|
||||
readonly remove: ClientRemoveOperation<E>
|
||||
}
|
||||
|
||||
export type ProjectListOutput = ReadonlyArray<Project.Info>
|
||||
export type ProjectListOperation<E = never> = () => Effect.Effect<ProjectListOutput, E>
|
||||
|
||||
@@ -2322,6 +2418,7 @@ export interface AppApi<E = never> {
|
||||
readonly integration: IntegrationApi<E>
|
||||
readonly mcp: McpApi<E>
|
||||
readonly credential: CredentialApi<E>
|
||||
readonly client: ClientApi<E>
|
||||
readonly project: ProjectApi<E>
|
||||
readonly form: FormApi<E>
|
||||
readonly permission: PermissionApi<E>
|
||||
|
||||
@@ -104,6 +104,8 @@ import type {
|
||||
SessionEnvironmentOutput,
|
||||
SessionViewInput,
|
||||
SessionViewOutput,
|
||||
SessionActivateInput,
|
||||
SessionActivateOutput,
|
||||
MessageListInput,
|
||||
MessageListOutput,
|
||||
ModelListInput,
|
||||
@@ -156,6 +158,15 @@ import type {
|
||||
CredentialActivateOutput,
|
||||
CredentialRemoveInput,
|
||||
CredentialRemoveOutput,
|
||||
ClientListOutput,
|
||||
ClientRegisterInput,
|
||||
ClientRegisterOutput,
|
||||
ClientGetInput,
|
||||
ClientGetOutput,
|
||||
ClientUpdateInput,
|
||||
ClientUpdateOutput,
|
||||
ClientRemoveInput,
|
||||
ClientRemoveOutput,
|
||||
ProjectListOutput,
|
||||
ProjectUpdateInput,
|
||||
ProjectUpdateOutput,
|
||||
@@ -737,6 +748,11 @@ const EndpointSessionView = (raw: RawClient["server.session"]) => (input: Sessio
|
||||
),
|
||||
)
|
||||
|
||||
const EndpointSessionActivate = (raw: RawClient["server.session"]) => (input: SessionActivateInput) =>
|
||||
preserveEffect<SessionActivateOutput>()(
|
||||
raw["session.activate"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)),
|
||||
)
|
||||
|
||||
const adaptGroupSession = (raw: RawClient["server.session"]) => ({
|
||||
list: EndpointSessionList(raw),
|
||||
stats: EndpointSessionStats(raw),
|
||||
@@ -791,6 +807,7 @@ const adaptGroupSession = (raw: RawClient["server.session"]) => ({
|
||||
},
|
||||
environment: EndpointSessionEnvironment(raw),
|
||||
view: EndpointSessionView(raw),
|
||||
activate: EndpointSessionActivate(raw),
|
||||
})
|
||||
|
||||
const EndpointMessageList = (raw: RawClient["server.message"]) => (input: MessageListInput) =>
|
||||
@@ -1032,6 +1049,62 @@ const adaptGroupCredential = (raw: RawClient["server.credential"]) => ({
|
||||
remove: EndpointCredentialRemove(raw),
|
||||
})
|
||||
|
||||
const EndpointClientList = (raw: RawClient["server.client"]) => () =>
|
||||
preserveEffect<ClientListOutput>()(
|
||||
raw["client.list"]({}).pipe(
|
||||
Effect.mapError(mapClientError),
|
||||
Effect.map((value) => value.data),
|
||||
),
|
||||
)
|
||||
|
||||
const EndpointClientRegister = (raw: RawClient["server.client"]) => (input: ClientRegisterInput) =>
|
||||
preserveEffect<ClientRegisterOutput>()(
|
||||
raw["client.register"]({
|
||||
payload: {
|
||||
kind: input["kind"],
|
||||
name: input["name"],
|
||||
sessions: input["sessions"],
|
||||
focused: input["focused"],
|
||||
pid: input["pid"],
|
||||
},
|
||||
}).pipe(
|
||||
Effect.mapError(mapClientError),
|
||||
Effect.map((value) => value.data),
|
||||
),
|
||||
)
|
||||
|
||||
const EndpointClientGet = (raw: RawClient["server.client"]) => (input: ClientGetInput) =>
|
||||
preserveEffect<ClientGetOutput>()(
|
||||
raw["client.get"]({ params: { clientID: input["clientID"] } }).pipe(
|
||||
Effect.mapError(mapClientError),
|
||||
Effect.map((value) => value.data),
|
||||
),
|
||||
)
|
||||
|
||||
const EndpointClientUpdate = (raw: RawClient["server.client"]) => (input: ClientUpdateInput) =>
|
||||
preserveEffect<ClientUpdateOutput>()(
|
||||
raw["client.update"]({
|
||||
params: { clientID: input["clientID"] },
|
||||
payload: { name: input["name"], sessions: input["sessions"], focused: input["focused"], pid: input["pid"] },
|
||||
}).pipe(
|
||||
Effect.mapError(mapClientError),
|
||||
Effect.map((value) => value.data),
|
||||
),
|
||||
)
|
||||
|
||||
const EndpointClientRemove = (raw: RawClient["server.client"]) => (input: ClientRemoveInput) =>
|
||||
preserveEffect<ClientRemoveOutput>()(
|
||||
raw["client.remove"]({ params: { clientID: input["clientID"] } }).pipe(Effect.mapError(mapClientError)),
|
||||
)
|
||||
|
||||
const adaptGroupClient = (raw: RawClient["server.client"]) => ({
|
||||
list: EndpointClientList(raw),
|
||||
register: EndpointClientRegister(raw),
|
||||
get: EndpointClientGet(raw),
|
||||
update: EndpointClientUpdate(raw),
|
||||
remove: EndpointClientRemove(raw),
|
||||
})
|
||||
|
||||
const EndpointProjectList = (raw: RawClient["server.project"]) => () =>
|
||||
preserveEffect<ProjectListOutput>()(raw["project.list"]({}).pipe(Effect.mapError(mapClientError)))
|
||||
|
||||
@@ -1539,6 +1612,7 @@ const adaptClient = (raw: RawClient) => ({
|
||||
integration: adaptGroupIntegration(raw["server.integration"]),
|
||||
mcp: adaptGroupMcp(raw["server.mcp"]),
|
||||
credential: adaptGroupCredential(raw["server.credential"]),
|
||||
client: adaptGroupClient(raw["server.client"]),
|
||||
project: adaptGroupProject(raw["server.project"]),
|
||||
form: adaptGroupForm(raw["server.form"]),
|
||||
permission: adaptGroupPermission(raw["server.permission"]),
|
||||
|
||||
@@ -98,6 +98,8 @@ import type {
|
||||
SessionEnvironmentOutput,
|
||||
SessionViewInput,
|
||||
SessionViewOutput,
|
||||
SessionActivateInput,
|
||||
SessionActivateOutput,
|
||||
MessageListInput,
|
||||
MessageListOutput,
|
||||
ModelListInput,
|
||||
@@ -150,6 +152,15 @@ import type {
|
||||
CredentialActivateOutput,
|
||||
CredentialRemoveInput,
|
||||
CredentialRemoveOutput,
|
||||
ClientListOutput,
|
||||
ClientRegisterInput,
|
||||
ClientRegisterOutput,
|
||||
ClientGetInput,
|
||||
ClientGetOutput,
|
||||
ClientUpdateInput,
|
||||
ClientUpdateOutput,
|
||||
ClientRemoveInput,
|
||||
ClientRemoveOutput,
|
||||
ProjectListOutput,
|
||||
ProjectUpdateInput,
|
||||
ProjectUpdateOutput,
|
||||
@@ -1063,6 +1074,17 @@ export function make(options: ClientOptions) {
|
||||
},
|
||||
requestOptions,
|
||||
),
|
||||
activate: (input: SessionActivateInput, requestOptions?: RequestOptions) =>
|
||||
request<SessionActivateOutput>(
|
||||
{
|
||||
method: "POST",
|
||||
path: `/api/session/${encodeURIComponent(input.sessionID)}/activate`,
|
||||
successStatus: 200,
|
||||
declaredStatuses: [400, 401, 404],
|
||||
empty: false,
|
||||
},
|
||||
requestOptions,
|
||||
),
|
||||
},
|
||||
message: {
|
||||
list: (input: MessageListInput, requestOptions?: RequestOptions) =>
|
||||
@@ -1404,6 +1426,65 @@ export function make(options: ClientOptions) {
|
||||
requestOptions,
|
||||
),
|
||||
},
|
||||
client: {
|
||||
list: (requestOptions?: RequestOptions) =>
|
||||
request<{ readonly data: ClientListOutput }>(
|
||||
{ method: "GET", path: `/api/client`, successStatus: 200, declaredStatuses: [400, 401], empty: false },
|
||||
requestOptions,
|
||||
).then((value) => value.data),
|
||||
register: (input: ClientRegisterInput, requestOptions?: RequestOptions) =>
|
||||
request<{ readonly data: ClientRegisterOutput }>(
|
||||
{
|
||||
method: "POST",
|
||||
path: `/api/client`,
|
||||
body: {
|
||||
kind: input["kind"],
|
||||
name: input["name"],
|
||||
sessions: input["sessions"],
|
||||
focused: input["focused"],
|
||||
pid: input["pid"],
|
||||
},
|
||||
successStatus: 200,
|
||||
declaredStatuses: [400, 401, 409],
|
||||
empty: false,
|
||||
},
|
||||
requestOptions,
|
||||
).then((value) => value.data),
|
||||
get: (input: ClientGetInput, requestOptions?: RequestOptions) =>
|
||||
request<{ readonly data: ClientGetOutput }>(
|
||||
{
|
||||
method: "GET",
|
||||
path: `/api/client/${encodeURIComponent(input.clientID)}`,
|
||||
successStatus: 200,
|
||||
declaredStatuses: [400, 401, 404],
|
||||
empty: false,
|
||||
},
|
||||
requestOptions,
|
||||
).then((value) => value.data),
|
||||
update: (input: ClientUpdateInput, requestOptions?: RequestOptions) =>
|
||||
request<{ readonly data: ClientUpdateOutput }>(
|
||||
{
|
||||
method: "PUT",
|
||||
path: `/api/client/${encodeURIComponent(input.clientID)}`,
|
||||
body: { name: input["name"], sessions: input["sessions"], focused: input["focused"], pid: input["pid"] },
|
||||
successStatus: 200,
|
||||
declaredStatuses: [400, 401, 404],
|
||||
empty: false,
|
||||
},
|
||||
requestOptions,
|
||||
).then((value) => value.data),
|
||||
remove: (input: ClientRemoveInput, requestOptions?: RequestOptions) =>
|
||||
request<ClientRemoveOutput>(
|
||||
{
|
||||
method: "DELETE",
|
||||
path: `/api/client/${encodeURIComponent(input.clientID)}`,
|
||||
successStatus: 204,
|
||||
declaredStatuses: [400, 401, 404],
|
||||
empty: true,
|
||||
},
|
||||
requestOptions,
|
||||
),
|
||||
},
|
||||
project: {
|
||||
list: (requestOptions?: RequestOptions) =>
|
||||
request<ProjectListOutput>(
|
||||
|
||||
@@ -216,6 +216,8 @@ export type FormExternalField = { key: string; type: "external"; url: string; ti
|
||||
|
||||
export type FormValue = string | number | "Infinity" | "-Infinity" | "NaN" | boolean | Array<string>
|
||||
|
||||
export type ClientKind = "tui" | "desktop" | "web" | "other"
|
||||
|
||||
export type ModelReasoningField = "reasoning" | "reasoning_content" | "reasoning_text" | (string & {})
|
||||
|
||||
export type ModelMaxTokensField = "max_completion_tokens" | "max_tokens"
|
||||
@@ -1164,6 +1166,15 @@ export type TuiSessionSelect = {
|
||||
data: { sessionID: string }
|
||||
}
|
||||
|
||||
export type ClientActivate = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
type: "client.activate"
|
||||
location?: LocationRef
|
||||
data: { clientID: string; sessionID: string }
|
||||
}
|
||||
|
||||
export type InstallationUpdated = {
|
||||
id: string
|
||||
created: number
|
||||
@@ -1437,6 +1448,17 @@ export type FormMultiselectField = {
|
||||
|
||||
export type FormAnswer = { [x: string]: FormValue }
|
||||
|
||||
export type ClientInfo = {
|
||||
id: string
|
||||
kind: ClientKind
|
||||
name?: string
|
||||
sessions: Array<string>
|
||||
focused: boolean
|
||||
focusedAt: number
|
||||
updatedAt: number
|
||||
pid?: number
|
||||
}
|
||||
|
||||
export type ModelCompatibility = {
|
||||
reasoningField?: ModelReasoningField
|
||||
requireReasoning?: boolean
|
||||
@@ -1868,6 +1890,12 @@ export type FormField =
|
||||
|
||||
export type FormState = { status: "pending" } | { status: "answered"; answer: FormAnswer } | { status: "cancelled" }
|
||||
|
||||
export type ClientActivate2 = { outcome: "activated" | "none"; client?: ClientInfo }
|
||||
|
||||
export type ClientListResponse = { data: Array<ClientInfo> }
|
||||
|
||||
export type ClientResponse = { data: ClientInfo }
|
||||
|
||||
export type FormField1 =
|
||||
| FormStringField1
|
||||
| FormNumberField1
|
||||
@@ -2443,6 +2471,7 @@ export type V2Event =
|
||||
| TuiCommandExecute
|
||||
| TuiToastShow
|
||||
| TuiSessionSelect
|
||||
| ClientActivate
|
||||
| InstallationUpdated
|
||||
| InstallationUpdateAvailable
|
||||
| VcsBranchUpdated
|
||||
@@ -2625,6 +2654,14 @@ export type McpServerNotFoundError = {
|
||||
export const isMcpServerNotFoundError = (value: unknown): value is McpServerNotFoundError =>
|
||||
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "McpServerNotFoundError"
|
||||
|
||||
export type ClientNotFoundError = {
|
||||
readonly _tag: "ClientNotFoundError"
|
||||
readonly clientID: string
|
||||
readonly message: string
|
||||
}
|
||||
export const isClientNotFoundError = (value: unknown): value is ClientNotFoundError =>
|
||||
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "ClientNotFoundError"
|
||||
|
||||
export type ProjectNotFoundError = {
|
||||
readonly _tag: "ProjectNotFoundError"
|
||||
readonly projectID: string
|
||||
@@ -5351,6 +5388,10 @@ export type SessionViewInput = {
|
||||
|
||||
export type SessionViewOutput = void
|
||||
|
||||
export type SessionActivateInput = { readonly sessionID: { readonly sessionID: string }["sessionID"] }
|
||||
|
||||
export type SessionActivateOutput = ClientActivate2
|
||||
|
||||
export type MessageListInput = {
|
||||
readonly sessionID: { readonly sessionID: string }["sessionID"]
|
||||
readonly limit?: {
|
||||
@@ -5664,6 +5705,86 @@ export type CredentialRemoveInput = { readonly credentialID: { readonly credenti
|
||||
|
||||
export type CredentialRemoveOutput = void
|
||||
|
||||
export type ClientListOutput = ClientListResponse["data"]
|
||||
|
||||
export type ClientRegisterInput = {
|
||||
readonly kind: {
|
||||
readonly kind: "tui" | "desktop" | "web" | "other"
|
||||
readonly name?: string
|
||||
readonly sessions?: ReadonlyArray<string>
|
||||
readonly focused?: boolean
|
||||
readonly pid?: number
|
||||
}["kind"]
|
||||
readonly name?: {
|
||||
readonly kind: "tui" | "desktop" | "web" | "other"
|
||||
readonly name?: string
|
||||
readonly sessions?: ReadonlyArray<string>
|
||||
readonly focused?: boolean
|
||||
readonly pid?: number
|
||||
}["name"]
|
||||
readonly sessions?: {
|
||||
readonly kind: "tui" | "desktop" | "web" | "other"
|
||||
readonly name?: string
|
||||
readonly sessions?: ReadonlyArray<string>
|
||||
readonly focused?: boolean
|
||||
readonly pid?: number
|
||||
}["sessions"]
|
||||
readonly focused?: {
|
||||
readonly kind: "tui" | "desktop" | "web" | "other"
|
||||
readonly name?: string
|
||||
readonly sessions?: ReadonlyArray<string>
|
||||
readonly focused?: boolean
|
||||
readonly pid?: number
|
||||
}["focused"]
|
||||
readonly pid?: {
|
||||
readonly kind: "tui" | "desktop" | "web" | "other"
|
||||
readonly name?: string
|
||||
readonly sessions?: ReadonlyArray<string>
|
||||
readonly focused?: boolean
|
||||
readonly pid?: number
|
||||
}["pid"]
|
||||
}
|
||||
|
||||
export type ClientRegisterOutput = ClientResponse["data"]
|
||||
|
||||
export type ClientGetInput = { readonly clientID: { readonly clientID: string }["clientID"] }
|
||||
|
||||
export type ClientGetOutput = ClientResponse["data"]
|
||||
|
||||
export type ClientUpdateInput = {
|
||||
readonly clientID: { readonly clientID: string }["clientID"]
|
||||
readonly name?: {
|
||||
readonly name?: string
|
||||
readonly sessions?: ReadonlyArray<string>
|
||||
readonly focused?: boolean
|
||||
readonly pid?: number
|
||||
}["name"]
|
||||
readonly sessions?: {
|
||||
readonly name?: string
|
||||
readonly sessions?: ReadonlyArray<string>
|
||||
readonly focused?: boolean
|
||||
readonly pid?: number
|
||||
}["sessions"]
|
||||
readonly focused?: {
|
||||
readonly name?: string
|
||||
readonly sessions?: ReadonlyArray<string>
|
||||
readonly focused?: boolean
|
||||
readonly pid?: number
|
||||
}["focused"]
|
||||
readonly pid?: {
|
||||
readonly name?: string
|
||||
readonly sessions?: ReadonlyArray<string>
|
||||
readonly focused?: boolean
|
||||
readonly pid?: number
|
||||
}["pid"]
|
||||
}
|
||||
|
||||
export type ClientUpdateOutput = ClientResponse["data"]
|
||||
|
||||
export type ClientRemoveInput = { readonly clientID: { readonly clientID: string }["clientID"] }
|
||||
|
||||
export type ClientRemoveOutput = void
|
||||
|
||||
export type ProjectListOutput = Array<Project>
|
||||
|
||||
export type ProjectUpdateInput = {
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
export * as Client from "./client.js"
|
||||
|
||||
import { Clock, Context, Effect, Layer, Schema } from "effect"
|
||||
import { Client } from "@opencode/schema/client"
|
||||
import { SessionID } from "@opencode/schema/session-id"
|
||||
import { makeGlobalNode } from "@opencode/util/effect/app-node"
|
||||
import { Bus } from "./bus.js"
|
||||
|
||||
export const ID = Client.ID
|
||||
export type ID = Client.ID
|
||||
|
||||
export const Info = Client.Info
|
||||
export type Info = Client.Info
|
||||
|
||||
export const Kind = Client.Kind
|
||||
export type Kind = Client.Kind
|
||||
|
||||
export const CreateInput = Client.CreateInput
|
||||
export type CreateInput = Client.CreateInput
|
||||
|
||||
export const UpdateInput = Client.UpdateInput
|
||||
export type UpdateInput = Client.UpdateInput
|
||||
|
||||
export const Activate = Client.Activate
|
||||
export type Activate = Client.Activate
|
||||
|
||||
export { Event } from "@opencode/schema/client"
|
||||
|
||||
// Presence is process-local. Clients that miss this heartbeat window are dropped.
|
||||
export const STALE_MS = 45_000
|
||||
|
||||
export class NotFoundError extends Schema.TaggedError<NotFoundError>()("Client.NotFoundError", {
|
||||
id: ID,
|
||||
}) {
|
||||
override get message() {
|
||||
return `Client not found: ${this.id}`
|
||||
}
|
||||
}
|
||||
|
||||
export class AlreadyExistsError extends Schema.TaggedError<AlreadyExistsError>()("Client.AlreadyExistsError", {
|
||||
id: ID,
|
||||
}) {
|
||||
override get message() {
|
||||
return `Client already exists: ${this.id}`
|
||||
}
|
||||
}
|
||||
|
||||
export type RegisterInput = CreateInput & { readonly id?: ID }
|
||||
|
||||
export interface Interface {
|
||||
readonly register: (input: RegisterInput) => Effect.Effect<Info, AlreadyExistsError>
|
||||
readonly list: () => Effect.Effect<ReadonlyArray<Info>>
|
||||
readonly get: (id: ID) => Effect.Effect<Info, NotFoundError>
|
||||
readonly update: (id: ID, input: UpdateInput) => Effect.Effect<Info, NotFoundError>
|
||||
readonly remove: (id: ID) => Effect.Effect<void, NotFoundError>
|
||||
readonly activate: (sessionID: SessionID) => Effect.Effect<Activate>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/Client") {}
|
||||
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const bus = yield* Bus.Service
|
||||
const clients = new Map<ID, Info>()
|
||||
|
||||
const prune = (now: number) => {
|
||||
for (const [id, info] of clients) {
|
||||
if (now - info.updatedAt <= STALE_MS) continue
|
||||
clients.delete(id)
|
||||
}
|
||||
}
|
||||
|
||||
const require = (id: ID, now: number) => {
|
||||
prune(now)
|
||||
const info = clients.get(id)
|
||||
if (!info) return new NotFoundError({ id })
|
||||
return info
|
||||
}
|
||||
|
||||
const register = Effect.fn("Client.register")(function* (input: RegisterInput) {
|
||||
const now = yield* Clock.currentTimeMillis
|
||||
prune(now)
|
||||
const id = input.id ?? ID.create()
|
||||
if (clients.has(id)) return yield* new AlreadyExistsError({ id })
|
||||
const focused = input.focused === true
|
||||
const info: Info = {
|
||||
id,
|
||||
kind: input.kind,
|
||||
sessions: input.sessions ?? [],
|
||||
focused,
|
||||
focusedAt: focused ? now : 0,
|
||||
updatedAt: now,
|
||||
...(input.name === undefined ? {} : { name: input.name }),
|
||||
...(input.pid === undefined ? {} : { pid: input.pid }),
|
||||
}
|
||||
clients.set(id, info)
|
||||
return info
|
||||
})
|
||||
|
||||
const list = Effect.fn("Client.list")(function* () {
|
||||
prune(yield* Clock.currentTimeMillis)
|
||||
return Array.from(clients.values())
|
||||
})
|
||||
|
||||
const get = Effect.fn("Client.get")(function* (id: ID) {
|
||||
const info = require(id, yield* Clock.currentTimeMillis)
|
||||
if (info instanceof NotFoundError) return yield* info
|
||||
return info
|
||||
})
|
||||
|
||||
const update = Effect.fn("Client.update")(function* (id: ID, input: UpdateInput) {
|
||||
const now = yield* Clock.currentTimeMillis
|
||||
const current = require(id, now)
|
||||
if (current instanceof NotFoundError) return yield* current
|
||||
const focused = input.focused ?? current.focused
|
||||
const name = input.name !== undefined ? input.name : current.name
|
||||
const pid = input.pid !== undefined ? input.pid : current.pid
|
||||
const info: Info = {
|
||||
id,
|
||||
kind: current.kind,
|
||||
sessions: input.sessions ?? current.sessions,
|
||||
focused,
|
||||
focusedAt: input.focused === true && !current.focused ? now : current.focusedAt,
|
||||
updatedAt: now,
|
||||
...(name === undefined ? {} : { name }),
|
||||
...(pid === undefined ? {} : { pid }),
|
||||
}
|
||||
clients.set(id, info)
|
||||
return info
|
||||
})
|
||||
|
||||
const remove = Effect.fn("Client.remove")(function* (id: ID) {
|
||||
const info = require(id, yield* Clock.currentTimeMillis)
|
||||
if (info instanceof NotFoundError) return yield* info
|
||||
clients.delete(id)
|
||||
})
|
||||
|
||||
const activate = Effect.fn("Client.activate")(function* (sessionID: SessionID) {
|
||||
const now = yield* Clock.currentTimeMillis
|
||||
prune(now)
|
||||
const picked = pick(Array.from(clients.values()), sessionID)
|
||||
if (!picked) return { outcome: "none" as const }
|
||||
yield* bus.publish(Client.Event.Activate, { clientID: picked.id, sessionID })
|
||||
return { outcome: "activated" as const, client: picked }
|
||||
})
|
||||
|
||||
return Service.of({ register, list, get, update, remove, activate })
|
||||
}),
|
||||
)
|
||||
|
||||
export const node = makeGlobalNode({ service: Service, layer, deps: [Bus.node] })
|
||||
|
||||
function pick(live: ReadonlyArray<Info>, sessionID: SessionID) {
|
||||
const candidates = live.filter((info) => canTake(info, sessionID))
|
||||
if (candidates.length === 0) return
|
||||
return candidates.reduce((best, info) => (compare(info, best, sessionID) > 0 ? info : best))
|
||||
}
|
||||
|
||||
function canTake(info: Info, sessionID: SessionID) {
|
||||
if (info.kind === "tui" || info.kind === "desktop") return true
|
||||
return info.sessions.includes(sessionID)
|
||||
}
|
||||
|
||||
function compare(left: Info, right: Info, sessionID: SessionID) {
|
||||
const showing = Number(left.sessions.includes(sessionID)) - Number(right.sessions.includes(sessionID))
|
||||
if (showing !== 0) return showing
|
||||
const focused = Number(left.focused) - Number(right.focused)
|
||||
if (focused !== 0) return focused
|
||||
if (left.focusedAt !== right.focusedAt) return left.focusedAt - right.focusedAt
|
||||
return left.updatedAt - right.updatedAt
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Duration, Effect } from "effect"
|
||||
import { TestClock } from "effect/testing"
|
||||
import { AppNodeBuilder } from "@opencode/core/effect/app-node-builder"
|
||||
import { LayerNode } from "@opencode/util/effect/layer-node"
|
||||
import { Bus } from "@opencode/core/bus"
|
||||
import { Client } from "@opencode/core/client"
|
||||
import { SessionSchema } from "@opencode/core/session/schema"
|
||||
import { testEffect } from "./lib/effect"
|
||||
|
||||
const clients = AppNodeBuilder.build(LayerNode.group([Bus.node, Client.node]))
|
||||
const it = testEffect(clients)
|
||||
const sessionA = SessionSchema.ID.make("ses_a")
|
||||
const sessionB = SessionSchema.ID.make("ses_b")
|
||||
|
||||
describe("Client presence", () => {
|
||||
it.effect("registers, lists, updates, and removes a client", () =>
|
||||
Effect.gen(function* () {
|
||||
const service = yield* Client.Service
|
||||
const created = yield* service.register({ kind: "tui", sessions: [sessionA], focused: true })
|
||||
expect(created.kind).toBe("tui")
|
||||
expect(created.sessions).toEqual([sessionA])
|
||||
expect(created.focused).toBe(true)
|
||||
expect(yield* service.list()).toEqual([created])
|
||||
expect(yield* service.get(created.id)).toEqual(created)
|
||||
|
||||
const updated = yield* service.update(created.id, { sessions: [sessionB], focused: false })
|
||||
expect(updated.sessions).toEqual([sessionB])
|
||||
expect(updated.focused).toBe(false)
|
||||
expect(updated.focusedAt).toBe(created.focusedAt)
|
||||
|
||||
yield* service.remove(created.id)
|
||||
expect(yield* service.list()).toEqual([])
|
||||
expect(yield* Effect.flip(service.get(created.id))).toBeInstanceOf(Client.NotFoundError)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("rejects a reused client id", () =>
|
||||
Effect.gen(function* () {
|
||||
const service = yield* Client.Service
|
||||
const id = Client.ID.create("client_dup")
|
||||
yield* service.register({ id, kind: "tui" })
|
||||
expect(yield* Effect.flip(service.register({ id, kind: "desktop" }))).toBeInstanceOf(Client.AlreadyExistsError)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("does not treat a focused heartbeat as a new focus", () =>
|
||||
Effect.gen(function* () {
|
||||
const service = yield* Client.Service
|
||||
const first = yield* service.register({ kind: "tui", sessions: [sessionA], focused: true })
|
||||
yield* TestClock.adjust(Duration.millis(5))
|
||||
const second = yield* service.register({ kind: "tui", sessions: [sessionA], focused: false })
|
||||
yield* TestClock.adjust(Duration.millis(5))
|
||||
const gained = yield* service.update(second.id, { focused: true })
|
||||
yield* TestClock.adjust(Duration.millis(5))
|
||||
const heartbeated = yield* service.update(first.id, { focused: true })
|
||||
expect(heartbeated.focusedAt).toBe(first.focusedAt)
|
||||
expect(gained.focusedAt).toBeGreaterThan(first.focusedAt)
|
||||
const result = yield* service.activate(sessionA)
|
||||
expect(result.client?.id).toBe(second.id)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("activates the last-focused client showing the session", () =>
|
||||
Effect.gen(function* () {
|
||||
const service = yield* Client.Service
|
||||
const bus = yield* Bus.Service
|
||||
const events: Array<string> = []
|
||||
const unsubscribe = yield* bus.listen((event) =>
|
||||
event.type === Client.Event.Activate.type
|
||||
? Effect.sync(() => {
|
||||
events.push((event.data as { readonly clientID: string }).clientID)
|
||||
})
|
||||
: Effect.void,
|
||||
)
|
||||
yield* Effect.addFinalizer(() => unsubscribe)
|
||||
|
||||
const older = yield* service.register({ kind: "tui", sessions: [sessionA], focused: true })
|
||||
yield* TestClock.adjust(Duration.millis(5))
|
||||
const newer = yield* service.register({ kind: "desktop", sessions: [sessionA], focused: true })
|
||||
const result = yield* service.activate(sessionA)
|
||||
expect(result.outcome).toBe("activated")
|
||||
expect(result.client?.id).toBe(newer.id)
|
||||
expect(events).toEqual([newer.id])
|
||||
expect(older.id).not.toBe(newer.id)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("falls back to any live tui or desktop when no client shows the session", () =>
|
||||
Effect.gen(function* () {
|
||||
const service = yield* Client.Service
|
||||
const tui = yield* service.register({ kind: "tui", sessions: [sessionA], focused: true })
|
||||
const result = yield* service.activate(sessionB)
|
||||
expect(result).toEqual({ outcome: "activated", client: tui })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("does not route to a stale client", () =>
|
||||
Effect.gen(function* () {
|
||||
const service = yield* Client.Service
|
||||
yield* service.register({ kind: "tui", sessions: [sessionA], focused: true })
|
||||
yield* TestClock.adjust(Duration.millis(Client.STALE_MS + 1))
|
||||
expect(yield* service.activate(sessionA)).toEqual({ outcome: "none" })
|
||||
expect(yield* service.list()).toEqual([])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("does not steal a web client that is not showing the session", () =>
|
||||
Effect.gen(function* () {
|
||||
const service = yield* Client.Service
|
||||
yield* service.register({ kind: "web", sessions: [sessionA], focused: true })
|
||||
expect(yield* service.activate(sessionB)).toEqual({ outcome: "none" })
|
||||
}),
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,264 @@
|
||||
function asArray(value) {
|
||||
if (!value) return []
|
||||
if (Array.isArray(value)) return value.slice()
|
||||
var length = Number(value.length || 0)
|
||||
if (!isFinite(length) || length <= 0) return []
|
||||
var list = []
|
||||
for (var i = 0; i < length; i++) list.push(value[i])
|
||||
return list
|
||||
}
|
||||
|
||||
function asObject(value) {
|
||||
return value && typeof value === "object" && !Array.isArray(value) ? value : {}
|
||||
}
|
||||
|
||||
function sessionId(session) {
|
||||
return session ? String(session.id || "") : ""
|
||||
}
|
||||
|
||||
function directoryOf(session) {
|
||||
if (!session) return ""
|
||||
var location = asObject(session.location)
|
||||
return String(location.directory || "")
|
||||
}
|
||||
|
||||
function projectLabel(session) {
|
||||
var directory = directoryOf(session)
|
||||
if (directory === "") return "Unknown project"
|
||||
var parts = directory.split("/")
|
||||
for (var i = parts.length - 1; i >= 0; i--) {
|
||||
if (parts[i] !== "") return parts[i]
|
||||
}
|
||||
return directory
|
||||
}
|
||||
|
||||
function modelLabel(session) {
|
||||
var model = session ? session.model : null
|
||||
if (!model || typeof model !== "object") return ""
|
||||
return String(model.id || "")
|
||||
}
|
||||
|
||||
function titleOf(session) {
|
||||
var title = session ? String(session.title || "").trim() : ""
|
||||
return title !== "" ? title : "Untitled session"
|
||||
}
|
||||
|
||||
function updatedMs(session) {
|
||||
var time = session && session.time ? session.time : null
|
||||
var value = time ? Number(time.updated || time.created || 0) : 0
|
||||
return isFinite(value) ? value : 0
|
||||
}
|
||||
|
||||
function isRunning(session, active) {
|
||||
var id = sessionId(session)
|
||||
if (id === "") return false
|
||||
var entry = asObject(active)[id]
|
||||
return !!(entry && entry.type === "running")
|
||||
}
|
||||
|
||||
function isUnread(session) {
|
||||
if (!session || !session.time) return false
|
||||
var idle = Number(session.time.idle || 0)
|
||||
if (!(idle > 0)) return false
|
||||
var viewed = session.time.viewed
|
||||
if (viewed === undefined || viewed === null) return true
|
||||
return Number(viewed) < idle
|
||||
}
|
||||
|
||||
function statusOf(session, active) {
|
||||
if (isRunning(session, active)) return "running"
|
||||
if (isUnread(session)) return "unread"
|
||||
var outcome = session ? String(session.outcome || "") : ""
|
||||
if (outcome === "failed" || outcome === "interrupted") return outcome
|
||||
return "idle"
|
||||
}
|
||||
|
||||
function statusLabel(status) {
|
||||
if (status === "running") return "Running"
|
||||
if (status === "unread") return "Needs attention"
|
||||
if (status === "failed") return "Failed"
|
||||
if (status === "interrupted") return "Interrupted"
|
||||
return "Idle"
|
||||
}
|
||||
|
||||
function formatAge(ms, nowMs) {
|
||||
var age = Number(nowMs) - Number(ms)
|
||||
if (!isFinite(age) || age < 0) age = 0
|
||||
var minutes = Math.floor(age / 60000)
|
||||
if (minutes < 1) return "now"
|
||||
if (minutes < 60) return minutes + "m"
|
||||
var hours = Math.floor(minutes / 60)
|
||||
if (hours < 24) return hours + "h"
|
||||
return Math.floor(hours / 24) + "d"
|
||||
}
|
||||
|
||||
function decorate(session, active, nowMs) {
|
||||
var status = statusOf(session, active)
|
||||
return {
|
||||
id: sessionId(session),
|
||||
title: titleOf(session),
|
||||
project: projectLabel(session),
|
||||
directory: directoryOf(session),
|
||||
model: modelLabel(session),
|
||||
agent: session ? String(session.agent || "") : "",
|
||||
status: status,
|
||||
statusLabel: statusLabel(status),
|
||||
outcome: session ? String(session.outcome || "") : "",
|
||||
age: formatAge(updatedMs(session), nowMs),
|
||||
updated: updatedMs(session),
|
||||
running: status === "running",
|
||||
unread: status === "unread"
|
||||
}
|
||||
}
|
||||
|
||||
function decorateAll(sessions, active, nowMs) {
|
||||
var decorated = []
|
||||
var list = asArray(sessions)
|
||||
for (var i = 0; i < list.length; i++) {
|
||||
var row = decorate(list[i], active, nowMs)
|
||||
if (row.id !== "") decorated.push(row)
|
||||
}
|
||||
return decorated
|
||||
}
|
||||
|
||||
function latest(sessions, active, nowMs) {
|
||||
var decorated = decorateAll(sessions, active, nowMs)
|
||||
decorated.sort(function(a, b) { return b.updated - a.updated })
|
||||
return decorated
|
||||
}
|
||||
|
||||
function compareDecorated(a, b) {
|
||||
if (a.running !== b.running) return a.running ? -1 : 1
|
||||
if (a.unread !== b.unread) return a.unread ? -1 : 1
|
||||
return b.updated - a.updated
|
||||
}
|
||||
|
||||
function groups(sessions, active, nowMs) {
|
||||
var decorated = decorateAll(sessions, active, nowMs)
|
||||
decorated.sort(compareDecorated)
|
||||
|
||||
var order = []
|
||||
var byProject = {}
|
||||
for (var j = 0; j < decorated.length; j++) {
|
||||
var item = decorated[j]
|
||||
var key = item.directory || item.project
|
||||
if (!byProject[key]) {
|
||||
byProject[key] = { label: item.project, sessions: [] }
|
||||
order.push(key)
|
||||
}
|
||||
byProject[key].sessions.push(item)
|
||||
}
|
||||
|
||||
var result = []
|
||||
for (var k = 0; k < order.length; k++) result.push(byProject[order[k]])
|
||||
return result
|
||||
}
|
||||
|
||||
function counts(sessions, active) {
|
||||
var running = 0
|
||||
var unread = 0
|
||||
var list = asArray(sessions)
|
||||
for (var i = 0; i < list.length; i++) {
|
||||
if (isRunning(list[i], active)) running++
|
||||
else if (isUnread(list[i])) unread++
|
||||
}
|
||||
return { running: running, unread: unread, total: list.length }
|
||||
}
|
||||
|
||||
function flatten(grouped) {
|
||||
var rows = []
|
||||
var list = asArray(grouped)
|
||||
for (var i = 0; i < list.length; i++) {
|
||||
var sessions = asArray(list[i].sessions)
|
||||
for (var j = 0; j < sessions.length; j++) rows.push(sessions[j])
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
function tokenParts(tokens) {
|
||||
var value = asObject(tokens)
|
||||
var cache = asObject(value.cache)
|
||||
return {
|
||||
input: Number(value.input || 0),
|
||||
output: Number(value.output || 0),
|
||||
reasoning: Number(value.reasoning || 0),
|
||||
cacheRead: Number(cache.read || 0),
|
||||
cacheWrite: Number(cache.write || 0)
|
||||
}
|
||||
}
|
||||
|
||||
function tokenTotal(parts) {
|
||||
return parts.input + parts.output + parts.reasoning + parts.cacheRead + parts.cacheWrite
|
||||
}
|
||||
|
||||
function formatTokenCount(n) {
|
||||
var value = Number(n)
|
||||
if (!isFinite(value) || value <= 0) return "0"
|
||||
if (value >= 1e9) return (value / 1e9).toFixed(1) + "B"
|
||||
if (value >= 1e6) return (value / 1e6).toFixed(1) + "M"
|
||||
if (value >= 1e3) return (value / 1e3).toFixed(1) + "K"
|
||||
return String(Math.round(value))
|
||||
}
|
||||
|
||||
function formatMoney(value) {
|
||||
var amount = Number(value)
|
||||
if (!isFinite(amount)) amount = 0
|
||||
return "$" + amount.toFixed(2)
|
||||
}
|
||||
|
||||
function emptyUsage() {
|
||||
return { cost: 0, tokens: ({ input: 0, output: 0, reasoning: 0, cacheRead: 0, cacheWrite: 0 }), totalTokens: 0, models: [], sessions: 0 }
|
||||
}
|
||||
|
||||
function modelNameFromStats(row) {
|
||||
var model = asObject(row && row.model)
|
||||
var name = String(model.id || "")
|
||||
return name !== "" ? name : "Unknown"
|
||||
}
|
||||
|
||||
function usageFromStats(stats) {
|
||||
if (!stats) return emptyUsage()
|
||||
var data = asObject(stats)
|
||||
var parts = tokenParts(data.tokens)
|
||||
var models = asArray(data.models).map(function(row) {
|
||||
var tokens = tokenParts(row.tokens)
|
||||
return {
|
||||
name: modelNameFromStats(row),
|
||||
total: tokenTotal(tokens),
|
||||
cost: Number(row.cost || 0),
|
||||
input: tokens.input,
|
||||
output: tokens.output,
|
||||
reasoning: tokens.reasoning,
|
||||
cacheRead: tokens.cacheRead,
|
||||
cacheWrite: tokens.cacheWrite
|
||||
}
|
||||
})
|
||||
models.sort(function(a, b) { return b.cost - a.cost })
|
||||
return {
|
||||
cost: Number(data.cost || 0),
|
||||
tokens: parts,
|
||||
totalTokens: tokenTotal(parts),
|
||||
models: models.slice(0, 4),
|
||||
sessions: Number(data.sessions || 0)
|
||||
}
|
||||
}
|
||||
|
||||
function parseSnapshot(raw) {
|
||||
try {
|
||||
var parsed = JSON.parse(String(raw || ""))
|
||||
if (!parsed || typeof parsed !== "object") return { ok: false, error: "offline" }
|
||||
if (parsed.ok !== true) return { ok: false, error: String(parsed.error || "offline") }
|
||||
var go = parsed.go && typeof parsed.go === "object" ? parsed.go : null
|
||||
var stats = parsed.stats && typeof parsed.stats === "object" ? parsed.stats : null
|
||||
return {
|
||||
ok: true,
|
||||
url: String(parsed.url || ""),
|
||||
sessions: asArray(parsed.sessions),
|
||||
active: asObject(parsed.active),
|
||||
goLimits: go ? asArray(go.limits) : [],
|
||||
stats: stats
|
||||
}
|
||||
} catch (e) {
|
||||
return { ok: false, error: "offline" }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import QtQuick
|
||||
import QtQuick.Shapes
|
||||
import qs.Commons
|
||||
|
||||
// Official OpenCode mark: a 16×20 block O with the recessed inner slab.
|
||||
// Drawn natively so the bar can tint it with the current foreground.
|
||||
Item {
|
||||
id: root
|
||||
|
||||
property real iconSize: Style.font.icon
|
||||
property color color: Color.foreground
|
||||
property color shadowColor: Qt.rgba(color.r, color.g, color.b, 0.32)
|
||||
|
||||
width: iconSize
|
||||
height: iconSize
|
||||
implicitWidth: iconSize
|
||||
implicitHeight: iconSize
|
||||
|
||||
readonly property real s: iconSize / 20
|
||||
readonly property real markW: 16 * s
|
||||
readonly property real markH: 20 * s
|
||||
readonly property real ox: (width - markW) / 2
|
||||
readonly property real oy: (height - markH) / 2
|
||||
|
||||
Rectangle {
|
||||
x: root.ox + 4 * root.s
|
||||
y: root.oy + 8 * root.s
|
||||
width: 8 * root.s
|
||||
height: 8 * root.s
|
||||
color: root.shadowColor
|
||||
}
|
||||
|
||||
Shape {
|
||||
anchors.fill: parent
|
||||
antialiasing: true
|
||||
layer.enabled: true
|
||||
layer.samples: 4
|
||||
|
||||
ShapePath {
|
||||
fillColor: root.color
|
||||
strokeWidth: 0
|
||||
fillRule: ShapePath.OddEvenFill
|
||||
startX: root.ox
|
||||
startY: root.oy
|
||||
PathLine { x: root.ox + root.markW; y: root.oy }
|
||||
PathLine { x: root.ox + root.markW; y: root.oy + root.markH }
|
||||
PathLine { x: root.ox; y: root.oy + root.markH }
|
||||
PathLine { x: root.ox; y: root.oy }
|
||||
PathMove { x: root.ox + 4 * root.s; y: root.oy + 4 * root.s }
|
||||
PathLine { x: root.ox + 12 * root.s; y: root.oy + 4 * root.s }
|
||||
PathLine { x: root.ox + 12 * root.s; y: root.oy + 16 * root.s }
|
||||
PathLine { x: root.ox + 4 * root.s; y: root.oy + 16 * root.s }
|
||||
PathLine { x: root.ox + 4 * root.s; y: root.oy + 4 * root.s }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,794 @@
|
||||
import QtQuick
|
||||
import QtQuick.Controls
|
||||
import QtQuick.Layouts
|
||||
import Quickshell
|
||||
import Quickshell.Io
|
||||
import qs.Commons
|
||||
import qs.Ui
|
||||
import "Model.js" as Model
|
||||
|
||||
Panel {
|
||||
id: root
|
||||
moduleName: "simon.opencode"
|
||||
ipcTarget: "simon.opencode"
|
||||
manageIpc: false
|
||||
|
||||
property bool cursorActive: false
|
||||
property bool keyboardMoving: false
|
||||
property bool activating: false
|
||||
property int selectedIndex: 0
|
||||
property string viewMode: "latest"
|
||||
property string activateSessionId: ""
|
||||
|
||||
readonly property color foreground: bar ? bar.foreground : Color.foreground
|
||||
readonly property color urgent: bar ? bar.urgent : Color.urgent
|
||||
readonly property color dim: Qt.darker(foreground, 1.55)
|
||||
readonly property string fontFamily: bar ? bar.fontFamily : Style.font.family
|
||||
readonly property color barIconColor: alarming ? urgent : (service.online ? barForeground : dim)
|
||||
readonly property color track: Style.selectedFillFor(foreground, Color.accent)
|
||||
readonly property var views: ["latest", "projects", "usage"]
|
||||
readonly property int viewIndex: viewMode === "projects" ? 1 : (viewMode === "usage" ? 2 : 0)
|
||||
readonly property var rows: viewMode === "projects" ? service.flat : (viewMode === "latest" ? service.latest : [])
|
||||
readonly property var selectedSession: {
|
||||
if (rows.length === 0) return null
|
||||
return rows[Math.max(0, Math.min(selectedIndex, rows.length - 1))]
|
||||
}
|
||||
readonly property bool alarming: service.runningCount > 0 || service.unreadCount > 0
|
||||
readonly property string heroMeta: {
|
||||
if (!service.online) return "Service is offline"
|
||||
if (service.unreadCount > 0 && service.runningCount === 0) return service.unreadCount + " need attention"
|
||||
if (service.runningCount === 0 && service.totalCount > 0) return "Idle"
|
||||
if (service.totalCount === 0) return "No sessions yet"
|
||||
return service.runningCount + " running"
|
||||
}
|
||||
|
||||
// Nothing to report, nothing in the bar: the slot collapses until the shared
|
||||
// OpenCode service is reachable, then the icon arrives on its own.
|
||||
visible: service.online || service.totalCount > 0
|
||||
implicitWidth: button.implicitWidth
|
||||
implicitHeight: button.implicitHeight
|
||||
|
||||
function clampIndex() {
|
||||
if (rows.length === 0) {
|
||||
selectedIndex = 0
|
||||
return
|
||||
}
|
||||
if (selectedIndex >= rows.length) selectedIndex = rows.length - 1
|
||||
if (selectedIndex < 0) selectedIndex = 0
|
||||
}
|
||||
|
||||
function moveCursor(dy) {
|
||||
if (viewMode === "usage") return
|
||||
keyboardMoving = true
|
||||
cursorActive = true
|
||||
if (rows.length === 0) return
|
||||
selectedIndex = Math.max(0, Math.min(rows.length - 1, selectedIndex + dy))
|
||||
Qt.callLater(function() { root.keyboardMoving = false })
|
||||
}
|
||||
|
||||
function setSessionCursor(index) {
|
||||
cursorActive = true
|
||||
selectedIndex = index
|
||||
}
|
||||
|
||||
function selectView(index) {
|
||||
var wrapped = ((index % root.views.length) + root.views.length) % root.views.length
|
||||
keyboardMoving = false
|
||||
cursorActive = false
|
||||
selectedIndex = 0
|
||||
viewMode = root.views[wrapped]
|
||||
resetScroll()
|
||||
}
|
||||
|
||||
function resetScroll() {
|
||||
if (panelFlick) panelFlick.contentY = 0
|
||||
scrollReset.restart()
|
||||
}
|
||||
|
||||
function scrollItemIntoView(item) {
|
||||
if (!panelFlick || !item) return
|
||||
Qt.callLater(function() {
|
||||
if (!item) return
|
||||
var margin = Style.space(6)
|
||||
var point = item.mapToItem(panelFlick.contentItem, 0, 0)
|
||||
var top = point.y
|
||||
var bottom = top + item.height
|
||||
var viewTop = panelFlick.contentY
|
||||
var viewBottom = viewTop + panelFlick.height
|
||||
var maxY = Math.max(0, panelFlick.contentHeight - panelFlick.height)
|
||||
if (top < viewTop + margin) panelFlick.contentY = Math.max(0, top - margin)
|
||||
else if (bottom > viewBottom - margin) panelFlick.contentY = Math.min(maxY, bottom + margin - panelFlick.height)
|
||||
})
|
||||
}
|
||||
|
||||
function scriptPath() {
|
||||
var url = Qt.resolvedUrl("fetch.ts").toString()
|
||||
if (url.indexOf("file://") === 0) return decodeURIComponent(url.slice(7))
|
||||
return url
|
||||
}
|
||||
|
||||
function launchFallback(sessionId) {
|
||||
if (!sessionId) return
|
||||
Util.execArgv([
|
||||
"omarchy-launch-or-focus-tui",
|
||||
"--app-id=org.omarchy.opencode." + sessionId,
|
||||
"opencode",
|
||||
"--session",
|
||||
sessionId
|
||||
])
|
||||
}
|
||||
|
||||
function attachSession(session) {
|
||||
if (!session || !session.id) return
|
||||
activateSessionId = session.id
|
||||
activating = true
|
||||
activateProcess.command = ["bun", scriptPath(), "activate", session.id]
|
||||
activateProcess.running = true
|
||||
}
|
||||
|
||||
function attachSelected() {
|
||||
attachSession(selectedSession)
|
||||
}
|
||||
|
||||
function launchNew() {
|
||||
Util.execArgv(["omarchy-launch-tui", "opencode"])
|
||||
root.close()
|
||||
}
|
||||
|
||||
function statusColor(status) {
|
||||
if (status === "running" || status === "unread" || status === "failed") return urgent
|
||||
return dim
|
||||
}
|
||||
|
||||
function statusMark(status) {
|
||||
if (status === "running") return "●"
|
||||
if (status === "unread") return "◉"
|
||||
if (status === "failed") return "!"
|
||||
return "○"
|
||||
}
|
||||
|
||||
function rowMeta(session) {
|
||||
if (!session) return ""
|
||||
var parts = []
|
||||
if (viewMode === "latest" && session.project !== "") parts.push(session.project)
|
||||
if (session.model !== "") parts.push(session.model)
|
||||
if (session.age !== "") parts.push(session.age)
|
||||
if (session.status !== "running" && session.outcome !== "") parts.push(session.outcome)
|
||||
return parts.join(" · ")
|
||||
}
|
||||
|
||||
function clamp(v, lo, hi) { return Math.max(lo, Math.min(hi, v)) }
|
||||
function alpha(c, a) { return Qt.rgba(c.r, c.g, c.b, a) }
|
||||
|
||||
function usageSummary() {
|
||||
var usage = service.usage || {}
|
||||
var parts = []
|
||||
if (Number(usage.sessions || 0) > 0) parts.push(usage.sessions + " session" + (usage.sessions === 1 ? "" : "s"))
|
||||
if (Number(usage.totalTokens || 0) > 0) parts.push(Model.formatTokenCount(usage.totalTokens) + " tokens")
|
||||
if (parts.length === 0) return "No usage today"
|
||||
return parts.join(" · ")
|
||||
}
|
||||
|
||||
function formatDuration(ms) {
|
||||
if (!(ms > 0)) return "now"
|
||||
var minutes = Math.floor(ms / 60000)
|
||||
var hours = Math.floor(minutes / 60)
|
||||
var days = Math.floor(hours / 24)
|
||||
if (days > 0) return days + "d " + (hours % 24) + "h"
|
||||
if (hours > 0) return hours + "h " + (minutes % 60) + "m"
|
||||
return Math.max(1, minutes) + "m"
|
||||
}
|
||||
|
||||
function resetMsFor(window) {
|
||||
if (!window || !window.resetsAt) return -1
|
||||
var ms = Date.parse(String(window.resetsAt))
|
||||
return isFinite(ms) ? ms - Date.now() : -1
|
||||
}
|
||||
|
||||
function modelTooltip(row) {
|
||||
if (!row) return ""
|
||||
return "In " + Model.formatTokenCount(row.input)
|
||||
+ " · out " + Model.formatTokenCount(row.output)
|
||||
+ " · reasoning " + Model.formatTokenCount(row.reasoning)
|
||||
+ " · cache read " + Model.formatTokenCount(row.cacheRead)
|
||||
+ " · cache write " + Model.formatTokenCount(row.cacheWrite)
|
||||
}
|
||||
|
||||
function tooltipText() {
|
||||
if (service.runningCount > 0) return service.runningCount + " running OpenCode session" + (service.runningCount === 1 ? "" : "s")
|
||||
if (service.unreadCount > 0) return service.unreadCount + " OpenCode session" + (service.unreadCount === 1 ? "" : "s") + " need attention"
|
||||
return "OpenCode sessions"
|
||||
}
|
||||
|
||||
onOpenedChanged: if (opened) {
|
||||
cursorActive = false
|
||||
if (panelFlick) panelFlick.contentY = 0
|
||||
service.refresh()
|
||||
Qt.callLater(function() { keyCatcher.forceActiveFocus() })
|
||||
}
|
||||
|
||||
Connections {
|
||||
target: service
|
||||
function onFlatChanged() { root.clampIndex() }
|
||||
function onLatestChanged() { root.clampIndex() }
|
||||
}
|
||||
|
||||
Service {
|
||||
id: service
|
||||
settings: root.settings
|
||||
opened: root.opened
|
||||
}
|
||||
|
||||
Process {
|
||||
id: activateProcess
|
||||
running: false
|
||||
stdout: StdioCollector {
|
||||
waitForEnd: true
|
||||
onStreamFinished: {
|
||||
var activated = false
|
||||
try {
|
||||
var parsed = JSON.parse(text)
|
||||
activated = !!(parsed && parsed.activated)
|
||||
} catch (e) {}
|
||||
if (!activated) root.launchFallback(root.activateSessionId)
|
||||
root.activating = false
|
||||
root.close()
|
||||
}
|
||||
}
|
||||
onExited: function(exitCode) {
|
||||
if (exitCode !== 0 && root.activating) {
|
||||
root.activating = false
|
||||
root.launchFallback(root.activateSessionId)
|
||||
root.close()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
IpcHandler {
|
||||
target: root.ipcTarget
|
||||
function open(): void { root.open() }
|
||||
function close(): void { root.close() }
|
||||
function show(): void { root.open() }
|
||||
function hide(): void { root.close() }
|
||||
function toggle(): void { root.toggle() }
|
||||
function refresh(): string { service.refresh(); return "ok" }
|
||||
}
|
||||
|
||||
BarIconButton {
|
||||
id: button
|
||||
anchors.fill: parent
|
||||
bar: root.bar
|
||||
active: root.alarming
|
||||
useActiveColor: false
|
||||
tooltipText: root.tooltipText()
|
||||
iconComponent: Component {
|
||||
Item {
|
||||
OpenCodeIcon {
|
||||
anchors.centerIn: parent
|
||||
iconSize: Style.space(11)
|
||||
color: root.barIconColor
|
||||
}
|
||||
}
|
||||
}
|
||||
onPressed: function(buttonCode) {
|
||||
if (buttonCode === Qt.RightButton) root.launchNew()
|
||||
else if (buttonCode === Qt.MiddleButton) service.refresh()
|
||||
else root.toggle()
|
||||
}
|
||||
}
|
||||
|
||||
KeyboardPanel {
|
||||
id: panel
|
||||
anchorItem: button
|
||||
owner: root
|
||||
bar: root.bar
|
||||
open: root.opened
|
||||
focusTarget: keyCatcher
|
||||
contentWidth: panel.fittedContentWidth(Style.space(380))
|
||||
contentHeight: Style.space(560)
|
||||
|
||||
PanelKeyCatcher {
|
||||
id: keyCatcher
|
||||
anchors.fill: parent
|
||||
onMoveRequested: function(dx, dy) {
|
||||
if (dx !== 0) {
|
||||
root.selectView(root.viewIndex + dx)
|
||||
return
|
||||
}
|
||||
if (!root.cursorActive) { root.cursorActive = true; return }
|
||||
if (dy !== 0) root.moveCursor(dy)
|
||||
}
|
||||
onActivateRequested: if (root.cursorActive && root.viewMode !== "usage") root.attachSelected()
|
||||
onCloseRequested: root.close()
|
||||
onTabRequested: function(direction) { root.switchPanel(direction) }
|
||||
onTextKey: function(t) {
|
||||
if (t === "r" || t === "R") service.refresh()
|
||||
else if (t === "n" || t === "N") root.launchNew()
|
||||
else if (t === "h" || t === "H") root.selectView(root.viewIndex - 1)
|
||||
else if (t === "l" || t === "L") root.selectView(root.viewIndex + 1)
|
||||
}
|
||||
|
||||
Flickable {
|
||||
id: panelFlick
|
||||
anchors.fill: parent
|
||||
contentWidth: width
|
||||
contentHeight: column.implicitHeight
|
||||
clip: true
|
||||
boundsBehavior: Flickable.StopAtBounds
|
||||
flickableDirection: Flickable.VerticalFlick
|
||||
interactive: contentHeight > height
|
||||
ScrollBar.vertical: ScrollBar { policy: ScrollBar.AsNeeded }
|
||||
|
||||
Column {
|
||||
id: column
|
||||
width: panelFlick.width
|
||||
spacing: Style.space(12)
|
||||
|
||||
PanelHero {
|
||||
id: hero
|
||||
width: parent.width
|
||||
title: "OpenCode"
|
||||
meta: root.heroMeta
|
||||
detail: service.runningCount > 0 ? String(service.runningCount) : ""
|
||||
foreground: root.foreground
|
||||
fontFamily: root.fontFamily
|
||||
iconOpacity: service.online ? 1.0 : 0.5
|
||||
iconComponent: Component {
|
||||
OpenCodeIcon {
|
||||
iconSize: Style.font.display
|
||||
color: root.alarming ? root.urgent : root.foreground
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Row {
|
||||
id: viewSwitch
|
||||
width: parent.width
|
||||
spacing: Style.space(4)
|
||||
|
||||
readonly property real cellWidth: (width - spacing * 2) / 3
|
||||
|
||||
Repeater {
|
||||
model: [
|
||||
{ id: "latest", label: "Latest" },
|
||||
{ id: "projects", label: "Projects" },
|
||||
{ id: "usage", label: "Usage" }
|
||||
]
|
||||
|
||||
CursorSurface {
|
||||
required property var modelData
|
||||
required property int index
|
||||
width: viewSwitch.cellWidth
|
||||
implicitHeight: tabLabel.implicitHeight + Style.space(8)
|
||||
current: root.viewMode === modelData.id
|
||||
foreground: root.foreground
|
||||
|
||||
Text {
|
||||
id: tabLabel
|
||||
anchors.centerIn: parent
|
||||
textFormat: Text.PlainText
|
||||
text: modelData.label
|
||||
color: root.viewMode === modelData.id ? root.foreground : root.dim
|
||||
font.family: root.fontFamily
|
||||
font.pixelSize: Style.font.caption
|
||||
font.bold: root.viewMode === modelData.id
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
anchors.fill: parent
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onClicked: root.selectView(index)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Column {
|
||||
id: usageSection
|
||||
visible: root.viewMode === "usage"
|
||||
width: parent.width
|
||||
spacing: Style.space(10)
|
||||
|
||||
PanelSeparator {
|
||||
foreground: root.foreground
|
||||
}
|
||||
|
||||
Column {
|
||||
visible: service.goLimits.length > 0
|
||||
width: parent.width
|
||||
spacing: Style.space(10)
|
||||
|
||||
PanelSectionHeader {
|
||||
text: "GO QUOTA"
|
||||
foreground: root.foreground
|
||||
fontFamily: root.fontFamily
|
||||
}
|
||||
|
||||
Repeater {
|
||||
model: service.goLimits
|
||||
|
||||
LimitRow {
|
||||
required property var modelData
|
||||
width: usageSection.width
|
||||
window: modelData
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Item {
|
||||
width: parent.width
|
||||
implicitHeight: Math.max(usageHeader.implicitHeight, usageCost.implicitHeight)
|
||||
|
||||
PanelSectionHeader {
|
||||
id: usageHeader
|
||||
text: "TODAY"
|
||||
foreground: root.foreground
|
||||
fontFamily: root.fontFamily
|
||||
anchors.left: parent.left
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
}
|
||||
|
||||
Text {
|
||||
id: usageCost
|
||||
textFormat: Text.PlainText
|
||||
text: Model.formatMoney(service.usage.cost)
|
||||
color: root.foreground
|
||||
font.family: root.fontFamily
|
||||
font.pixelSize: Style.font.body
|
||||
font.bold: true
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
}
|
||||
}
|
||||
|
||||
Text {
|
||||
width: parent.width
|
||||
text: root.usageSummary()
|
||||
color: root.dim
|
||||
font.family: root.fontFamily
|
||||
font.pixelSize: Style.font.caption
|
||||
}
|
||||
|
||||
Repeater {
|
||||
model: service.usage.models || []
|
||||
|
||||
ModelRow {
|
||||
required property var modelData
|
||||
width: usageSection.width
|
||||
row: modelData
|
||||
share: modelData.total / Math.max(1, (service.usage.models[0] ? service.usage.models[0].total : 1))
|
||||
}
|
||||
}
|
||||
|
||||
Text {
|
||||
visible: service.goLimits.length === 0 && Number(service.usage.sessions || 0) === 0
|
||||
width: parent.width
|
||||
text: "No Go subscription on this machine."
|
||||
color: root.dim
|
||||
font.family: root.fontFamily
|
||||
font.pixelSize: Style.font.caption
|
||||
}
|
||||
}
|
||||
|
||||
Text {
|
||||
visible: root.viewMode !== "usage" && service.totalCount === 0
|
||||
width: parent.width
|
||||
topPadding: Style.space(12)
|
||||
text: "No top-level sessions yet.\nRight-click the icon to start one."
|
||||
color: root.dim
|
||||
font.family: root.fontFamily
|
||||
font.pixelSize: Style.font.body
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
wrapMode: Text.WordWrap
|
||||
}
|
||||
|
||||
Column {
|
||||
visible: root.viewMode === "latest" && service.latest.length > 0
|
||||
width: parent.width
|
||||
spacing: Style.space(6)
|
||||
|
||||
PanelSeparator {
|
||||
foreground: root.foreground
|
||||
}
|
||||
|
||||
PanelSectionHeader {
|
||||
text: "SESSIONS"
|
||||
foreground: root.foreground
|
||||
fontFamily: root.fontFamily
|
||||
}
|
||||
|
||||
Repeater {
|
||||
model: service.latest
|
||||
|
||||
SessionRow {
|
||||
required property var modelData
|
||||
width: column.width
|
||||
session: modelData
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Repeater {
|
||||
model: root.viewMode === "projects" ? service.grouped : []
|
||||
|
||||
Column {
|
||||
required property var modelData
|
||||
width: column.width
|
||||
spacing: Style.space(6)
|
||||
|
||||
PanelSeparator {
|
||||
foreground: root.foreground
|
||||
}
|
||||
|
||||
PanelSectionHeader {
|
||||
text: String(modelData.label || "Project").toUpperCase()
|
||||
foreground: root.foreground
|
||||
fontFamily: root.fontFamily
|
||||
}
|
||||
|
||||
Repeater {
|
||||
model: modelData.sessions
|
||||
|
||||
SessionRow {
|
||||
required property var modelData
|
||||
width: column.width
|
||||
session: modelData
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Text {
|
||||
visible: service.totalCount > 0
|
||||
width: parent.width
|
||||
topPadding: Style.space(4)
|
||||
text: "h/l switch tabs · Enter opens a terminal · n new · r refresh"
|
||||
color: root.dim
|
||||
font.family: root.fontFamily
|
||||
font.pixelSize: Style.font.caption
|
||||
wrapMode: Text.WordWrap
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Timer {
|
||||
id: scrollReset
|
||||
interval: 1
|
||||
repeat: false
|
||||
onTriggered: {
|
||||
if (panelFlick) panelFlick.contentY = 0
|
||||
scrollResetFollowup.restart()
|
||||
}
|
||||
}
|
||||
|
||||
Timer {
|
||||
id: scrollResetFollowup
|
||||
interval: 32
|
||||
repeat: false
|
||||
onTriggered: if (panelFlick) panelFlick.contentY = 0
|
||||
}
|
||||
|
||||
component LimitRow: Column {
|
||||
id: limitRow
|
||||
property var window: null
|
||||
readonly property bool alarming: window && window.percent >= 0.9
|
||||
spacing: Style.space(6)
|
||||
|
||||
Item {
|
||||
width: parent.width
|
||||
implicitHeight: Math.max(limitLabel.implicitHeight, limitValue.implicitHeight)
|
||||
|
||||
Text {
|
||||
id: limitLabel
|
||||
textFormat: Text.PlainText
|
||||
text: limitRow.window ? limitRow.window.title : ""
|
||||
color: root.foreground
|
||||
font.family: root.fontFamily
|
||||
font.pixelSize: Style.font.body
|
||||
elide: Text.ElideRight
|
||||
anchors.left: parent.left
|
||||
anchors.right: limitValue.left
|
||||
anchors.rightMargin: Style.space(8)
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
}
|
||||
|
||||
Text {
|
||||
id: limitValue
|
||||
textFormat: Text.PlainText
|
||||
text: limitRow.window ? Math.round(limitRow.window.percent * 100) + "%" : "—"
|
||||
color: limitRow.alarming ? root.urgent : root.foreground
|
||||
font.family: root.fontFamily
|
||||
font.pixelSize: Style.font.caption
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
}
|
||||
}
|
||||
|
||||
Item {
|
||||
width: parent.width
|
||||
implicitHeight: Math.max(Style.space(4), Math.round(Style.spacing.controlHeight * 0.14))
|
||||
|
||||
Rectangle {
|
||||
id: meterTrack
|
||||
anchors.fill: parent
|
||||
radius: height / 2
|
||||
color: root.track
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
anchors.left: meterTrack.left
|
||||
anchors.verticalCenter: meterTrack.verticalCenter
|
||||
height: meterTrack.height
|
||||
radius: meterTrack.radius
|
||||
width: meterTrack.width * root.clamp(limitRow.window ? limitRow.window.percent : 0, 0, 1)
|
||||
color: limitRow.alarming ? root.urgent : root.foreground
|
||||
}
|
||||
}
|
||||
|
||||
Text {
|
||||
width: parent.width
|
||||
textFormat: Text.PlainText
|
||||
text: {
|
||||
var remainingMs = root.resetMsFor(limitRow.window)
|
||||
return remainingMs > 0 ? "Resets in " + root.formatDuration(remainingMs) : ""
|
||||
}
|
||||
color: root.dim
|
||||
font.family: root.fontFamily
|
||||
font.pixelSize: Style.font.caption
|
||||
}
|
||||
}
|
||||
|
||||
component ModelRow: Item {
|
||||
id: modelRow
|
||||
property var row: null
|
||||
property real share: 0
|
||||
|
||||
implicitHeight: modelName.implicitHeight + Style.spacing.lg
|
||||
|
||||
Rectangle {
|
||||
anchors.fill: parent
|
||||
radius: Style.cornerRadius
|
||||
color: root.alpha(root.foreground, 0.05)
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
anchors.left: parent.left
|
||||
anchors.top: parent.top
|
||||
anchors.bottom: parent.bottom
|
||||
width: parent.width * root.clamp(modelRow.share, 0, 1)
|
||||
radius: Style.cornerRadius
|
||||
color: root.alpha(root.foreground, 0.14)
|
||||
|
||||
Behavior on width {
|
||||
NumberAnimation { duration: 160; easing.type: Easing.OutCubic }
|
||||
}
|
||||
}
|
||||
|
||||
Text {
|
||||
id: modelName
|
||||
textFormat: Text.PlainText
|
||||
text: modelRow.row ? modelRow.row.name : ""
|
||||
color: root.foreground
|
||||
font.family: root.fontFamily
|
||||
font.pixelSize: Style.font.bodySmall
|
||||
elide: Text.ElideRight
|
||||
anchors.left: parent.left
|
||||
anchors.leftMargin: Style.space(8)
|
||||
anchors.right: modelTokens.left
|
||||
anchors.rightMargin: Style.space(8)
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
}
|
||||
|
||||
Text {
|
||||
id: modelTokens
|
||||
textFormat: Text.PlainText
|
||||
text: modelRow.row ? Model.formatTokenCount(modelRow.row.total) : ""
|
||||
color: root.dim
|
||||
font.family: root.fontFamily
|
||||
font.pixelSize: Style.font.bodySmall
|
||||
font.bold: true
|
||||
anchors.right: parent.right
|
||||
anchors.rightMargin: Style.space(8)
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
id: modelHover
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
acceptedButtons: Qt.NoButton
|
||||
}
|
||||
|
||||
PanelToolTip {
|
||||
visible: modelHover.containsMouse
|
||||
text: root.modelTooltip(modelRow.row)
|
||||
fontFamily: root.fontFamily
|
||||
}
|
||||
}
|
||||
|
||||
component SessionRow: CursorSurface {
|
||||
id: row
|
||||
|
||||
property var session: ({})
|
||||
readonly property string sessionId: session && session.id ? session.id : ""
|
||||
readonly property int flatIndex: {
|
||||
for (var i = 0; i < root.rows.length; i++) {
|
||||
if (root.rows[i].id === sessionId) return i
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
hasCursor: root.cursorActive && root.selectedSession && root.selectedSession.id === sessionId
|
||||
foreground: root.foreground
|
||||
implicitHeight: labels.implicitHeight + Style.space(12)
|
||||
|
||||
onHasCursorChanged: if (hasCursor && root.keyboardMoving) root.scrollItemIntoView(row)
|
||||
|
||||
MouseArea {
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onEntered: if (row.flatIndex >= 0 && !scrollReset.running && !scrollResetFollowup.running)
|
||||
root.setSessionCursor(row.flatIndex)
|
||||
onClicked: root.attachSession(row.session)
|
||||
}
|
||||
|
||||
RowLayout {
|
||||
id: body
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
anchors.leftMargin: Style.space(10)
|
||||
anchors.rightMargin: Style.space(10)
|
||||
spacing: Style.space(8)
|
||||
|
||||
Text {
|
||||
textFormat: Text.PlainText
|
||||
text: root.statusMark(row.session.status)
|
||||
color: root.statusColor(row.session.status)
|
||||
font.family: root.fontFamily
|
||||
font.pixelSize: Style.font.body
|
||||
Layout.alignment: Qt.AlignVCenter
|
||||
}
|
||||
|
||||
ColumnLayout {
|
||||
id: labels
|
||||
Layout.fillWidth: true
|
||||
spacing: Style.space(1)
|
||||
|
||||
Text {
|
||||
Layout.fillWidth: true
|
||||
textFormat: Text.PlainText
|
||||
text: row.session.title || "Untitled session"
|
||||
color: root.foreground
|
||||
font.family: root.fontFamily
|
||||
font.pixelSize: Style.font.body
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
|
||||
Text {
|
||||
Layout.fillWidth: true
|
||||
textFormat: Text.PlainText
|
||||
text: root.rowMeta(row.session)
|
||||
color: root.dim
|
||||
font.family: root.fontFamily
|
||||
font.pixelSize: Style.font.caption
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
}
|
||||
|
||||
Text {
|
||||
visible: row.session.status === "running"
|
||||
textFormat: Text.PlainText
|
||||
text: "LIVE"
|
||||
color: root.urgent
|
||||
font.family: root.fontFamily
|
||||
font.pixelSize: Style.font.caption
|
||||
font.bold: true
|
||||
Layout.alignment: Qt.AlignVCenter
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
# OpenCode sessions
|
||||
|
||||
A bar icon and panel for OpenCode sessions on the shared V2 background
|
||||
server. Selecting a session asks the server to activate a live TUI or
|
||||
Desktop client. If none can take it, the panel opens
|
||||
`opencode --session <id>` in a terminal.
|
||||
|
||||
## Panel
|
||||
|
||||
- **Hero** — OpenCode, plus running / needs-attention / idle.
|
||||
- **Latest / Projects / Usage** — recency list, project grouping, or usage.
|
||||
`h`/`l` or the chips switch the view.
|
||||
- **Usage** — Go subscription quota when a Go API key is present, plus today's
|
||||
cost and tokens by model from session stats.
|
||||
- **Row** — title, project (in Latest), model, age, and last outcome. Running
|
||||
rows are marked LIVE.
|
||||
|
||||
The icon hides while the background service is unreachable, the same way
|
||||
`omarchy.agents` stays off the bar until it has something to say.
|
||||
|
||||
## Interactions
|
||||
|
||||
- Bar icon: left = panel, right = new session, middle = refresh.
|
||||
- Panel: `h`/`l` switch tabs, `j`/`k` move, Enter attach, `n` new session,
|
||||
`r` refresh, Tab neighboring panel, Esc close.
|
||||
- IPC: `omarchy-shell simon.opencode <open|close|toggle|refresh>`.
|
||||
|
||||
Attach first calls `POST /api/session/:id/activate`. A live TUI or Desktop
|
||||
that has registered presence takes the session. Otherwise attach falls back
|
||||
to `omarchy launch or focus tui` with a per-session app id.
|
||||
|
||||
## Data
|
||||
|
||||
`fetch.ts` reads the local service registration and calls:
|
||||
|
||||
- `GET /api/session?parentID=null` — recent top-level sessions
|
||||
- `GET /api/session/active` — sessions this process is currently draining
|
||||
- `POST /api/session/:id/activate` — focus a live client, or report none
|
||||
- `GET /api/experimental/session/stats` — today's cost and tokens
|
||||
|
||||
It never starts the background server and never scrapes the OpenCode
|
||||
database. Refresh interval and list length are widget settings.
|
||||
|
||||
Copy into the local Omarchy plugin dir with `packages/omarchy/install`.
|
||||
|
||||
```
|
||||
omarchy bar set simon.opencode refreshIntervalSec 3 --json
|
||||
```
|
||||
@@ -0,0 +1,119 @@
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
import Quickshell.Io
|
||||
import "Model.js" as Model
|
||||
|
||||
Item {
|
||||
id: root
|
||||
visible: false
|
||||
|
||||
property var settings: ({})
|
||||
property bool opened: false
|
||||
property double nowMs: Date.now()
|
||||
|
||||
property bool online: false
|
||||
property bool refreshing: false
|
||||
property var sessions: []
|
||||
property var active: ({})
|
||||
property var grouped: []
|
||||
property var latest: []
|
||||
property var flat: []
|
||||
property var usage: ({ cost: 0, tokens: ({}), totalTokens: 0, models: [], sessions: 0 })
|
||||
property var goLimits: []
|
||||
property int runningCount: 0
|
||||
property int unreadCount: 0
|
||||
property int totalCount: 0
|
||||
|
||||
readonly property int refreshIntervalSec: Math.max(2, Math.min(60, parseInt(String(setting("refreshIntervalSec", 4)), 10) || 4))
|
||||
readonly property int sessionLimit: Math.max(5, Math.min(50, parseInt(String(setting("sessionLimit", 20)), 10) || 20))
|
||||
|
||||
function setting(name, fallback) {
|
||||
var value = settings ? settings[name] : undefined
|
||||
return value === undefined || value === null ? fallback : value
|
||||
}
|
||||
|
||||
function scriptPath() {
|
||||
var url = Qt.resolvedUrl("fetch.ts").toString()
|
||||
if (url.indexOf("file://") === 0) return decodeURIComponent(url.slice(7))
|
||||
return url
|
||||
}
|
||||
|
||||
function applySnapshot(raw) {
|
||||
var parsed = Model.parseSnapshot(raw)
|
||||
if (!parsed.ok) {
|
||||
if ((parsed.error || "offline") === "offline") {
|
||||
online = false
|
||||
sessions = []
|
||||
active = ({})
|
||||
goLimits = []
|
||||
usage = Model.emptyUsage()
|
||||
rebuild()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
online = true
|
||||
sessions = parsed.sessions
|
||||
active = parsed.active
|
||||
goLimits = parsed.goLimits
|
||||
usage = Model.usageFromStats(parsed.stats)
|
||||
rebuild()
|
||||
}
|
||||
|
||||
function rebuild() {
|
||||
var nextCounts = Model.counts(sessions, active)
|
||||
runningCount = nextCounts.running
|
||||
unreadCount = nextCounts.unread
|
||||
totalCount = nextCounts.total
|
||||
grouped = Model.groups(sessions, active, nowMs)
|
||||
latest = Model.latest(sessions, active, nowMs)
|
||||
flat = Model.flatten(grouped)
|
||||
}
|
||||
|
||||
function refresh() {
|
||||
if (fetchProcess.running) return
|
||||
refreshing = true
|
||||
fetchProcess.command = ["bun", scriptPath(), String(sessionLimit)]
|
||||
fetchProcess.running = true
|
||||
}
|
||||
|
||||
function tickAges() {
|
||||
nowMs = Date.now()
|
||||
if (online) rebuild()
|
||||
}
|
||||
|
||||
Process {
|
||||
id: fetchProcess
|
||||
running: false
|
||||
stdout: StdioCollector {
|
||||
waitForEnd: true
|
||||
onStreamFinished: {
|
||||
root.applySnapshot(text)
|
||||
root.refreshing = false
|
||||
}
|
||||
}
|
||||
onExited: function(exitCode) {
|
||||
if (exitCode !== 0 && root.refreshing) {
|
||||
root.online = false
|
||||
root.refreshing = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Timer {
|
||||
interval: root.refreshIntervalSec * 1000
|
||||
running: true
|
||||
repeat: true
|
||||
triggeredOnStart: true
|
||||
onTriggered: root.refresh()
|
||||
}
|
||||
|
||||
Timer {
|
||||
interval: 30000
|
||||
running: root.opened && root.online
|
||||
repeat: true
|
||||
onTriggered: root.tickAges()
|
||||
}
|
||||
|
||||
Component.onCompleted: refresh()
|
||||
}
|
||||
Executable
+237
@@ -0,0 +1,237 @@
|
||||
#!/usr/bin/env bun
|
||||
/** Read OpenCode sessions from an already-running local service. Does not start the server. */
|
||||
|
||||
const TIMEOUT_MS = 2_000
|
||||
const STATS_TIMEOUT_MS = 4_000
|
||||
const USERNAME = "opencode"
|
||||
const GO_USAGE_URL = "https://opencode.ai/zen/go/v1/usage"
|
||||
const GO_CACHE_TTL_MS = 300_000
|
||||
|
||||
function stateDir() {
|
||||
const xdg = process.env.XDG_STATE_HOME
|
||||
return `${xdg || `${process.env.HOME}/.local/state`}/opencode`
|
||||
}
|
||||
|
||||
function dataDir() {
|
||||
const xdg = process.env.XDG_DATA_HOME
|
||||
return `${xdg || `${process.env.HOME}/.local/share`}/opencode`
|
||||
}
|
||||
|
||||
function cacheDir() {
|
||||
const xdg = process.env.XDG_CACHE_HOME
|
||||
return `${xdg || `${process.env.HOME}/.cache`}/omarchy`
|
||||
}
|
||||
|
||||
function registrationPaths() {
|
||||
const directory = stateDir()
|
||||
return [`${directory}/service-v2.json`, `${directory}/service.json`]
|
||||
}
|
||||
|
||||
async function readJson(path: string) {
|
||||
const file = Bun.file(path)
|
||||
if (!(await file.exists())) return
|
||||
return file.json().catch(() => undefined)
|
||||
}
|
||||
|
||||
function registration(payload: unknown) {
|
||||
if (!payload || typeof payload !== "object") return
|
||||
const url = String(Reflect.get(payload, "url") || "")
|
||||
const password = Reflect.get(payload, "password")
|
||||
if (!url.startsWith("http")) return
|
||||
if (typeof password !== "string" || password === "") return
|
||||
return { url: url.replace(/\/$/, ""), password }
|
||||
}
|
||||
|
||||
async function request(
|
||||
service: { url: string; password: string },
|
||||
path: string,
|
||||
options?: { method?: string; timeout?: number },
|
||||
) {
|
||||
const method = options?.method ?? "GET"
|
||||
const response = await fetch(new URL(path, `${service.url}/`), {
|
||||
method,
|
||||
headers: {
|
||||
Authorization: `Basic ${btoa(`${USERNAME}:${service.password}`)}`,
|
||||
Accept: "application/json",
|
||||
},
|
||||
signal: AbortSignal.timeout(options?.timeout ?? TIMEOUT_MS),
|
||||
body: method === "GET" ? undefined : "",
|
||||
})
|
||||
if (response.status === 204) return { status: 204, body: undefined }
|
||||
return { status: response.status, body: await response.json().catch(() => undefined) }
|
||||
}
|
||||
|
||||
async function probe(service: { url: string; password: string }) {
|
||||
for (const path of ["/api/info", "/api/status"]) {
|
||||
const result = await request(service, path).catch(() => undefined)
|
||||
if (!result) return
|
||||
if (result.status === 404) continue
|
||||
if (result.body && typeof result.body === "object") return service
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
function goApiKey(payload: unknown) {
|
||||
if (!payload || typeof payload !== "object") return
|
||||
for (const name of ["opencode-go", "opencode"]) {
|
||||
const entry = Reflect.get(payload, name)
|
||||
if (!entry || typeof entry !== "object") continue
|
||||
for (const field of ["key", "apiKey", "token"]) {
|
||||
const value = Reflect.get(entry, field)
|
||||
if (typeof value === "string" && value.startsWith("sk-")) return value
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function goWindow(title: string, payload: unknown) {
|
||||
if (!payload || typeof payload !== "object") return
|
||||
const raw = Reflect.get(payload, "percent") ?? Reflect.get(payload, "usagePercent")
|
||||
const percent = Number(raw)
|
||||
if (!Number.isFinite(percent)) return
|
||||
const ratio = percent > 1 ? percent / 100 : percent
|
||||
const resets = Reflect.get(payload, "resetsAt") ?? Reflect.get(payload, "resetAt") ?? ""
|
||||
return {
|
||||
title,
|
||||
percent: Math.max(0, Math.min(1, ratio)),
|
||||
resetsAt: String(resets),
|
||||
status: String(Reflect.get(payload, "status") || ""),
|
||||
}
|
||||
}
|
||||
|
||||
async function readGoCache() {
|
||||
const cached = await readJson(`${cacheDir()}/opencode-go-usage.json`)
|
||||
if (!cached || typeof cached !== "object") return
|
||||
const fetched = Number(Reflect.get(cached, "fetchedAt") || 0)
|
||||
if (!(fetched > 0) || Date.now() - fetched > GO_CACHE_TTL_MS) return
|
||||
const limits = Reflect.get(cached, "limits")
|
||||
if (!Array.isArray(limits)) return
|
||||
return { limits }
|
||||
}
|
||||
|
||||
async function writeGoCache(limits: unknown[]) {
|
||||
const { mkdir } = await import("node:fs/promises")
|
||||
await mkdir(cacheDir(), { recursive: true })
|
||||
await Bun.write(`${cacheDir()}/opencode-go-usage.json`, JSON.stringify({ fetchedAt: Date.now(), limits }))
|
||||
}
|
||||
|
||||
async function fetchGoQuota() {
|
||||
const cached = await readGoCache()
|
||||
if (cached) return cached
|
||||
const key = goApiKey(await readJson(`${dataDir()}/auth.json`))
|
||||
if (!key) return null
|
||||
const response = await fetch(GO_USAGE_URL, {
|
||||
headers: { Authorization: `Bearer ${key}`, Accept: "application/json" },
|
||||
signal: AbortSignal.timeout(TIMEOUT_MS),
|
||||
}).catch(() => undefined)
|
||||
if (!response?.ok) return null
|
||||
const payload = await response.json().catch(() => undefined)
|
||||
const usage = payload && typeof payload === "object" && "usage" in payload ? payload.usage : payload
|
||||
if (!usage || typeof usage !== "object") return null
|
||||
const windows = [
|
||||
["5-hour", ["rolling", "rolling5h"]],
|
||||
["Weekly", ["weekly"]],
|
||||
["Monthly", ["monthly"]],
|
||||
] as const
|
||||
const limits = windows.flatMap(([title, fields]) => {
|
||||
const field = fields.find((name) => name in usage)
|
||||
const window = goWindow(title, field ? Reflect.get(usage, field) : undefined)
|
||||
return window ? [window] : []
|
||||
})
|
||||
if (limits.length === 0) return null
|
||||
await writeGoCache(limits)
|
||||
return { limits }
|
||||
}
|
||||
|
||||
async function localTimezone() {
|
||||
if (process.env.TZ) return process.env.TZ
|
||||
const { realpath } = await import("node:fs/promises")
|
||||
const resolved = await realpath("/etc/localtime").catch(() => "")
|
||||
const marker = "/zoneinfo/"
|
||||
const index = resolved.indexOf(marker)
|
||||
if (index < 0) return "UTC"
|
||||
return resolved.slice(index + marker.length)
|
||||
}
|
||||
|
||||
async function fetchTodayStats(service: { url: string; password: string }) {
|
||||
const now = new Date()
|
||||
const start = new Date(now.getFullYear(), now.getMonth(), now.getDate())
|
||||
const query = new URLSearchParams({
|
||||
from: String(start.getTime()),
|
||||
to: String(now.getTime() + 1),
|
||||
timezone: await localTimezone(),
|
||||
tools: "none",
|
||||
})
|
||||
const result = await request(service, `/api/experimental/session/stats?${query}`, {
|
||||
timeout: STATS_TIMEOUT_MS,
|
||||
}).catch(() => undefined)
|
||||
const body = result?.body
|
||||
if (!body || typeof body !== "object") return null
|
||||
const data = Reflect.get(body, "data")
|
||||
if (!data || typeof data !== "object") return null
|
||||
return data
|
||||
}
|
||||
|
||||
async function activateSession(service: { url: string; password: string }, sessionID: string) {
|
||||
const result = await request(service, `/api/session/${encodeURIComponent(sessionID)}/activate`, { method: "POST" })
|
||||
if (result.status === 404) return { ok: true, activated: false }
|
||||
if (result.status < 200 || result.status >= 300) throw new Error("unreachable")
|
||||
const body = result.body
|
||||
if (!body || typeof body !== "object") return { ok: true, activated: false }
|
||||
return { ok: true, activated: Reflect.get(body, "outcome") === "activated" }
|
||||
}
|
||||
|
||||
async function fetchSnapshot(service: { url: string; password: string }, limit: number) {
|
||||
const query = new URLSearchParams({ parentID: "null", limit: String(limit) })
|
||||
const sessions = await request(service, `/api/session?${query}`)
|
||||
const active = await request(service, "/api/session/active")
|
||||
const sessionBody = sessions.body
|
||||
const activeBody = active.body
|
||||
const sessionData =
|
||||
sessionBody && typeof sessionBody === "object" ? Reflect.get(sessionBody, "data") : undefined
|
||||
const activeData = activeBody && typeof activeBody === "object" ? Reflect.get(activeBody, "data") : undefined
|
||||
if (!Array.isArray(sessionData)) throw new Error("session list missing")
|
||||
return {
|
||||
ok: true,
|
||||
url: service.url,
|
||||
sessions: sessionData,
|
||||
active: activeData && typeof activeData === "object" ? activeData : {},
|
||||
go: await fetchGoQuota(),
|
||||
stats: await fetchTodayStats(service),
|
||||
}
|
||||
}
|
||||
|
||||
async function firstLive() {
|
||||
for (const path of registrationPaths()) {
|
||||
const loaded = registration(await readJson(path))
|
||||
if (!loaded) continue
|
||||
const live = await probe(loaded)
|
||||
if (live) return live
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const args = Bun.argv.slice(2)
|
||||
const activateID = args[0] === "activate" ? (args[1] ?? "") : ""
|
||||
if (args[0] === "activate" && activateID === "") {
|
||||
process.stdout.write(`${JSON.stringify({ ok: false, error: "offline" })}\n`)
|
||||
return 0
|
||||
}
|
||||
const parsedLimit = args[0] === "activate" ? NaN : Number.parseInt(args[0] ?? "", 10)
|
||||
const limit = Number.isFinite(parsedLimit) ? Math.max(1, Math.min(50, parsedLimit)) : 20
|
||||
const live = await firstLive()
|
||||
if (!live) {
|
||||
process.stdout.write(`${JSON.stringify({ ok: false, error: "offline" })}\n`)
|
||||
return 0
|
||||
}
|
||||
const payload = await (activateID ? activateSession(live, activateID) : fetchSnapshot(live, limit)).catch(
|
||||
() => undefined,
|
||||
)
|
||||
if (!payload) {
|
||||
process.stdout.write(`${JSON.stringify({ ok: false, error: "unreachable" })}\n`)
|
||||
return 0
|
||||
}
|
||||
process.stdout.write(`${JSON.stringify(payload)}\n`)
|
||||
return 0
|
||||
}
|
||||
|
||||
process.exit(await main())
|
||||
Executable
+14
@@ -0,0 +1,14 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
src=$(cd "$(dirname "$0")" && pwd)
|
||||
dest="${XDG_CONFIG_HOME:-$HOME/.config}/omarchy/plugins/simon.opencode"
|
||||
|
||||
mkdir -p "$dest"
|
||||
rsync -a --delete \
|
||||
--exclude install \
|
||||
--exclude '__pycache__/' \
|
||||
--exclude '*.pyc' \
|
||||
"$src/" "$dest/"
|
||||
|
||||
echo "Installed $src -> $dest"
|
||||
@@ -0,0 +1,46 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"id": "simon.opencode",
|
||||
"name": "OpenCode",
|
||||
"version": "0.1.0",
|
||||
"author": "simon",
|
||||
"license": "MIT",
|
||||
"description": "OpenCode sessions from the shared background service: running work, recent threads, and a terminal attach fallback.",
|
||||
"kinds": ["bar-widget"],
|
||||
"activation": "on-demand",
|
||||
"entryPoints": {
|
||||
"barWidget": "Panel.qml"
|
||||
},
|
||||
"barWidget": {
|
||||
"displayName": "OpenCode",
|
||||
"description": "Live OpenCode sessions from the shared server, with latest or project grouping and recent usage.",
|
||||
"category": "AI",
|
||||
"aliases": ["opencode", "sessions"],
|
||||
"allowMultiple": false,
|
||||
"defaultSection": "right",
|
||||
"defaults": {
|
||||
"refreshIntervalSec": 4,
|
||||
"sessionLimit": 20
|
||||
},
|
||||
"schema": [
|
||||
{
|
||||
"key": "refreshIntervalSec",
|
||||
"type": "integer",
|
||||
"label": "Refresh interval (seconds)",
|
||||
"min": 2,
|
||||
"max": 60,
|
||||
"step": 1,
|
||||
"defaultValue": 4
|
||||
},
|
||||
{
|
||||
"key": "sessionLimit",
|
||||
"type": "integer",
|
||||
"label": "Recent session limit",
|
||||
"min": 5,
|
||||
"max": 50,
|
||||
"step": 5,
|
||||
"defaultValue": 20
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -4807,6 +4807,68 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/session/{sessionID}/activate": {
|
||||
"post": {
|
||||
"tags": ["session"],
|
||||
"operationId": "session.activate",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "sessionID",
|
||||
"in": "path",
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"pattern": "^ses"
|
||||
},
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"security": [],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Client.Activate",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/Client.Activate"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "InvalidRequestError",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/InvalidRequestErrorEncoded"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "UnauthorizedError",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/UnauthorizedErrorEncoded"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "SessionNotFoundError",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/SessionNotFoundErrorEncoded"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Ask the best live UI client to show this session. Returns outcome none when no client can take it, so the caller may launch a fallback.",
|
||||
"summary": "Activate session"
|
||||
}
|
||||
},
|
||||
"/api/session/{sessionID}/message": {
|
||||
"get": {
|
||||
"tags": ["session"],
|
||||
@@ -7319,6 +7381,293 @@
|
||||
"summary": "Activate credential"
|
||||
}
|
||||
},
|
||||
"/api/client": {
|
||||
"get": {
|
||||
"tags": ["client"],
|
||||
"operationId": "client.list",
|
||||
"parameters": [],
|
||||
"security": [],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "ClientListResponse",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ClientListResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "InvalidRequestError",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/InvalidRequestErrorEncoded"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "UnauthorizedError",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/UnauthorizedErrorEncoded"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "List UI clients currently advertising presence on this server.",
|
||||
"summary": "List clients"
|
||||
},
|
||||
"post": {
|
||||
"tags": ["client"],
|
||||
"operationId": "client.register",
|
||||
"parameters": [],
|
||||
"security": [],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "ClientResponse",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ClientResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "InvalidRequestError",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/InvalidRequestErrorEncoded"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "UnauthorizedError",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/UnauthorizedErrorEncoded"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"409": {
|
||||
"description": "ConflictError",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ConflictErrorEncoded"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Register an ephemeral UI client so session activation can target it.",
|
||||
"summary": "Register client",
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/Client.CreateInput"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/client/{clientID}": {
|
||||
"get": {
|
||||
"tags": ["client"],
|
||||
"operationId": "client.get",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "clientID",
|
||||
"in": "path",
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"pattern": "^client_"
|
||||
},
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"security": [],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "ClientResponse",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ClientResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "InvalidRequestError",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/InvalidRequestErrorEncoded"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "UnauthorizedError",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/UnauthorizedErrorEncoded"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "ClientNotFoundError",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ClientNotFoundErrorEncoded"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Get one registered UI client.",
|
||||
"summary": "Get client"
|
||||
},
|
||||
"put": {
|
||||
"tags": ["client"],
|
||||
"operationId": "client.update",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "clientID",
|
||||
"in": "path",
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"pattern": "^client_"
|
||||
},
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"security": [],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "ClientResponse",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ClientResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "InvalidRequestError",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/InvalidRequestErrorEncoded"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "UnauthorizedError",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/UnauthorizedErrorEncoded"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "ClientNotFoundError",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ClientNotFoundErrorEncoded"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Update the sessions and focus state advertised by one UI client.",
|
||||
"summary": "Update client",
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/Client.UpdateInput"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": true
|
||||
}
|
||||
},
|
||||
"delete": {
|
||||
"tags": ["client"],
|
||||
"operationId": "client.remove",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "clientID",
|
||||
"in": "path",
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"pattern": "^client_"
|
||||
},
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"security": [],
|
||||
"responses": {
|
||||
"204": {
|
||||
"description": "<No Content>"
|
||||
},
|
||||
"400": {
|
||||
"description": "InvalidRequestError",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/InvalidRequestErrorEncoded"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "UnauthorizedError",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/UnauthorizedErrorEncoded"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "ClientNotFoundError",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ClientNotFoundErrorEncoded"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Unregister a UI client.",
|
||||
"summary": "Remove client"
|
||||
}
|
||||
},
|
||||
"/api/project": {
|
||||
"get": {
|
||||
"tags": ["project"],
|
||||
@@ -12602,6 +12951,151 @@
|
||||
"required": ["_tag", "agentID", "message"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"Client.Activate": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"outcome": {
|
||||
"type": "string",
|
||||
"enum": ["activated", "none"]
|
||||
},
|
||||
"client": {
|
||||
"$ref": "#/components/schemas/Client.Info"
|
||||
}
|
||||
},
|
||||
"required": ["outcome"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"Client.CreateInput": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"kind": {
|
||||
"$ref": "#/components/schemas/Client.Kind"
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"sessions": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string",
|
||||
"pattern": "^ses"
|
||||
}
|
||||
},
|
||||
"focused": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"pid": {
|
||||
"type": "integer",
|
||||
"minimum": 0
|
||||
}
|
||||
},
|
||||
"required": ["kind"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"Client.Info": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string",
|
||||
"pattern": "^client_"
|
||||
},
|
||||
"kind": {
|
||||
"$ref": "#/components/schemas/Client.Kind"
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"sessions": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string",
|
||||
"pattern": "^ses"
|
||||
}
|
||||
},
|
||||
"focused": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"focusedAt": {
|
||||
"type": "number"
|
||||
},
|
||||
"updatedAt": {
|
||||
"type": "number"
|
||||
},
|
||||
"pid": {
|
||||
"type": "integer",
|
||||
"minimum": 0
|
||||
}
|
||||
},
|
||||
"required": ["id", "kind", "sessions", "focused", "focusedAt", "updatedAt"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"Client.Kind": {
|
||||
"type": "string",
|
||||
"enum": ["tui", "desktop", "web", "other"]
|
||||
},
|
||||
"Client.UpdateInput": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"sessions": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string",
|
||||
"pattern": "^ses"
|
||||
}
|
||||
},
|
||||
"focused": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"pid": {
|
||||
"type": "integer",
|
||||
"minimum": 0
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"ClientListResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"data": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/Client.Info"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": ["data"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"ClientNotFoundErrorEncoded": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"_tag": {
|
||||
"type": "string",
|
||||
"enum": ["ClientNotFoundError"]
|
||||
},
|
||||
"clientID": {
|
||||
"type": "string"
|
||||
},
|
||||
"message": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": ["_tag", "clientID", "message"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"ClientResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"data": {
|
||||
"$ref": "#/components/schemas/Client.Info"
|
||||
}
|
||||
},
|
||||
"required": ["data"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"Command.Info": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -19094,6 +19588,10 @@
|
||||
{
|
||||
"name": "credential"
|
||||
},
|
||||
{
|
||||
"name": "client",
|
||||
"description": "Ephemeral UI client presence for session activation."
|
||||
},
|
||||
{
|
||||
"name": "project",
|
||||
"description": "Project routes."
|
||||
|
||||
@@ -27,6 +27,7 @@ import { makeLocationGroup } from "./groups/location.js"
|
||||
import { IntegrationGroup } from "./groups/integration.js"
|
||||
import { WebSearchGroup } from "./groups/websearch.js"
|
||||
import { McpGroup } from "./groups/mcp.js"
|
||||
import { ClientGroup } from "./groups/client.js"
|
||||
import { CredentialGroup } from "./groups/credential.js"
|
||||
import { ProjectGroup } from "./groups/project.js"
|
||||
import { WorktreeGroup } from "./groups/worktree.js"
|
||||
@@ -93,6 +94,7 @@ type ApiGroups<
|
||||
| typeof GenerateGroup
|
||||
| typeof PersistentPtyGroup
|
||||
| typeof CredentialGroup
|
||||
| typeof ClientGroup
|
||||
| LocationGroups<LocationId>
|
||||
| LocationGroup<LocationId, LocationService>
|
||||
| FormGroups<LocationId, LocationService>
|
||||
@@ -165,6 +167,7 @@ const makeApiFromGroup = <
|
||||
.add(IntegrationGroup.middleware(locationMiddleware))
|
||||
.add(McpGroup.middleware(locationMiddleware))
|
||||
.add(CredentialGroup)
|
||||
.add(ClientGroup)
|
||||
.add(ProjectGroup.middleware(locationMiddleware))
|
||||
.add(makeFormGroup(locationMiddleware))
|
||||
.add(makePermissionGroup(locationMiddleware, sessionLocationMiddleware))
|
||||
|
||||
@@ -47,6 +47,7 @@ export const groupNames = {
|
||||
"server.integration": "integration",
|
||||
"server.websearch": "websearch",
|
||||
"server.credential": "credential",
|
||||
"server.client": "client",
|
||||
"server.form": "form",
|
||||
"server.permission": "permission",
|
||||
"server.fs": "file",
|
||||
|
||||
@@ -147,6 +147,15 @@ export class SessionNotFoundError extends Schema.TaggedError<SessionNotFoundErro
|
||||
{ httpApiStatus: 404 },
|
||||
) {}
|
||||
|
||||
export class ClientNotFoundError extends Schema.TaggedError<ClientNotFoundError>()(
|
||||
"ClientNotFoundError",
|
||||
{
|
||||
clientID: Schema.String,
|
||||
message: Schema.String,
|
||||
},
|
||||
{ httpApiStatus: 404 },
|
||||
) {}
|
||||
|
||||
export class MessageNotFoundError extends Schema.TaggedError<MessageNotFoundError>()(
|
||||
"MessageNotFoundError",
|
||||
{
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import { Client } from "@opencode/schema/client"
|
||||
import { Schema } from "effect"
|
||||
import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi"
|
||||
import { ClientNotFoundError, ConflictError } from "../errors.js"
|
||||
|
||||
const ClientResponse = Schema.Struct({ data: Client.Info }).annotate({ identifier: "ClientResponse" })
|
||||
const ClientListResponse = Schema.Struct({ data: Schema.Array(Client.Info) }).annotate({
|
||||
identifier: "ClientListResponse",
|
||||
})
|
||||
|
||||
export const ClientGroup = HttpApiGroup.make("server.client")
|
||||
.add(
|
||||
HttpApiEndpoint.get("client.list", "/api/client", {
|
||||
success: ClientListResponse,
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "client.list",
|
||||
summary: "List clients",
|
||||
description: "List UI clients currently advertising presence on this server.",
|
||||
}),
|
||||
),
|
||||
)
|
||||
.add(
|
||||
HttpApiEndpoint.post("client.register", "/api/client", {
|
||||
payload: Client.CreateInput,
|
||||
success: ClientResponse,
|
||||
error: ConflictError,
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "client.register",
|
||||
summary: "Register client",
|
||||
description: "Register an ephemeral UI client so session activation can target it.",
|
||||
}),
|
||||
),
|
||||
)
|
||||
.add(
|
||||
HttpApiEndpoint.get("client.get", "/api/client/:clientID", {
|
||||
params: { clientID: Client.ID },
|
||||
success: ClientResponse,
|
||||
error: ClientNotFoundError,
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "client.get",
|
||||
summary: "Get client",
|
||||
description: "Get one registered UI client.",
|
||||
}),
|
||||
),
|
||||
)
|
||||
.add(
|
||||
HttpApiEndpoint.put("client.update", "/api/client/:clientID", {
|
||||
params: { clientID: Client.ID },
|
||||
payload: Client.UpdateInput,
|
||||
success: ClientResponse,
|
||||
error: ClientNotFoundError,
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "client.update",
|
||||
summary: "Update client",
|
||||
description: "Update the sessions and focus state advertised by one UI client.",
|
||||
}),
|
||||
),
|
||||
)
|
||||
.add(
|
||||
HttpApiEndpoint.delete("client.remove", "/api/client/:clientID", {
|
||||
params: { clientID: Client.ID },
|
||||
success: HttpApiSchema.NoContent,
|
||||
error: ClientNotFoundError,
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "client.remove",
|
||||
summary: "Remove client",
|
||||
description: "Unregister a UI client.",
|
||||
}),
|
||||
),
|
||||
)
|
||||
.annotateMerge(
|
||||
OpenApi.annotations({
|
||||
title: "client",
|
||||
description: "Ephemeral UI client presence for session activation.",
|
||||
}),
|
||||
)
|
||||
@@ -1,6 +1,7 @@
|
||||
import { SessionMessage } from "@opencode/schema/session-message"
|
||||
import { SessionInbox } from "@opencode/schema/session-inbox"
|
||||
import { PromptInput } from "@opencode/schema/prompt-input"
|
||||
import { Client } from "@opencode/schema/client"
|
||||
import { Session } from "@opencode/schema/session"
|
||||
import { SessionStats } from "@opencode/schema/session-stats"
|
||||
import { InstructionEntry } from "@opencode/schema/instruction-entry"
|
||||
@@ -894,6 +895,20 @@ export const makeSessionGroup = <
|
||||
}),
|
||||
),
|
||||
)
|
||||
.add(
|
||||
HttpApiEndpoint.post("session.activate", "/api/session/:sessionID/activate", {
|
||||
params: { sessionID: Session.ID },
|
||||
success: Client.Activate,
|
||||
error: SessionNotFoundError,
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "session.activate",
|
||||
summary: "Activate session",
|
||||
description:
|
||||
"Ask the best live UI client to show this session. Returns outcome none when no client can take it, so the caller may launch a fallback.",
|
||||
}),
|
||||
),
|
||||
)
|
||||
.annotateMerge(
|
||||
OpenApi.annotations({
|
||||
title: "session",
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
export * as Client from "./client.js"
|
||||
|
||||
import { Schema } from "effect"
|
||||
import { ephemeral, inventory } from "./event.js"
|
||||
import { ascending } from "./identifier.js"
|
||||
import { NonNegativeInt, optional, statics } from "./schema.js"
|
||||
import { SessionID } from "./session-id.js"
|
||||
|
||||
const IDSchema = Schema.String.check(Schema.isStartsWith("client_")).pipe(Schema.brand("Client.ID"))
|
||||
|
||||
export const ID = IDSchema.pipe(
|
||||
statics((schema: typeof IDSchema) => ({ create: (id?: string) => schema.make(id ?? "client_" + ascending()) })),
|
||||
)
|
||||
export type ID = typeof ID.Type
|
||||
|
||||
export const Kind = Schema.Literals(["tui", "desktop", "web", "other"]).annotate({ identifier: "Client.Kind" })
|
||||
export type Kind = typeof Kind.Type
|
||||
|
||||
export interface Info extends Schema.Schema.Type<typeof Info> {}
|
||||
export const Info = Schema.Struct({
|
||||
id: ID,
|
||||
kind: Kind,
|
||||
name: optional(Schema.String),
|
||||
sessions: Schema.Array(SessionID),
|
||||
focused: Schema.Boolean,
|
||||
focusedAt: Schema.Finite,
|
||||
updatedAt: Schema.Finite,
|
||||
pid: optional(NonNegativeInt),
|
||||
}).annotate({ identifier: "Client.Info" })
|
||||
|
||||
export interface CreateInput extends Schema.Schema.Type<typeof CreateInput> {}
|
||||
export const CreateInput = Schema.Struct({
|
||||
kind: Kind,
|
||||
name: optional(Schema.String),
|
||||
sessions: optional(Schema.Array(SessionID)),
|
||||
focused: optional(Schema.Boolean),
|
||||
pid: optional(NonNegativeInt),
|
||||
}).annotate({ identifier: "Client.CreateInput" })
|
||||
|
||||
export interface UpdateInput extends Schema.Schema.Type<typeof UpdateInput> {}
|
||||
export const UpdateInput = Schema.Struct({
|
||||
name: optional(Schema.String),
|
||||
sessions: optional(Schema.Array(SessionID)),
|
||||
focused: optional(Schema.Boolean),
|
||||
pid: optional(NonNegativeInt),
|
||||
}).annotate({ identifier: "Client.UpdateInput" })
|
||||
|
||||
export interface Activate extends Schema.Schema.Type<typeof Activate> {}
|
||||
export const Activate = Schema.Struct({
|
||||
outcome: Schema.Literals(["activated", "none"]),
|
||||
client: optional(Info),
|
||||
}).annotate({ identifier: "Client.Activate" })
|
||||
|
||||
const ActivateEvent = ephemeral({
|
||||
type: "client.activate",
|
||||
schema: {
|
||||
clientID: ID,
|
||||
sessionID: SessionID,
|
||||
},
|
||||
})
|
||||
|
||||
export const Event = {
|
||||
Activate: ActivateEvent,
|
||||
Definitions: inventory(ActivateEvent),
|
||||
}
|
||||
@@ -2,6 +2,7 @@ export * as EventManifest from "./event-manifest.js"
|
||||
|
||||
import { Schema } from "effect"
|
||||
import { Agent } from "./agent.js"
|
||||
import { Client } from "./client.js"
|
||||
import { Command } from "./command.js"
|
||||
import { Config } from "./config.js"
|
||||
import { Credential } from "./credential.js"
|
||||
@@ -74,6 +75,7 @@ export const ServerDefinitions = Event.inventory(
|
||||
// Current events the TUI consumes from the public stream.
|
||||
...SessionStatusEvent.Definitions,
|
||||
...TuiEvent.Definitions,
|
||||
...Client.Event.Definitions,
|
||||
...InstallationEvent.Definitions,
|
||||
...VcsEvent.Definitions,
|
||||
McpEvent.StatusChanged,
|
||||
@@ -89,6 +91,7 @@ export const Definitions = Event.inventory(
|
||||
...featureDefinitions,
|
||||
...LspEvent.Definitions,
|
||||
...TuiEvent.Definitions,
|
||||
...Client.Event.Definitions,
|
||||
...McpEvent.Definitions,
|
||||
...LegacyEventV1.Definitions,
|
||||
...FileSystemV1.Event.Definitions,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
export { Agent } from "./agent.js"
|
||||
export { Client } from "./client.js"
|
||||
export { Command } from "./command.js"
|
||||
export { Config } from "./config.js"
|
||||
export { Connection } from "./connection.js"
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { DateTime, Schema } from "effect"
|
||||
import { Agent } from "../src/agent.js"
|
||||
import { Client } from "../src/client.js"
|
||||
import { ConfigAgent } from "../src/config/agent.js"
|
||||
import { ConfigProvider } from "../src/config/provider.js"
|
||||
import { FileSystem } from "../src/filesystem.js"
|
||||
@@ -160,6 +161,7 @@ describe("contract hygiene", () => {
|
||||
test("current ID constructors expose create", () => {
|
||||
expect(Form.ID.create()).toStartWith("frm_")
|
||||
expect(Pty.ID.create()).toStartWith("pty_")
|
||||
expect(Client.ID.create()).toStartWith("client_")
|
||||
})
|
||||
|
||||
test("VCS info omits unavailable branch names", () => {
|
||||
@@ -169,6 +171,10 @@ describe("contract hygiene", () => {
|
||||
test("reusable public identifiers are stable and unique", () => {
|
||||
const identifiers = [
|
||||
Agent.Color,
|
||||
Client.Info,
|
||||
Client.CreateInput,
|
||||
Client.UpdateInput,
|
||||
Client.Activate,
|
||||
ConfigProvider.ModelSettings,
|
||||
ConfigProvider.Settings,
|
||||
FileSystem.Submatch,
|
||||
|
||||
@@ -2,6 +2,7 @@ import { describe, expect, test } from "bun:test"
|
||||
import { Schema } from "effect"
|
||||
import {
|
||||
Agent,
|
||||
Client,
|
||||
Config,
|
||||
Credential,
|
||||
FileSystem,
|
||||
@@ -39,6 +40,7 @@ describe("public event manifest", () => {
|
||||
expect(EventManifest.Latest.get("agent.updated")).toBe(Agent.Event.Updated)
|
||||
expect(EventManifest.Server.get("mcp.status.changed")).toBe(McpEvent.StatusChanged)
|
||||
expect(EventManifest.Server.get("mcp.resources.changed")).toBe(McpEvent.ResourcesChanged)
|
||||
expect(EventManifest.Server.get("client.activate")).toBe(Client.Event.Activate)
|
||||
expect(EventManifest.Server.get("session.created")).toBe(SessionEvent.Created)
|
||||
expect(EventManifest.Server.get("session.deleted")).toBe(SessionEvent.Deleted)
|
||||
expect(EventManifest.Server.get("project.updated")).toBe(Project.Event.Updated)
|
||||
|
||||
@@ -23,6 +23,7 @@ import { LocationHandler } from "./handlers/location"
|
||||
import { IntegrationHandler } from "./handlers/integration"
|
||||
import { WebSearchHandler } from "./handlers/websearch"
|
||||
import { McpHandler } from "./handlers/mcp"
|
||||
import { ClientHandler } from "./handlers/client"
|
||||
import { CredentialHandler } from "./handlers/credential"
|
||||
import { ProjectHandler } from "./handlers/project"
|
||||
import { WorktreeHandler } from "./handlers/worktree"
|
||||
@@ -47,6 +48,7 @@ export const handlers = Layer.mergeAll(
|
||||
WebSearchHandler,
|
||||
McpHandler,
|
||||
CredentialHandler,
|
||||
ClientHandler,
|
||||
ProjectHandler,
|
||||
FormHandler,
|
||||
PermissionHandler,
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import { Client } from "@opencode/core/client"
|
||||
import { Effect } from "effect"
|
||||
import { HttpApiBuilder, HttpApiSchema } from "effect/unstable/httpapi"
|
||||
import { ClientNotFoundError, ConflictError } from "@opencode/protocol/errors"
|
||||
import { Api } from "../api"
|
||||
|
||||
function missingClient(error: Client.NotFoundError) {
|
||||
return new ClientNotFoundError({ clientID: error.id, message: error.message })
|
||||
}
|
||||
|
||||
export const ClientHandler = HttpApiBuilder.group(Api, "server.client", (handlers) =>
|
||||
Effect.gen(function* () {
|
||||
const client = yield* Client.Service
|
||||
return handlers
|
||||
.handle(
|
||||
"client.list",
|
||||
Effect.fn(function* () {
|
||||
return { data: yield* client.list() }
|
||||
}),
|
||||
)
|
||||
.handle(
|
||||
"client.register",
|
||||
Effect.fn(function* (ctx) {
|
||||
return {
|
||||
data: yield* client.register(ctx.payload).pipe(
|
||||
Effect.catchTag(
|
||||
"Client.AlreadyExistsError",
|
||||
(error) => new ConflictError({ resource: error.id, message: error.message }),
|
||||
),
|
||||
),
|
||||
}
|
||||
}),
|
||||
)
|
||||
.handle(
|
||||
"client.get",
|
||||
Effect.fn(function* (ctx) {
|
||||
return {
|
||||
data: yield* client.get(ctx.params.clientID).pipe(Effect.catchTag("Client.NotFoundError", missingClient)),
|
||||
}
|
||||
}),
|
||||
)
|
||||
.handle(
|
||||
"client.update",
|
||||
Effect.fn(function* (ctx) {
|
||||
return {
|
||||
data: yield* client
|
||||
.update(ctx.params.clientID, ctx.payload)
|
||||
.pipe(Effect.catchTag("Client.NotFoundError", missingClient)),
|
||||
}
|
||||
}),
|
||||
)
|
||||
.handle(
|
||||
"client.remove",
|
||||
Effect.fn(function* (ctx) {
|
||||
yield* client.remove(ctx.params.clientID).pipe(Effect.catchTag("Client.NotFoundError", missingClient))
|
||||
return HttpApiSchema.NoContent.make()
|
||||
}),
|
||||
)
|
||||
}),
|
||||
)
|
||||
@@ -1,3 +1,4 @@
|
||||
import { Client } from "@opencode/core/client"
|
||||
import { Session } from "@opencode/core/session"
|
||||
import { SessionStats } from "@opencode/core/session/stats"
|
||||
import { SessionTitle } from "@opencode/core/session/title"
|
||||
@@ -34,6 +35,7 @@ function missingForm(id: Form.ID) {
|
||||
export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handlers) =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* Session.Service
|
||||
const client = yield* Client.Service
|
||||
const transfer = yield* SessionTransfer.Service
|
||||
const requireOwnedForm = Effect.fnUntraced(function* (sessionID: Form.Info["sessionID"], formID: Form.ID) {
|
||||
const form = yield* Form.Service
|
||||
@@ -203,6 +205,13 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl
|
||||
return HttpApiSchema.NoContent.make()
|
||||
}),
|
||||
)
|
||||
.handle(
|
||||
"session.activate",
|
||||
Effect.fn(function* (ctx) {
|
||||
yield* session.get(ctx.params.sessionID).pipe(Effect.catchTag("Session.NotFoundError", missingSession))
|
||||
return yield* client.activate(ctx.params.sessionID)
|
||||
}),
|
||||
)
|
||||
.handle(
|
||||
"session.remove",
|
||||
Effect.fn(function* (ctx) {
|
||||
|
||||
@@ -8,6 +8,7 @@ import { AppNodeBuilder } from "@opencode/core/effect/app-node-builder"
|
||||
import { Bus } from "@opencode/core/bus"
|
||||
import { EventLogger } from "@opencode/core/event-logger"
|
||||
import { FileSystemSearch } from "@opencode/core/filesystem/search"
|
||||
import { Client } from "@opencode/core/client"
|
||||
import { Credential } from "@opencode/core/credential"
|
||||
import { Config } from "@opencode/core/config"
|
||||
import { PermissionSaved } from "@opencode/core/permission/saved"
|
||||
@@ -66,6 +67,7 @@ const applicationServiceNodes = [
|
||||
PtyTicket.node,
|
||||
PersistentPty.node,
|
||||
Credential.node,
|
||||
Client.node,
|
||||
WellKnown.node,
|
||||
PtyEnvironment.node,
|
||||
LocationServiceMap.node,
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import { expect } from "bun:test"
|
||||
import { Client } from "@opencode/schema/client"
|
||||
import { Effect, Schema } from "effect"
|
||||
import { it } from "../../core/test/lib/effect"
|
||||
import { ServerFetch } from "../src/fetch"
|
||||
|
||||
const options = {
|
||||
app: { version: "test-version" },
|
||||
database: { path: ":memory:" },
|
||||
config: { project: false },
|
||||
models: { fetch: false },
|
||||
fs: { filewatcher: false },
|
||||
} as const
|
||||
|
||||
const List = Schema.Struct({ data: Schema.Array(Client.Info) })
|
||||
const One = Schema.Struct({ data: Client.Info })
|
||||
|
||||
it.live("registers UI clients and 404s activation for an unknown session", () =>
|
||||
Effect.gen(function* () {
|
||||
const handler = yield* ServerFetch.make(options)
|
||||
const created = Schema.decodeUnknownSync(One)(
|
||||
yield* body(
|
||||
handler,
|
||||
new Request("http://opencode.local/api/client", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ kind: "tui", focused: true }),
|
||||
}),
|
||||
200,
|
||||
),
|
||||
)
|
||||
expect(created.data.kind).toBe("tui")
|
||||
expect(created.data.focused).toBe(true)
|
||||
|
||||
const listed = Schema.decodeUnknownSync(List)(yield* body(handler, new Request("http://opencode.local/api/client"), 200))
|
||||
expect(listed.data.map((item) => item.id)).toEqual([created.data.id])
|
||||
|
||||
const missing = yield* Effect.promise(() =>
|
||||
handler(new Request("http://opencode.local/api/session/ses_missing/activate", { method: "POST" })),
|
||||
)
|
||||
expect(missing.status).toBe(404)
|
||||
}),
|
||||
)
|
||||
|
||||
function body(handler: (request: Request) => Promise<Response>, request: Request, status: number) {
|
||||
return Effect.promise(() => handler(request)).pipe(
|
||||
Effect.flatMap((response) =>
|
||||
Effect.gen(function* () {
|
||||
expect(response.status).toBe(status)
|
||||
return yield* Effect.promise(() => response.json())
|
||||
}),
|
||||
),
|
||||
)
|
||||
}
|
||||
@@ -48,6 +48,7 @@ import { PluginRouteMissing } from "./component/plugin-route-missing"
|
||||
import { EditorContextProvider } from "./context/editor"
|
||||
import { useEvent } from "./context/event"
|
||||
import { ClientProvider, useClient } from "./context/client"
|
||||
import { useClientPresence } from "./context/presence"
|
||||
import { StartupLoading } from "./component/startup-loading"
|
||||
import { DevToolsBar } from "./component/devtools-bar"
|
||||
import { Reconnecting } from "./component/reconnecting"
|
||||
@@ -481,6 +482,7 @@ function App(props: { pair?: DialogPairCredentials }) {
|
||||
const keymap = Keymap.use()
|
||||
const event = useEvent()
|
||||
const client = useClient()
|
||||
useClientPresence()
|
||||
const toast = useToast()
|
||||
const updater = useUpdateNotification()
|
||||
const theme = useTheme()
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import { isClientNotFoundError } from "@opencode/client"
|
||||
import { createEffect, createMemo, onCleanup } from "solid-js"
|
||||
import { useClient } from "./client"
|
||||
import { useEvent } from "./event"
|
||||
import { useRoute } from "./route"
|
||||
import { useSessionTabs } from "./session-tabs"
|
||||
|
||||
const HEARTBEAT_MS = 15_000
|
||||
|
||||
export function useClientPresence() {
|
||||
const client = useClient()
|
||||
const event = useEvent()
|
||||
const route = useRoute()
|
||||
const sessionTabs = useSessionTabs()
|
||||
let id: string | undefined
|
||||
|
||||
const sessions = createMemo(() => {
|
||||
const ids = sessionTabs.tabs().map((tab) => tab.sessionID).filter((sessionID) => sessionID.startsWith("ses"))
|
||||
if (route.data.type === "session" && route.data.sessionID.startsWith("ses") && !ids.includes(route.data.sessionID)) {
|
||||
return [...ids, route.data.sessionID]
|
||||
}
|
||||
return ids
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
if (client.connection.status() !== "connected") {
|
||||
if (!id) return
|
||||
const current = id
|
||||
id = undefined
|
||||
void client.api.client.remove({ clientID: current }).catch(() => undefined)
|
||||
return
|
||||
}
|
||||
|
||||
const displayed = sessions()
|
||||
const focused = sessionTabs.focused()
|
||||
let stopped = false
|
||||
const push = async () => {
|
||||
if (stopped) return
|
||||
const payload = { kind: "tui" as const, sessions: displayed, focused, pid: process.pid }
|
||||
if (id) {
|
||||
try {
|
||||
await client.api.client.update({ clientID: id, sessions: displayed, focused })
|
||||
return
|
||||
} catch (error) {
|
||||
if (!isClientNotFoundError(error)) throw error
|
||||
id = undefined
|
||||
}
|
||||
}
|
||||
const created = await client.api.client.register(payload)
|
||||
if (stopped) {
|
||||
await client.api.client.remove({ clientID: created.id }).catch(() => undefined)
|
||||
return
|
||||
}
|
||||
id = created.id
|
||||
}
|
||||
|
||||
void push().catch(() => undefined)
|
||||
const timer = setInterval(() => void push().catch(() => undefined), HEARTBEAT_MS)
|
||||
onCleanup(() => {
|
||||
stopped = true
|
||||
clearInterval(timer)
|
||||
})
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
const unsub = event.on("client.activate", (evt) => {
|
||||
if (!id || evt.data.clientID !== id) return
|
||||
route.navigate({ type: "session", sessionID: evt.data.sessionID })
|
||||
})
|
||||
onCleanup(unsub)
|
||||
})
|
||||
|
||||
onCleanup(() => {
|
||||
if (!id) return
|
||||
const current = id
|
||||
id = undefined
|
||||
void client.api.client.remove({ clientID: current }).catch(() => undefined)
|
||||
})
|
||||
}
|
||||
@@ -345,6 +345,7 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
|
||||
|
||||
return {
|
||||
enabled,
|
||||
focused: () => focused() === true,
|
||||
tabs() {
|
||||
return state().tabs
|
||||
},
|
||||
|
||||
@@ -481,12 +481,14 @@ test("views a selected unread session only while focused", async () => {
|
||||
})
|
||||
try {
|
||||
setup.blur()
|
||||
expect(setup.tabs.focused()).toBe(false)
|
||||
setup.route.navigate({ type: "session", sessionID: "first" })
|
||||
await wait(() => setup.tabs.current() === "first" && setup.tabs.status("first").unread === "activity")
|
||||
await Bun.sleep(20)
|
||||
expect(setup.views).toEqual([])
|
||||
|
||||
setup.focus()
|
||||
expect(setup.tabs.focused()).toBe(true)
|
||||
await wait(() => setup.views.includes("first"))
|
||||
setup.emit({
|
||||
id: "evt_viewed_first",
|
||||
|
||||
Reference in New Issue
Block a user