Compare commits

...

1 Commits

Author SHA1 Message Date
Simon Klee c2631c5465 omarchy: add OpenCode session widget
Surface running work, recent sessions, and usage in the bar so users can
monitor the shared server and return to an existing TUI without opening one.
2026-09-22 10:01:16 +02:00
34 changed files with 3133 additions and 0 deletions
+97
View File
@@ -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 = {
+172
View File
@@ -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
}
+115
View File
@@ -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" })
}),
)
})
+264
View File
@@ -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" }
}
}
+56
View File
@@ -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 }
}
}
}
+794
View File
@@ -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
}
}
}
}
+48
View File
@@ -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
```
+119
View File
@@ -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()
}
+237
View File
@@ -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())
+14
View File
@@ -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"
+46
View File
@@ -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
}
]
}
}
+498
View File
@@ -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."
+3
View File
@@ -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))
+1
View File
@@ -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",
+9
View 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",
{
+81
View File
@@ -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.",
}),
)
+15
View File
@@ -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",
+65
View File
@@ -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),
}
+3
View File
@@ -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
View File
@@ -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)
+2
View File
@@ -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,
+60
View File
@@ -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()
}),
)
}),
)
+9
View File
@@ -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) {
+2
View File
@@ -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,
+54
View File
@@ -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())
}),
),
)
}
+2
View File
@@ -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()
+79
View File
@@ -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",