mirror of
https://github.com/anomalyco/opencode.git
synced 2026-07-30 15:11:11 -04:00
Compare commits
11 Commits
diff-hunk-rows
..
v2
| Author | SHA1 | Date | |
|---|---|---|---|
| e1c04dcce6 | |||
| 188c642d8c | |||
| 9d55b223bb | |||
| ce7a7e4e23 | |||
| cba5ba03e3 | |||
| 49081a4e24 | |||
| d6371f2fcd | |||
| 7625cbdf47 | |||
| 0ffec67ca3 | |||
| bd132f2614 | |||
| dcb0df4ac1 |
@@ -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,
|
||||
|
||||
@@ -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 } },
|
||||
})
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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)),
|
||||
)
|
||||
|
||||
@@ -182,7 +182,8 @@ export const fromCatalogModel = (
|
||||
.model({ id: resolved.modelID ?? resolved.id, compatibility: resolved.compatibility }),
|
||||
)
|
||||
}
|
||||
if (Provider.isAISDK(resolved.package)) {
|
||||
const native = Provider.isAISDK(resolved.package) ? nativePackage(packageName) : resolved.package
|
||||
if (Provider.isAISDK(resolved.package) && !native) {
|
||||
if (!dependencies?.loadAISDK) return Effect.fail(unsupported(resolved))
|
||||
const runtime = produce(resolved, (draft) => {
|
||||
draft.settings = Provider.mergeOverlay(draft.settings, {
|
||||
@@ -193,20 +194,22 @@ 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 providerOptions = nativeProviderOptions(packageName, configured)
|
||||
const settings = {
|
||||
...(credential ? withoutNativeAuthSettings(configured) : configured),
|
||||
...nativeCredentialSettings(specifier, credential),
|
||||
headers: resolved.headers,
|
||||
body: resolved.body,
|
||||
limits: { context: resolved.limit.context, output: resolved.limit.output },
|
||||
...(providerOptions ? { providerOptions } : {}),
|
||||
}
|
||||
return yield* Effect.try({
|
||||
try: () => {
|
||||
@@ -223,6 +226,26 @@ export const fromCatalogModel = (
|
||||
})
|
||||
}
|
||||
|
||||
const nativePackage = (packageName: string | undefined) => {
|
||||
if (packageName === "@ai-sdk/google") return "@opencode-ai/ai/providers/google"
|
||||
if (packageName === "@openrouter/ai-sdk-provider") return "@opencode-ai/ai/providers/openrouter"
|
||||
if (packageName === "@ai-sdk/xai") return "@opencode-ai/ai/providers/xai"
|
||||
return undefined
|
||||
}
|
||||
|
||||
const nativeProviderOptions = (packageName: string | undefined, 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 undefined
|
||||
if (packageName === "@ai-sdk/google") return { gemini: values }
|
||||
if (packageName === "@openrouter/ai-sdk-provider") return { openrouter: values }
|
||||
if (packageName === "@ai-sdk/xai") return { xai: values }
|
||||
return undefined
|
||||
}
|
||||
|
||||
const isNativeOpenAI = (packageName: string | undefined) =>
|
||||
packageName === "@opencode-ai/ai/providers/openai" ||
|
||||
packageName?.startsWith("@opencode-ai/ai/providers/openai/") === true
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -657,6 +657,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) =>
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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 })
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -546,6 +546,76 @@ 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", "gemini"],
|
||||
["@openrouter/ai-sdk-provider", "@opencode-ai/ai/providers/openrouter", "openrouter"],
|
||||
["@ai-sdk/xai", "@opencode-ai/ai/providers/xai", "xai"],
|
||||
] as const
|
||||
|
||||
yield* Effect.forEach(packages, ([catalogPackage, nativePackage, optionKey]) =>
|
||||
ModelResolver.fromCatalogModel(
|
||||
model(Provider.aisdk(catalogPackage), {
|
||||
modelID: "api-model",
|
||||
settings: { baseURL: "https://provider.example/v1", reasoningEffort: "high" },
|
||||
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: { [optionKey]: { reasoningEffort: "high" } },
|
||||
})
|
||||
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 +624,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 +636,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 +652,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 +668,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 +682,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,
|
||||
{
|
||||
|
||||
@@ -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")
|
||||
}),
|
||||
|
||||
@@ -171,6 +171,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
|
||||
|
||||
@@ -1219,7 +1219,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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -8,6 +8,9 @@ import { abbreviateHome } from "../runtime"
|
||||
import { useTuiPaths } from "../context/runtime"
|
||||
import { useLocation } from "../context/location"
|
||||
import { useToast } from "../ui/toast"
|
||||
import { useTerminalDimensions } from "@opentui/solid"
|
||||
import { truncateFilePath } from "../ui/file-path"
|
||||
import { stringWidth } from "../util/string-width"
|
||||
|
||||
export function DialogProject() {
|
||||
const dialog = useDialog()
|
||||
@@ -16,6 +19,7 @@ export function DialogProject() {
|
||||
const paths = useTuiPaths()
|
||||
const location = useLocation()
|
||||
const toast = useToast()
|
||||
const dimensions = useTerminalDimensions()
|
||||
|
||||
data.project.invalidate()
|
||||
void data.project.sync().catch(toast.error)
|
||||
@@ -36,12 +40,18 @@ export function DialogProject() {
|
||||
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",
|
||||
}))
|
||||
.map((project) => {
|
||||
const title = project.name ?? path.basename(project.canonical)
|
||||
const description = abbreviateHome(project.canonical, paths.home)
|
||||
// Dialog padding, the current marker, title padding, and the separating space use nine columns.
|
||||
const width = Math.min(60, dimensions().width - 2) - 9 - stringWidth(title)
|
||||
return {
|
||||
title,
|
||||
description: truncateFilePath(description, width),
|
||||
searchText: description,
|
||||
value: project.canonical,
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
return (
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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()
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
sessionTabComplete,
|
||||
seedSessionTabMotion,
|
||||
sessionTabOverflowWidth,
|
||||
type SessionTab,
|
||||
type SessionTabUnread,
|
||||
} from "../context/session-tabs-model"
|
||||
import { createAnimatable, spring, tween } from "../ui/animation"
|
||||
@@ -24,10 +25,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" }
|
||||
|
||||
export function SessionTabs(props: { controller?: SessionTabsController; animations?: boolean } = {}) {
|
||||
const tabs = props.controller ?? useSessionTabs()
|
||||
const dimensions = useTerminalDimensions()
|
||||
@@ -42,8 +52,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 +62,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,
|
||||
{
|
||||
@@ -262,6 +273,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 +287,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 +296,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 +336,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 ? "×" : ""}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -1089,6 +1089,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])
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
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"
|
||||
@@ -55,6 +55,7 @@ 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 }
|
||||
|
||||
function state() {
|
||||
@@ -77,6 +78,7 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
|
||||
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,
|
||||
),
|
||||
@@ -176,6 +178,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")
|
||||
@@ -201,6 +211,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 +225,9 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
|
||||
tabs() {
|
||||
return state().tabs
|
||||
},
|
||||
newTab() {
|
||||
return route.data.type === "home"
|
||||
},
|
||||
current,
|
||||
status,
|
||||
select(sessionID: string) {
|
||||
@@ -219,8 +238,10 @@ 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
|
||||
}
|
||||
remove(target, true)
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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()}
|
||||
|
||||
@@ -16,9 +16,7 @@ export function ShellTab(props: { sessionID: string }) {
|
||||
const composer = useComposerTab()
|
||||
const shortcuts = Keymap.useShortcuts()
|
||||
|
||||
const entries = createMemo(() =>
|
||||
data.shell.list().filter((shell) => shell.metadata.sessionID === props.sessionID && shell.status === "running"),
|
||||
)
|
||||
const entries = createMemo(() => data.shell.listBySession(props.sessionID).filter((shell) => shell.status === "running"))
|
||||
|
||||
const [store, setStore] = createStore({ selected: 0 })
|
||||
let scroll: ScrollBoxRenderable | undefined
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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"
|
||||
@@ -3028,9 +3027,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 +3116,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()}
|
||||
|
||||
@@ -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()}
|
||||
|
||||
@@ -1,115 +0,0 @@
|
||||
import { createMemo, createSignal, Show } from "solid-js"
|
||||
import { useRouteData } from "../../context/route"
|
||||
import { useData } from "../../context/data"
|
||||
import { useTheme } from "../../context/theme"
|
||||
import { SplitBorder } from "../../ui/border"
|
||||
import { Locale } from "../../util/locale"
|
||||
import { useTerminalDimensions } from "@opentui/solid"
|
||||
import { Keymap } from "../../context/keymap"
|
||||
import { contextUsage, formatContextUsage } from "../../util/session"
|
||||
|
||||
const money = new Intl.NumberFormat("en-US", {
|
||||
style: "currency",
|
||||
currency: "USD",
|
||||
})
|
||||
|
||||
export function SubagentFooter() {
|
||||
const route = useRouteData("session")
|
||||
const data = useData()
|
||||
const session = createMemo(() => data.session.get(route.sessionID))
|
||||
|
||||
const subagentInfo = createMemo(() => {
|
||||
const s = session()
|
||||
if (!s) return "Subagent"
|
||||
const agentMatch = s.title.match(/@(\w+) subagent/)
|
||||
return agentMatch ? Locale.titlecase(agentMatch[1]) : "Subagent"
|
||||
})
|
||||
|
||||
const usage = createMemo(() => {
|
||||
const current = session()
|
||||
if (!current) return
|
||||
const cost = current.cost
|
||||
const formattedCost = cost > 0 ? money.format(cost) : undefined
|
||||
const context = contextUsage(
|
||||
data.session.message.list(route.sessionID),
|
||||
data.location.model.list(current.location),
|
||||
current.revert?.messageID,
|
||||
)
|
||||
|
||||
return {
|
||||
context: context ? formatContextUsage(context.tokens, context.percent) : undefined,
|
||||
cost: formattedCost,
|
||||
}
|
||||
})
|
||||
|
||||
const theme = useTheme("elevated")
|
||||
const keymap = Keymap.use()
|
||||
const shortcuts = Keymap.useShortcuts()
|
||||
const [hover, setHover] = createSignal<"parent" | "prev" | "next" | null>(null)
|
||||
useTerminalDimensions()
|
||||
|
||||
return (
|
||||
<box flexShrink={0}>
|
||||
<box
|
||||
paddingTop={1}
|
||||
paddingBottom={1}
|
||||
paddingLeft={2}
|
||||
paddingRight={1}
|
||||
{...SplitBorder}
|
||||
border={["left"]}
|
||||
borderColor={theme.border.default}
|
||||
flexShrink={0}
|
||||
backgroundColor={theme.background.default}
|
||||
>
|
||||
<box flexDirection="row" justifyContent="space-between" gap={1}>
|
||||
<box flexDirection="row" gap={1}>
|
||||
<text fg={theme.text.default}>
|
||||
<b>{subagentInfo()}</b>
|
||||
</text>
|
||||
<Show when={usage()}>
|
||||
{(item) => (
|
||||
<text fg={theme.text.subdued} wrapMode="none">
|
||||
{[item().context, item().cost].filter(Boolean).join(" · ")}
|
||||
</text>
|
||||
)}
|
||||
</Show>
|
||||
</box>
|
||||
<box flexDirection="row" gap={2}>
|
||||
<box
|
||||
onMouseOver={() => setHover("parent")}
|
||||
onMouseOut={() => setHover(null)}
|
||||
onMouseUp={() => keymap.dispatch("session.parent")}
|
||||
backgroundColor={
|
||||
hover() === "parent" ? theme.background.action.primary.hovered : theme.background.default
|
||||
}
|
||||
>
|
||||
<text fg={theme.text.default}>
|
||||
Parent <span style={{ fg: theme.text.subdued }}>{shortcuts.get("session.parent")}</span>
|
||||
</text>
|
||||
</box>
|
||||
<box
|
||||
onMouseOver={() => setHover("prev")}
|
||||
onMouseOut={() => setHover(null)}
|
||||
onMouseUp={() => keymap.dispatch("session.child.previous")}
|
||||
backgroundColor={hover() === "prev" ? theme.background.action.primary.hovered : theme.background.default}
|
||||
>
|
||||
<text fg={theme.text.default}>
|
||||
Prev <span style={{ fg: theme.text.subdued }}>{shortcuts.get("session.child.previous")}</span>
|
||||
</text>
|
||||
</box>
|
||||
<box
|
||||
onMouseOver={() => setHover("next")}
|
||||
onMouseOut={() => setHover(null)}
|
||||
onMouseUp={() => keymap.dispatch("session.child.next")}
|
||||
backgroundColor={hover() === "next" ? theme.background.action.primary.hovered : theme.background.default}
|
||||
>
|
||||
<text fg={theme.text.default}>
|
||||
Next <span style={{ fg: theme.text.subdued }}>{shortcuts.get("session.child.next")}</span>
|
||||
</text>
|
||||
</box>
|
||||
</box>
|
||||
</box>
|
||||
</box>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
@@ -62,9 +62,14 @@ export function truncateFilePath(value: string, maxWidth: number) {
|
||||
const separatorWidth = stringWidth(separator)
|
||||
let width = stringWidth(prefix + basename)
|
||||
for (let index = segments.length - 2; index >= 0; index--) {
|
||||
const next = stringWidth(segments[index]!) + separatorWidth
|
||||
if (width + next > maxWidth) break
|
||||
selected.unshift(segments[index]!)
|
||||
const segment = segments[index]!
|
||||
const next = stringWidth(segment) + separatorWidth
|
||||
if (width + next > maxWidth) {
|
||||
const available = maxWidth - width - separatorWidth
|
||||
if (available > 1) selected.unshift(takeStart(segment, available - 1) + "…")
|
||||
break
|
||||
}
|
||||
selected.unshift(segment)
|
||||
width += next
|
||||
}
|
||||
return prefix + selected.join(separator)
|
||||
|
||||
@@ -1,56 +0,0 @@
|
||||
export interface PatchHunk {
|
||||
readonly patch: string
|
||||
readonly header?: string
|
||||
readonly rows?: number
|
||||
}
|
||||
|
||||
export function splitPatchHunks(patch: string): PatchHunk[] {
|
||||
const starts = [
|
||||
...patch.matchAll(/^@@ -\d+(?:,\d+)? \+\d+(?:,\d+)? @@.*$/gm),
|
||||
].map((match) => match.index)
|
||||
if (starts.length <= 1) return [{ patch }]
|
||||
|
||||
const prefix = patch.slice(0, starts[0])
|
||||
return starts.map((start, index) => {
|
||||
const end = starts[index + 1] ?? patch.length
|
||||
const lineEnd = patch.indexOf("\n", start)
|
||||
return {
|
||||
header: patch.slice(start, lineEnd === -1 ? end : lineEnd),
|
||||
patch: prefix + patch.slice(start, end),
|
||||
rows: splitRows(patch.slice(start, end)),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function splitRows(hunk: string) {
|
||||
const lines = hunk.replace(/\n$/, "").split("\n").slice(1)
|
||||
let rows = 0
|
||||
let index = 0
|
||||
|
||||
while (index < lines.length) {
|
||||
const prefix = lines[index][0]
|
||||
if (prefix === " " || !prefix) {
|
||||
rows++
|
||||
index++
|
||||
continue
|
||||
}
|
||||
if (prefix === "\\") {
|
||||
index++
|
||||
continue
|
||||
}
|
||||
|
||||
let additions = 0
|
||||
let deletions = 0
|
||||
while (
|
||||
index < lines.length &&
|
||||
(lines[index][0] === "+" || lines[index][0] === "-")
|
||||
) {
|
||||
if (lines[index][0] === "+") additions++
|
||||
if (lines[index][0] === "-") deletions++
|
||||
index++
|
||||
}
|
||||
rows += Math.max(additions, deletions)
|
||||
}
|
||||
|
||||
return rows
|
||||
}
|
||||
+48
@@ -1,6 +1,10 @@
|
||||
/** @jsxImportSource @opentui/solid */
|
||||
import { expect, test } from "bun:test"
|
||||
import { RGBA } from "@opentui/core"
|
||||
import { testRender } from "@opentui/solid"
|
||||
import { createSignal } from "solid-js"
|
||||
import {
|
||||
TabPulse,
|
||||
blendTabPulseColor,
|
||||
completionPulseOpacity,
|
||||
glowIgnitionLevel,
|
||||
@@ -8,6 +12,50 @@ import {
|
||||
} from "../../src/component/tab-pulse"
|
||||
import { tint } from "../../src/theme/color"
|
||||
|
||||
test("a prompt pulse restarts the neutral edge flash while the tab remains busy", async () => {
|
||||
const background = RGBA.fromHex("#101010")
|
||||
const flash = RGBA.fromHex("#f0f0f0")
|
||||
const [promptPulse, setPromptPulse] = createSignal(0)
|
||||
const app = await testRender(
|
||||
() => (
|
||||
<box width={8} height={1} backgroundColor={background}>
|
||||
<TabPulse
|
||||
active={true}
|
||||
promptPulse={promptPulse()}
|
||||
color={background}
|
||||
flashColor={flash}
|
||||
backgroundColor={background}
|
||||
/>
|
||||
</box>
|
||||
),
|
||||
{ width: 8, height: 1 },
|
||||
)
|
||||
|
||||
const firstBackground = () => app.captureSpans().lines[0]?.spans[0]?.bg
|
||||
|
||||
try {
|
||||
await app.renderOnce()
|
||||
expect(firstBackground()?.equals(background)).toBeTrue()
|
||||
|
||||
setPromptPulse(1)
|
||||
await Bun.sleep(80)
|
||||
await app.renderOnce()
|
||||
expect(firstBackground()?.equals(background)).toBeFalse()
|
||||
expect(firstBackground()?.r ?? 0).toBeGreaterThan(0.17)
|
||||
|
||||
await Bun.sleep(800)
|
||||
await app.renderOnce()
|
||||
expect(firstBackground()?.equals(background)).toBeTrue()
|
||||
|
||||
setPromptPulse(2)
|
||||
await Bun.sleep(80)
|
||||
await app.renderOnce()
|
||||
expect(firstBackground()?.equals(background)).toBeFalse()
|
||||
} finally {
|
||||
app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test("completion pulse rises quickly and fades over the remaining duration", () => {
|
||||
expect(completionPulseOpacity(0)).toBe(0)
|
||||
expect(completionPulseOpacity(0.06)).toBeCloseTo(0.5)
|
||||
@@ -151,12 +151,12 @@ describe("session tabs", () => {
|
||||
expect(layout.widths.reduce((total, width) => total + width, 0)).toBe(76)
|
||||
})
|
||||
|
||||
test("does not reserve an active tab slot on the new session page", () => {
|
||||
const tabs = ["a", "b", "c", "d", "e"].map((sessionID) => ({ sessionID }))
|
||||
const layout = adaptiveSessionTabLayout(tabs, "dummy", 40)
|
||||
test("reserves an active tab slot for the new session page", () => {
|
||||
const tabs = ["a", "b", "c", "d", "new"].map((sessionID) => ({ sessionID }))
|
||||
const layout = adaptiveSessionTabLayout(tabs, "new", 54)
|
||||
|
||||
expect(layout.tabs).toEqual(tabs)
|
||||
expect(layout.widths).toEqual([8, 8, 8, 8, 8])
|
||||
expect(layout.widths).toEqual([8, 8, 8, 8, 22])
|
||||
expect(layout.widths.reduce((total, width) => total + width, 0)).toBe(layout.total)
|
||||
})
|
||||
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
/** @jsxImportSource @opentui/solid */
|
||||
import { expect, test } from "bun:test"
|
||||
import type { OpenCodeEvent } from "@opencode-ai/client"
|
||||
import { testRender } from "@opentui/solid"
|
||||
import { mkdtempSync, rmSync } from "fs"
|
||||
import { tmpdir } from "os"
|
||||
import path from "path"
|
||||
import { ConfigProvider } from "../../src/config"
|
||||
import { ClientProvider, useClient } from "../../src/context/client"
|
||||
import { DataProvider } from "../../src/context/data"
|
||||
import { RouteProvider, useRoute } from "../../src/context/route"
|
||||
import { TuiAppProvider } from "../../src/context/runtime"
|
||||
import { SessionTabsProvider, useSessionTabs } from "../../src/context/session-tabs"
|
||||
import { StorageProvider } from "../../src/context/storage"
|
||||
import { createApi, createEventStream, createFetch, directory } from "../fixture/tui-client"
|
||||
import { TestTuiContexts } from "../fixture/tui-environment"
|
||||
import { createTuiResolvedConfig } from "../fixture/tui-runtime"
|
||||
|
||||
async function wait(fn: () => boolean, timeout = 2_000) {
|
||||
const start = Date.now()
|
||||
while (!fn()) {
|
||||
if (Date.now() - start > timeout) throw new Error("timed out waiting for condition")
|
||||
await Bun.sleep(10)
|
||||
}
|
||||
}
|
||||
|
||||
async function renderSessionTabs(initialSessionID: string) {
|
||||
const state = mkdtempSync(path.join(tmpdir(), "opencode-session-tabs-"))
|
||||
const events = createEventStream()
|
||||
const calls = createFetch(undefined, events)
|
||||
let tabs!: ReturnType<typeof useSessionTabs>
|
||||
let route!: ReturnType<typeof useRoute>
|
||||
let client!: ReturnType<typeof useClient>
|
||||
|
||||
function Probe() {
|
||||
tabs = useSessionTabs()
|
||||
route = useRoute()
|
||||
client = useClient()
|
||||
return <box />
|
||||
}
|
||||
|
||||
const app = await testRender(() => (
|
||||
<TestTuiContexts paths={{ state }}>
|
||||
<TuiAppProvider value={{ name: "test", version: "test", channel: "test" }}>
|
||||
<StorageProvider>
|
||||
<ConfigProvider config={createTuiResolvedConfig({ tabs: { enabled: true } })}>
|
||||
<RouteProvider initialRoute={{ type: "session", sessionID: initialSessionID }}>
|
||||
<ClientProvider api={createApi(calls.fetch)}>
|
||||
<DataProvider>
|
||||
<SessionTabsProvider>
|
||||
<Probe />
|
||||
</SessionTabsProvider>
|
||||
</DataProvider>
|
||||
</ClientProvider>
|
||||
</RouteProvider>
|
||||
</ConfigProvider>
|
||||
</StorageProvider>
|
||||
</TuiAppProvider>
|
||||
</TestTuiContexts>
|
||||
))
|
||||
|
||||
await wait(() => client.connection.status() === "connected")
|
||||
return {
|
||||
tabs,
|
||||
route,
|
||||
emit: (event: OpenCodeEvent) => events.emit({ ...event, location: { directory } }),
|
||||
destroy() {
|
||||
app.renderer.destroy()
|
||||
rmSync(state, { recursive: true, force: true })
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
test("user prompt admissions pulse an already-busy background tab", async () => {
|
||||
const setup = await renderSessionTabs("background")
|
||||
const admitted = (sessionID: string, inputID: string): OpenCodeEvent => ({
|
||||
id: `evt_${inputID}`,
|
||||
created: Date.now(),
|
||||
type: "session.input.admitted",
|
||||
durable: { aggregateID: sessionID, seq: Number(inputID.replace(/\D/g, "")), version: 1 },
|
||||
data: {
|
||||
sessionID,
|
||||
inputID,
|
||||
input: { type: "user", data: { text: inputID }, delivery: "steer" },
|
||||
},
|
||||
})
|
||||
|
||||
try {
|
||||
await wait(() => setup.tabs.tabs().some((tab) => tab.sessionID === "background"))
|
||||
setup.route.navigate({ type: "session", sessionID: "active" })
|
||||
await wait(() => setup.tabs.current() === "active" && setup.tabs.tabs().length === 2)
|
||||
|
||||
setup.emit({
|
||||
id: "evt_context",
|
||||
created: Date.now(),
|
||||
type: "session.input.admitted",
|
||||
durable: { aggregateID: "background", seq: 0, version: 1 },
|
||||
data: {
|
||||
sessionID: "background",
|
||||
inputID: "msg_context",
|
||||
input: { type: "synthetic", data: { text: "editor context" }, delivery: "steer" },
|
||||
},
|
||||
})
|
||||
await Bun.sleep(20)
|
||||
expect(setup.tabs.status("background").promptPulse).toBe(0)
|
||||
|
||||
setup.emit(admitted("background", "msg_1"))
|
||||
await wait(
|
||||
() => setup.tabs.status("background").promptPulse === 1 && setup.tabs.status("background").busy,
|
||||
)
|
||||
|
||||
setup.emit(admitted("background", "msg_2"))
|
||||
await wait(() => setup.tabs.status("background").promptPulse === 2)
|
||||
|
||||
setup.emit(admitted("active", "msg_3"))
|
||||
await Bun.sleep(20)
|
||||
expect(setup.tabs.status("active").promptPulse).toBe(0)
|
||||
expect(setup.tabs.status("background")).toMatchObject({ promptPulse: 2, busy: true })
|
||||
} finally {
|
||||
setup.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test("tracks a temporary new session tab across close and creation", async () => {
|
||||
const setup = await renderSessionTabs("first")
|
||||
|
||||
try {
|
||||
await wait(() => setup.tabs.current() === "first")
|
||||
setup.route.navigate({ type: "session", sessionID: "second" })
|
||||
await wait(() => setup.tabs.current() === "second" && setup.tabs.tabs().length === 2)
|
||||
setup.route.navigate({ type: "session", sessionID: "first" })
|
||||
await wait(() => setup.tabs.current() === "first")
|
||||
|
||||
setup.route.navigate({ type: "home" })
|
||||
await wait(() => setup.tabs.newTab() && setup.tabs.current() === undefined)
|
||||
expect(setup.tabs.tabs().map((tab) => tab.sessionID)).toEqual(["first", "second"])
|
||||
setup.tabs.close()
|
||||
await wait(() => setup.route.data.type === "session")
|
||||
|
||||
expect(setup.route.data).toEqual({ type: "session", sessionID: "first" })
|
||||
|
||||
setup.route.navigate({ type: "home" })
|
||||
await wait(() => setup.tabs.newTab())
|
||||
setup.route.navigate({ type: "session", sessionID: "third" })
|
||||
await wait(
|
||||
() => setup.tabs.current() === "third" && setup.tabs.tabs().some((tab) => tab.sessionID === "third"),
|
||||
)
|
||||
|
||||
expect(setup.tabs.newTab()).toBe(false)
|
||||
} finally {
|
||||
setup.destroy()
|
||||
}
|
||||
})
|
||||
@@ -14,6 +14,11 @@ describe("truncateFilePath", () => {
|
||||
expect(truncateFilePath(path, 19)).toBe("…/dialog-select.tsx")
|
||||
})
|
||||
|
||||
test("uses remaining width for part of a long parent segment", () => {
|
||||
const path = "/private/var/folders/run-17f048ec-dbb2-4b36-860c-98637bb51a8d/files"
|
||||
expect(truncateFilePath(path, 40)).toBe("/…/run-17f048ec-dbb2-4b36-860c-98…/files")
|
||||
})
|
||||
|
||||
test("preserves the extension when the basename must shrink", () => {
|
||||
expect(truncateFilePath(path, 16)).toBe("…/dialog-se….tsx")
|
||||
expect(truncateFilePath("dialog-select.tsx", 12)).toBe("dialog-….tsx")
|
||||
|
||||
@@ -28,6 +28,13 @@ export namespace FSUtil {
|
||||
readonly type: "file" | "directory" | "symlink" | "other"
|
||||
}
|
||||
|
||||
export interface UpOptions {
|
||||
readonly targets: string[]
|
||||
readonly start: string
|
||||
readonly stop?: string
|
||||
readonly mode?: "all" | "first"
|
||||
}
|
||||
|
||||
export interface Interface extends FileSystem.FileSystem {
|
||||
readonly isDir: (path: string) => Effect.Effect<boolean>
|
||||
readonly isFile: (path: string) => Effect.Effect<boolean>
|
||||
@@ -40,7 +47,7 @@ export namespace FSUtil {
|
||||
readonly readDirectoryEntries: (path: string) => Effect.Effect<DirEntry[], Error>
|
||||
readonly resolve: (path: string) => Effect.Effect<string>
|
||||
readonly findUp: (target: string, start: string, stop?: string) => Effect.Effect<string[], Error>
|
||||
readonly up: (options: { targets: string[]; start: string; stop?: string }) => Effect.Effect<string[], Error>
|
||||
readonly up: (options: UpOptions) => Effect.Effect<string[], Error>
|
||||
readonly globUp: (pattern: string, start: string, stop?: string) => Effect.Effect<string[], Error>
|
||||
readonly scan: (pattern: string, options?: Glob.Options) => Effect.Effect<string[], Error>
|
||||
readonly globMatch: (pattern: string, filepath: string) => boolean
|
||||
@@ -153,27 +160,16 @@ export namespace FSUtil {
|
||||
})
|
||||
})
|
||||
|
||||
const findUp = Effect.fn("FileSystem.findUp")(function* (target: string, start: string, stop?: string) {
|
||||
const result: string[] = []
|
||||
let current = start
|
||||
while (true) {
|
||||
const search = join(current, target)
|
||||
if (yield* fs.exists(search)) result.push(search)
|
||||
if (stop === current) break
|
||||
const parent = dirname(current)
|
||||
if (parent === current) break
|
||||
current = parent
|
||||
}
|
||||
return result
|
||||
})
|
||||
|
||||
const up = Effect.fn("FileSystem.up")(function* (options: { targets: string[]; start: string; stop?: string }) {
|
||||
const up = Effect.fn("FileSystem.up")(function* (options: UpOptions) {
|
||||
const result: string[] = []
|
||||
let current = options.start
|
||||
while (true) {
|
||||
for (const target of options.targets) {
|
||||
const search = join(current, target)
|
||||
if (yield* fs.exists(search)) result.push(search)
|
||||
if (yield* fs.exists(search)) {
|
||||
result.push(search)
|
||||
if (options.mode === "first") return result
|
||||
}
|
||||
}
|
||||
if (options.stop === current) break
|
||||
const parent = dirname(current)
|
||||
@@ -183,6 +179,10 @@ export namespace FSUtil {
|
||||
return result
|
||||
})
|
||||
|
||||
const findUp = Effect.fn("FileSystem.findUp")((target: string, start: string, stop?: string) =>
|
||||
up({ targets: [target], start, stop }),
|
||||
)
|
||||
|
||||
const globUp = Effect.fn("FileSystem.globUp")(function* (pattern: string, start: string, stop?: string) {
|
||||
const result: string[] = []
|
||||
let current = start
|
||||
|
||||
@@ -141,6 +141,15 @@ diff --git a/dist/cjs/client/streamableHttp.js b/dist/cjs/client/streamableHttp.
|
||||
index a29a7d3a0f14d9cd800ef5b296485237350c666f..c362ae5fe6c62c8c8eae7e2e61de1eedff5443c9 100644
|
||||
--- a/dist/cjs/client/streamableHttp.js
|
||||
+++ b/dist/cjs/client/streamableHttp.js
|
||||
@@ -204,7 +204,7 @@ class StreamableHTTPClientTransport {
|
||||
if (!event.event || event.event === 'message') {
|
||||
try {
|
||||
const message = types_js_1.JSONRPCMessageSchema.parse(JSON.parse(event.data));
|
||||
- if ((0, types_js_1.isJSONRPCResultResponse)(message)) {
|
||||
+ if ((0, types_js_1.isJSONRPCResultResponse)(message) || (0, types_js_1.isJSONRPCErrorResponse)(message)) {
|
||||
// Mark that we received a response - no need to reconnect for this request
|
||||
receivedResponse = true;
|
||||
if (replayMessageId !== undefined) {
|
||||
@@ -290,7 +290,38 @@ class StreamableHTTPClientTransport {
|
||||
this.onclose?.();
|
||||
}
|
||||
@@ -518,10 +527,19 @@ index 624172aa24ae255a67c083f9c19053343e4a0581..ac75b14545fda44aff7ff4d97cc5da88
|
||||
@@ -1,5 +1,5 @@
|
||||
import { createFetchWithInit, normalizeHeaders } from '../shared/transport.js';
|
||||
-import { isInitializedNotification, isJSONRPCRequest, isJSONRPCResultResponse, JSONRPCMessageSchema } from '../types.js';
|
||||
+import { isInitializedNotification, isInitializeRequest, isJSONRPCRequest, isJSONRPCResultResponse, JSONRPCMessageSchema } from '../types.js';
|
||||
+import { isInitializedNotification, isInitializeRequest, isJSONRPCErrorResponse, isJSONRPCRequest, isJSONRPCResultResponse, JSONRPCMessageSchema } from '../types.js';
|
||||
import { auth, extractWWWAuthenticateParams, UnauthorizedError } from './auth.js';
|
||||
import { EventSourceParserStream } from 'eventsource-parser/stream';
|
||||
// Default reconnection options for StreamableHTTP connections
|
||||
@@ -200,7 +200,7 @@ export class StreamableHTTPClientTransport {
|
||||
if (!event.event || event.event === 'message') {
|
||||
try {
|
||||
const message = JSONRPCMessageSchema.parse(JSON.parse(event.data));
|
||||
- if (isJSONRPCResultResponse(message)) {
|
||||
+ if (isJSONRPCResultResponse(message) || isJSONRPCErrorResponse(message)) {
|
||||
// Mark that we received a response - no need to reconnect for this request
|
||||
receivedResponse = true;
|
||||
if (replayMessageId !== undefined) {
|
||||
@@ -286,7 +286,38 @@ export class StreamableHTTPClientTransport {
|
||||
this.onclose?.();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user