Compare commits

...

38 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
Aiden Cline 003b22edda fix: preserve provider error status (#39976) 2026-08-01 11:06:00 -05:00
Kit Langton d5f6c088f0 fix(tui): prevent sidebar scrollbar flash (#40056) 2026-08-01 15:12:37 +00:00
Kit Langton e872bd3c8f feat(tui): prioritize favorite model search results (#40049) 2026-08-01 14:54:46 +00:00
opencode-agent[bot] 85ea15e56d test(tui): wait for mini prompt readiness (#39980)
Co-authored-by: Kit Langton <kit.langton@gmail.com>
2026-08-01 01:05:18 +00:00
Kit Langton 6a543791b6 fix(tui): persist tab reorder once per drag (#39942) 2026-07-31 20:36:00 -04:00
Kit Langton 47c6840752 fix(tui): harden session tab state hygiene (#39941) 2026-07-31 20:35:07 -04:00
Kit Langton 14e4c3dfd7 fix(tui): ignore hidden tab close hitbox (#39940) 2026-07-31 20:34:13 -04:00
opencode-agent[bot] ee6577936d refactor(tui): remove unused duration formatter (#39964)
Co-authored-by: Kit Langton <kit.langton@gmail.com>
2026-07-31 20:32:46 -04:00
opencode-agent[bot] 8285a18f18 refactor(tui): remove unused revert diff parser (#39963)
Co-authored-by: Kit Langton <kit.langton@gmail.com>
2026-07-31 20:32:35 -04:00
opencode-agent[bot] cfcd8de62a refactor(cli): remove unused warning helper (#39962)
Co-authored-by: Kit Langton <kit.langton@gmail.com>
2026-07-31 20:32:21 -04:00
opencode-agent[bot] c2db18c59b refactor(tui): remove unwired file selection helper (#39961)
Co-authored-by: Kit Langton <kit.langton@gmail.com>
2026-07-31 20:31:50 -04:00
opencode-agent[bot] 70393a1d18 Remove unused error data serializer (#39960)
Co-authored-by: Kit Langton <kit.langton@gmail.com>
2026-07-31 20:31:22 -04:00
opencode-agent[bot] eab59ea021 Remove unused locale helpers (#39959)
Co-authored-by: Kit Langton <kit.langton@gmail.com>
2026-07-31 20:31:06 -04:00
opencode-agent[bot] 4569659b03 Remove unused Zed helpers (#39958)
Co-authored-by: Kit Langton <kit.langton@gmail.com>
2026-07-31 20:30:53 -04:00
opencode-agent[bot] b5afaed053 Remove unused config optional hook (#39957)
Co-authored-by: Kit Langton <kit.langton@gmail.com>
2026-07-31 20:30:29 -04:00
opencode-agent[bot] 97f5fb3399 Remove unused subagent retry formatter (#39952)
Co-authored-by: Kit Langton <kit.langton@gmail.com>
2026-07-31 20:30:20 -04:00
opencode-agent[bot] 18843682ad refactor(tui): remove ignored attention kv option (#39956)
Co-authored-by: Kit Langton <kit.langton@gmail.com>
2026-07-31 20:30:09 -04:00
opencode-agent[bot] 9eb9c1db7d refactor(tui): remove placeholder LSP panel (#39955)
Co-authored-by: Kit Langton <kit.langton@gmail.com>
2026-07-31 20:29:54 -04:00
opencode-agent[bot] 8846336e11 refactor(tui): remove unused fade file path (#39954)
Co-authored-by: Kit Langton <kit.langton@gmail.com>
2026-07-31 20:29:34 -04:00
opencode-agent[bot] f6b5f6b6b3 refactor(tui): remove prompt re-export barrels (#39953)
Co-authored-by: Kit Langton <kit.langton@gmail.com>
2026-07-31 20:29:27 -04:00
opencode-agent[bot] 02df1e9737 refactor(tui): reuse canonical logo (#39951)
Co-authored-by: Kit Langton <kit.langton@gmail.com>
2026-07-31 20:28:55 -04:00
opencode-agent[bot] 5b84e39f6d refactor(tui): remove stub child cycling commands (#39949)
Co-authored-by: Kit Langton <kit.langton@gmail.com>
2026-07-31 20:28:23 -04:00
opencode-agent[bot] 6d3f84b4ff refactor(tui): remove dialog show helpers (#39948)
Co-authored-by: Kit Langton <kit.langton@gmail.com>
2026-07-31 20:28:02 -04:00
opencode-agent[bot] 255e9bbf06 refactor(tui): remove onboarding hint residue (#39947)
Co-authored-by: Kit Langton <kit.langton@gmail.com>
2026-07-31 20:27:52 -04:00
opencode-agent[bot] 25b6a179e2 refactor(cli): remove no-op migrate command (#39946)
Co-authored-by: Kit Langton <kit.langton@gmail.com>
2026-07-31 20:27:29 -04:00
opencode-agent[bot] 80874c241f refactor(tui): remove dead assistant renderer (#39945)
Co-authored-by: Kit Langton <kit.langton@gmail.com>
2026-07-31 20:27:15 -04:00
opencode-agent[bot] f99469bdf8 refactor(core): remove unused layer exports (#39975)
Co-authored-by: Kit Langton <kit.langton@gmail.com>
2026-07-31 20:26:38 -04:00
opencode-agent[bot] fe0eb4ea8f refactor(core): remove orphaned move service (#39974)
Co-authored-by: Kit Langton <kit.langton@gmail.com>
2026-07-31 20:26:21 -04:00
opencode-agent[bot] a484f87680 refactor(core): remove unused dependencies (#39973)
Co-authored-by: Kit Langton <kit.langton@gmail.com>
2026-07-31 20:26:06 -04:00
opencode-agent[bot] 3b6ab392ac refactor(core): remove unused console state model (#39972)
Co-authored-by: Kit Langton <kit.langton@gmail.com>
2026-07-31 20:25:45 -04:00
opencode-agent[bot] 6c567dc745 refactor(core): remove unreferenced layer map example (#39971)
Co-authored-by: Kit Langton <kit.langton@gmail.com>
2026-07-31 20:25:27 -04:00
opencode-agent[bot] 92567700f6 refactor(tui): remove unused stop voice operation (#39969)
Co-authored-by: Kit Langton <kit.langton@gmail.com>
2026-07-31 20:25:17 -04:00
James Long 90c84639e3 feat(theme): export expandTheme (#39967) 2026-07-31 18:32:55 -04:00
81 changed files with 642 additions and 1383 deletions
-4
View File
@@ -88,7 +88,6 @@
"@tanstack/solid-query": "5.91.4", "@tanstack/solid-query": "5.91.4",
"@tanstack/solid-virtual": "catalog:", "@tanstack/solid-virtual": "catalog:",
"@thisbeyond/solid-dnd": "0.7.5", "@thisbeyond/solid-dnd": "0.7.5",
"diff": "catalog:",
"effect": "catalog:", "effect": "catalog:",
"fuzzysort": "catalog:", "fuzzysort": "catalog:",
"ghostty-web": "github:anomalyco/ghostty-web#83c0a07b8628b748aed073b232cb4b52a6ca11c1", "ghostty-web": "github:anomalyco/ghostty-web#83c0a07b8628b748aed073b232cb4b52a6ca11c1",
@@ -384,7 +383,6 @@
"@opencode-ai/ai": "workspace:*", "@opencode-ai/ai": "workspace:*",
"@opencode-ai/codemode": "workspace:*", "@opencode-ai/codemode": "workspace:*",
"@opencode-ai/effect-drizzle-sqlite": "workspace:*", "@opencode-ai/effect-drizzle-sqlite": "workspace:*",
"@opencode-ai/effect-sqlite-node": "workspace:*",
"@opencode-ai/plugin": "workspace:*", "@opencode-ai/plugin": "workspace:*",
"@opencode-ai/schema": "workspace:*", "@opencode-ai/schema": "workspace:*",
"@opencode-ai/util": "workspace:*", "@opencode-ai/util": "workspace:*",
@@ -405,7 +403,6 @@
"ignore": "7.0.5", "ignore": "7.0.5",
"immer": "11.1.4", "immer": "11.1.4",
"jsonc-parser": "3.3.1", "jsonc-parser": "3.3.1",
"semver": "^7.6.3",
"tree-sitter-bash": "0.25.0", "tree-sitter-bash": "0.25.0",
"tree-sitter-powershell": "0.25.10", "tree-sitter-powershell": "0.25.10",
"turndown": "7.2.0", "turndown": "7.2.0",
@@ -427,7 +424,6 @@
"@tsconfig/bun": "catalog:", "@tsconfig/bun": "catalog:",
"@types/bun": "catalog:", "@types/bun": "catalog:",
"@types/node": "catalog:", "@types/node": "catalog:",
"@types/semver": "catalog:",
"@types/turndown": "5.0.5", "@types/turndown": "5.0.5",
"@types/which": "3.0.4", "@types/which": "3.0.4",
"drizzle-kit": "catalog:", "drizzle-kit": "catalog:",
+8 -9
View File
@@ -16,7 +16,6 @@ import {
const patterns = [ const patterns = [
/prompt is too long/i, /prompt is too long/i,
/request_too_large/i,
/input is too long for requested model/i, /input is too long for requested model/i,
/exceeds the context window/i, /exceeds the context window/i,
/exceeds (?:the )?(?:model'?s )?maximum context length(?: of [\d,]+ tokens?|\s*\([\d,]+\))/i, /exceeds (?:the )?(?:model'?s )?maximum context length(?: of [\d,]+ tokens?|\s*\([\d,]+\))/i,
@@ -33,7 +32,6 @@ const patterns = [
/context window exceeds limit/i, /context window exceeds limit/i,
/exceeded model token limit/i, /exceeded model token limit/i,
/context[_ ]length[_ ]exceeded/i, /context[_ ]length[_ ]exceeded/i,
/request entity too large/i,
/context length is only \d+ tokens/i, /context length is only \d+ tokens/i,
/input length.*exceeds.*context length/i, /input length.*exceeds.*context length/i,
/prompt too long; exceeded (?:max )?context length/i, /prompt too long; exceeded (?:max )?context length/i,
@@ -44,11 +42,15 @@ const patterns = [
/token limit exceeded/i, /token limit exceeded/i,
] ]
const payloadPatterns = [/request_too_large/i, /request entity too large/i, /payload too large/i, /request too large/i]
const exclusions = [/^(throttling error|service unavailable):/i, /rate limit/i, /too many requests/i] const exclusions = [/^(throttling error|service unavailable):/i, /rate limit/i, /too many requests/i]
export const isContextOverflow = (message: string) => export const isContextOverflow = (message: string) =>
!exclusions.some((pattern) => pattern.test(message)) && !exclusions.some((pattern) => pattern.test(message)) &&
(patterns.some((pattern) => pattern.test(message)) || /^4(00|13)\s*(status code)?\s*\(no body\)/i.test(message)) (patterns.some((pattern) => pattern.test(message)) || /^400\s*(status code)?\s*\(no body\)/i.test(message))
export const isPayloadTooLarge = (message: string) => payloadPatterns.some((pattern) => pattern.test(message))
export const isContextOverflowFailure = (failure: unknown) => export const isContextOverflowFailure = (failure: unknown) =>
failure instanceof LLMError failure instanceof LLMError
@@ -100,6 +102,8 @@ export function classifyProviderFailure(input: ProviderFailure): LLMError["reaso
isContextOverflow(text)) isContextOverflow(text))
) )
return new InvalidRequestReason({ ...common, classification: "context-overflow" }) return new InvalidRequestReason({ ...common, classification: "context-overflow" })
if (input.status === 413 || isPayloadTooLarge(text))
return new InvalidRequestReason({ ...common, classification: "payload-too-large" })
if (CONTENT_POLICY_TEXT.test(text)) return new ContentPolicyReason(common) if (CONTENT_POLICY_TEXT.test(text)) return new ContentPolicyReason(common)
if (codes.some((code) => QUOTA_CODES.has(code)) || (input.status === 429 && QUOTA_TEXT.test(text))) if (codes.some((code) => QUOTA_CODES.has(code)) || (input.status === 429 && QUOTA_TEXT.test(text)))
return new QuotaExceededReason(common) return new QuotaExceededReason(common)
@@ -142,12 +146,7 @@ export function classifyProviderFailure(input: ProviderFailure): LLMError["reaso
retryAfterMs: input.retryAfterMs, retryAfterMs: input.retryAfterMs,
}) })
if (codes.some((code) => INVALID_REQUEST_CODES.has(code))) return new InvalidRequestReason(common) if (codes.some((code) => INVALID_REQUEST_CODES.has(code))) return new InvalidRequestReason(common)
if ( if (input.status === 400 || input.status === 404 || input.status === 413 || input.status === 422)
input.status === 400 ||
input.status === 404 ||
input.status === 413 ||
input.status === 422
)
return new InvalidRequestReason(common) return new InvalidRequestReason(common)
return new UnknownProviderReason({ ...common, status: input.status }) return new UnknownProviderReason({ ...common, status: input.status })
} }
+7 -4
View File
@@ -5,7 +5,7 @@ import { Endpoint, type EndpointPatch } from "./endpoint"
import { RequestExecutor } from "./executor" import { RequestExecutor } from "./executor"
import { Framing } from "./framing" import { Framing } from "./framing"
import { HttpTransport } from "./transport" import { HttpTransport } from "./transport"
import type { HttpRequestTransform, Transport, TransportRuntime } from "./transport" import type { HttpMiddleware, Transport, TransportRuntime } from "./transport"
import { WebSocketExecutor } from "./transport" import { WebSocketExecutor } from "./transport"
import type { Protocol } from "./protocol" import type { Protocol } from "./protocol"
import { applyCachePolicy } from "../cache-policy" import { applyCachePolicy } from "../cache-policy"
@@ -96,7 +96,10 @@ export interface RoutePatch<Body, Prepared> extends RouteDefaultsInput {
type RouteMappedModelInput = RouteModelInput | RouteRoutedModelInput 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) const provider = route.provider ?? ("provider" in mapped ? mapped.provider : undefined)
if (!provider) throw new Error(`Route.model(${route.id}) requires a provider`) if (!provider) throw new Error(`Route.model(${route.id}) requires a provider`)
if (!endpointBaseURL(route.endpoint)) if (!endpointBaseURL(route.endpoint))
@@ -150,7 +153,7 @@ export interface Interface {
} }
export interface StreamOptions { export interface StreamOptions {
readonly transform?: HttpRequestTransform readonly http?: HttpMiddleware
} }
export interface StreamMethod { export interface StreamMethod {
@@ -302,7 +305,7 @@ function makeFromTransport<Body, Prepared, Frame, Event, State>(
auth: routeInput.auth ?? Auth.none, auth: routeInput.auth ?? Auth.none,
encodeBody, encodeBody,
headers: routeInput.headers, headers: routeInput.headers,
transform: options?.transform, middleware: options?.http,
}), }),
streamPrepared: (prepared: Prepared, request: LLMRequest, runtime: TransportRuntime) => { streamPrepared: (prepared: Prepared, request: LLMRequest, runtime: TransportRuntime) => {
const route = `${request.model.provider}/${request.model.route.id}` 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 { import {
FetchHttpClient, FetchHttpClient,
Headers, Headers,
@@ -20,9 +20,13 @@ import { classifyProviderFailure } from "../provider-error"
export interface Interface { export interface Interface {
readonly execute: ( readonly execute: (
request: HttpClientRequest.HttpClientRequest, request: HttpClientRequest.HttpClientRequest,
middleware?: HttpMiddleware,
) => Effect.Effect<HttpClientResponse.HttpClientResponse, LLMError> ) => 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") {} export class Service extends Context.Service<Service, Interface>()("@opencode/LLM/RequestExecutor") {}
const BODY_LIMIT = 16_384 const BODY_LIMIT = 16_384
@@ -282,12 +286,41 @@ export const layer: Layer.Layer<Service, never, HttpClient.HttpClient> = Layer.e
Service, Service,
Effect.gen(function* () { Effect.gen(function* () {
const http = yield* HttpClient.HttpClient const http = yield* HttpClient.HttpClient
const executeOnce = (request: HttpClientRequest.HttpClientRequest) => const executeOnce = (request: HttpClientRequest.HttpClientRequest, middleware?: HttpMiddleware) =>
Effect.gen(function* () { Effect.gen(function* () {
const redactedNames = yield* Headers.CurrentRedactedNames const redactedNames = yield* Headers.CurrentRedactedNames
return yield* http if (!middleware)
.execute(request) return yield* http
.pipe(Effect.mapError(toHttpError(redactedNames)), Effect.flatMap(statusError(request, redactedNames))) .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({ return Service.of({
execute: executeOnce, 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 EndpointFn, EndpointInput } from "./endpoint"
export type { Definition as FramingDef } from "./framing" export type { Definition as FramingDef } from "./framing"
export type { Protocol as ProtocolDef } from "./protocol" 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 { Auth } from "../auth"
import { render as renderEndpoint } from "../endpoint" import { render as renderEndpoint } from "../endpoint"
import { Framing } from "../framing" import { Framing } from "../framing"
import type { Transport, TransportPrepareInput } from "./index" import type { HttpMiddleware, Transport, TransportPrepareInput } from "./index"
import * as ProviderShared from "../../protocols/shared" import * as ProviderShared from "../../protocols/shared"
import { mergeJsonRecords, type LLMRequest } from "../../schema" import { mergeJsonRecords, type LLMRequest } from "../../schema"
@@ -19,6 +19,7 @@ export interface JsonRequestParts<Body = unknown> {
export interface HttpPrepared<Frame> { export interface HttpPrepared<Frame> {
readonly request: HttpClientRequest.HttpClientRequest readonly request: HttpClientRequest.HttpClientRequest
readonly framing: Framing.Definition<Frame> readonly framing: Framing.Definition<Frame>
readonly middleware?: HttpMiddleware
} }
const applyQuery = (url: string, query: Record<string, string> | undefined) => { 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) => prepare: (prepareInput) =>
Effect.gen(function* () { Effect.gen(function* () {
const parts = yield* jsonRequestParts({ ...prepareInput }) const parts = yield* jsonRequestParts({ ...prepareInput })
const request = { url: parts.url, method: "POST", headers: { ...parts.headers }, body: parts.bodyText } const request = ProviderShared.jsonPost({
yield* (prepareInput.transform?.(request) ?? Effect.void) url: parts.url,
body: parts.bodyText,
headers: parts.headers,
})
return { return {
request: ProviderShared.jsonPost({ request,
url: request.url,
body: request.body ?? "",
headers: Headers.fromInput(request.headers),
}),
framing: input.framing, framing: input.framing,
middleware: prepareInput.middleware,
} }
}), }),
frames: (prepared, request, runtime) => frames: (prepared, request, runtime) =>
Stream.unwrap( Stream.unwrap(
runtime.http runtime.http
.execute(prepared.request) .execute(prepared.request, prepared.middleware)
.pipe( .pipe(
Effect.map((response) => Effect.map((response) =>
prepared.framing.frame( prepared.framing.frame(
+3 -11
View File
@@ -1,7 +1,7 @@
import type { Effect, Stream } from "effect" import type { Effect, Stream } from "effect"
import { Endpoint } from "../endpoint" import { Endpoint } from "../endpoint"
import { Auth } from "../auth" 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 { Interface as WebSocketExecutorInterface } from "./websocket"
import type { LLMError, LLMRequest } from "../../schema" import type { LLMError, LLMRequest } from "../../schema"
@@ -10,15 +10,6 @@ export interface TransportRuntime {
readonly webSocket?: WebSocketExecutorInterface 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> { export interface Transport<Body, Prepared, Frame> {
readonly id: string readonly id: string
readonly prepare: (input: TransportPrepareInput<Body>) => Effect.Effect<Prepared, LLMError> readonly prepare: (input: TransportPrepareInput<Body>) => Effect.Effect<Prepared, LLMError>
@@ -36,8 +27,9 @@ export interface TransportPrepareInput<Body> {
readonly auth: Auth.Definition readonly auth: Auth.Definition
readonly encodeBody: (body: Body) => string readonly encodeBody: (body: Body) => string
readonly headers?: (input: { readonly request: LLMRequest }) => Record<string, string> readonly headers?: (input: { readonly request: LLMRequest }) => Record<string, string>
readonly transform?: HttpRequestTransform readonly middleware?: HttpMiddleware
} }
export * as HttpTransport from "./http" export * as HttpTransport from "./http"
export type { HttpHandler, HttpMiddleware } from "../executor"
export { WebSocketExecutor, WebSocketTransport } from "./websocket" export { WebSocketExecutor, WebSocketTransport } from "./websocket"
+1 -1
View File
@@ -2,7 +2,7 @@ import { Schema } from "effect"
import { Tool } from "@opencode-ai/schema/tool" import { Tool } from "@opencode-ai/schema/tool"
import { ModelID, ProviderID, ProviderMetadata, RouteID } from "./ids" import { ModelID, ProviderID, ProviderMetadata, RouteID } from "./ids"
export const ProviderFailureClassification = Schema.Literal("context-overflow") export const ProviderFailureClassification = Schema.Literals(["context-overflow", "payload-too-large"])
export type ProviderFailureClassification = typeof ProviderFailureClassification.Type export type ProviderFailureClassification = typeof ProviderFailureClassification.Type
export class HttpRequestDetails extends Schema.Class<HttpRequestDetails>("LLM.HttpRequestDetails")({ export class HttpRequestDetails extends Schema.Class<HttpRequestDetails>("LLM.HttpRequestDetails")({
+93 -7
View File
@@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test" 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 { HttpClientRequest } from "effect/unstable/http"
import { LLM, mergeProviderOptions } from "../src" import { LLM, mergeProviderOptions } from "../src"
import { AnthropicMessages, OpenAIChat } from "../src/protocols" import { AnthropicMessages, OpenAIChat } from "../src/protocols"
@@ -146,12 +146,19 @@ describe("request option precedence", () => {
prompt: "Say hello.", prompt: "Say hello.",
}), }),
{ {
transform: (request) => http: (request, handler) =>
Effect.sync(() => { Effect.gen(function* () {
expect(request.headers.authorization).toBe("Bearer fresh-key") expect(request.headers.get("authorization")).toBe("Bearer fresh-key")
request.url = "https://proxy.test/v1/chat/completions" const headers = new Headers(request.headers)
request.headers["x-plugin"] = "transformed" headers.set("x-plugin", "transformed")
request.body = JSON.stringify({ transformed: true }) 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( ).pipe(
@@ -160,7 +167,9 @@ describe("request option precedence", () => {
Effect.gen(function* () { Effect.gen(function* () {
const web = yield* HttpClientRequest.toWeb(input.request).pipe(Effect.orDie) const web = yield* HttpClientRequest.toWeb(input.request).pipe(Effect.orDie)
expect(web.url).toBe("https://proxy.test/v1/chat/completions") 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("x-plugin")).toBe("transformed")
expect(web.headers.get("content-type")).toBe("application/custom+json")
expect(decodeJson(input.text)).toEqual({ transformed: true }) expect(decodeJson(input.text)).toEqual({ transformed: true })
return input.respond(sseEvents(deltaChunk({}, "stop")), { return input.respond(sseEvents(deltaChunk({}, "stop")), {
headers: { "content-type": "text/event-stream" }, 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", () => it.effect("applies raw body overlays after protocol lowering", () =>
LLMClient.generate( LLMClient.generate(
LLM.request({ LLM.request({
+6 -3
View File
@@ -85,14 +85,17 @@ describe("RequestExecutor", () => {
), ),
) )
it.effect("does not classify generic HTTP 413 payload errors as context overflow", () => it.effect("classifies generic HTTP 413 payload errors", () =>
Effect.gen(function* () { Effect.gen(function* () {
const executor = yield* RequestExecutor.Service const executor = yield* RequestExecutor.Service
const error = yield* executor.execute(request).pipe(Effect.flip) const error = yield* executor.execute(request).pipe(Effect.flip)
expectLLMError(error) expectLLMError(error)
expect(error.reason).toMatchObject({ _tag: "InvalidRequest" }) expect(error.reason).toMatchObject({
expect("classification" in error.reason ? error.reason.classification : undefined).toBeUndefined() _tag: "InvalidRequest",
classification: "payload-too-large",
http: { response: { status: 413 } },
})
}).pipe(Effect.provide(responsesLayer([new Response("request too large", { status: 413 })]))), }).pipe(Effect.provide(responsesLayer([new Response("request too large", { status: 413 })]))),
) )
+22 -4
View File
@@ -6,7 +6,6 @@ describe("provider error classification", () => {
test("classifies provider token limit messages as context overflow", () => { test("classifies provider token limit messages as context overflow", () => {
const messages = [ const messages = [
"tokens in request more than max tokens allowed", "tokens in request more than max tokens allowed",
'{"error":{"type":"request_too_large","message":"Request exceeds the maximum size"}}',
"Requested token count exceeds the model's maximum context length of 131072 tokens.", "Requested token count exceeds the model's maximum context length of 131072 tokens.",
"Input length (265330) exceeds model's maximum context length (262144).", "Input length (265330) exceeds model's maximum context length (262144).",
"Input length 131393 exceeds the maximum allowed input length of 131040 tokens.", "Input length 131393 exceeds the maximum allowed input length of 131040 tokens.",
@@ -19,6 +18,24 @@ describe("provider error classification", () => {
expect(messages.every(isContextOverflow)).toBe(true) expect(messages.every(isContextOverflow)).toBe(true)
}) })
test("classifies request size failures separately from context overflow", () => {
const failures = [
classifyProviderFailure({ message: "request too large", status: 413 }),
classifyProviderFailure({
message: '{"error":{"type":"request_too_large","message":"Request exceeds the maximum size"}}',
status: 400,
}),
classifyProviderFailure({ message: "upstream request entity too large", status: 502 }),
]
expect(failures).toEqual(
failures.map((failure) =>
expect.objectContaining({ _tag: "InvalidRequest", classification: "payload-too-large" }),
),
)
expect(isContextOverflow("413 status code (no body)")).toBe(false)
})
test("does not classify rate limits as context overflow", () => { test("does not classify rate limits as context overflow", () => {
const messages = [ const messages = [
"Throttling error: Too many tokens, please wait before trying again.", "Throttling error: Too many tokens, please wait before trying again.",
@@ -59,9 +76,10 @@ describe("provider error classification", () => {
}) })
test("classifies transient client statuses as provider internal", () => { test("classifies transient client statuses as provider internal", () => {
expect( expect([408, 409].map((status) => classifyProviderFailure({ message: `HTTP ${status}`, status })._tag)).toEqual([
[408, 409].map((status) => classifyProviderFailure({ message: `HTTP ${status}`, status })._tag), "ProviderInternal",
).toEqual(["ProviderInternal", "ProviderInternal"]) "ProviderInternal",
])
}) })
test("classifies nested provider codes when a top-level code is also present", () => { test("classifies nested provider codes when a top-level code is also present", () => {
-1
View File
@@ -133,7 +133,6 @@ export const Commands = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCO
description: "Manage plugins", description: "Manage plugins",
commands: [Spec.make("list", { description: "List active plugins" })], commands: [Spec.make("list", { description: "List active plugins" })],
}), }),
Spec.make("migrate", { description: "Migrate v1 data to v2" }),
Spec.make("mini", { Spec.make("mini", {
description: "Start the minimal interactive interface", description: "Start the minimal interactive interface",
params: { params: {
@@ -1,5 +0,0 @@
import { Effect } from "effect"
import { Commands } from "../commands"
import { Runtime } from "../../framework/runtime"
export default Runtime.handler(Commands.commands.migrate, (_input) => Effect.log("No migrations to run."))
-7
View File
@@ -116,13 +116,6 @@ export function migrateV1(legacy: TuiConfigV1.Info | undefined, kv: Record<strin
: { grouping: kv.exploration_grouping ? ("auto" as const) : ("none" as const) }), : { grouping: kv.exploration_grouping ? ("auto" as const) : ("none" as const) }),
}, },
}), }),
...(kv.dismissed_getting_started === undefined
? {}
: {
hints: {
onboarding: !kv.dismissed_getting_started,
},
}),
...(kv.animations_enabled === undefined ? {} : { animations: kv.animations_enabled }), ...(kv.animations_enabled === undefined ? {} : { animations: kv.animations_enabled }),
...(legacy?.mouse === undefined ? {} : { mouse: legacy.mouse }), ...(legacy?.mouse === undefined ? {} : { mouse: legacy.mouse }),
} }
-1
View File
@@ -35,7 +35,6 @@ const Handlers = Runtime.handlers(Commands, {
plugin: { plugin: {
list: () => import("./commands/handlers/plugin/list"), list: () => import("./commands/handlers/plugin/list"),
}, },
migrate: () => import("./commands/handlers/migrate"),
mini: () => import("./commands/handlers/mini"), mini: () => import("./commands/handlers/mini"),
run: () => import("./commands/handlers/run"), run: () => import("./commands/handlers/run"),
pair: () => import("./commands/handlers/pair"), pair: () => import("./commands/handlers/pair"),
-4
View File
@@ -235,10 +235,6 @@ async function renderToolError(part: SessionMessageAssistantTool, directory: str
UI.println(UI.Style.TEXT_NORMAL + "✗", UI.Style.TEXT_NORMAL + `${info.title} failed`) UI.println(UI.Style.TEXT_NORMAL + "✗", UI.Style.TEXT_NORMAL + `${info.title} failed`)
} }
function warning(message: string) {
UI.println(UI.Style.TEXT_WARNING_BOLD + "!", UI.Style.TEXT_NORMAL, message)
}
function errorMessage(error: unknown) { function errorMessage(error: unknown) {
if (error instanceof Error) return error.message if (error instanceof Error) return error.message
if (typeof error === "object" && error !== null && "message" in error && typeof error.message === "string") if (typeof error === "object" && error !== null && "message" in error && typeof error.message === "string")
+1 -1
View File
@@ -74,12 +74,12 @@ test("migrates tui and kv config into cli.json", async () => {
terminal: { title: false }, terminal: { title: false },
prompt: { editor: false, paste: "full" }, prompt: { editor: false, paste: "full" },
session: { sidebar: "hide", scrollbar: true, thinking: "show", grouping: "none" }, session: { sidebar: "hide", scrollbar: true, thinking: "show", grouping: "none" },
hints: { onboarding: false },
animations: false, animations: false,
mouse: false, mouse: false,
}) })
expect(config).not.toHaveProperty("skipped_version") expect(config).not.toHaveProperty("skipped_version")
expect(config).not.toHaveProperty("which_key") expect(config).not.toHaveProperty("which_key")
expect(config).not.toHaveProperty("hints")
expect((await Bun.file(path.join(directory, "cli.json")).json()).keybinds).toEqual({ leader: "ctrl+o" }) expect((await Bun.file(path.join(directory, "cli.json")).json()).keybinds).toEqual({ leader: "ctrl+o" })
expect(await Bun.file(path.join(directory, "cli.json")).exists()).toBe(true) expect(await Bun.file(path.join(directory, "cli.json")).exists()).toBe(true)
expect(await Bun.file(path.join(directory, "tui.json")).exists()).toBe(true) expect(await Bun.file(path.join(directory, "tui.json")).exists()).toBe(true)
+5 -5
View File
@@ -398,7 +398,7 @@ export type Endpoint5_26Output =
readonly location?: Location.Ref | undefined readonly location?: Location.Ref | undefined
readonly data: { readonly data: {
readonly sessionID: Session.ID readonly sessionID: Session.ID
readonly error: { readonly type: string; readonly message: string } readonly error: { readonly type: string; readonly message: string; readonly status?: number | undefined }
} }
} }
| { | {
@@ -524,7 +524,7 @@ export type Endpoint5_26Output =
readonly data: { readonly data: {
readonly sessionID: Session.ID readonly sessionID: Session.ID
readonly assistantMessageID: SessionMessage.ID readonly assistantMessageID: SessionMessage.ID
readonly error: { readonly type: string; readonly message: string } readonly error: { readonly type: string; readonly message: string; readonly status?: number | undefined }
readonly cost?: (number & Brand.Brand<"Money.USD">) | undefined readonly cost?: (number & Brand.Brand<"Money.USD">) | undefined
readonly tokens?: readonly tokens?:
| { | {
@@ -686,7 +686,7 @@ export type Endpoint5_26Output =
readonly sessionID: Session.ID readonly sessionID: Session.ID
readonly assistantMessageID: SessionMessage.ID readonly assistantMessageID: SessionMessage.ID
readonly callID: string readonly callID: string
readonly error: { readonly type: string; readonly message: string } readonly error: { readonly type: string; readonly message: string; readonly status?: number | undefined }
readonly content?: readonly content?:
| readonly [ | readonly [
( (
@@ -726,7 +726,7 @@ export type Endpoint5_26Output =
readonly assistantMessageID: SessionMessage.ID readonly assistantMessageID: SessionMessage.ID
readonly attempt: number readonly attempt: number
readonly at: number readonly at: number
readonly error: { readonly type: string; readonly message: string } readonly error: { readonly type: string; readonly message: string; readonly status?: number | undefined }
} }
} }
| { | {
@@ -776,7 +776,7 @@ export type Endpoint5_26Output =
readonly data: { readonly data: {
readonly sessionID: Session.ID readonly sessionID: Session.ID
readonly reason: "auto" | "manual" readonly reason: "auto" | "manual"
readonly error: { readonly type: string; readonly message: string } readonly error: { readonly type: string; readonly message: string; readonly status?: number | undefined }
readonly inputID?: SessionMessage.ID | undefined readonly inputID?: SessionMessage.ID | undefined
} }
} }
@@ -108,7 +108,7 @@ export type ToolTextContent = { type: "text"; text: string }
export type ToolFileContent = { type: "file"; uri: string; mime: string; name?: string | null } export type ToolFileContent = { type: "file"; uri: string; mime: string; name?: string | null }
export type SessionStructuredError = { type: string; message: string } export type SessionStructuredError = { type: string; message: string; status?: number }
export type SessionMessageCompactionRunning = { export type SessionMessageCompactionRunning = {
type: "compaction" type: "compaction"
-3
View File
@@ -56,7 +56,6 @@
"@tsconfig/bun": "catalog:", "@tsconfig/bun": "catalog:",
"@types/bun": "catalog:", "@types/bun": "catalog:",
"@types/node": "catalog:", "@types/node": "catalog:",
"@types/semver": "catalog:",
"@types/turndown": "5.0.5", "@types/turndown": "5.0.5",
"@types/which": "3.0.4", "@types/which": "3.0.4",
"@parcel/watcher-darwin-arm64": "2.5.1", "@parcel/watcher-darwin-arm64": "2.5.1",
@@ -100,7 +99,6 @@
"@ff-labs/fff-node": "0.10.1", "@ff-labs/fff-node": "0.10.1",
"@opencode-ai/codemode": "workspace:*", "@opencode-ai/codemode": "workspace:*",
"@opencode-ai/effect-drizzle-sqlite": "workspace:*", "@opencode-ai/effect-drizzle-sqlite": "workspace:*",
"@opencode-ai/effect-sqlite-node": "workspace:*",
"@opencode-ai/ai": "workspace:*", "@opencode-ai/ai": "workspace:*",
"@opencode-ai/schema": "workspace:*", "@opencode-ai/schema": "workspace:*",
"@opencode-ai/plugin": "workspace:*", "@opencode-ai/plugin": "workspace:*",
@@ -122,7 +120,6 @@
"immer": "11.1.4", "immer": "11.1.4",
"ignore": "7.0.5", "ignore": "7.0.5",
"jsonc-parser": "3.3.1", "jsonc-parser": "3.3.1",
"semver": "^7.6.3",
"turndown": "7.2.0", "turndown": "7.2.0",
"tree-sitter-bash": "0.25.0", "tree-sitter-bash": "0.25.0",
"tree-sitter-powershell": "0.25.10", "tree-sitter-powershell": "0.25.10",
@@ -1,181 +0,0 @@
export * as MoveSession from "./move-session"
import { Context, DateTime, Effect, Layer, Schema } from "effect"
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { Git } from "../git"
import { Global } from "@opencode-ai/util/global"
import { Project } from "../project"
import { Session } from "../session"
import { SessionExecution } from "../session/execution"
import { SessionSchema } from "../session/schema"
import { SessionStore } from "../session/store"
import { AbsolutePath } from "../schema"
import path from "path"
export const Destination = Schema.Struct({
directory: AbsolutePath,
}).annotate({ identifier: "MoveSession.Destination" })
export type Destination = typeof Destination.Type
export const Input = Schema.Struct({
sessionID: SessionSchema.ID,
destination: Destination,
moveChanges: Schema.optional(Schema.Boolean),
}).annotate({ identifier: "MoveSession.Input" })
export type Input = typeof Input.Type
export class DestinationProjectMismatchError extends Schema.TaggedErrorClass<DestinationProjectMismatchError>()(
"MoveSession.DestinationProjectMismatchError",
{
expected: Project.ID,
actual: Project.ID,
},
) {}
export class DestinationNotFoundError extends Schema.TaggedErrorClass<DestinationNotFoundError>()(
"MoveSession.DestinationNotFoundError",
{ directory: AbsolutePath },
) {}
export class DestinationNotDirectoryError extends Schema.TaggedErrorClass<DestinationNotDirectoryError>()(
"MoveSession.DestinationNotDirectoryError",
{ directory: AbsolutePath },
) {}
export class ApplyChangesError extends Schema.TaggedErrorClass<ApplyChangesError>()("MoveSession.ApplyChangesError", {
message: Schema.String,
}) {}
export class CaptureChangesError extends Schema.TaggedErrorClass<CaptureChangesError>()(
"MoveSession.CaptureChangesError",
{
message: Schema.String,
},
) {}
export class ResetSourceChangesError extends Schema.TaggedErrorClass<ResetSourceChangesError>()(
"MoveSession.ResetSourceChangesError",
{
directory: AbsolutePath,
message: Schema.String,
cause: Schema.optional(Schema.Defect()),
},
) {}
export type Error =
| Session.NotFoundError
| DestinationProjectMismatchError
| DestinationNotFoundError
| DestinationNotDirectoryError
| Session.DestinationNotFoundError
| Session.DestinationNotDirectoryError
| CaptureChangesError
| ApplyChangesError
| ResetSourceChangesError
export interface Interface {
readonly moveSession: (input: Input) => Effect.Effect<void, Error>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/ControlPlaneMoveSession") {}
const layer = Layer.effect(
Service,
Effect.gen(function* () {
const git = yield* Git.Service
const fs = yield* FSUtil.Service
const global = yield* Global.Service
const project = yield* Project.Service
const sessions = yield* SessionStore.Service
const session = yield* Session.Service
const execution = yield* SessionExecution.Service
const moveSession = Effect.fn("MoveSession.moveSession")(function* (input: Input) {
const current = yield* sessions.get(input.sessionID)
if (!current) return yield* new Session.NotFoundError({ sessionID: input.sessionID })
const value = input.destination.directory.trim()
const expanded = value === "~" ? global.home : value.startsWith("~/") ? path.join(global.home, value.slice(2)) : value
const directory = AbsolutePath.make(path.resolve(current.location.directory, expanded))
const destinationInfo = yield* fs.stat(directory).pipe(Effect.catch(() => Effect.succeed(undefined)))
if (!destinationInfo) return yield* new DestinationNotFoundError({ directory })
if (destinationInfo.type !== "Directory") return yield* new DestinationNotDirectoryError({ directory })
if (current.location.directory === directory) return
const source = yield* project.resolve(current.location.directory)
const destination = yield* project.resolve(directory)
if (input.moveChanges && current.projectID !== destination.id) {
return yield* new DestinationProjectMismatchError({ expected: current.projectID, actual: destination.id })
}
// A move must not race active execution: a mid-drain relocation would let
// the source Location dispatch a request assembled under stale instructions
// and history. Serialize like removal does — stop the drain, then move.
yield* execution.interrupt(input.sessionID)
yield* execution.awaitIdle(input.sessionID)
const moveChanges = input.moveChanges && source.directory !== destination.directory
const sourceRepository = moveChanges ? yield* git.repo.discover(current.location.directory) : undefined
if (moveChanges && !sourceRepository)
return yield* new CaptureChangesError({ message: "Source is not a Git repository" })
const patch = sourceRepository
? yield* git.change
.capture({ repository: sourceRepository, path: current.location.directory })
.pipe(Effect.mapError((error) => new CaptureChangesError({ message: error.message })))
: Git.ChangeSet.make("")
if (patch) {
const repository = yield* git.repo.discover(directory)
if (!repository) return yield* new ApplyChangesError({ message: "Destination is not a Git repository" })
yield* git.change
.apply({ repository, path: directory, changes: patch })
.pipe(Effect.mapError((error) => new ApplyChangesError({ message: error.message })))
}
yield* session.move({
sessionID: input.sessionID,
directory,
})
if (patch) {
const repository = yield* git.repo.discover(current.location.directory)
if (!repository)
return yield* new ResetSourceChangesError({
directory: current.location.directory,
message: "Source is not a Git repository",
})
yield* git.change
.discard({
repository,
path: current.location.directory,
index: "preserve",
untracked: "remove",
})
.pipe(
Effect.mapError(
(error) =>
new ResetSourceChangesError({
directory: current.location.directory,
message: error.message,
cause: error.cause,
}),
),
)
}
})
return Service.of({ moveSession })
}),
)
export const node = makeGlobalNode({
service: Service,
layer,
deps: [
FSUtil.node,
Git.node,
Global.node,
Project.node,
Session.node,
SessionStore.node,
SessionExecution.node,
],
})
-3
View File
@@ -148,6 +148,3 @@ export function buildLocationServiceMap(
), ),
) )
} }
// This is temporary for backwards compatibility
export const locationServiceMapLayer = buildLocationServiceMap()
@@ -1,94 +0,0 @@
export * as LayerMapExample from "./layer-map.example"
import { Context, Effect, Layer, LayerMap } from "effect"
import { Npm } from "@opencode-ai/util/npm"
/**
* Tutorial: split global services from context-specific services.
*
* Use this pattern when part of the app should be constructed once at the app edge,
* while another part should be cached per request/project/workspace key.
*
* In this example:
* - Npm.Service is the global service. It is not keyed by request context and should
* be provided once by the application runtime.
* - ConfigService is context-specific. It is built from a RequestContext key and is
* cached by LayerMap for that key.
* - ConfigServiceMap.layer owns the cache. Provide it once globally, then each
* request can provide ConfigServiceMap.get(context) to select the right instance.
*
* Lifetime model:
* - ConfigServiceMap.layer has the app/global lifetime and depends on Npm.Service.
* - ConfigServiceMap.get(context) has the request/context lifetime and provides
* ConfigService for exactly that context key.
* - The cached ConfigService entry stays alive while something is using it. Once idle,
* it remains cached for idleTimeToLive, then its scope is finalized.
* - invalidate(context) removes the cache entry for future lookups. Active users keep
* running on the old instance; the next lookup can create a fresh instance.
*
* Key model:
* - Keys can be strings, structs, classes, arrays, etc.
* - Prefer primitive or immutable keys. Effect uses Hash / Equal semantics for cache
* lookup, so mutating an object after it has been used as a key is a bug.
*/
export type RequestContext = {
readonly directory: string
readonly workspace: string
}
export class RequestContextRef extends Context.Service<RequestContextRef, RequestContext>()(
"@opencode/example/RequestContextRef",
) {}
export interface ConfigServiceShape {
readonly directory: string
readonly workspace: string
readonly nextUse: () => Effect.Effect<number>
readonly which: Npm.Interface["which"]
}
export class ConfigService extends Context.Service<ConfigService, ConfigServiceShape>()(
"@opencode/example/ConfigService",
) {}
const configServiceLayer = Layer.effect(
ConfigService,
Effect.gen(function* () {
const context = yield* RequestContextRef
const npm = yield* Npm.Service
let useCount = 0
return ConfigService.of({
directory: context.directory,
workspace: context.workspace,
nextUse: () => Effect.succeed(++useCount),
which: npm.which,
})
}),
)
export class ConfigServiceMap extends LayerMap.Service<ConfigServiceMap>()("@opencode/example/ConfigServiceMap", {
lookup: (context: RequestContext) =>
configServiceLayer.pipe(Layer.provide(Layer.succeed(RequestContextRef, RequestContextRef.of(context)))),
idleTimeToLive: "5 minutes",
}) {}
export const appLayer = ConfigServiceMap.layer
export const readConfig = Effect.fn("LayerMapExample.readConfig")(function* () {
const config = yield* ConfigService
return {
directory: config.directory,
workspace: config.workspace,
useCount: yield* config.nextUse(),
}
})
export const handleRequest = Effect.fn("LayerMapExample.handleRequest")(function* (context: RequestContext) {
return yield* readConfig().pipe(Effect.provide(ConfigServiceMap.get(context)))
})
export const invalidateContext = (context: RequestContext) => ConfigServiceMap.invalidate(context)
+32 -3
View File
@@ -194,7 +194,9 @@ export function fromPromise(plugin: Plugin) {
), ),
), ),
refresh: refresh:
refresh === undefined ? undefined : (credential) => Effect.promise(() => refresh(credential)), refresh === undefined
? undefined
: (credential) => Effect.promise(() => refresh(credential)),
}) })
}, },
remove: draft.method.remove, remove: draft.method.remove,
@@ -263,8 +265,35 @@ export function fromPromise(plugin: Plugin) {
), ),
}, },
session: { session: {
hook: (name, callback) => hook: (name, callback) => {
register(host.session.hook(name, (event) => Effect.promise(() => Promise.resolve(callback(event))))), 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) => create: (input) =>
run( run(
host.session.create( 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(() => { Effect.sync(() => {
if (!chatgpt || evt.model.providerID !== Provider.ID.openai) return if (!chatgpt || evt.model.providerID !== Provider.ID.openai) return
const url = new URL(evt.url) const request = evt.request
if (url.origin === "https://api.openai.com") { evt.request = (input) => {
evt.url = `${codexBaseURL}${url.pathname.replace(/^\/v1/, "")}${url.search}` 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
}), }),
) )
-1
View File
@@ -120,7 +120,6 @@ export const providerLayerWithCell = (cell: Cell) =>
) )
export const layer = layerWithCell(defaultCell) export const layer = layerWithCell(defaultCell)
export const providerLayer = providerLayerWithCell(defaultCell)
export const node = makeGlobalNode({ service: Service, layer, deps: [] }) export const node = makeGlobalNode({ service: Service, layer, deps: [] })
+4 -13
View File
@@ -220,24 +220,15 @@ export const layer = Layer.effect(
toolChoice: stepLimitReached ? "none" : undefined, toolChoice: stepLimitReached ? "none" : undefined,
}) })
const options: StreamOptions = { const options: StreamOptions = {
transform: (request) => http: (request, handler) =>
hooks hooks
.trigger("session", "request", { .trigger("session", "http", {
sessionID: session.id, sessionID: session.id,
agent: agent.id, agent: agent.id,
model: resolved.ref, model: resolved.ref,
...request, request: handler,
}) })
.pipe( .pipe(Effect.flatMap((event) => event.request(request))),
Effect.tap((event) =>
Effect.sync(() => {
request.url = event.url
request.headers = event.headers
request.body = event.body
}),
),
Effect.asVoid,
),
} }
if (promptCacheSnapshots) { if (promptCacheSnapshots) {
const current = PromptCacheDiagnostics.snapshot(request) const current = PromptCacheDiagnostics.snapshot(request)
+16 -10
View File
@@ -11,25 +11,25 @@ export function toSessionError(cause: unknown): SessionError.Error {
if (cause instanceof LLMError) { if (cause instanceof LLMError) {
switch (cause.reason._tag) { switch (cause.reason._tag) {
case "RateLimit": case "RateLimit":
return { type: "provider.rate-limit", message: cause.reason.message } return providerError("provider.rate-limit", cause.reason)
case "Authentication": case "Authentication":
return { type: "provider.auth", message: cause.reason.message } return providerError("provider.auth", cause.reason)
case "QuotaExceeded": case "QuotaExceeded":
return { type: "provider.quota", message: cause.reason.message } return providerError("provider.quota", cause.reason)
case "ContentPolicy": case "ContentPolicy":
return { type: "provider.content-filter", message: cause.reason.message } return providerError("provider.content-filter", cause.reason)
case "Transport": case "Transport":
return { type: "provider.transport", message: cause.reason.message } return providerError("provider.transport", cause.reason)
case "ProviderInternal": case "ProviderInternal":
return { type: "provider.internal", message: cause.reason.message } return providerError("provider.internal", cause.reason)
case "InvalidProviderOutput": case "InvalidProviderOutput":
return { type: "provider.invalid-output", message: cause.reason.message } return providerError("provider.invalid-output", cause.reason)
case "InvalidRequest": case "InvalidRequest":
return { type: "provider.invalid-request", message: cause.reason.message } return providerError("provider.invalid-request", cause.reason)
case "NoRoute": case "NoRoute":
return { type: "provider.no-route", message: cause.reason.message } return providerError("provider.no-route", cause.reason)
case "UnknownProvider": case "UnknownProvider":
return { type: "provider.unknown", message: cause.reason.message } return providerError("provider.unknown", cause.reason)
default: { default: {
const exhaustive: never = cause.reason const exhaustive: never = cause.reason
return exhaustive return exhaustive
@@ -58,3 +58,9 @@ export function toSessionError(cause: unknown): SessionError.Error {
if (cause instanceof Integration.AuthorizationError) return { type: "provider.auth", message: cause.message } if (cause instanceof Integration.AuthorizationError) return { type: "provider.auth", message: cause.message }
return { type: "unknown", message: cause instanceof Error ? cause.message : String(cause) } return { type: "unknown", message: cause instanceof Error ? cause.message : String(cause) }
} }
function providerError(type: string, reason: LLMError["reason"]): SessionError.Error {
const status =
("http" in reason ? reason.http?.response?.status : undefined) ?? ("status" in reason ? reason.status : undefined)
return { type, message: reason.message, ...(status === undefined ? {} : { status }) }
}
@@ -1,16 +0,0 @@
export * as ConfigConsoleStateV1 from "./console-state"
import { Schema } from "effect"
import { NonNegativeInt } from "../../schema"
export class ConsoleState extends Schema.Class<ConsoleState>("ConsoleState")({
consoleManagedProviders: Schema.mutable(Schema.Array(Schema.String)),
activeOrgName: Schema.optional(Schema.String),
switchableOrgCount: NonNegativeInt,
}) {}
export const emptyConsoleState: ConsoleState = ConsoleState.make({
consoleManagedProviders: [],
activeOrgName: undefined,
switchableOrgCount: 0,
})
-291
View File
@@ -1,291 +0,0 @@
import { describe, expect } from "bun:test"
import { $ } from "bun"
import fs from "fs/promises"
import path from "path"
import { eq } from "drizzle-orm"
import { Effect, Layer } from "effect"
import { MoveSession } from "@opencode-ai/core/control-plane/move-session"
import { Database } from "@opencode-ai/core/database/database"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { Bus } from "@opencode-ai/core/bus"
import { Job } from "@opencode-ai/core/job"
import { Project } from "@opencode-ai/core/project"
import { ProjectDirectories } from "@opencode-ai/core/project/directories"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { Session } from "@opencode-ai/core/session"
import { SessionExecution } from "@opencode-ai/core/session/execution"
import { SessionProjector } from "@opencode-ai/core/session/projector"
import { SessionTable } from "@opencode-ai/core/session/sql"
import { SessionStore } from "@opencode-ai/core/session/store"
import { tmpdir } from "./fixture/tmpdir"
import { testEffect } from "./lib/effect"
// Records the execution serialization a move must perform before relocating.
const executionCalls: string[] = []
const recordingExecution = Layer.succeed(
SessionExecution.Service,
SessionExecution.Service.of({
active: Effect.succeed(new Set()),
resume: () => Effect.void,
wake: () => Effect.void,
interrupt: (sessionID) => Effect.sync(() => void executionCalls.push(`interrupt:${sessionID}`)),
awaitIdle: (sessionID) => Effect.sync(() => void executionCalls.push(`awaitIdle:${sessionID}`)),
}),
)
const it = testEffect(
AppNodeBuilder.build(
LayerNode.group([
MoveSession.node,
Database.node,
Bus.node,
ProjectDirectories.node,
Project.node,
Session.node,
SessionProjector.node,
SessionStore.node,
]),
[[SessionExecution.node, recordingExecution]],
),
)
function abs(input: string) {
return AbsolutePath.make(input)
}
async function initRepo(directory: string) {
await $`git init`.cwd(directory).quiet()
await $`git config core.autocrlf false`.cwd(directory).quiet()
await $`git config core.fsmonitor false`.cwd(directory).quiet()
await $`git config commit.gpgsign false`.cwd(directory).quiet()
await $`git config user.email test@opencode.test`.cwd(directory).quiet()
await $`git config user.name Test`.cwd(directory).quiet()
await fs.writeFile(path.join(directory, "tracked.txt"), "initial\n")
await $`git add tracked.txt`.cwd(directory).quiet()
await $`git commit -m root`.cwd(directory).quiet()
}
describe("MoveSession", () => {
it.live("moves session changes to another project directory", () =>
Effect.gen(function* () {
const root = yield* Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(dir) => Effect.promise(() => dir[Symbol.asyncDispose]()),
)
yield* Effect.promise(() => initRepo(root.path))
const source = abs(yield* Effect.promise(() => fs.realpath(root.path)))
const destination = abs(`${root.path}-move-destination`)
yield* Effect.addFinalizer(() =>
Effect.promise(() => fs.rm(destination, { recursive: true, force: true })).pipe(Effect.ignore),
)
yield* Effect.promise(() => $`git worktree add --detach ${destination} HEAD`.cwd(root.path).quiet())
const moved = abs(yield* Effect.promise(() => fs.realpath(destination)))
yield* Effect.promise(() => fs.writeFile(path.join(source, "tracked.txt"), "changed\n"))
yield* Effect.promise(() => fs.writeFile(path.join(source, "untracked.txt"), "new\n"))
const projectID = (yield* Project.Service.use((service) => service.resolve(source))).id
const sessionID = Session.ID.make("ses_move")
const { db } = yield* Database.Service
yield* db
.insert(SessionTable)
.values({
id: sessionID,
project_id: projectID,
slug: "move",
directory: source,
title: "move",
version: "test",
time_created: 1,
time_updated: 1,
})
.run()
.pipe(Effect.orDie)
executionCalls.length = 0
yield* MoveSession.Service.use((service) =>
service.moveSession({ sessionID, destination: { directory: moved }, moveChanges: true }),
)
// The move stops active execution before any relocation side effect.
expect(executionCalls).toEqual([`interrupt:${sessionID}`, `awaitIdle:${sessionID}`])
expect(yield* Effect.promise(() => fs.readFile(path.join(moved, "tracked.txt"), "utf8"))).toBe("changed\n")
expect(yield* Effect.promise(() => fs.readFile(path.join(moved, "untracked.txt"), "utf8"))).toBe("new\n")
expect(yield* Effect.promise(() => fs.readFile(path.join(source, "tracked.txt"), "utf8"))).toBe("initial\n")
expect(yield* Effect.promise(() => Bun.file(path.join(source, "untracked.txt")).exists())).toBe(false)
expect(
yield* db
.select({ directory: SessionTable.directory, path: SessionTable.path })
.from(SessionTable)
.where(eq(SessionTable.id, sessionID))
.get(),
).toEqual({ directory: moved, path: "" })
}),
)
it.live("moves within a checkout without transferring existing changes", () =>
Effect.gen(function* () {
const root = yield* Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(dir) => Effect.promise(() => dir[Symbol.asyncDispose]()),
)
yield* Effect.promise(() => initRepo(root.path))
const source = abs(yield* Effect.promise(() => fs.realpath(root.path)))
const destination = abs(path.join(source, "packages"))
yield* Effect.promise(() => fs.writeFile(path.join(source, "tracked.txt"), "changed\n"))
yield* Effect.promise(() => fs.writeFile(path.join(source, "untracked.txt"), "new\n"))
const projectID = (yield* Project.Service.use((service) => service.resolve(source))).id
const sessionID = Session.ID.make("ses_move_nested")
const { db } = yield* Database.Service
yield* db
.insert(SessionTable)
.values({
id: sessionID,
project_id: projectID,
slug: "move-nested",
directory: source,
title: "move nested",
version: "test",
time_created: 1,
time_updated: 1,
})
.run()
.pipe(Effect.orDie)
const missing = yield* Session.Service.use((service) =>
service.move({ sessionID, directory: abs("packages") }).pipe(Effect.flip),
)
expect(missing._tag).toBe("Session.DestinationNotFoundError")
yield* Effect.promise(() => fs.mkdir(destination))
yield* MoveSession.Service.use((service) =>
service.moveSession({ sessionID, destination: { directory: abs("packages") }, moveChanges: true }),
)
expect(yield* Effect.promise(() => fs.readFile(path.join(source, "tracked.txt"), "utf8"))).toBe("changed\n")
expect(yield* Effect.promise(() => fs.readFile(path.join(source, "untracked.txt"), "utf8"))).toBe("new\n")
expect(
yield* db
.select({ directory: SessionTable.directory, path: SessionTable.path })
.from(SessionTable)
.where(eq(SessionTable.id, sessionID))
.get(),
).toEqual({ directory: destination, path: "packages" })
}),
)
it.live("moves a session to another project", () =>
Effect.gen(function* () {
const root = yield* Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(dir) => Effect.promise(() => dir[Symbol.asyncDispose]()),
)
yield* Effect.promise(() => initRepo(root.path))
const source = abs(yield* Effect.promise(() => fs.realpath(root.path)))
const destination = abs(`${root.path}-other-project`)
yield* Effect.acquireRelease(
Effect.promise(() => fs.mkdir(destination, { recursive: true })),
() => Effect.promise(() => fs.rm(destination, { recursive: true, force: true })),
)
const projectID = (yield* Project.Service.use((service) => service.resolve(source))).id
const destinationProjectID = (yield* Project.Service.use((service) => service.resolve(destination))).id
const sessionID = Session.ID.make("ses_move_project")
const { db } = yield* Database.Service
yield* db
.insert(SessionTable)
.values({
id: sessionID,
project_id: projectID,
slug: "move-project",
directory: source,
title: "move project",
version: "test",
time_created: 1,
time_updated: 1,
})
.run()
.pipe(Effect.orDie)
yield* Session.Service.use((service) =>
service.move({ sessionID, directory: destination }),
)
expect(
yield* db
.select({ projectID: SessionTable.project_id, directory: SessionTable.directory })
.from(SessionTable)
.where(eq(SessionTable.id, sessionID))
.get(),
).toEqual({ projectID: destinationProjectID, directory: destination })
}),
)
it.live("moves nested session changes without cleaning unrelated files", () =>
Effect.gen(function* () {
const root = yield* Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(dir) => Effect.promise(() => dir[Symbol.asyncDispose]()),
)
yield* Effect.promise(() => initRepo(root.path))
const source = abs(yield* Effect.promise(() => fs.realpath(root.path)))
const sourceDirectory = abs(path.join(source, "packages"))
yield* Effect.promise(() => fs.mkdir(sourceDirectory))
yield* Effect.promise(() => fs.writeFile(path.join(sourceDirectory, "tracked.txt"), "initial\n"))
yield* Effect.promise(() => fs.writeFile(path.join(sourceDirectory, "staged.txt"), "initial\n"))
yield* Effect.promise(() => $`git add packages/tracked.txt packages/staged.txt`.cwd(source).quiet())
yield* Effect.promise(() => $`git commit -m packages`.cwd(source).quiet())
const destination = abs(`${root.path}-move-nested-destination`)
yield* Effect.addFinalizer(() =>
Effect.promise(() => fs.rm(destination, { recursive: true, force: true })).pipe(Effect.ignore),
)
yield* Effect.promise(() => $`git worktree add --detach ${destination} HEAD`.cwd(source).quiet())
const moved = abs(path.join(yield* Effect.promise(() => fs.realpath(destination)), "packages"))
yield* Effect.promise(() => fs.writeFile(path.join(sourceDirectory, "tracked.txt"), "changed\n"))
yield* Effect.promise(() => fs.writeFile(path.join(sourceDirectory, "staged.txt"), "staged\n"))
yield* Effect.promise(() => $`git add packages/staged.txt`.cwd(source).quiet())
yield* Effect.promise(() => fs.writeFile(path.join(sourceDirectory, "untracked.txt"), "new\n"))
yield* Effect.promise(() => fs.writeFile(path.join(source, "tracked.txt"), "unrelated\n"))
yield* Effect.promise(() => fs.writeFile(path.join(source, "untracked.txt"), "unrelated\n"))
const projectID = (yield* Project.Service.use((service) => service.resolve(source))).id
const sessionID = Session.ID.make("ses_move_nested_checkout")
const { db } = yield* Database.Service
yield* db
.insert(SessionTable)
.values({
id: sessionID,
project_id: projectID,
slug: "move-nested-checkout",
directory: sourceDirectory,
title: "move nested checkout",
version: "test",
time_created: 1,
time_updated: 1,
})
.run()
.pipe(Effect.orDie)
yield* MoveSession.Service.use((service) =>
service.moveSession({ sessionID, destination: { directory: moved }, moveChanges: true }),
)
expect(yield* Effect.promise(() => fs.readFile(path.join(moved, "tracked.txt"), "utf8"))).toBe("changed\n")
expect(yield* Effect.promise(() => fs.readFile(path.join(moved, "staged.txt"), "utf8"))).toBe("staged\n")
expect(yield* Effect.promise(() => fs.readFile(path.join(moved, "untracked.txt"), "utf8"))).toBe("new\n")
expect(yield* Effect.promise(() => fs.readFile(path.join(sourceDirectory, "tracked.txt"), "utf8"))).toBe(
"initial\n",
)
expect(yield* Effect.promise(() => Bun.file(path.join(sourceDirectory, "untracked.txt")).exists())).toBe(false)
expect(yield* Effect.promise(() => fs.readFile(path.join(sourceDirectory, "staged.txt"), "utf8"))).toBe(
"staged\n",
)
expect(yield* Effect.promise(() => $`git status --porcelain -- packages/staged.txt`.cwd(source).text())).toBe(
"M packages/staged.txt\n",
)
expect(yield* Effect.promise(() => fs.readFile(path.join(source, "tracked.txt"), "utf8"))).toBe("unrelated\n")
expect(yield* Effect.promise(() => fs.readFile(path.join(source, "untracked.txt"), "utf8"))).toBe("unrelated\n")
}),
)
})
+85 -14
View File
@@ -1,6 +1,6 @@
import { describe, expect } from "bun:test" import { describe, expect } from "bun:test"
import { Message, SystemPart } from "@opencode-ai/ai" 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 { Agent } from "@opencode-ai/core/agent"
import { Catalog } from "@opencode-ai/core/catalog" import { Catalog } from "@opencode-ai/core/catalog"
import { Model } from "@opencode-ai/core/model" 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({ expect((await ctx.agent.get({ agentID: Agent.ID.make("reviewer") })).data).toMatchObject({
description: "Reviews code", 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 const models = (await ctx.catalog.model.list()).data
expect(models.find((model) => model.providerID === "test" && model.id === "alias")).toMatchObject({ expect(models.find((model) => model.providerID === "test" && model.id === "alias")).toMatchObject({
modelID: "gpt-5", 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", () => it.effect("disposes a hook registration on request", () =>
Effect.gen(function* () { Effect.gen(function* () {
const agents = yield* Agent.Service const agents = yield* Agent.Service
@@ -315,19 +388,17 @@ describe("fromPromise", () => {
id: "promise-tool", id: "promise-tool",
setup: async (ctx) => { setup: async (ctx) => {
await ctx.tool.transform((tools) => { await ctx.tool.transform((tools) => {
tools.add( tools.add({
{ name: "hello",
name: "hello", options: { codemode: false },
options: { codemode: false }, description: "Hello",
description: "Hello", input: Schema.Struct({ name: Schema.String }),
input: Schema.Struct({ name: Schema.String }), output: Schema.String,
output: Schema.String, execute: async ({ name }, context) => {
execute: async ({ name }, context) => { await context.progress({ phase: "greeting" })
await context.progress({ phase: "greeting" }) return { output: `Hello, ${name}!` }
return { output: `Hello, ${name}!` }
},
}, },
) })
}) })
}, },
}) })
@@ -29,6 +29,21 @@ function required<T>(value: T | undefined): T {
return value 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", () => { describe("OpenAIPlugin", () => {
it.effect("registers browser and headless ChatGPT OAuth methods", () => it.effect("registers browser and headless ChatGPT OAuth methods", () =>
Effect.gen(function* () { Effect.gen(function* () {
@@ -100,33 +115,9 @@ describe("OpenAIPlugin", () => {
}) })
yield* addPlugin() yield* addPlugin()
const request = yield* (yield* PluginHooks.Service).trigger("session", "request", { const request = yield* http(Provider.ID.openai, "https://api.openai.com/v1/responses")
sessionID: Session.ID.make("ses_test"), const custom = yield* http(Provider.ID.make("custom-openai"), "https://custom.example/v1/responses")
agent: Agent.ID.make("build"), const proxy = yield* http(Provider.ID.openai, "https://proxy.example/v1/responses?region=us")
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 provider = required(yield* catalog.provider.get(Provider.ID.openai)) const provider = required(yield* catalog.provider.get(Provider.ID.openai))
expect(provider.package).toBe("@opencode-ai/ai/providers/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(provider.headers).toMatchObject({ "chatgpt-account-id": "acct_123" })
expect(request.url).toBe("https://chatgpt.com/backend-api/codex/responses") expect(request.url).toBe("https://chatgpt.com/backend-api/codex/responses")
expect(request.headers).toMatchObject({ originator: "opencode", "session-id": "ses_test" }) 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.url).toBe("https://proxy.example/v1/responses?region=us")
expect(proxy.headers).toMatchObject({ originator: "opencode", "session-id": "ses_test" }) 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"))) const eligible = required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-5.5")))
@@ -184,21 +175,13 @@ describe("OpenAIPlugin", () => {
}) })
yield* addPlugin() yield* addPlugin()
const request = yield* (yield* PluginHooks.Service).trigger("session", "request", { const request = yield* http(Provider.ID.openai, "https://api.openai.com/v1/responses")
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 model = required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-5.5"))) 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.package).toBe("@opencode-ai/ai/providers/openai")
expect(model.enabled).toBe(true) expect(model.enabled).toBe(true)
expect(model.limit).toEqual({ context: 1_050_000, input: 922_000, output: 128_000 }) 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) expect(required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-4.1"))).enabled).toBe(true)
}), }),
) )
+20
View File
@@ -14,6 +14,9 @@ import {
TransportReason, TransportReason,
UnknownProviderReason, UnknownProviderReason,
ToolFailure, ToolFailure,
HttpContext,
HttpRequestDetails,
HttpResponseDetails,
} from "@opencode-ai/ai" } from "@opencode-ai/ai"
import { Permission } from "@opencode-ai/core/permission" import { Permission } from "@opencode-ai/core/permission"
import { Tool } from "@opencode-ai/schema/tool" import { Tool } from "@opencode-ai/schema/tool"
@@ -71,6 +74,23 @@ describe("toSessionError", () => {
}) })
}) })
test("preserves provider HTTP status", () => {
const http = new HttpContext({
request: new HttpRequestDetails({ method: "POST", url: "https://example.com", headers: {} }),
response: new HttpResponseDetails({ status: 413, headers: {} }),
})
expect(toSessionError(llm(new InvalidRequestReason({ message: "too large", http })))).toEqual({
type: "provider.invalid-request",
message: "too large",
status: 413,
})
expect(toSessionError(llm(new ProviderInternalReason({ message: "bad gateway", status: 502 })))).toEqual({
type: "provider.internal",
message: "bad gateway",
status: 502,
})
})
test("retries only rate limits, provider-internal failures, and transport failures", () => { test("retries only rate limits, provider-internal failures, and transport failures", () => {
const eligible = [ const eligible = [
llm(new RateLimitReason({ message: "rate" })), llm(new RateLimitReason({ message: "rate" })),
+4 -4
View File
@@ -1,10 +1,9 @@
import type { SessionApi } from "@opencode-ai/client/effect/api" import type { SessionApi } from "@opencode-ai/client/effect/api"
import type { Message, SystemPart } from "@opencode-ai/ai" 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 { Agent } from "@opencode-ai/schema/agent"
import type { Model } from "@opencode-ai/schema/model" import type { Model } from "@opencode-ai/schema/model"
import type { Session } from "@opencode-ai/schema/session" 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" import type { Hooks } from "./registration.js"
export interface SessionContext { export interface SessionContext {
@@ -16,15 +15,16 @@ export interface SessionContext {
tools: Record<string, { description: string; input: JsonSchema.JsonSchema }> tools: Record<string, { description: string; input: JsonSchema.JsonSchema }>
} }
export interface SessionRequest extends HttpRequest { export interface SessionHttp {
readonly sessionID: Session.ID readonly sessionID: Session.ID
readonly agent: Agent.ID readonly agent: Agent.ID
readonly model: Model.Ref readonly model: Model.Ref
request: (input: Request) => Effect.Effect<Response, Error>
} }
export interface SessionHooks { export interface SessionHooks {
readonly context: SessionContext readonly context: SessionContext
readonly request: SessionRequest readonly http: SessionHttp
} }
export type SessionDomain = Pick< export type SessionDomain = Pick<
+3 -3
View File
@@ -1,6 +1,5 @@
import type { SessionApi } from "@opencode-ai/client/promise/api" import type { SessionApi } from "@opencode-ai/client/promise/api"
import type { Message, SystemPart } from "@opencode-ai/ai" 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 { Agent } from "@opencode-ai/schema/agent"
import type { Model } from "@opencode-ai/schema/model" import type { Model } from "@opencode-ai/schema/model"
import type { Session } from "@opencode-ai/schema/session" import type { Session } from "@opencode-ai/schema/session"
@@ -16,15 +15,16 @@ export interface SessionContext {
tools: Record<string, { description: string; input: JsonSchema.JsonSchema }> tools: Record<string, { description: string; input: JsonSchema.JsonSchema }>
} }
export interface SessionRequest extends HttpRequest { export interface SessionHttp {
readonly sessionID: Session.ID readonly sessionID: Session.ID
readonly agent: Agent.ID readonly agent: Agent.ID
readonly model: Model.Ref readonly model: Model.Ref
request: (input: Request) => Promise<Response>
} }
export interface SessionHooks { export interface SessionHooks {
readonly context: SessionContext readonly context: SessionContext
readonly request: SessionRequest readonly http: SessionHttp
} }
export type SessionDomain = Pick< export type SessionDomain = Pick<
+2
View File
@@ -1,9 +1,11 @@
export * as SessionError from "./session-error.js" export * as SessionError from "./session-error.js"
import { Schema } from "effect" import { Schema } from "effect"
import { optional } from "./schema.js"
export interface Error extends Schema.Schema.Type<typeof Error> {} export interface Error extends Schema.Schema.Type<typeof Error> {}
export const Error = Schema.Struct({ export const Error = Schema.Struct({
type: Schema.String, type: Schema.String,
message: Schema.String, message: Schema.String,
status: Schema.Int.check(Schema.isBetween({ minimum: 100, maximum: 599 })).pipe(optional),
}).annotate({ identifier: "Session.StructuredError" }) }).annotate({ identifier: "Session.StructuredError" })
+1
View File
@@ -45,6 +45,7 @@ export type {
StatefulColor, StatefulColor,
} from "./types.js" } from "./types.js"
export { DEFAULT_CATEGORICAL, DEFAULT_THEME } from "./defaults.js" export { DEFAULT_CATEGORICAL, DEFAULT_THEME } from "./defaults.js"
export { expandTheme } from "./expand.js"
export { migrateV1 } from "./v1-migrate.js" export { migrateV1 } from "./v1-migrate.js"
export { resolveTheme, resolveThemeDocument, themeDecodeError } from "./resolve.js" export { resolveTheme, resolveThemeDocument, themeDecodeError } from "./resolve.js"
export { selectTheme, selectThemeMode, supportsThemeMode, themeModes } from "./select.js" export { selectTheme, selectThemeMode, supportsThemeMode, themeModes } from "./select.js"
-1
View File
@@ -93,7 +93,6 @@
"@opentui/solid": "catalog:", "@opentui/solid": "catalog:",
"@solid-primitives/event-bus": "1.1.2", "@solid-primitives/event-bus": "1.1.2",
"clipboardy": "4.0.0", "clipboardy": "4.0.0",
"diff": "catalog:",
"effect": "catalog:", "effect": "catalog:",
"fuzzysort": "catalog:", "fuzzysort": "catalog:",
"get-east-asian-width": "catalog:", "get-east-asian-width": "catalog:",
+3 -3
View File
@@ -72,9 +72,9 @@ import { ThemeErrorToast } from "./component/theme-error-toast"
import { ThemeProvider, useTheme, useThemes } from "./context/theme" import { ThemeProvider, useTheme, useThemes } from "./context/theme"
import { Home } from "./routes/home" import { Home } from "./routes/home"
import { Session } from "./routes/session" import { Session } from "./routes/session"
import { PromptHistoryProvider } from "./component/prompt/history" import { PromptHistoryProvider } from "./prompt/history"
import { FrecencyProvider } from "./component/prompt/frecency" import { FrecencyProvider } from "./prompt/frecency"
import { PromptStashProvider } from "./component/prompt/stash" import { PromptStashProvider } from "./prompt/stash"
import { Toast, ToastProvider, useToast } from "./ui/toast" import { Toast, ToastProvider, useToast } from "./ui/toast"
import { isFallbackTitle } from "@opencode-ai/util/session-title-fallback" import { isFallbackTitle } from "@opencode-ai/util/session-title-fallback"
import * as Model from "./util/model" import * as Model from "./util/model"
-3
View File
@@ -5,7 +5,6 @@ import type {
TuiAttentionNotifyResult, TuiAttentionNotifyResult,
TuiAttentionNotifySkipReason, TuiAttentionNotifySkipReason,
TuiAttentionWhen, TuiAttentionWhen,
TuiKV,
TuiAttentionSoundName, TuiAttentionSoundName,
TuiAttentionSoundPack, TuiAttentionSoundPack,
TuiAttentionSoundPackInfo, TuiAttentionSoundPackInfo,
@@ -115,8 +114,6 @@ export function createTuiAttention(input: {
renderer: AttentionRenderer renderer: AttentionRenderer
config: Pick<Config.Resolved, "attention"> config: Pick<Config.Resolved, "attention">
update?: Config.Interface["update"] update?: Config.Interface["update"]
/** @deprecated Ignored. Sound-pack persistence uses CLI config. */
kv?: TuiKV
audio?: Pick<typeof TuiAudio, "loadSoundFile" | "play"> audio?: Pick<typeof TuiAudio, "loadSoundFile" | "play">
}): TuiAttentionHost { }): TuiAttentionHost {
let focus: FocusState = "unknown" let focus: FocusState = "unknown"
+1 -5
View File
@@ -1,4 +1,4 @@
import { Audio, type AudioErrorContext, type AudioPlayOptions, type AudioSound, type AudioVoice } from "@opentui/core" import { Audio, type AudioErrorContext, type AudioPlayOptions, type AudioSound } from "@opentui/core"
import { readFile } from "node:fs/promises" import { readFile } from "node:fs/promises"
let audio: Audio | null | undefined let audio: Audio | null | undefined
@@ -42,10 +42,6 @@ export function play(sound: AudioSound, options?: AudioPlayOptions) {
return current.play(sound, options) return current.play(sound, options)
} }
export function stopVoice(voice: AudioVoice) {
return audio?.stopVoice(voice) ?? false
}
export function dispose() { export function dispose() {
audio?.dispose() audio?.dispose()
audio = undefined audio = undefined
@@ -43,15 +43,6 @@ export const settings: Setting[] = [
labels: ["off", "on"], labels: ["off", "on"],
keywords: ["motion", "effects"], keywords: ["motion", "effects"],
}, },
{
title: "Onboarding",
category: "Appearance",
path: ["hints", "onboarding"],
default: true,
values: [false, true],
labels: ["off", "on"],
keywords: ["hints", "getting started", "guidance"],
},
{ {
title: "Sidebar", title: "Sidebar",
category: "Session", category: "Session",
+10 -4
View File
@@ -63,15 +63,15 @@ export function DialogModel(props: { providerID?: string }) {
.filter((model) => (props.providerID ? model.providerID === props.providerID : true)) .filter((model) => (props.providerID ? model.providerID === props.providerID : true))
.map((model) => { .map((model) => {
const provider = providers().get(model.providerID) const provider = providers().get(model.providerID)
const favorite = favorites.some((item) => item.providerID === model.providerID && item.modelID === model.id)
return { return {
value: { providerID: model.providerID, modelID: model.id }, value: { providerID: model.providerID, modelID: model.id },
providerID: model.providerID, providerID: model.providerID,
providerName: provider?.name ?? model.providerID, providerName: provider?.name ?? model.providerID,
title: model.name, title: model.name,
releaseDate: model.time.released, releaseDate: model.time.released,
description: favorites.some((item) => item.providerID === model.providerID && item.modelID === model.id) favorite,
? "(Favorite)" description: favorite ? "(Favorite)" : undefined,
: undefined,
category: connected() ? (provider?.name ?? model.providerID) : undefined, category: connected() ? (provider?.name ?? model.providerID) : undefined,
footer: free(model) ? "Free" : undefined, footer: free(model) ? "Free" : undefined,
onSelect() { onSelect() {
@@ -96,7 +96,9 @@ export function DialogModel(props: { providerID?: string }) {
) )
if (needle) { if (needle) {
return fuzzysort.go(needle, modelOptions, { keys: ["title", "category"] }).map((item) => item.obj) return prioritizeFavorites(
fuzzysort.go(needle, modelOptions, { keys: ["title", "category"] }).map((item) => item.obj),
)
} }
return [...favoriteOptions, ...recentOptions, ...modelOptions] return [...favoriteOptions, ...recentOptions, ...modelOptions]
@@ -160,6 +162,10 @@ export function DialogModel(props: { providerID?: string }) {
) )
} }
export function prioritizeFavorites<T extends { favorite: boolean }>(options: T[]) {
return options.toSorted((a, b) => Number(b.favorite) - Number(a.favorite))
}
export function sortModelOptions< export function sortModelOptions<
T extends { providerID?: string; providerName?: string; releaseDate: string | number; title: string }, T extends { providerID?: string; providerName?: string; releaseDate: string | number; title: string },
>(options: T[]) { >(options: T[]) {
+1 -1
View File
@@ -4,7 +4,7 @@ import { createMemo, createSignal } from "solid-js"
import { Locale } from "../util/locale" import { Locale } from "../util/locale"
import { Keymap } from "../context/keymap" import { Keymap } from "../context/keymap"
import { useTheme } from "../context/theme" import { useTheme } from "../context/theme"
import { usePromptStash, type StashEntry } from "./prompt/stash" import { usePromptStash, type StashEntry } from "../prompt/stash"
function getRelativeTime(timestamp: number): string { function getRelativeTime(timestamp: number): string {
const now = Date.now() const now = Date.now()
@@ -1 +0,0 @@
export * from "../../prompt/frecency"
@@ -1 +0,0 @@
export * from "../../prompt/history"
@@ -1 +0,0 @@
export * from "../../prompt/stash"
+24 -2
View File
@@ -6,6 +6,7 @@ import { useSessionTabs } from "../context/session-tabs"
import { useTheme, useThemes } from "../context/theme" import { useTheme, useThemes } from "../context/theme"
import { import {
adaptiveSessionTabLayout, adaptiveSessionTabLayout,
moveSessionTab,
NEW_SESSION_TAB_TITLE, NEW_SESSION_TAB_TITLE,
sessionTabComplete, sessionTabComplete,
seedSessionTabMotion, seedSessionTabMotion,
@@ -48,6 +49,10 @@ export function SessionTabs(props: { controller?: SessionTabsController; animati
const animations = () => props.animations ?? config.animations ?? true const animations = () => props.animations ?? config.animations ?? true
const [hovered, setHovered] = createSignal<string>() const [hovered, setHovered] = createSignal<string>()
const [dragging, setDragging] = createSignal<string>() const [dragging, setDragging] = createSignal<string>()
// A drag reorders a local preview and persists one move on release instead of writing
// per slot crossing; the preview holds after release until the store reflects the move,
// so the strip never flashes the pre-drag order while the write is in flight.
const [preview, setPreview] = createSignal<{ sessionID: string; index: number }>()
let strip: { screenX: number } | undefined let strip: { screenX: number } | undefined
const hueStep = () => (mode() === "light" ? 800 : 200) const hueStep = () => (mode() === "light" ? 800 : 200)
const accent = () => theme.hue.accent[hueStep()] const accent = () => theme.hue.accent[hueStep()]
@@ -55,7 +60,18 @@ export function SessionTabs(props: { controller?: SessionTabsController; animati
const idleNumber = () => tint(theme.text.subdued, theme.background.default, 0.35) const idleNumber = () => tint(theme.text.subdued, theme.background.default, 0.35)
const newTab = () => tabs.newTab?.() ?? false const newTab = () => tabs.newTab?.() ?? false
const activeID = createMemo(() => (newTab() ? NEW_SESSION_TAB.sessionID : tabs.current())) const activeID = createMemo(() => (newTab() ? NEW_SESSION_TAB.sessionID : tabs.current()))
const items = createMemo(() => (newTab() ? [...tabs.tabs(), NEW_SESSION_TAB] : tabs.tabs())) 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()))
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)
})
const layout = createMemo((previous: ReturnType<typeof adaptiveSessionTabLayout> | undefined) => const layout = createMemo((previous: ReturnType<typeof adaptiveSessionTabLayout> | undefined) =>
adaptiveSessionTabLayout(items(), activeID(), dimensions().width, previous?.start), adaptiveSessionTabLayout(items(), activeID(), dimensions().width, previous?.start),
) )
@@ -279,6 +295,8 @@ export function SessionTabs(props: { controller?: SessionTabsController; animati
// keeping sloppy clicks indistinguishable from clean ones. // keeping sloppy clicks indistinguishable from clean ones.
const release = () => { const release = () => {
setDragging(undefined) setDragging(undefined)
const pending = preview()
if (pending?.sessionID === tab.sessionID) tabs.move(pending.sessionID, pending.index)
if (tab === NEW_SESSION_TAB) return if (tab === NEW_SESSION_TAB) return
tabs.select(tab.sessionID) tabs.select(tab.sessionID)
} }
@@ -295,7 +313,8 @@ export function SessionTabs(props: { controller?: SessionTabsController; animati
onMouseDrag={(event) => { onMouseDrag={(event) => {
if (tab === NEW_SESSION_TAB) return if (tab === NEW_SESSION_TAB) return
const slot = slotAt(event.x) const slot = slotAt(event.x)
if (slot !== undefined && slot !== tabNumber() - 1) tabs.move(tab.sessionID, slot) if (slot !== undefined && slot !== tabNumber() - 1)
setPreview({ sessionID: tab.sessionID, index: slot })
}} }}
onMouseDragEnd={release} onMouseDragEnd={release}
> >
@@ -341,6 +360,9 @@ export function SessionTabs(props: { controller?: SessionTabsController; animati
fg={closeColor()} fg={closeColor()}
selectable={false} selectable={false}
onMouseUp={(event) => { onMouseUp={(event) => {
// The close mark only renders while hovered; without motion events a click can
// land here first, and must select the tab instead of closing it invisibly.
if (hovered() !== tab.sessionID) return
event.stopPropagation() event.stopPropagation()
tabs.close(tab === NEW_SESSION_TAB ? undefined : tab.sessionID) tabs.close(tab === NEW_SESSION_TAB ? undefined : tab.sessionID)
}} }}
-9
View File
@@ -162,11 +162,6 @@ export const Info = Schema.Struct({
}), }),
}), }),
).annotate({ description: "Mini transcript presentation settings" }), ).annotate({ description: "Mini transcript presentation settings" }),
hints: Schema.optional(
Schema.Struct({
onboarding: Schema.optional(Schema.Boolean).annotate({ description: "Show getting-started guidance" }),
}),
).annotate({ description: "In-product guidance settings" }),
debug: Schema.optional( debug: Schema.optional(
Schema.Struct({ Schema.Struct({
devtools: Schema.optional(Schema.Boolean).annotate({ description: "Show the DevTools debug bar" }), devtools: Schema.optional(Schema.Boolean).annotate({ description: "Show the DevTools debug bar" }),
@@ -267,7 +262,3 @@ export function useConfig() {
if (!value) throw new Error("ConfigProvider is missing") if (!value) throw new Error("ConfigProvider is missing")
return value return value
} }
export function useConfigOptional() {
return useContext(ConfigContext)
}
-4
View File
@@ -105,8 +105,6 @@ export const Definitions = {
session_compact: keybind("<leader>c", "Compact the session"), session_compact: keybind("<leader>c", "Compact the session"),
session_queued_prompts: keybind("<leader>q", "View pending work"), session_queued_prompts: keybind("<leader>q", "View pending work"),
session_child_first: keybind("down", "Toggle subagent picker"), session_child_first: keybind("down", "Toggle subagent picker"),
session_child_cycle: keybind("right", "Go to next child session"),
session_child_cycle_reverse: keybind("left", "Go to previous child session"),
session_parent: keybind("up", "Go to parent session"), session_parent: keybind("up", "Go to parent session"),
session_pin_toggle: keybind("ctrl+f", "Pin or unpin session in the session list"), session_pin_toggle: keybind("ctrl+f", "Pin or unpin session in the session list"),
session_quick_switch_1: keybind("<leader>1", "Switch to session in quick slot 1"), session_quick_switch_1: keybind("<leader>1", "Switch to session in quick slot 1"),
@@ -308,8 +306,6 @@ export const CommandMap = {
session_compact: "session.compact", session_compact: "session.compact",
session_queued_prompts: "session.queued_prompts", session_queued_prompts: "session.queued_prompts",
session_child_first: "session.child.first", session_child_first: "session.child.first",
session_child_cycle: "session.child.next",
session_child_cycle_reverse: "session.child.previous",
session_parent: "session.parent", session_parent: "session.parent",
session_pin_toggle: "session.pin.toggle", session_pin_toggle: "session.pin.toggle",
session_quick_switch_1: "session.quick_switch.1", session_quick_switch_1: "session.quick_switch.1",
@@ -29,9 +29,11 @@ export function openSessionTab(tabs: SessionTab[], tab: SessionTab): SessionTab[
return tabs.map((item, position) => (position === index ? { ...item, title: tab.title } : item)) return tabs.map((item, position) => (position === index ? { ...item, title: tab.title } : item))
} }
export function closeSessionTab(tabs: readonly SessionTab[], sessionID: string) { export function closeSessionTab(tabs: SessionTab[], sessionID: string) {
const index = tabs.findIndex((tab) => tab.sessionID === sessionID) const index = tabs.findIndex((tab) => tab.sessionID === sessionID)
if (index === -1) return { tabs: [...tabs], next: undefined } // Like openSessionTab and moveSessionTab, a no-op returns the same reference so callers can
// detect it by identity.
if (index === -1) return { tabs, next: undefined }
return { return {
tabs: tabs.filter((tab) => tab.sessionID !== sessionID), tabs: tabs.filter((tab) => tab.sessionID !== sessionID),
next: tabs[index + 1]?.sessionID ?? tabs[index - 1]?.sessionID, next: tabs[index + 1]?.sessionID ?? tabs[index - 1]?.sessionID,
@@ -87,9 +89,13 @@ export function cycleSessionTab(tabs: readonly SessionTab[], active: string | un
return tabs[(start + direction + tabs.length) % tabs.length] return tabs[(start + direction + tabs.length) % tabs.length]
} }
// In-memory navigation history is bounded so a long-lived TUI does not accumulate one entry per
// session switch forever; the oldest entries fall off first.
const SESSION_TAB_HISTORY_LIMIT = 100
export function recordSessionTabHistory(history: SessionTabHistory, sessionID: string): SessionTabHistory { export function recordSessionTabHistory(history: SessionTabHistory, sessionID: string): SessionTabHistory {
if (history.entries[history.index] === sessionID) return history if (history.entries[history.index] === sessionID) return history
const entries = [...history.entries.slice(0, history.index + 1), sessionID] const entries = [...history.entries.slice(0, history.index + 1), sessionID].slice(-SESSION_TAB_HISTORY_LIMIT)
return { entries, index: entries.length - 1 } return { entries, index: entries.length - 1 }
} }
+3 -2
View File
@@ -73,7 +73,8 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
function update(mutation: (draft: TabsState) => void) { function update(mutation: (draft: TabsState) => void) {
const scope = config.tabs?.scope ?? "global" const scope = config.tabs?.scope ?? "global"
void updateStore((draft) => mutation(scope === "cwd" ? (draft.cwd[paths.cwd] ??= empty()) : draft.global)).catch( void updateStore((draft) => mutation(scope === "cwd" ? (draft.cwd[paths.cwd] ??= empty()) : draft.global)).catch(
() => {}, // Failed writes lose only tab layout, but silence would hide tabs resetting every launch.
(error) => console.error("Failed to persist session tabs", error),
) )
} }
@@ -222,7 +223,7 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
function remove(sessionID: string, navigate: boolean) { function remove(sessionID: string, navigate: boolean) {
const target = root(sessionID) const target = root(sessionID)
const closed = closeSessionTab(state().tabs, target) const closed = closeSessionTab(state().tabs, target)
if (closed.tabs.length === state().tabs.length) return if (closed.tabs === state().tabs) return
const selected = navigate && current() === target const selected = navigate && current() === target
const previous = selected const previous = selected
? moveSessionTabHistory(recordSessionTabHistory(history, target), closed.tabs, target, -1) ? moveSessionTabHistory(recordSessionTabHistory(history, target), closed.tabs, target, -1)
-9
View File
@@ -194,10 +194,6 @@ export function resolveZedDbPath() {
return candidates.find((item) => isFile(item)) return candidates.find((item) => isFile(item))
} }
export function isZedTerminal() {
return process.env.ZED_TERM === "true" || process.env.TERM_PROGRAM?.toLowerCase() === "zed"
}
function isFile(item: string) { function isFile(item: string) {
try { try {
return statSync(item).isFile() return statSync(item).isFile()
@@ -220,11 +216,6 @@ function zedWorkspacePaths(value: string | null) {
return value.split(/\r?\n/).filter(Boolean) return value.split(/\r?\n/).filter(Boolean)
} }
export function offsetToPosition(text: string, offset: number) {
const stringOffset = utf8ByteOffsetToStringIndex(text, offset)
return offsetsToSelection(text, stringOffset, stringOffset).start
}
function utf8ByteOffsetToStringIndex(text: string, byteOffset: number) { function utf8ByteOffsetToStringIndex(text: string, byteOffset: number) {
if (byteOffset <= 0) return 0 if (byteOffset <= 0) return 0
@@ -7,7 +7,7 @@ const money = new Intl.NumberFormat("en-US", {
currency: "USD", currency: "USD",
}) })
function View(props: { context: Plugin.Context; sessionID: string }) { export function SidebarContext(props: { context: Plugin.Context; sessionID: string }) {
const theme = props.context.theme const theme = props.context.theme
const msg = createMemo(() => props.context.data.session.message.list(props.sessionID)) const msg = createMemo(() => props.context.data.session.message.list(props.sessionID))
const session = createMemo(() => props.context.data.session.get(props.sessionID)) const session = createMemo(() => props.context.data.session.get(props.sessionID))
@@ -18,28 +18,32 @@ function View(props: { context: Plugin.Context; sessionID: string }) {
) )
return ( return (
<box> <Show when={state() || cost() > 0}>
<text fg={theme.text.default}> <box>
<b>Context</b> <text fg={theme.text.default}>
</text> <b>Context</b>
<Show when={state()} fallback={<text fg={theme.text.subdued}>Not measured</text>}> </text>
{(value) => ( <Show when={state()}>
<> {(value) => (
<text fg={theme.text.subdued}>{value().tokens.toLocaleString()} tokens</text> <>
<Show when={value().percent !== undefined}> <text fg={theme.text.subdued}>{value().tokens.toLocaleString()} tokens</text>
<text fg={theme.text.subdued}>{value().percent}% used</text> <Show when={value().percent !== undefined}>
</Show> <text fg={theme.text.subdued}>{value().percent}% used</text>
</> </Show>
)} </>
</Show> )}
<text fg={theme.text.subdued}>{money.format(cost())} spent</text> </Show>
</box> <Show when={cost() > 0}>
<text fg={theme.text.subdued}>{money.format(cost())} spent</text>
</Show>
</box>
</Show>
) )
} }
export default Plugin.define({ export default Plugin.define({
id: "internal:sidebar-context", id: "internal:sidebar-context",
setup(context) { setup(context) {
context.ui.slot("sidebar.content", (props) => <View context={context} sessionID={props.sessionID} />) context.ui.slot("sidebar.content", (props) => <SidebarContext context={context} sessionID={props.sessionID} />)
}, },
}) })
@@ -1,21 +0,0 @@
import { Plugin, usePlugin } from "@opencode-ai/plugin/tui"
function View() {
const context = usePlugin()
const theme = context.theme
return (
<box>
<text fg={theme.text.default}>
<b>LSP</b>
</text>
<text fg={theme.text.subdued}>LSP status unavailable</text>
</box>
)
}
export default Plugin.define({
id: "opencode.sidebar-lsp",
setup(context) {
context.ui.slot("sidebar.content", () => <View />)
},
})
@@ -141,22 +141,6 @@ export function moveFileTreeSelectionToParent(rows: readonly FileTreeRow[], sele
return rows.findLast((item, itemIndex) => itemIndex < index && item.depth < row.depth)?.id ?? selected return rows.findLast((item, itemIndex) => itemIndex < index && item.depth < row.depth)?.id ?? selected
} }
export function moveFileTreeSelectionToFile(
rows: readonly FileTreeRow[],
selected: number | undefined,
offset: number,
) {
const fileRows = rows.filter((row) => row.fileIndex !== undefined)
if (fileRows.length === 0) return undefined
const selectedIndex = selected === undefined ? -1 : rows.findIndex((row) => row.id === selected)
if (selectedIndex === -1) return offset < 0 ? fileRows[fileRows.length - 1]!.id : fileRows[0]!.id
const next =
offset < 0
? fileRows.findLast((row) => rows.findIndex((item) => item.id === row.id) < selectedIndex)
: fileRows.find((row) => rows.findIndex((item) => item.id === row.id) > selectedIndex)
return next?.id ?? (offset < 0 ? fileRows[0]!.id : fileRows[fileRows.length - 1]!.id)
}
export function fileTreeFileSelection(tree: FileTree, fileIndex: number) { export function fileTreeFileSelection(tree: FileTree, fileIndex: number) {
const node = tree.nodes.find((item) => item.kind === "file" && item.fileIndex === fileIndex) const node = tree.nodes.find((item) => item.kind === "file" && item.fileIndex === fileIndex)
if (!node) return undefined if (!node) return undefined
-2
View File
@@ -7,5 +7,3 @@ export const go = {
left: [" ", "█▀▀▀", "█_^█", "▀▀▀▀"], left: [" ", "█▀▀▀", "█_^█", "▀▀▀▀"],
right: [" ", "█▀▀█", "█__█", "▀▀▀▀"], right: [" ", "█▀▀█", "█__█", "▀▀▀▀"],
} }
export const marks = "_^~,"
-2
View File
@@ -2,7 +2,6 @@ import HomeFooter from "../feature-plugins/home/footer"
import PromptFooter from "../feature-plugins/prompt/footer" import PromptFooter from "../feature-plugins/prompt/footer"
import SidebarContext from "../feature-plugins/sidebar/context" import SidebarContext from "../feature-plugins/sidebar/context"
import SidebarFooter from "../feature-plugins/sidebar/footer" import SidebarFooter from "../feature-plugins/sidebar/footer"
import SidebarLsp from "../feature-plugins/sidebar/lsp"
import SidebarMcp from "../feature-plugins/sidebar/mcp" import SidebarMcp from "../feature-plugins/sidebar/mcp"
import DiffViewer from "../feature-plugins/system/diff-viewer" import DiffViewer from "../feature-plugins/system/diff-viewer"
import Notifications from "../feature-plugins/system/notifications" import Notifications from "../feature-plugins/system/notifications"
@@ -14,7 +13,6 @@ export const builtins = [
PromptFooter, PromptFooter,
SidebarContext, SidebarContext,
SidebarMcp, SidebarMcp,
SidebarLsp,
SidebarFooter, SidebarFooter,
Notifications, Notifications,
Plugins, Plugins,
-170
View File
@@ -887,22 +887,6 @@ export function Session() {
dialog.clear() dialog.clear()
}, },
}, },
{
title: "Next subagent",
id: "session.child.next",
group: "Session",
palette: undefined,
enabled: !!session()?.parentID,
run: () => unavailable("Subagent navigation"),
},
{
title: "Previous subagent",
id: "session.child.previous",
group: "Session",
palette: undefined,
enabled: !!session()?.parentID,
run: () => unavailable("Subagent navigation"),
},
]) ])
const commands = createMemo(() => const commands = createMemo(() =>
@@ -1925,122 +1909,6 @@ function UserMessage(props: { message: SessionMessageUser }) {
) )
} }
function AssistantMessage(props: { message: SessionMessageAssistant; last: boolean }) {
const ctx = use()
const local = useLocal()
const theme = useTheme("elevated")
const model = createMemo(
() =>
ctx
.models()
.find((model) => model.providerID === props.message.model.providerID && model.id === props.message.model.id)
?.name ?? `${props.message.model.providerID}/${props.message.model.id}`,
)
const final = createMemo(() => {
return props.message.finish && !["tool-calls", "unknown"].includes(props.message.finish)
})
const duration = createMemo(() => {
if (!final()) return 0
if (!props.message.time.completed) return 0
return props.message.time.completed - props.message.time.created
})
const exploration = createMemo(() => {
const grouped = new Map<string, { first: boolean; parts: SessionMessageAssistantTool[]; active: boolean }>()
if (!ctx.groupExploration()) return grouped
const runs = props.message.content
.map((part) =>
part.type === "tool" &&
["read", "glob", "grep"].includes(toolDisplay(part.name)) &&
part.state.status !== "streaming"
? part
: undefined,
)
.reduce<SessionMessageAssistantTool[][]>(
(runs, part) => {
if (part) runs[runs.length - 1].push(part)
if (!part && runs[runs.length - 1].length) runs.push([])
return runs
},
[[]],
)
.filter((run) => run.length > 0)
for (const run of runs) {
const summary = {
parts: run,
active: false,
}
run.forEach((part, index) => grouped.set(part.id, { ...summary, first: index === 0 }))
}
return grouped
})
return (
<>
<For each={props.message.content}>
{(content, index) => (
<Switch>
<Match when={content.type === "text"}>
<TextPart
part={content as SessionMessageAssistantText}
last={index() === props.message.content.length - 1}
/>
</Match>
<Match when={content.type === "reasoning"}>
<ReasoningPart
part={content as SessionMessageAssistantReasoning}
message={props.message}
last={index() === props.message.content.length - 1}
/>
</Match>
<Match when={content.type === "tool"}>
<Show when={exploration().get((content as SessionMessageAssistantTool).id)?.first !== false}>
<Show
when={exploration().get((content as SessionMessageAssistantTool).id)}
fallback={<ToolPart part={content as SessionMessageAssistantTool} />}
>
{(summary) => <ExplorationSummary {...summary()} />}
</Show>
</Show>
</Match>
</Switch>
)}
</For>
<Show when={props.message.error}>
<box
border={["left"]}
paddingTop={1}
paddingBottom={1}
paddingLeft={2}
backgroundColor={theme.background.default}
customBorderChars={SplitBorder.customBorderChars}
borderColor={theme.text.feedback.error.default}
>
<text fg={theme.text.subdued}>{errorMessage(props.message.error)}</text>
</box>
</Show>
<AssistantRetry retry={props.message.retry} />
<Switch>
<Match when={props.last || final() || props.message.error}>
<box paddingLeft={3}>
<text>
<span style={{ fg: props.message.error ? theme.text.subdued : local.agent.color(props.message.agent) }}>
{Locale.titlecase(props.message.agent)}
</span>
<span style={{ fg: theme.text.subdued }}> · {model()}</span>
<Show when={duration()}>
<span style={{ fg: theme.text.subdued }}> · {Locale.duration(duration())}</span>
</Show>
</text>
</box>
</Match>
</Switch>
</>
)
}
function AssistantRetry(props: { retry: SessionMessageAssistant["retry"] }) { function AssistantRetry(props: { retry: SessionMessageAssistant["retry"] }) {
const theme = useTheme() const theme = useTheme()
return ( return (
@@ -2056,40 +1924,6 @@ function AssistantRetry(props: { retry: SessionMessageAssistant["retry"] }) {
) )
} }
function ExplorationSummary(props: { parts: SessionMessageAssistantTool[]; active: boolean }) {
const theme = useTheme()
const pathFormatter = usePathFormatter()
const label = (part: SessionMessageAssistantTool) => {
const input = typeof part.state.input === "string" ? {} : part.state.input
const tool = toolDisplay(part.name)
if (tool === "read") return `Read ${pathFormatter.format(stringValue(input.path))}`
if (tool === "glob") return `Glob "${stringValue(input.pattern)}"`
return `Grep "${stringValue(input.pattern)}"`
}
return (
<box flexDirection="column">
<InlineToolRow
icon="✱"
color={theme.text.subdued}
complete={!props.active}
pending="Exploring"
spinner={props.active}
>
{props.active ? "Exploring" : "Explored"}
</InlineToolRow>
<For each={props.parts}>
{(part, index) => (
<box paddingLeft={5}>
<text fg={part.state.status === "error" ? theme.text.feedback.error.default : theme.text.subdued}>
{index() === props.parts.length - 1 ? "└" : "├"} {label(part)}
</text>
</box>
)}
</For>
</box>
)
}
const INLINE_TOOL_ICON_WIDTH = 2 const INLINE_TOOL_ICON_WIDTH = 2
function ReasoningPart(props: { function ReasoningPart(props: {
@@ -2954,10 +2788,6 @@ export function isBackgroundSubagent(
return status === "completed" && metadata.status === "running" return status === "completed" && metadata.status === "running"
} }
export function formatSubagentRetry(attempt: number, message: string) {
return `Retrying (attempt ${attempt}) · ${message}`
}
type ExecuteCall = { tool: string; status: "running" | "completed" | "error"; input?: Record<string, unknown> } type ExecuteCall = { tool: string; status: "running" | "completed" | "error"; input?: Record<string, unknown> }
function executeCalls(value: unknown): ExecuteCall[] { function executeCalls(value: unknown): ExecuteCall[] {
@@ -27,9 +27,11 @@ export function Sidebar(props: { sessionID: string; overlay?: boolean }) {
position={props.overlay ? "absolute" : "relative"} position={props.overlay ? "absolute" : "relative"}
> >
<scrollbox <scrollbox
ref={(scroll) => queueMicrotask(() => scroll.verticalScrollBar.resetVisibilityControl())}
flexGrow={1} flexGrow={1}
scrollAcceleration={scrollAcceleration()} scrollAcceleration={scrollAcceleration()}
verticalScrollbarOptions={{ verticalScrollbarOptions={{
visible: false,
trackOptions: { trackOptions: {
backgroundColor: theme.background.default, backgroundColor: theme.background.default,
foregroundColor: theme.scrollbar.default, foregroundColor: theme.scrollbar.default,
+1 -10
View File
@@ -1,7 +1,7 @@
import { TextAttributes } from "@opentui/core" import { TextAttributes } from "@opentui/core"
import { Keymap } from "../context/keymap" import { Keymap } from "../context/keymap"
import { useTheme } from "../context/theme" import { useTheme } from "../context/theme"
import { useDialog, type DialogContext } from "./dialog" import { useDialog } from "./dialog"
export type DialogAlertProps = { export type DialogAlertProps = {
title: string title: string
@@ -56,12 +56,3 @@ export function DialogAlert(props: DialogAlertProps) {
</box> </box>
) )
} }
DialogAlert.show = (dialog: DialogContext, title: string, message: string) => {
return new Promise<void>((resolve) => {
dialog.replace(
() => <DialogAlert title={title} message={message} onConfirm={() => resolve()} />,
() => resolve(),
)
})
}
+1 -20
View File
@@ -1,7 +1,7 @@
import { TextAttributes } from "@opentui/core" import { TextAttributes } from "@opentui/core"
import { Keymap } from "../context/keymap" import { Keymap } from "../context/keymap"
import { useTheme } from "../context/theme" import { useTheme } from "../context/theme"
import { useDialog, type DialogContext } from "./dialog" import { useDialog } from "./dialog"
import { createStore } from "solid-js/store" import { createStore } from "solid-js/store"
import { For } from "solid-js" import { For } from "solid-js"
import { Locale } from "../util/locale" import { Locale } from "../util/locale"
@@ -17,8 +17,6 @@ export type DialogConfirmProps = {
} }
} }
export type DialogConfirmResult = boolean | undefined
export function DialogConfirm(props: DialogConfirmProps) { export function DialogConfirm(props: DialogConfirmProps) {
const dialog = useDialog() const dialog = useDialog()
const theme = useTheme("elevated") const theme = useTheme("elevated")
@@ -93,20 +91,3 @@ export function DialogConfirm(props: DialogConfirmProps) {
</box> </box>
) )
} }
DialogConfirm.show = (dialog: DialogContext, title: string, message: string, label?: DialogConfirmProps["label"]) => {
return new Promise<DialogConfirmResult>((resolve) => {
dialog.replace(
() => (
<DialogConfirm
title={title}
message={message}
onConfirm={() => resolve(true)}
onCancel={() => resolve(false)}
label={label}
/>
),
() => resolve(undefined),
)
})
}
-56
View File
@@ -1,56 +0,0 @@
import type { RGBA } from "@opentui/core"
import { createEffect, createMemo, createSignal, Show } from "solid-js"
import { useConfig } from "../config"
import { tint } from "../theme/color"
import { createAnimatable, tween } from "./animation"
import { FilePath } from "./file-path"
// FilePath that crossfades when its value changes: the old path fades to the
// background, the text swaps at the midpoint, and the new path fades back in.
// The initial value renders immediately; only subsequent changes animate.
export function FadeFilePath(props: {
value: string | undefined
maxWidth: number
fg: RGBA
bg: RGBA
basenameFg?: RGBA
}) {
const config = useConfig().data
const fade = createAnimatable(
{ front: 1 },
{
enabled: () => config.animations ?? true,
transition: tween({ duration: 0.3 }),
},
)
const [text, setText] = createSignal(props.value)
const [previous, setPrevious] = createSignal<string>()
createEffect((current: string | undefined) => {
const next = props.value
if (next === undefined || next === current) return current
setText(next)
if (current === undefined) return next
setPrevious(current)
fade.jump({ front: 0 })
fade.animate({ front: 1 })
return next
}, props.value)
const display = createMemo(() => (previous() !== undefined && fade.value().front < 0.5 ? previous() : text()))
const fg = createMemo(() => {
if (previous() === undefined || fade.value().front >= 1) return props.fg
return tint(props.bg, props.fg, Math.abs(fade.value().front * 2 - 1))
})
return (
<Show when={display() !== undefined}>
<FilePath
value={display() ?? ""}
maxWidth={props.maxWidth}
fg={fg()}
basenameFg={fade.value().front >= 1 ? props.basenameFg : undefined}
/>
</Show>
)
}
-37
View File
@@ -143,40 +143,3 @@ export function errorMessage(error: unknown): string {
if (formatted) return formatted if (formatted) return formatted
return "unknown error" return "unknown error"
} }
export function errorData(error: unknown) {
if (error instanceof Error) {
return {
type: error.name,
message: errorMessage(error),
stack: error.stack,
cause: error.cause === undefined ? undefined : errorFormat(error.cause),
formatted: errorFormat(error),
}
}
if (!isRecord(error)) {
return {
type: typeof error,
message: errorMessage(error),
formatted: errorFormat(error),
}
}
const data = Object.getOwnPropertyNames(error).reduce<Record<string, unknown>>((acc, key) => {
const value = error[key]
if (value === undefined) return acc
if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") {
acc[key] = value
return acc
}
// oxlint-disable-next-line no-base-to-string -- intentional coercion of arbitrary error properties
acc[key] = value instanceof Error ? value.message : String(value)
return acc
}, {})
if (typeof data.message !== "string") data.message = errorMessage(error)
if (typeof data.type !== "string") data.type = error.constructor?.name
data.formatted = errorFormat(error)
return data
}
-20
View File
@@ -1,20 +0,0 @@
export function formatDuration(secs: number) {
if (secs <= 0) return ""
if (secs < 60) return `${secs}s`
if (secs < 3600) {
const mins = Math.floor(secs / 60)
const remaining = secs % 60
return remaining > 0 ? `${mins}m ${remaining}s` : `${mins}m`
}
if (secs < 86400) {
const hours = Math.floor(secs / 3600)
const remaining = Math.floor((secs % 3600) / 60)
return remaining > 0 ? `${hours}h ${remaining}m` : `${hours}h`
}
if (secs < 604800) {
const days = Math.floor(secs / 86400)
return days === 1 ? "~1 day" : `~${days} days`
}
const weeks = Math.floor(secs / 604800)
return weeks === 1 ? "~1 week" : `~${weeks} weeks`
}
-18
View File
@@ -16,19 +16,6 @@ export function datetime(input: number): string {
return `${localTime} · ${localDate}` return `${localTime} · ${localDate}`
} }
export function todayTimeOrDateTime(input: number): string {
const date = new Date(input)
const now = new Date()
const isToday =
date.getFullYear() === now.getFullYear() && date.getMonth() === now.getMonth() && date.getDate() === now.getDate()
if (isToday) {
return time(input)
} else {
return datetime(input)
}
}
export function number(num: number): string { export function number(num: number): string {
if (num >= 1000000) { if (num >= 1000000) {
return (num / 1000000).toFixed(1) + "M" return (num / 1000000).toFixed(1) + "M"
@@ -108,9 +95,4 @@ export function truncateMiddle(str: string, maxLength: number = 35): string {
return str.slice(0, keepStart) + ellipsis + str.slice(-keepEnd) return str.slice(0, keepStart) + ellipsis + str.slice(-keepEnd)
} }
export function pluralize(count: number, singular: string, plural: string): string {
const template = count === 1 ? singular : plural
return template.replace("{}", count.toString())
}
export * as Locale from "./locale" export * as Locale from "./locale"
+1 -4
View File
@@ -1,7 +1,4 @@
const logo = { import { logo } from "../logo"
left: [" ", "█▀▀█ █▀▀█ █▀▀█ █▀▀▄", "█__█ █__█ █^^^ █__█", "▀▀▀▀ █▀▀▀ ▀▀▀▀ ▀~~▀"],
right: [" ▄ ", "█▀▀▀ █▀▀█ █▀▀█ █▀▀█", "█___ █__█ █__█ █^^^", "▀▀▀▀ ▀▀▀▀ ▀▀▀▀ ▀▀▀▀"],
}
const reset = "\x1b[0m" const reset = "\x1b[0m"
const bold = "\x1b[1m" const bold = "\x1b[1m"
-18
View File
@@ -1,18 +0,0 @@
import { parsePatch } from "diff"
export function getRevertDiffFiles(diffText: string) {
if (!diffText) return []
try {
return parsePatch(diffText).map((patch) => {
const filename = [patch.newFileName, patch.oldFileName].find((item) => item && item !== "/dev/null") ?? "unknown"
return {
filename: filename.replace(/^[ab]\//, ""),
additions: patch.hunks.reduce((sum, hunk) => sum + hunk.lines.filter((line) => line.startsWith("+")).length, 0),
deletions: patch.hunks.reduce((sum, hunk) => sum + hunk.lines.filter((line) => line.startsWith("-")).length, 0),
}
})
} catch {
return []
}
}
@@ -1,5 +1,23 @@
import { describe, expect, test } from "bun:test" import { describe, expect, test } from "bun:test"
import { sortModelOptions } from "../../../../src/component/dialog-model" import { prioritizeFavorites, sortModelOptions } from "../../../../src/component/dialog-model"
describe("prioritizeFavorites", () => {
test("moves favorites first while preserving fuzzy result order", () => {
const prioritized = prioritizeFavorites([
{ title: "Best match", favorite: false },
{ title: "Favorite match", favorite: true },
{ title: "Second best match", favorite: false },
{ title: "Second favorite match", favorite: true },
])
expect(prioritized.map((model) => model.title)).toEqual([
"Favorite match",
"Second favorite match",
"Best match",
"Second best match",
])
})
})
describe("sortModelOptions", () => { describe("sortModelOptions", () => {
test("orders opencode models before other providers", () => { test("orders opencode models before other providers", () => {
@@ -2,7 +2,6 @@ import { afterEach, describe, expect, test } from "bun:test"
import { For } from "solid-js" import { For } from "solid-js"
import { testRender, type JSX } from "@opentui/solid" import { testRender, type JSX } from "@opentui/solid"
import { import {
formatSubagentRetry,
InlineToolRow, InlineToolRow,
isBackgroundSubagent, isBackgroundSubagent,
parseApplyPatchFiles, parseApplyPatchFiles,
@@ -198,10 +197,6 @@ describe("TUI inline tool wrapping", () => {
).toEqual([{ message: "valid", range: { start: { line: 2, character: 3 } } }]) ).toEqual([{ message: "valid", range: { start: { line: 2, character: 3 } } }])
}) })
test("keeps retry status ahead of wrapping messages", () => {
expect(formatSubagentRetry(2, "Rate limited by provider")).toBe("Retrying (attempt 2) · Rate limited by provider")
})
test("labels only detached or async subagents as background", () => { test("labels only detached or async subagents as background", () => {
expect(isBackgroundSubagent({ status: "running" }, "running")).toBeFalse() expect(isBackgroundSubagent({ status: "running" }, "running")).toBeFalse()
expect(isBackgroundSubagent({ status: "running" }, "completed")).toBeTrue() expect(isBackgroundSubagent({ status: "running" }, "completed")).toBeTrue()
@@ -80,6 +80,11 @@ describe("session tabs", () => {
expect(closeSessionTab([{ sessionID: "a" }], "a").next).toBeUndefined() expect(closeSessionTab([{ sessionID: "a" }], "a").next).toBeUndefined()
}) })
test("closing an unknown session returns the same tabs reference", () => {
const tabs = [{ sessionID: "a" }, { sessionID: "b" }]
expect(closeSessionTab(tabs, "missing").tabs).toBe(tabs)
})
test("cycles through a filtered tab set in either direction", () => { test("cycles through a filtered tab set in either direction", () => {
const tabs = ["a", "c", "e"].map((sessionID) => ({ sessionID })) const tabs = ["a", "c", "e"].map((sessionID) => ({ sessionID }))
expect(cycleSessionTab(tabs, "c", 1)?.sessionID).toBe("e") expect(cycleSessionTab(tabs, "c", 1)?.sessionID).toBe("e")
@@ -122,6 +127,15 @@ describe("session tabs", () => {
expect(recordSessionTabHistory(history, "b")).toBe(history) expect(recordSessionTabHistory(history, "b")).toBe(history)
}) })
test("drops the oldest history entries beyond the limit", () => {
const sessions = Array.from({ length: 150 }, (_, index) => `session-${index}`)
const history = sessions.reduce(recordSessionTabHistory, { entries: [], index: -1 })
expect(history.entries.length).toBe(100)
expect(history.entries[0]).toBe("session-50")
expect(history.entries[history.index]).toBe("session-149")
})
test("returns to the latest history entry when no tab is active", () => { test("returns to the latest history entry when no tab is active", () => {
const tabs = ["a", "b"].map((sessionID) => ({ sessionID })) const tabs = ["a", "b"].map((sessionID) => ({ sessionID }))
const history = ["a", "b"].reduce(recordSessionTabHistory, { entries: [], index: -1 }) const history = ["a", "b"].reduce(recordSessionTabHistory, { entries: [], index: -1 })
@@ -1,8 +1,8 @@
/** @jsxImportSource @opentui/solid */ /** @jsxImportSource @opentui/solid */
import { expect, test } from "bun:test" import { afterAll, expect, test } from "bun:test"
import type { OpenCodeEvent } from "@opencode-ai/client" import type { OpenCodeEvent } from "@opencode-ai/client"
import { testRender } from "@opentui/solid" import { testRender } from "@opentui/solid"
import { mkdtempSync, rmSync, watch } from "fs" import { mkdtempSync, readdirSync, rmSync, watch } from "fs"
import { tmpdir } from "os" import { tmpdir } from "os"
import path from "path" import path from "path"
import { ConfigProvider } from "../../src/config" import { ConfigProvider } from "../../src/config"
@@ -25,8 +25,32 @@ async function wait(fn: () => boolean | Promise<boolean>, timeout = 2_000) {
} }
} }
// State directories are removed after the whole suite instead of per test: persistence writes are
// fire-and-forget behind a file lock, so a teardown-time removal races any still-queued write.
const stateDirs: string[] = []
afterAll(async () => {
for (const dir of stateDirs) {
// Drain any lock still held by a late write before deleting the tree beneath it.
await wait(() => {
try {
return readdirSync(path.join(dir, "test", "locks")).length === 0
} catch {
return true
}
}).catch(() => undefined)
rmSync(dir, { recursive: true, force: true })
}
})
function stateDir(prefix: string) {
const dir = mkdtempSync(path.join(tmpdir(), prefix))
stateDirs.push(dir)
return dir
}
async function renderSessionTabs(initialSessionID: string, options?: { state?: string; title?: string }) { async function renderSessionTabs(initialSessionID: string, options?: { state?: string; title?: string }) {
const state = options?.state ?? mkdtempSync(path.join(tmpdir(), "opencode-session-tabs-")) const state = options?.state ?? stateDir("opencode-session-tabs-")
const events = createEventStream() const events = createEventStream()
const calls = createFetch((url) => { const calls = createFetch((url) => {
if (url.pathname !== `/api/session/${initialSessionID}`) return if (url.pathname !== `/api/session/${initialSessionID}`) return
@@ -84,7 +108,6 @@ async function renderSessionTabs(initialSessionID: string, options?: { state?: s
emit: (event: OpenCodeEvent) => events.emit({ ...event, location: { directory } }), emit: (event: OpenCodeEvent) => events.emit({ ...event, location: { directory } }),
destroy() { destroy() {
app.renderer.destroy() app.renderer.destroy()
if (!options?.state) rmSync(state, { recursive: true, force: true })
}, },
} }
} }
@@ -105,7 +128,7 @@ test("stores session tabs globally by default", async () => {
}) })
test("concurrent TUIs do not alternate shared tab titles from divergent session caches", async () => { test("concurrent TUIs do not alternate shared tab titles from divergent session caches", async () => {
const state = mkdtempSync(path.join(tmpdir(), "opencode-session-tabs-shared-")) const state = stateDir("opencode-session-tabs-shared-")
let titled: Awaited<ReturnType<typeof renderSessionTabs>> | undefined let titled: Awaited<ReturnType<typeof renderSessionTabs>> | undefined
let untitled: Awaited<ReturnType<typeof renderSessionTabs>> | undefined let untitled: Awaited<ReturnType<typeof renderSessionTabs>> | undefined
@@ -144,7 +167,6 @@ test("concurrent TUIs do not alternate shared tab titles from divergent session
} finally { } finally {
titled?.destroy() titled?.destroy()
untitled?.destroy() untitled?.destroy()
rmSync(state, { recursive: true, force: true })
} }
}) })
@@ -6,7 +6,6 @@ import {
flattenFileTree, flattenFileTree,
moveFileTreeSelection, moveFileTreeSelection,
moveFileTreeSelectionToFirstChild, moveFileTreeSelectionToFirstChild,
moveFileTreeSelectionToFile,
moveFileTreeSelectionToParent, moveFileTreeSelectionToParent,
movePatchFileIndex, movePatchFileIndex,
orderedPatchFileIndexes, orderedPatchFileIndexes,
@@ -219,25 +218,6 @@ describe("diff viewer file tree utilities", () => {
expect(moveFileTreeSelectionToParent(rows, undefined)).toBeUndefined() expect(moveFileTreeSelectionToParent(rows, undefined)).toBeUndefined()
}) })
test("moves file selection relative to the highlighted row", () => {
const rows = flattenFileTree(
buildFileTree([{ file: "src/config/tui.ts" }, { file: "src/session/index.ts" }, { file: "README.md" }]),
)
const config = rows.find((row) => row.kind === "directory" && row.name === "config")!
const session = rows.find((row) => row.kind === "directory" && row.name === "session")!
const tui = rows.find((row) => row.name === "tui.ts")!
const index = rows.find((row) => row.name === "index.ts")!
const readme = rows.find((row) => row.name === "README.md")!
expect(moveFileTreeSelectionToFile(rows, undefined, 1)).toBe(tui.id)
expect(moveFileTreeSelectionToFile(rows, undefined, -1)).toBe(readme.id)
expect(moveFileTreeSelectionToFile(rows, config.id, 1)).toBe(tui.id)
expect(moveFileTreeSelectionToFile(rows, session.id, -1)).toBe(tui.id)
expect(moveFileTreeSelectionToFile(rows, tui.id, 1)).toBe(index.id)
expect(moveFileTreeSelectionToFile(rows, index.id, -1)).toBe(tui.id)
expect(moveFileTreeSelectionToFile(rows, readme.id, 1)).toBe(readme.id)
})
test("selects a file tree node and expands its parents for a patch file", () => { test("selects a file tree node and expands its parents for a patch file", () => {
const tree = buildFileTree([{ file: "src/config/tui.ts" }, { file: "src/session/index.ts" }, { file: "README.md" }]) const tree = buildFileTree([{ file: "src/config/tui.ts" }, { file: "src/session/index.ts" }, { file: "README.md" }])
const selection = fileTreeFileSelection(tree, 1) const selection = fileTreeFileSelection(tree, 1)
@@ -0,0 +1,70 @@
/** @jsxImportSource @opentui/solid */
import { expect, test } from "bun:test"
import { RGBA } from "@opentui/core"
import { testRender } from "@opentui/solid"
import type { Context } from "@opencode-ai/plugin/tui/context"
import { SidebarContext } from "../../src/feature-plugins/sidebar/context"
function context(options?: { cost?: number; tokens?: number }) {
const color = RGBA.fromInts(200, 200, 200)
return {
theme: { text: { default: color, subdued: color } },
data: {
session: {
get: () => ({ location: { directory: "/workspace" } }),
cost: () => options?.cost ?? 0,
message: {
list: () =>
options?.tokens
? [
{
id: "message",
type: "assistant",
model: { providerID: "provider", id: "model" },
tokens: {
input: options.tokens,
output: 0,
reasoning: 0,
cache: { read: 0, write: 0 },
},
},
]
: [],
},
},
location: {
model: { list: () => [] },
},
},
} as unknown as Context
}
test("sidebar omits context before usage is available", async () => {
const app = await testRender(() => <SidebarContext context={context()} sessionID="session" />, {
width: 42,
height: 8,
})
try {
await app.renderOnce()
expect(app.captureCharFrame()).not.toContain("Context")
expect(app.captureCharFrame()).not.toContain("Not measured")
} finally {
app.renderer.destroy()
}
})
test("sidebar shows available context usage", async () => {
const app = await testRender(() => <SidebarContext context={context({ tokens: 1234 })} sessionID="session" />, {
width: 42,
height: 8,
})
try {
await app.renderOnce()
expect(app.captureCharFrame()).toContain("Context")
expect(app.captureCharFrame()).toContain("1,234 tokens")
} finally {
app.renderer.destroy()
}
})
+1 -3
View File
@@ -46,7 +46,7 @@ test("legacy page key aliases compile as page keys", async () => {
test("formats navigation keys as arrows", async () => { test("formats navigation keys as arrows", async () => {
let read = () => ({}) as Record<string, string> let read = () => ({}) as Record<string, string>
const commands = ["session.parent", "session.child.first", "session.child.previous", "session.child.next"] const commands = ["session.parent", "session.child.first"]
function Harness() { function Harness() {
const shortcuts = Keymap.useShortcuts() const shortcuts = Keymap.useShortcuts()
@@ -68,8 +68,6 @@ test("formats navigation keys as arrows", async () => {
expect(read()).toEqual({ expect(read()).toEqual({
"session.parent": "↑", "session.parent": "↑",
"session.child.first": "↓", "session.child.first": "↓",
"session.child.previous": "←",
"session.child.next": "→",
}) })
} finally { } finally {
app.renderer.destroy() app.renderer.destroy()
@@ -3,6 +3,10 @@ import type { FooterApi, FooterEvent, RunPrompt, StreamCommit } from "../../../s
export function createFooterApiFixture(input: { events?: FooterEvent[]; commits?: StreamCommit[] } = {}) { export function createFooterApiFixture(input: { events?: FooterEvent[]; commits?: StreamCommit[] } = {}) {
const prompts = new Set<(input: RunPrompt) => void>() const prompts = new Set<(input: RunPrompt) => void>()
const closes = new Set<() => void>() const closes = new Set<() => void>()
let ready!: () => void
const promptReady = new Promise<void>((resolve) => {
ready = resolve
})
const events = input.events ?? [] const events = input.events ?? []
const commits = input.commits ?? [] const commits = input.commits ?? []
const calls: Array<{ type: "event"; value: FooterEvent } | { type: "commit"; value: StreamCommit }> = [] const calls: Array<{ type: "event"; value: FooterEvent } | { type: "commit"; value: StreamCommit }> = []
@@ -14,6 +18,7 @@ export function createFooterApiFixture(input: { events?: FooterEvent[]; commits?
}, },
onPrompt(fn) { onPrompt(fn) {
prompts.add(fn) prompts.add(fn)
ready()
return () => prompts.delete(fn) return () => prompts.delete(fn)
}, },
onClose(fn) { onClose(fn) {
@@ -50,9 +55,12 @@ export function createFooterApiFixture(input: { events?: FooterEvent[]; commits?
events, events,
commits, commits,
calls, calls,
promptReady,
submit(text: string, mode?: RunPrompt["mode"]) { submit(text: string, mode?: RunPrompt["mode"]) {
if (prompts.size === 0) return false
const prompt: RunPrompt = mode ? { text, parts: [], mode } : { text, parts: [] } const prompt: RunPrompt = mode ? { text, parts: [], mode } : { text, parts: [] }
for (const fn of [...prompts]) fn(prompt) for (const fn of [...prompts]) fn(prompt)
return true
}, },
} }
} }
+20 -5
View File
@@ -55,6 +55,9 @@ describe("run interactive runtime", () => {
const api = ui.api const api = ui.api
const selected = defer<Awaited<ReturnType<typeof sdk.model.default>>>() const selected = defer<Awaited<ReturnType<typeof sdk.model.default>>>()
const catalogLoaded = defer<void>() const catalogLoaded = defer<void>()
const defaultModelReloaded = defer<void>()
const modelShown = defer<void>()
const turnStarted = defer<void>()
const model = catalogModel({ const model = catalogModel({
id: "resolved", id: "resolved",
providerID: "test", providerID: "test",
@@ -69,7 +72,17 @@ describe("run interactive runtime", () => {
providers: [catalogProvider("test", "Test Provider")], providers: [catalogProvider("test", "Test Provider")],
models: [model], models: [model],
}) })
const defaultModel = spyOn(sdk.model, "default").mockImplementation(() => selected.promise) let defaultModelCalls = 0
const defaultModel = spyOn(sdk.model, "default").mockImplementation(() => {
defaultModelCalls++
if (defaultModelCalls === 2) defaultModelReloaded.resolve()
return selected.promise
})
const emit = api.event.bind(api)
api.event = (event) => {
emit(event)
if (event.type === "model") modelShown.resolve()
}
const task = runInteractiveDeferredMode( const task = runInteractiveDeferredMode(
{ {
@@ -110,6 +123,7 @@ describe("run interactive runtime", () => {
runPromptTurn: async (input) => { runPromptTurn: async (input) => {
turnAgent = input.agent turnAgent = input.agent
turnModel = input.model turnModel = input.model
turnStarted.resolve()
api.close() api.close()
}, },
queuePromptTurn: async () => {}, queuePromptTurn: async () => {},
@@ -133,8 +147,8 @@ describe("run interactive runtime", () => {
location: { directory: "/tmp", project: { id: "pro-1", directory: "/tmp", canonical: "/tmp" } }, location: { directory: "/tmp", project: { id: "pro-1", directory: "/tmp", canonical: "/tmp" } },
data: model, data: model,
}) })
while (defaultModel.mock.calls.length < 2) await Bun.sleep(0) await defaultModelReloaded.promise
while (!events.some((event) => event.type === "model")) await Bun.sleep(0) await modelShown.promise
expect(events).toContainEqual({ expect(events).toContainEqual({
type: "model", type: "model",
model: "Resolved Model · Test Provider", model: "Resolved Model · Test Provider",
@@ -142,8 +156,9 @@ describe("run interactive runtime", () => {
}) })
expect(lifecycle.onCycleVariant?.()).toMatchObject({ status: "variant low", variant: "low" }) expect(lifecycle.onCycleVariant?.()).toMatchObject({ status: "variant low", variant: "low" })
lifecycle.onAgentSelect?.("review") lifecycle.onAgentSelect?.("review")
ui.submit("hello") await ui.promptReady
while (!turnModel) await Bun.sleep(0) expect(ui.submit("hello")).toBe(true)
await turnStarted.promise
expect(turnAgent).toBe("review") expect(turnAgent).toBe("review")
expect(turnModel).toEqual({ providerID: "test", modelID: "resolved" }) expect(turnModel).toEqual({ providerID: "test", modelID: "resolved" })
await task await task
+1 -14
View File
@@ -1,25 +1,16 @@
import { describe, expect, test } from "bun:test" import { describe, expect, test } from "bun:test"
import { errorData, errorFormat, errorMessage } from "../../src/util/error" import { errorFormat, errorMessage } from "../../src/util/error"
describe("util.error", () => { describe("util.error", () => {
test("formats native Error instances", () => { test("formats native Error instances", () => {
const err = new Error("boom") const err = new Error("boom")
expect(errorMessage(err)).toBe("boom") expect(errorMessage(err)).toBe("boom")
expect(errorFormat(err)).toContain("boom") expect(errorFormat(err)).toContain("boom")
const data = errorData(err)
expect(data.type).toBe("Error")
expect(data.message).toBe("boom")
expect(String(data.formatted)).toContain("boom")
}) })
test("extracts message from record-like values", () => { test("extracts message from record-like values", () => {
const err = { message: "bad input", code: "E_BAD" } const err = { message: "bad input", code: "E_BAD" }
expect(errorMessage(err)).toBe("bad input") expect(errorMessage(err)).toBe("bad input")
const data = errorData(err)
expect(data.message).toBe("bad input")
expect(data.code).toBe("E_BAD")
}) })
test("never returns bare {} for opaque object errors", () => { test("never returns bare {} for opaque object errors", () => {
@@ -41,9 +32,5 @@ describe("util.error", () => {
} }
expect(errorMessage(err)).toBe("ResolveMessage: Cannot resolve module") expect(errorMessage(err)).toBe("ResolveMessage: Cannot resolve module")
const data = errorData(err)
expect(data.message).toBe("ResolveMessage: Cannot resolve module")
expect(String(data.formatted)).toContain("ResolveMessage")
}) })
}) })
-59
View File
@@ -1,59 +0,0 @@
import { describe, expect, test } from "bun:test"
import { formatDuration } from "../../src/util/format"
describe("util.format", () => {
describe("formatDuration", () => {
test("returns empty string for zero or negative values", () => {
expect(formatDuration(0)).toBe("")
expect(formatDuration(-1)).toBe("")
expect(formatDuration(-100)).toBe("")
})
test("formats seconds under a minute", () => {
expect(formatDuration(1)).toBe("1s")
expect(formatDuration(30)).toBe("30s")
expect(formatDuration(59)).toBe("59s")
})
test("formats minutes under an hour", () => {
expect(formatDuration(60)).toBe("1m")
expect(formatDuration(61)).toBe("1m 1s")
expect(formatDuration(90)).toBe("1m 30s")
expect(formatDuration(120)).toBe("2m")
expect(formatDuration(330)).toBe("5m 30s")
expect(formatDuration(3599)).toBe("59m 59s")
})
test("formats hours under a day", () => {
expect(formatDuration(3600)).toBe("1h")
expect(formatDuration(3660)).toBe("1h 1m")
expect(formatDuration(7200)).toBe("2h")
expect(formatDuration(8100)).toBe("2h 15m")
expect(formatDuration(86399)).toBe("23h 59m")
})
test("formats days under a week", () => {
expect(formatDuration(86400)).toBe("~1 day")
expect(formatDuration(172800)).toBe("~2 days")
expect(formatDuration(259200)).toBe("~3 days")
expect(formatDuration(604799)).toBe("~6 days")
})
test("formats weeks", () => {
expect(formatDuration(604800)).toBe("~1 week")
expect(formatDuration(1209600)).toBe("~2 weeks")
expect(formatDuration(1609200)).toBe("~2 weeks")
})
test("handles boundary values correctly", () => {
expect(formatDuration(59)).toBe("59s")
expect(formatDuration(60)).toBe("1m")
expect(formatDuration(3599)).toBe("59m 59s")
expect(formatDuration(3600)).toBe("1h")
expect(formatDuration(86399)).toBe("23h 59m")
expect(formatDuration(86400)).toBe("~1 day")
expect(formatDuration(604799)).toBe("~6 days")
expect(formatDuration(604800)).toBe("~1 week")
})
})
})
@@ -1,35 +0,0 @@
import { describe, expect, test } from "bun:test"
import { getRevertDiffFiles } from "../../src/util/revert-diff"
describe("revert diff", () => {
test("prefers the actual file path over /dev/null for added and deleted files", () => {
const files = getRevertDiffFiles(`diff --git a/new.txt b/new.txt
new file mode 100644
index 0000000..3b18e51
--- /dev/null
+++ b/new.txt
@@ -0,0 +1 @@
+new content
diff --git a/old.txt b/old.txt
deleted file mode 100644
index 3b18e51..0000000
--- a/old.txt
+++ /dev/null
@@ -1 +0,0 @@
-old content
`)
expect(files).toEqual([
{
filename: "new.txt",
additions: 1,
deletions: 0,
},
{
filename: "old.txt",
additions: 0,
deletions: 1,
},
])
})
})
+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("sdk", callback)` | `sdk`, after inspecting `model`, `package`, and `options` |
| `ctx.aisdk.hook("language", callback)` | `language`, after inspecting `model`, `sdk`, 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.before", callback)` | `input`, before the selected tool executes |
| `ctx.tool.hook("execute.after", callback)` | Terminal `result` on success or `error` on failure | | `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({ export default Plugin.define({
id: "acme.guards", id: "acme.guards",
setup: async (ctx) => { setup: async (ctx) => {
await ctx.session.hook("request", (event) => { await ctx.session.hook("context", (event) => {
delete event.tools.write delete event.tools.write
}) })