Compare commits

..
39 Commits
Author SHA1 Message Date
Kit LangtonandGitHub dc3c996892 fix(tui): stabilize generated session titles (#39894) 2026-07-31 11:13:25 -04:00
Kit LangtonandGitHub 7fd12c560c fix(tui): fit project paths in open menu (#39887) 2026-07-31 11:07:12 -04:00
Kit LangtonandGitHub 7aaf4e7750 refactor(session): centralize fallback title policy (#39890) 2026-07-31 10:56:38 -04:00
Kit LangtonandGitHub 1311d909a7 fix(tui): preserve session location during handoff (#39886) 2026-07-31 10:54:36 -04:00
Kit LangtonandGitHub d07d9ae0da fix(tui): remove shells from their location (#39885) 2026-07-31 10:46:43 -04:00
Kit LangtonandGitHub 1d18459cdc fix(tui): align session picker scope (#39784) 2026-07-31 10:32:16 -04:00
Kit LangtonandGitHub db45026c6c fix(tui): default tabs to global scope (#39783) 2026-07-31 10:15:53 -04:00
Kit Langton 77df98db51 Revert "feat(tui): delete current session (#39750)"
This reverts commit 7814568ba0.
2026-07-31 10:01:35 -04:00
Aiden ClineandGitHub ff2b184af1 feat(ai): support Gemini thinking levels (#39796) 2026-07-30 23:38:00 -05:00
Aiden ClineandGitHub 671e164f8d refactor(core): contain Codex in OpenAI plugin (#39734) 2026-07-30 21:43:38 -05:00
Aiden ClineandGitHub 5b4ebf3e9d fix(core): map xAI native options (#39787) 2026-07-30 21:42:53 -05:00
Aiden ClineandGitHub 98229d466d feat(plugin): add session request hook (#39764) 2026-07-30 21:11:22 -05:00
Kit LangtonandGitHub 5c7e2b5042 fix(tui): clarify open menu project labels (#39780) 2026-07-30 21:34:29 -04:00
Kit LangtonandGitHub 0a6a5d3e80 fix(session): retry failed title generation (#39748) 2026-07-30 21:26:44 -04:00
Kit LangtonandGitHub eb95bd27fe feat(session): make generated titles optional (#39747) 2026-07-30 21:24:34 -04:00
Kit LangtonandGitHub 865f512a44 fix(tui): preserve current selection across list updates (#39774) 2026-07-31 00:48:51 +00:00
Kit LangtonandGitHub a460f02f67 feat(tui): inherit session directory when creating a new session (#39753) 2026-07-30 20:31:38 -04:00
Kit LangtonandGitHub 146fdb9de1 feat(tui): add open menu for sessions and projects (#39752) 2026-07-30 20:08:12 -04:00
Kit LangtonandGitHub b866900417 fix(tui): name deleted session in toast (#39768) 2026-07-30 19:59:27 -04:00
Kit LangtonandGitHub 8e3e94aa26 fix(tui): focus palette settings after layout (#39585) 2026-07-30 19:39:16 -04:00
Aiden ClineandGitHub cc1289048e refactor(core): isolate AI SDK native mappings (#39761) 2026-07-30 18:38:29 -05:00
Kit LangtonandGitHub 7814568ba0 feat(tui): delete current session (#39750) 2026-07-30 17:06:41 -04:00
Kit LangtonandGitHub 16b247f756 fix(tui): smooth new session tab handoff (#39745) 2026-07-30 16:17:01 -04:00
Kit LangtonandGitHub 9abd9594de fix(simulation): mirror kitty keyboard protocol (#39741) 2026-07-30 20:03:21 +00:00
Kit LangtonandGitHub 22d2012f75 fix(tui): register storybook only for story runs (#39733) 2026-07-30 15:55:05 -04:00
Kit LangtonandGitHub cc883058df feat(tui): reopen closed session tabs (#39731) 2026-07-30 15:54:43 -04:00
Kit LangtonandGitHub 8ebc4c6f85 fix(tui): call session tabs just tabs (#39730) 2026-07-30 15:52:58 -04:00
Kit LangtonandGitHub b0ed9990b3 feat(tui): add option tab shortcuts (#39725) 2026-07-30 15:51:10 -04:00
Kit LangtonandGitHub e1c04dcce6 feat(tui): add temporary new session tab (#39735) 2026-07-30 15:05:34 -04:00
Kit LangtonandGitHub 188c642d8c refactor(tui): flatten project picker list (#39726) 2026-07-30 14:18:16 -04:00
Kit LangtonandGitHub 9d55b223bb fix(core): stop repository discovery at nearest marker (#39714) 2026-07-30 13:28:29 -04:00
Kit LangtonandGitHub ce7a7e4e23 fix(tui): truncate project picker paths (#39678) 2026-07-30 13:13:31 -04:00
Kit LangtonandGitHub cba5ba03e3 fix(tui): show shells from session location (#39691) 2026-07-30 13:12:01 -04:00
Kit LangtonandGitHub 49081a4e24 fix(tui): flash tabs on prompt admission (#39702) 2026-07-30 16:57:36 +00:00
d6371f2fcd fix(session): update activity on prompt (#39539)
Co-authored-by: James Long <17031+jlongster@users.noreply.github.com>
2026-07-30 16:28:10 +00:00
Aiden ClineandGitHub 7625cbdf47 feat(core): route providers through native AI (#39615) 2026-07-30 11:26:18 -05:00
0ffec67ca3 refactor: remove unused V2 code (#39699)
Co-authored-by: Aiden Cline <rekram1-node@users.noreply.github.com>
2026-07-30 11:25:03 -05:00
Kit LangtonandGitHub bd132f2614 refactor(core): skip redundant worktree lookup (#39692) 2026-07-30 16:15:07 +00:00
dcb0df4ac1 fix(core): stop MCP SSE error reconnect loops (#39671)
Co-authored-by: Aiden Cline <rekram1-node@users.noreply.github.com>
2026-07-30 10:53:33 -05:00
123 changed files with 2546 additions and 2048 deletions
+2
View File
@@ -67,6 +67,7 @@
"@opencode-ai/sdk": "file:vendor/opencode-ai-sdk-1.18.8-dev.tgz",
"@opencode-ai/session-ui": "workspace:*",
"@opencode-ai/ui": "workspace:*",
"@opencode-ai/util": "workspace:*",
"@pierre/trees": "1.0.0-beta.4",
"@sentry/solid": "catalog:",
"@shikijs/transformers": "3.9.2",
@@ -520,6 +521,7 @@
"@opencode-ai/core": "workspace:*",
"@opencode-ai/session-ui": "workspace:*",
"@opencode-ai/ui": "workspace:*",
"@opencode-ai/util": "workspace:*",
"@pierre/diffs": "catalog:",
"@solidjs/meta": "catalog:",
"@solidjs/router": "catalog:",
+2
View File
@@ -12,6 +12,8 @@
Per-type constructors live on the type, not as top-level re-exports. Use `Message.system(...)`, `Message.user(...)`, `Message.assistant(...)`, `Message.tool(...)`, `Model.make(...)`, `ToolDefinition.make(...)`, `ToolCallPart.make(...)`, `ToolResultPart.make(...)`, `ToolChoice.make(...)`, `ToolChoice.named(...)`, `SystemPart.make(...)`, and `GenerationOptions.make(...)` directly. The top-level `LLM` namespace is reserved for request-shaped call APIs: `LLM.request`, `LLM.generate`, `LLM.stream`, `LLM.updateRequest`, and `LLM.generateObject`. Two ways to construct the same thing is one too many.
- Keep provider-defined string enums forward-compatible. Expose known values for autocomplete while accepting future values with `Known | (string & {})`; use `Schema.String` at runtime unless rejecting unknown values is required for correctness.
## Tests
- Use `testEffect(...)` from `test/lib/effect.ts` for tests requiring Effect layers.
+58 -4
View File
@@ -29,9 +29,30 @@ export const DEFAULT_BASE_URL = "https://generativelanguage.googleapis.com/v1bet
export interface OptionsInput {
readonly [key: string]: unknown
readonly cachedContent?: string
readonly safetySettings?: ReadonlyArray<{
readonly category:
| "HARM_CATEGORY_UNSPECIFIED"
| "HARM_CATEGORY_HATE_SPEECH"
| "HARM_CATEGORY_DANGEROUS_CONTENT"
| "HARM_CATEGORY_HARASSMENT"
| "HARM_CATEGORY_SEXUALLY_EXPLICIT"
| "HARM_CATEGORY_CIVIC_INTEGRITY"
| (string & {})
readonly threshold:
| "HARM_BLOCK_THRESHOLD_UNSPECIFIED"
| "BLOCK_LOW_AND_ABOVE"
| "BLOCK_MEDIUM_AND_ABOVE"
| "BLOCK_ONLY_HIGH"
| "BLOCK_NONE"
| "OFF"
| (string & {})
}>
readonly serviceTier?: "standard" | "flex" | "priority" | (string & {})
readonly thinkingConfig?: {
readonly thinkingBudget?: number
readonly includeThoughts?: boolean
readonly thinkingLevel?: "minimal" | "low" | "medium" | "high" | (string & {})
}
}
@@ -111,6 +132,12 @@ const GeminiToolConfig = Schema.Struct({
const GeminiThinkingConfig = Schema.Struct({
thinkingBudget: Schema.optional(Schema.Number),
includeThoughts: Schema.optional(Schema.Boolean),
thinkingLevel: Schema.optional(Schema.String),
})
const GeminiSafetySetting = Schema.Struct({
category: Schema.String,
threshold: Schema.String,
})
const GeminiGenerationConfig = Schema.Struct({
@@ -123,7 +150,10 @@ const GeminiGenerationConfig = Schema.Struct({
})
const GeminiBodyFields = {
cachedContent: Schema.optional(Schema.String),
contents: Schema.Array(GeminiContent),
safetySettings: optionalArray(GeminiSafetySetting),
serviceTier: Schema.optional(Schema.String),
systemInstruction: Schema.optional(GeminiSystemInstruction),
tools: optionalArray(GeminiTool),
toolConfig: Schema.optional(GeminiToolConfig),
@@ -316,17 +346,38 @@ const lowerMessages = Effect.fn("Gemini.lowerMessages")(function* (request: LLMR
})
const resolveOptions = (request: LLMRequest) => {
const value = request.providerOptions?.gemini?.thinkingConfig
if (!ProviderShared.isRecord(value)) return {}
const input = request.providerOptions?.gemini
const value = input?.thinkingConfig
const thinkingConfig = {
thinkingBudget: typeof value.thinkingBudget === "number" ? value.thinkingBudget : undefined,
includeThoughts: typeof value.includeThoughts === "boolean" ? value.includeThoughts : undefined,
thinkingBudget:
ProviderShared.isRecord(value) && typeof value.thinkingBudget === "number" ? value.thinkingBudget : undefined,
includeThoughts:
ProviderShared.isRecord(value) && typeof value.includeThoughts === "boolean"
? value.includeThoughts
: ProviderShared.isRecord(value)
? true
: undefined,
thinkingLevel:
ProviderShared.isRecord(value) && typeof value.thinkingLevel === "string" ? value.thinkingLevel : undefined,
}
return {
cachedContent: typeof input?.cachedContent === "string" ? input.cachedContent : undefined,
safetySettings: mapSafetySettings(input?.safetySettings),
serviceTier: typeof input?.serviceTier === "string" ? input.serviceTier : undefined,
thinkingConfig: Object.values(thinkingConfig).some((item) => item !== undefined) ? thinkingConfig : undefined,
}
}
function mapSafetySettings(value: unknown) {
if (!Array.isArray(value)) return undefined
const settings = value.flatMap((item) =>
ProviderShared.isRecord(item) && typeof item.category === "string" && typeof item.threshold === "string"
? [{ category: item.category, threshold: item.threshold }]
: [],
)
return settings
}
const fromRequest = Effect.fn("Gemini.fromRequest")(function* (request: LLMRequest) {
const hasTools = request.tools.length > 0
const generation = request.generation
@@ -342,7 +393,10 @@ const fromRequest = Effect.fn("Gemini.fromRequest")(function* (request: LLMReque
}
return {
cachedContent: options.cachedContent,
contents: yield* lowerMessages(request),
safetySettings: options.safetySettings,
serviceTier: options.serviceTier,
systemInstruction:
request.system.length === 0 ? undefined : { parts: [{ text: ProviderShared.joinText(request.system) }] },
tools: hasTools
+1
View File
@@ -1,6 +1,7 @@
import type { Model, ProviderOptions } from "./schema"
export interface Settings extends Readonly<Record<string, unknown>> {
readonly baseURL?: string
readonly headers?: Readonly<Record<string, string>>
readonly body?: Readonly<Record<string, unknown>>
readonly limits?: {
+36 -13
View File
@@ -1,38 +1,62 @@
import { AuthOptions, type ProviderAuthOption } from "../route/auth-options"
import type { RouteDefaultsInput } from "../route/client"
import { HttpOptions, ProviderID, type ModelID } from "../schema"
import { Route, type RouteDefaultsInput } from "../route/client"
import { Endpoint } from "../route/endpoint"
import { HttpOptions, ProviderID, type ModelID, type ProviderOptions } from "../schema"
import * as OpenAICompatibleProfiles from "./openai-compatible-profile"
import * as OpenAICompatibleChat from "../protocols/openai-compatible-chat"
import * as OpenAIChat from "../protocols/openai-chat"
import * as OpenAIResponses from "../protocols/openai-responses"
import { XAIImages } from "../protocols/xai-images"
import type { OpenAIProviderOptionsInput } from "./openai-options"
import type { OpenAIOptionsInput } from "./openai-options"
import type { ProviderPackage } from "../provider-package"
export const id = ProviderID.make("xai")
export type XAIProviderOptionsInput = ProviderOptions & {
readonly xai?: OpenAIOptionsInput
}
export type ModelOptions = Omit<RouteDefaultsInput, "providerOptions"> &
ProviderAuthOption<"optional"> & {
readonly baseURL?: string
readonly providerOptions?: OpenAIProviderOptionsInput
readonly providerOptions?: XAIProviderOptionsInput
}
export interface Settings extends ProviderPackage.Settings {
readonly apiKey?: string
readonly baseURL?: string
readonly providerOptions?: OpenAIProviderOptionsInput
readonly providerOptions?: XAIProviderOptionsInput
}
export type { XAIImageOptions } from "../protocols/xai-images"
export const routes = [OpenAIResponses.route, OpenAICompatibleChat.route]
const responsesRoute = Route.make({
id: "openai-responses",
provider: id,
providerMetadataKey: "xai",
protocol: OpenAIResponses.protocol,
endpoint: Endpoint.path("/responses", { baseURL: OpenAICompatibleProfiles.profiles.xai.baseURL }),
transport: OpenAIResponses.httpTransport,
defaults: { providerOptions: { xai: { store: false } } },
})
const chatRoute = Route.make({
id: "openai-compatible-chat",
provider: id,
providerMetadataKey: "xai",
protocol: OpenAIChat.protocol,
endpoint: Endpoint.path("/chat/completions", { baseURL: OpenAICompatibleProfiles.profiles.xai.baseURL }),
transport: OpenAICompatibleChat.route.transport,
})
export const routes = [responsesRoute, chatRoute]
const auth = (options: ProviderAuthOption<"optional">) => AuthOptions.bearer(options, "XAI_API_KEY")
const configuredResponsesRoute = (input: ModelOptions) => {
const { apiKey: _, auth: _auth, baseURL, ...rest } = input
return OpenAIResponses.route.with({
return responsesRoute.with({
...rest,
provider: id,
endpoint: { baseURL: baseURL ?? OpenAICompatibleProfiles.profiles.xai.baseURL },
auth: auth(input),
})
@@ -40,9 +64,8 @@ const configuredResponsesRoute = (input: ModelOptions) => {
const configuredChatRoute = (input: ModelOptions) => {
const { apiKey: _, auth: _auth, baseURL, ...rest } = input
return OpenAICompatibleChat.route.with({
return chatRoute.with({
...rest,
provider: id,
endpoint: { baseURL: baseURL ?? OpenAICompatibleProfiles.profiles.xai.baseURL },
auth: auth(input),
})
@@ -51,8 +74,8 @@ const configuredChatRoute = (input: ModelOptions) => {
export const configure = (input: ModelOptions = {}) => {
const responsesRoute = configuredResponsesRoute(input)
const chatRoute = configuredChatRoute(input)
const responses = (modelID: string | ModelID) => responsesRoute.model<OpenAIProviderOptionsInput>({ id: modelID })
const chat = (modelID: string | ModelID) => chatRoute.model<OpenAIProviderOptionsInput>({ id: modelID })
const responses = (modelID: string | ModelID) => responsesRoute.model<XAIProviderOptionsInput>({ id: modelID })
const chat = (modelID: string | ModelID) => chatRoute.model<XAIProviderOptionsInput>({ id: modelID })
const image = (modelID: string | ModelID) =>
XAIImages.model({
id: modelID,
@@ -72,7 +95,7 @@ export const configure = (input: ModelOptions = {}) => {
}
export const provider = configure()
export const model: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (modelID, settings) =>
export const model: ProviderPackage.Definition<Settings, XAIProviderOptionsInput>["model"] = (modelID, settings) =>
configure({
apiKey: settings.apiKey,
baseURL: settings.baseURL,
+26 -17
View File
@@ -5,7 +5,7 @@ import { Endpoint, type EndpointPatch } from "./endpoint"
import { RequestExecutor } from "./executor"
import { Framing } from "./framing"
import { HttpTransport } from "./transport"
import type { Transport, TransportRuntime } from "./transport"
import type { HttpRequestTransform, Transport, TransportRuntime } from "./transport"
import { WebSocketExecutor } from "./transport"
import type { Protocol } from "./protocol"
import { applyCachePolicy } from "../cache-policy"
@@ -46,7 +46,11 @@ export interface Route<Body, Prepared = unknown> {
readonly body: RouteBody<Body>
readonly with: (patch: RoutePatch<Body, Prepared>) => Route<Body, Prepared>
readonly model: <Options extends ProviderOptions = ProviderOptions>(input: RouteMappedModelInput) => Model<Options>
readonly prepareTransport: (body: Body, request: LLMRequest) => Effect.Effect<Prepared, LLMError>
readonly prepareTransport: (
body: Body,
request: LLMRequest,
options?: StreamOptions,
) => Effect.Effect<Prepared, LLMError>
readonly streamPrepared: (
prepared: Prepared,
request: LLMRequest,
@@ -145,12 +149,16 @@ export interface Interface {
readonly generate: GenerateMethod
}
export interface StreamOptions {
readonly transform?: HttpRequestTransform
}
export interface StreamMethod {
(request: LLMRequest): Stream.Stream<LLMEvent, LLMError>
(request: LLMRequest, options?: StreamOptions): Stream.Stream<LLMEvent, LLMError>
}
export interface GenerateMethod {
(request: LLMRequest): Effect.Effect<LLMResponse, LLMError>
(request: LLMRequest, options?: StreamOptions): Effect.Effect<LLMResponse, LLMError>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/LLMClient") {}
@@ -286,7 +294,7 @@ function makeFromTransport<Body, Prepared, Frame, Event, State>(
},
model: <Options extends ProviderOptions = ProviderOptions>(input: RouteMappedModelInput) =>
makeRouteModel<Options>(route, input),
prepareTransport: (body, request) =>
prepareTransport: (body, request, options) =>
routeInput.transport.prepare({
body,
request,
@@ -294,6 +302,7 @@ function makeFromTransport<Body, Prepared, Frame, Event, State>(
auth: routeInput.auth ?? Auth.none,
encodeBody,
headers: routeInput.headers,
transform: options?.transform,
}),
streamPrepared: (prepared: Prepared, request: LLMRequest, runtime: TransportRuntime) => {
const route = `${request.model.provider}/${request.model.route.id}`
@@ -359,14 +368,14 @@ export function make<Body, Prepared, Frame, Event, State>(
})
}
const compile = Effect.fn("LLM.compile")(function* (request: LLMRequest) {
const compile = Effect.fn("LLM.compile")(function* (request: LLMRequest, options?: StreamOptions) {
const resolved = applyCachePolicy(resolveRequestOptions(request))
const route = resolved.model.route
const body = yield* route.body
.from(resolved)
.pipe(Effect.flatMap(ProviderShared.validateWith(Schema.decodeUnknownEffect(route.body.schema))))
const prepared = yield* route.prepareTransport(body, resolved)
const prepared = yield* route.prepareTransport(body, resolved, options)
return {
request: resolved,
@@ -389,17 +398,17 @@ export const compileRequest = Effect.fn("LLM.compileRequest")(function* (request
}
})
const streamRequestWith = (runtime: TransportRuntime) => (request: LLMRequest) =>
const streamRequestWith = (runtime: TransportRuntime) => (request: LLMRequest, options?: StreamOptions) =>
Stream.unwrap(
Effect.gen(function* () {
const compiled = yield* compile(request)
const compiled = yield* compile(request, options)
return compiled.route.streamPrepared(compiled.prepared, compiled.request, runtime)
}),
)
const generateWith = (stream: Interface["stream"]) =>
Effect.fn("LLM.generate")(function* (request: LLMRequest) {
const state = yield* stream(request).pipe(Stream.runFold(LLMResponse.empty, LLMResponse.reduce))
Effect.fn("LLM.generate")(function* (request: LLMRequest, options?: StreamOptions) {
const state = yield* stream(request, options).pipe(Stream.runFold(LLMResponse.empty, LLMResponse.reduce))
const response = LLMResponse.complete(state)
if (response) return response
return yield* ProviderShared.eventError(
@@ -408,24 +417,24 @@ const generateWith = (stream: Interface["stream"]) =>
)
})
export function stream(request: LLMRequest): Stream.Stream<LLMEvent, LLMError> {
export function stream(request: LLMRequest, options?: StreamOptions): Stream.Stream<LLMEvent, LLMError> {
return Stream.unwrap(
Effect.gen(function* () {
return (yield* Service).stream(request)
return (yield* Service).stream(request, options)
}),
) as Stream.Stream<LLMEvent, LLMError>
}
export function generate(request: LLMRequest): Effect.Effect<LLMResponse, LLMError> {
export function generate(request: LLMRequest, options?: StreamOptions): Effect.Effect<LLMResponse, LLMError> {
return Effect.gen(function* () {
return yield* (yield* Service).generate(request)
return yield* (yield* Service).generate(request, options)
}) as Effect.Effect<LLMResponse, LLMError>
}
export const streamRequest = (request: LLMRequest) =>
export const streamRequest = (request: LLMRequest, options?: StreamOptions) =>
Stream.unwrap(
Effect.gen(function* () {
return (yield* Service).stream(request)
return (yield* Service).stream(request, options)
}),
)
+2 -1
View File
@@ -8,6 +8,7 @@ export type {
AnyRoute,
Interface as LLMClientShape,
Service as LLMClientService,
StreamOptions,
} from "./client"
export * from "./executor"
export { Auth } from "./auth"
@@ -22,4 +23,4 @@ export type { ApiKeyMode, AuthOverride, ProviderAuthOption } from "./auth-option
export type { Definition as EndpointFn, EndpointInput } from "./endpoint"
export type { Definition as FramingDef } from "./framing"
export type { Protocol as ProtocolDef } from "./protocol"
export type { Transport as TransportDef, TransportRuntime } from "./transport"
export type { HttpRequest, HttpRequestTransform, Transport as TransportDef, TransportRuntime } from "./transport"
+12 -7
View File
@@ -120,14 +120,19 @@ export const httpJson = <Body, Frame>(input: HttpJsonInput<Body, Frame>): HttpJs
id: "http-json",
with: (patch) => httpJson({ ...input, ...patch }),
prepare: (prepareInput) =>
jsonRequestParts({
...prepareInput,
}).pipe(
Effect.map((parts) => ({
request: ProviderShared.jsonPost({ url: parts.url, body: parts.bodyText, headers: parts.headers }),
Effect.gen(function* () {
const parts = yield* jsonRequestParts({ ...prepareInput })
const request = { url: parts.url, method: "POST", headers: { ...parts.headers }, body: parts.bodyText }
yield* (prepareInput.transform?.(request) ?? Effect.void)
return {
request: ProviderShared.jsonPost({
url: request.url,
body: request.body ?? "",
headers: Headers.fromInput(request.headers),
}),
framing: input.framing,
})),
),
}
}),
frames: (prepared, request, runtime) =>
Stream.unwrap(
runtime.http
+10
View File
@@ -10,6 +10,15 @@ export interface TransportRuntime {
readonly webSocket?: WebSocketExecutorInterface
}
export interface HttpRequest {
url: string
readonly method: string
headers: Record<string, string>
body: string | undefined
}
export type HttpRequestTransform = (request: HttpRequest) => Effect.Effect<void>
export interface Transport<Body, Prepared, Frame> {
readonly id: string
readonly prepare: (input: TransportPrepareInput<Body>) => Effect.Effect<Prepared, LLMError>
@@ -27,6 +36,7 @@ export interface TransportPrepareInput<Body> {
readonly auth: Auth.Definition
readonly encodeBody: (body: Body) => string
readonly headers?: (input: { readonly request: LLMRequest }) => Record<string, string>
readonly transform?: HttpRequestTransform
}
export * as HttpTransport from "./http"
+34
View File
@@ -137,6 +137,40 @@ describe("request option precedence", () => {
),
)
it.effect("transforms the final HTTP request after serialization and authentication", () =>
LLMClient.generate(
LLM.request({
model: OpenAIChat.route
.with({ endpoint: { baseURL: "https://api.openai.test/v1/" }, auth: Auth.bearer("fresh-key") })
.model({ id: "gpt-4o-mini" }),
prompt: "Say hello.",
}),
{
transform: (request) =>
Effect.sync(() => {
expect(request.headers.authorization).toBe("Bearer fresh-key")
request.url = "https://proxy.test/v1/chat/completions"
request.headers["x-plugin"] = "transformed"
request.body = JSON.stringify({ transformed: true })
}),
},
).pipe(
Effect.provide(
dynamicResponse((input) =>
Effect.gen(function* () {
const web = yield* HttpClientRequest.toWeb(input.request).pipe(Effect.orDie)
expect(web.url).toBe("https://proxy.test/v1/chat/completions")
expect(web.headers.get("x-plugin")).toBe("transformed")
expect(decodeJson(input.text)).toEqual({ transformed: true })
return input.respond(sseEvents(deltaChunk({}, "stop")), {
headers: { "content-type": "text/event-stream" },
})
}),
),
),
),
)
it.effect("rejects raw body overlays for protocol-owned roots", () =>
Effect.gen(function* () {
const model = OpenAIChat.route
@@ -9,9 +9,39 @@ LLM.request({
providerOptions: { gemini: { thinkingConfig: { thinkingBudget: 1024 } } },
})
LLM.request({
model,
prompt: "Hello",
providerOptions: {
gemini: {
// @ts-expect-error Gemini safety settings require a threshold for every category.
safetySettings: [{ category: "HARM_CATEGORY_HATE_SPEECH" }],
},
},
})
LLM.request({
model,
prompt: "Hello",
providerOptions: {
gemini: {
cachedContent: "cachedContents/example",
safetySettings: [{ category: "HARM_CATEGORY_HATE_SPEECH", threshold: "BLOCK_ONLY_HIGH" }],
serviceTier: "future-tier",
thinkingConfig: { thinkingLevel: "high", includeThoughts: true },
},
},
})
LLM.request({
model,
prompt: "Hello",
// @ts-expect-error Gemini thinking budgets must be numeric.
providerOptions: { gemini: { thinkingConfig: { thinkingBudget: "large" } } },
})
LLM.request({
model,
prompt: "Hello",
providerOptions: { gemini: { thinkingConfig: { thinkingLevel: "maximum" } } },
})
@@ -3,11 +3,11 @@ import { XAI } from "../../src/providers"
const model = XAI.provider.model("grok-4")
LLM.request({ model, prompt: "Hello", providerOptions: { openai: { reasoningEffort: "high" } } })
LLM.request({ model, prompt: "Hello", providerOptions: { xai: { reasoningEffort: "high" } } })
LLM.request({
model,
prompt: "Hello",
// @ts-expect-error xAI's OpenAI-compatible reasoning effort must be a string.
providerOptions: { openai: { reasoningEffort: true } },
providerOptions: { xai: { reasoningEffort: true } },
})
+2 -2
View File
@@ -47,7 +47,7 @@ describe("provider package entrypoints", () => {
})
const xai = XAI.model("grok-4", {
...settings,
providerOptions: { openai: { reasoningEffort: "high" } },
providerOptions: { xai: { reasoningEffort: "high" } },
})
for (const selected of [openrouter, xai]) {
@@ -57,7 +57,7 @@ describe("provider package entrypoints", () => {
expect(selected.route.defaults.limits).toEqual(settings.limits)
}
expect(openrouter.route.defaults.providerOptions).toEqual({ openrouter: { usage: true } })
expect(xai.route.defaults.providerOptions).toEqual({ openai: { reasoningEffort: "high", store: false } })
expect(xai.route.defaults.providerOptions).toMatchObject({ xai: { reasoningEffort: "high", store: false } })
})
test("maps package settings onto the executable model", () => {
+29 -1
View File
@@ -41,7 +41,14 @@ describe("Gemini route", () => {
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLMRequest.update(request, {
providerOptions: { gemini: { thinkingConfig: { thinkingBudget: 0, includeThoughts: false } } },
providerOptions: {
gemini: {
cachedContent: "cachedContents/example",
safetySettings: [{ category: "HARM_CATEGORY_HATE_SPEECH", threshold: "BLOCK_ONLY_HIGH" }],
serviceTier: "priority",
thinkingConfig: { thinkingBudget: 0, includeThoughts: false, thinkingLevel: "high" },
},
},
}),
)
const filtered = yield* compileRequest(
@@ -49,12 +56,33 @@ describe("Gemini route", () => {
providerOptions: { gemini: { thinkingConfig: { thinkingBudget: "invalid", includeThoughts: false } } },
}),
)
const defaulted = yield* compileRequest(
LLMRequest.update(request, {
providerOptions: { gemini: { thinkingConfig: { thinkingLevel: "high" } } },
}),
)
const emptySafetySettings = yield* compileRequest(
LLMRequest.update(request, {
providerOptions: { gemini: { safetySettings: [] } },
}),
)
expect(prepared.body.generationConfig?.thinkingConfig).toEqual({
thinkingBudget: 0,
includeThoughts: false,
thinkingLevel: "high",
})
expect(prepared.body.cachedContent).toBe("cachedContents/example")
expect(prepared.body.safetySettings).toEqual([
{ category: "HARM_CATEGORY_HATE_SPEECH", threshold: "BLOCK_ONLY_HIGH" },
])
expect(prepared.body.serviceTier).toBe("priority")
expect(filtered.body.generationConfig?.thinkingConfig).toEqual({ includeThoughts: false })
expect(defaulted.body.generationConfig?.thinkingConfig).toEqual({
includeThoughts: true,
thinkingLevel: "high",
})
expect(emptySafetySettings.body.safetySettings).toEqual([])
}),
)
+1
View File
@@ -59,6 +59,7 @@
"@opencode-ai/sdk": "file:vendor/opencode-ai-sdk-1.18.8-dev.tgz",
"@opencode-ai/session-ui": "workspace:*",
"@opencode-ai/ui": "workspace:*",
"@opencode-ai/util": "workspace:*",
"@pierre/trees": "1.0.0-beta.4",
"@sentry/solid": "catalog:",
"@shikijs/transformers": "3.9.2",
@@ -4,6 +4,7 @@ import { createResizeObserver } from "@solid-primitives/resize-observer"
import { createMutation } from "@tanstack/solid-query"
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
import { displayLabel } from "@opencode-ai/util/session-title-fallback"
import { useGlobal } from "@/context/global"
import { ServerConnection, serverName } from "@/context/server"
import { displayName, projectForSession } from "@/pages/layout/helpers"
@@ -54,7 +55,10 @@ export function TabNavItem(props: {
if (!session) return
return projectForSession(session, serverCtx()?.projects.list() ?? [])
})
const title = createMemo(() => props.session()?.title ?? props.fallbackTitle)
const title = createMemo(() => {
const session = props.session()
return session ? displayLabel(session) : props.fallbackTitle
})
const projectName = createMemo(() => {
const session = props.session()
@@ -143,7 +147,7 @@ export function TabNavItem(props: {
if (!canOpenTabRename(props.dragging, editing(), rename.isPending)) return
const session = props.session()
if (!session) return
titleEl.textContent = session.title
titleEl.textContent = session.title ?? ""
setEditing(true)
requestAnimationFrame(() => {
@@ -302,7 +306,7 @@ export function TabNavItem(props: {
}}
data={{
projectName: projectName(),
title: props.session()?.title,
title: title(),
path: previewPath(),
serverName: serverLabel(),
}}
@@ -3,6 +3,7 @@ import { useLanguage } from "@/context/language"
import { serverName } from "@/context/server"
import { displayName } from "@/pages/layout/helpers"
import { makeEventListener } from "@solid-primitives/event-listener"
import { displayLabel } from "@opencode-ai/util/session-title-fallback"
import { createMemo, onCleanup } from "solid-js"
import { createStore } from "solid-js/store"
import type { HomeController } from "./home-controller"
@@ -23,7 +24,7 @@ export function createHomeSessionSearchController(home: HomeController, sessions
if (!value) return []
return sessions.data
.searchRecords()
.filter((record) => `${record.session.title} ${record.projectName}`.toLowerCase().includes(value))
.filter((record) => `${displayLabel(record.session)} ${record.projectName}`.toLowerCase().includes(value))
})
const active = createMemo(() => {
const records = results()
@@ -6,10 +6,10 @@ import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
import { displayLabel } from "@opencode-ai/util/session-title-fallback"
import { useLanguage } from "@/context/language"
import { ServerConnection } from "@/context/server"
import { SessionTabAvatarView } from "@/pages/layout/session-tab-avatar"
import { sessionTitle } from "@/utils/session-title"
import { shouldOpenSessionInBackground } from "../home-session-open"
import {
HomeSessionStatusController,
@@ -344,7 +344,7 @@ function HomeSessionSearchResultRow(
selected: boolean
},
) {
const title = createMemo(() => sessionTitle(props.record.session.title) || props.record.session.id)
const title = createMemo(() => displayLabel(props.record.session))
const showProjectName = () => props.showProjectName() && props.record.projectName
const key = () => homeSessionSearchKey(props.record)
@@ -415,7 +415,7 @@ function HomeSessionGroupHeader(props: {
}
function HomeSessionRow(props: HomeSessionsViewProps & { record: HomeSessionRecord }) {
const title = createMemo(() => sessionTitle(props.record.session.title) || props.record.session.id)
const title = createMemo(() => displayLabel(props.record.session))
const showProjectName = () => props.showProjectName() && props.record.projectName
return (
@@ -5,6 +5,7 @@ import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { Spinner } from "@opencode-ai/ui/spinner"
import { Tooltip } from "@opencode-ai/ui/tooltip"
import { displayLabel } from "@opencode-ai/util/session-title-fallback"
import { getFilename } from "@opencode-ai/core/util/path"
import { A, useParams } from "@solidjs/router"
import { type Accessor, createMemo, For, type JSX, Match, Show, Switch } from "solid-js"
@@ -14,7 +15,6 @@ import { getAvatarColors, type LocalProject, useLayout } from "@/context/layout"
import { useNotification } from "@/context/notification"
import { usePermission } from "@/context/permission"
import { messageAgentColor } from "@/utils/agent"
import { sessionTitle } from "@/utils/session-title"
import { sessionPermissionRequest } from "../session/composer/session-request-tree"
import { childSessionOnPath, getProjectAvatarSource, hasProjectPermissions } from "./helpers"
@@ -104,7 +104,7 @@ const SessionRow = (props: {
warmPress: () => void
warmFocus: () => void
}): JSX.Element => {
const title = () => sessionTitle(props.session.title)
const title = () => displayLabel(props.session)
return (
<A
@@ -229,7 +229,7 @@ export const SessionItem = (props: SessionItemProps): JSX.Element => {
fallback={
<Tooltip
placement={props.mobile ? "bottom" : "right"}
value={sessionTitle(props.session.title)}
value={displayLabel(props.session)}
gutter={10}
class="min-w-0 w-full"
>
@@ -70,7 +70,7 @@ import { legacySessionHref, requireServerKey, sessionHref } from "@/utils/sessio
import { useSDK } from "@/context/sdk"
import { useSync } from "@/context/sync"
import { notifySessionTabsRemoved } from "@/components/titlebar-session-events"
import { sessionTitle } from "@/utils/session-title"
import { displayLabel } from "@opencode-ai/util/session-title-fallback"
import { scheduleConnectedMeasure } from "./measure"
import { observeElementOffsetReconnectAware } from "./observe-element-offset"
import { createTimelineProjection } from "./projection"
@@ -296,8 +296,11 @@ export function MessageTimeline(props: {
if (!id) return
return sync().session.get(id)
})
const titleValue = createMemo(() => info()?.title)
const titleLabel = createMemo(() => sessionTitle(titleValue()))
const titleLabel = createMemo(() => {
const session = info()
if (!session) return
return displayLabel(session)
})
const shareUrl = createMemo(() => info()?.share?.url)
const shareEnabled = createMemo(() => sync().data.config.share !== "disabled")
const parentID = createMemo(() => info()?.parentID)
@@ -311,7 +314,10 @@ export function MessageTimeline(props: {
if (!id) return emptyMessages
return sync().data.message[id] ?? emptyMessages
})
const parentTitle = createMemo(() => sessionTitle(parent()?.title) ?? language.t("command.session.new"))
const parentTitle = createMemo(() => {
const session = parent()
return session ? displayLabel(session) : language.t("command.session.new")
})
const getMsgParts = (msgId: string) => sync().data.part[msgId] ?? emptyParts
const getMsgPart = (messageID: string, partID: string) => getMsgParts(messageID).find((part) => part.id === partID)
const childTaskDescription = createMemo(() => {
@@ -329,7 +335,7 @@ export function MessageTimeline(props: {
if (value) return value
return language.t("command.session.new")
})
const showHeader = createMemo(() => !!(titleValue() || parentID()))
const showHeader = createMemo(() => !!(titleLabel() || parentID()))
const projection = createTimelineProjection({
messages: sessionMessages,
userMessages: () => props.userMessages,
@@ -912,9 +918,10 @@ export function MessageTimeline(props: {
}
function DialogDeleteSession(props: { sessionID: string }) {
const name = createMemo(
() => sessionTitle(sync().session.get(props.sessionID)?.title) ?? language.t("command.session.new"),
)
const name = createMemo(() => {
const session = sync().session.get(props.sessionID)
return session ? displayLabel(session) : language.t("command.session.new")
})
const handleDelete = async () => {
await deleteSession(props.sessionID)
dialog.close()
-7
View File
@@ -1,7 +0,0 @@
const pattern = /^(New session|Child session) - \d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/
export function sessionTitle(title?: string) {
if (!title) return title
const match = title.match(pattern)
return match?.[1] ?? title
}
+2 -1
View File
@@ -1,5 +1,6 @@
import type { SessionApi, SessionInfo, SessionListInput } from "@opencode-ai/client/promise"
import type { Session } from "@opencode-ai/sdk/v2/client"
import { withTimestampedFallback } from "@opencode-ai/util/session-title-fallback"
export function normalizeSessionInfo(input: SessionInfo | Session): Session {
if (!("location" in input)) return input
@@ -13,7 +14,7 @@ export function normalizeSessionInfo(input: SessionInfo | Session): Session {
parentID: input.parentID,
cost: input.cost,
tokens: input.tokens,
title: input.title,
title: withTimestampedFallback(input),
agent: input.agent,
model: input.model,
version: "",
+2 -1
View File
@@ -8,6 +8,7 @@ import {
type SessionMessageInfo,
type SkillInfo,
} from "@opencode-ai/client/promise"
import { withTimestampedFallback } from "@opencode-ai/util/session-title-fallback"
import type {
AgentSideConnection,
AuthenticateRequest,
@@ -213,7 +214,7 @@ export function make(input: { readonly client: OpenCodeClient; readonly connecti
sessions: page.data.map((session) => ({
sessionId: session.id,
cwd: session.location.directory,
title: session.title,
title: withTimestampedFallback(session),
updatedAt: new Date(session.time.updated).toISOString(),
})),
...(page.cursor.next ? { nextCursor: page.cursor.next } : {}),
@@ -1757,7 +1757,7 @@ export type SessionInfo = {
cost: MoneyUSD
tokens: TokenUsageInfo
time: { created: number; updated: number; archived?: number }
title: string
title?: string
location: LocationRef
subpath?: string
revert?: SessionRevert
@@ -2006,7 +2006,7 @@ export type SessionV1Info = {
cost?: number
tokens?: { input: number; output: number; reasoning: number; cache: { read: number; write: number } }
share?: { url: string }
title: string
title?: string
agent?: string
model?: { id: string; providerID: string; variant?: string }
version: string
+3 -3
View File
@@ -1,9 +1,9 @@
{
"version": "7",
"dialect": "sqlite",
"id": "db37a97f-9b5e-4c87-be8b-4feace35136c",
"id": "e43ed7e2-b9fc-4178-beae-3646e4a976e1",
"prevIds": [
"a4ba73b4-21bc-41ab-a415-94e2ca38d798"
"db37a97f-9b5e-4c87-be8b-4feace35136c"
],
"ddl": [
{
@@ -1302,7 +1302,7 @@
},
{
"type": "text",
"notNull": true,
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
+90
View File
@@ -0,0 +1,90 @@
export * as AISDKNative from "./aisdk-native"
import { isRecord } from "@opencode-ai/ai/utils/record"
export interface Mapping {
readonly package: string
readonly settings: Readonly<Record<string, unknown>>
}
export function map(packageName: string | undefined, settings: Readonly<Record<string, unknown>>): Mapping | undefined {
const baseSettings = mapBaseSettings(settings)
switch (packageName) {
case "@ai-sdk/google":
return {
package: "@opencode-ai/ai/providers/google",
settings: {
...baseSettings,
...mapAPIKey(settings),
...mapGoogleOptions(settings),
},
}
case "@openrouter/ai-sdk-provider":
return {
package: "@opencode-ai/ai/providers/openrouter",
settings: {
...baseSettings,
...mapAPIKey(settings),
...mapProviderOptions("openrouter", settings),
},
}
case "@ai-sdk/xai":
return {
package: "@opencode-ai/ai/providers/xai",
settings: {
...baseSettings,
...mapAPIKey(settings),
...mapXAIOptions(settings),
},
}
}
}
function mapBaseSettings(settings: Readonly<Record<string, unknown>>) {
return {
...(typeof settings.baseURL === "string" ? { baseURL: settings.baseURL } : {}),
}
}
function mapAPIKey(settings: Readonly<Record<string, unknown>>) {
return typeof settings.apiKey === "string" ? { apiKey: settings.apiKey } : {}
}
function mapGoogleOptions(settings: Readonly<Record<string, unknown>>) {
const input = settings.thinkingConfig
const thinkingConfig = {
...(isRecord(input) && typeof input.thinkingBudget === "number" ? { thinkingBudget: input.thinkingBudget } : {}),
...(isRecord(input) && typeof input.includeThoughts === "boolean"
? { includeThoughts: input.includeThoughts }
: {}),
...(isRecord(input) && typeof input.thinkingLevel === "string" ? { thinkingLevel: input.thinkingLevel } : {}),
}
const options = {
...(typeof settings.cachedContent === "string" ? { cachedContent: settings.cachedContent } : {}),
...(Array.isArray(settings.safetySettings) ? { safetySettings: settings.safetySettings } : {}),
...(typeof settings.serviceTier === "string" ? { serviceTier: settings.serviceTier } : {}),
...(Object.keys(thinkingConfig).length > 0 ? { thinkingConfig } : {}),
}
if (Object.keys(options).length === 0) return {}
return { providerOptions: { gemini: options } }
}
function mapXAIOptions(settings: Readonly<Record<string, unknown>>) {
const options = {
...(typeof settings.reasoningEffort === "string" ? { reasoningEffort: settings.reasoningEffort } : {}),
...(typeof settings.store === "boolean" ? { store: settings.store } : {}),
...(typeof settings.promptCacheKey === "string" ? { promptCacheKey: settings.promptCacheKey } : {}),
}
if (Object.keys(options).length === 0) return {}
return { providerOptions: { xai: options } }
}
function mapProviderOptions(namespace: string, settings: Readonly<Record<string, unknown>>) {
const values = Object.fromEntries(
Object.entries(settings).filter(
([key]) => !["apiKey", "authToken", "baseURL", "chunkTimeout", "fetch", "timeout"].includes(key),
),
)
if (Object.keys(values).length === 0) return {}
return { providerOptions: { [namespace]: values } }
}
+1
View File
@@ -58,5 +58,6 @@ export const migrations = (
import("./migration/20260722011141_delete_tool_progress_events"),
import("./migration/20260722170000_canonical_tool_results"),
import("./migration/20260729022634_session_fork_boundary"),
import("./migration/20260730195856_optional_session_title"),
])
).map((module) => module.default) satisfies DatabaseMigration.Migration[]
@@ -0,0 +1,14 @@
import { Effect } from "effect"
import type { DatabaseMigration } from "../migration"
export default {
id: "20260730195856_optional_session_title",
up(tx) {
return Effect.gen(function* () {
yield* tx.run(`ALTER TABLE \`session\` RENAME COLUMN \`title\` TO \`title_old\``)
yield* tx.run(`ALTER TABLE \`session\` ADD COLUMN \`title\` text`)
yield* tx.run(`UPDATE \`session\` SET \`title\` = \`title_old\``)
yield* tx.run(`ALTER TABLE \`session\` DROP COLUMN \`title_old\``)
})
},
} satisfies DatabaseMigration.Migration
+1 -1
View File
@@ -217,7 +217,7 @@ export default {
\`slug\` text NOT NULL,
\`directory\` text NOT NULL,
\`path\` text,
\`title\` text NOT NULL,
\`title\` text,
\`version\` text NOT NULL,
\`share_url\` text,
\`summary_additions\` integer,
+1 -1
View File
@@ -201,7 +201,7 @@ const layer = Layer.effect(
locks.withLock(repository.gitDirectory)(effect)
const discover = Effect.fn("Git.repo.discover")(function* (input: AbsolutePath) {
const dotgit = yield* fs.up({ targets: [".git"], start: input }).pipe(
const dotgit = yield* fs.up({ targets: [".git"], start: input, mode: "first" }).pipe(
Effect.map((matches) => matches[0]),
Effect.catch(() => Effect.succeed(undefined)),
)
+9 -31
View File
@@ -12,12 +12,12 @@ import { Auth, type AnyRoute } from "@opencode-ai/ai/route"
import { Context, Effect, Layer, Schema } from "effect"
import { produce } from "immer"
import { AISDK } from "./aisdk"
import { AISDKNative } from "./aisdk-native"
import { Catalog } from "./catalog"
import { Credential } from "./credential"
import { Integration } from "./integration"
import { Capabilities, ID, Info, Ref, VariantID } from "./model"
import { Npm } from "@opencode-ai/util/npm"
import { OpenAICodex } from "./plugin/provider/openai-codex"
import { Provider } from "./provider"
export class VariantUnavailableError extends Schema.TaggedErrorClass<VariantUnavailableError>()(
@@ -152,12 +152,7 @@ export const fromCatalogModel = (
const packageName = Provider.packageName(resolved.package)
const key = apiKey(resolved, credential)
if (OpenAICodex.isChatGPT(credential) && !Provider.isAISDK(resolved.package) && isNativeOpenAI(resolved.package)) {
return Effect.succeed(codexModel(resolved, credential, key))
}
if (Provider.isAISDK(resolved.package) && packageName === "@ai-sdk/openai") {
if (OpenAICodex.isChatGPT(credential)) return Effect.succeed(codexModel(resolved, credential, key))
return Effect.succeed(
withDefaults(resolved, OpenAIResponses.route)
.with({ auth: key === undefined ? Auth.none : Auth.bearer(key) })
@@ -182,7 +177,10 @@ export const fromCatalogModel = (
.model({ id: resolved.modelID ?? resolved.id, compatibility: resolved.compatibility }),
)
}
if (Provider.isAISDK(resolved.package)) {
const configured = { ...resolved.settings, ...credential?.metadata }
const mapping = Provider.isAISDK(resolved.package) ? AISDKNative.map(packageName, configured) : undefined
const native = mapping?.package ?? resolved.package
if (Provider.isAISDK(resolved.package) && !mapping) {
if (!dependencies?.loadAISDK) return Effect.fail(unsupported(resolved))
const runtime = produce(resolved, (draft) => {
draft.settings = Provider.mergeOverlay(draft.settings, {
@@ -193,16 +191,16 @@ export const fromCatalogModel = (
})
return dependencies.loadAISDK(runtime).pipe(Effect.mapError(() => unsupported(resolved)))
}
if (!resolved.package) return Effect.fail(unsupported(resolved))
if (!native) return Effect.fail(unsupported(resolved))
const specifier = resolved.package
const specifier = native
return Effect.gen(function* () {
const module = yield* (dependencies?.loadPackage ?? Provider.loadPackage)(specifier).pipe(
Effect.mapError(() => unsupported(resolved)),
)
const configured = { ...resolved.settings, ...credential?.metadata }
const mapped = mapping?.settings ?? configured
const settings = {
...(credential ? withoutNativeAuthSettings(configured) : configured),
...(credential ? withoutNativeAuthSettings(mapped) : mapped),
...nativeCredentialSettings(specifier, credential),
headers: resolved.headers,
body: resolved.body,
@@ -223,10 +221,6 @@ export const fromCatalogModel = (
})
}
const isNativeOpenAI = (packageName: string | undefined) =>
packageName === "@opencode-ai/ai/providers/openai" ||
packageName?.startsWith("@opencode-ai/ai/providers/openai/") === true
const nativeCredentialSettings = (specifier: string, credential: Credential.Value | undefined) => {
if (!credential) return {}
if (credential.type === "key") return { apiKey: credential.key }
@@ -248,22 +242,6 @@ const withoutNativeAuthSettings = (settings: Record<string, unknown>) => {
return rest
}
const codexModel = (
model: Info,
credential: Credential.Value | undefined,
key: ReturnType<typeof Auth.value> | undefined,
) => {
const account = OpenAICodex.accountID(credential)
return withDefaults(model, OpenAIResponses.route)
.with({
endpoint: { baseURL: OpenAICodex.baseURL },
auth: (key === undefined ? Auth.none : Auth.bearer(key)).andThen(
account === undefined ? Auth.none : Auth.headers({ "chatgpt-account-id": account }),
),
})
.model({ id: model.modelID ?? model.id, compatibility: model.compatibility })
}
const unsupported = (model: Info) =>
new UnsupportedPackageError({
providerID: model.providerID,
-2
View File
@@ -11,8 +11,6 @@ import { Permission } from "../permission"
// Combined output files written by the Shell service, e.g. `<data>/shell/<projectID>/<shellID>.out`.
// Whitelisted so agents can read a command's full captured output without an external-directory prompt.
const SHELL_OUTPUT_GLOB = path.join(Global.Path.data, "shell", "*", "*")
const BUILD_SYSTEM =
"You are an AI coding agent. Help the user accomplish software engineering tasks by inspecting the workspace, making targeted changes, and using tools according to the configured permissions."
const PROMPT_EXPLORE = `You are a file search specialist. You excel at thoroughly navigating and exploring codebases.
@@ -1,42 +0,0 @@
export * as OpenAICodex from "./openai-codex"
// TEMPORARY SEAM (#34765): plugins have no hook into LLM route construction, so
// Codex routing lives in ModelResolver and catalog filtering.
// in OpenAIPlugin, sharing this module. Once the native provider packages land
// (#33689/#33925/#34462) this should collapse into the native OpenAI provider.
// The eligibility rules mirror V1's CodexAuthPlugin allowlist; models.dev has no
// plan-eligibility data for OpenAI today, but models other vendors' subscriptions
// as dedicated providers (e.g. zai-coding-plan) - a future openai-chatgpt-plan
// provider entry could replace the hardcoded rules with catalog data.
/** ChatGPT-plan requests must target the codex backend instead of the public API. */
export const baseURL = "https://chatgpt.com/backend-api/codex"
const methodIDs: readonly string[] = ["chatgpt-browser", "chatgpt-headless"]
/** Structural credential shape so both core and plugin-facing credential types fit. */
type CredentialLike = {
readonly type: string
readonly methodID?: string
readonly metadata?: Record<string, unknown> | undefined
}
export const isChatGPT = (credential: CredentialLike | undefined) =>
credential?.type === "oauth" && credential.methodID !== undefined && methodIDs.includes(credential.methodID)
export const accountID = (credential: CredentialLike | undefined) => {
if (!isChatGPT(credential)) return undefined
const value = credential?.metadata?.accountID
return typeof value === "string" ? value : undefined
}
const allowed = new Set(["gpt-5.5", "gpt-5.3-codex-spark", "gpt-5.4", "gpt-5.4-mini"])
const disallowed = new Set(["gpt-5.5-pro", "gpt-5.6"])
/** Which API model ids a ChatGPT subscription may call through the codex backend. */
export const eligible = (apiID: string) => {
if (allowed.has(apiID)) return true
if (disallowed.has(apiID)) return false
const match = apiID.match(/^gpt-(\d+\.\d+)/)
return match ? Number.parseFloat(match[1]) > 5.4 : false
}
+40 -24
View File
@@ -10,14 +10,16 @@ import { Model } from "../../model"
import { OauthCallbackPage } from "../../oauth/page"
import { Provider } from "../../provider"
import type { PluginInternal } from "../internal"
import { OpenAICodex } from "./openai-codex"
const clientID = "app_EMoamEEZ73f0CkXaXp7hrann"
const issuer = "https://auth.openai.com"
const callbackPort = 1455
const pollingSafetyMargin = 3000
const codexBaseURL = "https://chatgpt.com/backend-api/codex"
const browserMethodID = Integration.MethodID.make("chatgpt-browser")
const headlessMethodID = Integration.MethodID.make("chatgpt-headless")
const codexAllowed = new Set(["gpt-5.5", "gpt-5.3-codex-spark", "gpt-5.4", "gpt-5.4-mini"])
const codexDisallowed = new Set(["gpt-5.5-pro", "gpt-5.6"])
type Pkce = {
verifier: string
@@ -164,14 +166,18 @@ export const OpenAIPlugin = define({
effect: Effect.fn(function* (ctx) {
const bus = yield* Bus.Service
const loading = Semaphore.makeUnsafe(1)
let chatgpt = false
let chatgpt: Credential.OAuth | undefined
const load = Effect.fn("OpenAIPlugin.load")(function* () {
const connection = yield* ctx.integration.connection.active("openai")
const credential = connection
? yield* ctx.integration.connection.resolve(connection).pipe(Effect.catch(() => Effect.succeed(undefined)))
: undefined
chatgpt = OpenAICodex.isChatGPT(credential)
chatgpt =
credential?.type === "oauth" &&
(credential.methodID === browserMethodID || credential.methodID === headlessMethodID)
? credential
: undefined
})
yield* ctx.integration.transform((draft) => {
@@ -183,16 +189,19 @@ export const OpenAIPlugin = define({
for (const item of evt.provider.list()) {
if (!Provider.isAISDK(item.provider.package)) continue
if (Provider.packageName(item.provider.package) !== "@ai-sdk/openai") continue
if (!item.models.has(Model.ID.make("gpt-5-chat-latest"))) continue
evt.model.update(item.provider.id, Model.ID.make("gpt-5-chat-latest"), (model) => {
// OpenAIPlugin sends OpenAI models through Responses; this alias is a
// chat-completions-only model, so hide it only from OpenAI's catalog.
model.enabled = false
evt.provider.update(item.provider.id, (provider) => {
provider.package = "@opencode-ai/ai/providers/openai"
})
}
if (!chatgpt) return
const item = evt.provider.get(Provider.ID.openai)
if (!item) return
item.provider.settings = Provider.mergeOverlay(item.provider.settings, { baseURL: codexBaseURL })
const account = chatgpt.metadata?.accountID
item.provider.headers = Provider.mergeHeaders(
item.provider.headers,
typeof account === "string" ? { "chatgpt-account-id": account } : undefined,
)
for (const model of item.models.values()) {
// ChatGPT-plan tokens only authorize codex-eligible models, and the
// subscription covers usage, so hide the rest and zero the cost.
@@ -201,14 +210,36 @@ export const OpenAIPlugin = define({
draft.enabled = false
return
}
if (!OpenAICodex.eligible(draft.modelID ?? draft.id)) {
const apiID = draft.modelID ?? draft.id
const match = apiID.match(/^gpt-(\d+\.\d+)/)
if (
!codexAllowed.has(apiID) &&
(codexDisallowed.has(apiID) || !match || Number.parseFloat(match[1]) <= 5.4)
) {
draft.enabled = false
return
}
draft.cost = []
if (draft.id.includes("gpt-5.5")) {
draft.limit = { context: 400_000, input: 272_000, output: 128_000 }
}
if (draft.id.includes("gpt-5.6")) {
draft.limit = { context: 500_000, input: 372_000, output: 128_000 }
}
})
}
})
yield* ctx.session.hook("request", (evt) =>
Effect.sync(() => {
if (!chatgpt || evt.model.providerID !== Provider.ID.openai) return
const url = new URL(evt.url)
if (url.origin === "https://api.openai.com") {
evt.url = `${codexBaseURL}${url.pathname.replace(/^\/v1/, "")}${url.search}`
}
evt.headers.originator = "opencode"
evt.headers["session-id"] = evt.sessionID
}),
)
const refresh = () => loading.withPermit(load().pipe(Effect.andThen(ctx.catalog.reload())))
yield* bus.subscribe(Integration.Event.ConnectionUpdated).pipe(
@@ -216,21 +247,6 @@ export const OpenAIPlugin = define({
Stream.runForEach(refresh),
Effect.forkScoped({ startImmediately: true }),
)
yield* ctx.aisdk.hook(
"sdk",
Effect.fn(function* (evt) {
if (evt.package !== "@ai-sdk/openai") return
const mod = yield* Effect.promise(() => import("@ai-sdk/openai"))
evt.sdk = mod.createOpenAI(evt.options)
}),
)
yield* ctx.aisdk.hook(
"language",
Effect.fn(function* (evt) {
if (evt.model.providerID !== Provider.ID.openai) return
evt.language = evt.sdk.responses(evt.model.modelID ?? evt.model.id)
}),
)
}),
} satisfies PluginInternal.InternalPlugin)
+9 -8
View File
@@ -49,7 +49,7 @@ export const root = Effect.fn("Project.root")(function* (
fs: FSUtil.Interface,
input: AbsolutePath,
) {
return yield* fs.up({ targets: [".git", ".hg"], start: input }).pipe(
return yield* fs.up({ targets: [".git", ".hg"], start: input, mode: "first" }).pipe(
Effect.map((matches) => matches[0] ? AbsolutePath.make(path.dirname(matches[0])) : undefined),
Effect.catch(() => Effect.succeed(undefined)),
)
@@ -224,7 +224,7 @@ const layer = Layer.effect(
})
const hgDiscover = Effect.fnUntraced(function* (input: AbsolutePath) {
const dotHg = yield* fs.up({ targets: [".hg"], start: input }).pipe(
const dotHg = yield* fs.up({ targets: [".hg"], start: input, mode: "first" }).pipe(
Effect.map((matches) => matches[0]),
Effect.catch(() => Effect.succeed(undefined)),
)
@@ -246,12 +246,13 @@ const layer = Layer.effect(
if (repo) {
const previous = yield* cached(repo.commonDirectory)
const id = (yield* remote(repo)) ?? previous ?? (yield* root(repo))
const canonical = yield* git.worktree
.list(repo)
.pipe(
Effect.map((items) => items.find((item) => item.kind === "main")?.directory ?? repo.worktree),
Effect.catch(() => Effect.succeed(repo.worktree)),
)
const canonical =
repo.gitDirectory === repo.commonDirectory
? repo.worktree
: yield* git.worktree.list(repo).pipe(
Effect.map((items) => items.find((item) => item.kind === "main")?.directory ?? repo.worktree),
Effect.catch(() => Effect.succeed(repo.worktree)),
)
return yield* persist({
previous,
id: id ?? ID.global,
+1 -1
View File
@@ -374,7 +374,7 @@ const layer = Layer.effect(
directory: location.directory,
path: path.relative(project.directory, location.directory).replaceAll("\\", "/"),
workspaceID: location.workspaceID ? Workspace.ID.make(location.workspaceID) : undefined,
title: input.title ?? `New session - ${new Date(now).toISOString()}`,
title: input.title,
agent: input.agent,
model: input.model
? {
+6 -7
View File
@@ -117,21 +117,20 @@ export const preview = Effect.fn("SessionHistory.preview")(function* (
.pipe(Effect.catch((error) => (error instanceof Instructions.InitializationBlocked ? error : Effect.die(error))))
})
/** Returns the session's sole user message, or `undefined` once a second one exists. */
export const firstUserMessageIfOnly = Effect.fn("SessionHistory.firstUserMessageIfOnly")(function* (
/** Returns the session's first user message. */
export const firstUserMessage = Effect.fn("SessionHistory.firstUserMessage")(function* (
db: DatabaseService,
sessionID: SessionSchema.ID,
) {
const rows = yield* db
const row = yield* db
.select()
.from(SessionMessageTable)
.where(and(eq(SessionMessageTable.session_id, sessionID), eq(SessionMessageTable.type, "user")))
.orderBy(asc(SessionMessageTable.seq))
.limit(2)
.all()
.get()
.pipe(Effect.orDie)
if (rows.length !== 1) return undefined
const message = yield* decodeMessageRow(rows[0]).pipe(Effect.catch(() => Effect.succeed(undefined)))
if (!row) return undefined
const message = yield* decodeMessageRow(row).pipe(Effect.catch(() => Effect.succeed(undefined)))
return message?.type === "user" ? message : undefined
})
+1 -1
View File
@@ -17,7 +17,7 @@ export function fromRow(row: typeof SessionTable.$inferSelect): SessionSchema.In
return SessionSchema.Info.make({
id: SessionSchema.ID.make(row.id),
projectID: Project.ID.make(row.project_id),
title: row.title,
title: row.title ?? undefined,
parentID: row.parent_id ? SessionSchema.ID.make(row.parent_id) : undefined,
fork:
row.fork_session_id && row.fork_boundary
@@ -1,6 +1,7 @@
export * as SessionModelRequest from "./model-request"
import { LLM, Message, SystemPart, type LLMRequest } from "@opencode-ai/ai"
import type { StreamOptions } from "@opencode-ai/ai/route"
import type { Content } from "@opencode-ai/schema/tool"
import { SessionError } from "@opencode-ai/schema/session-error"
import { Cause, Config, Context, Effect, Layer, Result } from "effect"
@@ -37,6 +38,7 @@ const declineDefect = (cause: Cause.Cause<Tool.Error>) => {
interface Prepared {
readonly request: LLMRequest
readonly options: StreamOptions
/**
* One request-scoped execution operation. Unknown, hook-removed, and
* step-limit-violating calls fail individually through the same seam.
@@ -162,6 +164,26 @@ export const layer = Layer.effect(
tools: hookedTools,
toolChoice: stepLimitReached ? "none" : undefined,
})
const options: StreamOptions = {
transform: (request) =>
hooks
.trigger("session", "request", {
sessionID: session.id,
agent: agent.id,
model: resolved.ref,
...request,
})
.pipe(
Effect.tap((event) =>
Effect.sync(() => {
request.url = event.url
request.headers = event.headers
request.body = event.body
}),
),
Effect.asVoid,
),
}
if (promptCacheSnapshots) {
const current = PromptCacheDiagnostics.snapshot(request)
const comparison = PromptCacheDiagnostics.compare(promptCacheSnapshots.get(session.id), current)
@@ -190,6 +212,7 @@ export const layer = Layer.effect(
}
return {
request,
options,
executeTool,
stepLimitReached,
}
+9 -2
View File
@@ -43,7 +43,8 @@ type Usage = {
const ForkBatchSize = 500
const forkTitle = (value: string) => {
const forkTitle = (value?: string) => {
if (value === undefined) return
const match = value.match(/^(.+) \(fork #(\d+)\)$/)
if (match) return `${match[1]} (fork #${Number.parseInt(match[2], 10) + 1})`
return `${value} (fork #1)`
@@ -216,7 +217,7 @@ const projectFork = Effect.fn("SessionProjector.projectFork")(function* (
slug: Slug.create(),
directory: parent.directory,
path: parent.path,
title: forkTitle(parent.title),
title: forkTitle(parent.title ?? undefined),
agent: parent.agent,
model: parent.model,
version: parent.version,
@@ -657,6 +658,12 @@ const layer = Layer.effectDiscard(
input: event.data.input,
timeCreated: event.created,
})
yield* db
.update(SessionTable)
.set({ time_updated: DateTime.toEpochMillis(event.created) })
.where(eq(SessionTable.id, event.data.sessionID))
.run()
.pipe(Effect.orDie)
}),
)
yield* bus.project(SessionEvent.Compaction.Admitted, (event) =>
+19 -11
View File
@@ -93,10 +93,9 @@ const layer = Layer.effect(
const db = (yield* Database.Service).db
const compaction = yield* SessionCompaction.Service
const title = yield* SessionTitle.Service
// Title generation is a side effect of the first step; it must not delay step continuation.
// Tracked per process so repeated wakes before the second user message arrives don't
// re-fire a redundant LLM call; `SessionTitle` itself is idempotent based on durable history.
const titleStarted = new Set<SessionSchema.ID>()
// Title generation is a side effect of a successful step; it must not delay continuation.
// The in-flight set coalesces overlapping steps while title presence records success durably.
const titlesRunning = new Set<SessionSchema.ID>()
const forkTitle = yield* FiberSet.makeRuntime<never, void, never>()
/**
* Drains eligible manual compaction and user input until the Session becomes idle.
@@ -125,7 +124,7 @@ const layer = Layer.effect(
let step = 1
while (true) {
const result = yield* runStep(sessionID, promotable, step)
yield* startTitleOnce(sessionID)
if (step === 1) yield* startTitle(sessionID)
yield* runPendingCompaction(sessionID)
if (!result.needsContinuation && !(yield* SessionPending.has(db, sessionID, "steer"))) return
promotable = "steer"
@@ -279,7 +278,7 @@ const layer = Layer.effect(
// event durably, fork one fiber per local tool call, and hold back a virgin
// context-overflow provider error so settlement may recover it via compaction.
let overflowFailure: ProviderErrorEvent | undefined
const providerStream = llm.stream(prepared.request).pipe(
const providerStream = llm.stream(prepared.request, prepared.options).pipe(
Stream.runForEach((event) =>
Effect.gen(function* () {
if (overflowFailure || publisher.hasProviderError()) return
@@ -481,11 +480,20 @@ const layer = Layer.effect(
}
})
/** Fires title generation once per process after the first step makes a user message visible. */
const startTitleOnce = Effect.fnUntraced(function* (sessionID: SessionSchema.ID) {
if (titleStarted.has(sessionID)) return
titleStarted.add(sessionID)
forkTitle(title.generateForFirstPrompt(yield* getSession(sessionID)).pipe(Effect.ignore))
/** Starts one title request at a time after a successful step makes user input visible. */
const startTitle = Effect.fnUntraced(function* (sessionID: SessionSchema.ID) {
if (titlesRunning.has(sessionID)) return
titlesRunning.add(sessionID)
forkTitle(
title.generateForFirstPrompt(sessionID).pipe(
Effect.ignore,
Effect.ensuring(
Effect.sync(() => {
titlesRunning.delete(sessionID)
}),
),
),
)
})
const getSession = Effect.fn("SessionRunner.getSession")(function* (sessionID: SessionSchema.ID) {
+1 -1
View File
@@ -36,7 +36,7 @@ export const SessionTable = sqliteTable(
slug: text().notNull(),
directory: directoryColumn().notNull(),
path: pathColumn(),
title: text().notNull(),
title: text(),
version: text().notNull(),
share_url: text(),
summary_additions: integer(),
+34 -12
View File
@@ -1,11 +1,12 @@
export * as SessionTitle from "./title"
import { LLM, LLMClient, LLMError, LLMEvent, Message, type LLMRequest } from "@opencode-ai/ai"
import { Context, Effect, Layer, Stream } from "effect"
import { Context, DateTime, Effect, Layer, Stream } from "effect"
import { Agent } from "../agent"
import { Database } from "../database/database"
import { Bus } from "../bus"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { isExactRootFallback } from "@opencode-ai/util/session-title-fallback"
import { App } from "../app"
import { llmClient } from "../effect/app-node-platform"
import { SessionEvent } from "./event"
@@ -14,8 +15,10 @@ import { SessionModelHeaders } from "./model-headers"
import { SessionRunnerModel } from "./runner/model"
import { SessionSchema } from "./schema"
import { SessionUsage } from "./usage"
import { SessionStore } from "./store"
const MAX_LENGTH = 100
const titleChanged = Symbol("Session title changed")
type Dependencies = {
readonly app: App.Info
@@ -25,24 +28,33 @@ type Dependencies = {
}
readonly agents: Agent.Interface
readonly models: SessionRunnerModel.Interface
readonly store: SessionStore.Interface
}
export interface Interface {
/** Generates a title from the session's first user message and renames the session. Runs at most once per session. */
readonly generateForFirstPrompt: (session: SessionSchema.Info) => Effect.Effect<void>
/** Generates a title from the session's first user message when the session remains untitled. */
readonly generateForFirstPrompt: (sessionID: SessionSchema.ID) => Effect.Effect<void>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/SessionTitle") {}
const truncate = (value: string) => (value.length <= MAX_LENGTH ? value : `${value.slice(0, MAX_LENGTH - 3)}...`)
const isUntitled = (session: SessionSchema.Info) =>
isExactRootFallback({
title: session.title,
time: { created: DateTime.toEpochMillis(session.time.created) },
})
const make = (dependencies: Dependencies) => {
const generateForFirstPrompt = Effect.fn("SessionTitle.generateForFirstPrompt")(function* (
db: Database.Interface["db"],
session: SessionSchema.Info,
sessionID: SessionSchema.ID,
) {
const session = yield* dependencies.store.get(sessionID)
if (!session) return
if (session.parentID) return
const firstUser = yield* SessionHistory.firstUserMessageIfOnly(db, session.id)
if (!isUntitled(session)) return
const firstUser = yield* SessionHistory.firstUserMessage(db, session.id)
if (!firstUser) return
const agent = yield* dependencies.agents.get(Agent.ID.make("title"))
if (!agent) return
@@ -96,10 +108,19 @@ const make = (dependencies: Dependencies) => {
.map((line) => line.trim())
.find((line) => line.length > 0)
if (!title) return
yield* dependencies.bus.publish(SessionEvent.Renamed, {
sessionID: session.id,
title: truncate(title),
})
const expectedSequence = (yield* Bus.latestSequence(db, sessionID)) + 1
const current = yield* dependencies.store.get(sessionID)
if (!current || !isUntitled(current)) return
yield* dependencies.bus
.publish(
SessionEvent.Renamed,
{
sessionID: session.id,
title: truncate(title),
},
{ commit: (sequence) => (sequence === expectedSequence ? Effect.void : Effect.die(titleChanged)) },
)
.pipe(Effect.catchDefect((defect) => (defect === titleChanged ? Effect.void : Effect.die(defect))))
})
return { generateForFirstPrompt }
}
@@ -111,11 +132,12 @@ export const layer = Layer.effect(
const llm = yield* LLMClient.Service
const agents = yield* Agent.Service
const models = yield* SessionRunnerModel.Service
const store = yield* SessionStore.Service
const database = yield* Database.Service
const app = yield* App.Metadata
const title = make({ bus, llm, agents, models, app })
const title = make({ bus, llm, agents, models, store, app })
return Service.of({
generateForFirstPrompt: (session) => title.generateForFirstPrompt(database.db, session),
generateForFirstPrompt: (sessionID) => title.generateForFirstPrompt(database.db, sessionID),
})
}),
)
@@ -123,5 +145,5 @@ export const layer = Layer.effect(
export const node = makeLocationNode({
service: Service,
layer,
deps: [Bus.node, llmClient, Agent.node, SessionRunnerModel.node, Database.node, App.node],
deps: [Bus.node, llmClient, Agent.node, SessionRunnerModel.node, SessionStore.node, Database.node, App.node],
})
+106
View File
@@ -0,0 +1,106 @@
import { describe, expect, test } from "bun:test"
import { AISDKNative } from "@opencode-ai/core/aisdk-native"
describe("AISDKNative", () => {
test("maps every Google thinking setting", () => {
expect(
AISDKNative.map("@ai-sdk/google", {
cachedContent: "cachedContents/example",
safetySettings: [{ category: "HARM_CATEGORY_HARASSMENT", threshold: "BLOCK_NONE" }],
serviceTier: "flex",
thinkingConfig: {
thinkingBudget: 0,
includeThoughts: false,
thinkingLevel: "high",
unknown: true,
},
}),
).toEqual({
package: "@opencode-ai/ai/providers/google",
settings: {
providerOptions: {
gemini: {
cachedContent: "cachedContents/example",
safetySettings: [{ category: "HARM_CATEGORY_HARASSMENT", threshold: "BLOCK_NONE" }],
serviceTier: "flex",
thinkingConfig: {
thinkingBudget: 0,
includeThoughts: false,
thinkingLevel: "high",
},
},
},
},
})
})
test("maps Google thinking settings independently", () => {
for (const thinkingConfig of [{ thinkingBudget: -1 }, { includeThoughts: true }, { thinkingLevel: "medium" }]) {
expect(AISDKNative.map("@ai-sdk/google", { thinkingConfig })).toMatchObject({
settings: { providerOptions: { gemini: { thinkingConfig } } },
})
}
})
test("maps Google request options without thinking settings", () => {
expect(
AISDKNative.map("@ai-sdk/google", {
cachedContent: "cachedContents/example",
safetySettings: [{ category: "HARM_CATEGORY_HARASSMENT", threshold: "BLOCK_NONE" }],
serviceTier: "future-tier",
}),
).toMatchObject({
settings: {
providerOptions: {
gemini: {
cachedContent: "cachedContents/example",
safetySettings: [{ category: "HARM_CATEGORY_HARASSMENT", threshold: "BLOCK_NONE" }],
serviceTier: "future-tier",
},
},
},
})
})
test("maps supported xAI settings", () => {
expect(
AISDKNative.map("@ai-sdk/xai", {
apiKey: "secret",
baseURL: "https://xai.example/v1",
reasoningEffort: "custom",
store: true,
promptCacheKey: "cache-key",
}),
).toEqual({
package: "@opencode-ai/ai/providers/xai",
settings: {
apiKey: "secret",
baseURL: "https://xai.example/v1",
providerOptions: {
xai: {
reasoningEffort: "custom",
store: true,
promptCacheKey: "cache-key",
},
},
},
})
})
test("omits invalid and unsupported xAI settings", () => {
expect(
AISDKNative.map("@ai-sdk/xai", {
reasoningEffort: 10,
store: "yes",
include: ["unknown"],
logprobs: true,
topLogprobs: 8,
previousResponseId: "response-id",
searchParameters: { mode: "auto" },
}),
).toEqual({
package: "@opencode-ai/ai/providers/xai",
settings: {},
})
})
})
@@ -26,6 +26,7 @@ import timeSuspendedMigration from "@opencode-ai/core/database/migration/2026070
import instructionSyncMigration from "@opencode-ai/core/database/migration/20260710025429_instruction_sync"
import deleteToolProgressEventsMigration from "@opencode-ai/core/database/migration/20260722011141_delete_tool_progress_events"
import canonicalToolResultsMigration from "@opencode-ai/core/database/migration/20260722170000_canonical_tool_results"
import optionalSessionTitleMigration from "@opencode-ai/core/database/migration/20260730195856_optional_session_title"
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"
@@ -399,6 +400,40 @@ describe("DatabaseMigration", () => {
).rejects.toThrow("Database is not empty and has no session table")
})
test("makes session titles nullable without deleting dependent rows", async () => {
await run(
Effect.gen(function* () {
const db = yield* makeDb
yield* db.run(sql`PRAGMA foreign_keys = ON`)
yield* db.run(sql`
CREATE TABLE session (
id text PRIMARY KEY,
title text NOT NULL
)
`)
yield* db.run(sql`
CREATE TABLE message (
id text PRIMARY KEY,
session_id text NOT NULL REFERENCES session(id) ON DELETE CASCADE
)
`)
yield* db.run(sql`INSERT INTO session VALUES ('ses_existing', 'Existing title')`)
yield* db.run(sql`INSERT INTO message VALUES ('msg_existing', 'ses_existing')`)
yield* DatabaseMigration.applyOnly(db, [optionalSessionTitleMigration])
expect(yield* db.get(sql`SELECT title FROM session WHERE id = 'ses_existing'`)).toEqual({
title: "Existing title",
})
expect(yield* db.get(sql`SELECT id FROM message WHERE id = 'msg_existing'`)).toEqual({ id: "msg_existing" })
expect(
yield* db.get<{ notnull: number }>(sql`SELECT "notnull" FROM pragma_table_info('session') WHERE name = 'title'`),
).toEqual({ notnull: 0 })
expect(yield* db.get<{ foreign_keys: number }>(sql`PRAGMA foreign_keys`)).toEqual({ foreign_keys: 1 })
}),
)
})
test("backfills existing Context Epoch rows to the build agent", async () => {
await run(
Effect.gen(function* () {
@@ -1,5 +1,5 @@
import { describe, test, expect } from "bun:test"
import { Effect, FileSystem } from "effect"
import { Effect, FileSystem, Layer } from "effect"
import { LayerNodePlatform } from "@opencode-ai/util/effect/app-node-platform"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { FSUtil } from "@opencode-ai/util/fs-util"
@@ -267,6 +267,33 @@ describe("FSUtil", () => {
expect(result).toContain(path.join(tmp, "b.txt"))
}),
)
it(
"stops at the first match when requested",
Effect.gen(function* () {
const filesys = yield* FileSystem.FileSystem
const tmp = yield* filesys.makeTempDirectoryScoped()
yield* filesys.writeFileString(path.join(tmp, "marker"), "root")
const child = path.join(tmp, "sub")
yield* filesys.makeDirectory(child)
yield* filesys.writeFileString(path.join(child, "marker"), "child")
const checked: string[] = []
const instrumented = FileSystem.FileSystem.of({
...filesys,
exists: (target) => Effect.sync(() => checked.push(target)).pipe(Effect.andThen(filesys.exists(target))),
})
const search = yield* FSUtil.Service.pipe(
Effect.provide(
FSUtil.layer.pipe(Layer.fresh, Layer.provide(Layer.succeed(FileSystem.FileSystem, instrumented))),
),
)
expect(yield* search.up({ targets: ["marker", "other"], start: child, mode: "first" })).toEqual([
path.join(child, "marker"),
])
expect(checked).toEqual([path.join(child, "marker")])
}),
)
})
describe("glob", () => {
+1 -1
View File
@@ -15,7 +15,7 @@ import { testEffect } from "./lib/effect"
const selected = Info.make({
...Info.default(Provider.ID.make("test-provider"), ID.make("gemini")),
package: Provider.aisdk("@ai-sdk/google"),
package: Provider.aisdk("@ai-sdk/mistral"),
})
const runtime = Model.make({ id: "gemini", provider: "test-provider", route: OpenAIChat.route })
+37
View File
@@ -1,6 +1,7 @@
import path from "node:path"
import { describe, expect, test } from "bun:test"
import { Client } from "@modelcontextprotocol/sdk/client/index.js"
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"
import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"
import { Server } from "@modelcontextprotocol/sdk/server/index.js"
import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js"
@@ -559,6 +560,42 @@ test("lists, reads, and reports MCP resource changes", async () => {
)
})
test("does not reconnect an SSE stream after a JSON-RPC error response", async () => {
let requests = 0
const transport = new StreamableHTTPClientTransport(new URL("http://mcp.invalid"), {
fetch: async () => {
requests += 1
return new Response(
new ReadableStream({
start(controller) {
controller.enqueue(new TextEncoder().encode("id: prime\nretry: 1\ndata:\n\n"))
controller.enqueue(
new TextEncoder().encode(
'id: error\ndata: {"jsonrpc":"2.0","error":{"code":-32601,"message":"Method not found"},"id":1}\n\n',
),
)
controller.close()
},
}),
{ status: 200, headers: { "content-type": "text/event-stream" } },
)
},
reconnectionOptions: {
initialReconnectionDelay: 1,
maxReconnectionDelay: 1,
reconnectionDelayGrowFactor: 1,
maxRetries: 2,
},
})
await transport.start()
await transport.send({ jsonrpc: "2.0", method: "resources/list", id: 1 })
await Bun.sleep(25)
await transport.close()
expect(requests).toBe(1)
})
test("skips MCP resource requests when the capability is absent", async () => {
await Effect.runPromise(
Effect.scoped(
+101 -76
View File
@@ -307,12 +307,12 @@ describe("ModelResolver", () => {
}),
)
it.effect("routes ChatGPT OAuth credentials to the codex backend", () =>
it.effect("applies plugin-projected OpenAI endpoint and headers", () =>
Effect.gen(function* () {
const resolved = yield* ModelResolver.fromCatalogModel(
model(Provider.aisdk("@ai-sdk/openai"), {
settings: { baseURL: "https://openai.example/v1" },
headers: {},
model("@opencode-ai/ai/providers/openai", {
settings: { baseURL: "https://chatgpt.com/backend-api/codex" },
headers: { "chatgpt-account-id": "acct_123" },
body: {},
}),
Credential.OAuth.make({
@@ -337,37 +337,8 @@ describe("ModelResolver", () => {
id: "openai-responses",
endpoint: { baseURL: "https://chatgpt.com/backend-api/codex" },
})
expect(resolved.route.defaults.headers).toMatchObject({ "chatgpt-account-id": "acct_123" })
expect(headers.authorization).toBe("Bearer chatgpt-token")
expect(headers["chatgpt-account-id"]).toBe("acct_123")
}),
)
it.effect("routes native OpenAI provider packages with ChatGPT credentials to the codex backend", () =>
Effect.gen(function* () {
const resolved = yield* ModelResolver.fromCatalogModel(
model("@opencode-ai/ai/providers/openai", {
settings: { baseURL: "https://openai.example/v1" },
}),
Credential.OAuth.make({
type: "oauth",
methodID: Integration.MethodID.make("chatgpt-browser"),
access: "chatgpt-token",
refresh: "refresh",
expires: Date.now() + 60_000,
metadata: { accountID: "acct_123" },
}),
)
const headers = yield* resolved.route.auth.apply({
request: LLM.request({ model: resolved, prompt: "Hello" }),
method: "POST",
url: "https://chatgpt.com/backend-api/codex/responses",
body: "{}",
headers: Headers.empty,
})
expect(resolved.route.endpoint.baseURL).toBe("https://chatgpt.com/backend-api/codex")
expect(headers.authorization).toBe("Bearer chatgpt-token")
expect(headers["chatgpt-account-id"]).toBe("acct_123")
}),
)
@@ -407,37 +378,6 @@ describe("ModelResolver", () => {
}),
)
it.effect("routes ChatGPT OAuth credentials without an account id to the codex backend", () =>
Effect.gen(function* () {
const resolved = yield* ModelResolver.fromCatalogModel(
model(Provider.aisdk("@ai-sdk/openai"), {
settings: { baseURL: "https://openai.example/v1" },
headers: {},
body: {},
}),
Credential.OAuth.make({
type: "oauth",
methodID: Integration.MethodID.make("chatgpt-headless"),
access: "chatgpt-token",
refresh: "refresh",
expires: Date.now() + 60_000,
}),
)
const request = LLM.request({ model: resolved, prompt: "Hello" })
const headers = yield* resolved.route.auth.apply({
request,
method: "POST",
url: "https://chatgpt.com/backend-api/codex/responses",
body: "{}",
headers: Headers.empty,
})
expect(resolved.route.endpoint.baseURL).toBe("https://chatgpt.com/backend-api/codex")
expect(headers.authorization).toBe("Bearer chatgpt-token")
expect(headers["chatgpt-account-id"]).toBeUndefined()
}),
)
it.effect("keeps non-ChatGPT OAuth credentials on the configured endpoint", () =>
Effect.gen(function* () {
const resolved = yield* ModelResolver.fromCatalogModel(
@@ -546,6 +486,91 @@ describe("ModelResolver", () => {
}),
)
it.effect("routes supported AISDK catalog packages through native provider packages", () =>
Effect.gen(function* () {
const native = yield* ModelResolver.fromCatalogModel(model(Provider.aisdk("@ai-sdk/openai")))
const packages = [
[
"@ai-sdk/google",
"@opencode-ai/ai/providers/google",
{ thinkingConfig: { thinkingLevel: "high" } },
{ gemini: { thinkingConfig: { thinkingLevel: "high" } } },
],
[
"@openrouter/ai-sdk-provider",
"@opencode-ai/ai/providers/openrouter",
{ reasoning: { effort: "high" } },
{ openrouter: { reasoning: { effort: "high" } } },
],
[
"@ai-sdk/xai",
"@opencode-ai/ai/providers/xai",
{ reasoningEffort: "high" },
{ xai: { reasoningEffort: "high" } },
],
] as const
yield* Effect.forEach(packages, ([catalogPackage, nativePackage, sourceOptions, providerOptions]) =>
ModelResolver.fromCatalogModel(
model(Provider.aisdk(catalogPackage), {
modelID: "api-model",
settings: { baseURL: "https://provider.example/v1", ...sourceOptions },
headers: { "x-provider": "header" },
body: { custom: true },
}),
Credential.Key.make({ type: "key", key: "secret" }),
{
loadPackage: (specifier) => {
expect(specifier).toBe(nativePackage)
return Effect.succeed({
model: (modelID, settings) => {
expect(modelID).toBe("api-model")
expect(settings).toMatchObject({
apiKey: "secret",
baseURL: "https://provider.example/v1",
headers: { "x-provider": "header" },
body: { custom: true },
limits: { context: 100, output: 20 },
providerOptions,
})
return Model.make({ id: modelID, provider: "native-provider", route: native.route })
},
})
},
loadAISDK: () => Effect.die("AI SDK loader should not be called"),
},
),
)
}),
)
it.effect("loads supported AISDK catalog packages as native routes", () =>
Effect.gen(function* () {
const google = yield* ModelResolver.fromCatalogModel(
model(Provider.aisdk("@ai-sdk/google"), { settings: { thinkingConfig: { thinkingBudget: 1_024 } } }),
)
const openrouter = yield* ModelResolver.fromCatalogModel(
model(Provider.aisdk("@openrouter/ai-sdk-provider"), {
settings: { reasoning: { effort: "high" } },
}),
)
const xai = yield* ModelResolver.fromCatalogModel(
model(Provider.aisdk("@ai-sdk/xai"), { settings: { reasoningEffort: "high" } }),
)
expect(google.route.id).toBe("gemini")
expect(google.route.defaults.providerOptions).toEqual({
gemini: { thinkingConfig: { thinkingBudget: 1_024 } },
})
expect(openrouter.route.id).toBe("openrouter")
expect(openrouter.route.defaults.providerOptions).toEqual({ openrouter: { reasoning: { effort: "high" } } })
expect(xai.route.id).toBe("openai-responses")
expect(xai.route.defaults.providerOptions).toEqual({
xai: { reasoningEffort: "high", store: false },
})
}),
)
it.effect("loads arbitrary AISDK packages through the injected AISDK loader", () =>
Effect.gen(function* () {
const native = yield* ModelResolver.fromCatalogModel(
@@ -554,8 +579,8 @@ describe("ModelResolver", () => {
}),
)
const resolved = yield* ModelResolver.fromCatalogModel(
model(Provider.aisdk("@ai-sdk/google"), {
modelID: "gemini-api-model",
model(Provider.aisdk("@ai-sdk/mistral"), {
modelID: "mistral-api-model",
settings: { project: "test" },
headers: { "x-aisdk": "header" },
body: { custom: true },
@@ -566,9 +591,9 @@ describe("ModelResolver", () => {
Effect.sync(() => {
expect(runtime).toMatchObject({
id: "test-model",
modelID: "gemini-api-model",
modelID: "mistral-api-model",
providerID: "test-provider",
package: Provider.aisdk("@ai-sdk/google"),
package: Provider.aisdk("@ai-sdk/mistral"),
settings: { project: "test", apiKey: "fallback-secret" },
headers: { "x-aisdk": "header" },
body: { custom: true },
@@ -582,15 +607,15 @@ describe("ModelResolver", () => {
},
)
expect(resolved).toMatchObject({ id: "gemini-api-model", provider: "test-provider" })
expect(resolved).toMatchObject({ id: "mistral-api-model", provider: "test-provider" })
}),
)
it.effect("rejects AISDK packages without an available loader", () =>
Effect.gen(function* () {
const failure = yield* ModelResolver.fromCatalogModel(
model(Provider.aisdk("@ai-sdk/google"), {
settings: { baseURL: "https://google.example/v1" },
model(Provider.aisdk("@ai-sdk/mistral"), {
settings: { baseURL: "https://mistral.example/v1" },
}),
).pipe(Effect.flip)
@@ -598,9 +623,9 @@ describe("ModelResolver", () => {
_tag: "SessionRunnerModel.UnsupportedPackageError",
providerID: "test-provider",
modelID: "test-model",
package: "aisdk:@ai-sdk/google",
package: "aisdk:@ai-sdk/mistral",
})
expect(failure.message).toBe("Unsupported package for test-provider/test-model: aisdk:@ai-sdk/google")
expect(failure.message).toBe("Unsupported package for test-provider/test-model: aisdk:@ai-sdk/mistral")
}),
)
@@ -612,8 +637,8 @@ describe("ModelResolver", () => {
}),
)
yield* ModelResolver.fromCatalogModel(
model(Provider.aisdk("@ai-sdk/google"), {
settings: { apiKey: "", baseURL: "https://google.example/v1" },
model(Provider.aisdk("@ai-sdk/mistral"), {
settings: { apiKey: "", baseURL: "https://mistral.example/v1" },
}),
undefined,
{
+67 -140
View File
@@ -1,7 +1,7 @@
import { AISDK } from "@opencode-ai/core/aisdk"
import { Money } from "@opencode-ai/schema/money"
import { Agent } from "@opencode-ai/schema/agent"
import { Session } from "@opencode-ai/schema/session"
import { describe, expect } from "bun:test"
import type { LanguageModelV3 } from "@ai-sdk/provider"
import { Effect } from "effect"
import { Catalog } from "@opencode-ai/core/catalog"
import { Credential } from "@opencode-ai/core/credential"
@@ -9,6 +9,7 @@ import { Integration } from "@opencode-ai/core/integration"
import { Model } from "@opencode-ai/core/model"
import { Plugin } from "@opencode-ai/core/plugin"
import { PluginHost } from "@opencode-ai/core/plugin/host"
import { PluginHooks } from "@opencode-ai/core/plugin/hooks"
import { OpenAIPlugin } from "@opencode-ai/core/plugin/provider/openai"
import { Provider } from "@opencode-ai/core/provider"
import { testEffect } from "../lib/effect"
@@ -18,7 +19,6 @@ const it = testEffect(PluginTestLayer)
const addPlugin = Effect.fn(function* () {
const plugin = yield* Plugin.Service
const aisdk = yield* AISDK.Service
const host = yield* PluginHost.make(plugin)
const integrations = yield* Integration.Service
yield* OpenAIPlugin.effect(host).pipe(Effect.provideService(Integration.Service, integrations))
@@ -29,19 +29,6 @@ function required<T>(value: T | undefined): T {
return value
}
function fakeSelectorSdk(calls: string[]) {
const make = (method: string) => (id: string) => {
calls.push(`${method}:${id}`)
return { modelId: id, provider: method, specificationVersion: "v3" } as unknown as LanguageModelV3
}
return {
responses: make("responses"),
messages: make("messages"),
chat: make("chat"),
languageModel: make("languageModel"),
}
}
describe("OpenAIPlugin", () => {
it.effect("registers browser and headless ChatGPT OAuth methods", () =>
Effect.gen(function* () {
@@ -61,104 +48,6 @@ describe("OpenAIPlugin", () => {
}),
)
it.effect("creates an OpenAI SDK for @ai-sdk/openai using the provider ID as SDK name", () =>
Effect.gen(function* () {
const plugin = yield* Plugin.Service
const aisdk = yield* AISDK.Service
yield* addPlugin()
const result = yield* aisdk.runSDK({
model: Model.Info.make({
...Model.Info.default(Provider.ID.make("custom-openai"), Model.ID.make("gpt-5")),
modelID: Model.ID.make("gpt-5"),
package: Provider.aisdk("test-provider"),
}),
package: "@ai-sdk/openai",
options: { name: "custom-openai", apiKey: "test" },
})
expect(result.sdk?.responses("gpt-5").provider).toBe("custom-openai.responses")
}),
)
it.effect("ignores non-OpenAI SDK packages", () =>
Effect.gen(function* () {
const plugin = yield* Plugin.Service
const aisdk = yield* AISDK.Service
yield* addPlugin()
const result = yield* aisdk.runSDK({
model: Model.Info.make({
...Model.Info.default(Provider.ID.openai, Model.ID.make("gpt-5")),
modelID: Model.ID.make("gpt-5"),
package: Provider.aisdk("test-provider"),
}),
package: "@ai-sdk/openai-compatible",
options: { name: "openai" },
})
expect(result.sdk).toBeUndefined()
}),
)
it.effect("uses the Responses API for language models", () =>
Effect.gen(function* () {
const plugin = yield* Plugin.Service
const aisdk = yield* AISDK.Service
const calls: string[] = []
yield* addPlugin()
const result = yield* aisdk.runLanguage({
model: Model.Info.make({
...Model.Info.default(Provider.ID.openai, Model.ID.make("alias")),
modelID: Model.ID.make("gpt-5"),
package: Provider.aisdk("test-provider"),
}),
sdk: fakeSelectorSdk(calls),
options: {},
})
expect(calls).toEqual(["responses:gpt-5"])
expect(result.language).toBeDefined()
}),
)
it.effect("ignores non-OpenAI providers", () =>
Effect.gen(function* () {
const plugin = yield* Plugin.Service
const aisdk = yield* AISDK.Service
const calls: string[] = []
yield* addPlugin()
const result = yield* aisdk.runLanguage({
model: Model.Info.make({
...Model.Info.default(Provider.ID.anthropic, Model.ID.make("gpt-5")),
modelID: Model.ID.make("gpt-5"),
package: Provider.aisdk("test-provider"),
}),
sdk: fakeSelectorSdk(calls),
options: {},
})
expect(calls).toEqual([])
expect(result.language).toBeUndefined()
}),
)
it.effect("disables gpt-5-chat-latest during catalog transforms", () =>
Effect.gen(function* () {
const catalog = yield* Catalog.Service
yield* catalog.transform((catalog) => {
const item = Provider.Info.make({
...Provider.Info.empty(Provider.ID.openai),
package: Provider.aisdk("@ai-sdk/openai"),
})
catalog.provider.update(item.id, (draft) => {
draft.package = item.package
})
catalog.model.update(item.id, Model.ID.make("gpt-5"), () => {})
catalog.model.update(item.id, Model.ID.make("gpt-5-chat-latest"), () => {})
})
yield* addPlugin()
expect(required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-5"))).enabled).toBe(true)
expect(
required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-5-chat-latest"))).enabled,
).toBe(false)
}),
)
it.effect("filters the OpenAI catalog to codex-eligible models under a ChatGPT connection", () =>
Effect.gen(function* () {
const catalog = yield* Catalog.Service
@@ -172,6 +61,7 @@ describe("OpenAIPlugin", () => {
draft.package = item.package
})
catalog.model.update(item.id, Model.ID.make("gpt-5.5"), (model) => {
model.limit = { context: 1_050_000, input: 922_000, output: 128_000 }
model.cost = [
{
input: Money.USDPerMillionTokens.make(1),
@@ -189,7 +79,9 @@ describe("OpenAIPlugin", () => {
model.body = { reasoning: { mode: "pro" } }
})
catalog.model.update(item.id, Model.ID.make("gpt-5.6"), () => {})
catalog.model.update(item.id, Model.ID.make("gpt-5.6-sol"), () => {})
catalog.model.update(item.id, Model.ID.make("gpt-5.6-sol"), (model) => {
model.limit = { context: 1_050_000, input: 922_000, output: 128_000 }
})
catalog.model.update(item.id, Model.ID.make("gpt-4.1"), () => {})
})
yield* credentials.create({
@@ -205,8 +97,47 @@ describe("OpenAIPlugin", () => {
})
yield* addPlugin()
const request = yield* (yield* PluginHooks.Service).trigger("session", "request", {
sessionID: Session.ID.make("ses_test"),
agent: Agent.ID.make("build"),
model: Model.Ref.make({ providerID: Provider.ID.openai, id: Model.ID.make("gpt-5.5") }),
url: "https://api.openai.com/v1/responses",
method: "POST",
headers: {},
body: "{}",
})
const custom = yield* (yield* PluginHooks.Service).trigger("session", "request", {
sessionID: Session.ID.make("ses_test"),
agent: Agent.ID.make("build"),
model: Model.Ref.make({ providerID: Provider.ID.make("custom-openai"), id: Model.ID.make("gpt-5.5") }),
url: "https://custom.example/v1/responses",
method: "POST",
headers: {},
body: "{}",
})
const proxy = yield* (yield* PluginHooks.Service).trigger("session", "request", {
sessionID: Session.ID.make("ses_test"),
agent: Agent.ID.make("build"),
model: Model.Ref.make({ providerID: Provider.ID.openai, id: Model.ID.make("gpt-5.5") }),
url: "https://proxy.example/v1/responses?region=us",
method: "POST",
headers: {},
body: "{}",
})
const provider = required(yield* catalog.provider.get(Provider.ID.openai))
expect(provider.package).toBe("@opencode-ai/ai/providers/openai")
expect(provider.settings).toMatchObject({ baseURL: "https://chatgpt.com/backend-api/codex" })
expect(provider.headers).toMatchObject({ "chatgpt-account-id": "acct_123" })
expect(request.url).toBe("https://chatgpt.com/backend-api/codex/responses")
expect(request.headers).toMatchObject({ originator: "opencode", "session-id": "ses_test" })
expect(custom.headers).toEqual({})
expect(proxy.url).toBe("https://proxy.example/v1/responses?region=us")
expect(proxy.headers).toMatchObject({ originator: "opencode", "session-id": "ses_test" })
const eligible = required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-5.5")))
expect(eligible.package).toBe("@opencode-ai/ai/providers/openai")
expect(eligible.cost).toEqual([])
expect(eligible.limit).toEqual({ context: 400_000, input: 272_000, output: 128_000 })
expect(eligible.enabled).toBe(true)
expect(required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-5.5-pro"))).enabled).toBe(
false,
@@ -215,9 +146,9 @@ describe("OpenAIPlugin", () => {
false,
)
expect(required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-5.6"))).enabled).toBe(false)
expect(required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-5.6-sol"))).enabled).toBe(
true,
)
const gpt56 = required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-5.6-sol")))
expect(gpt56.enabled).toBe(true)
expect(gpt56.limit).toEqual({ context: 500_000, input: 372_000, output: 128_000 })
expect(required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-4.1"))).enabled).toBe(false)
}),
)
@@ -234,7 +165,9 @@ describe("OpenAIPlugin", () => {
catalog.provider.update(item.id, (draft) => {
draft.package = item.package
})
catalog.model.update(item.id, Model.ID.make("gpt-5.5"), () => {})
catalog.model.update(item.id, Model.ID.make("gpt-5.5"), (model) => {
model.limit = { context: 1_050_000, input: 922_000, output: 128_000 }
})
catalog.model.update(item.id, Model.ID.make("gpt-4.1"), () => {})
})
yield* credentials.create({
@@ -243,29 +176,23 @@ describe("OpenAIPlugin", () => {
})
yield* addPlugin()
expect(required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-5.5"))).enabled).toBe(true)
const request = yield* (yield* PluginHooks.Service).trigger("session", "request", {
sessionID: Session.ID.make("ses_test"),
agent: Agent.ID.make("build"),
model: Model.Ref.make({ providerID: Provider.ID.openai, id: Model.ID.make("gpt-5.5") }),
url: "https://api.openai.com/v1/responses",
method: "POST",
headers: {},
body: "{}",
})
const model = required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-5.5")))
expect(model.package).toBe("@opencode-ai/ai/providers/openai")
expect(model.enabled).toBe(true)
expect(model.limit).toEqual({ context: 1_050_000, input: 922_000, output: 128_000 })
expect(request.headers).toEqual({})
expect(required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-4.1"))).enabled).toBe(true)
}),
)
it.effect("does not disable gpt-5-chat-latest for non-OpenAI providers", () =>
Effect.gen(function* () {
const catalog = yield* Catalog.Service
yield* catalog.transform((catalog) => {
const item = Provider.Info.make({
...Provider.Info.empty(Provider.ID.make("custom-openai")),
package: Provider.aisdk("test-provider"),
})
catalog.provider.update(item.id, (draft) => {
draft.package = item.package
})
catalog.model.update(item.id, Model.ID.make("gpt-5-chat-latest"), () => {})
})
yield* addPlugin()
expect(
required(yield* catalog.model.get(Provider.ID.make("custom-openai"), Model.ID.make("gpt-5-chat-latest")))
.enabled,
).toBe(true)
}),
)
})
+1
View File
@@ -143,6 +143,7 @@ describe("Project.resolve", () => {
expect(result.id).toBe(Project.ID.make(yield* Effect.promise(() => rootCommit(tmp.path))))
expect(result.directory).toBe(yield* real(tmp.path))
expect(result.canonical).toBe(result.directory)
expect(result.previous).toBeUndefined()
expect(result.vcs?.type).toBe("git")
}),
+62
View File
@@ -71,6 +71,27 @@ function withTmp<A, E, R>(f: (directory: string) => Effect.Effect<A, E, R>) {
}
describe("Session.create", () => {
it.effect("persists a missing title until one is generated or supplied", () =>
Effect.gen(function* () {
const session = yield* Session.Service
const { db } = yield* Database.Service
const created = yield* session.create({ location })
const row = yield* db.select().from(SessionTable).where(eq(SessionTable.id, created.id)).get().pipe(Effect.orDie)
const event = yield* db
.select({ data: EventTable.data })
.from(EventTable)
.where(eq(EventTable.aggregate_id, created.id))
.get()
.pipe(Effect.orDie)
expect(created.title).toBeUndefined()
expect(row?.title).toBeNull()
expect(event?.data).not.toHaveProperty("info.title")
expect((yield* session.create({ location, title: "Explicit title" })).title).toBe("Explicit title")
}),
)
it.effect("creates a fresh projected session when the ID is omitted", () =>
Effect.gen(function* () {
const session = yield* Session.Service
@@ -171,6 +192,30 @@ describe("Session.create", () => {
}),
)
it.effect("orders sessions by their latest prompt", () =>
Effect.gen(function* () {
const session = yield* Session.Service
const { db } = yield* Database.Service
const active = yield* session.create({ location, title: "active" })
const newer = yield* session.create({ location, title: "newer" })
yield* db
.update(SessionTable)
.set({ time_created: -2, time_updated: -2 })
.where(eq(SessionTable.id, active.id))
.run()
yield* db
.update(SessionTable)
.set({ time_created: -1, time_updated: -1 })
.where(eq(SessionTable.id, newer.id))
.run()
yield* session.prompt({ sessionID: active.id, text: "continue", resume: false })
expect((yield* session.list()).data.map((item) => item.id)).toEqual([active.id, newer.id])
}),
)
it.effect("filters direct child sessions by parent ID", () =>
Effect.gen(function* () {
const session = yield* Session.Service
@@ -272,6 +317,23 @@ describe("Session.create", () => {
}),
)
it.effect("keeps a fork untitled when its parent is untitled", () =>
Effect.gen(function* () {
const session = yield* Session.Service
const bus = yield* Bus.Service
const { db } = yield* Database.Service
const parent = yield* session.create({ location })
yield* session.prompt({ sessionID: parent.id, text: "First", resume: false })
yield* SessionPending.promote(db, bus, parent.id, "steer")
const forked = yield* session.fork({ sessionID: parent.id, boundary: { type: "through" } })
const row = yield* db.select().from(SessionTable).where(eq(SessionTable.id, forked.id)).get().pipe(Effect.orDie)
expect(forked.title).toBeUndefined()
expect(row?.title).toBeNull()
}),
)
it.effect("rejects forking an empty session", () =>
Effect.gen(function* () {
const session = yield* Session.Service
+53
View File
@@ -796,6 +796,59 @@ const verifyPartialFlushOnInterruption = (kind: FragmentKind) =>
})
describe("SessionRunnerLLM", () => {
it.effect("retries title generation from the first prompt after execution and title failures", () =>
Effect.gen(function* () {
const session = yield* setup
const agents = yield* Agent.Service
const { db } = yield* Database.Service
yield* db.update(SessionTable).set({ title: null }).where(eq(SessionTable.id, sessionID)).run().pipe(Effect.orDie)
yield* agents.transform((draft) =>
draft.update(Agent.ID.make("title"), (agent) => {
agent.mode = "primary"
agent.hidden = true
agent.system = "Generate a title."
}),
)
yield* admit(session, "First prompt")
yield* TestLLM.push(Stream.fail(invalidRequest()))
expect((yield* session.resume(sessionID).pipe(Effect.exit))._tag).toBe("Failure")
yield* admit(session, "Second prompt")
const titleFailed = yield* Deferred.make<void>()
yield* TestLLM.push(
TestLLM.text("Recovered", "text-recovered"),
Stream.make(LLMEvent.providerError({ message: "Title provider unavailable" })).pipe(
Stream.ensuring(Deferred.succeed(titleFailed, undefined)),
),
)
yield* session.resume(sessionID)
yield* Deferred.await(titleFailed)
yield* Effect.yieldNow
expect((yield* session.get(sessionID)).title).toBeUndefined()
const bus = yield* Bus.Service
const renamed = yield* bus.subscribe(SessionEvent.Renamed).pipe(
Stream.filter((event) => event.data.sessionID === sessionID),
Stream.take(1),
Stream.runCollect,
Effect.forkScoped({ startImmediately: true }),
)
yield* admit(session, "Third prompt")
yield* TestLLM.push(
TestLLM.text("Recovered again", "text-recovered-again"),
TestLLM.text("Generated title", "text-title"),
)
yield* session.resume(sessionID)
yield* Fiber.join(renamed)
expect(requests).toHaveLength(5)
expect(requests[2]?.messages).toContainEqual(Message.user("First prompt"))
expect(requests[4]?.messages).toContainEqual(Message.user("First prompt"))
expect((yield* session.get(sessionID)).title).toBe("Generated title")
}),
)
it.effect("applies session context hooks without exposing unavailable tools", () =>
Effect.gen(function* () {
const session = yield* setup
+148 -44
View File
@@ -20,7 +20,7 @@ import { ProjectTable } from "@opencode-ai/core/project/sql"
import { App } from "@opencode-ai/core/app"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { Money } from "@opencode-ai/schema/money"
import { Effect, Layer, Stream } from "effect"
import { Deferred, Effect, Fiber, Layer, Stream } from "effect"
import { testEffect } from "./lib/effect"
let requests: LLMRequest[] = []
@@ -39,27 +39,30 @@ const cost = [
},
},
]
const successfulTitle = () =>
Stream.make(
LLMEvent.textDelta({ id: "title", text: "Generated Title\n" }),
LLMEvent.stepFinish({
index: 0,
reason: { normalized: "stop" },
usage: {
inputTokens: 15,
outputTokens: 6,
nonCachedInputTokens: 10,
cacheReadInputTokens: 3,
cacheWriteInputTokens: 2,
reasoningTokens: 2,
},
}),
LLMEvent.finish({
reason: { normalized: "stop" },
}),
)
let titleStream: () => Stream.Stream<LLMEvent> = successfulTitle
const client = Layer.mock(LLMClient.Service)({
stream: (request: LLMRequest) => {
requests.push(request)
return Stream.make(
LLMEvent.textDelta({ id: "title", text: "Generated Title\n" }),
LLMEvent.stepFinish({
index: 0,
reason: { normalized: "stop" },
usage: {
inputTokens: 15,
outputTokens: 6,
nonCachedInputTokens: 10,
cacheReadInputTokens: 3,
cacheWriteInputTokens: 2,
reasoningTokens: 2,
},
}),
LLMEvent.finish({
reason: { normalized: "stop" },
}),
)
return titleStream()
},
generate: () => Effect.die("unused"),
})
@@ -89,7 +92,7 @@ const it = testEffect(
),
)
const insertSession = (id: Session.ID) =>
const insertSession = (id: Session.ID, title?: string, created?: number) =>
Effect.gen(function* () {
const { db } = yield* Database.Service
yield* db
@@ -105,7 +108,8 @@ const insertSession = (id: Session.ID) =>
project_id: Project.ID.global,
slug: id,
directory: "/project",
title: "New session - fake",
title,
time_created: created,
version: "test",
})
.onConflictDoNothing()
@@ -131,6 +135,7 @@ const prompt = (sessionID: Session.ID, text: string) =>
it.effect("generates a title from the sole user message and renames the session", () =>
Effect.gen(function* () {
requests = []
titleStream = successfulTitle
const agentService = yield* Agent.Service
yield* agentService.transform((editor) => {
editor.update(Agent.ID.make("title"), (agent) => {
@@ -144,11 +149,8 @@ it.effect("generates a title from the sole user message and renames the session"
yield* prompt(sessionID, "Help me debug the failing build")
const store = yield* SessionStore.Service
const session = yield* store
.get(sessionID)
.pipe(Effect.flatMap((session) => (session ? Effect.succeed(session) : Effect.die("session missing"))))
const title = yield* SessionTitle.Service
yield* title.generateForFirstPrompt(session)
yield* title.generateForFirstPrompt(sessionID)
expect(requests).toHaveLength(1)
expect(requests[0]?.http?.headers).toEqual({
@@ -167,9 +169,10 @@ it.effect("generates a title from the sole user message and renames the session"
}),
)
it.effect("does not generate once a second user message exists", () =>
it.effect("generates from the first user message after later messages exist", () =>
Effect.gen(function* () {
requests = []
titleStream = successfulTitle
const agentService = yield* Agent.Service
yield* agentService.transform((editor) => {
editor.update(Agent.ID.make("title"), (agent) => {
@@ -184,21 +187,46 @@ it.effect("does not generate once a second user message exists", () =>
yield* prompt(sessionID, "Second message")
const store = yield* SessionStore.Service
const session = yield* store
.get(sessionID)
.pipe(Effect.flatMap((session) => (session ? Effect.succeed(session) : Effect.die("session missing"))))
const title = yield* SessionTitle.Service
yield* title.generateForFirstPrompt(session)
yield* title.generateForFirstPrompt(sessionID)
expect(requests).toHaveLength(0)
const untouched = yield* store.get(sessionID)
expect(untouched?.title).toBe("New session - fake")
expect(requests).toHaveLength(1)
expect(JSON.stringify(requests[0]?.messages)).toContain("First message")
expect(JSON.stringify(requests[0]?.messages)).not.toContain("Second message")
expect((yield* store.get(sessionID))?.title).toBe("Generated Title")
}),
)
it.effect("retries a legacy persisted fallback title", () =>
Effect.gen(function* () {
requests = []
titleStream = successfulTitle
const agentService = yield* Agent.Service
yield* agentService.transform((editor) => {
editor.update(Agent.ID.make("title"), (agent) => {
agent.mode = "primary"
agent.hidden = true
agent.system = "You are a title generator."
})
})
const sessionID = Session.ID.make("ses_title_legacy")
const created = Date.parse("2026-07-30T18:45:03.662Z")
yield* insertSession(sessionID, "New session - 2026-07-30T18:45:03.662Z", created)
yield* prompt(sessionID, "Retry the legacy title")
const title = yield* SessionTitle.Service
yield* title.generateForFirstPrompt(sessionID)
const store = yield* SessionStore.Service
expect(requests).toHaveLength(1)
expect((yield* store.get(sessionID))?.title).toBe("Generated Title")
}),
)
it.effect("does not generate for a child session", () =>
Effect.gen(function* () {
requests = []
titleStream = successfulTitle
const agentService = yield* Agent.Service
yield* agentService.transform((editor) => {
editor.update(Agent.ID.make("title"), (agent) => {
@@ -223,7 +251,6 @@ it.effect("does not generate for a child session", () =>
parent_id: Session.ID.make("ses_title_parent"),
slug: sessionID,
directory: "/project",
title: "Child session - fake",
version: "test",
})
.onConflictDoNothing()
@@ -231,12 +258,8 @@ it.effect("does not generate for a child session", () =>
.pipe(Effect.orDie)
yield* prompt(sessionID, "Do this subtask")
const store = yield* SessionStore.Service
const session = yield* store
.get(sessionID)
.pipe(Effect.flatMap((session) => (session ? Effect.succeed(session) : Effect.die("session missing"))))
const title = yield* SessionTitle.Service
yield* title.generateForFirstPrompt(session)
yield* title.generateForFirstPrompt(sessionID)
expect(requests).toHaveLength(0)
}),
@@ -245,19 +268,100 @@ it.effect("does not generate for a child session", () =>
it.effect("does not generate when the title agent is removed", () =>
Effect.gen(function* () {
requests = []
titleStream = successfulTitle
const sessionID = Session.ID.make("ses_title_no_agent")
yield* insertSession(sessionID)
yield* prompt(sessionID, "Help me debug the failing build")
const store = yield* SessionStore.Service
const session = yield* store
.get(sessionID)
.pipe(Effect.flatMap((session) => (session ? Effect.succeed(session) : Effect.die("session missing"))))
const title = yield* SessionTitle.Service
yield* title.generateForFirstPrompt(session)
yield* title.generateForFirstPrompt(sessionID)
expect(requests).toHaveLength(0)
const untouched = yield* store.get(sessionID)
expect(untouched?.title).toBe("New session - fake")
expect(untouched?.title).toBeUndefined()
}),
)
it.effect("does not overwrite an explicit title", () =>
Effect.gen(function* () {
requests = []
titleStream = successfulTitle
const sessionID = Session.ID.make("ses_title_explicit")
yield* insertSession(sessionID)
yield* prompt(sessionID, "Help me debug the failing build")
const events = yield* Bus.Service
yield* events.publish(SessionEvent.Renamed, { sessionID, title: "New session - 2099-01-01T00:00:00.000Z" })
const title = yield* SessionTitle.Service
yield* title.generateForFirstPrompt(sessionID)
const store = yield* SessionStore.Service
expect(requests).toHaveLength(0)
expect((yield* store.get(sessionID))?.title).toBe("New session - 2099-01-01T00:00:00.000Z")
}),
)
it.effect("retries after a failed title request", () =>
Effect.gen(function* () {
requests = []
const agentService = yield* Agent.Service
yield* agentService.transform((editor) => {
editor.update(Agent.ID.make("title"), (agent) => {
agent.mode = "primary"
agent.hidden = true
agent.system = "You are a title generator."
})
})
const sessionID = Session.ID.make("ses_title_retry")
yield* insertSession(sessionID)
yield* prompt(sessionID, "Retry this title")
const title = yield* SessionTitle.Service
titleStream = () => Stream.make(LLMEvent.providerError({ message: "Provider unavailable" }))
yield* title.generateForFirstPrompt(sessionID)
titleStream = successfulTitle
yield* title.generateForFirstPrompt(sessionID)
const store = yield* SessionStore.Service
expect(requests).toHaveLength(2)
expect((yield* store.get(sessionID))?.title).toBe("Generated Title")
}),
)
it.effect("preserves a manual rename completed while generation is in flight", () =>
Effect.gen(function* () {
requests = []
const agentService = yield* Agent.Service
yield* agentService.transform((editor) => {
editor.update(Agent.ID.make("title"), (agent) => {
agent.mode = "primary"
agent.hidden = true
agent.system = "You are a title generator."
})
})
const sessionID = Session.ID.make("ses_title_manual_rename")
yield* insertSession(sessionID)
yield* prompt(sessionID, "Generate this title")
const started = yield* Deferred.make<void>()
const release = yield* Deferred.make<void>()
titleStream = () =>
Stream.unwrap(
Deferred.succeed(started, undefined).pipe(
Effect.andThen(Deferred.await(release)),
Effect.as(successfulTitle()),
),
)
const title = yield* SessionTitle.Service
const fiber = yield* title.generateForFirstPrompt(sessionID).pipe(Effect.forkScoped)
yield* Deferred.await(started)
const events = yield* Bus.Service
yield* events.publish(SessionEvent.Renamed, { sessionID, title: "Manual title" })
yield* Deferred.succeed(release, undefined)
yield* Fiber.join(fiber)
const store = yield* SessionStore.Service
expect(requests).toHaveLength(1)
expect((yield* store.get(sessionID))?.title).toBe("Manual title")
}),
)
+1 -1
View File
@@ -47,7 +47,7 @@ const executionNode = makeGlobalNode({
const completed = new Set<Session.ID>()
const complete = Effect.fn("SubagentTest.complete")(function* (sessionID: Session.ID) {
if (completed.has(sessionID)) return
if ((yield* store.get(sessionID))?.title.includes("fail")) {
if ((yield* store.get(sessionID))?.title?.includes("fail")) {
yield* new SessionRunnerModel.ModelNotSelectedError({ sessionID })
return
}
+1
View File
@@ -16,6 +16,7 @@
"@opencode-ai/core": "workspace:*",
"@opencode-ai/session-ui": "workspace:*",
"@opencode-ai/ui": "workspace:*",
"@opencode-ai/util": "workspace:*",
"aws4fetch": "^1.0.20",
"@pierre/diffs": "catalog:",
"@solidjs/router": "catalog:",
@@ -4,6 +4,7 @@ import { SessionReview } from "@opencode-ai/session-ui/session-review"
import { DataProvider } from "@opencode-ai/session-ui/context"
import { FileComponentProvider } from "@opencode-ai/ui/context/file"
import { WorkerPoolProvider } from "@opencode-ai/ui/context/worker-pool"
import { withTimestampedFallback } from "@opencode-ai/util/session-title-fallback"
import { createAsync, query, useParams } from "@solidjs/router"
import { createMemo, createSignal, ErrorBoundary, For, Match, Show, Switch } from "solid-js"
import { Share } from "~/core/share"
@@ -158,6 +159,7 @@ export default function () {
const match = createMemo(() => Binary.search(data().session, data().sessionID, (s) => s.id))
if (!match().found) throw new Error(`Session ${data().sessionID} not found`)
const info = createMemo(() => data().session[match().index])
const title = createMemo(() => withTimestampedFallback(info()))
const ogImage = createMemo(() => {
const models = new Set<string>()
const messages = data().message[data().sessionID] ?? []
@@ -167,7 +169,7 @@ export default function () {
}
}
const modelIDs = Array.from(models)
const encodedTitle = encodeURIComponent(Base64.encode(encodeURIComponent(info().title.substring(0, 700))))
const encodedTitle = encodeURIComponent(Base64.encode(encodeURIComponent(title().substring(0, 700))))
let modelParam: string
if (modelIDs.length === 1) {
modelParam = modelIDs[0]
@@ -184,9 +186,7 @@ export default function () {
return (
<>
<Show when={info().title}>
<Title>{info().title} | OpenCode</Title>
</Show>
<Title>{title()} | OpenCode</Title>
<Meta name="description" content="opencode - The AI coding agent built for the terminal." />
<Meta property="og:image" content={ogImage()} />
<Meta name="twitter:image" content={ogImage()} />
@@ -240,7 +240,7 @@ export default function () {
</div>
</div>
</div>
<div class="text-left text-16-medium text-text-strong">{info().title}</div>
<div class="text-left text-16-medium text-text-strong">{title()}</div>
</div>
)
+8
View File
@@ -1,5 +1,6 @@
import type { SessionApi } from "@opencode-ai/client/effect/api"
import type { Message, SystemPart } from "@opencode-ai/ai"
import type { HttpRequest } from "@opencode-ai/ai/route"
import type { Agent } from "@opencode-ai/schema/agent"
import type { Model } from "@opencode-ai/schema/model"
import type { Session } from "@opencode-ai/schema/session"
@@ -15,8 +16,15 @@ export interface SessionContext {
tools: Record<string, { description: string; input: JsonSchema.JsonSchema }>
}
export interface SessionRequest extends HttpRequest {
readonly sessionID: Session.ID
readonly agent: Agent.ID
readonly model: Model.Ref
}
export interface SessionHooks {
readonly context: SessionContext
readonly request: SessionRequest
}
export type SessionDomain = Pick<
+8
View File
@@ -1,5 +1,6 @@
import type { SessionApi } from "@opencode-ai/client/promise/api"
import type { Message, SystemPart } from "@opencode-ai/ai"
import type { HttpRequest } from "@opencode-ai/ai/route"
import type { Agent } from "@opencode-ai/schema/agent"
import type { Model } from "@opencode-ai/schema/model"
import type { Session } from "@opencode-ai/schema/session"
@@ -15,8 +16,15 @@ export interface SessionContext {
tools: Record<string, { description: string; input: JsonSchema.JsonSchema }>
}
export interface SessionRequest extends HttpRequest {
readonly sessionID: Session.ID
readonly agent: Agent.ID
readonly model: Model.Ref
}
export interface SessionHooks {
readonly context: SessionContext
readonly request: SessionRequest
}
export type SessionDomain = Pick<
+1 -1
View File
@@ -42,7 +42,7 @@ export const Info = Schema.Struct({
updated: DateTimeUtcFromMillis,
archived: DateTimeUtcFromMillis.pipe(optional),
}),
title: Schema.String,
title: Schema.String.pipe(optional),
location: Location.Ref,
subpath: RelativePath.pipe(optional),
revert: Revert.pipe(optional),
+1 -1
View File
@@ -552,7 +552,7 @@ export const SessionInfo = Schema.Struct({
cost: optional(Schema.Finite),
tokens: optional(SessionTokens),
share: optional(SessionShare),
title: Schema.String,
title: optional(Schema.String),
agent: optional(Schema.String),
model: optional(SessionModel),
version: Schema.String,
+13 -1
View File
@@ -17,7 +17,7 @@ import { Money } from "../src/money.js"
import { Skill } from "../src/skill.js"
import { Shell } from "../src/shell.js"
import { PersistedRevert } from "../src/session-revert.js"
import { optional } from "../src/schema.js"
import { AbsolutePath, optional } from "../src/schema.js"
describe("contract hygiene", () => {
test("restricts agent colors to six-digit hex values", () => {
@@ -52,6 +52,18 @@ describe("contract hygiene", () => {
metadata: undefined,
}),
).toEqual({ text: "completed" })
expect(
Schema.encodeSync(Session.Info)({
id: Session.ID.make("ses_untitled"),
projectID: Project.ID.make("global"),
cost: Money.USD.zero,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: DateTime.makeUnsafe(0), updated: DateTime.makeUnsafe(0) },
title: undefined,
location: { directory: AbsolutePath.make("/project") },
}),
).not.toHaveProperty("title")
})
test("pending session items omit the internal admission sequence", () => {
@@ -41,6 +41,7 @@ export const create = Effect.fn("SimulationRenderer.create")(function* (
...options,
width: cols,
height: rows,
kittyKeyboard: Boolean(options.useKittyKeyboard),
...(recording
? {
stdout: recording as unknown as NodeJS.WriteStream,
+19
View File
@@ -43,6 +43,25 @@ test("normalizes named keys for OpenTUI", async () => {
])
})
test("headless input mirrors the configured kitty keyboard protocol", async () => {
await Effect.runPromise(
Effect.scoped(
Effect.gen(function* () {
const renderer = yield* SimulationRenderer.create({ useKittyKeyboard: {} })
const harness = createHarness(renderer)
let key: { readonly name: string; readonly source: string } | undefined
renderer.keyInput.once("keypress", (event) => {
key = event
})
yield* execute(harness, { type: "ui.press", key: "escape" })
expect(key).toMatchObject({ name: "escape", source: "kitty" })
}),
),
)
})
test("clicks a target at relative coordinates through descendant text", async () => {
await Effect.runPromise(
Effect.scoped(
+40 -37
View File
@@ -66,7 +66,7 @@ import { DialogThemeList } from "./component/dialog-theme-list"
import { DialogHelp } from "./ui/dialog-help"
import { DialogAgent } from "./component/dialog-agent"
import { DialogSessionList } from "./component/dialog-session-list"
import { DialogProject } from "./component/dialog-project"
import { DialogOpen } from "./component/dialog-open"
import { SessionTabs } from "./component/session-tabs"
import { ThemeErrorToast } from "./component/theme-error-toast"
import { ThemeProvider, useTheme, useThemes } from "./context/theme"
@@ -76,7 +76,7 @@ import { PromptHistoryProvider } from "./component/prompt/history"
import { FrecencyProvider } from "./component/prompt/frecency"
import { PromptStashProvider } from "./component/prompt/stash"
import { Toast, ToastProvider, useToast } from "./ui/toast"
import { isDefaultTitle } from "./util/session"
import { isFallbackTitle } from "@opencode-ai/util/session-title-fallback"
import * as Model from "./util/model"
import { ArgsProvider, useArgs, type Args } from "./context/args"
import open from "open"
@@ -95,16 +95,15 @@ import { StorageProvider } from "./context/storage"
registerOpencodeSpinner()
const appGlobalBindingCommands = ["session.list", "session.new"] as const
const appGlobalBindingCommands = ["session.list", "session.new", "open.menu"] as const
const sessionTabBindingCommands = [
"session.tab.next",
"session.tab.previous",
"session.tab.history.back",
"session.tab.history.forward",
"session.tab.next_unread",
"session.tab.previous_unread",
"session.tab.close",
"session.tab.reopen",
"session.tab.select.1",
"session.tab.select.2",
"session.tab.select.3",
@@ -524,8 +523,11 @@ function App(props: { pair?: DialogPairCredentials }) {
renderer.useMouse = config.data.mouse
})
let active: { id: string; title?: string } | undefined
// Update terminal window title based on current route and session
createEffect(() => {
const session = route.data.type === "session" ? data.session.get(route.data.sessionID) : undefined
if (session) active = { id: session.id, title: session.title }
if (!terminalTitleEnabled()) return
if (route.data.type === "home") {
@@ -534,14 +536,13 @@ function App(props: { pair?: DialogPairCredentials }) {
}
if (route.data.type === "session") {
const session = data.session.get(route.data.sessionID)
if (!session || isDefaultTitle(session.title)) {
const title = session?.title
if (!title || isFallbackTitle(title)) {
renderer.setTerminalTitle("OpenCode")
return
}
const title = session.title.length > 40 ? session.title.slice(0, 37) + "..." : session.title
renderer.setTerminalTitle(`OC | ${title}`)
renderer.setTerminalTitle(`OC | ${title.length > 40 ? title.slice(0, 37) + "..." : title}`)
return
}
@@ -645,17 +646,21 @@ function App(props: { pair?: DialogPairCredentials }) {
run: () => {
route.navigate({
type: "home",
location:
route.data.type === "session"
? (data.session.get(route.data.sessionID)?.location ?? location.ref)
: undefined,
})
dialog.clear()
},
},
{
name: "project.switch",
title: "Switch project",
name: "open.menu",
title: "Open session or project",
category: "Session",
slash: { name: "projects", aliases: ["project"] },
slash: { name: "open", aliases: ["projects", "project"] },
run: () => {
dialog.replace(() => <DialogProject />)
dialog.replace(() => <DialogOpen />)
},
},
...Array.from({ length: 9 }, (_, i) => ({
@@ -668,7 +673,7 @@ function App(props: { pair?: DialogPairCredentials }) {
})),
{
name: "session.tab.next",
title: "Next open session tab",
title: "Next tab",
category: "Session",
palette: undefined,
enabled: sessionTabs.enabled,
@@ -676,31 +681,15 @@ function App(props: { pair?: DialogPairCredentials }) {
},
{
name: "session.tab.previous",
title: "Previous open session tab",
title: "Previous tab",
category: "Session",
palette: undefined,
enabled: sessionTabs.enabled,
run: () => sessionTabs.cycle(-1),
},
{
name: "session.tab.history.back",
title: "Back in session tab history",
category: "Session",
palette: undefined,
enabled: sessionTabs.enabled,
run: () => sessionTabs.history(-1),
},
{
name: "session.tab.history.forward",
title: "Forward in session tab history",
category: "Session",
palette: undefined,
enabled: sessionTabs.enabled,
run: () => sessionTabs.history(1),
},
{
name: "session.tab.next_unread",
title: "Next unread session tab",
title: "Next unread tab",
category: "Session",
palette: undefined,
enabled: sessionTabs.enabled,
@@ -708,7 +697,7 @@ function App(props: { pair?: DialogPairCredentials }) {
},
{
name: "session.tab.previous_unread",
title: "Previous unread session tab",
title: "Previous unread tab",
category: "Session",
palette: undefined,
enabled: sessionTabs.enabled,
@@ -716,14 +705,21 @@ function App(props: { pair?: DialogPairCredentials }) {
},
{
name: "session.tab.close",
title: "Close current session tab",
title: "Close tab",
category: "Session",
enabled: sessionTabs.enabled,
run: () => sessionTabs.close(),
},
{
name: "session.tab.reopen",
title: "Reopen closed tab",
category: "Session",
enabled: sessionTabs.enabled,
run: () => sessionTabs.reopen(),
},
...Array.from({ length: 9 }, (_, i) => ({
name: `session.tab.select.${i + 1}`,
title: `Switch to session tab ${i + 1}`,
title: `Switch to tab ${i + 1}`,
category: "Session",
palette: undefined,
enabled: sessionTabs.enabled,
@@ -1152,10 +1148,11 @@ function App(props: { pair?: DialogPairCredentials }) {
event.on("session.deleted", (evt) => {
if (route.data.type === "session" && route.data.sessionID === evt.data.sessionID) {
const title = active?.id === evt.data.sessionID ? active.title : undefined
route.navigate({ type: "home" })
toast.show({
variant: "info",
message: "The current session was deleted",
message: title ? `Session "${title}" was deleted` : "The current session was deleted",
})
}
})
@@ -1219,7 +1216,13 @@ function App(props: { pair?: DialogPairCredentials }) {
<box flexGrow={1} minWidth={0} flexDirection="column">
<Show when={plugins.ready()}>
<box flexGrow={1} minHeight={0} flexDirection="column">
<Show when={sessionTabs.enabled() && sessionTabs.tabs().length > 0 && route.data.type !== "plugin"}>
<Show
when={
sessionTabs.enabled() &&
(sessionTabs.tabs().length > 0 || sessionTabs.newTab()) &&
route.data.type !== "plugin"
}
>
<SessionTabs />
</Show>
<Switch>
@@ -1,436 +0,0 @@
import { OptimizedBuffer, RGBA, TextAttributes } from "@opentui/core"
import { go } from "../logo"
const PERIOD = 4600
const RINGS = 3
const WIDTH = 3.8
const TAIL = 9.5
const AMP = 0.55
const TAIL_AMP = 0.16
const BREATH_AMP = 0.05
const BREATH_SPEED = 0.0008
// Offset so the bg ring emits from the estimated GO center when the logo shimmer peaks.
const PHASE_OFFSET = 0.29
const LOGO_GAP = 1
const LOGO_TOP_BIAS = -1
const LOGO_LEFT_WIDTH = go.left[0]?.length ?? 0
const LOGO_LINES = go.left.map((line, index) => line + " ".repeat(LOGO_GAP) + go.right[index])
const LOGO_WIDTH = LOGO_LINES[0]?.length ?? 0
const LOGO_HEIGHT = LOGO_LINES.length
const SPACE = " ".codePointAt(0)!
const TOP_HALF = "▀".codePointAt(0)!
const FULL_BLOCK = "█".codePointAt(0)!
const RING_SCALE = 1 / RINGS
const TAIL_SCALE = 1 / TAIL
const LOGO_REACH = Math.hypot(LOGO_WIDTH, LOGO_HEIGHT * 2) + 3
const enum LogoCellKind {
Background,
Top,
ShadowTop,
Solid,
Char,
}
type LogoTemplateCell = {
x: number
y: number
kind: LogoCellKind
charCode: number
attributes: number
topDist: number
bottomDist: number
}
const LOGO_TEMPLATE: LogoTemplateCell[] = LOGO_LINES.flatMap((line, y) =>
Array.from(line)
.map((char, x) => {
if (char === " ") return
const kind =
char === "_"
? LogoCellKind.Background
: char === "^"
? LogoCellKind.Top
: char === "~"
? LogoCellKind.ShadowTop
: char === "█"
? LogoCellKind.Solid
: LogoCellKind.Char
return {
x,
y,
kind,
charCode: char.codePointAt(0) ?? SPACE,
attributes: x > LOGO_LEFT_WIDTH ? TextAttributes.BOLD : 0,
topDist: Math.hypot(x + 0.5 - LOGO_WIDTH / 2, y * 2 - LOGO_HEIGHT),
bottomDist: Math.hypot(x + 0.5 - LOGO_WIDTH / 2, y * 2 + 1 - LOGO_HEIGHT),
}
})
.filter((cell): cell is LogoTemplateCell => !!cell),
)
export type Rgb = [number, number, number]
export type GoUpsellArtRenderOptions = {
deltaTime?: number
rgb?: boolean
cache?: boolean
}
const CACHE_FRAME_COUNT = Math.round(PERIOD / (1000 / 30))
const CACHE_FRAMES_PER_RENDER = 1
export function toRgb(color: RGBA): Rgb {
const [r, g, b] = color.toInts()
return [r, g, b]
}
function clamp(n: number) {
return Math.max(0, Math.min(1, n))
}
function writeRgb(buffer: Uint16Array, offset: number, r: number, g: number, b: number, a = 255) {
buffer[offset] = r
buffer[offset + 1] = g
buffer[offset + 2] = b
buffer[offset + 3] = a
}
function mixChannel(base: number, overlay: number, alpha: number) {
return Math.round(base + (overlay - base) * clamp(alpha))
}
function writeLogoTint(
buffer: Uint16Array,
offset: number,
base: Rgb,
primary: Rgb,
primaryMix: number,
peakMix: number,
) {
const p = clamp(primaryMix)
const q = clamp(peakMix)
const r = mixChannel(mixChannel(base[0], primary[0], p), 255, q)
const g = mixChannel(mixChannel(base[1], primary[1], p), 255, q)
const b = mixChannel(mixChannel(base[2], primary[2], p), 255, q)
writeRgb(buffer, offset, r, g, b)
}
function sameRgb(a: Rgb, b: Rgb) {
return a[0] === b[0] && a[1] === b[1] && a[2] === b[2]
}
export class GoUpsellArtPainter {
private panelRgb: Rgb = [0, 0, 0]
private primaryRgb: Rgb = [255, 255, 255]
private logoBaseRgb: Rgb = [180, 180, 180]
private elapsed = 0
private distances = new Float32Array(0)
private edgeFalloff = new Float32Array(0)
private geometryWidth = 0
private geometryHeight = 0
private reach = 1
private logoX = 0
private logoY = 0
private logoIndexes = new Int32Array(0)
private logoRgb: boolean | undefined
private pulsePeak = 0
private pulsePrimary = 0
private cacheDirty = true
private frameCache: Array<{ fg: Uint16Array; bg: Uint16Array }> = []
private cacheBuildIndex = 0
setBackgroundPanel(value: RGBA | Rgb | undefined) {
if (!value) return false
const next = value instanceof RGBA ? toRgb(value) : value
if (sameRgb(this.panelRgb, next)) return false
this.panelRgb = next
this.invalidateCache()
return true
}
setLogoBase(value: RGBA | Rgb | undefined) {
if (!value) return false
const next = value instanceof RGBA ? toRgb(value) : value
if (sameRgb(this.logoBaseRgb, next)) return false
this.logoBaseRgb = next
this.invalidateCache()
return true
}
setPrimary(value: RGBA | Rgb | undefined) {
if (!value) return false
const next = value instanceof RGBA ? toRgb(value) : value
if (sameRgb(this.primaryRgb, next)) return false
this.primaryRgb = next
this.invalidateCache()
return true
}
render(frameBuffer: OptimizedBuffer, options: GoUpsellArtRenderOptions = {}) {
const rgb = options.rgb === true
this.elapsed = (this.elapsed + (options.deltaTime ?? 0)) % PERIOD
this.rebuildGeometry(frameBuffer, rgb)
if (options.cache !== false) {
this.drawCached(frameBuffer, rgb)
return
}
this.drawBackground(frameBuffer, this.elapsed)
this.drawLogo(frameBuffer, this.elapsed, rgb)
}
private invalidateCache() {
this.cacheDirty = true
this.cacheBuildIndex = 0
this.frameCache = []
}
private rebuildGeometry(frameBuffer: OptimizedBuffer, rgb: boolean) {
const width = frameBuffer.width
const height = frameBuffer.height
const geometryChanged = width !== this.geometryWidth || height !== this.geometryHeight
const logoTemplateChanged = this.logoRgb !== rgb
if (!geometryChanged && !logoTemplateChanged) return
if (geometryChanged) {
this.geometryWidth = width
this.geometryHeight = height
this.logoX = Math.max(0, Math.floor((width - LOGO_WIDTH) / 2))
this.logoY = Math.max(
0,
Math.min(Math.max(0, height - LOGO_HEIGHT), Math.round((height - LOGO_HEIGHT) / 2) + LOGO_TOP_BIAS),
)
const centerX = this.logoX + LOGO_WIDTH / 2
const centerY = this.logoY + LOGO_HEIGHT / 2
this.reach = Math.hypot(Math.max(centerX, width - centerX), Math.max(centerY, height - centerY) * 2) + TAIL
this.distances = new Float32Array(width * height)
this.edgeFalloff = new Float32Array(width * height)
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
const index = y * width + x
const dist = Math.hypot(x + 0.5 - centerX, (y + 0.5 - centerY) * 2)
this.distances[index] = dist
this.edgeFalloff[index] = Math.max(0, 1 - (dist / (this.reach * 0.85)) ** 2)
}
}
}
this.logoRgb = rgb
this.invalidateCache()
this.rebuildCellTemplate(frameBuffer, rgb)
}
private drawCached(frameBuffer: OptimizedBuffer, rgb: boolean) {
if (this.cacheDirty) this.startFrameCache(frameBuffer, rgb)
if (this.cacheBuildIndex < CACHE_FRAME_COUNT) {
this.buildFrameCache(frameBuffer, rgb)
this.drawBackground(frameBuffer, this.elapsed)
this.drawLogo(frameBuffer, this.elapsed, rgb)
return
}
const frame = this.frameCache[Math.floor((this.elapsed / PERIOD) * CACHE_FRAME_COUNT) % CACHE_FRAME_COUNT]
if (frame) {
frameBuffer.buffers.fg.set(frame.fg)
frameBuffer.buffers.bg.set(frame.bg)
}
}
private startFrameCache(frameBuffer: OptimizedBuffer, rgb: boolean) {
this.frameCache = []
this.cacheBuildIndex = 0
this.rebuildCellTemplate(frameBuffer, rgb)
this.cacheDirty = false
}
private buildFrameCache(frameBuffer: OptimizedBuffer, rgb: boolean) {
const end = Math.min(CACHE_FRAME_COUNT, this.cacheBuildIndex + CACHE_FRAMES_PER_RENDER)
for (; this.cacheBuildIndex < end; this.cacheBuildIndex++) {
const t = (this.cacheBuildIndex / CACHE_FRAME_COUNT) * PERIOD
this.drawBackground(frameBuffer, t)
this.drawLogo(frameBuffer, t, rgb)
this.frameCache.push({
fg: new Uint16Array(frameBuffer.buffers.fg),
bg: new Uint16Array(frameBuffer.buffers.bg),
})
}
}
private rebuildCellTemplate(frameBuffer: OptimizedBuffer, rgb: boolean) {
const buffers = frameBuffer.buffers
buffers.char.fill(SPACE)
buffers.attributes.fill(0)
if (this.geometryWidth < LOGO_WIDTH || this.geometryHeight < LOGO_HEIGHT) {
this.logoIndexes = new Int32Array(0)
return
}
this.logoIndexes = new Int32Array(LOGO_TEMPLATE.length)
for (let i = 0; i < LOGO_TEMPLATE.length; i++) {
const cell = LOGO_TEMPLATE[i]!
const index = (this.logoY + cell.y) * this.geometryWidth + this.logoX + cell.x
this.logoIndexes[i] = index
buffers.attributes[index] = cell.attributes
buffers.char[index] =
cell.kind === LogoCellKind.Background
? SPACE
: cell.kind === LogoCellKind.Top || cell.kind === LogoCellKind.ShadowTop
? TOP_HALF
: cell.kind === LogoCellKind.Solid
? rgb
? TOP_HALF
: FULL_BLOCK
: cell.charCode
}
}
private drawBackground(frameBuffer: OptimizedBuffer, t: number) {
const buffers = frameBuffer.buffers
const fg = buffers.fg
const bg = buffers.bg
const distances = this.distances
const edgeFalloff = this.edgeFalloff
const baseR = this.panelRgb[0]
const baseG = this.panelRgb[1]
const baseB = this.panelRgb[2]
const deltaR = this.primaryRgb[0] - baseR
const deltaG = this.primaryRgb[1] - baseG
const deltaB = this.primaryRgb[2] - baseB
const breath = (0.5 + 0.5 * Math.sin(t * BREATH_SPEED)) * BREATH_AMP
const phase0 = (t / PERIOD - PHASE_OFFSET + 1) % 1
const phase1 = (t / PERIOD + 1 / RINGS - PHASE_OFFSET + 1) % 1
const phase2 = (t / PERIOD + 2 / RINGS - PHASE_OFFSET + 1) % 1
const envelope0 = Math.sin(phase0 * Math.PI)
const envelope1 = Math.sin(phase1 * Math.PI)
const envelope2 = Math.sin(phase2 * Math.PI)
const eased0 = envelope0 * envelope0 * (3 - 2 * envelope0)
const eased1 = envelope1 * envelope1 * (3 - 2 * envelope1)
const eased2 = envelope2 * envelope2 * (3 - 2 * envelope2)
const head0 = phase0 * this.reach
const head1 = phase1 * this.reach
const head2 = phase2 * this.reach
for (let index = 0; index < distances.length; index++) {
const dist = distances[index]
const delta0 = dist - head0
const abs0 = delta0 < 0 ? -delta0 : delta0
const crest0 = abs0 < WIDTH ? 0.5 + 0.5 * Math.cos((delta0 / WIDTH) * Math.PI) : 0
const tail0 = delta0 < 0 && delta0 > -TAIL ? (1 + delta0 * TAIL_SCALE) ** 2.3 : 0
const delta1 = dist - head1
const abs1 = delta1 < 0 ? -delta1 : delta1
const crest1 = abs1 < WIDTH ? 0.5 + 0.5 * Math.cos((delta1 / WIDTH) * Math.PI) : 0
const tail1 = delta1 < 0 && delta1 > -TAIL ? (1 + delta1 * TAIL_SCALE) ** 2.3 : 0
const delta2 = dist - head2
const abs2 = delta2 < 0 ? -delta2 : delta2
const crest2 = abs2 < WIDTH ? 0.5 + 0.5 * Math.cos((delta2 / WIDTH) * Math.PI) : 0
const tail2 = delta2 < 0 && delta2 > -TAIL ? (1 + delta2 * TAIL_SCALE) ** 2.3 : 0
const level =
(crest0 * AMP + tail0 * TAIL_AMP) * eased0 +
(crest1 * AMP + tail1 * TAIL_AMP) * eased1 +
(crest2 * AMP + tail2 * TAIL_AMP) * eased2
const rawStrength = (level * RING_SCALE + breath) * edgeFalloff[index]
const strength = (rawStrength > 1 ? 1 : rawStrength) * 0.7
const offset = index * 4
const r = Math.round(baseR + deltaR * strength)
const g = Math.round(baseG + deltaG * strength)
const b = Math.round(baseB + deltaB * strength)
bg[offset] = fg[offset] = r
bg[offset + 1] = fg[offset + 1] = g
bg[offset + 2] = fg[offset + 2] = b
bg[offset + 3] = fg[offset + 3] = 255
}
}
private setLogoPulse(dist: number, head0: number, eased0: number, head1: number, eased1: number) {
let peak = 0.04
let primary = 0
const delta0 = dist - head0
const core0 = Math.exp(-(Math.abs(delta0 / 1.2) ** 1.8))
const soft0 = Math.exp(-(Math.abs(delta0 / 7) ** 1.6))
const tail0 = delta0 < 0 && delta0 > -7 ? (1 + delta0 / 7) ** 2.6 : 0
peak += core0 * 0.65 * eased0
primary += (soft0 * 0.16 + tail0 * 0.22) * eased0
const delta1 = dist - head1
const core1 = Math.exp(-(Math.abs(delta1 / 1.2) ** 1.8))
const soft1 = Math.exp(-(Math.abs(delta1 / 7) ** 1.6))
const tail1 = delta1 < 0 && delta1 > -7 ? (1 + delta1 / 7) ** 2.6 : 0
peak += core1 * 0.65 * eased1
primary += (soft1 * 0.16 + tail1 * 0.22) * eased1
this.pulsePeak = peak > 1 ? 1 : peak
this.pulsePrimary = primary > 1 ? 1 : primary
}
private drawLogo(frameBuffer: OptimizedBuffer, t: number, rgb: boolean) {
if (this.logoIndexes.length === 0) return
const buffers = frameBuffer.buffers
const fg = buffers.fg
const bg = buffers.bg
const shadow: Rgb = [
mixChannel(this.panelRgb[0], this.logoBaseRgb[0], 0.25),
mixChannel(this.panelRgb[1], this.logoBaseRgb[1], 0.25),
mixChannel(this.panelRgb[2], this.logoBaseRgb[2], 0.25),
]
const phase0 = (t / PERIOD) % 1
const phase1 = (t / PERIOD + 0.5) % 1
const envelope0 = Math.sin(phase0 * Math.PI)
const envelope1 = Math.sin(phase1 * Math.PI)
const eased0 = envelope0 * envelope0 * (3 - 2 * envelope0)
const eased1 = envelope1 * envelope1 * (3 - 2 * envelope1)
const head0 = phase0 * LOGO_REACH
const head1 = phase1 * LOGO_REACH
for (let i = 0; i < LOGO_TEMPLATE.length; i++) {
const cell = LOGO_TEMPLATE[i]!
const index = this.logoIndexes[i]!
const offset = index * 4
this.setLogoPulse(cell.topDist, head0, eased0, head1, eased1)
const topPeak = this.pulsePeak
const topPrimary = this.pulsePrimary
this.setLogoPulse(cell.bottomDist, head0, eased0, head1, eased1)
const bottomPeak = this.pulsePeak
const bottomPrimary = this.pulsePrimary
if (cell.kind === LogoCellKind.Background) {
writeLogoTint(bg, offset, shadow, this.primaryRgb, 0, Math.max(topPeak, bottomPeak) * 0.18)
continue
}
if (cell.kind === LogoCellKind.Top) {
writeLogoTint(fg, offset, this.logoBaseRgb, this.primaryRgb, topPrimary, topPeak)
writeLogoTint(bg, offset, shadow, this.primaryRgb, 0, bottomPeak * 0.18)
continue
}
if (cell.kind === LogoCellKind.ShadowTop) {
writeLogoTint(fg, offset, shadow, this.primaryRgb, 0, topPeak * 0.18)
continue
}
if (cell.kind === LogoCellKind.Solid && rgb) {
writeLogoTint(fg, offset, this.logoBaseRgb, this.primaryRgb, topPrimary, topPeak)
writeLogoTint(bg, offset, this.logoBaseRgb, this.primaryRgb, bottomPrimary, bottomPeak)
continue
}
writeLogoTint(
fg,
offset,
this.logoBaseRgb,
this.primaryRgb,
(topPrimary + bottomPrimary) / 2,
(topPeak + bottomPeak) / 2,
)
}
}
}
-102
View File
@@ -1,102 +0,0 @@
import {
FrameBufferRenderable,
RGBA,
type OptimizedBuffer,
type RenderContext,
type RenderableOptions,
} from "@opentui/core"
import { extend, useRenderer } from "@opentui/solid"
import { onCleanup, onMount } from "solid-js"
import { useTheme, useThemes } from "../context/theme"
import { tint } from "../theme/color"
import { GoUpsellArtPainter } from "./bg-pulse-render"
type GoUpsellArtOptions = RenderableOptions<FrameBufferRenderable> & {
backgroundPanel?: RGBA
primary?: RGBA
logoBase?: RGBA
}
class GoUpsellArtRenderable extends FrameBufferRenderable {
private painter = new GoUpsellArtPainter()
constructor(ctx: RenderContext, options: GoUpsellArtOptions = {}) {
const width = typeof options.width === "number" ? options.width : 1
const height = typeof options.height === "number" ? options.height : 1
super(ctx, {
...options,
width,
height,
live: options.live ?? true,
respectAlpha: false,
})
if (options.width !== undefined && typeof options.width !== "number") this.width = options.width
if (options.height !== undefined && typeof options.height !== "number") this.height = options.height
this.painter.setBackgroundPanel(options.backgroundPanel)
this.painter.setPrimary(options.primary)
this.painter.setLogoBase(options.logoBase)
}
set backgroundPanel(value: RGBA | undefined) {
if (this.painter.setBackgroundPanel(value)) this.requestRender()
}
set logoBase(value: RGBA | undefined) {
if (this.painter.setLogoBase(value)) this.requestRender()
}
set primary(value: RGBA | undefined) {
if (this.painter.setPrimary(value)) this.requestRender()
}
protected override renderSelf(buffer: OptimizedBuffer, deltaTime = 0): void {
if (!this.visible || this.isDestroyed) return
this.painter.render(this.frameBuffer, {
deltaTime,
rgb: this._ctx.capabilities?.rgb === true,
})
super.renderSelf(buffer)
}
}
declare module "@opentui/solid" {
interface OpenTUIComponents {
go_upsell_art: typeof GoUpsellArtRenderable
}
}
extend({ go_upsell_art: GoUpsellArtRenderable })
export function BgPulse() {
const themes = useThemes()
const theme = useTheme("elevated")
const mode = themes.mode
const renderer = useRenderer()
let targetFps = renderer.targetFps
let maxFps = renderer.maxFps
onMount(() => {
targetFps = renderer.targetFps
maxFps = renderer.maxFps
renderer.targetFps = 30
renderer.maxFps = 30
})
onCleanup(() => {
renderer.targetFps = targetFps
renderer.maxFps = maxFps
})
return (
<go_upsell_art
width="100%"
height="100%"
backgroundPanel={theme.background.default}
primary={theme.hue.interactive[mode() === "light" ? 800 : 200]}
logoBase={tint(theme.background.default, theme.text.default, 0.62)}
live
/>
)
}
+1 -1
View File
@@ -105,7 +105,7 @@ export const settings: Setting[] = [
title: "Scope",
category: "Tabs",
path: ["tabs", "scope"],
default: "cwd",
default: "global",
values: ["cwd", "global"],
labels: ["current directory", "global"],
},
@@ -2,8 +2,8 @@ import { useTerminalDimensions } from "@opentui/solid"
import { TextAttributes } from "@opentui/core"
import { createMemo, createResource, createSignal, onMount, Show } from "solid-js"
import path from "path"
import { DialogSelect, type DialogSelectOption } from "../ui/dialog-select"
import { useDialog } from "../ui/dialog"
import { DialogSelect, dialogSelectContentWidth, type DialogSelectOption } from "../ui/dialog-select"
import { dialogWidth, useDialog } from "../ui/dialog"
import { useClient } from "../context/client"
import { Keymap } from "../context/keymap"
import { useTheme } from "../context/theme"
@@ -159,7 +159,10 @@ export function DialogMoveSession(props: DialogMoveSessionProps) {
if (b.location === b.root.directory) return 1
return a.location.localeCompare(b.location)
})
const titleWidth = Math.max(1, Math.min(116, dimensions().width - 2) - 12)
const titleWidth = Math.max(
1,
dialogSelectContentWidth(Math.min(dialogWidth("xlarge"), dimensions().width - 2)),
)
return list.map((item) => {
const title = abbreviateHome(item.location, paths.home)
+166
View File
@@ -0,0 +1,166 @@
import path from "path"
import { createMemo, createResource, createSignal, onMount } from "solid-js"
import type { SessionInfo } from "@opencode-ai/client"
import { useTerminalDimensions } from "@opentui/solid"
import { dialogWidth, useDialog } from "../ui/dialog"
import { DialogSelect, dialogSelectContentWidth } from "../ui/dialog-select"
import { useRoute } from "../context/route"
import { useData } from "../context/data"
import { useClient } from "../context/client"
import { useLocation } from "../context/location"
import { useSessionTabs } from "../context/session-tabs"
import { useTheme, useThemes } from "../context/theme"
import { Keymap } from "../context/keymap"
import { Locale } from "../util/locale"
import { abbreviateHome } from "../runtime"
import { useTuiPaths } from "../context/runtime"
import { truncateFilePath } from "../ui/file-path"
import { stringWidth } from "../util/string-width"
import { withTimestampedFallback } from "@opencode-ai/util/session-title-fallback"
import { Spinner } from "./spinner"
const RECENT_LIMIT = 8
type OpenTarget = { type: "session"; sessionID: string } | { type: "project"; directory: string }
export function DialogOpen() {
const dialog = useDialog()
const route = useRoute()
const data = useData()
const client = useClient()
const location = useLocation()
const sessionTabs = useSessionTabs()
const themes = useThemes()
const theme = useTheme("elevated")
const mode = themes.mode
const paths = useTuiPaths()
const dimensions = useTerminalDimensions()
const shortcuts = Keymap.useShortcuts()
const [filter, setFilter] = createSignal("")
data.project.invalidate()
void data.project.sync().catch(() => {})
// One background fetch fills in recent sessions from other projects; the menu renders
// immediately from the local store and never blocks on the network.
const [fetched] = createResource(
() =>
client.api.session
.list({ limit: 50, order: "desc", parentID: null })
.then((response) => response.data)
.catch(() => [] as SessionInfo[]),
{ initialValue: [] },
)
const openTabs = createMemo(() => new Set(sessionTabs.tabs().map((tab) => tab.sessionID)))
const currentSessionID = createMemo(() => (route.data.type === "session" ? route.data.sessionID : undefined))
const sessions = createMemo(() => {
const seen = new Set<string>()
return [...data.session.list(), ...fetched()]
.filter((session) => {
if (session.parentID || seen.has(session.id)) return false
seen.add(session.id)
return true
})
.toSorted((a, b) => b.time.updated - a.time.updated)
})
const options = createMemo(() => {
const tabs = openTabs()
// With an empty query the menu shows what is not already one keystroke away: open tabs are
// visible in the strip, so recents exclude them. Typing widens the pool to every session so
// matching a tab by name still switches to it.
const recent = filter().trim()
? sessions()
: sessions()
.filter((session) => !tabs.has(session.id))
.slice(0, RECENT_LIMIT)
const sessionOptions = recent.map((session) => {
const project = data.project.get(session.projectID)
const name = project?.canonical === "/" ? undefined : project?.name || path.basename(project?.canonical ?? "")
const running = data.session.family(session.id).some((id) => data.session.status(id) === "running")
return {
title: withTimestampedFallback(session),
value: { type: "session", sessionID: session.id } as OpenTarget,
category: "Sessions",
footer: `${name ? `${Locale.truncate(name, 20)} · ` : ""}${timeAgo(session.time.updated)}`,
onSelect: () => location.set(session.location),
gutter: running
? () => <Spinner />
: tabs.has(session.id)
? () => <text fg={theme.hue.accent[mode() === "light" ? 800 : 200]}></text>
: undefined,
}
})
const current = location.current?.project
const seen = new Set<string>()
const projectOptions = data.project
.list()
.filter((project) => {
if (project.canonical === "/" || project.id === current?.id || seen.has(project.canonical)) return false
seen.add(project.canonical)
return true
})
.map((project) => {
const title = project.name ?? path.basename(project.canonical)
const footer = abbreviateHome(project.canonical, paths.home)
const width =
dialogSelectContentWidth(Math.min(dialogWidth("large"), dimensions().width - 2)) - stringWidth(title)
return {
title,
footer: truncateFilePath(footer, width),
searchText: footer,
value: { type: "project", directory: project.canonical } as OpenTarget,
category: "Projects",
}
})
return [...sessionOptions, ...projectOptions]
})
onMount(() => dialog.setSize("large"))
return (
<DialogSelect
title="Open"
placeholder="Search sessions and projects…"
options={options()}
current={currentSessionID() ? ({ type: "session", sessionID: currentSessionID()! } as OpenTarget) : undefined}
focusCurrent={false}
onFilter={setFilter}
noMatchView={
<box paddingLeft={4} paddingRight={4}>
<text fg={theme.text.subdued}>
{shortcuts.get("session.list")
? `No matches · search all sessions with ${shortcuts.get("session.list")}`
: "No matches"}
</text>
</box>
}
onSelect={(option) => {
dialog.clear()
if (option.value.type === "session") {
route.navigate({ type: "session", sessionID: option.value.sessionID })
return
}
const target = { directory: option.value.directory }
route.navigate({ type: "home", location: target })
location.set(target)
}}
/>
)
}
function timeAgo(timestamp: number) {
const minutes = Math.floor((Date.now() - timestamp) / 60_000)
if (minutes < 1) return "now"
if (minutes < 60) return `${minutes}m`
const hours = Math.floor(minutes / 60)
if (hours < 24) return `${hours}h`
const days = Math.floor(hours / 24)
if (days < 30) return `${days}d`
const months = Math.floor(days / 30)
if (months < 12) return `${months}mo`
return `${Math.floor(days / 365)}y`
}
@@ -1,67 +0,0 @@
import path from "path"
import { createMemo } from "solid-js"
import { DialogSelect } from "../ui/dialog-select"
import { useDialog } from "../ui/dialog"
import { useData } from "../context/data"
import { useRoute } from "../context/route"
import { abbreviateHome } from "../runtime"
import { useTuiPaths } from "../context/runtime"
import { useLocation } from "../context/location"
import { useToast } from "../ui/toast"
export function DialogProject() {
const dialog = useDialog()
const data = useData()
const route = useRoute()
const paths = useTuiPaths()
const location = useLocation()
const toast = useToast()
data.project.invalidate()
void data.project.sync().catch(toast.error)
const current = () => location.current?.project
const options = createMemo(() => {
const seen = new Set<string>()
return data.project
.list()
.filter((project) => {
if (project.canonical === "/" || seen.has(project.canonical)) return false
seen.add(project.canonical)
return true
})
.toSorted((a, b) => {
if (a.id === current()?.id) return -1
if (b.id === current()?.id) return 1
return 0
})
.map((project) => ({
title: project.name ?? path.basename(project.canonical),
description: abbreviateHome(project.canonical, paths.home),
value: project.canonical,
category: project.id === current()?.id ? "Current" : "Projects",
}))
})
return (
<DialogSelect
title="Switch project"
placeholder="Search projects…"
options={options()}
current={current()?.canonical}
emptyView={
<box paddingLeft={4} paddingRight={4} paddingTop={1}>
<text>No projects found</text>
</box>
}
onSelect={(option) => {
dialog.clear()
if (option.value === current()?.canonical) return
const target = { directory: option.value }
route.navigate({ type: "home", location: target })
location.set(target)
}}
/>
)
}
@@ -1,164 +0,0 @@
import { RGBA, TextAttributes } from "@opentui/core"
import open from "open"
import { createSignal } from "solid-js"
import { Keymap } from "../context/keymap"
import { useTheme } from "../context/theme"
import { useDialog, type DialogContext } from "../ui/dialog"
import { Link } from "../ui/link"
import { BgPulse } from "./bg-pulse"
const GO_URL = "https://opencode.ai/go"
const PAD_X = 3
const PAD_TOP_OUTER = 1
const FOREGROUND_ALPHA = 186
export type DialogRetryActionProps = {
title: string
message: string
label: string
link?: string
onClose?: (dontShowAgain?: boolean) => void
}
function runAction(props: DialogRetryActionProps, dialog: ReturnType<typeof useDialog>) {
if (props.link) open(props.link).catch(() => {})
props.onClose?.()
dialog.clear()
}
function dismiss(props: DialogRetryActionProps, dialog: ReturnType<typeof useDialog>) {
props.onClose?.(true)
dialog.clear()
}
function panelOverlay(color: RGBA) {
const [r, g, b] = color.toInts()
return RGBA.fromInts(r, g, b, FOREGROUND_ALPHA)
}
export function DialogRetryAction(props: DialogRetryActionProps) {
const dialog = useDialog()
const theme = useTheme("elevated")
const showGoTreatment = () => props.link === GO_URL
const textBg = () => (showGoTreatment() ? panelOverlay(theme.background.default) : undefined)
const [selected, setSelected] = createSignal<"dismiss" | "action">("action")
Keymap.createLayer(() => ({
mode: "modal",
commands: [
{
bind: "left",
title: "Previous retry option",
group: "Dialog",
run: () => setSelected((value) => (value === "action" ? "dismiss" : "action")),
},
{
bind: "right",
title: "Next retry option",
group: "Dialog",
run: () => setSelected((value) => (value === "action" ? "dismiss" : "action")),
},
{
bind: "tab",
title: "Next retry option",
group: "Dialog",
run: () => setSelected((value) => (value === "action" ? "dismiss" : "action")),
},
{
bind: "return",
title: "Confirm retry option",
group: "Dialog",
run: () => {
if (selected() === "action") runAction(props, dialog)
else dismiss(props, dialog)
},
},
],
}))
return (
<box>
{showGoTreatment() ? (
<box position="absolute" top={-PAD_TOP_OUTER} left={0} right={0} bottom={0} zIndex={0}>
<BgPulse />
</box>
) : null}
<box zIndex={1} paddingLeft={PAD_X} paddingRight={PAD_X} paddingBottom={1} gap={1}>
<box flexDirection="row" justifyContent="space-between">
<text attributes={TextAttributes.BOLD} fg={theme.text.default} bg={textBg()}>
{props.title}
</text>
<text fg={theme.text.subdued} bg={textBg()} onMouseUp={() => dialog.clear()}>
esc
</text>
</box>
<box gap={0}>
<text fg={theme.text.subdued} bg={textBg()}>
{props.message}
</text>
</box>
{props.link ? (
showGoTreatment() ? (
<box alignItems="center" justifyContent="flex-end" height={7} paddingBottom={1}>
<Link href={props.link} fg={theme.markdown.link} bg={textBg()} wrapMode="none" />
</box>
) : (
<box width="100%" flexDirection="row" justifyContent="center" paddingBottom={1}>
<Link href={props.link} fg={theme.markdown.link} wrapMode="none" />
</box>
)
) : (
<box paddingBottom={1} />
)}
<box flexDirection="row" justifyContent="space-between">
<box
paddingLeft={2}
paddingRight={2}
backgroundColor={
selected() === "dismiss" ? theme.background.action.primary.focused : RGBA.fromInts(0, 0, 0, 0)
}
onMouseOver={() => setSelected("dismiss")}
onMouseUp={() => dismiss(props, dialog)}
>
<text
fg={selected() === "dismiss" ? theme.text.action.primary.focused : theme.text.subdued}
bg={selected() === "dismiss" ? undefined : textBg()}
attributes={selected() === "dismiss" ? TextAttributes.BOLD : undefined}
>
don't show again
</text>
</box>
<box
paddingLeft={2}
paddingRight={2}
backgroundColor={
selected() === "action" ? theme.background.action.primary.focused : RGBA.fromInts(0, 0, 0, 0)
}
onMouseOver={() => setSelected("action")}
onMouseUp={() => runAction(props, dialog)}
>
<text
fg={selected() === "action" ? theme.text.action.primary.focused : theme.text.default}
bg={selected() === "action" ? undefined : textBg()}
attributes={selected() === "action" ? TextAttributes.BOLD : undefined}
>
{props.label}
</text>
</box>
</box>
</box>
</box>
)
}
DialogRetryAction.show = (
dialog: DialogContext,
props: Pick<DialogRetryActionProps, "title" | "message" | "label" | "link">,
) => {
return new Promise<boolean>((resolve) => {
dialog.replace(
() => <DialogRetryAction {...props} onClose={(dontShow) => resolve(dontShow ?? false)} />,
() => resolve(false),
)
})
}
@@ -1,113 +0,0 @@
import { TextAttributes } from "@opentui/core"
import { Keymap } from "../context/keymap"
import { useTheme } from "../context/theme"
import { useDialog } from "../ui/dialog"
import { createStore } from "solid-js/store"
import { For } from "solid-js"
export function DialogSessionDeleteFailed(props: {
session: string
workspace: string
onDelete?: () => boolean | void | Promise<boolean | void>
onRestore?: () => boolean | void | Promise<boolean | void>
onDone?: () => void
}) {
const dialog = useDialog()
const theme = useTheme("elevated")
const [store, setStore] = createStore({
active: "delete" as "delete" | "restore",
})
const options = [
{
id: "delete" as const,
title: "Delete workspace",
description: "Delete the workspace and all sessions attached to it.",
run: props.onDelete,
},
{
id: "restore" as const,
title: "Restore to new workspace",
description: "Try to restore this session into a new workspace.",
run: props.onRestore,
},
]
async function confirm() {
const result = await options.find((item) => item.id === store.active)?.run?.()
if (result === false) return
props.onDone?.()
if (!props.onDone) dialog.clear()
}
Keymap.createLayer(() => ({
mode: "modal",
commands: [
{ bind: "return", title: "Confirm recovery option", group: "Dialog", run: () => void confirm() },
{ bind: "left", title: "Delete broken session", group: "Dialog", run: () => setStore("active", "delete") },
{ bind: "up", title: "Delete broken session", group: "Dialog", run: () => setStore("active", "delete") },
{
bind: "right",
title: "Restore broken session",
group: "Dialog",
run: () => setStore("active", "restore"),
},
{
bind: "down",
title: "Restore broken session",
group: "Dialog",
run: () => setStore("active", "restore"),
},
],
}))
return (
<box paddingLeft={2} paddingRight={2} gap={1}>
<box flexDirection="row" justifyContent="space-between">
<text attributes={TextAttributes.BOLD} fg={theme.text.default}>
Failed to Delete Session
</text>
<text fg={theme.text.subdued} onMouseUp={() => dialog.clear()}>
esc
</text>
</box>
<text fg={theme.text.subdued} wrapMode="word">
{`The session "${props.session}" could not be deleted because the workspace "${props.workspace}" is not available.`}
</text>
<text fg={theme.text.subdued} wrapMode="word">
Choose how you want to recover this broken workspace session.
</text>
<box flexDirection="column" paddingBottom={1} gap={1}>
<For each={options}>
{(item) => (
<box
flexDirection="column"
paddingLeft={1}
paddingRight={1}
paddingTop={1}
paddingBottom={1}
backgroundColor={item.id === store.active ? theme.background.action.primary.focused : undefined}
onMouseUp={() => {
setStore("active", item.id)
void confirm()
}}
>
<text
attributes={TextAttributes.BOLD}
fg={item.id === store.active ? theme.text.action.primary.focused : theme.text.default}
>
{item.title}
</text>
<text
fg={item.id === store.active ? theme.text.action.primary.focused : theme.text.subdued}
wrapMode="word"
>
{item.description}
</text>
</box>
)}
</For>
</box>
</box>
)
}
@@ -17,6 +17,9 @@ import { DialogSessionRename } from "./dialog-session-rename"
import { Spinner } from "./spinner"
import { errorMessage } from "../util/error"
import { useSessionTabs } from "../context/session-tabs"
import { useStorage } from "../context/storage"
import { useConfig } from "../config"
import { withTimestampedFallback } from "@opencode-ai/util/session-title-fallback"
export function DialogSessionList() {
const dialog = useDialog()
@@ -28,12 +31,16 @@ export function DialogSessionList() {
const client = useClient()
const local = useLocal()
const sessionTabs = useSessionTabs()
const config = useConfig().data
const toast = useToast()
const [filter, setFilter] = createSignal("")
const shortcuts = Keymap.useShortcuts()
const [search, setSearch] = createDebouncedSignal("", 150)
const [toDelete, setToDelete] = createSignal<string>()
const [allProjects, setAllProjects] = createSignal(false)
const [prefs, updatePrefs] = useStorage().store("session-list", {
initial: { allProjects: config.tabs?.scope !== "cwd" },
})
const allProjects = () => prefs.allProjects
const [searchResults, { mutate: setSearchResults }] = createResource(
() => ({ query: search().trim(), allProjects: allProjects() }),
@@ -75,7 +82,9 @@ export function DialogSessionList() {
(session.projectID === current?.project.id && session.location.directory === current.directory),
)
if (!query) return sessions
return sessions.filter((session) => !session.parentID && session.title.toLowerCase().includes(query))
return sessions.filter(
(session) => !session.parentID && withTimestampedFallback(session).toLowerCase().includes(query),
)
})
const sessions = createMemo(() => {
const query = filter().trim()
@@ -137,7 +146,9 @@ export function DialogSessionList() {
const slot = sessionTabs.enabled() ? undefined : slotByID.get(session.id)
const deleting = toDelete() === session.id
return {
title: deleting ? `Press ${shortcuts.get("session.delete")} again to confirm` : session.title,
title: deleting
? `Press ${shortcuts.get("session.delete")} again to confirm`
: withTimestampedFallback(session),
value: session.id,
category,
footer,
@@ -189,7 +200,9 @@ export function DialogSessionList() {
title: allProjects() ? "Show current directory sessions" : "Show all project sessions",
group: "Dialog",
run: () => {
setAllProjects((value) => !value)
void updatePrefs((draft) => {
draft.allProjects = !draft.allProjects
}).catch(() => {})
},
},
]}
@@ -231,7 +244,9 @@ export function DialogSessionList() {
.remove({ sessionID: option.value })
.then(() => {
setSearchResults((result) =>
result ? { ...result, sessions: result.sessions.filter((session) => session.id !== option.value) } : result,
result
? { ...result, sessions: result.sessions.filter((session) => session.id !== option.value) }
: result,
)
})
.catch((error) => {
-52
View File
@@ -1,52 +0,0 @@
import { createMemo, createResource } from "solid-js"
import { DialogSelect } from "../ui/dialog-select"
import { useDialog } from "../ui/dialog"
import { useClient } from "../context/client"
import { useData } from "../context/data"
import { createStore } from "solid-js/store"
export function DialogTag(props: { onSelect?: (value: string) => void }) {
const client = useClient()
const dialog = useDialog()
const data = useData()
const [store] = createStore({
filter: "",
})
const [files] = createResource(
() => [store.filter],
async () => {
const result = await client.api.file
.find({
query: store.filter,
type: "file",
limit: 5,
location: {
directory: data.location.default().directory,
workspace: data.location.default().workspaceID,
},
})
.catch(() => undefined)
return result?.data.map((item) => item.path) ?? []
},
)
const options = createMemo(() =>
(files() ?? []).map((file) => ({
value: file,
title: file,
})),
)
return (
<DialogSelect
title="Autocomplete"
options={options()}
onSelect={(option) => {
props.onSelect?.(option.value)
dialog.clear()
}}
/>
)
}
-73
View File
@@ -1,73 +0,0 @@
/** @jsxImportSource @opentui/solid */
import { DiffRenderable, LineNumberRenderable, type ColorInput } from "@opentui/core"
import type { JSX } from "@opentui/solid"
import { createMemo, For, Show, splitProps } from "solid-js"
import { splitPatchHunks } from "../util/diff"
import { stringWidth } from "../util/string-width"
type Props = Omit<JSX.IntrinsicElements["diff"], "diff" | "lineNumberBg"> & {
diff: string
hunkFg: ColorInput
lineNumberBg: ColorInput
}
export function PatchDiff(props: Props) {
const [local, diffProps] = splitProps(props, ["diff", "hunkFg", "lineNumberBg"])
const hunks = createMemo(() => splitPatchHunks(local.diff))
const nodes = new Set<DiffRenderable>()
const syncGutters = (attempt = 0) => {
requestAnimationFrame(() => {
const sides = [...nodes]
.filter((item) => !item.isDestroyed)
.flatMap((item) => item.getChildren().filter((side) => side instanceof LineNumberRenderable))
const lineNumbers = sides.map((side) => new Map([...side.getLineNumbers()].filter(([line]) => line >= 0)))
const digits = lineNumbers.map((numbers) => Math.max(0, ...numbers.values()).toString().length)
const after = sides.map((side) =>
Math.max(
0,
...[...side.getLineSigns()]
.filter(([line]) => line >= 0)
.map(([, sign]) => stringWidth(sign.after ?? "")),
),
)
const maxDigits = Math.max(...digits)
const maxAfter = Math.max(...after)
if (!maxDigits && attempt < 2) return syncGutters(attempt + 1)
if (!maxDigits) return
sides.forEach((side) => {
const index = sides.indexOf(side)
const signs = new Map([...side.getLineSigns()].filter(([line]) => line >= 0))
signs.set(-1, { after: " ".repeat(maxAfter + maxDigits - digits[index]) })
side.setLineNumbers(lineNumbers[index])
side.setLineSigns(signs)
})
})
}
const register = (node: DiffRenderable) => {
nodes.add(node)
syncGutters()
}
return (
<For each={hunks()}>
{(hunk, index) => (
<>
<Show when={index() > 0}>
<box width="100%" height={1} backgroundColor={local.lineNumberBg}>
<text fg={local.hunkFg} bg={local.lineNumberBg}>
{` ${hunk.header ?? ""}`}
</text>
</box>
</Show>
<diff
{...diffProps}
ref={register}
diff={hunk.patch}
minHeight={hunk.rows}
lineNumberBg={local.lineNumberBg}
/>
</>
)}
</For>
)
}
+10 -3
View File
@@ -240,7 +240,6 @@ export function Prompt(props: PromptProps) {
return undefined
})
if (!location) return
move.setDirectory(location.directory, location.directory !== location.project.directory)
currentLocation.set(location)
return
}
@@ -963,7 +962,10 @@ export function Prompt(props: PromptProps) {
const directory = await move.getDirectory()
if (move.pending() && !directory) return false
finishMoveProgress = Boolean(move.progress())
const location = data.location.default()
// The location context is where the next session is created: seeded by the home
// route (launch cwd, inherited session location, or picked project) and updated
// by /cd before a session exists.
const location = currentLocation.ref ?? data.location.default()
const created = await client.api.session
.create({
@@ -1299,7 +1301,12 @@ export function Prompt(props: PromptProps) {
return `Ask anything... "${list()[store.placeholder % list().length]}"`
})
const locationLabel = createMemo(() => {
if (!props.sessionID || status() !== "idle") return
if (!props.sessionID) {
// No session yet: show where the next session will be created.
const directory = currentLocation.ref?.directory ?? data.location.default().directory
return abbreviateHome(directory, paths.home)
}
if (status() !== "idle") return
const directory = data.session.get(props.sessionID)?.location.directory
return directory ? abbreviateHome(directory, paths.home) : undefined
})
+24 -4
View File
@@ -6,9 +6,11 @@ import { useSessionTabs } from "../context/session-tabs"
import { useTheme, useThemes } from "../context/theme"
import {
adaptiveSessionTabLayout,
NEW_SESSION_TAB_TITLE,
sessionTabComplete,
seedSessionTabMotion,
sessionTabOverflowWidth,
type SessionTab,
type SessionTabUnread,
} from "../context/session-tabs-model"
import { createAnimatable, spring, tween } from "../ui/animation"
@@ -24,10 +26,19 @@ type ContextController = ReturnType<typeof useSessionTabs>
export type SessionTabsStatus = Omit<ReturnType<ContextController["status"]>, "unread"> & {
unread: SessionTabUnread | undefined
}
export const EMPTY_SESSION_TAB_STATUS: SessionTabsStatus = {
unread: undefined,
promptPulse: 0,
attention: false,
busy: false,
}
export type SessionTabsController = Pick<ContextController, "tabs" | "current" | "select" | "close" | "move"> & {
newTab?: () => boolean
status(sessionID: string): SessionTabsStatus
}
const NEW_SESSION_TAB: SessionTab = { sessionID: "new", title: NEW_SESSION_TAB_TITLE }
export function SessionTabs(props: { controller?: SessionTabsController; animations?: boolean } = {}) {
const tabs = props.controller ?? useSessionTabs()
const dimensions = useTerminalDimensions()
@@ -42,8 +53,9 @@ export function SessionTabs(props: { controller?: SessionTabsController; animati
const accent = () => theme.hue.accent[hueStep()]
const activeNumber = () => theme.hue.interactive[hueStep()]
const idleNumber = () => tint(theme.text.subdued, theme.background.default, 0.35)
const activeID = createMemo(tabs.current)
const items = tabs.tabs
const newTab = () => tabs.newTab?.() ?? false
const activeID = createMemo(() => (newTab() ? NEW_SESSION_TAB.sessionID : tabs.current()))
const items = createMemo(() => (newTab() ? [...tabs.tabs(), NEW_SESSION_TAB] : tabs.tabs()))
const layout = createMemo((previous: ReturnType<typeof adaptiveSessionTabLayout> | undefined) =>
adaptiveSessionTabLayout(items(), activeID(), dimensions().width, previous?.start),
)
@@ -51,7 +63,7 @@ export function SessionTabs(props: { controller?: SessionTabsController; animati
() =>
new Map(
layout().tabs.map((tab) => {
const status = tabs.status(tab.sessionID)
const status = tab === NEW_SESSION_TAB ? EMPTY_SESSION_TAB_STATUS : tabs.status(tab.sessionID)
return [
tab.sessionID,
{
@@ -198,6 +210,11 @@ export function SessionTabs(props: { controller?: SessionTabsController; animati
createEffect((previous: string) => {
const next = title()
if (next === previous) return next
if (previous === NEW_SESSION_TAB_TITLE) {
setOutgoingTitle(undefined)
wipe.jump({ front: 1 })
return next
}
setOutgoingTitle(previous)
wipe.jump({ front: 0 })
wipe.animate({ front: 1 })
@@ -262,6 +279,7 @@ export function SessionTabs(props: { controller?: SessionTabsController; animati
// keeping sloppy clicks indistinguishable from clean ones.
const release = () => {
setDragging(undefined)
if (tab === NEW_SESSION_TAB) return
tabs.select(tab.sessionID)
}
return (
@@ -275,6 +293,7 @@ export function SessionTabs(props: { controller?: SessionTabsController; animati
onMouseDown={() => setDragging(tab.sessionID)}
onMouseUp={release}
onMouseDrag={(event) => {
if (tab === NEW_SESSION_TAB) return
const slot = slotAt(event.x)
if (slot !== undefined && slot !== tabNumber() - 1) tabs.move(tab.sessionID, slot)
}}
@@ -283,6 +302,7 @@ export function SessionTabs(props: { controller?: SessionTabsController; animati
<TabPulse
enabled={animations()}
active={status().busy && !status().attention}
promptPulse={status().promptPulse}
complete={status().complete && !status().attention}
glow={glows()}
breathe={status().attention}
@@ -322,7 +342,7 @@ export function SessionTabs(props: { controller?: SessionTabsController; animati
selectable={false}
onMouseUp={(event) => {
event.stopPropagation()
tabs.close(tab.sessionID)
tabs.close(tab === NEW_SESSION_TAB ? undefined : tab.sessionID)
}}
>
{hovered() === tab.sessionID ? "×" : ""}
+20
View File
@@ -4,6 +4,7 @@ import { extend } from "@opentui/solid"
type TabPulseOptions = RenderableOptions<TabPulseRenderable> & {
enabled?: boolean
active?: boolean
promptPulse?: number
complete?: boolean
glow?: boolean
breathe?: boolean
@@ -28,6 +29,7 @@ const COMPLETION_OPACITY = 0.18
const EDGE_FLASH_DURATION = 800
const EDGE_FLASH_ATTACK = 0.1
const EDGE_FLASH_OPACITY = 0.1
const PROMPT_FLASH_SCALE = 2
const GLOW_IGNITION_DURATION = 600
const GLOW_IGNITION_PEAK = 1.5
const GLOW_IGNITION_ATTACK = 0.3
@@ -102,6 +104,11 @@ class Envelope {
this.scale = scale
}
restart(scale = 1) {
this.clock = 0
this.scale = scale
}
stop() {
this.clock = undefined
}
@@ -127,6 +134,7 @@ const envelopeActive = (envelope: Envelope) => envelope.active
class TabPulseRenderable extends Renderable {
private _enabled: boolean
private _active: boolean
private _promptPulse: number
private _complete: boolean
private _glow: boolean
private _breathe: boolean
@@ -154,6 +162,7 @@ class TabPulseRenderable extends Renderable {
super(ctx, { ...options, height: 1, live: enabled && active })
this._enabled = enabled
this._active = active
this._promptPulse = options.promptPulse ?? 0
this._complete = options.complete ?? false
this._glow = options.glow ?? false
this._breathe = options.breathe ?? false
@@ -222,6 +231,15 @@ class TabPulseRenderable extends Renderable {
this.requestRender()
}
set promptPulse(value: number) {
if (value === this._promptPulse) return
this._promptPulse = value
if (!this._enabled) return
this.edgeFlash.restart(PROMPT_FLASH_SCALE)
this.live = true
this.requestRender()
}
set complete(value: boolean) {
if (value === this._complete) return
this._complete = value
@@ -368,6 +386,7 @@ extend({ tab_pulse: TabPulseRenderable })
export function TabPulse(props: {
enabled?: boolean
active: boolean
promptPulse?: number
complete?: boolean
glow?: boolean
breathe?: boolean
@@ -385,6 +404,7 @@ export function TabPulse(props: {
width="100%"
enabled={props.enabled ?? true}
active={props.active}
promptPulse={props.promptPulse ?? 0}
complete={props.complete ?? false}
glow={props.glow ?? false}
breathe={props.breathe ?? false}
+3 -3
View File
@@ -127,13 +127,13 @@ export const Info = Schema.Struct({
tabs: Schema.optional(
Schema.Struct({
enabled: Schema.optional(Schema.Boolean).annotate({
description: "Use a persistent session tab strip instead of pinned quick-switch sessions",
description: "Use a persistent tab strip instead of pinned quick-switch sessions",
}),
scope: Schema.optional(Schema.Literals(["global", "cwd"])).annotate({
description: "Share session tabs globally or keep a separate set for each working directory",
description: "Share tabs globally or keep a separate set for each working directory",
}),
}),
).annotate({ description: "Session tab settings" }),
).annotate({ description: "Tab strip settings" }),
mini: Schema.optional(
Schema.Struct({
thinking: Schema.optional(Schema.Literals(["show", "hide"])).annotate({
+18 -18
View File
@@ -87,13 +87,13 @@ export const Definitions = {
session_move: keybind("none", "Move session"),
session_new: keybind("<leader>n", "Create a new session"),
session_list: keybind("<leader>l", "List all sessions"),
session_tab_next: keybind("ctrl+tab,<leader>right", "Switch to next open session tab"),
session_tab_previous: keybind("ctrl+shift+tab,<leader>left", "Switch to previous open session tab"),
session_tab_history_back: keybind("ctrl+o", "Go back in session tab history"),
session_tab_history_forward: keybind("ctrl+i", "Go forward in session tab history"),
session_tab_next_unread: keybind("<leader>down", "Switch to next unread session tab"),
session_tab_previous_unread: keybind("<leader>up", "Switch to previous unread session tab"),
session_tab_close: keybind("<leader>w", "Close current session tab"),
open_menu: keybind("ctrl+o", "Open recent sessions and projects"),
session_tab_next: keybind("ctrl+tab,<leader>right,alt+shift+]", "Switch to next open tab"),
session_tab_previous: keybind("ctrl+shift+tab,<leader>left,alt+shift+[", "Switch to previous open tab"),
session_tab_next_unread: keybind("<leader>down", "Switch to next unread tab"),
session_tab_previous_unread: keybind("<leader>up", "Switch to previous unread tab"),
session_tab_close: keybind("<leader>w", "Close current tab"),
session_tab_reopen: keybind("ctrl+shift+t", "Reopen last closed tab"),
session_timeline: keybind("<leader>g", "Show session timeline"),
session_fork: keybind("none", "Fork session from message"),
session_rename: keybind("ctrl+r", "Rename session"),
@@ -118,15 +118,15 @@ export const Definitions = {
session_quick_switch_7: keybind("<leader>7", "Switch to session in quick slot 7"),
session_quick_switch_8: keybind("<leader>8", "Switch to session in quick slot 8"),
session_quick_switch_9: keybind("<leader>9", "Switch to session in quick slot 9"),
session_tab_select_1: keybind("<leader>1,ctrl+1", "Switch to session tab 1"),
session_tab_select_2: keybind("<leader>2,ctrl+2", "Switch to session tab 2"),
session_tab_select_3: keybind("<leader>3,ctrl+3", "Switch to session tab 3"),
session_tab_select_4: keybind("<leader>4,ctrl+4", "Switch to session tab 4"),
session_tab_select_5: keybind("<leader>5,ctrl+5", "Switch to session tab 5"),
session_tab_select_6: keybind("<leader>6,ctrl+6", "Switch to session tab 6"),
session_tab_select_7: keybind("<leader>7,ctrl+7", "Switch to session tab 7"),
session_tab_select_8: keybind("<leader>8,ctrl+8", "Switch to session tab 8"),
session_tab_select_9: keybind("<leader>9,ctrl+9", "Switch to session tab 9"),
session_tab_select_1: keybind("<leader>1,ctrl+1", "Switch to tab 1"),
session_tab_select_2: keybind("<leader>2,ctrl+2", "Switch to tab 2"),
session_tab_select_3: keybind("<leader>3,ctrl+3", "Switch to tab 3"),
session_tab_select_4: keybind("<leader>4,ctrl+4", "Switch to tab 4"),
session_tab_select_5: keybind("<leader>5,ctrl+5", "Switch to tab 5"),
session_tab_select_6: keybind("<leader>6,ctrl+6", "Switch to tab 6"),
session_tab_select_7: keybind("<leader>7,ctrl+7", "Switch to tab 7"),
session_tab_select_8: keybind("<leader>8,ctrl+8", "Switch to tab 8"),
session_tab_select_9: keybind("<leader>9,ctrl+9", "Switch to tab 9"),
stash_delete: keybind("ctrl+d", "Delete stash entry"),
model_provider_list: keybind("ctrl+a", "Open provider list from model dialog"),
@@ -290,13 +290,13 @@ export const CommandMap = {
session_move: "session.move",
session_new: "session.new",
session_list: "session.list",
open_menu: "open.menu",
session_tab_next: "session.tab.next",
session_tab_previous: "session.tab.previous",
session_tab_history_back: "session.tab.history.back",
session_tab_history_forward: "session.tab.history.forward",
session_tab_next_unread: "session.tab.next_unread",
session_tab_previous_unread: "session.tab.previous_unread",
session_tab_close: "session.tab.close",
session_tab_reopen: "session.tab.reopen",
session_timeline: "session.timeline",
session_fork: "session.fork",
session_rename: "session.rename",
+28 -5
View File
@@ -45,6 +45,7 @@ const messageIDFromEvent = (eventID: string) => eventID.replace(/^evt_/, "msg_")
// server cannot recover their Location when settling them. Preserve the event Location
// until MCP elicitations carry session ownership.
export type FormWithLocation = FormInfo & { readonly location?: LocationRef }
type ShellWithLocation = ShellInfo & { readonly location: LocationRef }
type LocationData = {
info?: LocationGetOutput
@@ -61,7 +62,7 @@ type LocationData = {
websearch?: WebSearchProvider[]
// Currently running shell commands for this location, keyed by shell id. Entries are removed
// once the command exits or is deleted, so this only ever holds in-flight shells.
shell?: Record<string, ShellInfo>
shell?: Record<string, ShellWithLocation>
skill?: SkillInfo[]
}
@@ -374,8 +375,11 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
.catch((error) => console.error("Failed to load projected model switch message", error))
break
case "session.renamed":
if (store.session.info[event.data.sessionID])
setStore("session", "info", event.data.sessionID, "title", event.data.title)
// Preserve the live title when it races the session's initial read.
void result.session.sync(event.data.sessionID).then(() => {
if (store.session.info[event.data.sessionID])
setStore("session", "info", event.data.sessionID, "title", event.data.title)
})
break
case "session.moved":
if (store.session.info[event.data.sessionID]) {
@@ -851,7 +855,10 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
case "shell.created":
setStore("location", locationKey(event.location ?? defaultLocation()), (data) => ({
...data,
shell: { ...data?.shell, [event.data.info.id]: event.data.info },
shell: {
...data?.shell,
[event.data.info.id]: { ...event.data.info, location: event.location ?? defaultLocation() },
},
}))
break
case "shell.exited":
@@ -1089,6 +1096,11 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
list(location?: LocationRef) {
return Object.values(store.location[locationKey(location ?? defaultLocation())]?.shell ?? {})
},
listBySession(sessionID: string) {
return Object.values(store.location)
.flatMap((data) => Object.values(data.shell ?? {}))
.filter((shell) => shell.metadata.sessionID === sessionID)
},
get(id: string) {
return Object.values(store.location)
.map((data) => data.shell?.[id])
@@ -1101,7 +1113,18 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
const key = locationKey(response.location)
setStore("location", key, {
...store.location[key],
shell: Object.fromEntries(response.data.map((info) => [info.id, info])),
shell: Object.fromEntries(
response.data.map((info) => [
info.id,
{
...info,
location: {
directory: response.location.directory,
workspaceID: response.location.workspaceID,
},
},
]),
),
})
})
},
-13
View File
@@ -1,13 +0,0 @@
import { createMemo } from "solid-js"
import { useData } from "./data"
import { abbreviateHome } from "../runtime"
import { useTuiPaths } from "./runtime"
export function useDirectory() {
const data = useData()
const paths = useTuiPaths()
return createMemo(() => {
const directory = data.location.info()?.directory ?? data.location.default().directory ?? paths.cwd
return abbreviateHome(directory, paths.home)
})
}
+5
View File
@@ -5,6 +5,8 @@ import { useData } from "./data"
const context = createContext<{
readonly current: LocationGetOutput | undefined
// The target location as set, available before the server-synced info in `current` arrives.
readonly ref: LocationRef | undefined
set: (location?: LocationRef) => void
}>()
@@ -37,6 +39,9 @@ export function LocationProvider(props: ParentProps) {
get current() {
return current()
},
get ref() {
return ref()
},
set,
}}
>
+1
View File
@@ -7,6 +7,7 @@ import { useTuiStartup } from "./runtime"
export type HomeRoute = {
type: "home"
prompt?: PromptInfo
// Location carried over from the previous session or project picker so a new session lands there.
location?: LocationRef
}
@@ -5,6 +5,8 @@ export type SessionTab = {
export type SessionTabUnread = "activity" | "error"
export const NEW_SESSION_TAB_TITLE = "New session"
export type SessionTabHistory = {
entries: readonly string[]
index: number
@@ -36,6 +38,39 @@ export function closeSessionTab(tabs: readonly SessionTab[], sessionID: string)
}
}
export type ClosedSessionTab = {
tab: SessionTab
index: number
}
const CLOSED_SESSION_TAB_LIMIT = 10
export function recordClosedSessionTab(
stack: readonly ClosedSessionTab[],
tab: SessionTab,
index: number,
): ClosedSessionTab[] {
return [...stack.filter((entry) => entry.tab.sessionID !== tab.sessionID), { tab, index }].slice(
-CLOSED_SESSION_TAB_LIMIT,
)
}
/**
* Pop the most recently closed tab that is not already open and restore it at its original
* position. Entries for already-open sessions are consumed so repeated reopens walk the stack.
*/
export function reopenSessionTab(stack: readonly ClosedSessionTab[], tabs: readonly SessionTab[]) {
const remaining = [...stack]
while (remaining.length > 0) {
const entry = remaining.pop()!
if (tabs.some((tab) => tab.sessionID === entry.tab.sessionID)) continue
const next = [...tabs]
next.splice(Math.min(entry.index, tabs.length), 0, entry.tab)
return { stack: remaining, tabs: next, sessionID: entry.tab.sessionID }
}
return { stack: remaining, tabs: undefined, sessionID: undefined }
}
export function moveSessionTab(tabs: SessionTab[], sessionID: string, index: number): SessionTab[] {
const from = tabs.findIndex((tab) => tab.sessionID === sessionID)
const to = Math.max(0, Math.min(tabs.length - 1, index))
+85 -34
View File
@@ -1,8 +1,9 @@
import { createEffect, createMemo, onCleanup } from "solid-js"
import { createEffect, createMemo, createSignal, onCleanup } from "solid-js"
import { isDeepEqual } from "remeda"
import { createSimpleContext } from "./helper"
import { useClient } from "./client"
import { useData } from "./data"
import { withTimestampedFallback } from "@opencode-ai/util/session-title-fallback"
import { useEvent } from "./event"
import { useRoute } from "./route"
import { useConfig } from "../config"
@@ -13,8 +14,12 @@ import {
cycleSessionTab,
moveSessionTab,
moveSessionTabHistory,
NEW_SESSION_TAB_TITLE,
openSessionTab,
recordClosedSessionTab,
recordSessionTabHistory,
reopenSessionTab,
type ClosedSessionTab,
type SessionTab,
type SessionTabHistory,
type SessionTabUnread,
@@ -55,28 +60,53 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
key: "sessionID",
})
const fallback = empty()
const [promptPulses, setPromptPulses] = createSignal<Record<string, number>>({})
let history: SessionTabHistory = { entries: [], index: -1 }
// User-closed tabs eligible for reopening; in-memory like history, deleted sessions pruned.
let closedTabs: ClosedSessionTab[] = []
function state() {
if (config.tabs?.scope === "global") return store.global
return store.cwd[paths.cwd] ?? fallback
if (config.tabs?.scope === "cwd") return store.cwd[paths.cwd] ?? fallback
return store.global
}
function update(mutation: (draft: TabsState) => void) {
const scope = config.tabs?.scope ?? "cwd"
const scope = config.tabs?.scope ?? "global"
void updateStore((draft) => mutation(scope === "cwd" ? (draft.cwd[paths.cwd] ??= empty()) : draft.global)).catch(
() => {},
)
}
const root = (sessionID: string) => data.session.root(sessionID)
const title = (sessionID: string, persisted?: string, fallback?: string) => {
const session = data.session.get(sessionID)
return session?.title ?? persisted ?? fallback ?? (session ? withTimestampedFallback(session) : undefined)
}
const normalize = (value: TabsState) => ({
tabs: value.tabs.reduce<SessionTab[]>((tabs, tab) => {
const sessionID = root(tab.sessionID)
return openSessionTab(tabs, { sessionID, title: title(sessionID, tab.title) })
}, []),
unread: Object.entries(value.unread).reduce<Record<string, SessionTabUnread>>((result, entry) => {
const sessionID = root(entry[0])
result[sessionID] = result[sessionID] === "error" ? "error" : entry[1]
return result
}, {}),
})
const current = () => (route.data.type === "session" ? root(route.data.sessionID) : undefined)
const newTab = createMemo((open = false) => {
if (route.data.type === "home") return true
if (!open) return false
const sessionID = current()
return sessionID !== undefined && !state().tabs.some((tab) => tab.sessionID === sessionID)
}, false)
const status = (sessionID: string) => {
const session = root(sessionID)
const members = data.session.family(session)
const family = members.length > 0 ? members : [session]
return {
unread: state().unread[session],
promptPulse: promptPulses()[session] ?? 0,
attention: family.some(
(id) => (data.session.permission.list(id)?.length ?? 0) > 0 || (data.session.form.list(id)?.length ?? 0) > 0,
),
@@ -100,37 +130,29 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
if (route.data.type !== "session" || route.data.sessionID === "dummy") return
const sessionID = root(route.data.sessionID)
history = recordSessionTabHistory(history, sessionID)
const title = data.session.get(sessionID)?.title
const tabs = openSessionTab(state().tabs, { sessionID, title })
const fallback = newTab() ? NEW_SESSION_TAB_TITLE : undefined
const tabs = openSessionTab(state().tabs, {
sessionID,
title: title(sessionID, state().tabs.find((tab) => tab.sessionID === sessionID)?.title, fallback),
})
if (tabs === state().tabs && !state().unread[sessionID]) return
update((draft) => {
draft.tabs = openSessionTab(draft.tabs, { sessionID, title })
draft.tabs = openSessionTab(draft.tabs, {
sessionID,
title: title(sessionID, draft.tabs.find((tab) => tab.sessionID === sessionID)?.title, fallback),
})
delete draft.unread[sessionID]
})
})
createEffect(() => {
if (!enabled()) return
const next = state().tabs.reduce<SessionTab[]>((tabs, tab) => {
const sessionID = root(tab.sessionID)
return openSessionTab(tabs, { sessionID, title: data.session.get(sessionID)?.title ?? tab.title })
}, [])
const unread = Object.entries(state().unread).reduce<Record<string, SessionTabUnread>>((result, entry) => {
const sessionID = root(entry[0])
result[sessionID] = result[sessionID] === "error" ? "error" : entry[1]
return result
}, {})
if (isDeepEqual(next, state().tabs) && isDeepEqual(unread, state().unread)) return
const next = normalize(state())
if (isDeepEqual(next, state())) return
update((draft) => {
draft.tabs = draft.tabs.reduce<SessionTab[]>((tabs, tab) => {
const sessionID = root(tab.sessionID)
return openSessionTab(tabs, { sessionID, title: data.session.get(sessionID)?.title ?? tab.title })
}, [])
draft.unread = Object.entries(draft.unread).reduce<Record<string, SessionTabUnread>>((result, entry) => {
const sessionID = root(entry[0])
result[sessionID] = result[sessionID] === "error" ? "error" : entry[1]
return result
}, {})
const next = normalize(draft)
draft.tabs = next.tabs
draft.unread = next.unread
})
})
@@ -176,6 +198,14 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
onCleanup(event.on("session.execution.succeeded", (evt) => markUnread(evt.data.sessionID, "activity")))
onCleanup(event.on("session.execution.interrupted", (evt) => markUnread(evt.data.sessionID, "activity")))
onCleanup(event.on("session.execution.failed", (evt) => markUnread(evt.data.sessionID, "error")))
onCleanup(
event.on("session.input.admitted", (evt) => {
if (!enabled() || evt.data.input.type !== "user") return
const sessionID = root(evt.data.sessionID)
if (current() === sessionID || !state().tabs.some((tab) => tab.sessionID === sessionID)) return
setPromptPulses((pulses) => ({ ...pulses, [sessionID]: (pulses[sessionID] ?? 0) + 1 }))
}),
)
onCleanup(
event.on("session.error", (evt) => {
if (evt.data.sessionID) markUnread(evt.data.sessionID, "error")
@@ -183,6 +213,8 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
)
onCleanup(
event.on("session.deleted", (evt) => {
const target = root(evt.data.sessionID)
closedTabs = closedTabs.filter((entry) => entry.tab.sessionID !== target)
remove(evt.data.sessionID, enabled())
}),
)
@@ -201,6 +233,12 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
draft.tabs = closeSessionTab(draft.tabs, target).tabs
delete draft.unread[target]
})
setPromptPulses((pulses) => {
if (pulses[target] === undefined) return pulses
const next = { ...pulses }
delete next[target]
return next
})
if (selected) route.navigate(next ? { type: "session", sessionID: next } : { type: "home" })
}
@@ -209,6 +247,9 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
tabs() {
return state().tabs
},
newTab() {
return newTab()
},
current,
status,
select(sessionID: string) {
@@ -219,12 +260,28 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
if (!enabled()) return
const target = sessionID ? root(sessionID) : current()
if (!target) {
const previous = state().tabs.at(-1)
if (route.data.type === "home" && previous) route.navigate({ type: "session", sessionID: previous.sessionID })
const previous = moveSessionTabHistory(history, state().tabs, undefined, -1)
history = previous.history
const session = previous.sessionID ?? state().tabs.at(-1)?.sessionID
if (route.data.type === "home" && session) route.navigate({ type: "session", sessionID: session })
return
}
const index = state().tabs.findIndex((tab) => tab.sessionID === target)
const tab = state().tabs[index]
if (tab) closedTabs = recordClosedSessionTab(closedTabs, tab, index)
remove(target, true)
},
reopen() {
if (!enabled()) return
const result = reopenSessionTab(closedTabs, state().tabs)
closedTabs = result.stack
const tabs = result.tabs
if (!tabs || !result.sessionID) return
update((draft) => {
draft.tabs = tabs
})
route.navigate({ type: "session", sessionID: result.sessionID })
},
move(sessionID: string, index: number) {
if (!enabled()) return
const session = root(sessionID)
@@ -247,12 +304,6 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
)
if (tab) route.navigate({ type: "session", sessionID: tab.sessionID })
},
history(direction: 1 | -1) {
if (!enabled()) return
const next = moveSessionTabHistory(history, state().tabs, current(), direction)
history = next.history
if (next.sessionID) route.navigate({ type: "session", sessionID: next.sessionID })
},
selectIndex(index: number) {
if (!enabled()) return
const tab = state().tabs[index]
@@ -2,7 +2,11 @@ import { Plugin } from "@opencode-ai/plugin/tui"
import { useTerminalDimensions } from "@opentui/solid"
import { batch, createSignal, For, onCleanup } from "solid-js"
import { createStore, reconcile } from "solid-js/store"
import { SessionTabs, type SessionTabsController } from "../../../component/session-tabs"
import {
EMPTY_SESSION_TAB_STATUS,
SessionTabs,
type SessionTabsController,
} from "../../../component/session-tabs"
import { moveSessionTab } from "../../../context/session-tabs-model"
import type { Story } from "./index"
@@ -23,7 +27,6 @@ const FIXTURE_TABS = [
{ sessionID: "fixture-12", title: "Prepare review" },
]
const EMPTY_STATUS: FixtureStatus = { unread: undefined, attention: false, busy: false }
const RUN_DURATION = 1_800
const RESUME_DURATION = 900
@@ -66,7 +69,7 @@ function SessionTabsStory(props: { context: Plugin.Context }) {
if (!resumed && roll < 0.25) {
setStatuses((current) => ({
...current,
[sessionID]: { ...(current[sessionID] ?? EMPTY_STATUS), attention: true },
[sessionID]: { ...(current[sessionID] ?? EMPTY_SESSION_TAB_STATUS), attention: true },
}))
setLastEvent(`tab ${number(sessionID)} needs input; select it to resolve`)
return
@@ -77,7 +80,7 @@ function SessionTabsStory(props: { context: Plugin.Context }) {
setOutcomes((current) => ({ ...current, [sessionID]: failed ? "failed" : "completed" }))
setStatuses((current) => ({
...current,
[sessionID]: { ...(current[sessionID] ?? EMPTY_STATUS), busy: false, unread },
[sessionID]: { ...(current[sessionID] ?? EMPTY_SESSION_TAB_STATUS), busy: false, unread },
}))
// An untitled session earns its title after its first completed run, like a real summarization.
const index = number(sessionID) - 1
@@ -110,7 +113,7 @@ function SessionTabsStory(props: { context: Plugin.Context }) {
tabs,
current: active,
status(sessionID) {
return statuses()[sessionID] ?? EMPTY_STATUS
return statuses()[sessionID] ?? EMPTY_SESSION_TAB_STATUS
},
select,
move(sessionID: string, index: number) {
@@ -150,7 +153,7 @@ function SessionTabsStory(props: { context: Plugin.Context }) {
const startRun = (sessionID: string) => {
setStatuses((current) => ({
...current,
[sessionID]: { ...(current[sessionID] ?? EMPTY_STATUS), busy: true, unread: undefined },
[sessionID]: { ...(current[sessionID] ?? EMPTY_SESSION_TAB_STATUS), busy: true, unread: undefined },
}))
setOutcomes((current) => {
const next = { ...current }
@@ -223,7 +226,7 @@ function SessionTabsStory(props: { context: Plugin.Context }) {
const selectedState = () => {
const current = active()
const status = current ? controller.status(current) : EMPTY_STATUS
const status = current ? controller.status(current) : EMPTY_SESSION_TAB_STATUS
const activity = status.busy
? "running"
: status.unread === "activity"
@@ -351,7 +354,7 @@ function SessionTabsStory(props: { context: Plugin.Context }) {
paddingRight={1}
flexDirection="row"
>
<text fg={elevatedTheme.text.subdued}>storybook / session tabs</text>
<text fg={elevatedTheme.text.subdued}>storybook / tabs</text>
<box flexGrow={1} />
<text fg={elevatedTheme.text.subdued}>
space/s run | t add | d close | r reset | / 1-0 move | drag reorders | esc back
@@ -363,6 +366,6 @@ function SessionTabsStory(props: { context: Plugin.Context }) {
export const sessionTabsStory: Story = {
id: "session-tabs",
title: "Session tabs",
title: "Tabs",
render: (context) => <SessionTabsStory context={context} />,
}
+1 -3
View File
@@ -32,7 +32,6 @@ import { footerWidthPolicy } from "./footer.width"
import { toolFiletype } from "./tool"
import { transparent, type RunBlockTheme, type RunFooterTheme } from "./theme"
import type { MiniPermissionRequest, PermissionReply } from "./types"
import { PatchDiff } from "../component/patch-diff"
function buttons(
list: PermissionOption[],
@@ -406,9 +405,8 @@ export function RunPermissionBody(props: {
</Show>
}
>
<PatchDiff
<diff
diff={info().diff!}
hunkFg={props.block.diffLineNumber}
view="unified"
filetype={ft()}
syntaxStyle={props.block.syntax}
+3 -3
View File
@@ -10,7 +10,7 @@
// back to the usual two-press exit sequence through RunFooter.requestExit().
import path from "path"
import { CliRenderEvents, createCliRenderer, type CliRenderer, type ScrollbackWriter } from "@opentui/core"
import { isDefaultTitle } from "../util/session"
import { isFallbackTitle } from "@opencode-ai/util/session-title-fallback"
import { monoSnapshot } from "./mono"
import { entrySplash, exitSplash, splashMeta } from "./splash"
import { resolveRunTheme } from "./theme"
@@ -105,7 +105,7 @@ function shutdown(renderer: CliRenderer): void {
}
function splashInfo(title: string | undefined, history: RunPrompt[]) {
if (title && !isDefaultTitle(title)) {
if (title && !isFallbackTitle(title)) {
return {
title,
showSession: true,
@@ -176,7 +176,7 @@ export async function createRuntimeLifecycle(input: LifecycleInput): Promise<Lif
if (mono) renderer.on(CliRenderEvents.EXTERNAL_OUTPUT, monoSnapshot)
const setTitle = (title?: string) => {
if (input.host.platform !== "linux") return
if (!title || isDefaultTitle(title)) return renderer.setTerminalTitle("OpenCode")
if (!title || isFallbackTitle(title)) return renderer.setTerminalTitle("OpenCode")
renderer.setTerminalTitle(`OC | ${title.length > 40 ? title.slice(0, 37) + "..." : title}`)
}
setTitle(input.sessionTitle)
+1 -3
View File
@@ -13,7 +13,6 @@ import { entryColor, entryLook, entrySyntax } from "./scrollback.shared"
import { toolFiletype, toolStructuredFinal } from "./tool"
import { RUN_THEME_FALLBACK, transparent, type RunTheme } from "./theme"
import type { EntryLayout, RunEntryBody, ScrollbackOptions, StreamCommit, TurnSummary } from "./types"
import { PatchDiff } from "../component/patch-diff"
export function entryGroupKey(commit: StreamCommit): string | undefined {
if (!commit.partID) {
@@ -179,9 +178,8 @@ export function RunEntryContent(props: {
</text>
{item.diff.trim() ? (
<box width="100%" paddingLeft={1}>
<PatchDiff
<diff
diff={item.diff}
hunkFg={theme().block.diffLineNumber}
view="unified"
filetype={toolFiletype(item.file)}
syntaxStyle={syntax()}
+3 -1
View File
@@ -18,6 +18,8 @@ export const builtins = [
SidebarFooter,
Notifications,
Plugins,
Storybook,
// The storybook is a development tool; keep its route and palette commands out of
// normal launches and register it only for OPENCODE_STORY runs.
...(process.env.OPENCODE_STORY ? [Storybook] : []),
DiffViewer,
]
+8 -2
View File
@@ -1,5 +1,5 @@
import { Prompt, type PromptRef } from "../component/prompt"
import { createEffect, createMemo, createSignal, onMount, Show } from "solid-js"
import { createEffect, createMemo, createSignal, onMount, Show, untrack } from "solid-js"
import { Logo } from "../component/logo"
import { useArgs } from "../context/args"
import { useRouteData } from "../context/route"
@@ -31,7 +31,13 @@ export function Home() {
const forms = createMemo(() => data.session.form.list("global", currentLocation()) ?? [])
let sent = false
createEffect(() => location.set(currentLocation()))
// Track only the route location and (when absent) the default location; location.set
// reads other signals internally and tracking them would re-assert the route location
// after the user overrides it with /cd.
createEffect(() => {
const target = currentLocation()
untrack(() => location.set(target))
})
onMount(() => {
editor.clearSelection()
@@ -2,7 +2,6 @@ import { createMemo, For, Show, createEffect, onMount, onCleanup } from "solid-j
import { createStore } from "solid-js/store"
import { TextAttributes, ScrollBoxRenderable } from "@opentui/core"
import { useData } from "../../../context/data"
import { useLocation } from "../../../context/location"
import { useClient } from "../../../context/client"
import { useTheme } from "../../../context/theme"
import { Keymap } from "../../../context/keymap"
@@ -10,14 +9,13 @@ import { useComposerTab } from "./index"
export function ShellTab(props: { sessionID: string }) {
const data = useData()
const location = useLocation()
const client = useClient()
const theme = useTheme()
const composer = useComposerTab()
const shortcuts = Keymap.useShortcuts()
const entries = createMemo(() =>
data.shell.list().filter((shell) => shell.metadata.sessionID === props.sessionID && shell.status === "running"),
data.shell.listBySession(props.sessionID).filter((shell) => shell.status === "running"),
)
const [store, setStore] = createStore({ selected: 0 })
@@ -85,10 +83,9 @@ export function ShellTab(props: { sessionID: string }) {
run() {
const entry = selectedEntry()
if (!entry) return
const ref = location.current
void client.api.shell.remove({
id: entry.id,
location: ref ? { directory: ref.directory, workspace: ref.workspaceID } : undefined,
location: { directory: entry.location.directory, workspace: entry.location.workspaceID },
})
},
},
@@ -8,6 +8,7 @@ import { useTheme } from "../../../context/theme"
import { Locale } from "../../../util/locale"
import { Keymap } from "../../../context/keymap"
import { useComposerTab } from "./index"
import { withTimestampedFallback } from "@opencode-ai/util/session-title-fallback"
interface SubagentEntry {
sessionID: string
@@ -38,13 +39,14 @@ export function SubagentsTab(props: { sessionID: string }) {
if (current.parentID) {
const siblings = data.session.list().filter((s) => s.parentID === current.parentID)
for (const sibling of siblings) {
const agentMatch = sibling.title.match(/@(\w+) subagent/)
const title = withTimestampedFallback(sibling)
const agentMatch = title.match(/@(\w+) subagent/)
const agent = sibling.agent
? Locale.titlecase(sibling.agent)
: agentMatch
? Locale.titlecase(agentMatch[1])
: "Subagent"
const name = agentMatch ? sibling.title.replace(agentMatch[0], "").trim() || sibling.title : sibling.title
const name = agentMatch ? title.replace(agentMatch[0], "").trim() || title : title
result.push({
sessionID: sibling.id,
agent,
@@ -56,13 +58,14 @@ export function SubagentsTab(props: { sessionID: string }) {
} else {
const children = data.session.list().filter((s) => s.parentID === props.sessionID)
for (const child of children) {
const agentMatch = child.title.match(/@(\w+) subagent/)
const title = withTimestampedFallback(child)
const agentMatch = title.match(/@(\w+) subagent/)
const agent = child.agent
? Locale.titlecase(child.agent)
: agentMatch
? Locale.titlecase(agentMatch[1])
: "Subagent"
const name = agentMatch ? child.title.replace(agentMatch[0], "").trim() || child.title : child.title
const name = agentMatch ? title.replace(agentMatch[0], "").trim() || title : title
result.push({
sessionID: child.id,
agent,
@@ -1,26 +0,0 @@
import { DialogSelect } from "../../ui/dialog-select"
import { useRoute } from "../../context/route"
export function DialogSubagent(props: { sessionID: string }) {
const route = useRoute()
return (
<DialogSelect
title="Subagent Actions"
options={[
{
title: "Open",
value: "subagent.view",
description: "the subagent's session",
onSelect: (dialog) => {
route.navigate({
type: "session",
sessionID: props.sessionID,
})
dialog.clear()
},
},
]}
/>
)
}
@@ -1,91 +0,0 @@
import { createMemo, Match, onCleanup, onMount, Show, Switch } from "solid-js"
import { useTheme } from "../../context/theme"
import { useData } from "../../context/data"
import { useDirectory } from "../../context/directory"
import { useConnected } from "../../component/use-connected"
import { createStore } from "solid-js/store"
import { useRoute } from "../../context/route"
import { usePermission } from "../../context/permission"
export function Footer() {
const theme = useTheme()
const data = useData()
const route = useRoute()
const permission = usePermission()
const mcp = createMemo(
() => (data.location.mcp.server.list() ?? []).filter((x) => x.status.status === "connected").length,
)
const mcpError = createMemo(() => (data.location.mcp.server.list() ?? []).some((x) => x.status.status === "failed"))
const permissions = createMemo(() => {
if (route.data.type !== "session") return []
return data.session.permission.list(route.data.sessionID) ?? []
})
const directory = useDirectory()
const connected = useConnected()
const [store, setStore] = createStore({
welcome: false,
})
onMount(() => {
// Track all timeouts to ensure proper cleanup
const timeouts: ReturnType<typeof setTimeout>[] = []
function tick() {
if (connected()) return
if (!store.welcome) {
setStore("welcome", true)
timeouts.push(setTimeout(() => tick(), 5000))
return
}
if (store.welcome) {
setStore("welcome", false)
timeouts.push(setTimeout(() => tick(), 10_000))
return
}
}
timeouts.push(setTimeout(() => tick(), 10_000))
onCleanup(() => {
timeouts.forEach(clearTimeout)
})
})
return (
<box flexDirection="row" justifyContent="space-between" gap={1} flexShrink={0}>
<text fg={theme.text.subdued}>{directory()}</text>
<box gap={2} flexDirection="row" flexShrink={0}>
<Switch>
<Match when={store.welcome}>
<text fg={theme.text.default}>
Get started <span style={{ fg: theme.text.subdued }}>/connect</span>
</text>
</Match>
<Match when={connected()}>
<Show when={permission.mode !== "auto" && permissions().length > 0}>
<text fg={theme.text.feedback.warning.default}>
<span style={{ fg: theme.text.feedback.warning.default }}></span> {permissions().length} Permission
{permissions().length > 1 ? "s" : ""}
</text>
</Show>
<Show when={mcp()}>
<text fg={theme.text.default}>
<Switch>
<Match when={mcpError()}>
<span style={{ fg: theme.text.feedback.error.default }}> </span>
</Match>
<Match when={true}>
<span style={{ fg: theme.text.feedback.success.default }}> </span>
</Match>
</Switch>
{mcp()} MCP
</text>
</Show>
<text fg={theme.text.subdued}>/status</text>
</Match>
</Switch>
</box>
</box>
)
}
+5 -7
View File
@@ -22,7 +22,6 @@ import { useData } from "../../context/data"
import { SplitBorder } from "../../ui/border"
import { useTuiPaths, useTuiTerminalEnvironment } from "../../context/runtime"
import { Spinner, SPINNER_FRAMES } from "../../component/spinner"
import { PatchDiff } from "../../component/patch-diff"
import { ThemeContextProvider, useTheme, useThemes } from "../../context/theme"
import { BoxRenderable, ScrollBoxRenderable, addDefaultParsers, TextAttributes, RGBA } from "@opentui/core"
import { Prompt, type PromptRef } from "../../component/prompt"
@@ -93,6 +92,7 @@ import { switchLabel } from "../../util/model"
import { findMessageBoundary, messageNavigationSlack } from "./message-navigation"
import { stringWidth } from "../../util/string-width"
import { useArgs } from "../../context/args"
import { withTimestampedFallback } from "@opencode-ai/util/session-title-fallback"
addDefaultParsers(parsers.parsers)
@@ -143,8 +143,8 @@ export function Session() {
const promptRef = usePromptRef()
const session = createMemo(() => data.session.get(route.sessionID))
const messages = () => data.session.message.list(route.sessionID)
const location = createMemo(() => session()?.location)
const currentLocation = useLocation()
const location = createMemo(() => session()?.location ?? currentLocation.ref)
createEffect(() => currentLocation.set(location()))
@@ -3028,9 +3028,8 @@ function Edit(props: ToolProps) {
{(item) => (
<BlockTool path={{ label: "← Edit", value: pathFormatter.format(path()) }} part={props.part}>
<box paddingLeft={1}>
<PatchDiff
<diff
diff={item().patch}
hunkFg={theme.diff.text.hunkHeader}
view={view()}
filetype={filetype(path())}
syntaxStyle={syntax()}
@@ -3118,9 +3117,8 @@ function ApplyPatch(props: ToolProps) {
}
>
<box paddingLeft={1}>
<PatchDiff
<diff
diff={file.patch}
hunkFg={theme.diff.text.hunkHeader}
view={view()}
filetype={filetype(file.relativePath)}
syntaxStyle={syntax()}
@@ -3310,7 +3308,7 @@ function formatSessionTranscript(session: SessionInfo, messages: SessionMessageI
})
return [`## Assistant\n\n${content.join("\n\n")}`]
})
return `# ${session.title}\n\n**Session ID:** ${session.id}\n**Created:** ${new Date(session.time.created).toLocaleString()}\n**Updated:** ${new Date(session.time.updated).toLocaleString()}\n\n---\n\n${body.join("\n\n---\n\n")}\n`
return `# ${withTimestampedFallback(session)}\n\n**Session ID:** ${session.id}\n**Created:** ${new Date(session.time.created).toLocaleString()}\n**Updated:** ${new Date(session.time.updated).toLocaleString()}\n\n---\n\n${body.join("\n\n---\n\n")}\n`
}
export function parseApplyPatchFiles(value: unknown) {
@@ -14,7 +14,6 @@ import { useConfig } from "../../config"
import { Keymap } from "../../context/keymap"
import { usePathFormatter } from "../../context/path-format"
import { SimulationSemantics } from "../../simulation/semantics"
import { PatchDiff } from "../../component/patch-diff"
type PermissionStage = "permission" | "always" | "reject"
@@ -51,9 +50,8 @@ function EditBody(props: { file?: string; diff?: string; patch?: string }) {
},
}}
>
<PatchDiff
<diff
diff={diff()}
hunkFg={theme.diff.text.hunkHeader}
view={view()}
filetype={ft()}
syntaxStyle={syntax()}

Some files were not shown because too many files have changed in this diff Show More