Compare commits

..

6 Commits

Author SHA1 Message Date
James Long 193b9d5b8a test(tui): defer diff hunk coverage 2026-07-30 16:52:59 +00:00
James Long cfd8fa4961 fix(tui): align hunk header inset 2026-07-30 16:42:44 +00:00
James Long 71a86995c6 fix(tui): preserve line number alignment 2026-07-30 16:38:17 +00:00
James Long 0d4ab8e6be fix(tui): synchronize diff gutters 2026-07-30 16:28:45 +00:00
James Long ffa9a597dc fix(tui): fill hunk header background 2026-07-30 16:09:44 +00:00
James Long 71b2fa2920 fix(tui): preserve diff hunk boundaries 2026-07-30 16:02:45 +00:00
63 changed files with 1589 additions and 1537 deletions
-1
View File
@@ -1,7 +1,6 @@
import type { Model, ProviderOptions } from "./schema"
export interface Settings extends Readonly<Record<string, unknown>> {
readonly baseURL?: string
readonly headers?: Readonly<Record<string, string>>
readonly body?: Readonly<Record<string, unknown>>
readonly limits?: {
+13 -36
View File
@@ -1,62 +1,38 @@
import { AuthOptions, type ProviderAuthOption } from "../route/auth-options"
import { Route, type RouteDefaultsInput } from "../route/client"
import { Endpoint } from "../route/endpoint"
import { HttpOptions, ProviderID, type ModelID, type ProviderOptions } from "../schema"
import type { RouteDefaultsInput } from "../route/client"
import { HttpOptions, ProviderID, type ModelID } 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 { OpenAIOptionsInput } from "./openai-options"
import type { OpenAIProviderOptionsInput } 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?: XAIProviderOptionsInput
readonly providerOptions?: OpenAIProviderOptionsInput
}
export interface Settings extends ProviderPackage.Settings {
readonly apiKey?: string
readonly baseURL?: string
readonly providerOptions?: XAIProviderOptionsInput
readonly providerOptions?: OpenAIProviderOptionsInput
}
export type { XAIImageOptions } from "../protocols/xai-images"
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]
export const routes = [OpenAIResponses.route, OpenAICompatibleChat.route]
const auth = (options: ProviderAuthOption<"optional">) => AuthOptions.bearer(options, "XAI_API_KEY")
const configuredResponsesRoute = (input: ModelOptions) => {
const { apiKey: _, auth: _auth, baseURL, ...rest } = input
return responsesRoute.with({
return OpenAIResponses.route.with({
...rest,
provider: id,
endpoint: { baseURL: baseURL ?? OpenAICompatibleProfiles.profiles.xai.baseURL },
auth: auth(input),
})
@@ -64,8 +40,9 @@ const configuredResponsesRoute = (input: ModelOptions) => {
const configuredChatRoute = (input: ModelOptions) => {
const { apiKey: _, auth: _auth, baseURL, ...rest } = input
return chatRoute.with({
return OpenAICompatibleChat.route.with({
...rest,
provider: id,
endpoint: { baseURL: baseURL ?? OpenAICompatibleProfiles.profiles.xai.baseURL },
auth: auth(input),
})
@@ -74,8 +51,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<XAIProviderOptionsInput>({ id: modelID })
const chat = (modelID: string | ModelID) => chatRoute.model<XAIProviderOptionsInput>({ id: modelID })
const responses = (modelID: string | ModelID) => responsesRoute.model<OpenAIProviderOptionsInput>({ id: modelID })
const chat = (modelID: string | ModelID) => chatRoute.model<OpenAIProviderOptionsInput>({ id: modelID })
const image = (modelID: string | ModelID) =>
XAIImages.model({
id: modelID,
@@ -95,7 +72,7 @@ export const configure = (input: ModelOptions = {}) => {
}
export const provider = configure()
export const model: ProviderPackage.Definition<Settings, XAIProviderOptionsInput>["model"] = (modelID, settings) =>
export const model: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["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: { xai: { reasoningEffort: "high" } } })
LLM.request({ model, prompt: "Hello", providerOptions: { openai: { reasoningEffort: "high" } } })
LLM.request({
model,
prompt: "Hello",
// @ts-expect-error xAI's OpenAI-compatible reasoning effort must be a string.
providerOptions: { xai: { reasoningEffort: true } },
providerOptions: { openai: { reasoningEffort: true } },
})
+2 -2
View File
@@ -47,7 +47,7 @@ describe("provider package entrypoints", () => {
})
const xai = XAI.model("grok-4", {
...settings,
providerOptions: { xai: { reasoningEffort: "high" } },
providerOptions: { openai: { 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).toMatchObject({ xai: { reasoningEffort: "high", store: false } })
expect(xai.route.defaults.providerOptions).toEqual({ openai: { reasoningEffort: "high", store: false } })
})
test("maps package settings onto the executable model", () => {
-59
View File
@@ -1,59 +0,0 @@
export * as AISDKNative from "./aisdk-native"
export interface Mapping {
readonly package: string
readonly settings: Readonly<Record<string, unknown>>
}
export function map(packageName: string | undefined, settings: Readonly<Record<string, unknown>>): Mapping | undefined {
const baseSettings = mapBaseSettings(settings)
switch (packageName) {
case "@ai-sdk/google":
return {
package: "@opencode-ai/ai/providers/google",
settings: {
...baseSettings,
...mapAPIKey(settings),
...mapProviderOptions("gemini", settings),
},
}
case "@openrouter/ai-sdk-provider":
return {
package: "@opencode-ai/ai/providers/openrouter",
settings: {
...baseSettings,
...mapAPIKey(settings),
...mapProviderOptions("openrouter", settings),
},
}
case "@ai-sdk/xai":
return {
package: "@opencode-ai/ai/providers/xai",
settings: {
...baseSettings,
...mapAPIKey(settings),
...mapProviderOptions("xai", settings),
},
}
}
}
function mapBaseSettings(settings: Readonly<Record<string, unknown>>) {
return {
...(typeof settings.baseURL === "string" ? { baseURL: settings.baseURL } : {}),
}
}
function mapAPIKey(settings: Readonly<Record<string, unknown>>) {
return typeof settings.apiKey === "string" ? { apiKey: settings.apiKey } : {}
}
function mapProviderOptions(namespace: string, settings: Readonly<Record<string, unknown>>) {
const values = Object.fromEntries(
Object.entries(settings).filter(
([key]) => !["apiKey", "authToken", "baseURL", "chunkTimeout", "fetch", "timeout"].includes(key),
),
)
if (Object.keys(values).length === 0) return {}
return { providerOptions: { [namespace]: values } }
}
+1 -1
View File
@@ -201,7 +201,7 @@ const layer = Layer.effect(
locks.withLock(repository.gitDirectory)(effect)
const discover = Effect.fn("Git.repo.discover")(function* (input: AbsolutePath) {
const dotgit = yield* fs.up({ targets: [".git"], start: input, mode: "first" }).pipe(
const dotgit = yield* fs.up({ targets: [".git"], start: input }).pipe(
Effect.map((matches) => matches[0]),
Effect.catch(() => Effect.succeed(undefined)),
)
+5 -9
View File
@@ -12,7 +12,6 @@ import { Auth, type AnyRoute } from "@opencode-ai/ai/route"
import { Context, Effect, Layer, Schema } from "effect"
import { produce } from "immer"
import { AISDK } from "./aisdk"
import { AISDKNative } from "./aisdk-native"
import { Catalog } from "./catalog"
import { Credential } from "./credential"
import { Integration } from "./integration"
@@ -183,10 +182,7 @@ export const fromCatalogModel = (
.model({ id: resolved.modelID ?? resolved.id, compatibility: resolved.compatibility }),
)
}
const configured = { ...resolved.settings, ...credential?.metadata }
const mapping = Provider.isAISDK(resolved.package) ? AISDKNative.map(packageName, configured) : undefined
const native = mapping?.package ?? resolved.package
if (Provider.isAISDK(resolved.package) && !mapping) {
if (Provider.isAISDK(resolved.package)) {
if (!dependencies?.loadAISDK) return Effect.fail(unsupported(resolved))
const runtime = produce(resolved, (draft) => {
draft.settings = Provider.mergeOverlay(draft.settings, {
@@ -197,16 +193,16 @@ export const fromCatalogModel = (
})
return dependencies.loadAISDK(runtime).pipe(Effect.mapError(() => unsupported(resolved)))
}
if (!native) return Effect.fail(unsupported(resolved))
if (!resolved.package) return Effect.fail(unsupported(resolved))
const specifier = native
const specifier = resolved.package
return Effect.gen(function* () {
const module = yield* (dependencies?.loadPackage ?? Provider.loadPackage)(specifier).pipe(
Effect.mapError(() => unsupported(resolved)),
)
const mapped = mapping?.settings ?? configured
const configured = { ...resolved.settings, ...credential?.metadata }
const settings = {
...(credential ? withoutNativeAuthSettings(mapped) : mapped),
...(credential ? withoutNativeAuthSettings(configured) : configured),
...nativeCredentialSettings(specifier, credential),
headers: resolved.headers,
body: resolved.body,
+2
View File
@@ -11,6 +11,8 @@ 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.
+8 -9
View File
@@ -49,7 +49,7 @@ export const root = Effect.fn("Project.root")(function* (
fs: FSUtil.Interface,
input: AbsolutePath,
) {
return yield* fs.up({ targets: [".git", ".hg"], start: input, mode: "first" }).pipe(
return yield* fs.up({ targets: [".git", ".hg"], start: input }).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, mode: "first" }).pipe(
const dotHg = yield* fs.up({ targets: [".hg"], start: input }).pipe(
Effect.map((matches) => matches[0]),
Effect.catch(() => Effect.succeed(undefined)),
)
@@ -246,13 +246,12 @@ const layer = Layer.effect(
if (repo) {
const previous = yield* cached(repo.commonDirectory)
const id = (yield* remote(repo)) ?? previous ?? (yield* root(repo))
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)),
)
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)),
)
return yield* persist({
previous,
id: id ?? ID.global,
-6
View File
@@ -657,12 +657,6 @@ 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, Layer } from "effect"
import { Effect, FileSystem } 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,33 +267,6 @@ describe("FSUtil", () => {
expect(result).toContain(path.join(tmp, "b.txt"))
}),
)
it(
"stops at the first match when requested",
Effect.gen(function* () {
const filesys = yield* FileSystem.FileSystem
const tmp = yield* filesys.makeTempDirectoryScoped()
yield* filesys.writeFileString(path.join(tmp, "marker"), "root")
const child = path.join(tmp, "sub")
yield* filesys.makeDirectory(child)
yield* filesys.writeFileString(path.join(child, "marker"), "child")
const checked: string[] = []
const instrumented = FileSystem.FileSystem.of({
...filesys,
exists: (target) => Effect.sync(() => checked.push(target)).pipe(Effect.andThen(filesys.exists(target))),
})
const search = yield* FSUtil.Service.pipe(
Effect.provide(
FSUtil.layer.pipe(Layer.fresh, Layer.provide(Layer.succeed(FileSystem.FileSystem, instrumented))),
),
)
expect(yield* search.up({ targets: ["marker", "other"], start: child, mode: "first" })).toEqual([
path.join(child, "marker"),
])
expect(checked).toEqual([path.join(child, "marker")])
}),
)
})
describe("glob", () => {
+1 -1
View File
@@ -15,7 +15,7 @@ import { testEffect } from "./lib/effect"
const selected = Info.make({
...Info.default(Provider.ID.make("test-provider"), ID.make("gemini")),
package: Provider.aisdk("@ai-sdk/mistral"),
package: Provider.aisdk("@ai-sdk/google"),
})
const runtime = Model.make({ id: "gemini", provider: "test-provider", route: OpenAIChat.route })
-37
View File
@@ -1,7 +1,6 @@
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"
@@ -560,42 +559,6 @@ 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(
+11 -81
View File
@@ -546,76 +546,6 @@ 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(
@@ -624,8 +554,8 @@ describe("ModelResolver", () => {
}),
)
const resolved = yield* ModelResolver.fromCatalogModel(
model(Provider.aisdk("@ai-sdk/mistral"), {
modelID: "mistral-api-model",
model(Provider.aisdk("@ai-sdk/google"), {
modelID: "gemini-api-model",
settings: { project: "test" },
headers: { "x-aisdk": "header" },
body: { custom: true },
@@ -636,9 +566,9 @@ describe("ModelResolver", () => {
Effect.sync(() => {
expect(runtime).toMatchObject({
id: "test-model",
modelID: "mistral-api-model",
modelID: "gemini-api-model",
providerID: "test-provider",
package: Provider.aisdk("@ai-sdk/mistral"),
package: Provider.aisdk("@ai-sdk/google"),
settings: { project: "test", apiKey: "fallback-secret" },
headers: { "x-aisdk": "header" },
body: { custom: true },
@@ -652,15 +582,15 @@ describe("ModelResolver", () => {
},
)
expect(resolved).toMatchObject({ id: "mistral-api-model", provider: "test-provider" })
expect(resolved).toMatchObject({ id: "gemini-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/mistral"), {
settings: { baseURL: "https://mistral.example/v1" },
model(Provider.aisdk("@ai-sdk/google"), {
settings: { baseURL: "https://google.example/v1" },
}),
).pipe(Effect.flip)
@@ -668,9 +598,9 @@ describe("ModelResolver", () => {
_tag: "SessionRunnerModel.UnsupportedPackageError",
providerID: "test-provider",
modelID: "test-model",
package: "aisdk:@ai-sdk/mistral",
package: "aisdk:@ai-sdk/google",
})
expect(failure.message).toBe("Unsupported package for test-provider/test-model: aisdk:@ai-sdk/mistral")
expect(failure.message).toBe("Unsupported package for test-provider/test-model: aisdk:@ai-sdk/google")
}),
)
@@ -682,8 +612,8 @@ describe("ModelResolver", () => {
}),
)
yield* ModelResolver.fromCatalogModel(
model(Provider.aisdk("@ai-sdk/mistral"), {
settings: { apiKey: "", baseURL: "https://mistral.example/v1" },
model(Provider.aisdk("@ai-sdk/google"), {
settings: { apiKey: "", baseURL: "https://google.example/v1" },
}),
undefined,
{
-1
View File
@@ -143,7 +143,6 @@ 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")
}),
-24
View File
@@ -171,30 +171,6 @@ 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
-3
View File
@@ -136,9 +136,6 @@ export interface SlotMap {
readonly sessionID?: string
readonly mode: "normal" | "shell"
}
readonly "session.composer.top": {
readonly sessionID: string
}
readonly "sidebar.content": {
readonly sessionID: string
}
@@ -41,7 +41,6 @@ export const create = Effect.fn("SimulationRenderer.create")(function* (
...options,
width: cols,
height: rows,
kittyKeyboard: Boolean(options.useKittyKeyboard),
...(recording
? {
stdout: recording as unknown as NodeJS.WriteStream,
-19
View File
@@ -43,25 +43,6 @@ test("normalizes named keys for OpenTUI", async () => {
])
})
test("headless input mirrors the configured kitty keyboard protocol", async () => {
await Effect.runPromise(
Effect.scoped(
Effect.gen(function* () {
const renderer = yield* SimulationRenderer.create({ useKittyKeyboard: {} })
const harness = createHarness(renderer)
let key: { readonly name: string; readonly source: string } | undefined
renderer.keyInput.once("keypress", (event) => {
key = event
})
yield* execute(harness, { type: "ui.press", key: "escape" })
expect(key).toMatchObject({ name: "escape", source: "kitty" })
}),
),
)
})
test("clicks a target at relative coordinates through descendant text", async () => {
await Effect.runPromise(
Effect.scoped(
+33 -32
View File
@@ -66,7 +66,7 @@ import { DialogThemeList } from "./component/dialog-theme-list"
import { DialogHelp } from "./ui/dialog-help"
import { DialogAgent } from "./component/dialog-agent"
import { DialogSessionList } from "./component/dialog-session-list"
import { DialogOpen } from "./component/dialog-open"
import { DialogProject } from "./component/dialog-project"
import { SessionTabs } from "./component/session-tabs"
import { ThemeErrorToast } from "./component/theme-error-toast"
import { ThemeProvider, useTheme, useThemes } from "./context/theme"
@@ -95,15 +95,16 @@ import { StorageProvider } from "./context/storage"
registerOpencodeSpinner()
const appGlobalBindingCommands = ["session.list", "session.new", "open.menu"] as const
const appGlobalBindingCommands = ["session.list", "session.new"] as const
const sessionTabBindingCommands = [
"session.tab.next",
"session.tab.previous",
"session.tab.history.back",
"session.tab.history.forward",
"session.tab.next_unread",
"session.tab.previous_unread",
"session.tab.close",
"session.tab.reopen",
"session.tab.select.1",
"session.tab.select.2",
"session.tab.select.3",
@@ -523,11 +524,8 @@ function App(props: { pair?: DialogPairCredentials }) {
renderer.useMouse = config.data.mouse
})
let active: { id: string; title: string } | undefined
// Update terminal window title based on current route and session
createEffect(() => {
const session = route.data.type === "session" ? data.session.get(route.data.sessionID) : undefined
if (session) active = { id: session.id, title: session.title }
if (!terminalTitleEnabled()) return
if (route.data.type === "home") {
@@ -536,6 +534,7 @@ function App(props: { pair?: DialogPairCredentials }) {
}
if (route.data.type === "session") {
const session = data.session.get(route.data.sessionID)
if (!session || isDefaultTitle(session.title)) {
renderer.setTerminalTitle("OpenCode")
return
@@ -651,12 +650,12 @@ function App(props: { pair?: DialogPairCredentials }) {
},
},
{
name: "open.menu",
title: "Open session or project",
name: "project.switch",
title: "Switch project",
category: "Session",
slash: { name: "open", aliases: ["projects", "project"] },
slash: { name: "projects", aliases: ["project"] },
run: () => {
dialog.replace(() => <DialogOpen />)
dialog.replace(() => <DialogProject />)
},
},
...Array.from({ length: 9 }, (_, i) => ({
@@ -669,7 +668,7 @@ function App(props: { pair?: DialogPairCredentials }) {
})),
{
name: "session.tab.next",
title: "Next tab",
title: "Next open session tab",
category: "Session",
palette: undefined,
enabled: sessionTabs.enabled,
@@ -677,15 +676,31 @@ function App(props: { pair?: DialogPairCredentials }) {
},
{
name: "session.tab.previous",
title: "Previous tab",
title: "Previous open session tab",
category: "Session",
palette: undefined,
enabled: sessionTabs.enabled,
run: () => sessionTabs.cycle(-1),
},
{
name: "session.tab.history.back",
title: "Back in session tab history",
category: "Session",
palette: undefined,
enabled: sessionTabs.enabled,
run: () => sessionTabs.history(-1),
},
{
name: "session.tab.history.forward",
title: "Forward in session tab history",
category: "Session",
palette: undefined,
enabled: sessionTabs.enabled,
run: () => sessionTabs.history(1),
},
{
name: "session.tab.next_unread",
title: "Next unread tab",
title: "Next unread session tab",
category: "Session",
palette: undefined,
enabled: sessionTabs.enabled,
@@ -693,7 +708,7 @@ function App(props: { pair?: DialogPairCredentials }) {
},
{
name: "session.tab.previous_unread",
title: "Previous unread tab",
title: "Previous unread session tab",
category: "Session",
palette: undefined,
enabled: sessionTabs.enabled,
@@ -701,21 +716,14 @@ function App(props: { pair?: DialogPairCredentials }) {
},
{
name: "session.tab.close",
title: "Close tab",
title: "Close current session tab",
category: "Session",
enabled: sessionTabs.enabled,
run: () => sessionTabs.close(),
},
{
name: "session.tab.reopen",
title: "Reopen closed tab",
category: "Session",
enabled: sessionTabs.enabled,
run: () => sessionTabs.reopen(),
},
...Array.from({ length: 9 }, (_, i) => ({
name: `session.tab.select.${i + 1}`,
title: `Switch to tab ${i + 1}`,
title: `Switch to session tab ${i + 1}`,
category: "Session",
palette: undefined,
enabled: sessionTabs.enabled,
@@ -1144,11 +1152,10 @@ function App(props: { pair?: DialogPairCredentials }) {
event.on("session.deleted", (evt) => {
if (route.data.type === "session" && route.data.sessionID === evt.data.sessionID) {
const title = active?.id === evt.data.sessionID ? active.title : undefined
route.navigate({ type: "home" })
toast.show({
variant: "info",
message: title ? `Session "${title}" was deleted` : "The current session was deleted",
message: "The current session was deleted",
})
}
})
@@ -1212,13 +1219,7 @@ 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 || sessionTabs.newTab()) &&
route.data.type !== "plugin"
}
>
<Show when={sessionTabs.enabled() && sessionTabs.tabs().length > 0 && route.data.type !== "plugin"}>
<SessionTabs />
</Show>
<Switch>
@@ -0,0 +1,436 @@
import { OptimizedBuffer, RGBA, TextAttributes } from "@opentui/core"
import { go } from "../logo"
const PERIOD = 4600
const RINGS = 3
const WIDTH = 3.8
const TAIL = 9.5
const AMP = 0.55
const TAIL_AMP = 0.16
const BREATH_AMP = 0.05
const BREATH_SPEED = 0.0008
// Offset so the bg ring emits from the estimated GO center when the logo shimmer peaks.
const PHASE_OFFSET = 0.29
const LOGO_GAP = 1
const LOGO_TOP_BIAS = -1
const LOGO_LEFT_WIDTH = go.left[0]?.length ?? 0
const LOGO_LINES = go.left.map((line, index) => line + " ".repeat(LOGO_GAP) + go.right[index])
const LOGO_WIDTH = LOGO_LINES[0]?.length ?? 0
const LOGO_HEIGHT = LOGO_LINES.length
const SPACE = " ".codePointAt(0)!
const TOP_HALF = "▀".codePointAt(0)!
const FULL_BLOCK = "█".codePointAt(0)!
const RING_SCALE = 1 / RINGS
const TAIL_SCALE = 1 / TAIL
const LOGO_REACH = Math.hypot(LOGO_WIDTH, LOGO_HEIGHT * 2) + 3
const enum LogoCellKind {
Background,
Top,
ShadowTop,
Solid,
Char,
}
type LogoTemplateCell = {
x: number
y: number
kind: LogoCellKind
charCode: number
attributes: number
topDist: number
bottomDist: number
}
const LOGO_TEMPLATE: LogoTemplateCell[] = LOGO_LINES.flatMap((line, y) =>
Array.from(line)
.map((char, x) => {
if (char === " ") return
const kind =
char === "_"
? LogoCellKind.Background
: char === "^"
? LogoCellKind.Top
: char === "~"
? LogoCellKind.ShadowTop
: char === "█"
? LogoCellKind.Solid
: LogoCellKind.Char
return {
x,
y,
kind,
charCode: char.codePointAt(0) ?? SPACE,
attributes: x > LOGO_LEFT_WIDTH ? TextAttributes.BOLD : 0,
topDist: Math.hypot(x + 0.5 - LOGO_WIDTH / 2, y * 2 - LOGO_HEIGHT),
bottomDist: Math.hypot(x + 0.5 - LOGO_WIDTH / 2, y * 2 + 1 - LOGO_HEIGHT),
}
})
.filter((cell): cell is LogoTemplateCell => !!cell),
)
export type Rgb = [number, number, number]
export type GoUpsellArtRenderOptions = {
deltaTime?: number
rgb?: boolean
cache?: boolean
}
const CACHE_FRAME_COUNT = Math.round(PERIOD / (1000 / 30))
const CACHE_FRAMES_PER_RENDER = 1
export function toRgb(color: RGBA): Rgb {
const [r, g, b] = color.toInts()
return [r, g, b]
}
function clamp(n: number) {
return Math.max(0, Math.min(1, n))
}
function writeRgb(buffer: Uint16Array, offset: number, r: number, g: number, b: number, a = 255) {
buffer[offset] = r
buffer[offset + 1] = g
buffer[offset + 2] = b
buffer[offset + 3] = a
}
function mixChannel(base: number, overlay: number, alpha: number) {
return Math.round(base + (overlay - base) * clamp(alpha))
}
function writeLogoTint(
buffer: Uint16Array,
offset: number,
base: Rgb,
primary: Rgb,
primaryMix: number,
peakMix: number,
) {
const p = clamp(primaryMix)
const q = clamp(peakMix)
const r = mixChannel(mixChannel(base[0], primary[0], p), 255, q)
const g = mixChannel(mixChannel(base[1], primary[1], p), 255, q)
const b = mixChannel(mixChannel(base[2], primary[2], p), 255, q)
writeRgb(buffer, offset, r, g, b)
}
function sameRgb(a: Rgb, b: Rgb) {
return a[0] === b[0] && a[1] === b[1] && a[2] === b[2]
}
export class GoUpsellArtPainter {
private panelRgb: Rgb = [0, 0, 0]
private primaryRgb: Rgb = [255, 255, 255]
private logoBaseRgb: Rgb = [180, 180, 180]
private elapsed = 0
private distances = new Float32Array(0)
private edgeFalloff = new Float32Array(0)
private geometryWidth = 0
private geometryHeight = 0
private reach = 1
private logoX = 0
private logoY = 0
private logoIndexes = new Int32Array(0)
private logoRgb: boolean | undefined
private pulsePeak = 0
private pulsePrimary = 0
private cacheDirty = true
private frameCache: Array<{ fg: Uint16Array; bg: Uint16Array }> = []
private cacheBuildIndex = 0
setBackgroundPanel(value: RGBA | Rgb | undefined) {
if (!value) return false
const next = value instanceof RGBA ? toRgb(value) : value
if (sameRgb(this.panelRgb, next)) return false
this.panelRgb = next
this.invalidateCache()
return true
}
setLogoBase(value: RGBA | Rgb | undefined) {
if (!value) return false
const next = value instanceof RGBA ? toRgb(value) : value
if (sameRgb(this.logoBaseRgb, next)) return false
this.logoBaseRgb = next
this.invalidateCache()
return true
}
setPrimary(value: RGBA | Rgb | undefined) {
if (!value) return false
const next = value instanceof RGBA ? toRgb(value) : value
if (sameRgb(this.primaryRgb, next)) return false
this.primaryRgb = next
this.invalidateCache()
return true
}
render(frameBuffer: OptimizedBuffer, options: GoUpsellArtRenderOptions = {}) {
const rgb = options.rgb === true
this.elapsed = (this.elapsed + (options.deltaTime ?? 0)) % PERIOD
this.rebuildGeometry(frameBuffer, rgb)
if (options.cache !== false) {
this.drawCached(frameBuffer, rgb)
return
}
this.drawBackground(frameBuffer, this.elapsed)
this.drawLogo(frameBuffer, this.elapsed, rgb)
}
private invalidateCache() {
this.cacheDirty = true
this.cacheBuildIndex = 0
this.frameCache = []
}
private rebuildGeometry(frameBuffer: OptimizedBuffer, rgb: boolean) {
const width = frameBuffer.width
const height = frameBuffer.height
const geometryChanged = width !== this.geometryWidth || height !== this.geometryHeight
const logoTemplateChanged = this.logoRgb !== rgb
if (!geometryChanged && !logoTemplateChanged) return
if (geometryChanged) {
this.geometryWidth = width
this.geometryHeight = height
this.logoX = Math.max(0, Math.floor((width - LOGO_WIDTH) / 2))
this.logoY = Math.max(
0,
Math.min(Math.max(0, height - LOGO_HEIGHT), Math.round((height - LOGO_HEIGHT) / 2) + LOGO_TOP_BIAS),
)
const centerX = this.logoX + LOGO_WIDTH / 2
const centerY = this.logoY + LOGO_HEIGHT / 2
this.reach = Math.hypot(Math.max(centerX, width - centerX), Math.max(centerY, height - centerY) * 2) + TAIL
this.distances = new Float32Array(width * height)
this.edgeFalloff = new Float32Array(width * height)
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
const index = y * width + x
const dist = Math.hypot(x + 0.5 - centerX, (y + 0.5 - centerY) * 2)
this.distances[index] = dist
this.edgeFalloff[index] = Math.max(0, 1 - (dist / (this.reach * 0.85)) ** 2)
}
}
}
this.logoRgb = rgb
this.invalidateCache()
this.rebuildCellTemplate(frameBuffer, rgb)
}
private drawCached(frameBuffer: OptimizedBuffer, rgb: boolean) {
if (this.cacheDirty) this.startFrameCache(frameBuffer, rgb)
if (this.cacheBuildIndex < CACHE_FRAME_COUNT) {
this.buildFrameCache(frameBuffer, rgb)
this.drawBackground(frameBuffer, this.elapsed)
this.drawLogo(frameBuffer, this.elapsed, rgb)
return
}
const frame = this.frameCache[Math.floor((this.elapsed / PERIOD) * CACHE_FRAME_COUNT) % CACHE_FRAME_COUNT]
if (frame) {
frameBuffer.buffers.fg.set(frame.fg)
frameBuffer.buffers.bg.set(frame.bg)
}
}
private startFrameCache(frameBuffer: OptimizedBuffer, rgb: boolean) {
this.frameCache = []
this.cacheBuildIndex = 0
this.rebuildCellTemplate(frameBuffer, rgb)
this.cacheDirty = false
}
private buildFrameCache(frameBuffer: OptimizedBuffer, rgb: boolean) {
const end = Math.min(CACHE_FRAME_COUNT, this.cacheBuildIndex + CACHE_FRAMES_PER_RENDER)
for (; this.cacheBuildIndex < end; this.cacheBuildIndex++) {
const t = (this.cacheBuildIndex / CACHE_FRAME_COUNT) * PERIOD
this.drawBackground(frameBuffer, t)
this.drawLogo(frameBuffer, t, rgb)
this.frameCache.push({
fg: new Uint16Array(frameBuffer.buffers.fg),
bg: new Uint16Array(frameBuffer.buffers.bg),
})
}
}
private rebuildCellTemplate(frameBuffer: OptimizedBuffer, rgb: boolean) {
const buffers = frameBuffer.buffers
buffers.char.fill(SPACE)
buffers.attributes.fill(0)
if (this.geometryWidth < LOGO_WIDTH || this.geometryHeight < LOGO_HEIGHT) {
this.logoIndexes = new Int32Array(0)
return
}
this.logoIndexes = new Int32Array(LOGO_TEMPLATE.length)
for (let i = 0; i < LOGO_TEMPLATE.length; i++) {
const cell = LOGO_TEMPLATE[i]!
const index = (this.logoY + cell.y) * this.geometryWidth + this.logoX + cell.x
this.logoIndexes[i] = index
buffers.attributes[index] = cell.attributes
buffers.char[index] =
cell.kind === LogoCellKind.Background
? SPACE
: cell.kind === LogoCellKind.Top || cell.kind === LogoCellKind.ShadowTop
? TOP_HALF
: cell.kind === LogoCellKind.Solid
? rgb
? TOP_HALF
: FULL_BLOCK
: cell.charCode
}
}
private drawBackground(frameBuffer: OptimizedBuffer, t: number) {
const buffers = frameBuffer.buffers
const fg = buffers.fg
const bg = buffers.bg
const distances = this.distances
const edgeFalloff = this.edgeFalloff
const baseR = this.panelRgb[0]
const baseG = this.panelRgb[1]
const baseB = this.panelRgb[2]
const deltaR = this.primaryRgb[0] - baseR
const deltaG = this.primaryRgb[1] - baseG
const deltaB = this.primaryRgb[2] - baseB
const breath = (0.5 + 0.5 * Math.sin(t * BREATH_SPEED)) * BREATH_AMP
const phase0 = (t / PERIOD - PHASE_OFFSET + 1) % 1
const phase1 = (t / PERIOD + 1 / RINGS - PHASE_OFFSET + 1) % 1
const phase2 = (t / PERIOD + 2 / RINGS - PHASE_OFFSET + 1) % 1
const envelope0 = Math.sin(phase0 * Math.PI)
const envelope1 = Math.sin(phase1 * Math.PI)
const envelope2 = Math.sin(phase2 * Math.PI)
const eased0 = envelope0 * envelope0 * (3 - 2 * envelope0)
const eased1 = envelope1 * envelope1 * (3 - 2 * envelope1)
const eased2 = envelope2 * envelope2 * (3 - 2 * envelope2)
const head0 = phase0 * this.reach
const head1 = phase1 * this.reach
const head2 = phase2 * this.reach
for (let index = 0; index < distances.length; index++) {
const dist = distances[index]
const delta0 = dist - head0
const abs0 = delta0 < 0 ? -delta0 : delta0
const crest0 = abs0 < WIDTH ? 0.5 + 0.5 * Math.cos((delta0 / WIDTH) * Math.PI) : 0
const tail0 = delta0 < 0 && delta0 > -TAIL ? (1 + delta0 * TAIL_SCALE) ** 2.3 : 0
const delta1 = dist - head1
const abs1 = delta1 < 0 ? -delta1 : delta1
const crest1 = abs1 < WIDTH ? 0.5 + 0.5 * Math.cos((delta1 / WIDTH) * Math.PI) : 0
const tail1 = delta1 < 0 && delta1 > -TAIL ? (1 + delta1 * TAIL_SCALE) ** 2.3 : 0
const delta2 = dist - head2
const abs2 = delta2 < 0 ? -delta2 : delta2
const crest2 = abs2 < WIDTH ? 0.5 + 0.5 * Math.cos((delta2 / WIDTH) * Math.PI) : 0
const tail2 = delta2 < 0 && delta2 > -TAIL ? (1 + delta2 * TAIL_SCALE) ** 2.3 : 0
const level =
(crest0 * AMP + tail0 * TAIL_AMP) * eased0 +
(crest1 * AMP + tail1 * TAIL_AMP) * eased1 +
(crest2 * AMP + tail2 * TAIL_AMP) * eased2
const rawStrength = (level * RING_SCALE + breath) * edgeFalloff[index]
const strength = (rawStrength > 1 ? 1 : rawStrength) * 0.7
const offset = index * 4
const r = Math.round(baseR + deltaR * strength)
const g = Math.round(baseG + deltaG * strength)
const b = Math.round(baseB + deltaB * strength)
bg[offset] = fg[offset] = r
bg[offset + 1] = fg[offset + 1] = g
bg[offset + 2] = fg[offset + 2] = b
bg[offset + 3] = fg[offset + 3] = 255
}
}
private setLogoPulse(dist: number, head0: number, eased0: number, head1: number, eased1: number) {
let peak = 0.04
let primary = 0
const delta0 = dist - head0
const core0 = Math.exp(-(Math.abs(delta0 / 1.2) ** 1.8))
const soft0 = Math.exp(-(Math.abs(delta0 / 7) ** 1.6))
const tail0 = delta0 < 0 && delta0 > -7 ? (1 + delta0 / 7) ** 2.6 : 0
peak += core0 * 0.65 * eased0
primary += (soft0 * 0.16 + tail0 * 0.22) * eased0
const delta1 = dist - head1
const core1 = Math.exp(-(Math.abs(delta1 / 1.2) ** 1.8))
const soft1 = Math.exp(-(Math.abs(delta1 / 7) ** 1.6))
const tail1 = delta1 < 0 && delta1 > -7 ? (1 + delta1 / 7) ** 2.6 : 0
peak += core1 * 0.65 * eased1
primary += (soft1 * 0.16 + tail1 * 0.22) * eased1
this.pulsePeak = peak > 1 ? 1 : peak
this.pulsePrimary = primary > 1 ? 1 : primary
}
private drawLogo(frameBuffer: OptimizedBuffer, t: number, rgb: boolean) {
if (this.logoIndexes.length === 0) return
const buffers = frameBuffer.buffers
const fg = buffers.fg
const bg = buffers.bg
const shadow: Rgb = [
mixChannel(this.panelRgb[0], this.logoBaseRgb[0], 0.25),
mixChannel(this.panelRgb[1], this.logoBaseRgb[1], 0.25),
mixChannel(this.panelRgb[2], this.logoBaseRgb[2], 0.25),
]
const phase0 = (t / PERIOD) % 1
const phase1 = (t / PERIOD + 0.5) % 1
const envelope0 = Math.sin(phase0 * Math.PI)
const envelope1 = Math.sin(phase1 * Math.PI)
const eased0 = envelope0 * envelope0 * (3 - 2 * envelope0)
const eased1 = envelope1 * envelope1 * (3 - 2 * envelope1)
const head0 = phase0 * LOGO_REACH
const head1 = phase1 * LOGO_REACH
for (let i = 0; i < LOGO_TEMPLATE.length; i++) {
const cell = LOGO_TEMPLATE[i]!
const index = this.logoIndexes[i]!
const offset = index * 4
this.setLogoPulse(cell.topDist, head0, eased0, head1, eased1)
const topPeak = this.pulsePeak
const topPrimary = this.pulsePrimary
this.setLogoPulse(cell.bottomDist, head0, eased0, head1, eased1)
const bottomPeak = this.pulsePeak
const bottomPrimary = this.pulsePrimary
if (cell.kind === LogoCellKind.Background) {
writeLogoTint(bg, offset, shadow, this.primaryRgb, 0, Math.max(topPeak, bottomPeak) * 0.18)
continue
}
if (cell.kind === LogoCellKind.Top) {
writeLogoTint(fg, offset, this.logoBaseRgb, this.primaryRgb, topPrimary, topPeak)
writeLogoTint(bg, offset, shadow, this.primaryRgb, 0, bottomPeak * 0.18)
continue
}
if (cell.kind === LogoCellKind.ShadowTop) {
writeLogoTint(fg, offset, shadow, this.primaryRgb, 0, topPeak * 0.18)
continue
}
if (cell.kind === LogoCellKind.Solid && rgb) {
writeLogoTint(fg, offset, this.logoBaseRgb, this.primaryRgb, topPrimary, topPeak)
writeLogoTint(bg, offset, this.logoBaseRgb, this.primaryRgb, bottomPrimary, bottomPeak)
continue
}
writeLogoTint(
fg,
offset,
this.logoBaseRgb,
this.primaryRgb,
(topPrimary + bottomPrimary) / 2,
(topPeak + bottomPeak) / 2,
)
}
}
}
+102
View File
@@ -0,0 +1,102 @@
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
/>
)
}
-164
View File
@@ -1,164 +0,0 @@
import path from "path"
import { createMemo, createResource, createSignal, onMount } from "solid-js"
import type { SessionInfo } from "@opencode-ai/client"
import { useTerminalDimensions } from "@opentui/solid"
import { useDialog } from "../ui/dialog"
import { DialogSelect } from "../ui/dialog-select"
import { useRoute } from "../context/route"
import { useData } from "../context/data"
import { useClient } from "../context/client"
import { useLocation } from "../context/location"
import { useSessionTabs } from "../context/session-tabs"
import { useTheme, useThemes } from "../context/theme"
import { Keymap } from "../context/keymap"
import { Locale } from "../util/locale"
import { abbreviateHome } from "../runtime"
import { useTuiPaths } from "../context/runtime"
import { truncateFilePath } from "../ui/file-path"
import { stringWidth } from "../util/string-width"
import { Spinner } from "./spinner"
const RECENT_LIMIT = 8
type OpenTarget = { type: "session"; sessionID: string } | { type: "project"; directory: string }
export function DialogOpen() {
const dialog = useDialog()
const route = useRoute()
const data = useData()
const client = useClient()
const location = useLocation()
const sessionTabs = useSessionTabs()
const themes = useThemes()
const theme = useTheme("elevated")
const mode = themes.mode
const paths = useTuiPaths()
const dimensions = useTerminalDimensions()
const shortcuts = Keymap.useShortcuts()
const [filter, setFilter] = createSignal("")
data.project.invalidate()
void data.project.sync().catch(() => {})
// One background fetch fills in recent sessions from other projects; the menu renders
// immediately from the local store and never blocks on the network.
const [fetched] = createResource(
() =>
client.api.session
.list({ limit: 50, order: "desc", parentID: null })
.then((response) => response.data)
.catch(() => [] as SessionInfo[]),
{ initialValue: [] },
)
const openTabs = createMemo(() => new Set(sessionTabs.tabs().map((tab) => tab.sessionID)))
const currentSessionID = createMemo(() => (route.data.type === "session" ? route.data.sessionID : undefined))
const sessions = createMemo(() => {
const seen = new Set<string>()
return [...data.session.list(), ...fetched()]
.filter((session) => {
if (session.parentID || seen.has(session.id)) return false
seen.add(session.id)
return true
})
.toSorted((a, b) => b.time.updated - a.time.updated)
})
const options = createMemo(() => {
const tabs = openTabs()
// With an empty query the menu shows what is not already one keystroke away: open tabs are
// visible in the strip, so recents exclude them. Typing widens the pool to every session so
// matching a tab by name still switches to it.
const recent = filter().trim()
? sessions()
: sessions()
.filter((session) => !tabs.has(session.id))
.slice(0, RECENT_LIMIT)
const sessionOptions = recent.map((session) => {
const project = data.project.get(session.projectID)
const name = project?.name || path.basename(project?.canonical ?? session.location.directory)
const running = data.session.family(session.id).some((id) => data.session.status(id) === "running")
return {
title: session.title,
value: { type: "session", sessionID: session.id } as OpenTarget,
category: "Sessions",
footer: `${Locale.truncate(name, 20)} · ${timeAgo(session.time.updated)}`,
gutter: running
? () => <Spinner />
: tabs.has(session.id)
? () => <text fg={theme.hue.accent[mode() === "light" ? 800 : 200]}></text>
: undefined,
}
})
const current = location.current?.project
const seen = new Set<string>()
const projectOptions = data.project
.list()
.filter((project) => {
if (project.canonical === "/" || project.id === current?.id || seen.has(project.canonical)) return false
seen.add(project.canonical)
return true
})
.map((project) => {
const title = project.name ?? path.basename(project.canonical)
const description = abbreviateHome(project.canonical, paths.home)
// Dialog padding, the gutter column, 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: { type: "project", directory: project.canonical } as OpenTarget,
category: "Projects",
}
})
return [...sessionOptions, ...projectOptions]
})
onMount(() => dialog.setSize("large"))
return (
<DialogSelect
title="Open"
placeholder="Search sessions and projects…"
options={options()}
current={currentSessionID() ? ({ type: "session", sessionID: currentSessionID()! } as OpenTarget) : undefined}
focusCurrent={false}
onFilter={setFilter}
noMatchView={
<box paddingLeft={4} paddingRight={4}>
<text fg={theme.text.subdued}>
{shortcuts.get("session.list")
? `No matches · search all sessions with ${shortcuts.get("session.list")}`
: "No matches"}
</text>
</box>
}
onSelect={(option) => {
dialog.clear()
if (option.value.type === "session") {
route.navigate({ type: "session", sessionID: option.value.sessionID })
return
}
const target = { directory: option.value.directory }
route.navigate({ type: "home", location: target })
location.set(target)
}}
/>
)
}
function timeAgo(timestamp: number) {
const minutes = Math.floor((Date.now() - timestamp) / 60_000)
if (minutes < 1) return "now"
if (minutes < 60) return `${minutes}m`
const hours = Math.floor(minutes / 60)
if (hours < 24) return `${hours}h`
const days = Math.floor(hours / 24)
if (days < 30) return `${days}d`
const months = Math.floor(days / 30)
if (months < 12) return `${months}mo`
return `${Math.floor(days / 365)}y`
}
@@ -0,0 +1,67 @@
import path from "path"
import { createMemo } from "solid-js"
import { DialogSelect } from "../ui/dialog-select"
import { useDialog } from "../ui/dialog"
import { useData } from "../context/data"
import { useRoute } from "../context/route"
import { abbreviateHome } from "../runtime"
import { useTuiPaths } from "../context/runtime"
import { useLocation } from "../context/location"
import { useToast } from "../ui/toast"
export function DialogProject() {
const dialog = useDialog()
const data = useData()
const route = useRoute()
const paths = useTuiPaths()
const location = useLocation()
const toast = useToast()
data.project.invalidate()
void data.project.sync().catch(toast.error)
const current = () => location.current?.project
const options = createMemo(() => {
const seen = new Set<string>()
return data.project
.list()
.filter((project) => {
if (project.canonical === "/" || seen.has(project.canonical)) return false
seen.add(project.canonical)
return true
})
.toSorted((a, b) => {
if (a.id === current()?.id) return -1
if (b.id === current()?.id) return 1
return 0
})
.map((project) => ({
title: project.name ?? path.basename(project.canonical),
description: abbreviateHome(project.canonical, paths.home),
value: project.canonical,
category: project.id === current()?.id ? "Current" : "Projects",
}))
})
return (
<DialogSelect
title="Switch project"
placeholder="Search projects…"
options={options()}
current={current()?.canonical}
emptyView={
<box paddingLeft={4} paddingRight={4} paddingTop={1}>
<text>No projects found</text>
</box>
}
onSelect={(option) => {
dialog.clear()
if (option.value === current()?.canonical) return
const target = { directory: option.value }
route.navigate({ type: "home", location: target })
location.set(target)
}}
/>
)
}
@@ -0,0 +1,164 @@
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),
)
})
}
@@ -0,0 +1,113 @@
import { TextAttributes } from "@opentui/core"
import { Keymap } from "../context/keymap"
import { useTheme } from "../context/theme"
import { useDialog } from "../ui/dialog"
import { createStore } from "solid-js/store"
import { For } from "solid-js"
export function DialogSessionDeleteFailed(props: {
session: string
workspace: string
onDelete?: () => boolean | void | Promise<boolean | void>
onRestore?: () => boolean | void | Promise<boolean | void>
onDone?: () => void
}) {
const dialog = useDialog()
const theme = useTheme("elevated")
const [store, setStore] = createStore({
active: "delete" as "delete" | "restore",
})
const options = [
{
id: "delete" as const,
title: "Delete workspace",
description: "Delete the workspace and all sessions attached to it.",
run: props.onDelete,
},
{
id: "restore" as const,
title: "Restore to new workspace",
description: "Try to restore this session into a new workspace.",
run: props.onRestore,
},
]
async function confirm() {
const result = await options.find((item) => item.id === store.active)?.run?.()
if (result === false) return
props.onDone?.()
if (!props.onDone) dialog.clear()
}
Keymap.createLayer(() => ({
mode: "modal",
commands: [
{ bind: "return", title: "Confirm recovery option", group: "Dialog", run: () => void confirm() },
{ bind: "left", title: "Delete broken session", group: "Dialog", run: () => setStore("active", "delete") },
{ bind: "up", title: "Delete broken session", group: "Dialog", run: () => setStore("active", "delete") },
{
bind: "right",
title: "Restore broken session",
group: "Dialog",
run: () => setStore("active", "restore"),
},
{
bind: "down",
title: "Restore broken session",
group: "Dialog",
run: () => setStore("active", "restore"),
},
],
}))
return (
<box paddingLeft={2} paddingRight={2} gap={1}>
<box flexDirection="row" justifyContent="space-between">
<text attributes={TextAttributes.BOLD} fg={theme.text.default}>
Failed to Delete Session
</text>
<text fg={theme.text.subdued} onMouseUp={() => dialog.clear()}>
esc
</text>
</box>
<text fg={theme.text.subdued} wrapMode="word">
{`The session "${props.session}" could not be deleted because the workspace "${props.workspace}" is not available.`}
</text>
<text fg={theme.text.subdued} wrapMode="word">
Choose how you want to recover this broken workspace session.
</text>
<box flexDirection="column" paddingBottom={1} gap={1}>
<For each={options}>
{(item) => (
<box
flexDirection="column"
paddingLeft={1}
paddingRight={1}
paddingTop={1}
paddingBottom={1}
backgroundColor={item.id === store.active ? theme.background.action.primary.focused : undefined}
onMouseUp={() => {
setStore("active", item.id)
void confirm()
}}
>
<text
attributes={TextAttributes.BOLD}
fg={item.id === store.active ? theme.text.action.primary.focused : theme.text.default}
>
{item.title}
</text>
<text
fg={item.id === store.active ? theme.text.action.primary.focused : theme.text.subdued}
wrapMode="word"
>
{item.description}
</text>
</box>
)}
</For>
</box>
</box>
)
}
@@ -17,7 +17,6 @@ import { DialogSessionRename } from "./dialog-session-rename"
import { Spinner } from "./spinner"
import { errorMessage } from "../util/error"
import { useSessionTabs } from "../context/session-tabs"
import { useStorage } from "../context/storage"
export function DialogSessionList() {
const dialog = useDialog()
@@ -34,8 +33,7 @@ export function DialogSessionList() {
const shortcuts = Keymap.useShortcuts()
const [search, setSearch] = createDebouncedSignal("", 150)
const [toDelete, setToDelete] = createSignal<string>()
const [prefs, updatePrefs] = useStorage().store("session-list", { initial: { allProjects: false } })
const allProjects = () => prefs.allProjects
const [allProjects, setAllProjects] = createSignal(false)
const [searchResults, { mutate: setSearchResults }] = createResource(
() => ({ query: search().trim(), allProjects: allProjects() }),
@@ -191,9 +189,7 @@ export function DialogSessionList() {
title: allProjects() ? "Show current directory sessions" : "Show all project sessions",
group: "Dialog",
run: () => {
void updatePrefs((draft) => {
draft.allProjects = !draft.allProjects
}).catch(() => {})
setAllProjects((value) => !value)
},
},
]}
+52
View File
@@ -0,0 +1,52 @@
import { createMemo, createResource } from "solid-js"
import { DialogSelect } from "../ui/dialog-select"
import { useDialog } from "../ui/dialog"
import { useClient } from "../context/client"
import { useData } from "../context/data"
import { createStore } from "solid-js/store"
export function DialogTag(props: { onSelect?: (value: string) => void }) {
const client = useClient()
const dialog = useDialog()
const data = useData()
const [store] = createStore({
filter: "",
})
const [files] = createResource(
() => [store.filter],
async () => {
const result = await client.api.file
.find({
query: store.filter,
type: "file",
limit: 5,
location: {
directory: data.location.default().directory,
workspace: data.location.default().workspaceID,
},
})
.catch(() => undefined)
return result?.data.map((item) => item.path) ?? []
},
)
const options = createMemo(() =>
(files() ?? []).map((file) => ({
value: file,
title: file,
})),
)
return (
<DialogSelect
title="Autocomplete"
options={options()}
onSelect={(option) => {
props.onSelect?.(option.value)
dialog.clear()
}}
/>
)
}
+73
View File
@@ -0,0 +1,73 @@
/** @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>
)
}
+4 -24
View File
@@ -6,11 +6,9 @@ import { useSessionTabs } from "../context/session-tabs"
import { useTheme, useThemes } from "../context/theme"
import {
adaptiveSessionTabLayout,
NEW_SESSION_TAB_TITLE,
sessionTabComplete,
seedSessionTabMotion,
sessionTabOverflowWidth,
type SessionTab,
type SessionTabUnread,
} from "../context/session-tabs-model"
import { createAnimatable, spring, tween } from "../ui/animation"
@@ -26,19 +24,10 @@ type ContextController = ReturnType<typeof useSessionTabs>
export type SessionTabsStatus = Omit<ReturnType<ContextController["status"]>, "unread"> & {
unread: SessionTabUnread | undefined
}
export const EMPTY_SESSION_TAB_STATUS: SessionTabsStatus = {
unread: undefined,
promptPulse: 0,
attention: false,
busy: false,
}
export type SessionTabsController = Pick<ContextController, "tabs" | "current" | "select" | "close" | "move"> & {
newTab?: () => boolean
status(sessionID: string): SessionTabsStatus
}
const NEW_SESSION_TAB: SessionTab = { sessionID: "new", title: NEW_SESSION_TAB_TITLE }
export function SessionTabs(props: { controller?: SessionTabsController; animations?: boolean } = {}) {
const tabs = props.controller ?? useSessionTabs()
const dimensions = useTerminalDimensions()
@@ -53,9 +42,8 @@ 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 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 activeID = createMemo(tabs.current)
const items = tabs.tabs
const layout = createMemo((previous: ReturnType<typeof adaptiveSessionTabLayout> | undefined) =>
adaptiveSessionTabLayout(items(), activeID(), dimensions().width, previous?.start),
)
@@ -63,7 +51,7 @@ export function SessionTabs(props: { controller?: SessionTabsController; animati
() =>
new Map(
layout().tabs.map((tab) => {
const status = tab === NEW_SESSION_TAB ? EMPTY_SESSION_TAB_STATUS : tabs.status(tab.sessionID)
const status = tabs.status(tab.sessionID)
return [
tab.sessionID,
{
@@ -210,11 +198,6 @@ export function SessionTabs(props: { controller?: SessionTabsController; animati
createEffect((previous: string) => {
const next = title()
if (next === previous) return next
if (previous === NEW_SESSION_TAB_TITLE) {
setOutgoingTitle(undefined)
wipe.jump({ front: 1 })
return next
}
setOutgoingTitle(previous)
wipe.jump({ front: 0 })
wipe.animate({ front: 1 })
@@ -279,7 +262,6 @@ 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 (
@@ -293,7 +275,6 @@ 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)
}}
@@ -302,7 +283,6 @@ 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}
@@ -342,7 +322,7 @@ export function SessionTabs(props: { controller?: SessionTabsController; animati
selectable={false}
onMouseUp={(event) => {
event.stopPropagation()
tabs.close(tab === NEW_SESSION_TAB ? undefined : tab.sessionID)
tabs.close(tab.sessionID)
}}
>
{hovered() === tab.sessionID ? "×" : ""}
-20
View File
@@ -4,7 +4,6 @@ import { extend } from "@opentui/solid"
type TabPulseOptions = RenderableOptions<TabPulseRenderable> & {
enabled?: boolean
active?: boolean
promptPulse?: number
complete?: boolean
glow?: boolean
breathe?: boolean
@@ -29,7 +28,6 @@ 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
@@ -104,11 +102,6 @@ class Envelope {
this.scale = scale
}
restart(scale = 1) {
this.clock = 0
this.scale = scale
}
stop() {
this.clock = undefined
}
@@ -134,7 +127,6 @@ 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
@@ -162,7 +154,6 @@ 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
@@ -231,15 +222,6 @@ 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
@@ -386,7 +368,6 @@ extend({ tab_pulse: TabPulseRenderable })
export function TabPulse(props: {
enabled?: boolean
active: boolean
promptPulse?: number
complete?: boolean
glow?: boolean
breathe?: boolean
@@ -404,7 +385,6 @@ export function TabPulse(props: {
width="100%"
enabled={props.enabled ?? true}
active={props.active}
promptPulse={props.promptPulse ?? 0}
complete={props.complete ?? false}
glow={props.glow ?? false}
breathe={props.breathe ?? false}
+3 -3
View File
@@ -127,13 +127,13 @@ export const Info = Schema.Struct({
tabs: Schema.optional(
Schema.Struct({
enabled: Schema.optional(Schema.Boolean).annotate({
description: "Use a persistent tab strip instead of pinned quick-switch sessions",
description: "Use a persistent session tab strip instead of pinned quick-switch sessions",
}),
scope: Schema.optional(Schema.Literals(["global", "cwd"])).annotate({
description: "Share tabs globally or keep a separate set for each working directory",
description: "Share session tabs globally or keep a separate set for each working directory",
}),
}),
).annotate({ description: "Tab strip settings" }),
).annotate({ description: "Session tab settings" }),
mini: Schema.optional(
Schema.Struct({
thinking: Schema.optional(Schema.Literals(["show", "hide"])).annotate({
+18 -18
View File
@@ -87,13 +87,13 @@ export const Definitions = {
session_move: keybind("none", "Move session"),
session_new: keybind("<leader>n", "Create a new session"),
session_list: keybind("<leader>l", "List all sessions"),
open_menu: keybind("ctrl+o", "Open recent sessions and projects"),
session_tab_next: keybind("ctrl+tab,<leader>right,alt+shift+]", "Switch to next open tab"),
session_tab_previous: keybind("ctrl+shift+tab,<leader>left,alt+shift+[", "Switch to previous open tab"),
session_tab_next_unread: keybind("<leader>down", "Switch to next unread tab"),
session_tab_previous_unread: keybind("<leader>up", "Switch to previous unread tab"),
session_tab_close: keybind("<leader>w", "Close current tab"),
session_tab_reopen: keybind("ctrl+shift+t", "Reopen last closed tab"),
session_tab_next: keybind("ctrl+tab,<leader>right", "Switch to next open session tab"),
session_tab_previous: keybind("ctrl+shift+tab,<leader>left", "Switch to previous open session tab"),
session_tab_history_back: keybind("ctrl+o", "Go back in session tab history"),
session_tab_history_forward: keybind("ctrl+i", "Go forward in session tab history"),
session_tab_next_unread: keybind("<leader>down", "Switch to next unread session tab"),
session_tab_previous_unread: keybind("<leader>up", "Switch to previous unread session tab"),
session_tab_close: keybind("<leader>w", "Close current session tab"),
session_timeline: keybind("<leader>g", "Show session timeline"),
session_fork: keybind("none", "Fork session from message"),
session_rename: keybind("ctrl+r", "Rename session"),
@@ -118,15 +118,15 @@ export const Definitions = {
session_quick_switch_7: keybind("<leader>7", "Switch to session in quick slot 7"),
session_quick_switch_8: keybind("<leader>8", "Switch to session in quick slot 8"),
session_quick_switch_9: keybind("<leader>9", "Switch to session in quick slot 9"),
session_tab_select_1: keybind("<leader>1,ctrl+1", "Switch to tab 1"),
session_tab_select_2: keybind("<leader>2,ctrl+2", "Switch to tab 2"),
session_tab_select_3: keybind("<leader>3,ctrl+3", "Switch to tab 3"),
session_tab_select_4: keybind("<leader>4,ctrl+4", "Switch to tab 4"),
session_tab_select_5: keybind("<leader>5,ctrl+5", "Switch to tab 5"),
session_tab_select_6: keybind("<leader>6,ctrl+6", "Switch to tab 6"),
session_tab_select_7: keybind("<leader>7,ctrl+7", "Switch to tab 7"),
session_tab_select_8: keybind("<leader>8,ctrl+8", "Switch to tab 8"),
session_tab_select_9: keybind("<leader>9,ctrl+9", "Switch to tab 9"),
session_tab_select_1: keybind("<leader>1,ctrl+1", "Switch to session tab 1"),
session_tab_select_2: keybind("<leader>2,ctrl+2", "Switch to session tab 2"),
session_tab_select_3: keybind("<leader>3,ctrl+3", "Switch to session tab 3"),
session_tab_select_4: keybind("<leader>4,ctrl+4", "Switch to session tab 4"),
session_tab_select_5: keybind("<leader>5,ctrl+5", "Switch to session tab 5"),
session_tab_select_6: keybind("<leader>6,ctrl+6", "Switch to session tab 6"),
session_tab_select_7: keybind("<leader>7,ctrl+7", "Switch to session tab 7"),
session_tab_select_8: keybind("<leader>8,ctrl+8", "Switch to session tab 8"),
session_tab_select_9: keybind("<leader>9,ctrl+9", "Switch to session tab 9"),
stash_delete: keybind("ctrl+d", "Delete stash entry"),
model_provider_list: keybind("ctrl+a", "Open provider list from model dialog"),
@@ -290,13 +290,13 @@ export const CommandMap = {
session_move: "session.move",
session_new: "session.new",
session_list: "session.list",
open_menu: "open.menu",
session_tab_next: "session.tab.next",
session_tab_previous: "session.tab.previous",
session_tab_history_back: "session.tab.history.back",
session_tab_history_forward: "session.tab.history.forward",
session_tab_next_unread: "session.tab.next_unread",
session_tab_previous_unread: "session.tab.previous_unread",
session_tab_close: "session.tab.close",
session_tab_reopen: "session.tab.reopen",
session_timeline: "session.timeline",
session_fork: "session.fork",
session_rename: "session.rename",
-5
View File
@@ -1089,11 +1089,6 @@ 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])
+13
View File
@@ -0,0 +1,13 @@
import { createMemo } from "solid-js"
import { useData } from "./data"
import { abbreviateHome } from "../runtime"
import { useTuiPaths } from "./runtime"
export function useDirectory() {
const data = useData()
const paths = useTuiPaths()
return createMemo(() => {
const directory = data.location.info()?.directory ?? data.location.default().directory ?? paths.cwd
return abbreviateHome(directory, paths.home)
})
}
@@ -5,8 +5,6 @@ export type SessionTab = {
export type SessionTabUnread = "activity" | "error"
export const NEW_SESSION_TAB_TITLE = "New session"
export type SessionTabHistory = {
entries: readonly string[]
index: number
@@ -38,39 +36,6 @@ export function closeSessionTab(tabs: readonly SessionTab[], sessionID: string)
}
}
export type ClosedSessionTab = {
tab: SessionTab
index: number
}
const CLOSED_SESSION_TAB_LIMIT = 10
export function recordClosedSessionTab(
stack: readonly ClosedSessionTab[],
tab: SessionTab,
index: number,
): ClosedSessionTab[] {
return [...stack.filter((entry) => entry.tab.sessionID !== tab.sessionID), { tab, index }].slice(
-CLOSED_SESSION_TAB_LIMIT,
)
}
/**
* Pop the most recently closed tab that is not already open and restore it at its original
* position. Entries for already-open sessions are consumed so repeated reopens walk the stack.
*/
export function reopenSessionTab(stack: readonly ClosedSessionTab[], tabs: readonly SessionTab[]) {
const remaining = [...stack]
while (remaining.length > 0) {
const entry = remaining.pop()!
if (tabs.some((tab) => tab.sessionID === entry.tab.sessionID)) continue
const next = [...tabs]
next.splice(Math.min(entry.index, tabs.length), 0, entry.tab)
return { stack: remaining, tabs: next, sessionID: entry.tab.sessionID }
}
return { stack: remaining, tabs: undefined, sessionID: undefined }
}
export function moveSessionTab(tabs: SessionTab[], sessionID: string, index: number): SessionTab[] {
const from = tabs.findIndex((tab) => tab.sessionID === sessionID)
const to = Math.max(0, Math.min(tabs.length - 1, index))
+10 -53
View File
@@ -1,4 +1,4 @@
import { createEffect, createMemo, createSignal, onCleanup } from "solid-js"
import { createEffect, createMemo, onCleanup } from "solid-js"
import { isDeepEqual } from "remeda"
import { createSimpleContext } from "./helper"
import { useClient } from "./client"
@@ -13,12 +13,8 @@ import {
cycleSessionTab,
moveSessionTab,
moveSessionTabHistory,
NEW_SESSION_TAB_TITLE,
openSessionTab,
recordClosedSessionTab,
recordSessionTabHistory,
reopenSessionTab,
type ClosedSessionTab,
type SessionTab,
type SessionTabHistory,
type SessionTabUnread,
@@ -59,10 +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 }
// User-closed tabs eligible for reopening; in-memory like history, deleted sessions pruned.
let closedTabs: ClosedSessionTab[] = []
function state() {
if (config.tabs?.scope === "global") return store.global
@@ -78,19 +71,12 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
const root = (sessionID: string) => data.session.root(sessionID)
const current = () => (route.data.type === "session" ? root(route.data.sessionID) : undefined)
const newTab = createMemo((open = false) => {
if (route.data.type === "home") return true
if (!open) return false
const sessionID = current()
return sessionID !== undefined && !state().tabs.some((tab) => tab.sessionID === sessionID)
}, false)
const status = (sessionID: string) => {
const session = root(sessionID)
const members = data.session.family(session)
const family = members.length > 0 ? members : [session]
return {
unread: state().unread[session],
promptPulse: promptPulses()[session] ?? 0,
attention: family.some(
(id) => (data.session.permission.list(id)?.length ?? 0) > 0 || (data.session.form.list(id)?.length ?? 0) > 0,
),
@@ -114,7 +100,7 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
if (route.data.type !== "session" || route.data.sessionID === "dummy") return
const sessionID = root(route.data.sessionID)
history = recordSessionTabHistory(history, sessionID)
const title = data.session.get(sessionID)?.title ?? (newTab() ? NEW_SESSION_TAB_TITLE : undefined)
const title = data.session.get(sessionID)?.title
const tabs = openSessionTab(state().tabs, { sessionID, title })
if (tabs === state().tabs && !state().unread[sessionID]) return
update((draft) => {
@@ -190,14 +176,6 @@ 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")
@@ -205,8 +183,6 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
)
onCleanup(
event.on("session.deleted", (evt) => {
const target = root(evt.data.sessionID)
closedTabs = closedTabs.filter((entry) => entry.tab.sessionID !== target)
remove(evt.data.sessionID, enabled())
}),
)
@@ -225,12 +201,6 @@ 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" })
}
@@ -239,9 +209,6 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
tabs() {
return state().tabs
},
newTab() {
return newTab()
},
current,
status,
select(sessionID: string) {
@@ -252,28 +219,12 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
if (!enabled()) return
const target = sessionID ? root(sessionID) : current()
if (!target) {
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 })
const previous = state().tabs.at(-1)
if (route.data.type === "home" && previous) route.navigate({ type: "session", sessionID: previous.sessionID })
return
}
const index = state().tabs.findIndex((tab) => tab.sessionID === target)
const tab = state().tabs[index]
if (tab) closedTabs = recordClosedSessionTab(closedTabs, tab, index)
remove(target, true)
},
reopen() {
if (!enabled()) return
const result = reopenSessionTab(closedTabs, state().tabs)
closedTabs = result.stack
const tabs = result.tabs
if (!tabs || !result.sessionID) return
update((draft) => {
draft.tabs = tabs
})
route.navigate({ type: "session", sessionID: result.sessionID })
},
move(sessionID: string, index: number) {
if (!enabled()) return
const session = root(sessionID)
@@ -296,6 +247,12 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
)
if (tab) route.navigate({ type: "session", sessionID: tab.sessionID })
},
history(direction: 1 | -1) {
if (!enabled()) return
const next = moveSessionTabHistory(history, state().tabs, current(), direction)
history = next.history
if (next.sessionID) route.navigate({ type: "session", sessionID: next.sessionID })
},
selectIndex(index: number) {
if (!enabled()) return
const tab = state().tabs[index]
@@ -2,11 +2,7 @@ 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 {
EMPTY_SESSION_TAB_STATUS,
SessionTabs,
type SessionTabsController,
} from "../../../component/session-tabs"
import { SessionTabs, type SessionTabsController } from "../../../component/session-tabs"
import { moveSessionTab } from "../../../context/session-tabs-model"
import type { Story } from "./index"
@@ -27,6 +23,7 @@ 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
@@ -69,7 +66,7 @@ function SessionTabsStory(props: { context: Plugin.Context }) {
if (!resumed && roll < 0.25) {
setStatuses((current) => ({
...current,
[sessionID]: { ...(current[sessionID] ?? EMPTY_SESSION_TAB_STATUS), attention: true },
[sessionID]: { ...(current[sessionID] ?? EMPTY_STATUS), attention: true },
}))
setLastEvent(`tab ${number(sessionID)} needs input; select it to resolve`)
return
@@ -80,7 +77,7 @@ function SessionTabsStory(props: { context: Plugin.Context }) {
setOutcomes((current) => ({ ...current, [sessionID]: failed ? "failed" : "completed" }))
setStatuses((current) => ({
...current,
[sessionID]: { ...(current[sessionID] ?? EMPTY_SESSION_TAB_STATUS), busy: false, unread },
[sessionID]: { ...(current[sessionID] ?? EMPTY_STATUS), busy: false, unread },
}))
// An untitled session earns its title after its first completed run, like a real summarization.
const index = number(sessionID) - 1
@@ -113,7 +110,7 @@ function SessionTabsStory(props: { context: Plugin.Context }) {
tabs,
current: active,
status(sessionID) {
return statuses()[sessionID] ?? EMPTY_SESSION_TAB_STATUS
return statuses()[sessionID] ?? EMPTY_STATUS
},
select,
move(sessionID: string, index: number) {
@@ -153,7 +150,7 @@ function SessionTabsStory(props: { context: Plugin.Context }) {
const startRun = (sessionID: string) => {
setStatuses((current) => ({
...current,
[sessionID]: { ...(current[sessionID] ?? EMPTY_SESSION_TAB_STATUS), busy: true, unread: undefined },
[sessionID]: { ...(current[sessionID] ?? EMPTY_STATUS), busy: true, unread: undefined },
}))
setOutcomes((current) => {
const next = { ...current }
@@ -226,7 +223,7 @@ function SessionTabsStory(props: { context: Plugin.Context }) {
const selectedState = () => {
const current = active()
const status = current ? controller.status(current) : EMPTY_SESSION_TAB_STATUS
const status = current ? controller.status(current) : EMPTY_STATUS
const activity = status.busy
? "running"
: status.unread === "activity"
@@ -354,7 +351,7 @@ function SessionTabsStory(props: { context: Plugin.Context }) {
paddingRight={1}
flexDirection="row"
>
<text fg={elevatedTheme.text.subdued}>storybook / tabs</text>
<text fg={elevatedTheme.text.subdued}>storybook / session tabs</text>
<box flexGrow={1} />
<text fg={elevatedTheme.text.subdued}>
space/s run | t add | d close | r reset | / 1-0 move | drag reorders | esc back
@@ -366,6 +363,6 @@ function SessionTabsStory(props: { context: Plugin.Context }) {
export const sessionTabsStory: Story = {
id: "session-tabs",
title: "Tabs",
title: "Session tabs",
render: (context) => <SessionTabsStory context={context} />,
}
+3 -1
View File
@@ -32,6 +32,7 @@ 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[],
@@ -405,8 +406,9 @@ export function RunPermissionBody(props: {
</Show>
}
>
<diff
<PatchDiff
diff={info().diff!}
hunkFg={props.block.diffLineNumber}
view="unified"
filetype={ft()}
syntaxStyle={props.block.syntax}
+3 -1
View File
@@ -13,6 +13,7 @@ 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) {
@@ -178,8 +179,9 @@ export function RunEntryContent(props: {
</text>
{item.diff.trim() ? (
<box width="100%" paddingLeft={1}>
<diff
<PatchDiff
diff={item.diff}
hunkFg={theme().block.diffLineNumber}
view="unified"
filetype={toolFiletype(item.file)}
syntaxStyle={syntax()}
+1 -3
View File
@@ -18,8 +18,6 @@ export const builtins = [
SidebarFooter,
Notifications,
Plugins,
// The storybook is a development tool; keep its route and palette commands out of
// normal launches and register it only for OPENCODE_STORY runs.
...(process.env.OPENCODE_STORY ? [Storybook] : []),
Storybook,
DiffViewer,
]
+76 -260
View File
@@ -4,7 +4,6 @@ import {
createContext,
createEffect,
createMemo,
ErrorBoundary,
For,
on,
onCleanup,
@@ -14,12 +13,10 @@ import {
type ParentProps,
} from "solid-js"
import path from "path"
import { watch } from "fs"
import { stat } from "fs/promises"
import { fileURLToPath, pathToFileURL } from "url"
import type { Context, Dialog, Page, Slot, SlotMap, SlotName, Toast } from "@opencode-ai/plugin/tui/context"
import { createStore, produce, reconcile as reconcileStore } from "solid-js/store"
import { isDeepEqual } from "remeda"
import { useRenderer } from "@opentui/solid"
import "#runtime-plugin-support"
import { useConfig } from "../config"
@@ -41,13 +38,14 @@ import { useStorage } from "../context/storage"
import { useSessionTabs } from "../context/session-tabs"
import { abbreviateHome } from "../util/path-format"
import { builtins } from "./builtins"
import { discoverTuiPlugins, freshSpecifier, localSource, tuiPluginDirectory } from "./discovery"
import { discoverTuiPlugins } from "./discovery"
export interface PackageResolver {
readonly resolve: (spec: string) => Promise<string | undefined>
}
type State =
| { readonly target: string; readonly status: "loading" }
| { readonly target: string; readonly id: string; readonly status: "active" | "inactive" }
| { readonly target: string; readonly status: "unsupported" }
| { readonly target: string; readonly status: "failed"; readonly error: string }
@@ -63,7 +61,7 @@ type Value = {
readonly list: () => ReadonlyArray<State>
readonly registered: () => ReadonlyArray<RegisteredPlugin>
readonly route: (id: string, name: string) => Page["render"] | undefined
readonly slot: <Name extends SlotName>(name: Name) => ReadonlyArray<{ readonly id: string; readonly render: Slot<Name> }>
readonly slot: <Name extends SlotName>(name: Name) => ReadonlyArray<Slot<Name>>
readonly activate: (id: string) => Promise<boolean>
readonly deactivate: (id: string) => Promise<boolean>
}
@@ -72,8 +70,6 @@ type Dispose = () => Promise<void>
type Registration = {
plugin: Plugin.Definition
source: RegisteredPlugin["source"]
target?: string
version: string
options?: Readonly<Record<string, any>>
active: boolean
routes: Record<string, Page>
@@ -81,9 +77,6 @@ type Registration = {
cleanups: Dispose[]
}
// One entry of the desired plugin generation produced by the resolve phase.
type Desired = Pick<Registration, "plugin" | "source" | "target" | "version" | "options"> & { enabled: boolean }
const PluginContext = createContext<Value>()
export function PluginProvider(props: ParentProps<{ packages: PackageResolver }>) {
@@ -381,199 +374,85 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver }>
return true
}
// Hot-reload local plugin sources: watch the discovery directory and any
// local entrypoints, then rebuild the plugin generation when one changes.
// Watches are deduped by path and never torn down individually (a stale
// watch costs one fs handle and a no-op reconcile); all die with this
// provider. Failed watches leave the set so a later reconcile can retry
// once the path exists.
const watchers: ReturnType<typeof watch>[] = []
const watched = new Set<string>()
let disposed = false
let pending: ReturnType<typeof setTimeout> | undefined
const scheduleReconcile = () => {
clearTimeout(pending)
pending = setTimeout(() => {
loading = loading.catch(() => undefined).then(() => reconcile())
// Observe failures immediately: a plugin cleanup that throws would
// otherwise surface as an unhandled rejection until the next trigger.
void loading.catch(() => undefined)
}, 100)
}
const watchSource = (target: string) => {
if (watched.has(target)) return
watched.add(target)
stat(target)
.then((info) => {
if (disposed) return
// Watch the parent for files: editors that save by rename replace the
// inode, which silently kills a direct file watch after the first save.
const dir = info.isDirectory() ? target : path.dirname(target)
if (dir !== target && watched.has(dir)) return
watched.add(dir)
const watcher = watch(dir, scheduleReconcile)
// A watched directory can disappear out from under us; without a
// listener the error event would crash the process. Forget the paths
// so a later reconcile can re-arm once they exist again.
watcher.on("error", () => {
watcher.close()
watched.delete(dir)
watched.delete(target)
})
watchers.push(watcher)
const reconcile = async (configured = config.data.plugins ?? []) => {
await Promise.all(
Object.entries(store.registrations)
.filter(([, registration]) => registration.active)
.map(([id]) => deactivate(id)),
)
const entries = [...(await discoverTuiPlugins(paths.cwd)), ...configured]
batch(() => {
setStore("registrations", reconcileStore({}))
setStore("states", [])
})
for (const plugin of builtins) {
setStore("registrations", plugin.id, {
plugin,
source: "builtin",
active: false,
routes: {},
slots: {},
cleanups: [],
})
.catch(() => watched.delete(target))
}
onCleanup(() => {
disposed = true
clearTimeout(pending)
for (const watcher of watchers) watcher.close()
})
await activate(plugin.id)
}
// Rebuild the plugin generation as resolve → compare → swap, mirroring the
// core plugin registry: fold the ordered entries into a desired end state
// (importing only new or changed sources, before anything running is
// touched), no-op when the generation is unchanged, and restart only the
// plugins that differ. Membership or order changes rebuild the whole
// generation to preserve slot-order semantics.
// Package resolution failures would otherwise retry a full npm install on
// every watch event; remember them until the configuration changes.
const npmFailures = new Map<string, string>()
const reconcile = async (configured?: NonNullable<typeof config.data.plugins>) => {
if (configured) npmFailures.clear()
const entries = [...(await discoverTuiPlugins(paths.cwd)), ...(configured ?? config.data.plugins ?? [])]
watchSource(tuiPluginDirectory(paths.cwd))
// Resolve: fold entries into one desired generation. A source that fails
// to import keeps its running previous version and only reports failure.
const desired = new Map<string, Desired>()
for (const plugin of builtins) desired.set(plugin.id, { plugin, source: "builtin", version: "builtin", enabled: true })
const failures: State[] = []
for (const entry of entries) {
const target = typeof entry === "string" ? entry : entry.package
if (target.startsWith("-")) {
for (const item of desired.values()) if (matches(target.slice(1), item.plugin.id)) item.enabled = false
for (const id of Object.keys(store.registrations).filter((id) => matches(target.slice(1), id)))
await deactivate(id)
continue
}
const selected = [...desired.values()].filter((item) => matches(target, item.plugin.id))
const selected = Object.keys(store.registrations).filter((id) => matches(target, id))
if (selected.length || target === "*" || target.endsWith(".*") || target.startsWith("opencode.")) {
for (const item of selected) item.enabled = true
for (const id of selected) await activate(id)
continue
}
const options = typeof entry === "string" ? undefined : entry.options
// Watch even when the resolve below fails so fixing a broken plugin reloads it.
const local = localSource(target, directory)
if (local) watchSource(fileURLToPath(local))
const previous = Object.values(store.registrations).find((registration) => registration.target === target)
const memo = local ? undefined : npmFailures.get(target)
const resolved = memo
? { status: "failed" as const, error: memo }
: await resolvePlugin(target, local, options, previous, props.packages).catch((error) => ({
status: "failed" as const,
error: error instanceof Error ? error.message : String(error),
}))
if (resolved.status === "unsupported") {
failures.push({ target, status: "unsupported" })
continue
}
if (resolved.status === "failed") {
if (!local && !previous) npmFailures.set(target, resolved.error)
failures.push({
target,
status: "failed",
error: previous ? `${resolved.error} (previous version still active)` : resolved.error,
})
if (previous)
desired.set(previous.plugin.id, {
plugin: previous.plugin,
source: "external",
target,
version: previous.version,
options: previous.options,
enabled: true,
})
continue
}
desired.set(resolved.plugin.id, {
plugin: resolved.plugin,
source: "external",
target,
version: resolved.version,
options,
enabled: true,
setStore("states", (items) => [...items, { target, status: "loading" }])
const plugin = await loadPlugin(target, directory, props.packages).catch((error) => {
setStore("states", (items) =>
items.map((state) =>
state.target === target
? { target, status: "failed", error: error instanceof Error ? error.message : String(error) }
: state,
),
)
return undefined
})
}
// Compare: unchanged plugins are never touched, and a fully unchanged
// generation is a no-op, so spurious watch events cost nothing.
const currentIds = Object.keys(store.registrations)
const desiredIds = [...desired.keys()]
const structural = currentIds.length !== desiredIds.length || currentIds.some((id, index) => desiredIds[index] !== id)
if (structural) {
await Promise.all(
Object.entries(store.registrations)
.filter(([, registration]) => registration.active)
.map(([id]) => deactivate(id).catch(() => undefined)),
)
setStore("registrations", reconcileStore({}))
}
const changed = structural
? desiredIds
: desiredIds.filter((id) => {
const registration = store.registrations[id]!
const item = desired.get(id)!
return (
registration.version !== item.version ||
!sameOptions(registration.options, item.options) ||
registration.active !== item.enabled
)
})
// Swap: cleanup failures are logged into states, never propagated, so one
// broken plugin cannot take the rest of the generation down.
const errors = new Map<string, string>()
for (const id of changed) {
const item = desired.get(id)!
const registration = store.registrations[id]
if (!registration || registration.version !== item.version || !sameOptions(registration.options, item.options)) {
if (registration) await deactivate(id).catch(() => undefined)
// In-place replacement keeps the registration's key position, which
// slot ordering (mode "replace" takes the last one) depends on.
setStore("registrations", id, toRegistration(item))
}
if (!item.enabled) {
await deactivate(id).catch(() => undefined)
if (!plugin) {
setStore("states", (items) =>
items.map((state) =>
state.target === target && state.status !== "failed" ? { target, status: "unsupported" } : state,
),
)
continue
}
const error = await activate(id).then(
setStore("registrations", plugin.id, {
plugin,
source: "external",
options,
active: false,
routes: {},
slots: {},
cleanups: [],
})
const error = await activate(plugin.id).then(
() => undefined,
(error) => (error instanceof Error ? error.message : String(error)),
)
if (error) errors.set(id, error)
setStore("states", (items) => [
...items.filter((state) => state.target !== target && (!("id" in state) || state.id !== plugin.id)),
error
? { target, status: "failed", error }
: { target, id: plugin.id, status: "active" },
])
}
const states: State[] = [
...[...desired.values()].flatMap((item): State[] => {
if (item.target === undefined) return []
// A failed reload keeps this item running; the failure entry covers it.
if (failures.some((failure) => failure.target === item.target)) return []
const error = errors.get(item.plugin.id)
if (error) return [{ target: item.target, status: "failed", error }]
const status = store.registrations[item.plugin.id]?.active ? "active" : "inactive"
return [{ target: item.target, id: item.plugin.id, status }]
}),
...failures,
]
// Surface newly failing plugins; repeated reconciles stay silent.
for (const state of states)
if (
state.status === "failed" &&
!store.states.some((prev) => prev.status === "failed" && prev.target === state.target && prev.error === state.error)
)
toast.show({ variant: "error", title: "Plugin", message: `${state.target}: ${state.error}` })
setStore("states", reconcileStore(states))
}
let loading = Promise.resolve()
createEffect(
@@ -599,7 +478,7 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver }>
Promise.all(
Object.entries(store.registrations)
.filter(([, registration]) => registration.active)
.map(([id]) => deactivate(id).catch(() => undefined)),
.map(([id]) => deactivate(id)),
),
)
.then(() => setStore("registrations", reconcileStore({})))
@@ -621,8 +500,8 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver }>
Object.entries(store.registrations).map(([id, plugin]) => ({ id, source: plugin.source, active: plugin.active })),
route: (id, name) => store.registrations[id]?.routes[name]?.render,
slot: (name) =>
Object.entries(store.registrations).flatMap(([id, registration]) =>
registration.active && registration.slots[name] ? [{ id, render: registration.slots[name] }] : [],
Object.values(store.registrations).flatMap((registration) =>
registration.active && registration.slots[name] ? [registration.slots[name]] : [],
),
activate,
deactivate,
@@ -652,45 +531,17 @@ function matches(selector: string, id: string) {
return selector === "*" || selector === id || (selector.endsWith(".*") && id.startsWith(selector.slice(0, -1)))
}
async function resolvePlugin(
spec: string,
local: URL | undefined,
options: Readonly<Record<string, any>> | undefined,
previous: Registration | undefined,
packages: PackageResolver,
) {
// Package entrypoints never change within a session, so a loaded previous
// version needs no re-resolution (which could otherwise hit npm).
if (!local && previous && sameOptions(previous.options, options))
return { status: "unchanged" as const, plugin: previous.plugin, version: previous.version }
async function loadPlugin(spec: string, directory: string, packages: PackageResolver) {
const local = spec.startsWith("file://")
? new URL(spec)
: spec.startsWith("./") || spec.startsWith("../") || path.isAbsolute(spec)
? pathToFileURL(path.resolve(directory, spec))
: undefined
const entrypoint = local ? await resolveLocal(local) : await packages.resolve(spec)
if (!entrypoint) return { status: "unsupported" as const }
// The cache-busted specifier doubles as the version: unique per entrypoint
// and mtime, so equal versions mean an identical module.
const version = local ? freshSpecifier(entrypoint, (await stat(new URL(entrypoint))).mtimeMs) : entrypoint
if (previous && previous.version === version && sameOptions(previous.options, options))
return { status: "unchanged" as const, plugin: previous.plugin, version }
const mod: { readonly default?: unknown } = await import(version)
if (!entrypoint) return
const mod: { readonly default?: unknown } = await import(entrypoint)
if (!isPlugin(mod.default)) throw new Error(`Invalid V2 TUI plugin module: ${spec}`)
return { status: "loaded" as const, plugin: mod.default, version }
}
function toRegistration(item: Desired): Registration {
return {
plugin: item.plugin,
source: item.source,
target: item.target,
version: item.version,
options: item.options,
active: false,
routes: {},
slots: {},
cleanups: [],
}
}
function sameOptions(a: Registration["options"], b: Registration["options"]) {
return isDeepEqual(a ?? null, b ?? null)
return mod.default
}
async function resolveLocal(url: URL) {
@@ -726,43 +577,16 @@ export function usePlugin() {
return value
}
// Contain render-time plugin crashes: a throwing slot or route must not take
// down the app or the other plugins. The crash surfaces as one error toast.
function PluginBoundary(props: ParentProps<{ id: string; where: string }>) {
const toast = useToast()
return (
<ErrorBoundary
fallback={(error) => {
createEffect(() =>
toast.show({
variant: "error",
title: "Plugin",
message: `${props.id} crashed in ${props.where}: ${error instanceof Error ? error.message : String(error)}`,
}),
)
return null
}}
>
{props.children}
</ErrorBoundary>
)
}
export function PluginRoute(props: { readonly fallback: (id: string, name: string) => JSX.Element }) {
const plugins = usePlugin()
const route = useRoute()
const id = createMemo(() => (route.data.type === "plugin" ? route.data.id : ""))
const content = createMemo(() => {
if (route.data.type !== "plugin") return
const render = plugins.route(route.data.id, route.data.name)
if (!render) return props.fallback(route.data.id, route.data.name)
return render({ data: route.data.data })
})
return (
<PluginBoundary id={id()} where="route">
{content()}
</PluginBoundary>
)
return <>{content()}</>
}
export function PluginSlot<Name extends SlotName>(props: {
@@ -776,13 +600,5 @@ export function PluginSlot<Name extends SlotName>(props: {
if (props.mode === "replace") return items.slice(-1)
return items
})
return (
<For each={renderers()}>
{(item) => (
<PluginBoundary id={item.id} where={`slot ${props.name}`}>
{item.render(props.input)}
</PluginBoundary>
)}
</For>
)
return <For each={renderers()}>{(render) => render(props.input)}</For>
}
+1 -22
View File
@@ -1,15 +1,10 @@
import { readdir } from "node:fs/promises"
import path from "node:path"
import { fileURLToPath, pathToFileURL } from "node:url"
const extensions = new Set([".cjs", ".cts", ".js", ".jsx", ".mjs", ".mts", ".ts", ".tsx"])
export function tuiPluginDirectory(cwd: string) {
return path.join(cwd, ".opencode", "plugins", "tui")
}
export async function discoverTuiPlugins(cwd: string) {
const directory = tuiPluginDirectory(cwd)
const directory = path.join(cwd, ".opencode", "plugins", "tui")
const entries = await readdir(directory, { withFileTypes: true }).catch((error: unknown) => {
if (error && typeof error === "object" && Reflect.get(error, "code") === "ENOENT") return []
return Promise.reject(error)
@@ -19,19 +14,3 @@ export async function discoverTuiPlugins(cwd: string) {
.map((entry) => path.join(directory, entry.name))
.sort()
}
export function localSource(spec: string, directory: string) {
if (spec.startsWith("file://")) return new URL(spec)
if (spec.startsWith("./") || spec.startsWith("../") || path.isAbsolute(spec))
return pathToFileURL(path.resolve(directory, spec))
return undefined
}
// Key local plugin imports by mtime so edited sources re-import fresh instead
// of hitting the ESM cache. Bun ignores query params when caching file:// URL
// imports, so bust with a plain path there; Node keys its cache on the full
// URL. Mirrors the core plugin supervisor's loader.
export function freshSpecifier(entrypoint: string, mtime: number) {
if (typeof Bun !== "undefined") return `${fileURLToPath(entrypoint).replaceAll("\\", "/")}?mtime=${mtime}`
return `${entrypoint}?mtime=${mtime}`
}
@@ -16,7 +16,9 @@ export function ShellTab(props: { sessionID: string }) {
const composer = useComposerTab()
const shortcuts = Keymap.useShortcuts()
const entries = createMemo(() => data.shell.listBySession(props.sessionID).filter((shell) => shell.status === "running"))
const entries = createMemo(() =>
data.shell.list().filter((shell) => shell.metadata.sessionID === props.sessionID && shell.status === "running"),
)
const [store, setStore] = createStore({ selected: 0 })
let scroll: ScrollBoxRenderable | undefined
@@ -0,0 +1,26 @@
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()
},
},
]}
/>
)
}
@@ -0,0 +1,91 @@
import { createMemo, Match, onCleanup, onMount, Show, Switch } from "solid-js"
import { useTheme } from "../../context/theme"
import { useData } from "../../context/data"
import { useDirectory } from "../../context/directory"
import { useConnected } from "../../component/use-connected"
import { createStore } from "solid-js/store"
import { useRoute } from "../../context/route"
import { usePermission } from "../../context/permission"
export function Footer() {
const theme = useTheme()
const data = useData()
const route = useRoute()
const permission = usePermission()
const mcp = createMemo(
() => (data.location.mcp.server.list() ?? []).filter((x) => x.status.status === "connected").length,
)
const mcpError = createMemo(() => (data.location.mcp.server.list() ?? []).some((x) => x.status.status === "failed"))
const permissions = createMemo(() => {
if (route.data.type !== "session") return []
return data.session.permission.list(route.data.sessionID) ?? []
})
const directory = useDirectory()
const connected = useConnected()
const [store, setStore] = createStore({
welcome: false,
})
onMount(() => {
// Track all timeouts to ensure proper cleanup
const timeouts: ReturnType<typeof setTimeout>[] = []
function tick() {
if (connected()) return
if (!store.welcome) {
setStore("welcome", true)
timeouts.push(setTimeout(() => tick(), 5000))
return
}
if (store.welcome) {
setStore("welcome", false)
timeouts.push(setTimeout(() => tick(), 10_000))
return
}
}
timeouts.push(setTimeout(() => tick(), 10_000))
onCleanup(() => {
timeouts.forEach(clearTimeout)
})
})
return (
<box flexDirection="row" justifyContent="space-between" gap={1} flexShrink={0}>
<text fg={theme.text.subdued}>{directory()}</text>
<box gap={2} flexDirection="row" flexShrink={0}>
<Switch>
<Match when={store.welcome}>
<text fg={theme.text.default}>
Get started <span style={{ fg: theme.text.subdued }}>/connect</span>
</text>
</Match>
<Match when={connected()}>
<Show when={permission.mode !== "auto" && permissions().length > 0}>
<text fg={theme.text.feedback.warning.default}>
<span style={{ fg: theme.text.feedback.warning.default }}></span> {permissions().length} Permission
{permissions().length > 1 ? "s" : ""}
</text>
</Show>
<Show when={mcp()}>
<text fg={theme.text.default}>
<Switch>
<Match when={mcpError()}>
<span style={{ fg: theme.text.feedback.error.default }}> </span>
</Match>
<Match when={true}>
<span style={{ fg: theme.text.feedback.success.default }}> </span>
</Match>
</Switch>
{mcp()} MCP
</text>
</Show>
<text fg={theme.text.subdued}>/status</text>
</Match>
</Switch>
</box>
</box>
)
}
+5 -31
View File
@@ -22,6 +22,7 @@ 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"
@@ -51,7 +52,6 @@ import { useClient } from "../../context/client"
import { useEditorContext } from "../../context/editor"
import { openEditor } from "../../editor"
import { useDialog } from "../../ui/dialog"
import { DialogConfirm } from "../../ui/dialog-confirm"
import { DialogSessionRename } from "../../component/dialog-session-rename"
import { DialogMessage } from "./dialog-message"
import { DialogFork } from "./dialog-fork"
@@ -80,7 +80,6 @@ import { collapseToolOutput } from "../../util/collapse-tool-output"
import { Keymap, type KeymapCommand } from "../../context/keymap"
import { usePathFormatter } from "../../context/path-format"
import { useLocation } from "../../context/location"
import { PluginSlot } from "../../plugin/context"
import {
cacheReuseDrop,
createSessionRows,
@@ -523,32 +522,6 @@ export function Session() {
slash: { name: "rename" },
run: () => DialogSessionRename.show(dialog, route.sessionID, session()?.title),
},
{
title: "Delete session",
id: "session.delete",
group: "Session",
slash: { name: "delete" },
run: async () => {
const current = session()
if (!current) return
const confirmed = await DialogConfirm.show(
dialog,
"Delete Session",
`Delete "${current.title}"? This action cannot be undone.`,
)
if (confirmed !== true) return
const error = await client.api.session.remove({ sessionID: route.sessionID }).then(
() => undefined,
(error) => error,
)
if (!error) return
toast.show({
message: `Failed to delete session: ${errorMessage(error)}`,
variant: "error",
duration: 5000,
})
},
},
{
title: "Jump to message",
id: "session.timeline",
@@ -1030,7 +1003,6 @@ export function Session() {
</Show>
</scrollbox>
<box flexShrink={0}>
<PluginSlot name="session.composer.top" input={{ sessionID: route.sessionID }} mode="all" />
<Composer
sessionID={route.sessionID}
open={composer.open || (!!session()?.parentID && forms().length === 0)}
@@ -3056,8 +3028,9 @@ function Edit(props: ToolProps) {
{(item) => (
<BlockTool path={{ label: "← Edit", value: pathFormatter.format(path()) }} part={props.part}>
<box paddingLeft={1}>
<diff
<PatchDiff
diff={item().patch}
hunkFg={theme.diff.text.hunkHeader}
view={view()}
filetype={filetype(path())}
syntaxStyle={syntax()}
@@ -3145,8 +3118,9 @@ function ApplyPatch(props: ToolProps) {
}
>
<box paddingLeft={1}>
<diff
<PatchDiff
diff={file.patch}
hunkFg={theme.diff.text.hunkHeader}
view={view()}
filetype={filetype(file.relativePath)}
syntaxStyle={syntax()}
@@ -14,6 +14,7 @@ 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"
@@ -50,8 +51,9 @@ function EditBody(props: { file?: string; diff?: string; patch?: string }) {
},
}}
>
<diff
<PatchDiff
diff={diff()}
hunkFg={theme.diff.text.hunkHeader}
view={view()}
filetype={ft()}
syntaxStyle={syntax()}
@@ -0,0 +1,115 @@
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>
)
}
+24 -32
View File
@@ -1,10 +1,10 @@
import { CliRenderEvents, InputRenderable, RGBA, ScrollBoxRenderable, TextAttributes } from "@opentui/core"
import { InputRenderable, RGBA, ScrollBoxRenderable, TextAttributes } from "@opentui/core"
import { Keymap, type KeymapCommand } from "../context/keymap"
import { useTheme, useThemes } from "../context/theme"
import { entries, filter, flatMap, groupBy, pipe } from "remeda"
import { batch, createEffect, createMemo, createSignal, For, Show, type JSX, on, onCleanup } from "solid-js"
import { createStore } from "solid-js/store"
import { useRenderer, useTerminalDimensions } from "@opentui/solid"
import { useTerminalDimensions } from "@opentui/solid"
import * as fuzzysort from "fuzzysort"
import { isDeepEqual } from "remeda"
import { useDialog, type DialogContext } from "./dialog"
@@ -100,7 +100,6 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
const mode = themes.mode
const config = useConfig().data
const scrollAcceleration = createMemo(() => getScrollAcceleration(config))
const renderer = useRenderer()
const [store, setStore] = createStore({
selected: 0,
@@ -111,18 +110,7 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
const actionFocused = createMemo(() => focusedAction() !== undefined)
let selection: { value: T; category?: string } | undefined
let resetSelection = false
let pendingScroll: (() => void) | undefined
function scrollAfterLayout(center: boolean, value: T) {
if (pendingScroll) renderer.off(CliRenderEvents.FRAME, pendingScroll)
pendingScroll = () => {
pendingScroll = undefined
if (!isDeepEqual(selected()?.value, value)) return
scrollToSelection(center)
}
renderer.once(CliRenderEvents.FRAME, pendingScroll)
renderer.requestRender()
}
let visibilityGeneration = 0
createEffect(
on(
@@ -276,8 +264,16 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
setStore("selected", index)
selection = option
if (!moved) return
if (!props.preserveSelection || store.filter.length > 0) return
scrollAfterLayout(false, option.value)
const value = option.value
const generation = ++visibilityGeneration
requestAnimationFrame(() => {
requestAnimationFrame(() => {
if (generation !== visibilityGeneration) return
if (!props.preserveSelection || store.filter.length > 0) return
if (!isDeepEqual(selected()?.value, value)) return
scrollToSelection(false)
})
})
return
}
const next = reconcileSelection(store.selected, flat().length)
@@ -288,26 +284,22 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
),
)
onCleanup(() => {
if (!pendingScroll) return
renderer.off(CliRenderEvents.FRAME, pendingScroll)
pendingScroll = undefined
visibilityGeneration++
})
createEffect(
on([() => store.filter, () => props.current], ([filter, current]) => {
if (filter.length > 0) resetSelection = true
if (filter.length > 0) {
const option = flat()[0]
if (!option) return
moveTo(0, true, false)
scrollAfterLayout(true, option.value)
return
}
if (!current || props.focusCurrent === false) return
const currentIndex = flat().findIndex((opt) => isDeepEqual(opt.value, current))
if (currentIndex < 0) return
moveTo(currentIndex, true)
scrollAfterLayout(true, current)
setTimeout(() => {
if (filter.length > 0) {
moveTo(0, true, false)
} else if (current && props.focusCurrent !== false) {
const currentIndex = flat().findIndex((opt) => isDeepEqual(opt.value, current))
if (currentIndex >= 0) {
moveTo(currentIndex, true)
}
}
}, 0)
}),
)
+3 -8
View File
@@ -62,14 +62,9 @@ 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 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)
const next = stringWidth(segments[index]!) + separatorWidth
if (width + next > maxWidth) break
selected.unshift(segments[index]!)
width += next
}
return prefix + selected.join(separator)
+56
View File
@@ -0,0 +1,56 @@
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
}
@@ -73,12 +73,16 @@ test("searches settings globally and opens the matching setting", async () => {
expect(app.captureCharFrame()).not.toContain("Animations")
await app.waitFor(() => app.renderer.currentFocusedEditor instanceof InputRenderable)
app.mockInput.pressArrow("down")
for (const key of "sounds") app.mockInput.pressKey(key)
for (const key of "side") app.mockInput.pressKey(key)
await app.waitForFrame((frame) => frame.includes("Sidebar"))
expect(app.captureCharFrame()).not.toContain("New session")
expect(app.captureCharFrame()).not.toContain("Switch model")
expect(app.captureCharFrame()).not.toContain("Markdown")
app.mockInput.pressEnter()
await app.waitForFrame((frame) => frame.includes("Settings") && frame.includes("Sounds"))
await app.waitForFrame((frame) => frame.includes("Settings") && frame.includes("Color mode"))
app.mockInput.pressEnter()
await app.waitFor(() => current.attention?.sound === false)
await app.waitFor(() => current.session?.sidebar === "hide")
} finally {
app.renderer.destroy()
}
@@ -1,10 +1,6 @@
/** @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,
@@ -12,50 +8,6 @@ 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)
+4 -4
View File
@@ -106,10 +106,10 @@ test("opens the subagent picker with down", () => {
test("navigates session tabs with leader arrows", () => {
const config = resolve({}, { terminalSuspend: true })
expect(config.keybinds.get("session.tab.next")).toMatchObject([{ key: "ctrl+tab,<leader>right,alt+shift+]" }])
expect(config.keybinds.get("session.tab.previous")).toMatchObject([
{ key: "ctrl+shift+tab,<leader>left,alt+shift+[" },
])
expect(config.keybinds.get("session.tab.next")).toMatchObject([{ key: "ctrl+tab,<leader>right" }])
expect(config.keybinds.get("session.tab.previous")).toMatchObject([{ key: "ctrl+shift+tab,<leader>left" }])
expect(config.keybinds.get("session.tab.history.back")).toMatchObject([{ key: "ctrl+o" }])
expect(config.keybinds.get("session.tab.history.forward")).toMatchObject([{ key: "ctrl+i" }])
expect(config.keybinds.get("session.tab.next_unread")).toMatchObject([{ key: "<leader>down" }])
expect(config.keybinds.get("session.tab.previous_unread")).toMatchObject([{ key: "<leader>up" }])
})
@@ -6,9 +6,7 @@ import {
moveSessionTab,
moveSessionTabHistory,
openSessionTab,
recordClosedSessionTab,
recordSessionTabHistory,
reopenSessionTab,
seedSessionTabMotion,
sessionTabComplete,
sessionTabOverflowWidth,
@@ -138,45 +136,6 @@ describe("session tabs", () => {
expect(moveSessionTabHistory(current, closed.tabs, "b", -1).sessionID).toBe("c")
})
test("reopens the most recently closed tab at its original position", () => {
const tabs = ["a", "b", "c"].map((sessionID) => ({ sessionID }))
const stack = recordClosedSessionTab([], { sessionID: "b", title: "Middle" }, 1)
const reopened = reopenSessionTab(stack, [{ sessionID: "a" }, { sessionID: "c" }])
expect(reopened.sessionID).toBe("b")
expect(reopened.tabs).toEqual([{ sessionID: "a" }, { sessionID: "b", title: "Middle" }, { sessionID: "c" }])
expect(reopened.stack).toEqual([])
expect(reopenSessionTab([], tabs)).toEqual({ stack: [], tabs: undefined, sessionID: undefined })
})
test("skips and consumes closed entries that are already open", () => {
const stack = [
{ tab: { sessionID: "a" }, index: 0 },
{ tab: { sessionID: "b" }, index: 1 },
]
const reopened = reopenSessionTab(stack, [{ sessionID: "b" }])
expect(reopened.sessionID).toBe("a")
expect(reopened.tabs).toEqual([{ sessionID: "a" }, { sessionID: "b" }])
expect(reopened.stack).toEqual([])
})
test("clamps restored positions and keeps one entry per session", () => {
const twice = recordClosedSessionTab(recordClosedSessionTab([], { sessionID: "a" }, 5), { sessionID: "a" }, 2)
expect(twice).toEqual([{ tab: { sessionID: "a" }, index: 2 }])
const reopened = reopenSessionTab(twice, [{ sessionID: "b" }])
expect(reopened.tabs).toEqual([{ sessionID: "b" }, { sessionID: "a" }])
const overflow = Array.from({ length: 12 }, (_, index) => ({ sessionID: String(index) })).reduce(
(stack, tab, index) => recordClosedSessionTab(stack, tab, index),
twice,
)
expect(overflow).toHaveLength(10)
expect(overflow.at(-1)?.tab.sessionID).toBe("11")
expect(overflow[0]?.tab.sessionID).toBe("2")
})
test("reveals completion activity only after session work becomes idle", () => {
expect(sessionTabComplete("activity", true)).toBe(false)
expect(sessionTabComplete("activity", false)).toBe(true)
@@ -192,12 +151,12 @@ describe("session tabs", () => {
expect(layout.widths.reduce((total, width) => total + width, 0)).toBe(76)
})
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)
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)
expect(layout.tabs).toEqual(tabs)
expect(layout.widths).toEqual([8, 8, 8, 8, 22])
expect(layout.widths).toEqual([8, 8, 8, 8, 8])
expect(layout.widths.reduce((total, width) => total + width, 0)).toBe(layout.total)
})
@@ -1,156 +0,0 @@
/** @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 { NEW_SESSION_TAB_TITLE } from "../../src/context/session-tabs-model"
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" })
expect(setup.tabs.newTab()).toBe(true)
await wait(
() => setup.tabs.current() === "third" && setup.tabs.tabs().some((tab) => tab.sessionID === "third"),
)
expect(setup.tabs.newTab()).toBe(false)
expect(setup.tabs.tabs().find((tab) => tab.sessionID === "third")?.title).toBe(NEW_SESSION_TAB_TITLE)
} finally {
setup.destroy()
}
})
@@ -1,157 +0,0 @@
import { expect, mock, test } from "bun:test"
import { createTestRenderer } from "@opentui/core/testing"
import { Effect, FileSystem } from "effect"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { Global } from "@opencode-ai/util/global"
import { mkdir, readFile, writeFile } from "node:fs/promises"
import path from "node:path"
import { createEventStream, createFetch } from "./fixture/tui-client"
import { tmpdir } from "./fixture/fixture"
function lifecycleSource(marker: string, id: string, version: string) {
return `
import { appendFile } from "node:fs/promises"
export default {
id: ${JSON.stringify(id)},
setup: async () => {
await appendFile(${JSON.stringify(marker)}, "${version}:setup\\n")
return () => appendFile(${JSON.stringify(marker)}, "${version}:cleanup\\n")
},
}
`
}
async function until(read: () => Promise<string>, expected: (value: string | undefined) => boolean) {
let value: string | undefined
for (let attempt = 0; attempt < 200; attempt++) {
value = await read().catch(() => undefined)
if (expected(value)) return value
await new Promise((resolve) => setTimeout(resolve, 50))
}
return value
}
async function bootApp(directory: string) {
const setup = await createTestRenderer({ width: 80, height: 24, useThread: false })
const core = await import("@opentui/core")
mock.module("@opentui/core", () => ({ ...core, createCliRenderer: async () => setup.renderer }))
const events = createEventStream()
const calls = createFetch(undefined, events)
const server = Bun.serve({ port: 0, fetch: (request) => calls.fetch(request) })
const cwd = process.cwd()
process.chdir(directory)
const { run } = await import("../src/app")
const task = Effect.runPromise(
run({
app: { name: "test", version: "test", channel: "test" },
server: { endpoint: { url: server.url.toString() } },
config: { get: async () => ({}), update: async () => ({}) },
packages: { resolve: async () => undefined },
args: {},
log: () => {},
}).pipe(Effect.provide(AppNodeBuilder.build(Global.node)), Effect.provide(FileSystem.layerNoop({}))),
)
return {
task,
async [Symbol.asyncDispose]() {
process.chdir(cwd)
if (!setup.renderer.isDestroyed) setup.renderer.destroy()
await server.stop()
mock.restore()
},
}
}
test("editing a discovered TUI plugin hot-reloads its fresh module", async () => {
await using tmp = await tmpdir()
const directory = path.join(tmp.path, ".opencode", "plugins", "tui")
await mkdir(directory, { recursive: true })
const marker = path.join(tmp.path, "marker.txt")
const source = path.join(directory, "hot.ts")
await writeFile(source, lifecycleSource(marker, "test.hot", "v1"))
await using app = await bootApp(tmp.path)
const read = () => readFile(marker, "utf8")
expect(await until(read, (value) => value === "v1:setup\n")).toBe("v1:setup\n")
await writeFile(source, lifecycleSource(marker, "test.hot", "v2"))
expect(await until(read, (value) => value?.includes("v2:setup") ?? false)).toBe("v1:setup\nv1:cleanup\nv2:setup\n")
process.emit("SIGHUP")
await app.task
})
test("a plugin whose slot render throws does not take down the TUI", async () => {
await using tmp = await tmpdir()
const directory = path.join(tmp.path, ".opencode", "plugins", "tui")
await mkdir(directory, { recursive: true })
const markerA = path.join(tmp.path, "a.txt")
const markerCrash = path.join(tmp.path, "crash.txt")
const sourceA = path.join(directory, "a.ts")
await writeFile(sourceA, lifecycleSource(markerA, "test.a", "a1"))
await writeFile(
path.join(directory, "crash.ts"),
`
import { appendFile } from "node:fs/promises"
export default {
id: "test.crash",
setup: async (context: any) => {
context.ui.slot("home.footer", () => {
throw new Error("boom")
})
await appendFile(${JSON.stringify(markerCrash)}, "setup\\n")
},
}
`,
)
await using app = await bootApp(tmp.path)
const readA = () => readFile(markerA, "utf8")
await until(readA, (value) => value === "a1:setup\n")
await until(() => readFile(markerCrash, "utf8"), (value) => value === "setup\n")
// The app survives the crashing slot: hot reload still works for others.
await writeFile(sourceA, lifecycleSource(markerA, "test.a", "a2"))
expect(await until(readA, (value) => value?.includes("a2:setup") ?? false)).toBe("a1:setup\na1:cleanup\na2:setup\n")
process.emit("SIGHUP")
await app.task
})
test("editing one plugin leaves others untouched and a broken save keeps the last good version", async () => {
await using tmp = await tmpdir()
const directory = path.join(tmp.path, ".opencode", "plugins", "tui")
await mkdir(directory, { recursive: true })
const markerA = path.join(tmp.path, "a.txt")
const markerB = path.join(tmp.path, "b.txt")
const sourceB = path.join(directory, "b.ts")
await writeFile(path.join(directory, "a.ts"), lifecycleSource(markerA, "test.a", "a1"))
await writeFile(sourceB, lifecycleSource(markerB, "test.b", "b1"))
await using app = await bootApp(tmp.path)
const readA = () => readFile(markerA, "utf8")
const readB = () => readFile(markerB, "utf8")
await until(readA, (value) => value === "a1:setup\n")
await until(readB, (value) => value === "b1:setup\n")
// Editing B restarts only B: A sees no cleanup and no second setup.
await writeFile(sourceB, lifecycleSource(markerB, "test.b", "b2"))
expect(await until(readB, (value) => value?.includes("b2:setup") ?? false)).toBe("b1:setup\nb1:cleanup\nb2:setup\n")
expect(await readA()).toBe("a1:setup\n")
// A broken save keeps the last good version running: b2 is never cleaned up.
await writeFile(sourceB, "export default {")
await new Promise((resolve) => setTimeout(resolve, 800))
expect(await readB()).toBe("b1:setup\nb1:cleanup\nb2:setup\n")
expect(await readA()).toBe("a1:setup\n")
// Fixing the file replaces the kept version.
await writeFile(sourceB, lifecycleSource(markerB, "test.b", "b3"))
expect(await until(readB, (value) => value?.includes("b3:setup") ?? false)).toBe(
"b1:setup\nb1:cleanup\nb2:setup\nb2:cleanup\nb3:setup\n",
)
expect(await readA()).toBe("a1:setup\n")
process.emit("SIGHUP")
await app.task
})
-30
View File
@@ -1,30 +0,0 @@
import { writeFile } from "node:fs/promises"
import path from "node:path"
import { pathToFileURL } from "node:url"
import { expect, test } from "bun:test"
import { freshSpecifier, localSource } from "../src/plugin/discovery"
import { tmpdir } from "./fixture/fixture"
test("localSource resolves file URLs and local paths but not package specs", () => {
const base = process.cwd()
const absolute = path.resolve(base, "abs", "plugin.ts")
expect(localSource("file:///tmp/plugin.ts", base)?.href).toBe("file:///tmp/plugin.ts")
expect(localSource("./plugin.ts", base)?.href).toBe(pathToFileURL(path.join(base, "plugin.ts")).href)
expect(localSource("../plugin.ts", path.join(base, "nested"))?.href).toBe(
pathToFileURL(path.join(base, "plugin.ts")).href,
)
expect(localSource(absolute, base)?.href).toBe(pathToFileURL(absolute).href)
expect(localSource("some-package", base)).toBeUndefined()
expect(localSource("@scope/some-package", base)).toBeUndefined()
})
test("freshSpecifier re-imports a plugin source after it changes", async () => {
await using tmp = await tmpdir()
const file = path.join(tmp.path, "plugin.ts")
await writeFile(file, "export default 1")
const first: { readonly default?: unknown } = await import(freshSpecifier(pathToFileURL(file).href, 1))
await writeFile(file, "export default 2")
const second: { readonly default?: unknown } = await import(freshSpecifier(pathToFileURL(file).href, 2))
expect(first.default).toBe(1)
expect(second.default).toBe(2)
})
-5
View File
@@ -14,11 +14,6 @@ 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")
+17 -17
View File
@@ -28,13 +28,6 @@ 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>
@@ -47,7 +40,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: UpOptions) => Effect.Effect<string[], Error>
readonly up: (options: { targets: string[]; start: string; stop?: string }) => 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
@@ -160,16 +153,27 @@ export namespace FSUtil {
})
})
const up = Effect.fn("FileSystem.up")(function* (options: UpOptions) {
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 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 (options.mode === "first") return result
}
if (yield* fs.exists(search)) result.push(search)
}
if (options.stop === current) break
const parent = dirname(current)
@@ -179,10 +183,6 @@ 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,15 +141,6 @@ 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?.();
}
@@ -527,19 +518,10 @@ 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, isJSONRPCErrorResponse, isJSONRPCRequest, isJSONRPCResultResponse, JSONRPCMessageSchema } from '../types.js';
+import { isInitializedNotification, isInitializeRequest, 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?.();
}