Compare commits

..

5 Commits

Author SHA1 Message Date
Aiden Cline 1eec3e640a fix(ai): expose raw HTTP responses to hooks 2026-08-02 11:44:53 -05:00
Aiden Cline 4dc5ba66d8 feat(plugin): wrap native session HTTP 2026-08-01 22:02:56 -05:00
Aiden Cline a21598901d refactor(plugin): rename session HTTP hook 2026-08-01 21:54:49 -05:00
Aiden Cline 3a7f507135 Revert "feat(plugin): wrap session HTTP requests"
This reverts commit b1f86ee72b.
2026-08-01 21:54:28 -05:00
Aiden Cline b1f86ee72b feat(plugin): wrap session HTTP requests 2026-08-01 11:44:08 -05:00
47 changed files with 667 additions and 1292 deletions
-7
View File
@@ -4,13 +4,6 @@
- The default branch in this repo is `dev`.
- Local `main` ref may not exist; use `dev` or `origin/dev` for diffs.
## Live V2 TUI Testing
- Run `bun run dev:live` from a development worktree to test its TUI against the currently elected `opencode2` background server and live sessions.
- Pass a directory after the script when needed, for example `bun run dev:live /path/to/project`.
- The script discovers the server with `opencode2 service status`, injects its private local credential from `opencode2 service get password`, and uses the `next` TUI storage channel so tabs and other client-local state match the installed client.
- Prefer `dev:live` over plain `bun run dev` for this workflow. An implicit managed-service connection may replace the live server when the worktree client version differs; explicit `--server` warns and continues without replacing it.
## Branch Names
Use a short branch name of at most three words, separated by hyphens. Do not use slashes or type prefixes such as `feat/` or `fix/`.
-1
View File
@@ -8,7 +8,6 @@
"packageManager": "bun@1.3.14",
"scripts": {
"dev": "bun run --cwd packages/cli --conditions=browser src/index.ts",
"dev:live": "OPENCODE_TUI_CHANNEL=next OPENCODE_PASSWORD=\"$(opencode2 service get password)\" bun run dev --server \"$(opencode2 service status)\"",
"dev:desktop": "bun --cwd packages/desktop dev",
"dev:web": "bun --cwd packages/app dev",
"dev:console": "ulimit -n 10240 2>/dev/null; bun run --cwd packages/console/app dev",
+7 -4
View File
@@ -5,7 +5,7 @@ import { Endpoint, type EndpointPatch } from "./endpoint"
import { RequestExecutor } from "./executor"
import { Framing } from "./framing"
import { HttpTransport } from "./transport"
import type { HttpRequestTransform, Transport, TransportRuntime } from "./transport"
import type { HttpMiddleware, Transport, TransportRuntime } from "./transport"
import { WebSocketExecutor } from "./transport"
import type { Protocol } from "./protocol"
import { applyCachePolicy } from "../cache-policy"
@@ -96,7 +96,10 @@ export interface RoutePatch<Body, Prepared> extends RouteDefaultsInput {
type RouteMappedModelInput = RouteModelInput | RouteRoutedModelInput
const makeRouteModel = <Options extends ProviderOptions = ProviderOptions>(route: AnyRoute, mapped: RouteMappedModelInput) => {
const makeRouteModel = <Options extends ProviderOptions = ProviderOptions>(
route: AnyRoute,
mapped: RouteMappedModelInput,
) => {
const provider = route.provider ?? ("provider" in mapped ? mapped.provider : undefined)
if (!provider) throw new Error(`Route.model(${route.id}) requires a provider`)
if (!endpointBaseURL(route.endpoint))
@@ -150,7 +153,7 @@ export interface Interface {
}
export interface StreamOptions {
readonly transform?: HttpRequestTransform
readonly http?: HttpMiddleware
}
export interface StreamMethod {
@@ -302,7 +305,7 @@ function makeFromTransport<Body, Prepared, Frame, Event, State>(
auth: routeInput.auth ?? Auth.none,
encodeBody,
headers: routeInput.headers,
transform: options?.transform,
middleware: options?.http,
}),
streamPrepared: (prepared: Prepared, request: LLMRequest, runtime: TransportRuntime) => {
const route = `${request.model.provider}/${request.model.route.id}`
+38 -5
View File
@@ -1,4 +1,4 @@
import { Cause, Context, Effect, Layer } from "effect"
import { Cause, Context, Effect, Layer, Stream } from "effect"
import {
FetchHttpClient,
Headers,
@@ -20,9 +20,13 @@ import { classifyProviderFailure } from "../provider-error"
export interface Interface {
readonly execute: (
request: HttpClientRequest.HttpClientRequest,
middleware?: HttpMiddleware,
) => Effect.Effect<HttpClientResponse.HttpClientResponse, LLMError>
}
export type HttpHandler = (request: Request) => Effect.Effect<Response, Error>
export type HttpMiddleware = (request: Request, handler: HttpHandler) => Effect.Effect<Response, Error>
export class Service extends Context.Service<Service, Interface>()("@opencode/LLM/RequestExecutor") {}
const BODY_LIMIT = 16_384
@@ -282,12 +286,41 @@ export const layer: Layer.Layer<Service, never, HttpClient.HttpClient> = Layer.e
Service,
Effect.gen(function* () {
const http = yield* HttpClient.HttpClient
const executeOnce = (request: HttpClientRequest.HttpClientRequest) =>
const executeOnce = (request: HttpClientRequest.HttpClientRequest, middleware?: HttpMiddleware) =>
Effect.gen(function* () {
const redactedNames = yield* Headers.CurrentRedactedNames
return yield* http
.execute(request)
.pipe(Effect.mapError(toHttpError(redactedNames)), Effect.flatMap(statusError(request, redactedNames)))
if (!middleware)
return yield* http
.execute(request)
.pipe(Effect.mapError(toHttpError(redactedNames)), Effect.flatMap(statusError(request, redactedNames)))
let sent = request
const origins = new WeakMap<Response, HttpClientRequest.HttpClientRequest>()
const response = yield* HttpClientRequest.toWeb(request).pipe(
Effect.flatMap((web) =>
middleware(web, (input) =>
Effect.gen(function* () {
sent = HttpClientRequest.fromWeb(input)
if (input.body)
sent = HttpClientRequest.bodyUint8Array(
sent,
new Uint8Array(yield* Effect.promise(() => input.arrayBuffer())),
input.headers.get("content-type") ?? undefined,
)
const response = yield* http
.execute(sent)
.pipe(Effect.mapError((cause) => (cause instanceof Error ? cause : new Error(String(cause)))))
const body = yield* Stream.toReadableStreamEffect(response.stream)
const web = new Response(body, { status: response.status, headers: response.headers })
origins.set(web, sent)
return web
}),
),
),
Effect.mapError(toHttpError(redactedNames)),
)
const origin = origins.get(response) ?? sent
return yield* statusError(origin, redactedNames)(HttpClientResponse.fromWeb(origin, response))
})
return Service.of({
execute: executeOnce,
+1 -1
View File
@@ -23,4 +23,4 @@ export type { ApiKeyMode, AuthOverride, ProviderAuthOption } from "./auth-option
export type { Definition as EndpointFn, EndpointInput } from "./endpoint"
export type { Definition as FramingDef } from "./framing"
export type { Protocol as ProtocolDef } from "./protocol"
export type { HttpRequest, HttpRequestTransform, Transport as TransportDef, TransportRuntime } from "./transport"
export type { HttpHandler, HttpMiddleware, Transport as TransportDef, TransportRuntime } from "./transport"
+10 -9
View File
@@ -3,7 +3,7 @@ import { Headers, HttpClientRequest } from "effect/unstable/http"
import { Auth } from "../auth"
import { render as renderEndpoint } from "../endpoint"
import { Framing } from "../framing"
import type { Transport, TransportPrepareInput } from "./index"
import type { HttpMiddleware, Transport, TransportPrepareInput } from "./index"
import * as ProviderShared from "../../protocols/shared"
import { mergeJsonRecords, type LLMRequest } from "../../schema"
@@ -19,6 +19,7 @@ export interface JsonRequestParts<Body = unknown> {
export interface HttpPrepared<Frame> {
readonly request: HttpClientRequest.HttpClientRequest
readonly framing: Framing.Definition<Frame>
readonly middleware?: HttpMiddleware
}
const applyQuery = (url: string, query: Record<string, string> | undefined) => {
@@ -74,21 +75,21 @@ export const httpJson = <Body, Frame>(input: HttpJsonInput<Body, Frame>): HttpJs
prepare: (prepareInput) =>
Effect.gen(function* () {
const parts = yield* jsonRequestParts({ ...prepareInput })
const request = { url: parts.url, method: "POST", headers: { ...parts.headers }, body: parts.bodyText }
yield* (prepareInput.transform?.(request) ?? Effect.void)
const request = ProviderShared.jsonPost({
url: parts.url,
body: parts.bodyText,
headers: parts.headers,
})
return {
request: ProviderShared.jsonPost({
url: request.url,
body: request.body ?? "",
headers: Headers.fromInput(request.headers),
}),
request,
framing: input.framing,
middleware: prepareInput.middleware,
}
}),
frames: (prepared, request, runtime) =>
Stream.unwrap(
runtime.http
.execute(prepared.request)
.execute(prepared.request, prepared.middleware)
.pipe(
Effect.map((response) =>
prepared.framing.frame(
+3 -11
View File
@@ -1,7 +1,7 @@
import type { Effect, Stream } from "effect"
import { Endpoint } from "../endpoint"
import { Auth } from "../auth"
import type { Interface as RequestExecutorInterface } from "../executor"
import type { HttpMiddleware, Interface as RequestExecutorInterface } from "../executor"
import type { Interface as WebSocketExecutorInterface } from "./websocket"
import type { LLMError, LLMRequest } from "../../schema"
@@ -10,15 +10,6 @@ export interface TransportRuntime {
readonly webSocket?: WebSocketExecutorInterface
}
export interface HttpRequest {
url: string
readonly method: string
headers: Record<string, string>
body: string | undefined
}
export type HttpRequestTransform = (request: HttpRequest) => Effect.Effect<void>
export interface Transport<Body, Prepared, Frame> {
readonly id: string
readonly prepare: (input: TransportPrepareInput<Body>) => Effect.Effect<Prepared, LLMError>
@@ -36,8 +27,9 @@ export interface TransportPrepareInput<Body> {
readonly auth: Auth.Definition
readonly encodeBody: (body: Body) => string
readonly headers?: (input: { readonly request: LLMRequest }) => Record<string, string>
readonly transform?: HttpRequestTransform
readonly middleware?: HttpMiddleware
}
export * as HttpTransport from "./http"
export type { HttpHandler, HttpMiddleware } from "../executor"
export { WebSocketExecutor, WebSocketTransport } from "./websocket"
+93 -7
View File
@@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test"
import { Effect, Schema } from "effect"
import { Effect, Ref, Schema } from "effect"
import { HttpClientRequest } from "effect/unstable/http"
import { LLM, mergeProviderOptions } from "../src"
import { AnthropicMessages, OpenAIChat } from "../src/protocols"
@@ -146,12 +146,19 @@ describe("request option precedence", () => {
prompt: "Say hello.",
}),
{
transform: (request) =>
Effect.sync(() => {
expect(request.headers.authorization).toBe("Bearer fresh-key")
request.url = "https://proxy.test/v1/chat/completions"
request.headers["x-plugin"] = "transformed"
request.body = JSON.stringify({ transformed: true })
http: (request, handler) =>
Effect.gen(function* () {
expect(request.headers.get("authorization")).toBe("Bearer fresh-key")
const headers = new Headers(request.headers)
headers.set("x-plugin", "transformed")
headers.set("content-type", "application/custom+json")
return yield* handler(
new Request("https://proxy.test/v1/chat/completions", {
method: "PUT",
headers,
body: JSON.stringify({ transformed: true }),
}),
)
}),
},
).pipe(
@@ -160,7 +167,9 @@ describe("request option precedence", () => {
Effect.gen(function* () {
const web = yield* HttpClientRequest.toWeb(input.request).pipe(Effect.orDie)
expect(web.url).toBe("https://proxy.test/v1/chat/completions")
expect(web.method).toBe("PUT")
expect(web.headers.get("x-plugin")).toBe("transformed")
expect(web.headers.get("content-type")).toBe("application/custom+json")
expect(decodeJson(input.text)).toEqual({ transformed: true })
return input.respond(sseEvents(deltaChunk({}, "stop")), {
headers: { "content-type": "text/event-stream" },
@@ -171,6 +180,83 @@ describe("request option precedence", () => {
),
)
it.effect("transforms the HTTP response before protocol decoding", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(
LLM.request({
model: OpenAIChat.route
.with({ endpoint: { baseURL: "https://api.openai.test/v1/" }, auth: Auth.bearer("test") })
.model({ id: "gpt-4o-mini" }),
prompt: "Say hello.",
}),
{
http: (request, handler) =>
Effect.gen(function* () {
const response = yield* handler(request)
const body = yield* Effect.promise(() => response.text())
return new Response(body.replace("network", "hooked"), {
status: response.status,
headers: response.headers,
})
}),
},
).pipe(
Effect.provide(
dynamicResponse((input) =>
Effect.succeed(
input.respond(sseEvents(deltaChunk({ content: "network" }, "stop")), {
headers: { "content-type": "text/event-stream" },
}),
),
),
),
)
expect(response.text).toBe("hooked")
}),
)
it.effect("can inspect an error response and retry the native request", () =>
Effect.gen(function* () {
const attempts = yield* Ref.make(0)
const response = yield* LLMClient.generate(
LLM.request({
model: OpenAIChat.route
.with({ endpoint: { baseURL: "https://api.openai.test/v1/" }, auth: Auth.bearer("stale") })
.model({ id: "gpt-4o-mini" }),
prompt: "Say hello.",
}),
{
http: (request, handler) =>
Effect.gen(function* () {
const retry = request.clone()
const response = yield* handler(request)
expect(response.status).toBe(401)
const headers = new Headers(retry.headers)
headers.set("authorization", "Bearer refreshed")
return yield* handler(new Request(retry, { headers }))
}),
},
).pipe(
Effect.provide(
dynamicResponse((input) =>
Effect.gen(function* () {
yield* Ref.update(attempts, (value) => value + 1)
if (input.request.headers.authorization !== "Bearer refreshed")
return input.respond("unauthorized", { status: 401 })
return input.respond(sseEvents(deltaChunk({ content: "retried" }, "stop")), {
headers: { "content-type": "text/event-stream" },
})
}),
),
),
)
expect(response.text).toBe("retried")
expect(yield* Ref.get(attempts)).toBe(2)
}),
)
it.effect("applies raw body overlays after protocol lowering", () =>
LLMClient.generate(
LLM.request({
@@ -45,11 +45,7 @@ export default Runtime.handler(Commands, (input) =>
const runPromise = Effect.runPromiseWith(context)
const service = server.service
yield* run({
app: {
name: process.env.OPENCODE_CLIENT ?? "cli",
version: OPENCODE_VERSION,
channel: process.env.OPENCODE_TUI_CHANNEL ?? OPENCODE_CHANNEL,
},
app: { name: process.env.OPENCODE_CLIENT ?? "cli", version: OPENCODE_VERSION, channel: OPENCODE_CHANNEL },
server: {
endpoint: server.endpoint,
service: service
+32 -3
View File
@@ -194,7 +194,9 @@ export function fromPromise(plugin: Plugin) {
),
),
refresh:
refresh === undefined ? undefined : (credential) => Effect.promise(() => refresh(credential)),
refresh === undefined
? undefined
: (credential) => Effect.promise(() => refresh(credential)),
})
},
remove: draft.method.remove,
@@ -263,8 +265,35 @@ export function fromPromise(plugin: Plugin) {
),
},
session: {
hook: (name, callback) =>
register(host.session.hook(name, (event) => Effect.promise(() => Promise.resolve(callback(event))))),
hook: (name, callback) => {
if (name !== "http")
return register(
host.session.hook(name, (event) =>
Effect.promise(() => Promise.resolve(Reflect.apply(callback, undefined, [event]))),
),
)
return register(
host.session.hook("http", (event) => {
const request = event.request
const output = {
...event,
request: (input: Request) =>
Effect.runPromiseWith(context)(request(input), { signal: input.signal }),
}
return Effect.promise(() => Promise.resolve(Reflect.apply(callback, undefined, [output]))).pipe(
Effect.tap(() =>
Effect.sync(() => {
event.request = (input) =>
Effect.tryPromise({
try: (signal) => output.request(new Request(input, { signal })),
catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))),
})
}),
),
)
}),
)
},
create: (input) =>
run(
host.session.create(
+16 -6
View File
@@ -225,15 +225,25 @@ export const OpenAIPlugin = define({
})
}
})
yield* ctx.session.hook("request", (evt) =>
yield* ctx.session.hook("http", (evt) =>
Effect.sync(() => {
if (!chatgpt || evt.model.providerID !== Provider.ID.openai) return
const url = new URL(evt.url)
if (url.origin === "https://api.openai.com") {
evt.url = `${codexBaseURL}${url.pathname.replace(/^\/v1/, "")}${url.search}`
const request = evt.request
evt.request = (input) => {
const url = new URL(input.url)
const headers = new Headers(input.headers)
headers.set("originator", "opencode")
headers.set("session-id", evt.sessionID)
if (url.origin !== "https://api.openai.com") return request(new Request(input, { headers }))
return request(
new Request(`${codexBaseURL}${url.pathname.replace(/^\/v1/, "")}${url.search}`, {
method: input.method,
headers,
body: input.body,
signal: input.signal,
}),
)
}
evt.headers.originator = "opencode"
evt.headers["session-id"] = evt.sessionID
}),
)
+4 -13
View File
@@ -220,24 +220,15 @@ export const layer = Layer.effect(
toolChoice: stepLimitReached ? "none" : undefined,
})
const options: StreamOptions = {
transform: (request) =>
http: (request, handler) =>
hooks
.trigger("session", "request", {
.trigger("session", "http", {
sessionID: session.id,
agent: agent.id,
model: resolved.ref,
...request,
request: handler,
})
.pipe(
Effect.tap((event) =>
Effect.sync(() => {
request.url = event.url
request.headers = event.headers
request.body = event.body
}),
),
Effect.asVoid,
),
.pipe(Effect.flatMap((event) => event.request(request))),
}
if (promptCacheSnapshots) {
const current = PromptCacheDiagnostics.snapshot(request)
+85 -14
View File
@@ -1,6 +1,6 @@
import { describe, expect } from "bun:test"
import { Message, SystemPart } from "@opencode-ai/ai"
import { DateTime, Effect, Schema } from "effect"
import { DateTime, Deferred, Effect, Fiber, Schema } from "effect"
import { Agent } from "@opencode-ai/core/agent"
import { Catalog } from "@opencode-ai/core/catalog"
import { Model } from "@opencode-ai/core/model"
@@ -148,7 +148,9 @@ describe("fromPromise", () => {
expect((await ctx.agent.get({ agentID: Agent.ID.make("reviewer") })).data).toMatchObject({
description: "Reviews code",
})
await expect(ctx.agent.get({ agentID: Agent.ID.make("missing") })).rejects.toThrow("Agent not found: missing")
await expect(ctx.agent.get({ agentID: Agent.ID.make("missing") })).rejects.toThrow(
"Agent not found: missing",
)
const models = (await ctx.catalog.model.list()).data
expect(models.find((model) => model.providerID === "test" && model.id === "alias")).toMatchObject({
modelID: "gpt-5",
@@ -221,6 +223,77 @@ describe("fromPromise", () => {
}),
)
it.effect("adapts promise session HTTP hooks", () =>
Effect.gen(function* () {
const plugin = yield* Plugin.Service
const hooks = yield* PluginHooks.Service
const host = yield* PluginHost.make(plugin)
yield* PluginPromise.fromPromise(
define({
id: "promise-session-http",
setup: async (ctx) => {
await ctx.session.hook("http", (event) => {
const request = event.request
event.request = async (input) => {
const response = await request(new Request(input, { headers: { "x-hook": "promise" } }))
return new Response(`${await response.text()}-response`)
}
})
},
}),
).effect(host)
const event: SessionHooks["http"] = {
sessionID: Session.ID.make("ses_promise_session_http"),
agent: Agent.ID.make("build"),
model: Model.Ref.make({ providerID: Provider.ID.make("test"), id: Model.ID.make("model") }),
request: (input) => Effect.succeed(new Response(input.headers.get("x-hook") ?? "missing")),
}
yield* hooks.trigger("session", "http", event)
const response = yield* event.request(new Request("https://provider.test"))
expect(yield* Effect.promise(() => response.text())).toBe("promise-response")
}),
)
it.effect("interrupts the Effect request through a promise session HTTP hook", () =>
Effect.gen(function* () {
const plugin = yield* Plugin.Service
const hooks = yield* PluginHooks.Service
const host = yield* PluginHost.make(plugin)
yield* PluginPromise.fromPromise(
define({
id: "promise-session-http-interrupt",
setup: async (ctx) => {
await ctx.session.hook("http", (event) => {
const request = event.request
event.request = (input) => request(input)
})
},
}),
).effect(host)
const started = yield* Deferred.make<void>()
const interrupted = yield* Deferred.make<void>()
const event: SessionHooks["http"] = {
sessionID: Session.ID.make("ses_promise_session_http_interrupt"),
agent: Agent.ID.make("build"),
model: Model.Ref.make({ providerID: Provider.ID.make("test"), id: Model.ID.make("model") }),
request: () =>
Deferred.succeed(started, undefined).pipe(
Effect.andThen(Effect.never),
Effect.onInterrupt(() => Deferred.succeed(interrupted, undefined)),
),
}
yield* hooks.trigger("session", "http", event)
const fiber = yield* event.request(new Request("https://provider.test")).pipe(Effect.forkChild)
yield* Deferred.await(started)
yield* Fiber.interrupt(fiber)
expect(yield* Deferred.isDone(interrupted)).toBeTrue()
}),
)
it.effect("disposes a hook registration on request", () =>
Effect.gen(function* () {
const agents = yield* Agent.Service
@@ -315,19 +388,17 @@ describe("fromPromise", () => {
id: "promise-tool",
setup: async (ctx) => {
await ctx.tool.transform((tools) => {
tools.add(
{
name: "hello",
options: { codemode: false },
description: "Hello",
input: Schema.Struct({ name: Schema.String }),
output: Schema.String,
execute: async ({ name }, context) => {
await context.progress({ phase: "greeting" })
return { output: `Hello, ${name}!` }
},
tools.add({
name: "hello",
options: { codemode: false },
description: "Hello",
input: Schema.Struct({ name: Schema.String }),
output: Schema.String,
execute: async ({ name }, context) => {
await context.progress({ phase: "greeting" })
return { output: `Hello, ${name}!` }
},
)
})
})
},
})
@@ -29,6 +29,21 @@ function required<T>(value: T | undefined): T {
return value
}
const http = Effect.fn(function* (providerID: Provider.ID, url: string) {
const event = yield* (yield* PluginHooks.Service).trigger("session", "http", {
sessionID: Session.ID.make("ses_test"),
agent: Agent.ID.make("build"),
model: Model.Ref.make({ providerID, id: Model.ID.make("gpt-5.5") }),
request: (input) => {
const headers = new Headers(input.headers)
headers.set("x-seen-url", input.url)
return Effect.succeed(new Response(null, { headers }))
},
})
const response = yield* event.request(new Request(url, { method: "POST", body: "{}" }))
return { url: response.headers.get("x-seen-url"), headers: Object.fromEntries(response.headers.entries()) }
})
describe("OpenAIPlugin", () => {
it.effect("registers browser and headless ChatGPT OAuth methods", () =>
Effect.gen(function* () {
@@ -100,33 +115,9 @@ describe("OpenAIPlugin", () => {
})
yield* addPlugin()
const request = yield* (yield* PluginHooks.Service).trigger("session", "request", {
sessionID: Session.ID.make("ses_test"),
agent: Agent.ID.make("build"),
model: Model.Ref.make({ providerID: Provider.ID.openai, id: Model.ID.make("gpt-5.5") }),
url: "https://api.openai.com/v1/responses",
method: "POST",
headers: {},
body: "{}",
})
const custom = yield* (yield* PluginHooks.Service).trigger("session", "request", {
sessionID: Session.ID.make("ses_test"),
agent: Agent.ID.make("build"),
model: Model.Ref.make({ providerID: Provider.ID.make("custom-openai"), id: Model.ID.make("gpt-5.5") }),
url: "https://custom.example/v1/responses",
method: "POST",
headers: {},
body: "{}",
})
const proxy = yield* (yield* PluginHooks.Service).trigger("session", "request", {
sessionID: Session.ID.make("ses_test"),
agent: Agent.ID.make("build"),
model: Model.Ref.make({ providerID: Provider.ID.openai, id: Model.ID.make("gpt-5.5") }),
url: "https://proxy.example/v1/responses?region=us",
method: "POST",
headers: {},
body: "{}",
})
const request = yield* http(Provider.ID.openai, "https://api.openai.com/v1/responses")
const custom = yield* http(Provider.ID.make("custom-openai"), "https://custom.example/v1/responses")
const proxy = yield* http(Provider.ID.openai, "https://proxy.example/v1/responses?region=us")
const provider = required(yield* catalog.provider.get(Provider.ID.openai))
expect(provider.package).toBe("@opencode-ai/ai/providers/openai")
@@ -134,7 +125,7 @@ describe("OpenAIPlugin", () => {
expect(provider.headers).toMatchObject({ "chatgpt-account-id": "acct_123" })
expect(request.url).toBe("https://chatgpt.com/backend-api/codex/responses")
expect(request.headers).toMatchObject({ originator: "opencode", "session-id": "ses_test" })
expect(custom.headers).toEqual({})
expect(custom.headers).not.toHaveProperty("originator")
expect(proxy.url).toBe("https://proxy.example/v1/responses?region=us")
expect(proxy.headers).toMatchObject({ originator: "opencode", "session-id": "ses_test" })
const eligible = required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-5.5")))
@@ -184,21 +175,13 @@ describe("OpenAIPlugin", () => {
})
yield* addPlugin()
const request = yield* (yield* PluginHooks.Service).trigger("session", "request", {
sessionID: Session.ID.make("ses_test"),
agent: Agent.ID.make("build"),
model: Model.Ref.make({ providerID: Provider.ID.openai, id: Model.ID.make("gpt-5.5") }),
url: "https://api.openai.com/v1/responses",
method: "POST",
headers: {},
body: "{}",
})
const request = yield* http(Provider.ID.openai, "https://api.openai.com/v1/responses")
const model = required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-5.5")))
expect(model.package).toBe("@opencode-ai/ai/providers/openai")
expect(model.enabled).toBe(true)
expect(model.limit).toEqual({ context: 1_050_000, input: 922_000, output: 128_000 })
expect(request.headers).toEqual({})
expect(request.headers).not.toHaveProperty("originator")
expect(required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-4.1"))).enabled).toBe(true)
}),
)
+4 -4
View File
@@ -1,10 +1,9 @@
import type { SessionApi } from "@opencode-ai/client/effect/api"
import type { Message, SystemPart } from "@opencode-ai/ai"
import type { HttpRequest } from "@opencode-ai/ai/route"
import type { Agent } from "@opencode-ai/schema/agent"
import type { Model } from "@opencode-ai/schema/model"
import type { Session } from "@opencode-ai/schema/session"
import type { JsonSchema } from "effect"
import type { Effect, JsonSchema } from "effect"
import type { Hooks } from "./registration.js"
export interface SessionContext {
@@ -16,15 +15,16 @@ export interface SessionContext {
tools: Record<string, { description: string; input: JsonSchema.JsonSchema }>
}
export interface SessionRequest extends HttpRequest {
export interface SessionHttp {
readonly sessionID: Session.ID
readonly agent: Agent.ID
readonly model: Model.Ref
request: (input: Request) => Effect.Effect<Response, Error>
}
export interface SessionHooks {
readonly context: SessionContext
readonly request: SessionRequest
readonly http: SessionHttp
}
export type SessionDomain = Pick<
+3 -3
View File
@@ -1,6 +1,5 @@
import type { SessionApi } from "@opencode-ai/client/promise/api"
import type { Message, SystemPart } from "@opencode-ai/ai"
import type { HttpRequest } from "@opencode-ai/ai/route"
import type { Agent } from "@opencode-ai/schema/agent"
import type { Model } from "@opencode-ai/schema/model"
import type { Session } from "@opencode-ai/schema/session"
@@ -16,15 +15,16 @@ export interface SessionContext {
tools: Record<string, { description: string; input: JsonSchema.JsonSchema }>
}
export interface SessionRequest extends HttpRequest {
export interface SessionHttp {
readonly sessionID: Session.ID
readonly agent: Agent.ID
readonly model: Model.Ref
request: (input: Request) => Promise<Response>
}
export interface SessionHooks {
readonly context: SessionContext
readonly request: SessionRequest
readonly http: SessionHttp
}
export type SessionDomain = Pick<
+11 -20
View File
@@ -68,7 +68,6 @@ import { DialogAgent } from "./component/dialog-agent"
import { DialogSessionList } from "./component/dialog-session-list"
import { DialogOpen } from "./component/dialog-open"
import { SessionTabs } from "./component/session-tabs"
import { sessionTabsFitVertically } from "./ui/layout"
import { ThemeErrorToast } from "./component/theme-error-toast"
import { ThemeProvider, useTheme, useThemes } from "./context/theme"
import { Home } from "./routes/home"
@@ -84,7 +83,6 @@ import open from "open"
import { PromptRefProvider, usePromptRef } from "./context/prompt"
import { Config, ConfigProvider, useConfig } from "./config"
import { PluginProvider, usePlugin, type PackageResolver } from "./plugin/context"
import { tuiPluginDirectories } from "./plugin/discovery"
import { PluginRoute, PluginSlot } from "./plugin/render"
import { CommandPaletteDialog } from "./component/command-palette"
import { COMMAND_PALETTE_COMMAND, Keymap, type KeymapCommand } from "./context/keymap"
@@ -210,13 +208,9 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
})
const options = { baseUrl: input.server.endpoint.url, headers: Service.headers(input.server.endpoint) }
const api = OpenCode.make(options)
const location = yield* Effect.tryPromise(() => api.file.list({ location: { directory: process.cwd() } })).pipe(
Effect.map((response) => response.location),
Effect.catch(() => Effect.tryPromise(() => api.location.get())),
)
const directory = location.directory
const pluginDirectories = yield* Effect.promise(() =>
tuiPluginDirectories(process.cwd(), global.config),
const directory = yield* Effect.tryPromise(() => api.file.list({ location: { directory: process.cwd() } })).pipe(
Effect.map((response) => response.location.directory),
Effect.catch(() => Effect.tryPromise(() => api.location.get()).pipe(Effect.map((response) => response.directory))),
)
const handoff = input.terminalHandoff ? yield* Effect.promise(input.terminalHandoff) : undefined
const managed = input.server.service
@@ -384,10 +378,7 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
<PromptRefProvider>
<EditorContextProvider>
<AttentionProvider>
<PluginProvider
packages={input.packages}
directories={pluginDirectories}
>
<PluginProvider packages={input.packages}>
<App
pair={
input.server.endpoint.auth
@@ -528,9 +519,6 @@ function App(props: { pair?: DialogPairCredentials }) {
const terminalTitleEnabled = () => config.data.terminal?.title ?? true
const copyOnSelectEnabled = () => config.data.terminal?.copy_on_select ?? process.platform !== "win32"
const pasteSummaryEnabled = () => config.data.prompt?.paste !== "full"
const tabsVertical = () => (config.data.tabs?.vertical ?? false) && sessionTabsFitVertically(dimensions().width)
const tabsVisible = () =>
sessionTabs.enabled() && (sessionTabs.tabs().length > 0 || sessionTabs.newTab()) && route.data.type !== "plugin"
createEffect(() => {
renderer.useMouse = config.data.mouse
@@ -1226,13 +1214,16 @@ function App(props: { pair?: DialogPairCredentials }) {
onMouseUp={copyOnSelectEnabled() ? () => Selection.copy(renderer, toast, clipboard) : undefined}
>
<box flexGrow={1} minHeight={0} flexDirection="row">
<Show when={tabsVisible() && tabsVertical()}>
<SessionTabs orientation="vertical" />
</Show>
<box flexGrow={1} minWidth={0} flexDirection="column">
<Show when={plugins.ready()}>
<box flexGrow={1} minHeight={0} flexDirection="column">
<Show when={tabsVisible() && !tabsVertical()}>
<Show
when={
sessionTabs.enabled() &&
(sessionTabs.tabs().length > 0 || sessionTabs.newTab()) &&
route.data.type !== "plugin"
}
>
<SessionTabs />
</Show>
<Switch>
@@ -100,15 +100,6 @@ export const settings: Setting[] = [
values: ["cwd", "global"],
labels: ["current directory", "global"],
},
{
title: "Vertical",
category: "Tabs",
path: ["tabs", "vertical"],
default: false,
values: [false, true],
labels: ["off", "on"],
keywords: ["sidebar", "orientation", "left"],
},
{
title: "Layout",
category: "Diffs",
+3 -3
View File
@@ -1,3 +1,4 @@
import path from "path"
import { createMemo, createResource, createSignal, onMount } from "solid-js"
import type { SessionInfo } from "@opencode-ai/client"
import { useTerminalDimensions } from "@opentui/solid"
@@ -17,7 +18,6 @@ import { truncateFilePath } from "../ui/file-path"
import { stringWidth } from "../util/string-width"
import { withTimestampedFallback } from "@opencode-ai/util/session-title-fallback"
import { Spinner } from "./spinner"
import { projectName } from "../util/project"
const RECENT_LIMIT = 8
@@ -81,7 +81,7 @@ export function DialogOpen() {
.slice(0, RECENT_LIMIT)
const sessionOptions = recent.map((session) => {
const project = data.project.get(session.projectID)
const name = projectName(project)
const name = project?.canonical === "/" ? undefined : project?.name || path.basename(project?.canonical ?? "")
const running =
data.session.status(session.id) === "running" ||
data.session.family(session.id).some((id) => data.session.status(id) === "running")
@@ -109,7 +109,7 @@ export function DialogOpen() {
return true
})
.map((project) => {
const title = projectName(project) ?? project.canonical
const title = project.name ?? path.basename(project.canonical)
const footer = abbreviateHome(project.canonical, paths.home)
const width =
dialogSelectContentWidth(Math.min(dialogWidth("large"), dimensions().width - 2)) - stringWidth(title)
@@ -20,7 +20,6 @@ import { useSessionTabs } from "../context/session-tabs"
import { useStorage } from "../context/storage"
import { useConfig } from "../config"
import { withTimestampedFallback } from "@opencode-ai/util/session-title-fallback"
import { projectName } from "../util/project"
export function DialogSessionList() {
const dialog = useDialog()
@@ -124,7 +123,8 @@ export function DialogSessionList() {
const current = data.location.info()
if (!current) return ""
const project = data.project.get(current.project.id)
return projectName(project) ?? ""
if (!project) return ""
return project.name || path.basename(project.canonical)
})
const options = createMemo(() => {
@@ -141,7 +141,9 @@ export function DialogSessionList() {
const option = (session: SessionInfo, category: string) => {
const directory = session.location.directory
const project = data.project.get(session.projectID)
const footer = allProjects() ? Locale.truncate(projectName(project, directory) ?? "", 20) : undefined
const footer = allProjects()
? Locale.truncate(project?.name || path.basename(project?.canonical ?? directory), 20)
: undefined
const slot = sessionTabs.enabled() ? undefined : slotByID.get(session.id)
const deleting = toDelete() === session.id
return {
@@ -4,6 +4,8 @@ import { useDialog } from "../ui/dialog"
import { useData } from "../context/data"
import { For, Match, Switch, Show, createMemo } from "solid-js"
export type DialogStatusProps = {}
export function DialogStatus() {
const data = useData()
const theme = useTheme("elevated")
@@ -23,7 +23,6 @@ import { displayCharAt, mentionTriggerIndex } from "../../prompt/display"
import type { FileSystemEntry } from "@opencode-ai/client"
import { stringWidth } from "../../util/string-width"
import { parseFileLineRange, stripFileLineRange } from "../../prompt/parse"
import { moveSelection, revealSelectionOffset } from "../../ui/select-controller"
export type AutocompleteRef = {
onInput: (value: string) => void
@@ -502,19 +501,22 @@ export function Autocomplete(props: {
function move(direction: -1 | 1) {
if (!store.visible) return
if (!options().length) return
moveTo(moveSelection(store.selected, { count: options().length, delta: direction, policy: "wrap" }))
let next = store.selected + direction
if (next < 0) next = options().length - 1
if (next >= options().length) next = 0
moveTo(next)
}
function moveTo(next: number) {
setStore("selected", next)
if (!scroll) return
const offset = revealSelectionOffset(scroll.scrollTop, {
count: options().length,
limit: Math.min(height(), options().length),
selected: next,
})
if (offset === scroll.scrollTop) return
scroll.scrollBy(offset - scroll.scrollTop)
const viewportHeight = Math.min(height(), options().length)
const scrollBottom = scroll.scrollTop + viewportHeight
if (next < scroll.scrollTop) {
scroll.scrollBy(next - scroll.scrollTop)
} else if (next + 1 > scrollBottom) {
scroll.scrollBy(next + 1 - scrollBottom)
}
}
function select() {
@@ -8,6 +8,7 @@ import {
type KeyEvent,
} from "@opentui/core"
import { createEffect, createMemo, onMount, createSignal, onCleanup, on, Show, Switch, Match } from "solid-js"
import { registerOpencodeSpinner } from "../register-spinner"
import path from "path"
import { fileURLToPath } from "url"
import { useLocal } from "../../context/local"
@@ -54,6 +55,8 @@ import { Keymap, type KeymapCommand } from "../../context/keymap"
import { abbreviateHome } from "../../runtime"
import { PluginSlot } from "../../plugin/render"
registerOpencodeSpinner()
export type PromptProps = {
sessionID?: string
visible?: boolean
+16 -375
View File
@@ -1,9 +1,8 @@
import { RGBA, ScrollBoxRenderable, TextAttributes } from "@opentui/core"
import { For, Show, createComputed, createEffect, createMemo, createSignal, onCleanup, untrack } from "solid-js"
import { RGBA, TextAttributes } from "@opentui/core"
import { For, Show, createComputed, createEffect, createMemo, createSignal, untrack } from "solid-js"
import { useTerminalDimensions } from "@opentui/solid"
import { useConfig } from "../config"
import { useSessionTabs } from "../context/session-tabs"
import { useData } from "../context/data"
import { useTheme, useThemes } from "../context/theme"
import {
adaptiveSessionTabLayout,
@@ -18,10 +17,8 @@ import {
import { createAnimatable, spring, tween } from "../ui/animation"
import { Locale } from "../util/locale"
import { stringWidth } from "../util/string-width"
import { TabPulse, TabPulseTimeline, unreadGlowIntensity } from "./tab-pulse"
import { TabPulse, unreadGlowIntensity } from "./tab-pulse"
import { tint } from "../theme/color"
import { SESSION_SIDEBAR_WIDTH } from "../ui/layout"
import { projectName } from "../util/project"
// A long title fades out over its last cells instead of cutting hard.
const FADE_WIDTH = 4
@@ -42,364 +39,8 @@ export type SessionTabsController = Pick<ContextController, "tabs" | "current" |
}
const NEW_SESSION_TAB: SessionTab = { sessionID: "new", title: NEW_SESSION_TAB_TITLE }
const glowTextColor = (base: RGBA, glow: RGBA, index: number, width: number) =>
tint(base, glow, 0.12 * unreadGlowIntensity(index, width))
export function SessionTabs(
props: { controller?: SessionTabsController; animations?: boolean; orientation?: "horizontal" | "vertical" } = {},
) {
if (props.orientation === "vertical")
return <VerticalSessionTabs controller={props.controller} animations={props.animations} />
return <HorizontalSessionTabs controller={props.controller} animations={props.animations} />
}
function VerticalSessionTabs(props: { controller?: SessionTabsController; animations?: boolean }) {
const tabs = props.controller ?? useSessionTabs()
const data = useData()
const theme = useTheme("elevated")
const { mode } = useThemes()
const config = useConfig().data
const animations = () => props.animations ?? config.animations ?? true
const width = () => SESSION_SIDEBAR_WIDTH
const hueStep = () => (mode() === "light" ? 800 : 200)
const accent = () => theme.hue.accent[hueStep()]
const activeNumber = () => theme.hue.interactive[hueStep()]
const idleNumber = () => tint(theme.text.subdued, theme.background.default, 0.35)
const separatorUpperPulseColor = createMemo(() => tint(theme.background.default, theme.text.default, 0.04))
const separatorLowerPulseColor = createMemo(() => tint(theme.background.default, theme.text.default, 0.05))
const [hovered, setHovered] = createSignal<string>()
const [dragging, setDragging] = createSignal<string>()
const [preview, setPreview] = createSignal<{ sessionID: string; index: number }>()
const newTab = () => tabs.newTab?.() ?? false
const activeID = createMemo(() => (newTab() ? NEW_SESSION_TAB.sessionID : tabs.current()))
const ordered = createMemo(() => {
const pending = preview()
if (!pending) return tabs.tabs()
return moveSessionTab(tabs.tabs(), pending.sessionID, pending.index)
})
const items = createMemo(() => (newTab() ? [...ordered(), NEW_SESSION_TAB] : ordered()))
const statuses = createMemo(
() =>
new Map(
items().map((tab) => {
const status = tab === NEW_SESSION_TAB ? EMPTY_SESSION_TAB_STATUS : tabs.status(tab.sessionID)
return [
tab.sessionID,
{
...status,
complete: sessionTabComplete(status.unread, status.busy),
runs: status.busy && !status.attention,
glows:
tab.sessionID !== activeID() && (status.attention || (!status.busy && status.unread !== undefined)),
},
] as const
}),
),
)
const itemStatus = (tab: SessionTab) => statuses().get(tab.sessionID)!
const pulseTimelines = new Map<string, TabPulseTimeline>()
const pulseTimeline = (sessionID: string) => {
const existing = pulseTimelines.get(sessionID)
if (existing) return existing
const created = new TabPulseTimeline()
pulseTimelines.set(sessionID, created)
return created
}
let rail: { screenY: number } | undefined
let scroll: ScrollBoxRenderable | undefined
createEffect(() => {
const pending = preview()
if (!pending || dragging()) return
const index = tabs.tabs().findIndex((tab) => tab.sessionID === pending.sessionID)
if (index === -1 || index === Math.min(pending.index, tabs.tabs().length - 1)) setPreview(undefined)
})
createEffect(() => {
if (!scroll) return
const index = items().findIndex((tab) => tab.sessionID === activeID())
if (index === -1) return
const top = index * 3
if (top < scroll.scrollTop) return scroll.scrollTo(top)
if (top + 2 > scroll.scrollTop + scroll.viewport.height) {
scroll.scrollTo(top + 2 - scroll.viewport.height)
}
})
return (
<box
ref={(element) => (rail = element)}
width={width()}
height="100%"
flexShrink={0}
flexDirection="column"
paddingTop={1}
backgroundColor={theme.background.default}
>
<scrollbox ref={(element) => (scroll = element)} flexGrow={1} scrollbarOptions={{ visible: false }}>
<box flexShrink={0} flexDirection="column" gap={1}>
<For each={items()}>
{(tab, index) => {
const timeline = pulseTimeline(tab.sessionID)
onCleanup(() => pulseTimelines.delete(tab.sessionID))
const selected = () => activeID() === tab.sessionID
const status = createMemo(() => itemStatus(tab))
const [sweepLevel, setSweepLevel] = createSignal(0)
const session = createMemo(() => (tab === NEW_SESSION_TAB ? undefined : data.session.get(tab.sessionID)))
const project = createMemo(() => {
const value = session()
return value ? data.project.get(value.projectID) : undefined
})
const numberWidth = () => String(index() + 1).length + 1
const titleWidth = () => Math.max(1, width() - numberWidth() - 2 - (hovered() === tab.sessionID ? 1 : 0))
const title = () => tab.title ?? "Untitled session"
const visibleTitle = createMemo(() => Locale.takeWidth(title(), titleWidth()))
const visibleTitleParts = createMemo(() => Locale.graphemes(visibleTitle()))
const titleFades = createMemo(() => stringWidth(title()) >= titleWidth() && titleWidth() > FADE_WIDTH)
const detail = createMemo(() => {
if (tab === NEW_SESSION_TAB) return Locale.takeWidth("Start a new session", titleWidth())
const value = session()
return Locale.takeWidth(projectName(project(), value?.location.directory) ?? "", titleWidth())
})
const background = createMemo(() => {
if (selected()) return theme.background.action.primary.selected
if (hovered() === tab.sessionID || dragging() === tab.sessionID)
return theme.background.action.primary.hovered
return theme.background.default
})
const pulseBackground = createMemo(() => tint(theme.background.default, background(), background().a))
const numberColor = () => {
if (status().attention) return theme.text.feedback.warning.default
if (status().unread === "error") return theme.text.feedback.error.default
const base =
hovered() === tab.sessionID && !selected()
? foreground()
: tint(idleNumber(), activeNumber(), Number(selected()))
const color = tint(base, accent(), Number(complete()))
return sweepLevel() === 0 ? color : tint(color, theme.text.default, 0.15 * sweepLevel())
}
const foreground = () => {
if (hovered() === tab.sessionID) return theme.text.default
return selected() ? theme.text.default : theme.text.subdued
}
const complete = () => status().complete
const glowHue = () => {
if (status().attention) return theme.text.feedback.warning.default
if (status().unread === "error") return theme.text.feedback.error.default
return accent()
}
const pulseColor = createMemo(() => tint(pulseBackground(), theme.text.default, 0.25))
const glowColor = createMemo(() => tint(pulseBackground(), glowHue(), 0.45))
const detailPulseColor = createMemo(() => tint(pulseBackground(), theme.text.default, 0.13))
const detailGlowColor = createMemo(() => tint(pulseBackground(), glowHue(), 0.25))
const detailColor = createMemo(() => tint(theme.text.subdued, pulseBackground(), 0.35))
const glows = () => status().glows
const previous = createMemo(() => items()[index() - 1])
const previousTimeline = () => {
const tab = previous()
return tab ? pulseTimeline(tab.sessionID) : undefined
}
const previousStatus = createMemo(() => {
const tab = previous()
return tab
? itemStatus(tab)
: { ...EMPTY_SESSION_TAB_STATUS, complete: false, runs: false, glows: false }
})
const previousGlows = () => previousStatus().glows
const runs = () => status().runs
const previousRuns = () => previousStatus().runs
const previousGlowHue = () => {
if (previousStatus().attention) return theme.text.feedback.warning.default
if (previousStatus().unread === "error") return theme.text.feedback.error.default
return accent()
}
const separatorUpperColor = createMemo(() => tint(theme.background.default, previousGlowHue(), 0.1))
const separatorLowerColor = createMemo(() => tint(theme.background.default, glowHue(), 0.12))
const titleColor = (index: number) => {
const color = glows()
? glowTextColor(foreground(), glowColor(), 1 + numberWidth() + index, width())
: foreground()
if (!titleFades() || index < visibleTitleParts().length - FADE_WIDTH) return color
const position = index - (visibleTitleParts().length - FADE_WIDTH)
return tint(color, pulseBackground(), 0.2 + 0.72 * (position / Math.max(1, FADE_WIDTH - 1)))
}
const release = () => {
setDragging(undefined)
const pending = preview()
if (pending?.sessionID === tab.sessionID) tabs.move(pending.sessionID, pending.index)
if (tab !== NEW_SESSION_TAB) tabs.select(tab.sessionID)
}
return (
<box
height={2}
width="100%"
position="relative"
flexDirection="column"
backgroundColor={background()}
onMouseOver={() => setHovered(tab.sessionID)}
onMouseOut={() => setHovered(undefined)}
onMouseDown={() => setDragging(tab.sessionID)}
onMouseUp={release}
onMouseDrag={(event) => {
if (!rail || tab === NEW_SESSION_TAB) return
const target = Math.max(
0,
Math.min(
tabs.tabs().length - 1,
Math.floor((event.y - rail.screenY - 1 + (scroll?.scrollTop ?? 0)) / 3),
),
)
if (target !== index() && preview()?.index !== target)
setPreview({ sessionID: tab.sessionID, index: target })
}}
onMouseDragEnd={release}
>
<TabPulse
top={-1}
edge="above"
enabled={animations()}
layer={{
timeline,
active: runs(),
promptPulse: status().promptPulse,
complete: complete() && !status().attention,
glow: glows(),
breathe: status().attention,
color: separatorLowerPulseColor(),
glowColor: separatorLowerColor(),
glowTail: 8,
completionColor: separatorLowerColor(),
}}
edgeLayer={{
timeline: previousTimeline(),
active: previousRuns(),
promptPulse: previousStatus().promptPulse,
complete: previousStatus().complete && !previousStatus().attention,
glow: previousGlows(),
breathe: previousStatus().attention,
color: separatorUpperPulseColor(),
glowColor: separatorUpperColor(),
glowTail: 5,
completionColor: separatorUpperColor(),
}}
backgroundColor={theme.background.default}
/>
<Show when={index() === items().length - 1}>
<TabPulse
top={2}
edge="below"
enabled={animations()}
layer={{
timeline,
active: runs(),
promptPulse: status().promptPulse,
complete: complete() && !status().attention,
glow: glows(),
breathe: status().attention,
color: tint(theme.background.default, theme.text.default, 0.04),
glowColor: tint(theme.background.default, glowHue(), 0.1),
glowTail: 8,
completionColor: tint(theme.background.default, glowHue(), 0.1),
}}
edgeLayer={{
color: tint(theme.background.default, theme.text.default, 0.006),
glowColor: theme.background.default,
glowTail: 5,
completionColor: theme.background.default,
}}
backgroundColor={theme.background.default}
/>
</Show>
<box height={1} width="100%" flexDirection="row" position="relative">
<TabPulse
enabled={animations()}
layer={{
timeline,
active: runs(),
promptPulse: status().promptPulse,
complete: complete() && !status().attention,
glow: glows(),
breathe: status().attention,
color: pulseColor(),
glowColor: glowColor(),
completionColor: glowColor(),
}}
backgroundColor={pulseBackground()}
onLevel={setSweepLevel}
/>
<box zIndex={1} width="100%" flexDirection="row" paddingLeft={1} paddingRight={1}>
<text
width={numberWidth()}
fg={numberColor()}
selectable={false}
attributes={selected() ? TextAttributes.BOLD : undefined}
>
{index() + 1}
</text>
<text
width={titleWidth()}
fg={foreground()}
wrapMode="none"
selectable={false}
attributes={selected() ? TextAttributes.BOLD : undefined}
>
<Show when={glows() || titleFades()} fallback={visibleTitle()}>
<For each={visibleTitleParts()}>
{(character, index) => <span style={{ fg: titleColor(index()) }}>{character}</span>}
</For>
</Show>
</text>
<text
position="absolute"
right={1}
zIndex={2}
width={1}
fg={theme.text.subdued}
selectable={false}
onMouseUp={(event) => {
if (hovered() !== tab.sessionID) return
event.stopPropagation()
tabs.close(tab === NEW_SESSION_TAB ? undefined : tab.sessionID)
}}
>
{hovered() === tab.sessionID ? "×" : ""}
</text>
</box>
</box>
<box height={1} width="100%" position="relative" flexDirection="row">
<TabPulse
enabled={animations()}
layer={{
timeline,
active: runs(),
promptPulse: status().promptPulse,
complete: complete() && !status().attention,
glow: glows(),
breathe: status().attention,
color: detailPulseColor(),
glowColor: detailGlowColor(),
glowTail: 10,
completionColor: detailGlowColor(),
}}
backgroundColor={pulseBackground()}
/>
<box zIndex={1} width="100%" flexDirection="row" paddingLeft={numberWidth() + 1} paddingRight={2}>
<text fg={detailColor()} wrapMode="none" selectable={false}>
{detail()}
</text>
</box>
</box>
</box>
)
}}
</For>
</box>
</scrollbox>
</box>
)
}
function HorizontalSessionTabs(props: { controller?: SessionTabsController; animations?: boolean } = {}) {
export function SessionTabs(props: { controller?: SessionTabsController; animations?: boolean } = {}) {
const tabs = props.controller ?? useSessionTabs()
const dimensions = useTerminalDimensions()
const theme = useTheme()
@@ -628,7 +269,9 @@ function HorizontalSessionTabs(props: { controller?: SessionTabsController; anim
// spatial falloff as the glow itself; characters beyond the tail stay neutral.
const characterColor = (index: number) => {
const base = foreground()
const color = glows() ? glowTextColor(base, glowColor(), 1 + numberWidth() + index, width()) : base
const color = glows()
? tint(base, glowColor(), 0.12 * unreadGlowIntensity(1 + numberWidth() + index, width()))
: base
if (!titleFades() || index < displayedParts().length - FADE_WIDTH) return color
const position = index - (displayedParts().length - FADE_WIDTH)
return tint(color, background(), 0.2 + 0.72 * (position / Math.max(1, FADE_WIDTH - 1)))
@@ -677,17 +320,15 @@ function HorizontalSessionTabs(props: { controller?: SessionTabsController; anim
>
<TabPulse
enabled={animations()}
layer={{
active: status().busy && !status().attention,
promptPulse: status().promptPulse,
complete: status().complete && !status().attention,
glow: glows(),
breathe: status().attention,
color: pulseColor(),
glowColor: glowColor(),
flashColor: flashColor(),
completionColor: accent(),
}}
active={status().busy && !status().attention}
promptPulse={status().promptPulse}
complete={status().complete && !status().attention}
glow={glows()}
breathe={status().attention}
color={pulseColor()}
glowColor={glowColor()}
flashColor={flashColor()}
completionColor={accent()}
backgroundColor={background()}
onLevel={setSweepLevel}
/>
+194 -438
View File
@@ -1,28 +1,17 @@
import { OptimizedBuffer, Renderable, RGBA, type RenderableOptions, type RenderContext } from "@opentui/core"
import { extend } from "@opentui/solid"
export type TabPulseState = {
type TabPulseOptions = RenderableOptions<TabPulseRenderable> & {
enabled?: boolean
active?: boolean
promptPulse?: number
complete?: boolean
glow?: boolean
breathe?: boolean
}
export type TabPulseLayer = TabPulseState & {
timeline?: TabPulseTimeline
color: RGBA
color?: RGBA
glowColor?: RGBA
glowTail?: number
flashColor?: RGBA
completionColor?: RGBA
}
type TabPulseOptions = RenderableOptions<TabPulseRenderable> & {
edge?: "above" | "below"
enabled?: boolean
layer?: TabPulseLayer
edgeLayer?: TabPulseLayer
backgroundColor?: RGBA
/** Reports the running sweep's intensity at the tab number's cell, quantized; 0 when idle. */
onLevel?: (level: number) => void
@@ -31,7 +20,6 @@ type TabPulseOptions = RenderableOptions<TabPulseRenderable> & {
const clamp = (value: number) => Math.max(0, Math.min(1, value))
const smootherstep = (value: number) => value * value * value * (value * (value * 6 - 15) + 10)
const RUN_DURATION = 2_800
const RUN_ATTACK = 450
const RUN_HEAD = 4
const RUN_TAIL = 18
const RUN_FADE_OUT = 500
@@ -70,10 +58,9 @@ const attackDecay = (progress: number, attack: number, peak: number, rest: numbe
export const completionPulseOpacity = (progress: number) => attackDecay(progress, COMPLETION_ATTACK, 1, 0)
export const glowIgnitionLevel = (progress: number) =>
attackDecay(progress, GLOW_IGNITION_ATTACK, GLOW_IGNITION_PEAK, 1)
const glowIntensityAt = (index: number, tail: number) => smootherstep(clamp(1 - Math.max(0, index - 1) / tail))
export const unreadGlowIntensity = (index: number, width: number, maximumTail = GLOW_TAIL) => {
const tail = Math.min(maximumTail, Math.max(1, width - 2))
return glowIntensityAt(index, tail)
export const unreadGlowIntensity = (index: number, width: number) => {
const tail = Math.min(GLOW_TAIL, Math.max(1, width - 2))
return smootherstep(clamp(1 - Math.max(0, index - 1) / tail))
}
export function blendTabPulseColor(
output: RGBA,
@@ -144,328 +131,45 @@ class Envelope {
// Hoisted so the per-frame liveness check allocates no closure.
const envelopeActive = (envelope: Envelope) => envelope.active
type PulseStateOptions = {
enabled: boolean
active: boolean
promptPulse: number
complete: boolean
glow: boolean
breathe: boolean
}
class PulseState {
private enabled: boolean
private active: boolean
private promptPulse: number
private complete: boolean
private glow: boolean
private breathe: boolean
class TabPulseRenderable extends Renderable {
private _enabled: boolean
private _active: boolean
private _promptPulse: number
private _complete: boolean
private _glow: boolean
private _breathe: boolean
private _color: RGBA
private _glowColor: RGBA
private _flashColor: RGBA
private _completionColor: RGBA
private _backgroundColor: RGBA
private clock = 0
private breatheClock = 0
private completionPending = false
private runAttack = new Envelope(RUN_ATTACK, smootherstep)
private runFade = new Envelope(RUN_FADE_OUT, fadeOut)
private completionPulse = new Envelope(COMPLETION_DURATION, completionPulseOpacity)
private edgeFlash = new Envelope(EDGE_FLASH_DURATION, (progress) => attackDecay(progress, EDGE_FLASH_ATTACK, 1, 0))
private ignition = new Envelope(GLOW_IGNITION_DURATION, glowIgnitionLevel)
private glowOff = new Envelope(GLOW_FADE_OUT, fadeOut)
private envelopes = [this.runAttack, this.runFade, this.completionPulse, this.edgeFlash, this.ignition, this.glowOff]
constructor(options: PulseStateOptions) {
this.enabled = options.enabled
this.active = options.active
this.promptPulse = options.promptPulse
this.complete = options.complete
this.glow = options.glow
this.breathe = options.breathe
if (this.enabled && this.active) this.runAttack.start()
}
private get breathing() {
return this.enabled && this.glow && this.breathe
}
get live() {
return this.active || this.breathing || this.envelopes.some(envelopeActive)
}
get running() {
if (!this.enabled) return 0
return this.active ? (this.runAttack.active ? this.runAttack.level() : 1) : this.runFade.level()
}
get completion() {
return this.completionPulse.level() * COMPLETION_OPACITY
}
get flash() {
return this.edgeFlash.level() * EDGE_FLASH_OPACITY
}
get glowLevel() {
if (!this.glow) return this.glowOff.level()
const base = this.ignition.active ? this.ignition.level() : 1
if (!this.breathing) return base
return (
base * (1 + GLOW_BREATHE_RISE * 0.5 * (1 - Math.cos((2 * Math.PI * this.breatheClock) / GLOW_BREATHE_PERIOD)))
)
}
setEnabled(value: boolean) {
if (value === this.enabled) return false
this.enabled = value
if (!value) {
for (const envelope of this.envelopes) envelope.stop()
this.completionPending = false
this.breatheClock = 0
} else if (this.active) {
this.runAttack.restart()
}
return true
}
setActive(value: boolean) {
if (value === this.active) return false
this.active = value
if (!this.enabled) return true
if (value) {
this.clock = 0
this.runAttack.restart()
this.runFade.stop()
this.completionPulse.stop()
this.completionPending = false
} else {
const level = this.runAttack.active ? this.runAttack.level() : 1
this.runAttack.stop()
this.runFade.start(level)
this.completionPending = true
}
this.edgeFlash.start()
return true
}
setPromptPulse(value: number) {
if (value === this.promptPulse) return false
this.promptPulse = value
if (this.enabled) this.edgeFlash.restart(PROMPT_FLASH_SCALE)
return true
}
setComplete(value: boolean) {
if (value === this.complete) return false
this.complete = value
if (!value) {
this.completionPulse.stop()
this.completionPending = false
}
if (value && this.completionPending) {
this.completionPending = false
if (this.enabled) this.completionPulse.start()
}
return true
}
setGlow(value: boolean) {
if (value === this.glow) return false
if (this.enabled && !value) this.glowOff.start(this.glowLevel)
this.glow = value
this.ignition.stop()
this.breatheClock = 0
if (this.enabled && value) {
this.glowOff.stop()
this.ignition.start()
}
return true
}
setBreathe(value: boolean) {
if (value === this.breathe) return false
this.breathe = value
this.breatheClock = 0
return true
}
advance(deltaTime: number) {
if (!this.enabled) return
if (this.active || this.runFade.active) this.clock += deltaTime
if (this.breathing) this.breatheClock += deltaTime
for (const envelope of this.envelopes) envelope.advance(deltaTime)
if (!this.completionPending) return
if (this.complete) {
this.completionPending = false
this.completionPulse.start()
return
}
if (!this.runFade.active) this.completionPending = false
}
fronts(width: number) {
const cycles = this.clock / RUN_DURATION
const progress = cycles % 1
const start = -RUN_HEAD
const end = width - 1 + RUN_TAIL
const secondProgress = cycles < 0.5 ? 0 : (cycles + 0.5) % 1
return [start + coast(progress) * (end - start), start + coast(secondProgress) * (end - start)] as const
}
}
export class TabPulseTimeline {
private state: PulseState
private frame = -1
constructor(options: TabPulseState = {}, enabled = true) {
this.state = new PulseState({
enabled,
active: options.active ?? false,
promptPulse: options.promptPulse ?? 0,
complete: options.complete ?? false,
glow: options.glow ?? false,
breathe: options.breathe ?? false,
})
}
set(options: TabPulseState, enabled: boolean) {
return [
this.state.setEnabled(enabled),
// Stopping a run arms completion; apply complete afterward so an atomic idle update can consume it.
this.state.setActive(options.active ?? false),
this.state.setPromptPulse(options.promptPulse ?? 0),
this.state.setComplete(options.complete ?? false),
this.state.setGlow(options.glow ?? false),
this.state.setBreathe(options.breathe ?? false),
].some(Boolean)
}
setEnabled(value: boolean) {
return this.state.setEnabled(value)
}
advance(deltaTime: number, frame: number) {
if (frame === this.frame) return
this.frame = frame
this.state.advance(deltaTime)
}
get live() {
return this.state.live
}
get running() {
return this.state.running
}
get completion() {
return this.state.completion
}
get flash() {
return this.state.flash
}
get glowLevel() {
return this.state.glowLevel
}
fronts(width: number) {
return this.state.fronts(width)
}
}
class PulseLayer {
timeline: TabPulseTimeline
private source: TabPulseTimeline | undefined
private enabled: boolean
color: RGBA
glowColor: RGBA
glowTail: number
flashColor: RGBA
completionColor: RGBA
constructor(options: TabPulseLayer, enabled: boolean) {
this.enabled = enabled
this.source = options.timeline
this.timeline = options.timeline ?? new TabPulseTimeline(options, enabled)
if (options.timeline) options.timeline.set(options, enabled)
this.color = options.color
this.glowColor = options.glowColor ?? options.color
this.glowTail = options.glowTail ?? GLOW_TAIL
this.flashColor = options.flashColor ?? options.color
this.completionColor = options.completionColor ?? options.color
}
set(options: TabPulseLayer) {
const sourceChanged = options.timeline !== this.source
if (sourceChanged) {
this.source = options.timeline
this.timeline = options.timeline ?? new TabPulseTimeline(options, this.enabled)
}
const stateChanged = sourceChanged
? (options.timeline?.set(options, this.enabled) ?? false)
: this.timeline.set(options, this.enabled)
const color = options.color
const glowColor = options.glowColor ?? color
const glowTail = options.glowTail ?? GLOW_TAIL
const flashColor = options.flashColor ?? color
const completionColor = options.completionColor ?? color
const changed = [
sourceChanged,
stateChanged,
!color.equals(this.color),
!glowColor.equals(this.glowColor),
glowTail !== this.glowTail,
!flashColor.equals(this.flashColor),
!completionColor.equals(this.completionColor),
].some(Boolean)
this.color = color
this.glowColor = glowColor
this.glowTail = glowTail
this.flashColor = flashColor
this.completionColor = completionColor
return changed
}
setEnabled(value: boolean) {
this.enabled = value
return this.timeline.setEnabled(value)
}
}
const DEFAULT_LAYER: TabPulseLayer = { color: RGBA.defaultForeground() }
class TabPulseRenderable extends Renderable {
private _enabled: boolean
private primary: PulseLayer
private adjacent: PulseLayer
private primaryAssigned: boolean
private adjacentAssigned: boolean
private _edge: "above" | "below" | undefined
private _backgroundColor: RGBA
private primaryRenderColor = RGBA.fromInts(0, 0, 0)
private adjacentRenderColor = RGBA.fromInts(0, 0, 0)
private envelopes = [this.runFade, this.completionPulse, this.edgeFlash, this.ignition, this.glowOff]
private renderColor = RGBA.fromInts(0, 0, 0)
private _onLevel: ((level: number) => void) | undefined
private lastLevel = 0
constructor(ctx: RenderContext, options: TabPulseOptions = {}) {
const enabled = options.enabled ?? true
const primary = options.layer ?? DEFAULT_LAYER
const adjacent = options.edgeLayer ?? DEFAULT_LAYER
const edge = options.edge
super(ctx, {
...options,
height: 1,
live:
enabled &&
((primary.active ?? false) ||
((primary.glow ?? false) && (primary.breathe ?? false)) ||
(edge !== undefined &&
((adjacent.active ?? false) || ((adjacent.glow ?? false) && (adjacent.breathe ?? false))))),
})
const active = options.active ?? false
super(ctx, { ...options, height: 1, live: enabled && active })
this._enabled = enabled
this.primary = new PulseLayer(primary, enabled)
this.adjacent = new PulseLayer(adjacent, enabled && edge !== undefined)
this.primaryAssigned = options.layer !== undefined
this.adjacentAssigned = options.edgeLayer !== undefined
this._edge = edge
this._active = active
this._promptPulse = options.promptPulse ?? 0
this._complete = options.complete ?? false
this._glow = options.glow ?? false
this._breathe = options.breathe ?? false
this._color = options.color ?? RGBA.defaultForeground()
this._glowColor = options.glowColor ?? this._color
this._flashColor = options.flashColor ?? this._color
this._completionColor = options.completionColor ?? this._color
this._backgroundColor = options.backgroundColor ?? RGBA.defaultBackground()
this._onLevel = options.onLevel
}
@@ -475,46 +179,128 @@ class TabPulseRenderable extends Renderable {
}
private emitLevel(value: number) {
if (!this._onLevel) return
const quantized = Math.round(value * 32) / 32
if (quantized === this.lastLevel) return
this.lastLevel = quantized
this._onLevel(quantized)
this._onLevel?.(quantized)
}
private get breathing() {
return this._enabled && this._glow && this._breathe
}
/** Resting glow is 1; ignition overshoots on arrival, breathing swells while pending, glowOff decays after. */
private glowLevel() {
if (!this._glow) return this.glowOff.level()
const base = this.ignition.active ? this.ignition.level() : 1
if (!this.breathing) return base
return (
base * (1 + GLOW_BREATHE_RISE * 0.5 * (1 - Math.cos((2 * Math.PI * this.breatheClock) / GLOW_BREATHE_PERIOD)))
)
}
set enabled(value: boolean) {
if (value === this._enabled) return
this._enabled = value
this.primary.setEnabled(value)
this.adjacent.setEnabled(value && this._edge !== undefined)
this.changed()
}
set layer(value: TabPulseLayer) {
if (!this.primaryAssigned) {
this.primaryAssigned = true
this.primary = new PulseLayer(value, this._enabled)
this.changed()
return
if (!value) {
for (const envelope of this.envelopes) envelope.stop()
this.completionPending = false
this.breatheClock = 0
this.live = false
} else if (this._active || this.breathing) {
this.live = true
}
if (this.primary.set(value)) this.changed()
this.requestRender()
}
set edgeLayer(value: TabPulseLayer) {
if (!this.adjacentAssigned) {
this.adjacentAssigned = true
this.adjacent = new PulseLayer(value, this._enabled && this._edge !== undefined)
this.changed()
return
set active(value: boolean) {
if (value === this._active) return
this._active = value
if (!this._enabled) return
if (value) {
this.runFade.stop()
this.completionPulse.stop()
this.completionPending = false
} else {
this.runFade.start()
this.completionPending = true
}
if (this.adjacent.set(value)) this.changed()
// The same neutral edge flash marks both the start and the finish of a run.
this.edgeFlash.start()
this.live = true
this.requestRender()
}
set edge(value: "above" | "below" | undefined) {
if (value === this._edge) return
this._edge = value
this.adjacent.setEnabled(this._enabled && value !== undefined)
this.changed()
set promptPulse(value: number) {
if (value === this._promptPulse) return
this._promptPulse = value
if (!this._enabled) return
this.edgeFlash.restart(PROMPT_FLASH_SCALE)
this.live = true
this.requestRender()
}
set complete(value: boolean) {
if (value === this._complete) return
this._complete = value
if (!value) {
this.completionPulse.stop()
this.completionPending = false
}
if (value && this.completionPending) {
this.completionPending = false
if (this._enabled) {
this.completionPulse.start()
this.live = true
}
}
this.requestRender()
}
set glow(value: boolean) {
if (value === this._glow) return
if (this._enabled && !value) this.glowOff.start(this.glowLevel())
this._glow = value
this.ignition.stop()
this.breatheClock = 0
if (this._enabled && value) {
this.glowOff.stop()
this.ignition.start()
this.live = true
}
this.requestRender()
}
set breathe(value: boolean) {
if (value === this._breathe) return
this._breathe = value
this.breatheClock = 0
if (this.breathing) this.live = true
this.requestRender()
}
set color(value: RGBA) {
if (value.equals(this._color)) return
this._color = value
this.requestRender()
}
set glowColor(value: RGBA) {
if (value.equals(this._glowColor)) return
this._glowColor = value
this.requestRender()
}
set flashColor(value: RGBA) {
if (value.equals(this._flashColor)) return
this._flashColor = value
this.requestRender()
}
set completionColor(value: RGBA) {
if (value.equals(this._completionColor)) return
this._completionColor = value
this.requestRender()
}
set backgroundColor(value: RGBA) {
@@ -523,52 +309,44 @@ class TabPulseRenderable extends Renderable {
this.requestRender()
}
private changed() {
this.live = this.primary.timeline.live || this.adjacent.timeline.live
this.requestRender()
}
protected override onUpdate(deltaTime: number): void {
if (!this._enabled) return
this.primary.timeline.advance(deltaTime, this.ctx.frameId)
this.adjacent.timeline.advance(deltaTime, this.ctx.frameId)
this.live = this.primary.timeline.live || this.adjacent.timeline.live
if (this._active || this.runFade.active) this.clock += deltaTime
if (this.breathing) this.breatheClock += deltaTime
for (const envelope of this.envelopes) envelope.advance(deltaTime)
if (this.completionPending) {
if (this._complete) {
this.completionPending = false
this.completionPulse.start()
} else if (!this.runFade.active) {
this.completionPending = false
}
}
this.live = this._active || this.breathing || this.envelopes.some(envelopeActive)
}
protected override renderSelf(buffer: OptimizedBuffer): void {
if (!this.visible || this.isDestroyed || this.width <= 0) return
const running = this.primary.timeline.running
const completion = this.primary.timeline.completion
const flash = this.primary.timeline.flash
const glowLevel = this.primary.timeline.glowLevel
const adjacentRunning = this.adjacent.timeline.running
const adjacentCompletion = this.adjacent.timeline.completion
const adjacentFlash = this.adjacent.timeline.flash
const adjacentGlowLevel = this.adjacent.timeline.glowLevel
if (
glowLevel === 0 &&
running === 0 &&
completion === 0 &&
flash === 0 &&
adjacentGlowLevel === 0 &&
adjacentRunning === 0 &&
adjacentCompletion === 0 &&
adjacentFlash === 0
) {
const running = !this._enabled ? 0 : this._active ? 1 : this.runFade.level()
const completion = this.completionPulse.level() * COMPLETION_OPACITY
// The edge flash is a neutral wash on the running stage; the accent completion stage stays reserved for results.
const flash = this.edgeFlash.level() * EDGE_FLASH_OPACITY
const glowLevel = this.glowLevel()
if (glowLevel === 0 && running === 0 && completion === 0 && flash === 0) {
this.emitLevel(0)
return
}
const [front, secondFront] = this.primary.timeline.fronts(this.width)
const [adjacentFront, adjacentSecondFront] = this.adjacent.timeline.fronts(this.width)
if (this._onLevel)
this.emitLevel(
running === 0
? 0
: Math.max(intensityAt(1, front, RUN_HEAD, RUN_TAIL), intensityAt(1, secondFront, RUN_HEAD, RUN_TAIL)) *
running,
)
const glowTail = Math.min(this.primary.glowTail, Math.max(1, this.width - 2))
const adjacentGlowTail = Math.min(this.adjacent.glowTail, Math.max(1, this.width - 2))
const progress = (this.clock % RUN_DURATION) / RUN_DURATION
const start = -RUN_HEAD
const end = this.width - 1 + RUN_TAIL
const front = start + coast(progress) * (end - start)
const secondFront = start + coast((progress + 0.5) % 1) * (end - start)
this.emitLevel(
running === 0
? 0
: Math.max(intensityAt(1, front, RUN_HEAD, RUN_TAIL), intensityAt(1, secondFront, RUN_HEAD, RUN_TAIL)) *
running,
)
for (let index = 0; index < this.width; index++) {
// Skip per-cell sweep and glow math when that stage is idle, e.g. a steady breathing glow.
const sweep =
@@ -580,50 +358,19 @@ class TabPulseRenderable extends Renderable {
) *
0.14 *
running
const adjacentSweep =
adjacentRunning === 0
? 0
: Math.max(
intensityAt(index, adjacentFront, RUN_HEAD, RUN_TAIL),
intensityAt(index, adjacentSecondFront, RUN_HEAD, RUN_TAIL),
) *
0.14 *
adjacentRunning
blendTabPulseColor(
this.primaryRenderColor,
this.renderColor,
this._backgroundColor,
this.primary.glowColor,
this.primary.color,
this.primary.flashColor,
this.primary.completionColor,
glowLevel === 0 ? 0 : glowIntensityAt(index, glowTail) * GLOW_OPACITY * glowLevel,
this._glowColor,
this._color,
this._flashColor,
this._completionColor,
glowLevel === 0 ? 0 : unreadGlowIntensity(index, this.width) * GLOW_OPACITY * glowLevel,
sweep,
flash,
completion,
)
if (!this._edge) {
buffer.setCell(this.screenX + index, this.screenY, " ", DEFAULT_FOREGROUND, this.primaryRenderColor)
continue
}
blendTabPulseColor(
this.adjacentRenderColor,
this._backgroundColor,
this.adjacent.glowColor,
this.adjacent.color,
this.adjacent.flashColor,
this.adjacent.completionColor,
adjacentGlowLevel === 0 ? 0 : glowIntensityAt(index, adjacentGlowTail) * GLOW_OPACITY * adjacentGlowLevel,
adjacentSweep,
adjacentFlash,
adjacentCompletion,
)
buffer.setCell(
this.screenX + index,
this.screenY,
this._edge === "above" ? "▄" : "▀",
this.primaryRenderColor,
this.adjacentRenderColor,
)
buffer.setCell(this.screenX + index, this.screenY, " ", DEFAULT_FOREGROUND, this.renderColor)
}
}
}
@@ -637,25 +384,34 @@ declare module "@opentui/solid" {
extend({ tab_pulse: TabPulseRenderable })
export function TabPulse(props: {
top?: number
width?: number
edge?: "above" | "below"
enabled?: boolean
layer: TabPulseLayer
edgeLayer?: TabPulseLayer
active: boolean
promptPulse?: number
complete?: boolean
glow?: boolean
breathe?: boolean
color: RGBA
glowColor?: RGBA
flashColor?: RGBA
completionColor?: RGBA
backgroundColor: RGBA
onLevel?: (level: number) => void
}) {
return (
<tab_pulse
position="absolute"
top={props.top}
edge={props.edge}
zIndex={0}
width={props.width ?? "100%"}
width="100%"
enabled={props.enabled ?? true}
layer={props.layer}
edgeLayer={props.edge === undefined ? undefined : (props.edgeLayer ?? props.layer)}
active={props.active}
promptPulse={props.promptPulse ?? 0}
complete={props.complete ?? false}
glow={props.glow ?? false}
breathe={props.breathe ?? false}
color={props.color}
glowColor={props.glowColor ?? props.color}
flashColor={props.flashColor ?? props.color}
completionColor={props.completionColor ?? props.color}
backgroundColor={props.backgroundColor}
onLevel={props.onLevel}
/>
-3
View File
@@ -132,9 +132,6 @@ export const Info = Schema.Struct({
scope: Schema.optional(Schema.Literals(["global", "cwd"])).annotate({
description: "Share tabs globally or keep a separate set for each working directory",
}),
vertical: Schema.optional(Schema.Boolean).annotate({
description: "Show tabs in a left sidebar instead of a horizontal strip",
}),
}),
).annotate({ description: "Tab strip settings" }),
mini: Schema.optional(
+2
View File
@@ -67,6 +67,8 @@ function initialRoute(value: unknown): Route | undefined {
}
}
export type RouteContext = ReturnType<typeof useRoute>
export function useRouteData<T extends Route["type"]>(type: T) {
const route = useRoute()
return route.data as Extract<Route, { type: typeof type }>
+2 -3
View File
@@ -22,7 +22,7 @@ import {
type ThemeDocumentSource,
} from "../theme"
import { generateSystem, terminalMode } from "../theme/system"
import { discoverThemes } from "../theme/discovery"
import { discoverThemes, themeDirectories } from "../theme/discovery"
import { createComponentTheme, type ComponentTheme } from "../theme/component"
import { createEffect, createMemo, onCleanup, onMount, type Accessor, type ParentProps } from "solid-js"
import { createStore, produce } from "solid-js/store"
@@ -30,7 +30,6 @@ import { createSimpleContext } from "./helper"
import { useConfig } from "../config"
import { Global } from "@opencode-ai/util/global"
import { DevTools } from "../devtools"
import { configDirectories } from "../util/config-directories"
const themePerformance = DevTools.register({ id: "theme-performance", title: "Theme performance" })
export type ThemeError = { name: string; error: Error }
@@ -71,7 +70,7 @@ export type ThemeSource = Readonly<{
const themeSource: ThemeSource = {
async discover() {
return discoverThemes(configDirectories(Global.Path.config, process.cwd()))
return discoverThemes(themeDirectories(Global.Path.config, process.cwd()))
},
subscribeRefresh(refresh) {
process.on("SIGUSR2", refresh)
@@ -10,18 +10,24 @@ const money = new Intl.NumberFormat("en-US", {
export function PromptFooter(props: { context: Plugin.Context; sessionID?: string; mode: "normal" | "shell" }) {
const dimensions = useTerminalDimensions()
const subagents = createMemo(() => {
const activeSubagents = createMemo(() => {
if (!props.sessionID) return 0
const count = props.context.data.session
return props.context.data.session
.family(props.sessionID)
.filter((id) => id !== props.sessionID && props.context.data.session.status(id) === "running").length
})
const runningShells = createMemo(() => {
if (!props.sessionID) return 0
return props.context.data.shell
.list(props.context.location)
.filter((shell) => shell.metadata.sessionID === props.sessionID).length
})
const subagents = createMemo(() => {
const count = activeSubagents()
return count ? `${count} subagent${count === 1 ? "" : "s"}` : undefined
})
const shells = createMemo(() => {
if (!props.sessionID) return 0
const count = props.context.data.shell
.list(props.context.location)
.filter((shell) => shell.metadata.sessionID === props.sessionID).length
const count = runningShells()
return count ? `${count} shell${count === 1 ? "" : "s"}` : undefined
})
const status = createMemo(() => {
@@ -34,10 +40,8 @@ export function PromptFooter(props: { context: Plugin.Context; sessionID?: strin
session.revert?.messageID,
)
const cost = props.context.data.session.cost(props.sessionID)
return [
usage ? formatContextUsage(usage.tokens, usage.percent) : undefined,
cost > 0 ? money.format(cost) : undefined,
].filter((item): item is string => Boolean(item))
return [usage ? formatContextUsage(usage.tokens, usage.percent) : undefined, cost > 0 ? money.format(cost) : undefined]
.filter((item): item is string => Boolean(item))
})
const live = createMemo(() => Boolean(subagents() || shells()))
const shortcut = (id: string) => props.context.keymap.shortcuts(id)[0]
+4 -1
View File
@@ -90,7 +90,10 @@ export async function runPromptQueue(input: QueueInput): Promise<void> {
draining = (async () => {
try {
while (!state.closed && state.queue.length > 0) {
const prompt = state.queue.shift()!
const prompt = state.queue.shift()
if (!prompt) {
continue
}
if (prompt.mode !== "shell" && isNewCommand(prompt.text)) {
if (!input.onNewSession) {
+7 -9
View File
@@ -13,7 +13,7 @@ import { errorMessage } from "../util/error"
import { builtins } from "./builtins"
import { createPluginContext, usePluginHost, type Dispose } from "./api"
import { createSourceWatcher } from "./watch"
import { discoverTuiPlugins, freshSpecifier, localSource } from "./discovery"
import { discoverTuiPlugins, freshSpecifier, localSource, tuiPluginDirectory } from "./discovery"
export interface PackageResolver {
readonly resolve: (spec: string) => Promise<string | undefined>
@@ -57,7 +57,7 @@ type Desired = Pick<Registration, "plugin" | "source" | "target" | "version" | "
const PluginContext = createContext<Value>()
export function PluginProvider(props: ParentProps<{ packages: PackageResolver; directories: string[] }>) {
export function PluginProvider(props: ParentProps<{ packages: PackageResolver }>) {
const host = usePluginHost()
const config = useConfig()
const lifecycle = useTuiLifecycle()
@@ -171,11 +171,10 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver; d
void enqueue(reconcile).catch(() => undefined)
}, 100)
})
const stopWatching = () => {
onCleanup(() => {
clearTimeout(pending)
watcher.dispose()
}
onCleanup(stopWatching)
})
// Rebuild the plugin generation as resolve → compare → swap, mirroring the
// core plugin registry: fold the ordered entries into a desired end state
@@ -187,8 +186,8 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver; d
// every watch event; remember them until the configuration changes.
const npmFailures = new Map<string, string>()
const reconcile = async () => {
await Promise.all(props.directories.map(watcher.wait))
const entries = [...(await discoverTuiPlugins(props.directories)), ...(config.data.plugins ?? [])]
const entries = [...(await discoverTuiPlugins(host.paths.cwd)), ...(config.data.plugins ?? [])]
watcher.add(tuiPluginDirectory(host.paths.cwd))
// Resolve: fold entries into one desired generation. A source that fails
// to import keeps its running previous version and only reports failure.
@@ -211,7 +210,7 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver; d
const options = typeof entry === "string" ? undefined : entry.options
// Watch even when the resolve below fails so fixing a broken plugin reloads it.
const local = localSource(target, directory)
if (local) await watcher.add(fileURLToPath(local))
if (local) watcher.add(fileURLToPath(local))
const previous = Object.values(store.registrations).find((registration) => registration.target === target)
const memo = local ? undefined : npmFailures.get(target)
const resolved = memo
@@ -364,7 +363,6 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver; d
let disposing: Promise<void> | undefined
const dispose = () => {
if (disposing) return disposing
stopWatching()
disposing = loading
.catch(() => undefined)
.then(() =>
+13 -37
View File
@@ -1,47 +1,23 @@
import { readdir, stat } from "node:fs/promises"
import { readdir } from "node:fs/promises"
import path from "node:path"
import { fileURLToPath, pathToFileURL } from "node:url"
import {
isMissingPath,
localProjectDirectory,
projectConfigDirectories,
} from "../util/config-directories"
const extensions = new Set([".cjs", ".cts", ".js", ".jsx", ".mjs", ".mts", ".ts", ".tsx"])
export async function tuiPluginDirectories(cwd: string, configDirectory: string) {
const projectDirectory = await localProjectDirectory(cwd)
const projectConfig = path.join(projectDirectory, ".opencode")
const directories = [configDirectory, ...projectConfigDirectories(projectDirectory, cwd)]
const exists = await Promise.all(
directories.map((directory) => {
if (directory === configDirectory || directory === projectConfig) return true
return stat(directory).then(
(info) => info.isDirectory(),
(error) => (isMissingPath(error) ? false : Promise.reject(error)),
)
}),
)
return directories
.filter((_, index) => exists[index])
.map((directory) => path.join(directory, "plugins", "tui"))
export function tuiPluginDirectory(cwd: string) {
return path.join(cwd, ".opencode", "plugins", "tui")
}
export async function discoverTuiPlugins(directories: string[]) {
return (
await Promise.all(
directories.map(async (directory) => {
const entries = await readdir(directory, { withFileTypes: true }).catch((error: unknown) => {
if (isMissingPath(error)) return []
return Promise.reject(error)
})
return entries
.filter((entry) => (entry.isFile() || entry.isSymbolicLink()) && extensions.has(path.extname(entry.name)))
.map((entry) => path.join(directory, entry.name))
.sort()
}),
)
).flat()
export async function discoverTuiPlugins(cwd: string) {
const directory = tuiPluginDirectory(cwd)
const entries = await readdir(directory, { withFileTypes: true }).catch((error: unknown) => {
if (error && typeof error === "object" && Reflect.get(error, "code") === "ENOENT") return []
return Promise.reject(error)
})
return entries
.filter((entry) => (entry.isFile() || entry.isSymbolicLink()) && extensions.has(path.extname(entry.name)))
.map((entry) => path.join(directory, entry.name))
.sort()
}
export function localSource(spec: string, directory: string) {
+17 -51
View File
@@ -10,30 +10,21 @@ import { lstat, realpath, stat } from "fs/promises"
// Directory targets are watched at their root only: edits to nested helper
// files do not change the entrypoint mtime and are not detected. Watches are
// never torn down individually (a stale watch costs one fs handle and a
// spurious onChange); all die with dispose(). Missing retryable targets are
// polled until they can be armed without relying on a racy chain of ancestor
// watches.
// spurious onChange); all die with dispose(). Failed or vanished watches are
// forgotten so a later add() can re-arm once the path exists.
export function createSourceWatcher(onChange: () => void) {
const watchers = new Map<string, ReturnType<typeof watch>>()
const watched = new Map<string, Set<string> | null>()
const missing = new Set<string>()
const arming = new Map<string, Promise<void>>()
let disposed = false
const notify = () => {
if (!disposed) onChange()
}
const forget = (dir: string) => {
watchers.get(dir)?.close()
watchers.delete(dir)
watched.delete(dir)
}
const arm = (target: string, retry: boolean) => {
const active = arming.get(target)
if (active) return active
const result = stat(target)
const arm = (target: string) => {
stat(target)
.then((info) => {
if (disposed) return
const appeared = missing.delete(target)
const dir = info.isDirectory() ? target : path.dirname(target)
// Directories accept every filename (null); files accept their basename.
const name = info.isDirectory() ? null : path.basename(target)
@@ -41,69 +32,44 @@ export function createSourceWatcher(onChange: () => void) {
if (existing !== undefined) {
if (name === null) watched.set(dir, null)
else existing?.add(name)
if (appeared) notify()
return
}
watched.set(dir, name === null ? null : new Set([name]))
const watcher = watch(dir, (_event, filename) => {
// A replaced directory keeps this watcher on the dead inode (Linux
// emits rename, not error); forget it so a later add() re-arms on
// the recreated path, and still schedule so reconcile runs now.
if (!existsSync(dir)) {
forget(dir)
notify()
onChange()
return
}
// A null filename (platform-dependent) always schedules.
const accept = watched.get(dir)
if (filename && accept && !accept.has(filename.toString())) return
notify()
})
watched.set(dir, name === null ? null : new Set([name]))
// Reconcile after watcher errors so every source is re-added and any
// temporarily unavailable target moves into the polling set.
watcher.on("error", () => {
forget(dir)
notify()
onChange()
})
// A watched directory can disappear out from under us; without a
// listener the error event would crash the process. Forget the path
// so a later add can re-arm once it exists again.
watcher.on("error", () => forget(dir))
watchers.set(dir, watcher)
if (appeared) notify()
})
.catch((error: unknown) => {
if (!disposed && retry && isMissing(error)) missing.add(target)
})
.finally(() => arming.delete(target))
arming.set(target, result)
return result
.catch(() => undefined)
}
const add = async (target: string, retry: boolean) => {
await arm(target, retry)
const add = (target: string) => {
arm(target)
// A symlinked source receives edits at its resolved target.
await lstat(target)
lstat(target)
.then((info) => {
if (!info.isSymbolicLink()) return
return realpath(target).then((target) => arm(target, retry))
return realpath(target).then(arm)
})
.catch(() => undefined)
}
const dispose = () => {
disposed = true
clearInterval(poll)
for (const watcher of watchers.values()) watcher.close()
watchers.clear()
watched.clear()
missing.clear()
}
const poll = setInterval(() => missing.forEach((target) => arm(target, true)), 500)
poll.unref()
return {
add: (target: string) => add(target, false),
wait: (target: string) => add(target, true),
dispose,
}
}
function isMissing(error: unknown) {
if (!error || typeof error !== "object") return false
const code = Reflect.get(error, "code")
return code === "ENOENT" || code === "ENOTDIR"
return { add, dispose }
}
+2 -10
View File
@@ -64,7 +64,6 @@ import { errorMessage } from "../../util/error"
import { useToast } from "../../ui/toast"
import stripAnsi from "strip-ansi"
import { usePromptRef } from "../../context/prompt"
import { sessionTabsFitVertically, SESSION_SIDEBAR_WIDTH } from "../../ui/layout"
import { projectedPromptInput } from "../../prompt/codec"
import { useEpilogue } from "../../context/epilogue"
import { normalizePath } from "../../util/path"
@@ -200,21 +199,14 @@ export function Session() {
const diffWrapMode = createMemo(() => config.diffs?.wrap ?? "word")
const groupExploration = createMemo(() => config.session?.grouping !== "none")
const availableWidth = createMemo(
() =>
dimensions().width -
(config.tabs?.enabled && config.tabs.vertical && sessionTabsFitVertically(dimensions().width)
? SESSION_SIDEBAR_WIDTH
: 0),
)
const wide = createMemo(() => availableWidth() > 120)
const wide = createMemo(() => dimensions().width > 120)
const sidebarVisible = createMemo(() => {
if (session()?.parentID) return false
if (sidebarOpen()) return true
if (sidebar() === "auto" && wide()) return true
return false
})
const contentWidth = createMemo(() => availableWidth() - (sidebarVisible() ? 42 : 0) - 4)
const contentWidth = createMemo(() => dimensions().width - (sidebarVisible() ? 42 : 0) - 4)
const models = createMemo(() => data.location.model.list(location()) ?? [])
const scrollAcceleration = createMemo(() => getScrollAcceleration(config))
+2 -7
View File
@@ -6,7 +6,6 @@ import { PluginSlot } from "../../plugin/render"
import { withTimestampedFallback } from "@opencode-ai/util/session-title-fallback"
import { getScrollAcceleration } from "../../util/scroll"
import { SESSION_SIDEBAR_WIDTH } from "../../ui/layout"
export function Sidebar(props: { sessionID: string; overlay?: boolean }) {
const data = useData()
@@ -19,7 +18,7 @@ export function Sidebar(props: { sessionID: string; overlay?: boolean }) {
<Show when={session()}>
<box
backgroundColor={theme.background.default}
width={SESSION_SIDEBAR_WIDTH}
width={42}
height="100%"
paddingTop={1}
paddingBottom={1}
@@ -28,11 +27,7 @@ export function Sidebar(props: { sessionID: string; overlay?: boolean }) {
position={props.overlay ? "absolute" : "relative"}
>
<scrollbox
ref={(scroll) =>
queueMicrotask(() => {
if (!scroll.isDestroyed) scroll.verticalScrollBar.resetVisibilityControl()
})
}
ref={(scroll) => queueMicrotask(() => scroll.verticalScrollBar.resetVisibilityControl())}
flexGrow={1}
scrollAcceleration={scrollAcceleration()}
verticalScrollbarOptions={{
+9
View File
@@ -1,6 +1,15 @@
import { readdir, readFile } from "node:fs/promises"
import path from "node:path"
export function themeDirectories(config: string, cwd: string) {
const directories: string[] = []
for (let current = cwd; ; current = path.dirname(current)) {
directories.push(path.join(current, ".opencode"))
if (path.dirname(current) === current) break
}
return [config, ...directories.reverse()]
}
export async function discoverThemes(directories: string[]) {
const result: Record<string, unknown> = {}
for (const directory of directories) {
+12 -1
View File
@@ -1,7 +1,7 @@
import { TextareaRenderable, TextAttributes } from "@opentui/core"
import { Keymap } from "../context/keymap"
import { useTheme } from "../context/theme"
import { useDialog } from "./dialog"
import { useDialog, type DialogContext } from "./dialog"
import { Show, createEffect, createSignal, onMount, type JSX } from "solid-js"
import { Spinner } from "../component/spinner"
@@ -112,3 +112,14 @@ export function DialogPrompt(props: DialogPromptProps) {
</box>
)
}
DialogPrompt.show = (dialog: DialogContext, title: string, options?: Omit<DialogPromptProps, "title">) => {
return new Promise<string | null>((resolve) => {
dialog.replace(
() => (
<DialogPrompt title={title} {...options} onConfirm={(value) => resolve(value)} onCancel={() => resolve(null)} />
),
() => resolve(null),
)
})
}
-6
View File
@@ -1,6 +0,0 @@
export const SESSION_SIDEBAR_WIDTH = 42
const SESSION_CONTENT_MIN_WIDTH = 44
export function sessionTabsFitVertically(total: number) {
return total >= SESSION_SIDEBAR_WIDTH + SESSION_CONTENT_MIN_WIDTH
}
@@ -1,45 +0,0 @@
import path from "node:path"
import { stat } from "node:fs/promises"
export function configDirectories(config: string, cwd: string) {
return [...new Set([config, ...ancestors(cwd).map((directory) => path.join(directory, ".opencode"))])]
}
export function projectConfigDirectories(project: string, cwd: string) {
const directories = ancestors(cwd)
return directories
.slice(directories.indexOf(path.resolve(project)))
.map((directory) => path.join(directory, ".opencode"))
}
export async function localProjectDirectory(cwd: string) {
const directories = ancestors(cwd)
const repositories = await Promise.all(
directories.map((directory) =>
Promise.all(
[".git", ".hg"].map((name) =>
stat(path.join(directory, name)).then(
() => true,
(error) => (isMissingPath(error) ? false : Promise.reject(error)),
),
),
).then((matches) => matches.some(Boolean)),
),
)
return directories.findLast((_, index) => repositories[index]) ?? path.resolve(cwd)
}
export function isMissingPath(error: unknown) {
if (!error || typeof error !== "object") return false
const code = Reflect.get(error, "code")
return code === "ENOENT" || code === "ENOTDIR"
}
function ancestors(cwd: string) {
const directories: string[] = []
for (let current = path.resolve(cwd); ; current = path.dirname(current)) {
directories.push(current)
if (path.dirname(current) === current) break
}
return directories.reverse()
}
-7
View File
@@ -1,7 +0,0 @@
import path from "path"
export function projectName(project?: { canonical: string; name?: string }, fallback = "") {
const canonical = project?.canonical ?? fallback
if (canonical === "/") return fallback ? path.basename(fallback) : undefined
return project?.name || path.basename(canonical)
}
@@ -20,12 +20,10 @@ test("a prompt pulse restarts the neutral edge flash while the tab remains busy"
() => (
<box width={8} height={1} backgroundColor={background}>
<TabPulse
layer={{
active: true,
promptPulse: promptPulse(),
color: background,
flashColor: flash,
}}
active={true}
promptPulse={promptPulse()}
color={background}
flashColor={flash}
backgroundColor={background}
/>
</box>
+1 -1
View File
@@ -17,7 +17,7 @@ test("validates mini replay settings", () => {
test("validates the session tabs setting", () => {
const decode = Schema.decodeUnknownSync(Info)
expect(decode({ tabs: { enabled: true, vertical: true } })).toEqual({ tabs: { enabled: true, vertical: true } })
expect(decode({ tabs: { enabled: true } })).toEqual({ tabs: { enabled: true } })
expect(() => decode({ tabs: { enabled: "on" } })).toThrow()
})
+6 -51
View File
@@ -1,8 +1,7 @@
import { mkdir, writeFile } from "node:fs/promises"
import path from "node:path"
import { expect, test } from "bun:test"
import { discoverTuiPlugins, tuiPluginDirectories } from "../src/plugin/discovery"
import { localProjectDirectory } from "../src/util/config-directories"
import { discoverTuiPlugins } from "../src/plugin/discovery"
import { tmpdir } from "./fixture/fixture"
test("discovers project TUI plugin files in stable order", async () => {
@@ -16,57 +15,13 @@ test("discovers project TUI plugin files in stable order", async () => {
writeFile(path.join(directory, "nested", "ignored.ts"), "export default {}"),
])
expect(
await discoverTuiPlugins(await tuiPluginDirectories(tmp.path, path.join(tmp.path, "config"))),
).toEqual([path.join(directory, "first.js"), path.join(directory, "second.tsx")])
expect(await discoverTuiPlugins(tmp.path)).toEqual([
path.join(directory, "first.js"),
path.join(directory, "second.tsx"),
])
})
test("returns no project TUI plugins when the directory is absent", async () => {
await using tmp = await tmpdir()
const roots = await tuiPluginDirectories(tmp.path, path.join(tmp.path, "config"))
expect(await discoverTuiPlugins(roots)).toEqual([])
expect(roots).toContain(path.join(tmp.path, ".opencode", "plugins", "tui"))
})
test("discovers global and ancestor plugin roots in precedence order", async () => {
await using tmp = await tmpdir()
const cwd = path.join(tmp.path, "repo", "packages", "app")
const project = path.join(tmp.path, "repo")
const config = path.join(tmp.path, "config")
const directories = [
path.join(config, "plugins", "tui"),
path.join(tmp.path, "repo", ".opencode", "plugins", "tui"),
path.join(tmp.path, "repo", "packages", ".opencode", "plugins", "tui"),
]
const outside = path.join(tmp.path, ".opencode", "plugins", "tui")
await mkdir(path.join(project, ".git"), { recursive: true })
await Promise.all([...directories, outside].map((directory) => mkdir(directory, { recursive: true })))
await Promise.all(
directories.map((directory, index) => writeFile(path.join(directory, `${index}.ts`), "export default {}")),
)
await writeFile(path.join(outside, "outside.ts"), "export default {}")
const roots = await tuiPluginDirectories(cwd, config)
expect(await discoverTuiPlugins(roots)).toEqual(
directories.map((directory, index) => path.join(directory, `${index}.ts`)),
)
expect(roots).not.toContain(path.join(cwd, ".opencode", "plugins", "tui"))
expect(roots).not.toContain(outside)
})
test("uses an Hg root for a missing project plugin directory", async () => {
await using tmp = await tmpdir()
const project = path.join(tmp.path, "repo")
const cwd = path.join(project, "package")
await mkdir(path.join(project, ".hg"), { recursive: true })
await mkdir(cwd, { recursive: true })
expect(await tuiPluginDirectories(cwd, path.join(tmp.path, "config"))).toContain(
path.join(project, ".opencode", "plugins", "tui"),
)
})
test("propagates non-missing filesystem errors", async () => {
await expect(localProjectDirectory("\0")).rejects.toBeInstanceOf(Error)
await expect(discoverTuiPlugins(["\0"])).rejects.toBeInstanceOf(Error)
expect(await discoverTuiPlugins(tmp.path)).toEqual([])
})
+4 -43
View File
@@ -1,10 +1,11 @@
import { expect, mock, test } from "bun:test"
import { createTestRenderer } from "@opentui/core/testing"
import { Effect, FileSystem } from "effect"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { Global } from "@opencode-ai/util/global"
import { mkdir, readFile, symlink, writeFile } from "node:fs/promises"
import path from "node:path"
import { createEventStream, createFetch, json } from "./fixture/tui-client"
import { createEventStream, createFetch } from "./fixture/tui-client"
import { tmpdir } from "./fixture/fixture"
function lifecycleSource(marker: string, id: string, version: string) {
@@ -35,16 +36,7 @@ async function bootApp(directory: string) {
const core = await import("@opentui/core")
mock.module("@opentui/core", () => ({ ...core, createCliRenderer: async () => setup.renderer }))
const events = createEventStream()
const calls = createFetch((url) => {
if (url.pathname !== "/api/fs/list") return
return json({
location: {
directory,
project: { id: "proj_test", directory, canonical: directory },
},
data: [],
})
}, events)
const calls = createFetch(undefined, events)
const server = Bun.serve({ port: 0, fetch: (request) => calls.fetch(request) })
const cwd = process.cwd()
process.chdir(directory)
@@ -57,10 +49,7 @@ async function bootApp(directory: string) {
packages: { resolve: async () => undefined },
args: {},
log: () => {},
}).pipe(
Effect.provide(Global.layerWith({ config: path.join(directory, ".global") })),
Effect.provide(FileSystem.layerNoop({})),
),
}).pipe(Effect.provide(AppNodeBuilder.build(Global.node)), Effect.provide(FileSystem.layerNoop({}))),
)
return {
task,
@@ -73,34 +62,6 @@ async function bootApp(directory: string) {
}
}
test("discovers an ancestor TUI plugin directory created after startup", async () => {
await using tmp = await tmpdir()
const cwd = path.join(tmp.path, "repo", "packages", "app")
await mkdir(cwd, { recursive: true })
await mkdir(path.join(tmp.path, "repo", ".git"))
const ready = path.join(tmp.path, "ready.txt")
const marker = path.join(tmp.path, "marker.txt")
const initial = path.join(cwd, ".opencode", "plugins", "tui")
await mkdir(initial, { recursive: true })
await writeFile(path.join(initial, "ready.ts"), lifecycleSource(ready, "test.ready", "ready"))
await using app = await bootApp(cwd)
expect(await until(() => readFile(ready, "utf8"), (value) => value === "ready:setup\n")).toBe("ready:setup\n")
const directory = path.join(tmp.path, "repo", ".opencode", "plugins", "tui")
await mkdir(directory, { recursive: true })
await writeFile(path.join(directory, "hot.ts"), lifecycleSource(marker, "test.hot", "v1"))
expect(
await until(
() => readFile(marker, "utf8"),
(value) => value === "v1:setup\n",
),
).toBe("v1:setup\n")
process.emit("SIGHUP")
await app.task
})
test("editing a discovered TUI plugin hot-reloads its fresh module", async () => {
await using tmp = await tmpdir()
const directory = path.join(tmp.path, ".opencode", "plugins", "tui")
+2 -3
View File
@@ -12,8 +12,7 @@ import {
setCustomThemes,
upsertTheme,
} from "../src/theme"
import { discoverThemes } from "../src/theme/discovery"
import { configDirectories } from "../src/util/config-directories"
import { discoverThemes, themeDirectories } from "../src/theme/discovery"
import { terminalMode } from "../src/theme/system"
import { tmpdir } from "./fixture/fixture"
@@ -188,7 +187,7 @@ test("theme directories include global config before project directories", async
await writeFile(path.join(global, "themes", "global.json"), JSON.stringify({ source: "global" }))
await writeFile(path.join(project, ".opencode", "themes", "project.json"), JSON.stringify({ source: "project" }))
await expect(discoverThemes(configDirectories(global, project))).resolves.toEqual({
await expect(discoverThemes(themeDirectories(global, project))).resolves.toEqual({
global: { source: "global" },
project: { source: "project" },
})
-8
View File
@@ -1,8 +0,0 @@
import { expect, test } from "bun:test"
import { sessionTabsFitVertically, SESSION_SIDEBAR_WIDTH } from "../../src/ui/layout"
test("vertical tabs match the session sidebar and preserve compact content width", () => {
expect(SESSION_SIDEBAR_WIDTH).toBe(42)
expect(sessionTabsFitVertically(86)).toBe(true)
expect(sessionTabsFitVertically(85)).toBe(false)
})
+3 -2
View File
@@ -246,7 +246,8 @@ mutable fields:
| ------------------------------------------- | ------------------------------------------------------------------------------ |
| `ctx.aisdk.hook("sdk", callback)` | `sdk`, after inspecting `model`, `package`, and `options` |
| `ctx.aisdk.hook("language", callback)` | `language`, after inspecting `model`, `sdk`, and `options` |
| `ctx.session.hook("request", callback)` | `system`, `messages`, and the `tools` record immediately before model dispatch |
| `ctx.session.hook("context", callback)` | `system`, `messages`, and the `tools` record immediately before model dispatch |
| `ctx.session.hook("http", callback)` | `request`, wrapping the model's HTTP request and response |
| `ctx.tool.hook("execute.before", callback)` | `input`, before the selected tool executes |
| `ctx.tool.hook("execute.after", callback)` | Terminal `result` on success or `error` on failure |
@@ -259,7 +260,7 @@ import { Plugin } from "@opencode-ai/plugin"
export default Plugin.define({
id: "acme.guards",
setup: async (ctx) => {
await ctx.session.hook("request", (event) => {
await ctx.session.hook("context", (event) => {
delete event.tools.write
})