mirror of
https://github.com/anomalyco/opencode.git
synced 2026-07-28 22:21:25 -04:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 51cc99d970 |
@@ -1,16 +0,0 @@
|
||||
import type { Context } from "../../../packages/plugin/src/tui/context"
|
||||
|
||||
export default {
|
||||
id: "test.tui-discovery-smoke",
|
||||
setup(context: Context) {
|
||||
const timer = setTimeout(() => {
|
||||
context.ui.toast.show({
|
||||
title: "TUI plugin discovery works",
|
||||
message: "Loaded .opencode/plugins/tui/discovery-smoke.ts",
|
||||
variant: "success",
|
||||
duration: 30_000,
|
||||
})
|
||||
}, 1_000)
|
||||
return () => clearTimeout(timer)
|
||||
},
|
||||
}
|
||||
@@ -147,11 +147,6 @@
|
||||
"ws": "8.21.0",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@ff-labs/fff-bin-darwin-arm64": "0.10.1",
|
||||
"@ff-labs/fff-bin-linux-arm64-gnu": "0.10.1",
|
||||
"@ff-labs/fff-bin-linux-x64-gnu": "0.10.1",
|
||||
"@ff-labs/fff-bin-win32-arm64": "0.10.1",
|
||||
"@ff-labs/fff-bin-win32-x64": "0.10.1",
|
||||
"@lydell/node-pty-darwin-arm64": "1.2.0-beta.12",
|
||||
"@lydell/node-pty-darwin-x64": "1.2.0-beta.12",
|
||||
"@lydell/node-pty-linux-arm64": "1.2.0-beta.12",
|
||||
@@ -169,11 +164,6 @@
|
||||
"@types/bun": "catalog:",
|
||||
"@types/semver": "catalog:",
|
||||
"@typescript/native-preview": "catalog:",
|
||||
"@yuuang/ffi-rs-darwin-arm64": "1.3.2",
|
||||
"@yuuang/ffi-rs-linux-arm64-gnu": "1.3.2",
|
||||
"@yuuang/ffi-rs-linux-x64-gnu": "1.3.2",
|
||||
"@yuuang/ffi-rs-win32-arm64-msvc": "1.3.2",
|
||||
"@yuuang/ffi-rs-win32-x64-msvc": "1.3.2",
|
||||
"vite": "catalog:",
|
||||
"vite-plugin-solid": "catalog:",
|
||||
},
|
||||
@@ -374,7 +364,6 @@
|
||||
"@effect/platform-node": "catalog:",
|
||||
"@effect/sql-sqlite-bun": "catalog:",
|
||||
"@ff-labs/fff-bun": "0.10.1",
|
||||
"@ff-labs/fff-node": "0.10.1",
|
||||
"@lydell/node-pty": "catalog:",
|
||||
"@modelcontextprotocol/sdk": "1.29.0",
|
||||
"@opencode-ai/ai": "workspace:*",
|
||||
@@ -607,20 +596,17 @@
|
||||
"@tsconfig/node22": "catalog:",
|
||||
"@types/node": "catalog:",
|
||||
"@typescript/native-preview": "catalog:",
|
||||
"solid-js": "catalog:",
|
||||
"typescript": "catalog:",
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@opentui/core": ">=0.4.5",
|
||||
"@opentui/keymap": ">=0.4.5",
|
||||
"@opentui/solid": ">=0.4.5",
|
||||
"solid-js": ">=1.9.0",
|
||||
},
|
||||
"optionalPeers": [
|
||||
"@opentui/core",
|
||||
"@opentui/keymap",
|
||||
"@opentui/solid",
|
||||
"solid-js",
|
||||
],
|
||||
},
|
||||
"packages/protocol": {
|
||||
@@ -873,20 +859,6 @@
|
||||
"vite": "catalog:",
|
||||
},
|
||||
},
|
||||
"packages/theme": {
|
||||
"name": "@opencode-ai/theme",
|
||||
"version": "0.0.0",
|
||||
"dependencies": {
|
||||
"@opentui/core": "catalog:",
|
||||
"effect": "catalog:",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tsconfig/bun": "catalog:",
|
||||
"@types/bun": "catalog:",
|
||||
"@typescript/native-preview": "catalog:",
|
||||
"typescript": "catalog:",
|
||||
},
|
||||
},
|
||||
"packages/tui": {
|
||||
"name": "@opencode-ai/tui",
|
||||
"version": "1.18.4",
|
||||
@@ -896,7 +868,6 @@
|
||||
"@opencode-ai/plugin": "workspace:*",
|
||||
"@opencode-ai/schema": "workspace:*",
|
||||
"@opencode-ai/simulation": "workspace:*",
|
||||
"@opencode-ai/theme": "workspace:*",
|
||||
"@opencode-ai/ui": "workspace:*",
|
||||
"@opencode-ai/util": "workspace:*",
|
||||
"@opentui/core": "catalog:",
|
||||
@@ -1059,7 +1030,6 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@astrojs/cloudflare": "14.1.4",
|
||||
"@opencode-ai/theme": "workspace:*",
|
||||
"@types/bun": "catalog:",
|
||||
"astro": "7.1.3",
|
||||
"effect": "catalog:",
|
||||
@@ -1707,8 +1677,6 @@
|
||||
|
||||
"@ff-labs/fff-bun": ["@ff-labs/fff-bun@0.10.1", "", { "optionalDependencies": { "@ff-labs/fff-bin-android-arm64": "0.10.1", "@ff-labs/fff-bin-darwin-arm64": "0.10.1", "@ff-labs/fff-bin-darwin-x64": "0.10.1", "@ff-labs/fff-bin-linux-arm64-gnu": "0.10.1", "@ff-labs/fff-bin-linux-arm64-musl": "0.10.1", "@ff-labs/fff-bin-linux-x64-gnu": "0.10.1", "@ff-labs/fff-bin-linux-x64-musl": "0.10.1", "@ff-labs/fff-bin-win32-arm64": "0.10.1", "@ff-labs/fff-bin-win32-x64": "0.10.1" }, "os": [ "!aix", "!sunos", "!freebsd", "!openbsd", ], "cpu": [ "x64", "arm64", ] }, "sha512-9oUCxypGbf2q3vNfKZ31wdzt5KqjhA9S6TwQaFol/j1lkSHVGvtIa3RvdGHPs1UUuvR/MO7b6pHj054BR9bXPQ=="],
|
||||
|
||||
"@ff-labs/fff-node": ["@ff-labs/fff-node@0.10.1", "", { "dependencies": { "ffi-rs": "^1.0.0" }, "optionalDependencies": { "@ff-labs/fff-bin-android-arm64": "0.10.1", "@ff-labs/fff-bin-darwin-arm64": "0.10.1", "@ff-labs/fff-bin-darwin-x64": "0.10.1", "@ff-labs/fff-bin-linux-arm64-gnu": "0.10.1", "@ff-labs/fff-bin-linux-arm64-musl": "0.10.1", "@ff-labs/fff-bin-linux-x64-gnu": "0.10.1", "@ff-labs/fff-bin-linux-x64-musl": "0.10.1", "@ff-labs/fff-bin-win32-arm64": "0.10.1", "@ff-labs/fff-bin-win32-x64": "0.10.1" }, "os": [ "!aix", "!sunos", "!freebsd", "!openbsd", ], "cpu": [ "x64", "arm64", ] }, "sha512-I2TIWHkey4wpLCncUQymBz/yWrjp/TE91yjrPvSLaPMUMRf3wXwEmelGNnZ5ynxy3cUauYg5b5hsUwhOK4skfA=="],
|
||||
|
||||
"@floating-ui/core": ["@floating-ui/core@1.8.0", "", { "dependencies": { "@floating-ui/utils": "^0.2.12" } }, "sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ=="],
|
||||
|
||||
"@floating-ui/dom": ["@floating-ui/dom@1.8.0", "", { "dependencies": { "@floating-ui/core": "^1.8.0", "@floating-ui/utils": "^0.2.12" } }, "sha512-yXSrzeHZBTZadLOlfyhCkJHNeLJnHRnRInwdZ40L7ZiaAtrBwoYlsDrX3v5zB1Utk7CLfzcOVnVVWoXEky7Ceg=="],
|
||||
@@ -2107,8 +2075,6 @@
|
||||
|
||||
"@opencode-ai/storybook": ["@opencode-ai/storybook@workspace:packages/storybook"],
|
||||
|
||||
"@opencode-ai/theme": ["@opencode-ai/theme@workspace:packages/theme"],
|
||||
|
||||
"@opencode-ai/tui": ["@opencode-ai/tui@workspace:packages/tui"],
|
||||
|
||||
"@opencode-ai/ui": ["@opencode-ai/ui@workspace:packages/ui"],
|
||||
@@ -3285,28 +3251,6 @@
|
||||
|
||||
"@xmldom/xmldom": ["@xmldom/xmldom@0.8.13", "", {}, "sha512-KRYzxepc14G/CEpEGc3Yn+JKaAeT63smlDr+vjB8jRfgTBBI9wRj/nkQEO+ucV8p8I9bfKLWp37uHgFrbntPvw=="],
|
||||
|
||||
"@yuuang/ffi-rs-android-arm64": ["@yuuang/ffi-rs-android-arm64@1.3.2", "", { "os": "android", "cpu": "arm64" }, "sha512-eDYLT0kVBkp7e2BwdRDmt6N1rkeDPUHDefk3ZX0/nok+GLsqfy1WBoSL3Yg7HVXN1EyW8OBVc2uK8Zq8HbmaSA=="],
|
||||
|
||||
"@yuuang/ffi-rs-darwin-arm64": ["@yuuang/ffi-rs-darwin-arm64@1.3.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-kRdgPaOM6TfuC5wHUwstlatk4HNie2lwSLJWQL2LiAUIJ7+96CoiWUNVhwBcFrhdfxhnWenYS6F668CV0vit8Q=="],
|
||||
|
||||
"@yuuang/ffi-rs-darwin-x64": ["@yuuang/ffi-rs-darwin-x64@1.3.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-O3AlVgre8FQcZRJe44Xs7A6iDLumoPXqbw40+eJCa2gyXaXyLPdHoWrS1W9rBCa1QZRRnG7zRulPVFw8C5uo8g=="],
|
||||
|
||||
"@yuuang/ffi-rs-linux-arm-gnueabihf": ["@yuuang/ffi-rs-linux-arm-gnueabihf@1.3.2", "", { "os": "linux", "cpu": "arm" }, "sha512-IXiNdTbIcTCPny5eeElijFWYeKSJjQWSjt9ZyJNdLHYiB1Np+XD6K7wNZS6EOMgMelhW1kQE62T654skGkVDIA=="],
|
||||
|
||||
"@yuuang/ffi-rs-linux-arm64-gnu": ["@yuuang/ffi-rs-linux-arm64-gnu@1.3.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-gWFO6xufUK9lPYUqDvKa6IR243dPqdetgl9Q7HrZWaDu7wLo06QQrosw8QTzndafQnOcBKm6LoLujmGCfTgJOA=="],
|
||||
|
||||
"@yuuang/ffi-rs-linux-arm64-musl": ["@yuuang/ffi-rs-linux-arm64-musl@1.3.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-lejvOSqypPziQH5rzfkDlJ6e92qhWbDutE9ttOO6z5I2k83zoh9iZhZWhaXSU5VqgQpcshRkrbtXb9gy1ft5dA=="],
|
||||
|
||||
"@yuuang/ffi-rs-linux-x64-gnu": ["@yuuang/ffi-rs-linux-x64-gnu@1.3.2", "", { "os": "linux", "cpu": "x64" }, "sha512-s8VCFazaJKmgY2hgMTpWk4TtBY/zy5ovbaGgwyY0FvBD0YvyhcET4IrMsDJpHhFVTPCYfKZ1dN45clD/YiFp6g=="],
|
||||
|
||||
"@yuuang/ffi-rs-linux-x64-musl": ["@yuuang/ffi-rs-linux-x64-musl@1.3.2", "", { "os": "linux", "cpu": "x64" }, "sha512-Ahr5chfKZKWUik20bEZRug+be57LZ2yYrtolyjSRoo7A4ZniBUHBZUNWm6TD6i0CJayqyxWeVk/XiaABD8bY0w=="],
|
||||
|
||||
"@yuuang/ffi-rs-win32-arm64-msvc": ["@yuuang/ffi-rs-win32-arm64-msvc@1.3.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-yhpLcj0qel5VNlpzxPZfNmi7+rEX8444QHjUP6WWLxdRfqPllROu/Cp3OpkBpw3BLdxfcDhWkjWMD5QsJN0Pvg=="],
|
||||
|
||||
"@yuuang/ffi-rs-win32-ia32-msvc": ["@yuuang/ffi-rs-win32-ia32-msvc@1.3.2", "", { "os": "win32", "cpu": [ "x64", "ia32", ] }, "sha512-BFVSbdtg/7mJBw5kQFOPKFiA+SF7z3240HpzHN81Umm4Bp4dWkyx0msYn8+Q7/BBJiLQ4F6bi3Nftk58YA9r9w=="],
|
||||
|
||||
"@yuuang/ffi-rs-win32-x64-msvc": ["@yuuang/ffi-rs-win32-x64-msvc@1.3.2", "", { "os": "win32", "cpu": "x64" }, "sha512-ZL5MJ76n2rjwGo26kCWW7wK6QT/cee00Rx8pfW79pz6vM6jqfhoE7zTnwFiw4aOQUes9+HUc5DeeJ3z+Vb9oLg=="],
|
||||
|
||||
"@zip.js/zip.js": ["@zip.js/zip.js@2.7.62", "", {}, "sha512-OaLvZ8j4gCkLn048ypkZu29KX30r8/OfFF2w4Jo5WXFr+J04J+lzJ5TKZBVgFXhlvSkqNFQdfnY1Q8TMTCyBVA=="],
|
||||
|
||||
"abbrev": ["abbrev@4.0.0", "", {}, "sha512-a1wflyaL0tHtJSmLSOVybYhy22vRih4eduhhrkcjgrWGnRfrZtovJ2FRjxuTtkkj47O/baf0R86QU5OuYpz8fA=="],
|
||||
@@ -4071,8 +4015,6 @@
|
||||
|
||||
"file-uri-to-path": ["file-uri-to-path@1.0.0", "", {}, "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw=="],
|
||||
|
||||
"ffi-rs": ["ffi-rs@1.3.2", "", { "optionalDependencies": { "@yuuang/ffi-rs-android-arm64": "1.3.2", "@yuuang/ffi-rs-darwin-arm64": "1.3.2", "@yuuang/ffi-rs-darwin-x64": "1.3.2", "@yuuang/ffi-rs-linux-arm-gnueabihf": "1.3.2", "@yuuang/ffi-rs-linux-arm64-gnu": "1.3.2", "@yuuang/ffi-rs-linux-arm64-musl": "1.3.2", "@yuuang/ffi-rs-linux-x64-gnu": "1.3.2", "@yuuang/ffi-rs-linux-x64-musl": "1.3.2", "@yuuang/ffi-rs-win32-arm64-msvc": "1.3.2", "@yuuang/ffi-rs-win32-ia32-msvc": "1.3.2", "@yuuang/ffi-rs-win32-x64-msvc": "1.3.2" } }, "sha512-4s8dX9VbBw/jd5NOuE3EJRqXaIVdjMyiumeeDzrOhtjQRwp6Bz2za7iksWXTnvTQKV/tTdm1s1w7mObe92zPjQ=="],
|
||||
|
||||
"filelist": ["filelist@1.0.6", "", { "dependencies": { "minimatch": "^5.0.1" } }, "sha512-5giy2PkLYY1cP39p17Ech+2xlpTRL9HLspOfEgm0L6CwBXBTgsK5ou0JtzYuepxkaQ/tvhCFIJ5uXo0OrM2DxA=="],
|
||||
|
||||
"fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="],
|
||||
|
||||
@@ -135,7 +135,7 @@ export function classifyProviderFailure(input: ProviderFailure): LLMError["reaso
|
||||
rateLimit: input.rateLimit,
|
||||
})
|
||||
}
|
||||
if (input.status === 408 || input.status === 409 || (input.status !== undefined && input.status >= 500))
|
||||
if (input.status !== undefined && input.status >= 500)
|
||||
return new ProviderInternalReason({
|
||||
...common,
|
||||
status: input.status,
|
||||
@@ -145,6 +145,7 @@ export function classifyProviderFailure(input: ProviderFailure): LLMError["reaso
|
||||
if (
|
||||
input.status === 400 ||
|
||||
input.status === 404 ||
|
||||
input.status === 409 ||
|
||||
input.status === 413 ||
|
||||
input.status === 422
|
||||
)
|
||||
|
||||
@@ -58,12 +58,6 @@ describe("provider error classification", () => {
|
||||
).toEqual(["ProviderInternal", "ProviderInternal"])
|
||||
})
|
||||
|
||||
test("classifies transient client statuses as provider internal", () => {
|
||||
expect(
|
||||
[408, 409].map((status) => classifyProviderFailure({ message: `HTTP ${status}`, status })._tag),
|
||||
).toEqual(["ProviderInternal", "ProviderInternal"])
|
||||
})
|
||||
|
||||
test("classifies nested provider codes when a top-level code is also present", () => {
|
||||
expect(
|
||||
[
|
||||
|
||||
@@ -56,16 +56,6 @@
|
||||
"@lydell/node-pty-linux-x64": "1.2.0-beta.12",
|
||||
"@lydell/node-pty-win32-arm64": "1.2.0-beta.12",
|
||||
"@lydell/node-pty-win32-x64": "1.2.0-beta.12",
|
||||
"@ff-labs/fff-bin-darwin-arm64": "0.10.1",
|
||||
"@ff-labs/fff-bin-linux-arm64-gnu": "0.10.1",
|
||||
"@ff-labs/fff-bin-linux-x64-gnu": "0.10.1",
|
||||
"@ff-labs/fff-bin-win32-arm64": "0.10.1",
|
||||
"@ff-labs/fff-bin-win32-x64": "0.10.1",
|
||||
"@yuuang/ffi-rs-darwin-arm64": "1.3.2",
|
||||
"@yuuang/ffi-rs-linux-arm64-gnu": "1.3.2",
|
||||
"@yuuang/ffi-rs-linux-x64-gnu": "1.3.2",
|
||||
"@yuuang/ffi-rs-win32-arm64-msvc": "1.3.2",
|
||||
"@yuuang/ffi-rs-win32-x64-msvc": "1.3.2",
|
||||
"@parcel/watcher-darwin-arm64": "2.5.1",
|
||||
"@parcel/watcher-linux-arm64-glibc": "2.5.1",
|
||||
"@parcel/watcher-linux-x64-glibc": "2.5.1",
|
||||
|
||||
@@ -5,6 +5,7 @@ import { rm } from "fs/promises"
|
||||
import path from "path"
|
||||
import { Script } from "@opencode-ai/script"
|
||||
import { createSolidTransformPlugin } from "@opentui/solid/bun-plugin"
|
||||
import type { BunPlugin } from "bun"
|
||||
import pkg from "../package.json"
|
||||
import { modelsData } from "./generate"
|
||||
|
||||
@@ -22,7 +23,7 @@ await rm(outdir, { recursive: true, force: true })
|
||||
const singleFlag = process.argv.includes("--single")
|
||||
const baselineFlag = process.argv.includes("--baseline")
|
||||
const skipInstall = process.argv.includes("--skip-install")
|
||||
const plugin = createSolidTransformPlugin()
|
||||
const solidPlugin = createSolidTransformPlugin()
|
||||
|
||||
const allTargets: {
|
||||
os: string
|
||||
@@ -55,6 +56,16 @@ const targets = singleFlag
|
||||
if (!skipInstall) await $`bun install --os="*" --cpu="*" @opentui/core@${pkg.dependencies["@opentui/core"]}`
|
||||
|
||||
for (const item of targets) {
|
||||
const parcelWatcherPackage = `@parcel/watcher-${item.os}-${item.arch}${item.os === "linux" ? `-${item.abi ?? "glibc"}` : ""}`
|
||||
const parcelWatcherPlugin: BunPlugin = {
|
||||
name: "parcel-watcher-binding",
|
||||
setup(build) {
|
||||
build.onLoad({ filter: /filesystem\/watcher-binding\.ts$/ }, () => ({
|
||||
contents: `import binding from ${JSON.stringify(parcelWatcherPackage)}; export default () => binding`,
|
||||
loader: "js",
|
||||
}))
|
||||
},
|
||||
}
|
||||
const target = [
|
||||
binary,
|
||||
item.os === "win32" ? "windows" : item.os,
|
||||
@@ -69,7 +80,7 @@ for (const item of targets) {
|
||||
const result = await Bun.build({
|
||||
entrypoints: ["./src/index.ts"],
|
||||
tsconfig: "./tsconfig.json",
|
||||
plugins: [plugin],
|
||||
plugins: [solidPlugin, parcelWatcherPlugin],
|
||||
external: ["node-gyp"],
|
||||
format: "esm",
|
||||
minify: true,
|
||||
|
||||
@@ -37,8 +37,6 @@ export async function collectNodeAssets(target: NodeTarget) {
|
||||
...(target.platform === "linux" ? { libc: "glibc" as const } : {}),
|
||||
}),
|
||||
{ key: target.parcelWatcherAsset, source: fileURLToPath(import.meta.resolve(target.parcelWatcherPackage)) },
|
||||
{ key: target.fffAsset, source: fileURLToPath(import.meta.resolve(target.fffPackage)) },
|
||||
{ key: target.fffFfiAsset, source: fileURLToPath(import.meta.resolve(target.fffFfiPackage)) },
|
||||
{
|
||||
key: photonWasmAsset,
|
||||
source: fileURLToPath(import.meta.resolve(photonWasmAsset)),
|
||||
|
||||
@@ -13,7 +13,7 @@ const directory = path.join(import.meta.dir, "..", "dist", ...(nodeBuild ? ["nod
|
||||
const binary = path.join(directory, `opencode2${nodeBuild ? "-node" : ""}${process.platform === "win32" ? ".exe" : ""}`)
|
||||
if (!(await Bun.file(binary).exists())) throw new Error(`Missing compiled CLI in ${directory}`)
|
||||
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-service-smoke-"))
|
||||
const root = await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), "opencode-service-smoke-")))
|
||||
const env = {
|
||||
...process.env,
|
||||
HOME: root,
|
||||
@@ -29,6 +29,7 @@ const processes: Array<ReturnType<typeof Bun.spawn>> = []
|
||||
const errors: Array<Promise<string>> = []
|
||||
let failure: unknown
|
||||
try {
|
||||
await fs.mkdir(path.join(root, ".opencode"))
|
||||
spawnService()
|
||||
spawnService()
|
||||
const registration = await waitForRegistration()
|
||||
@@ -49,6 +50,11 @@ try {
|
||||
{ signal: AbortSignal.timeout(5_000) },
|
||||
)
|
||||
if (tokenOpenApi.status !== 200) throw new Error("Compiled application rejected query authentication")
|
||||
if ((await pluginIDs(info.url, headers)).includes("smoke")) throw new Error("Smoke plugin existed before creation")
|
||||
const plugin = path.join(root, ".opencode", "plugins", "smoke.ts")
|
||||
await fs.mkdir(path.dirname(plugin), { recursive: true })
|
||||
await fs.writeFile(plugin, pluginSource())
|
||||
await waitForPlugin(info.url, headers)
|
||||
|
||||
const unauthorizedHealth = await fetch(new URL("/api/health", info.url), {
|
||||
signal: AbortSignal.timeout(5_000),
|
||||
@@ -88,6 +94,7 @@ try {
|
||||
} finally {
|
||||
processes.forEach((process) => process.kill())
|
||||
await Promise.all(processes.map((process) => process.exited))
|
||||
if (failure) errors.push(fs.readFile(path.join(root, "data", "opencode", "log", "opencode.log"), "utf8").catch(() => ""))
|
||||
}
|
||||
|
||||
const output = await Promise.all(errors)
|
||||
@@ -133,3 +140,31 @@ async function waitForReady(url: string, headers: HeadersInit) {
|
||||
function exitsWithin(process: Bun.Subprocess, milliseconds: number) {
|
||||
return Promise.race([process.exited.then(() => true), Bun.sleep(milliseconds).then(() => false)])
|
||||
}
|
||||
|
||||
function pluginSource() {
|
||||
return 'export default { id: "smoke", setup: async () => {} }\n'
|
||||
}
|
||||
|
||||
async function pluginIDs(url: string, headers: HeadersInit) {
|
||||
const endpoint = new URL("/api/plugin", url)
|
||||
endpoint.searchParams.set("location[directory]", root)
|
||||
const response = await fetch(endpoint, { headers, signal: AbortSignal.timeout(5_000) })
|
||||
const body: unknown = await response.json()
|
||||
if (typeof body !== "object" || body === null || !("data" in body) || !Array.isArray(body.data)) {
|
||||
throw new Error("Compiled service returned an invalid plugin list")
|
||||
}
|
||||
return body.data.flatMap((plugin) =>
|
||||
typeof plugin === "object" && plugin !== null && "id" in plugin && typeof plugin.id === "string"
|
||||
? [plugin.id]
|
||||
: [],
|
||||
)
|
||||
}
|
||||
|
||||
async function waitForPlugin(url: string, headers: HeadersInit) {
|
||||
const deadline = Date.now() + 10_000
|
||||
while (Date.now() < deadline) {
|
||||
if ((await pluginIDs(url, headers)).includes("smoke")) return
|
||||
await Bun.sleep(25)
|
||||
}
|
||||
throw new Error("Compiled service did not discover the created plugin")
|
||||
}
|
||||
|
||||
@@ -14,23 +14,10 @@ const ServerParams = {
|
||||
),
|
||||
}
|
||||
|
||||
const PermissionParams = {
|
||||
auto: Flag.boolean("auto").pipe(
|
||||
Flag.withDescription("Auto-approve permissions that are not explicitly denied"),
|
||||
Flag.withDefault(false),
|
||||
),
|
||||
yolo: Flag.boolean("yolo").pipe(Flag.withDefault(false), Flag.withHidden),
|
||||
dangerouslySkipPermissions: Flag.boolean("dangerously-skip-permissions").pipe(
|
||||
Flag.withDefault(false),
|
||||
Flag.withHidden,
|
||||
),
|
||||
}
|
||||
|
||||
export const Commands = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCODE_CLI_NAME : "opencode", {
|
||||
description: "OpenCode 2.0 preview command line interface",
|
||||
params: {
|
||||
...ServerParams,
|
||||
...PermissionParams,
|
||||
directory: Argument.string("directory").pipe(
|
||||
Argument.withDescription("Directory to start OpenCode in"),
|
||||
Argument.optional,
|
||||
@@ -209,7 +196,11 @@ export const Commands = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCO
|
||||
),
|
||||
title: Flag.string("title").pipe(Flag.withDescription("Session title"), Flag.optional),
|
||||
thinking: Flag.boolean("thinking").pipe(Flag.withDescription("Show thinking blocks"), Flag.withDefault(false)),
|
||||
...PermissionParams,
|
||||
auto: Flag.boolean("auto").pipe(
|
||||
Flag.withDescription("Auto-approve permissions that are not explicitly denied"),
|
||||
Flag.withDefault(false),
|
||||
),
|
||||
yolo: Flag.boolean("yolo").pipe(Flag.withDefault(false), Flag.withHidden),
|
||||
},
|
||||
}),
|
||||
Spec.make("service", {
|
||||
|
||||
@@ -59,7 +59,6 @@ export default Runtime.handler(Commands, (input) =>
|
||||
continue: input.continue,
|
||||
sessionID: Option.getOrUndefined(input.session),
|
||||
prompt: Option.getOrUndefined(input.prompt),
|
||||
auto: input.auto || input.yolo || input.dangerouslySkipPermissions,
|
||||
},
|
||||
config: {
|
||||
path: config.path,
|
||||
|
||||
@@ -24,7 +24,7 @@ export default Runtime.handler(Commands.commands.run, (input) =>
|
||||
file: [...input.file],
|
||||
title: Option.getOrUndefined(input.title),
|
||||
thinking: input.thinking,
|
||||
auto: input.auto || input.yolo || input.dangerouslySkipPermissions,
|
||||
auto: input.auto || input.yolo,
|
||||
}),
|
||||
)
|
||||
}),
|
||||
|
||||
@@ -11,8 +11,6 @@ export function nodeTarget(platform: string, arch: string) {
|
||||
const targetArch = arch as "arm64" | "x64"
|
||||
const nodePtyPackage = `@lydell/node-pty-${targetPlatform}-${targetArch}`
|
||||
const parcelWatcherPackage = `@parcel/watcher-${targetPlatform}-${targetArch}${targetPlatform === "linux" ? "-glibc" : ""}`
|
||||
const fffPackage = `@ff-labs/fff-bin-${targetPlatform}-${targetArch}${targetPlatform === "linux" ? "-gnu" : ""}`
|
||||
const fffFfiPackage = `@yuuang/ffi-rs-${targetPlatform}-${targetArch}${targetPlatform === "linux" ? "-gnu" : targetPlatform === "win32" ? "-msvc" : ""}`
|
||||
|
||||
return {
|
||||
platform: targetPlatform,
|
||||
@@ -21,10 +19,6 @@ export function nodeTarget(platform: string, arch: string) {
|
||||
nodePtyEntryAsset: `${nodePtyPackage}/lib/index.js`,
|
||||
parcelWatcherPackage,
|
||||
parcelWatcherAsset: `${parcelWatcherPackage}/watcher.node`,
|
||||
fffPackage,
|
||||
fffAsset: `${fffPackage}/${targetPlatform === "darwin" ? "libfff_c.dylib" : targetPlatform === "win32" ? "fff_c.dll" : "libfff_c.so"}`,
|
||||
fffFfiPackage,
|
||||
fffFfiAsset: `${fffFfiPackage}/ffi-rs.${targetPlatform}-${targetArch}${targetPlatform === "linux" ? "-gnu" : targetPlatform === "win32" ? "-msvc" : ""}.node`,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -30,44 +30,6 @@ function runtimeRequirePlugin(): Plugin {
|
||||
}
|
||||
}
|
||||
|
||||
function fffNodePlugin(): Plugin {
|
||||
return {
|
||||
name: "opencode:fff-node",
|
||||
enforce: "pre",
|
||||
transform(code, id) {
|
||||
const normalized = id.replaceAll("\\", "/")
|
||||
if (normalized.endsWith("/ffi-rs/index.js")) {
|
||||
const start = code.indexOf("if (!nativeBinding) {")
|
||||
if (start === -1) this.error("Failed to rewrite ffi-rs native binding loader")
|
||||
return `const unavailable = () => { throw new Error("ffi-rs native binding unavailable") }
|
||||
const nativeBinding = globalThis.__OPENCODE_FFF_FFI ?? {
|
||||
DataType: new Proxy({}, { get: (target, key) => target[key] ?? key }),
|
||||
PointerType: {},
|
||||
FFITypeTag: {},
|
||||
open: unavailable,
|
||||
close: unavailable,
|
||||
load: unavailable,
|
||||
isNullPointer: unavailable,
|
||||
createPointer: unavailable,
|
||||
restorePointer: unavailable,
|
||||
unwrapPointer: unavailable,
|
||||
wrapPointer: unavailable,
|
||||
freePointer: unavailable,
|
||||
}
|
||||
const loadError = undefined
|
||||
${code.slice(start)}`
|
||||
}
|
||||
if (!normalized.endsWith("/fff-node/dist/src/binary.js")) return
|
||||
const transformed = code.replace(
|
||||
"export function findBinary() {",
|
||||
"export function findBinary() { if (process.env.FFF_BINARY_PATH) return process.env.FFF_BINARY_PATH;",
|
||||
)
|
||||
if (transformed === code) this.error("Failed to rewrite FFF binary loader")
|
||||
return transformed
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const resolve = {
|
||||
alias: [
|
||||
{ find: /^solid-js\/store$/, replacement: "solid-js/store/dist/store.js" },
|
||||
@@ -194,11 +156,6 @@ process.env.OTUI_ASSET_ROOT = __ocAssetRoot
|
||||
process.env.OPENCODE_NODE_PTY_PATH = __ocPath.join(__ocAssetRoot, ${JSON.stringify(input.target.nodePtyEntryAsset)})
|
||||
process.env.OPENCODE_PARCEL_WATCHER_PATH = __ocPath.join(__ocAssetRoot, ${JSON.stringify(input.target.parcelWatcherAsset)})
|
||||
process.env.OPENCODE_PHOTON_WASM_PATH = __ocPath.join(__ocAssetRoot, ${JSON.stringify(photonWasmAsset)})
|
||||
process.env.FFF_BINARY_PATH = __ocPath.join(__ocAssetRoot, ${JSON.stringify(input.target.fffAsset)})
|
||||
process.env.OPENCODE_FFF_FFI_PATH = __ocPath.join(__ocAssetRoot, ${JSON.stringify(input.target.fffFfiAsset)})
|
||||
try {
|
||||
globalThis.__OPENCODE_FFF_FFI = require(process.env.OPENCODE_FFF_FFI_PATH)
|
||||
} catch {}
|
||||
globalThis.__OPENCODE_PHOTON_WASM_PATH = process.env.OPENCODE_PHOTON_WASM_PATH
|
||||
if (process.platform === "linux") process.env.OPENTUI_LIBC = "glibc"`
|
||||
}
|
||||
@@ -217,7 +174,6 @@ export function mainConfig(input: NodeBuildInput): UserConfig {
|
||||
plugins: [
|
||||
rawTextPlugin(),
|
||||
runtimeRequirePlugin(),
|
||||
fffNodePlugin(),
|
||||
solid({
|
||||
solid: {
|
||||
generate: "universal",
|
||||
|
||||
@@ -92,7 +92,6 @@
|
||||
"@lydell/node-pty": "catalog:",
|
||||
"@modelcontextprotocol/sdk": "1.29.0",
|
||||
"@ff-labs/fff-bun": "0.10.1",
|
||||
"@ff-labs/fff-node": "0.10.1",
|
||||
"@opencode-ai/codemode": "workspace:*",
|
||||
"@opencode-ai/effect-drizzle-sqlite": "workspace:*",
|
||||
"@opencode-ai/effect-sqlite-node": "workspace:*",
|
||||
|
||||
@@ -1,52 +1,71 @@
|
||||
import type {
|
||||
DirItem,
|
||||
DirSearchResult,
|
||||
FileItem,
|
||||
GrepCursor,
|
||||
GrepMatch,
|
||||
GrepResult,
|
||||
InitOptions,
|
||||
MixedItem,
|
||||
MixedSearchResult,
|
||||
SearchResult,
|
||||
} from "@ff-labs/fff-node"
|
||||
|
||||
const { FileFinder } = await import("@ff-labs/fff-node").catch(() => ({ FileFinder: undefined }))
|
||||
|
||||
export type Result<T> = { ok: true; value: T } | { ok: false; error: string }
|
||||
|
||||
export type Init = InitOptions
|
||||
export interface Init {
|
||||
basePath: string
|
||||
frecencyDbPath?: string
|
||||
historyDbPath?: string
|
||||
useUnsafeNoLock?: boolean
|
||||
disableMmapCache?: boolean
|
||||
disableContentIndexing?: boolean
|
||||
disableWatch?: boolean
|
||||
aiMode?: boolean
|
||||
logFilePath?: string
|
||||
logLevel?: "trace" | "debug" | "info" | "warn" | "error"
|
||||
enableFsRootScanning?: boolean
|
||||
enableHomeDirScanning?: boolean
|
||||
}
|
||||
|
||||
export interface File {
|
||||
relativePath: string
|
||||
fileName: string
|
||||
modified: number
|
||||
}
|
||||
|
||||
export interface Directory {
|
||||
relativePath: string
|
||||
dirName: string
|
||||
maxAccessFrecency: number
|
||||
}
|
||||
|
||||
export type Mixed = { type: "file"; item: File } | { type: "directory"; item: Directory }
|
||||
|
||||
export interface Search {
|
||||
items: FileItem[]
|
||||
scores: SearchResult["scores"]
|
||||
items: File[]
|
||||
scores: Array<{ total: number }>
|
||||
totalMatched: number
|
||||
totalFiles: number
|
||||
}
|
||||
|
||||
export interface DirSearch {
|
||||
items: DirItem[]
|
||||
scores: DirSearchResult["scores"]
|
||||
items: Directory[]
|
||||
scores: Array<{ total: number }>
|
||||
totalMatched: number
|
||||
totalDirs: number
|
||||
}
|
||||
|
||||
export interface MixedSearch {
|
||||
items: MixedItem[]
|
||||
scores: MixedSearchResult["scores"]
|
||||
items: Mixed[]
|
||||
scores: Array<{ total: number }>
|
||||
totalMatched: number
|
||||
totalFiles: number
|
||||
totalDirs: number
|
||||
}
|
||||
|
||||
export type File = FileItem
|
||||
export type Directory = DirItem
|
||||
export type Mixed = MixedItem
|
||||
export type Cursor = GrepCursor | null
|
||||
export type Hit = GrepMatch
|
||||
export type Cursor = null
|
||||
|
||||
export interface Hit {
|
||||
relativePath: string
|
||||
fileName: string
|
||||
lineNumber: number
|
||||
byteOffset: number
|
||||
lineContent: string
|
||||
matchRanges: [number, number][]
|
||||
contextBefore?: string[]
|
||||
contextAfter?: string[]
|
||||
}
|
||||
|
||||
export interface Grep {
|
||||
items: GrepResult["items"]
|
||||
items: Hit[]
|
||||
totalMatched: number
|
||||
totalFilesSearched: number
|
||||
totalFiles: number
|
||||
@@ -109,30 +128,11 @@ export interface Picker {
|
||||
}
|
||||
|
||||
export function available() {
|
||||
return FileFinder?.isAvailable() ?? false
|
||||
return false
|
||||
}
|
||||
|
||||
export function create(opts: Init): Result<Picker> {
|
||||
if (!FileFinder) return { ok: false, error: "fff unavailable on node runtime" }
|
||||
const made = FileFinder.create(opts)
|
||||
if (!made.ok) return made
|
||||
const pick = made.value
|
||||
return {
|
||||
ok: true,
|
||||
value: {
|
||||
destroy: () => pick.destroy(),
|
||||
isScanning: () => pick.isScanning(),
|
||||
waitForScan: (timeoutMs) => pick.waitForScan(timeoutMs),
|
||||
refreshGitStatus: () => pick.refreshGitStatus(),
|
||||
fileSearch: (query, next) => pick.fileSearch(query, next),
|
||||
glob: (pattern, next) => pick.glob(pattern, next),
|
||||
directorySearch: (query, next) => pick.directorySearch(query, next),
|
||||
mixedSearch: (query, next) => pick.mixedSearch(query, next),
|
||||
grep: (query, next) => pick.grep(query, next),
|
||||
trackQuery: (query, file) => pick.trackQuery(query, file),
|
||||
getHistoricalQuery: (offset) => pick.getHistoricalQuery(offset),
|
||||
},
|
||||
}
|
||||
export function create(_opts: Init): Result<Picker> {
|
||||
return { ok: false, error: "fff unavailable on node runtime" }
|
||||
}
|
||||
|
||||
export * as Fff from "./fff.node"
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import { createRequire } from "node:module"
|
||||
|
||||
declare const OPENCODE_LIBC: string | undefined
|
||||
|
||||
const require = createRequire(import.meta.url)
|
||||
|
||||
export default function load() {
|
||||
const libc = typeof OPENCODE_LIBC === "undefined" ? undefined : OPENCODE_LIBC
|
||||
return require(
|
||||
process.env.OPENCODE_PARCEL_WATCHER_PATH ??
|
||||
`@parcel/watcher-${process.platform}-${process.arch}${process.platform === "linux" ? `-${libc || "glibc"}` : ""}`,
|
||||
)
|
||||
}
|
||||
@@ -9,23 +9,14 @@ import { Cause, Context, Effect, Layer, PubSub, RcMap, Schema, Stream } from "ef
|
||||
import { lazy } from "../util/lazy"
|
||||
import { watch as watchFileSystem } from "node:fs"
|
||||
import path from "path"
|
||||
import { createRequire } from "node:module"
|
||||
|
||||
declare const OPENCODE_LIBC: string | undefined
|
||||
import loadBinding from "./watcher-binding"
|
||||
|
||||
const SUBSCRIBE_TIMEOUT_MS = 10_000
|
||||
const require = createRequire(import.meta.url)
|
||||
|
||||
export const Event = { Updated: FileSystem.Event.Changed }
|
||||
|
||||
const watcher = lazy((): typeof import("@parcel/watcher") | undefined => {
|
||||
try {
|
||||
const libc = typeof OPENCODE_LIBC === "undefined" ? undefined : OPENCODE_LIBC
|
||||
const binding = require(
|
||||
process.env.OPENCODE_PARCEL_WATCHER_PATH ??
|
||||
`@parcel/watcher-${process.platform}-${process.arch}${process.platform === "linux" ? `-${libc || "glibc"}` : ""}`,
|
||||
)
|
||||
return createWrapper(binding) as typeof import("@parcel/watcher")
|
||||
return createWrapper(loadBinding()) as typeof import("@parcel/watcher")
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -44,7 +44,6 @@ type Active = {
|
||||
* here; callers (e.g. `ShellTool`) own that association and store the shell ID.
|
||||
*/
|
||||
export interface Interface {
|
||||
readonly name: () => Effect.Effect<string>
|
||||
readonly create: (input: Shell.CreateInput) => Effect.Effect<Shell.Info>
|
||||
// Currently running commands only; exited shells are retained for get/output but excluded here.
|
||||
readonly list: () => Effect.Effect<Shell.Info[]>
|
||||
@@ -135,13 +134,6 @@ export const layer = (options?: ShellSelect.Options) => Layer.effect(
|
||||
return session.info
|
||||
})
|
||||
|
||||
const resolve = () =>
|
||||
config
|
||||
.entries()
|
||||
.pipe(Effect.map((entries) => ShellSelect.preferred(Config.latest(entries, "shell"), options)))
|
||||
|
||||
const name = () => resolve().pipe(Effect.map(ShellSelect.name))
|
||||
|
||||
const output = Effect.fn("Shell.output")(function* (id: Shell.ID, input?: Shell.OutputInput) {
|
||||
const session = yield* require(id)
|
||||
const cursor = input?.cursor ?? 0
|
||||
@@ -175,7 +167,8 @@ export const layer = (options?: ShellSelect.Options) => Layer.effect(
|
||||
const create = Effect.fn("Shell.create")(function* (input: Shell.CreateInput) {
|
||||
const id = Shell.ID.ascending()
|
||||
const cwd = input.cwd ?? location.directory
|
||||
const shell = yield* resolve()
|
||||
const configShell = Config.latest(yield* config.entries(), "shell")
|
||||
const shell = ShellSelect.preferred(configShell, options)
|
||||
const args = ShellSelect.args(shell, input.command)
|
||||
const file = path.join(outputDir, `${id}.out`)
|
||||
const env = {
|
||||
@@ -319,7 +312,7 @@ export const layer = (options?: ShellSelect.Options) => Layer.effect(
|
||||
return session.info
|
||||
})
|
||||
|
||||
return Service.of({ name, create, list, get, wait, timeout, output, remove })
|
||||
return Service.of({ create, list, get, wait, timeout, output, remove })
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -15,40 +15,22 @@ import { Shell } from "../../shell"
|
||||
|
||||
export const name = "shell"
|
||||
export const DEFAULT_TIMEOUT_MS = 2 * 60 * 1_000
|
||||
export const MAX_TIMEOUT_MS = 10 * 60 * 1_000
|
||||
export const MAX_CAPTURE_BYTES = 1024 * 1024
|
||||
|
||||
const BACKGROUND_STARTED = "The command was moved to the background."
|
||||
const BACKGROUND_INSTRUCTION =
|
||||
"You will be notified automatically when the command finishes. DO NOT sleep, poll, or proactively check on its progress."
|
||||
const OS =
|
||||
process.platform === "darwin"
|
||||
? "macOS"
|
||||
: process.platform === "win32"
|
||||
? "Windows"
|
||||
: process.platform === "linux"
|
||||
? "Linux"
|
||||
: process.platform
|
||||
const description = (shell?: string) =>
|
||||
[
|
||||
"Execute a shell command and return its output.",
|
||||
...(shell ? [`Commands run on ${OS} using ${shell}.`] : []),
|
||||
"Quote file paths containing spaces or special characters.",
|
||||
"Prefer dedicated tools over shell commands when possible.",
|
||||
"When output is large, the full result is saved to a file and a truncated preview is returned.",
|
||||
"Rely on automatic truncation unless filtering the output is more useful.",
|
||||
"Commands accept an optional timeout, background commands have no timeout by default.",
|
||||
"Background commands return immediately, and you will be notified when they complete.",
|
||||
].join(" ")
|
||||
|
||||
export const Input = Schema.Struct({
|
||||
command: Schema.String.annotate({ description: "Shell command string to execute" }),
|
||||
workdir: Schema.optionalKey(Schema.String).annotate({
|
||||
description:
|
||||
"Working directory to execute the command in. Defaults to the current working directory. When possible, avoid changing directories in the command and set the working directory here instead.",
|
||||
}),
|
||||
timeout: Schema.optionalKey(NonNegativeInt).annotate({
|
||||
description: `Timeout in milliseconds. Set to 0 to disable the timeout. Defaults to ${DEFAULT_TIMEOUT_MS} for foreground commands. Background commands have no timeout by default.`,
|
||||
description: "Working directory. Defaults to the active Location; relative paths resolve from that Location.",
|
||||
}),
|
||||
timeout: Schema.optionalKey(NonNegativeInt.check(Schema.isLessThanOrEqualTo(MAX_TIMEOUT_MS)))
|
||||
.annotate({
|
||||
description: `Optional timeout in milliseconds. Zero means unlimited. Foreground commands default to ${DEFAULT_TIMEOUT_MS}; background commands default to unlimited. May not exceed ${MAX_TIMEOUT_MS}.`,
|
||||
}),
|
||||
background: Schema.optionalKey(Schema.Boolean).annotate({
|
||||
description:
|
||||
"Run the command in the background and return immediately. You will be notified when it completes. DO NOT poll its progress.",
|
||||
@@ -87,11 +69,13 @@ const modelOutput = (output: Output): string | undefined => {
|
||||
// TODO: Port tree-sitter bash / PowerShell parser-based approval reduction.
|
||||
// TODO: Port BashArity reusable command-prefix approvals.
|
||||
// TODO: Replace token-based command-argument external-directory advisories with parser-based detection.
|
||||
// TODO: Restore PowerShell and cmd-specific invocation/path handling on Windows.
|
||||
// TODO: Add plugin shell.env environment augmentation once plugin hooks exist.
|
||||
// TODO: Persist job status and define restart recovery before exposing remote observation.
|
||||
// TODO: Add HTTP job observation only after durable status, restart recovery, and authorization are defined.
|
||||
// TODO: Revisit process-group cleanup and platform coverage with shell-specific tests if current AppProcess semantics do not fully cover it.
|
||||
// TODO: Revisit binary output handling if stdout/stderr decoding is text-only.
|
||||
// TODO: Stream full shell output into managed storage while retaining only a bounded in-memory preview.
|
||||
|
||||
const shellTokens = (command: string) => command.match(/(?:[^\s"']+|"[^"]*"|'[^']*')+/g) ?? []
|
||||
const unquote = (value: string) => value.replace(/^(['"])(.*)\1$/, "$2")
|
||||
@@ -160,7 +144,7 @@ export const Plugin = {
|
||||
({
|
||||
name,
|
||||
options: { codemode: false },
|
||||
description: description(),
|
||||
description: `Execute one shell command string with the host user's filesystem, process, and network authority. The active Location is the default working directory. Relative workdir values resolve from that Location. External workdir values require external_directory approval; best-effort command-argument path warnings are advisory only. An optional timeout may be provided in milliseconds (zero: unlimited; foreground default: ${DEFAULT_TIMEOUT_MS}; maximum: ${MAX_TIMEOUT_MS}). Background commands default to unlimited. Uses the configured shell when set; otherwise uses /bin/sh on POSIX and COMSPEC or cmd.exe on Windows. Background mode (background=true) launches the command asynchronously and returns immediately; you are notified when it finishes.`,
|
||||
input: Input,
|
||||
output: Output,
|
||||
execute: (input, context) =>
|
||||
@@ -205,12 +189,8 @@ export const Plugin = {
|
||||
yield* context.progress({ shellID: info.id })
|
||||
|
||||
const captureShell = Effect.fn("ShellTool.captureShell")(function* () {
|
||||
const latest = yield* shell.output(info.id, { cursor: Number.MAX_SAFE_INTEGER })
|
||||
const truncated = latest.size > MAX_CAPTURE_BYTES
|
||||
const page = yield* shell.output(info.id, {
|
||||
cursor: Math.max(0, latest.size - MAX_CAPTURE_BYTES),
|
||||
limit: MAX_CAPTURE_BYTES,
|
||||
})
|
||||
const page = yield* shell.output(info.id, { limit: MAX_CAPTURE_BYTES })
|
||||
const truncated = page.size > page.cursor
|
||||
const notice = truncated ? `\n\n[output truncated; full output saved to: ${info.file}]` : ""
|
||||
return {
|
||||
output: `${page.output || "(no output)"}${notice}`,
|
||||
@@ -311,13 +291,5 @@ export const Plugin = {
|
||||
),
|
||||
)
|
||||
.pipe(Effect.orDie)
|
||||
|
||||
yield* ctx.session.hook("context", (event) =>
|
||||
Effect.gen(function* () {
|
||||
const tool = event.tools[name]
|
||||
if (!tool) return
|
||||
tool.description = description(yield* shell.name())
|
||||
}),
|
||||
)
|
||||
}),
|
||||
}
|
||||
|
||||
@@ -165,8 +165,8 @@ const bodyExitCommand = isWindows
|
||||
: "printf body && exit 7"
|
||||
const overflowCommand = (bytes: number) =>
|
||||
isWindows
|
||||
? `[Console]::Out.Write('output-start' + ('x' * ${bytes}) + 'output-end'); Start-Sleep -Milliseconds 100`
|
||||
: `printf output-start; head -c ${bytes} /dev/zero | tr '\\0' 'x'; printf output-end`
|
||||
? `[Console]::Out.Write(('x' * ${bytes})); Start-Sleep -Milliseconds 100`
|
||||
: `head -c ${bytes} /dev/zero | tr '\\0' 'x'`
|
||||
const progressOverflowCommand = (bytes: number, release: string) =>
|
||||
isWindows
|
||||
? `[Console]::Out.Write(('x' * ${bytes})); while (!(Test-Path -LiteralPath '${release}')) { Start-Sleep -Milliseconds 50 }`
|
||||
@@ -200,11 +200,10 @@ describe("ShellTool", () => {
|
||||
return withSession(tmp.path, (registry) =>
|
||||
Effect.gen(function* () {
|
||||
const definitions = yield* toolDefinitions(registry)
|
||||
const definition = definitions.find((tool) => tool.name === "shell")
|
||||
expect(definition?.description).toStartWith("Execute a shell command and return its output.")
|
||||
expect(definition?.inputSchema).not.toHaveProperty("properties.timeout.maximum")
|
||||
const shell = definitions.find((tool) => tool.name === "shell")
|
||||
expect(shell).toBeDefined()
|
||||
// Code Mode receives the declared output schema, including the command output text.
|
||||
expect(definition?.outputSchema).toHaveProperty("properties.output")
|
||||
expect(shell?.outputSchema).toHaveProperty("properties.output")
|
||||
expect(
|
||||
(yield* toolDefinitions(registry, [{ action: "shell", resource: "*", effect: "deny" }])).map(
|
||||
(tool) => tool.name,
|
||||
@@ -410,11 +409,7 @@ describe("ShellTool", () => {
|
||||
Effect.andThen((settled) =>
|
||||
Effect.sync(() => {
|
||||
expect(settled.metadata).toMatchObject({ exit: 0, truncated: true })
|
||||
const content = settled.content?.[0]
|
||||
if (!content || content.type !== "text") throw new Error("Expected text content")
|
||||
expect(content.text.includes("output-start")).toBe(false)
|
||||
expect(content.text.includes("output-end")).toBe(true)
|
||||
expect(content).toMatchObject({
|
||||
expect(settled.content?.[0]).toMatchObject({
|
||||
type: "text",
|
||||
text: expect.stringContaining("output truncated; full output saved to:"),
|
||||
})
|
||||
|
||||
@@ -32,8 +32,7 @@
|
||||
"peerDependencies": {
|
||||
"@opentui/core": ">=0.4.5",
|
||||
"@opentui/keymap": ">=0.4.5",
|
||||
"@opentui/solid": ">=0.4.5",
|
||||
"solid-js": ">=1.9.0"
|
||||
"@opentui/solid": ">=0.4.5"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@opentui/core": {
|
||||
@@ -44,9 +43,6 @@
|
||||
},
|
||||
"@opentui/solid": {
|
||||
"optional": true
|
||||
},
|
||||
"solid-js": {
|
||||
"optional": true
|
||||
}
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -56,7 +52,6 @@
|
||||
"@tsconfig/bun": "catalog:",
|
||||
"@tsconfig/node22": "catalog:",
|
||||
"@types/node": "catalog:",
|
||||
"solid-js": "catalog:",
|
||||
"typescript": "catalog:",
|
||||
"@typescript/native-preview": "catalog:"
|
||||
}
|
||||
|
||||
@@ -113,22 +113,7 @@ export interface Page {
|
||||
readonly render: (input: { readonly data?: Record<string, any> }) => JSX.Element
|
||||
}
|
||||
|
||||
export interface SlotMap {
|
||||
readonly app: Readonly<Record<string, never>>
|
||||
readonly "home.footer": Readonly<Record<string, never>>
|
||||
readonly "sidebar.content": {
|
||||
readonly sessionID: string
|
||||
}
|
||||
readonly "sidebar.footer": Readonly<Record<string, never>>
|
||||
}
|
||||
|
||||
export type SlotName = keyof SlotMap
|
||||
export type Slot<Name extends SlotName = SlotName> = (props: SlotMap[Name]) => JSX.Element
|
||||
|
||||
export interface App {
|
||||
readonly version: string
|
||||
readonly channel: string
|
||||
}
|
||||
export type Slot = (props: Record<string, any>) => JSX.Element
|
||||
|
||||
export type ToastVariant = "info" | "success" | "warning" | "error"
|
||||
|
||||
@@ -230,11 +215,11 @@ export interface DialogSelectOptions<Value> {
|
||||
}
|
||||
|
||||
export interface Dialog {
|
||||
/** Shows a dialog. */
|
||||
show(render: () => JSX.Element, onClose?: () => void): void
|
||||
/** Sets the active dialog's presentation options. */
|
||||
/** Shows a dialog and returns a function that closes it. */
|
||||
show(render: () => JSX.Element, onClose?: () => void): () => void
|
||||
/** Sets the presentation options for this plugin's active dialog. */
|
||||
set(options: DialogOptions): void
|
||||
/** Closes the active dialog. */
|
||||
/** Closes this plugin's active dialog. */
|
||||
clear(): void
|
||||
alert(options: DialogAlertOptions): Promise<void>
|
||||
confirm(options: DialogConfirmOptions): Promise<boolean | undefined>
|
||||
@@ -323,21 +308,17 @@ export interface Keymap {
|
||||
export interface UI {
|
||||
readonly dialog: Dialog
|
||||
readonly toast: Toast
|
||||
readonly format: {
|
||||
path(value: string): string
|
||||
}
|
||||
readonly router: {
|
||||
register(page: Page): () => void
|
||||
navigate(destination: Destination): void
|
||||
current(): Route
|
||||
}
|
||||
readonly slot: <Name extends SlotName>(name: Name, render: Slot<Name>) => () => void
|
||||
readonly slot: (name: string, render: Slot) => () => void
|
||||
}
|
||||
|
||||
export interface Context {
|
||||
readonly options: Readonly<Record<string, any>>
|
||||
readonly location: LocationRef | undefined
|
||||
readonly app: App
|
||||
readonly renderer: CliRenderer
|
||||
readonly client: OpenCodeClient
|
||||
readonly data: Data
|
||||
|
||||
@@ -1,2 +1 @@
|
||||
export * as Plugin from "./plugin.js"
|
||||
export { PluginContextProvider, usePlugin } from "./solid.js"
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
import { createComponent, createContext, useContext, type JSX } from "solid-js"
|
||||
import type { Context } from "./context.js"
|
||||
|
||||
const PluginContext = createContext<Context>()
|
||||
|
||||
export function PluginContextProvider(props: { readonly value: Context; readonly children: JSX.Element }) {
|
||||
return createComponent(PluginContext.Provider, {
|
||||
value: props.value,
|
||||
get children() {
|
||||
return props.children
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function usePlugin() {
|
||||
const context = useContext(PluginContext)
|
||||
if (!context) throw new Error("PluginContextProvider is missing")
|
||||
return context
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode-ai/theme",
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/anomalyco/opencode.git",
|
||||
"directory": "packages/theme"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"exports": {
|
||||
"./tui": "./src/tui/index.ts",
|
||||
"./tui/v1": "./src/tui/v1.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "bun run script/build.ts",
|
||||
"typecheck": "tsgo --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@opentui/core": "catalog:",
|
||||
"effect": "catalog:"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tsconfig/bun": "catalog:",
|
||||
"@types/bun": "catalog:",
|
||||
"@typescript/native-preview": "catalog:",
|
||||
"typescript": "catalog:"
|
||||
}
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
#!/usr/bin/env bun
|
||||
|
||||
import { $ } from "bun"
|
||||
import { fileURLToPath } from "node:url"
|
||||
|
||||
process.chdir(fileURLToPath(new URL("..", import.meta.url)))
|
||||
|
||||
await $`rm -rf dist`
|
||||
await $`bun tsc -p tsconfig.build.json`
|
||||
@@ -1,45 +0,0 @@
|
||||
#!/usr/bin/env bun
|
||||
|
||||
import { Script } from "@opencode-ai/script"
|
||||
import { $ } from "bun"
|
||||
import { rm } from "node:fs/promises"
|
||||
import { fileURLToPath } from "node:url"
|
||||
|
||||
process.chdir(fileURLToPath(new URL("..", import.meta.url)))
|
||||
|
||||
const originalText = await Bun.file("package.json").text()
|
||||
const pkg = JSON.parse(originalText) as {
|
||||
name: string
|
||||
version: string
|
||||
exports: Record<string, string | { import: string; types: string }>
|
||||
}
|
||||
const tarball = `${pkg.name.replace("@", "").replace("/", "-")}-${pkg.version}.tgz`
|
||||
|
||||
if ((await $`npm view ${pkg.name}@${pkg.version} version`.nothrow()).exitCode === 0) {
|
||||
console.log(`already published ${pkg.name}@${pkg.version}`)
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
try {
|
||||
await $`bun run typecheck`
|
||||
await $`bun run build`
|
||||
pkg.exports = Object.fromEntries(
|
||||
Object.entries(pkg.exports).map(([key, value]) => {
|
||||
if (typeof value !== "string") return [key, value]
|
||||
return [
|
||||
key,
|
||||
{
|
||||
import: value.replace("./src/", "./dist/").replace(/\.ts$/, ".js"),
|
||||
types: value.replace("./src/", "./dist/").replace(/\.ts$/, ".d.ts"),
|
||||
},
|
||||
]
|
||||
}),
|
||||
)
|
||||
await Bun.write("package.json", JSON.stringify(pkg, null, 2) + "\n")
|
||||
await rm(tarball, { force: true })
|
||||
await $`bun pm pack`
|
||||
await $`npm publish ${tarball} --tag ${Script.channel} --access public`
|
||||
} finally {
|
||||
await Bun.write("package.json", originalText)
|
||||
await rm(tarball, { force: true })
|
||||
}
|
||||
@@ -1,77 +0,0 @@
|
||||
type OklchColor = {
|
||||
l: number
|
||||
c: number
|
||||
h: number
|
||||
}
|
||||
|
||||
function clamp(value: number, min: number, max: number) {
|
||||
return Math.max(min, Math.min(max, value))
|
||||
}
|
||||
|
||||
function hue(value: number) {
|
||||
return ((value % 360) + 360) % 360
|
||||
}
|
||||
|
||||
function linearToSrgb(value: number) {
|
||||
if (value <= 0.0031308) return value * 12.92
|
||||
return 1.055 * Math.pow(value, 1 / 2.4) - 0.055
|
||||
}
|
||||
|
||||
function srgbToLinear(value: number) {
|
||||
if (value <= 0.04045) return value / 12.92
|
||||
return Math.pow((value + 0.055) / 1.055, 2.4)
|
||||
}
|
||||
|
||||
export function rgbToOklch(red: number, green: number, blue: number): OklchColor {
|
||||
const linearRed = srgbToLinear(red)
|
||||
const linearGreen = srgbToLinear(green)
|
||||
const linearBlue = srgbToLinear(blue)
|
||||
const lRoot = Math.cbrt(0.4122214708 * linearRed + 0.5363325363 * linearGreen + 0.0514459929 * linearBlue)
|
||||
const mRoot = Math.cbrt(0.2119034982 * linearRed + 0.6806995451 * linearGreen + 0.1073969566 * linearBlue)
|
||||
const sRoot = Math.cbrt(0.0883024619 * linearRed + 0.2817188376 * linearGreen + 0.6299787005 * linearBlue)
|
||||
const lightness = 0.2104542553 * lRoot + 0.793617785 * mRoot - 0.0040720468 * sRoot
|
||||
const a = 1.9779984951 * lRoot - 2.428592205 * mRoot + 0.4505937099 * sRoot
|
||||
const b = 0.0259040371 * lRoot + 0.7827717662 * mRoot - 0.808675766 * sRoot
|
||||
const chroma = Math.sqrt(a * a + b * b)
|
||||
const angle = Math.atan2(b, a) * (180 / Math.PI)
|
||||
return { l: lightness, c: chroma, h: angle < 0 ? angle + 360 : angle }
|
||||
}
|
||||
|
||||
function oklchToRgb(color: OklchColor) {
|
||||
const a = color.c * Math.cos((color.h * Math.PI) / 180)
|
||||
const b = color.c * Math.sin((color.h * Math.PI) / 180)
|
||||
const lRoot = color.l + 0.3963377774 * a + 0.2158037573 * b
|
||||
const mRoot = color.l - 0.1055613458 * a - 0.0638541728 * b
|
||||
const sRoot = color.l - 0.0894841775 * a - 1.291485548 * b
|
||||
const l = lRoot * lRoot * lRoot
|
||||
const m = mRoot * mRoot * mRoot
|
||||
const s = sRoot * sRoot * sRoot
|
||||
return {
|
||||
r: linearToSrgb(4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s),
|
||||
g: linearToSrgb(-1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s),
|
||||
b: linearToSrgb(-0.0041960863 * l - 0.7034186147 * m + 1.707614701 * s),
|
||||
}
|
||||
}
|
||||
|
||||
function fitOklch(color: OklchColor): OklchColor {
|
||||
const base = { l: clamp(color.l, 0, 1), c: Math.max(0, color.c), h: hue(color.h) }
|
||||
const rgb = oklchToRgb(base)
|
||||
if (rgb.r >= 0 && rgb.r <= 1 && rgb.g >= 0 && rgb.g <= 1 && rgb.b >= 0 && rgb.b <= 1) return base
|
||||
|
||||
const fitted = Array.from({ length: 24 }).reduce<OklchColor | undefined>((result, _, index) => {
|
||||
if (result) return result
|
||||
const next = { ...base, c: base.c * Math.pow(0.9, index + 1) }
|
||||
const output = oklchToRgb(next)
|
||||
if (output.r >= 0 && output.r <= 1 && output.g >= 0 && output.g <= 1 && output.b >= 0 && output.b <= 1) return next
|
||||
}, undefined)
|
||||
return fitted ?? { ...base, c: 0 }
|
||||
}
|
||||
|
||||
export function oklchToHex(color: OklchColor) {
|
||||
const rgb = oklchToRgb(fitOklch(color))
|
||||
const toHex = (value: number) =>
|
||||
Math.round(clamp(value, 0, 1) * 255)
|
||||
.toString(16)
|
||||
.padStart(2, "0")
|
||||
return `#${toHex(rgb.r)}${toHex(rgb.g)}${toHex(rgb.b)}`
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
import type { ThemeTokensDefinition } from "./index.js"
|
||||
import { ActionVariant, FeedbackKind } from "./schema.js"
|
||||
|
||||
export function fallback(): ThemeTokensDefinition {
|
||||
const red = "#ff0000"
|
||||
|
||||
return {
|
||||
text: {
|
||||
default: red,
|
||||
action: Object.fromEntries(ActionVariant.literals.map((variant) => [variant, { default: red }])),
|
||||
formfield: { default: red },
|
||||
feedback: Object.fromEntries(FeedbackKind.literals.map((kind) => [kind, { default: red }])),
|
||||
},
|
||||
background: {
|
||||
default: red,
|
||||
surface: { offset: red, overlay: red },
|
||||
action: Object.fromEntries(ActionVariant.literals.map((variant) => [variant, { default: red }])),
|
||||
formfield: { default: red },
|
||||
feedback: Object.fromEntries(FeedbackKind.literals.map((kind) => [kind, { default: red }])),
|
||||
},
|
||||
border: { default: red },
|
||||
scrollbar: { default: red },
|
||||
diff: {
|
||||
text: { added: red, removed: red, context: red, hunkHeader: red },
|
||||
background: { added: red, removed: red, context: red },
|
||||
highlight: { added: red, removed: red },
|
||||
lineNumber: { text: red, background: { added: red, removed: red } },
|
||||
},
|
||||
syntax: {
|
||||
comment: red,
|
||||
keyword: red,
|
||||
function: red,
|
||||
variable: red,
|
||||
string: red,
|
||||
number: red,
|
||||
type: red,
|
||||
operator: red,
|
||||
punctuation: red,
|
||||
},
|
||||
markdown: {
|
||||
text: red,
|
||||
heading: red,
|
||||
link: red,
|
||||
linkText: red,
|
||||
code: red,
|
||||
blockQuote: red,
|
||||
emphasis: red,
|
||||
strong: red,
|
||||
horizontalRule: red,
|
||||
listItem: red,
|
||||
listEnumeration: red,
|
||||
image: red,
|
||||
imageText: red,
|
||||
codeBlock: red,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -1,76 +0,0 @@
|
||||
import type { RGBA } from "@opentui/core"
|
||||
|
||||
export type Theme = {
|
||||
readonly primary: RGBA
|
||||
readonly secondary: RGBA
|
||||
readonly accent: RGBA
|
||||
readonly error: RGBA
|
||||
readonly warning: RGBA
|
||||
readonly success: RGBA
|
||||
readonly info: RGBA
|
||||
readonly text: RGBA
|
||||
readonly textMuted: RGBA
|
||||
readonly selectedListItemText: RGBA
|
||||
readonly background: RGBA
|
||||
readonly backgroundPanel: RGBA
|
||||
readonly backgroundElement: RGBA
|
||||
readonly backgroundMenu: RGBA
|
||||
readonly border: RGBA
|
||||
readonly borderActive: RGBA
|
||||
readonly borderSubtle: RGBA
|
||||
readonly diffAdded: RGBA
|
||||
readonly diffRemoved: RGBA
|
||||
readonly diffContext: RGBA
|
||||
readonly diffHunkHeader: RGBA
|
||||
readonly diffHighlightAdded: RGBA
|
||||
readonly diffHighlightRemoved: RGBA
|
||||
readonly diffAddedBg: RGBA
|
||||
readonly diffRemovedBg: RGBA
|
||||
readonly diffContextBg: RGBA
|
||||
readonly diffLineNumber: RGBA
|
||||
readonly diffAddedLineNumberBg: RGBA
|
||||
readonly diffRemovedLineNumberBg: RGBA
|
||||
readonly markdownText: RGBA
|
||||
readonly markdownHeading: RGBA
|
||||
readonly markdownLink: RGBA
|
||||
readonly markdownLinkText: RGBA
|
||||
readonly markdownCode: RGBA
|
||||
readonly markdownBlockQuote: RGBA
|
||||
readonly markdownEmph: RGBA
|
||||
readonly markdownStrong: RGBA
|
||||
readonly markdownHorizontalRule: RGBA
|
||||
readonly markdownListItem: RGBA
|
||||
readonly markdownListEnumeration: RGBA
|
||||
readonly markdownImage: RGBA
|
||||
readonly markdownImageText: RGBA
|
||||
readonly markdownCodeBlock: RGBA
|
||||
readonly syntaxComment: RGBA
|
||||
readonly syntaxKeyword: RGBA
|
||||
readonly syntaxFunction: RGBA
|
||||
readonly syntaxVariable: RGBA
|
||||
readonly syntaxString: RGBA
|
||||
readonly syntaxNumber: RGBA
|
||||
readonly syntaxType: RGBA
|
||||
readonly syntaxOperator: RGBA
|
||||
readonly syntaxPunctuation: RGBA
|
||||
readonly thinkingOpacity: number
|
||||
_hasSelectedListItemText: boolean
|
||||
}
|
||||
|
||||
export type ThemeColor = Exclude<keyof Theme, "thinkingOpacity" | "_hasSelectedListItemText">
|
||||
export type HexColor = `#${string}`
|
||||
export type RefName = string
|
||||
export type Variant = {
|
||||
dark: HexColor | RefName
|
||||
light: HexColor | RefName
|
||||
}
|
||||
export type ColorValue = HexColor | RefName | Variant | RGBA | number
|
||||
export type ThemeV1Json = {
|
||||
$schema?: string
|
||||
defs?: Record<string, HexColor | RefName>
|
||||
theme: Omit<Record<ThemeColor, ColorValue>, "selectedListItemText" | "backgroundMenu"> & {
|
||||
selectedListItemText?: ColorValue
|
||||
backgroundMenu?: ColorValue
|
||||
thinkingOpacity?: number
|
||||
}
|
||||
}
|
||||
Vendored
-10
@@ -1,10 +0,0 @@
|
||||
/* This file is auto-generated by SST. Do not edit. */
|
||||
/* tslint:disable */
|
||||
/* eslint-disable */
|
||||
/* deno-fmt-ignore-file */
|
||||
/* biome-ignore-all lint: auto-generated */
|
||||
|
||||
/// <reference path="../../sst-env.d.ts" />
|
||||
|
||||
import "sst"
|
||||
export {}
|
||||
@@ -1,11 +0,0 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "dist",
|
||||
"noEmit": false,
|
||||
"declaration": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"extends": "@tsconfig/bun/tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"lib": ["ESNext", "DOM", "DOM.Iterable"],
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"allowImportingTsExtensions": false,
|
||||
"allowJs": false,
|
||||
"noUncheckedIndexedAccess": false
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@
|
||||
},
|
||||
"exports": {
|
||||
".": "./src/index.tsx",
|
||||
"./builtins": "./src/feature-plugins/builtins.ts",
|
||||
"./config": "./src/config/index.tsx",
|
||||
"./config/keybind": "./src/config/keybind.ts",
|
||||
"./config/v1": "./src/config/v1/index.tsx",
|
||||
@@ -36,6 +37,9 @@
|
||||
"./context/keymap": "./src/context/keymap.tsx",
|
||||
"./prompt/content": "./src/prompt/content.ts",
|
||||
"./prompt/display": "./src/prompt/display.ts",
|
||||
"./plugin/runtime": "./src/plugin/runtime.tsx",
|
||||
"./plugin/slots": "./src/plugin/slots.tsx",
|
||||
"./plugin/command-shim": "./src/plugin/command-shim.ts",
|
||||
"./parsers-config": "./src/parsers-config.ts",
|
||||
"./util/error": "./src/util/error.ts",
|
||||
"./util/filetype": "./src/util/filetype.ts",
|
||||
@@ -79,7 +83,6 @@
|
||||
"@opencode-ai/plugin": "workspace:*",
|
||||
"@opencode-ai/schema": "workspace:*",
|
||||
"@opencode-ai/simulation": "workspace:*",
|
||||
"@opencode-ai/theme": "workspace:*",
|
||||
"@opencode-ai/ui": "workspace:*",
|
||||
"@opencode-ai/util": "workspace:*",
|
||||
"@opentui/core": "catalog:",
|
||||
|
||||
+112
-200
@@ -51,7 +51,6 @@ import { StartupLoading } from "./component/startup-loading"
|
||||
import { DevToolsBar } from "./component/devtools-bar"
|
||||
import { Reconnecting } from "./component/reconnecting"
|
||||
import { DataProvider, useData } from "./context/data"
|
||||
import { SessionTabsProvider, useSessionTabs } from "./context/session-tabs"
|
||||
import { LocationProvider, useLocation } from "./context/location"
|
||||
import { LocalProvider, useLocal } from "./context/local"
|
||||
import { PermissionProvider } from "./context/permission"
|
||||
@@ -66,9 +65,8 @@ 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 { SessionTabs } from "./component/session-tabs"
|
||||
import { ThemeErrorToast } from "./component/theme-error-toast"
|
||||
import { ThemeProvider, useTheme, useThemes } from "./context/theme"
|
||||
import { ThemeProvider, useTheme } from "./context/theme"
|
||||
import { Home } from "./routes/home"
|
||||
import { Session } from "./routes/session"
|
||||
import { PromptHistoryProvider } from "./component/prompt/history"
|
||||
@@ -81,6 +79,7 @@ import { ArgsProvider, useArgs, type Args } from "./context/args"
|
||||
import open from "open"
|
||||
import { PromptRefProvider, usePromptRef } from "./context/prompt"
|
||||
import { Config, ConfigProvider, useConfig } from "./config"
|
||||
import { createPluginRuntime, PluginRuntimeProvider, usePluginRuntime } from "./plugin/runtime"
|
||||
import { PluginProvider, PluginRoute, PluginSlot, usePlugin, type PackageResolver } from "./plugin/context"
|
||||
import { CommandPaletteDialog } from "./component/command-palette"
|
||||
import { COMMAND_PALETTE_COMMAND, Keymap, type KeymapCommand } from "./context/keymap"
|
||||
@@ -93,28 +92,9 @@ import { AttentionProvider } from "./context/attention"
|
||||
|
||||
registerOpencodeSpinner()
|
||||
|
||||
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.select.1",
|
||||
"session.tab.select.2",
|
||||
"session.tab.select.3",
|
||||
"session.tab.select.4",
|
||||
"session.tab.select.5",
|
||||
"session.tab.select.6",
|
||||
"session.tab.select.7",
|
||||
"session.tab.select.8",
|
||||
"session.tab.select.9",
|
||||
] as const
|
||||
|
||||
const pinnedSessionBindingCommands = [
|
||||
const appGlobalBindingCommands = [
|
||||
"session.list",
|
||||
"session.new",
|
||||
"session.quick_switch.1",
|
||||
"session.quick_switch.2",
|
||||
"session.quick_switch.3",
|
||||
@@ -274,6 +254,8 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
|
||||
() => Effect.sync(() => process.off("SIGHUP", onSighup)),
|
||||
)
|
||||
renderer.once("destroy", () => Deferred.doneUnsafe(shutdown, Effect.void))
|
||||
const pluginRuntime = createPluginRuntime()
|
||||
|
||||
yield* Effect.tryPromise(async () => {
|
||||
// Prewarm palette before ThemeProvider mounts so `system` theme avoids a first-paint fallback flash.
|
||||
void renderer.getPalette({ size: 16 }).catch(() => undefined)
|
||||
@@ -295,113 +277,113 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
|
||||
<ErrorBoundary
|
||||
fallback={(error, reset) => <ErrorComponent error={error} reset={reset} mode={mode} />}
|
||||
>
|
||||
<TuiPathsProvider
|
||||
<TuiPathsProvider
|
||||
value={{
|
||||
cwd: process.cwd(),
|
||||
home: global.home,
|
||||
state: global.state,
|
||||
worktree: global.data + "/worktree",
|
||||
}}
|
||||
>
|
||||
<TuiLifecycleProvider
|
||||
value={{
|
||||
cwd: process.cwd(),
|
||||
home: global.home,
|
||||
state: global.state,
|
||||
worktree: global.data + "/worktree",
|
||||
add(finalizer) {
|
||||
finalizers.add(finalizer)
|
||||
return () => finalizers.delete(finalizer)
|
||||
},
|
||||
}}
|
||||
>
|
||||
<TuiLifecycleProvider
|
||||
<TuiTerminalEnvironmentProvider
|
||||
value={{
|
||||
add(finalizer) {
|
||||
finalizers.add(finalizer)
|
||||
return () => finalizers.delete(finalizer)
|
||||
},
|
||||
platform: process.platform,
|
||||
multiplexer: process.env.TMUX ? "tmux" : process.env.STY ? "screen" : undefined,
|
||||
displayServer: process.env.WAYLAND_DISPLAY
|
||||
? "wayland"
|
||||
: process.env.DISPLAY
|
||||
? "x11"
|
||||
: undefined,
|
||||
}}
|
||||
>
|
||||
<TuiTerminalEnvironmentProvider
|
||||
<TuiStartupProvider
|
||||
value={{
|
||||
platform: process.platform,
|
||||
multiplexer: process.env.TMUX ? "tmux" : process.env.STY ? "screen" : undefined,
|
||||
displayServer: process.env.WAYLAND_DISPLAY
|
||||
? "wayland"
|
||||
: process.env.DISPLAY
|
||||
? "x11"
|
||||
initialRoute: process.env.OPENCODE_SCRAP
|
||||
? { type: "plugin", id: "scrap", name: "scrap" }
|
||||
: process.env.OPENCODE_ROUTE
|
||||
? JSON.parse(process.env.OPENCODE_ROUTE)
|
||||
: undefined,
|
||||
skipInitialLoading: Boolean(process.env.OPENCODE_FAST_BOOT),
|
||||
}}
|
||||
>
|
||||
<TuiStartupProvider
|
||||
value={{
|
||||
initialRoute: process.env.OPENCODE_SCRAP
|
||||
? { type: "plugin", id: "scrap", name: "scrap" }
|
||||
: process.env.OPENCODE_ROUTE
|
||||
? JSON.parse(process.env.OPENCODE_ROUTE)
|
||||
: undefined,
|
||||
skipInitialLoading: Boolean(process.env.OPENCODE_FAST_BOOT),
|
||||
}}
|
||||
>
|
||||
<ClipboardProvider>
|
||||
<ArgsProvider {...input.args}>
|
||||
<ConfigProvider
|
||||
config={config}
|
||||
service={input.config}
|
||||
options={{ terminalSuspend: process.platform !== "win32" }}
|
||||
>
|
||||
<Keymap.Provider>
|
||||
<ToastProvider>
|
||||
<RouteProvider
|
||||
initialRoute={
|
||||
input.args.continue
|
||||
? {
|
||||
type: "session",
|
||||
sessionID: "dummy",
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<ClipboardProvider>
|
||||
<ArgsProvider {...input.args}>
|
||||
<ConfigProvider
|
||||
config={config}
|
||||
service={input.config}
|
||||
options={{ terminalSuspend: process.platform !== "win32" }}
|
||||
>
|
||||
<Keymap.Provider>
|
||||
<ToastProvider>
|
||||
<RouteProvider
|
||||
initialRoute={
|
||||
input.args.continue
|
||||
? {
|
||||
type: "session",
|
||||
sessionID: "dummy",
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<PluginRuntimeProvider value={pluginRuntime}>
|
||||
<ClientProvider api={api} service={service}>
|
||||
<PermissionProvider>
|
||||
<DataProvider>
|
||||
<LocationProvider>
|
||||
<SessionTabsProvider>
|
||||
<ThemeProvider mode={mode}>
|
||||
<ThemeErrorToast />
|
||||
<LocalProvider>
|
||||
<PromptStashProvider>
|
||||
<DialogProvider>
|
||||
<FrecencyProvider>
|
||||
<PromptHistoryProvider>
|
||||
<PromptRefProvider>
|
||||
<EditorContextProvider>
|
||||
<AttentionProvider>
|
||||
<PluginProvider packages={input.packages}>
|
||||
<App
|
||||
pair={
|
||||
input.server.endpoint.auth
|
||||
? input.server.endpoint.auth
|
||||
: {
|
||||
username: "opencode",
|
||||
password: "",
|
||||
}
|
||||
}
|
||||
/>
|
||||
</PluginProvider>
|
||||
</AttentionProvider>
|
||||
</EditorContextProvider>
|
||||
</PromptRefProvider>
|
||||
</PromptHistoryProvider>
|
||||
</FrecencyProvider>
|
||||
</DialogProvider>
|
||||
</PromptStashProvider>
|
||||
</LocalProvider>
|
||||
</ThemeProvider>
|
||||
</SessionTabsProvider>
|
||||
<ThemeProvider mode={mode}>
|
||||
<ThemeErrorToast />
|
||||
<LocalProvider>
|
||||
<PromptStashProvider>
|
||||
<DialogProvider>
|
||||
<FrecencyProvider>
|
||||
<PromptHistoryProvider>
|
||||
<PromptRefProvider>
|
||||
<EditorContextProvider>
|
||||
<AttentionProvider>
|
||||
<PluginProvider packages={input.packages}>
|
||||
<App
|
||||
pair={
|
||||
input.server.endpoint.auth
|
||||
? input.server.endpoint.auth
|
||||
: {
|
||||
username: "opencode",
|
||||
password: "",
|
||||
}
|
||||
}
|
||||
/>
|
||||
</PluginProvider>
|
||||
</AttentionProvider>
|
||||
</EditorContextProvider>
|
||||
</PromptRefProvider>
|
||||
</PromptHistoryProvider>
|
||||
</FrecencyProvider>
|
||||
</DialogProvider>
|
||||
</PromptStashProvider>
|
||||
</LocalProvider>
|
||||
</ThemeProvider>
|
||||
</LocationProvider>
|
||||
</DataProvider>
|
||||
</PermissionProvider>
|
||||
</ClientProvider>
|
||||
</RouteProvider>
|
||||
</ToastProvider>
|
||||
</Keymap.Provider>
|
||||
</ConfigProvider>
|
||||
</ArgsProvider>
|
||||
</ClipboardProvider>
|
||||
</TuiStartupProvider>
|
||||
</TuiTerminalEnvironmentProvider>
|
||||
</TuiLifecycleProvider>
|
||||
</TuiPathsProvider>
|
||||
</PluginRuntimeProvider>
|
||||
</RouteProvider>
|
||||
</ToastProvider>
|
||||
</Keymap.Provider>
|
||||
</ConfigProvider>
|
||||
</ArgsProvider>
|
||||
</ClipboardProvider>
|
||||
</TuiStartupProvider>
|
||||
</TuiTerminalEnvironmentProvider>
|
||||
</TuiLifecycleProvider>
|
||||
</TuiPathsProvider>
|
||||
</ErrorBoundary>
|
||||
</TuiAppProvider>
|
||||
</EpilogueProvider>
|
||||
@@ -437,17 +419,17 @@ function App(props: { pair?: DialogPairCredentials }) {
|
||||
const renderer = useRenderer()
|
||||
const dialog = useDialog()
|
||||
const local = useLocal()
|
||||
const sessionTabs = useSessionTabs()
|
||||
const keymap = Keymap.use()
|
||||
const event = useEvent()
|
||||
const client = useClient()
|
||||
const toast = useToast()
|
||||
const theme = useTheme()
|
||||
const { mode, supports, setMode, locked, lock, unlock } = useThemes()
|
||||
const themeState = useTheme()
|
||||
const { themeV2, mode, supports, setMode, locked, lock, unlock } = themeState
|
||||
const data = useData()
|
||||
const location = useLocation()
|
||||
const exit = useExit()
|
||||
const promptRef = usePromptRef()
|
||||
const pluginRuntime = usePluginRuntime()
|
||||
const plugins = usePlugin()
|
||||
const clipboard = useClipboard()
|
||||
|
||||
@@ -641,71 +623,9 @@ function App(props: { pair?: DialogPairCredentials }) {
|
||||
title: `Switch to session in quick slot ${i + 1}`,
|
||||
category: "Session",
|
||||
palette: undefined,
|
||||
enabled: () => !sessionTabs.enabled(),
|
||||
run: () => local.session.quickSwitch(i + 1),
|
||||
})),
|
||||
{
|
||||
name: "session.tab.next",
|
||||
title: "Next open session tab",
|
||||
category: "Session",
|
||||
palette: undefined,
|
||||
enabled: sessionTabs.enabled,
|
||||
run: () => sessionTabs.cycle(1),
|
||||
},
|
||||
{
|
||||
name: "session.tab.previous",
|
||||
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 session tab",
|
||||
category: "Session",
|
||||
palette: undefined,
|
||||
enabled: sessionTabs.enabled,
|
||||
run: () => sessionTabs.cycleUnread(1),
|
||||
},
|
||||
{
|
||||
name: "session.tab.previous_unread",
|
||||
title: "Previous unread session tab",
|
||||
category: "Session",
|
||||
palette: undefined,
|
||||
enabled: sessionTabs.enabled,
|
||||
run: () => sessionTabs.cycleUnread(-1),
|
||||
},
|
||||
{
|
||||
name: "session.tab.close",
|
||||
title: "Close current session tab",
|
||||
category: "Session",
|
||||
enabled: sessionTabs.enabled,
|
||||
run: () => sessionTabs.close(),
|
||||
},
|
||||
...Array.from({ length: 9 }, (_, i) => ({
|
||||
name: `session.tab.select.${i + 1}`,
|
||||
title: `Switch to session tab ${i + 1}`,
|
||||
category: "Session",
|
||||
palette: undefined,
|
||||
enabled: sessionTabs.enabled,
|
||||
run: () => sessionTabs.selectIndex(i),
|
||||
run: () => {
|
||||
local.session.quickSwitch(i + 1)
|
||||
},
|
||||
})),
|
||||
{
|
||||
name: "model.list",
|
||||
@@ -1084,18 +1004,6 @@ function App(props: { pair?: DialogPairCredentials }) {
|
||||
bindings: appGlobalBindingCommands,
|
||||
}))
|
||||
|
||||
Keymap.createLayer(() => ({
|
||||
mode: "global",
|
||||
enabled: sessionTabs.enabled,
|
||||
bindings: sessionTabBindingCommands,
|
||||
}))
|
||||
|
||||
Keymap.createLayer(() => ({
|
||||
mode: "global",
|
||||
enabled: () => !sessionTabs.enabled(),
|
||||
bindings: pinnedSessionBindingCommands,
|
||||
}))
|
||||
|
||||
Keymap.createLayer(() => ({
|
||||
enabled: () => {
|
||||
const current = promptRef.current
|
||||
@@ -1182,7 +1090,7 @@ function App(props: { pair?: DialogPairCredentials }) {
|
||||
width={dimensions().width}
|
||||
height={dimensions().height}
|
||||
flexDirection="column"
|
||||
backgroundColor={theme.background.default}
|
||||
backgroundColor={themeV2.background.default}
|
||||
onMouseDown={(evt) => {
|
||||
if (copyOnSelectEnabled()) return
|
||||
if (evt.button !== MouseButton.RIGHT) return
|
||||
@@ -1191,15 +1099,16 @@ function App(props: { pair?: DialogPairCredentials }) {
|
||||
evt.preventDefault()
|
||||
evt.stopPropagation()
|
||||
}}
|
||||
onMouseUp={copyOnSelectEnabled() ? () => Selection.copy(renderer, toast, clipboard) : undefined}
|
||||
onMouseUp={
|
||||
copyOnSelectEnabled()
|
||||
? () => Selection.copy(renderer, toast, clipboard)
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<box flexGrow={1} minHeight={0} flexDirection="row">
|
||||
<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 > 1 && route.data.type !== "plugin"}>
|
||||
<SessionTabs />
|
||||
</Show>
|
||||
<Switch>
|
||||
<Match when={route.data.type === "home"}>
|
||||
<Home />
|
||||
@@ -1218,7 +1127,10 @@ function App(props: { pair?: DialogPairCredentials }) {
|
||||
</Match>
|
||||
</Switch>
|
||||
</box>
|
||||
<PluginSlot name="app" input={{}} mode="all" />
|
||||
<box flexShrink={0}>
|
||||
<PluginSlot name="app.bottom" />
|
||||
</box>
|
||||
<PluginSlot name="app" />
|
||||
</Show>
|
||||
</box>
|
||||
</box>
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
} from "@opentui/core"
|
||||
import { extend, useRenderer } from "@opentui/solid"
|
||||
import { onCleanup, onMount } from "solid-js"
|
||||
import { useThemes } from "../context/theme"
|
||||
import { useTheme } from "../context/theme"
|
||||
import { tint } from "../theme/color"
|
||||
import { GoUpsellArtPainter } from "./bg-pulse-render"
|
||||
|
||||
@@ -70,9 +70,7 @@ declare module "@opentui/solid" {
|
||||
extend({ go_upsell_art: GoUpsellArtRenderable })
|
||||
|
||||
export function BgPulse() {
|
||||
const themes = useThemes()
|
||||
const theme = themes.contextual("elevated")
|
||||
const mode = themes.mode
|
||||
const { themeV2, mode } = useTheme().contextual("elevated")
|
||||
const renderer = useRenderer()
|
||||
let targetFps = renderer.targetFps
|
||||
let maxFps = renderer.maxFps
|
||||
@@ -93,9 +91,9 @@ export function BgPulse() {
|
||||
<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)}
|
||||
backgroundPanel={themeV2.background.default}
|
||||
primary={themeV2.hue.interactive[mode() === "light" ? 800 : 200]}
|
||||
logoBase={tint(themeV2.background.default, themeV2.text.default, 0.62)}
|
||||
live
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -11,7 +11,7 @@ import { useData } from "../context/data"
|
||||
import { useLocation } from "../context/location"
|
||||
import { useRoute } from "../context/route"
|
||||
import { Keymap } from "../context/keymap"
|
||||
import { useTheme, useThemes } from "../context/theme"
|
||||
import { useTheme } from "../context/theme"
|
||||
import { DevTools } from "../devtools"
|
||||
import { usePlugin } from "../plugin/context"
|
||||
import { errorMessage } from "../util/error"
|
||||
@@ -31,12 +31,12 @@ export function DevToolsBar() {
|
||||
const location = useLocation()
|
||||
const route = useRoute()
|
||||
const plugins = usePlugin()
|
||||
const themes = useThemes()
|
||||
const theme = useTheme()
|
||||
const keymap = Keymap.use()
|
||||
const renderer = useRenderer()
|
||||
const dimensions = useTerminalDimensions()
|
||||
const { current: theme, mode, supports, setMode } = themes
|
||||
const elevatedTheme = themes.contextual("elevated")
|
||||
const { themeV2, mode, supports, setMode } = theme
|
||||
const elevatedTheme = theme.contextual("elevated").themeV2
|
||||
const [panel, setPanel] = createSignal<Panel>()
|
||||
const [dumping, setDumping] = createSignal(false)
|
||||
const [dumpPath, setDumpPath] = createSignal<string>()
|
||||
@@ -196,8 +196,8 @@ export function DevToolsBar() {
|
||||
})),
|
||||
},
|
||||
theme: {
|
||||
name: themes.selected,
|
||||
mode: themes.mode(),
|
||||
name: theme.selected,
|
||||
mode: theme.mode(),
|
||||
},
|
||||
},
|
||||
null,
|
||||
@@ -213,7 +213,7 @@ export function DevToolsBar() {
|
||||
}
|
||||
|
||||
return (
|
||||
<box height={1} flexShrink={0} flexDirection="row" backgroundColor={theme.raise(theme.background.default)}>
|
||||
<box height={1} flexShrink={0} flexDirection="row" backgroundColor={themeV2.raise(themeV2.background.default)}>
|
||||
<Show when={panel()}>
|
||||
<box
|
||||
position="absolute"
|
||||
@@ -230,12 +230,12 @@ export function DevToolsBar() {
|
||||
<text
|
||||
fg={
|
||||
panel() === "server"
|
||||
? theme.text.action.primary.focused
|
||||
? themeV2.text.action.primary.focused
|
||||
: serverIndicator().state === "connected"
|
||||
? theme.text.feedback.success.default
|
||||
? themeV2.text.feedback.success.default
|
||||
: serverIndicator().state === "disconnected"
|
||||
? theme.text.feedback.error.default
|
||||
: theme.text.default
|
||||
? themeV2.text.feedback.error.default
|
||||
: themeV2.text.default
|
||||
}
|
||||
>
|
||||
{serverIndicator().icon}
|
||||
@@ -243,10 +243,10 @@ export function DevToolsBar() {
|
||||
<text
|
||||
fg={
|
||||
panel() === "server"
|
||||
? theme.text.action.primary.focused
|
||||
? themeV2.text.action.primary.focused
|
||||
: serverIndicator().state === "disconnected"
|
||||
? theme.text.feedback.error.default
|
||||
: theme.text.subdued
|
||||
? themeV2.text.feedback.error.default
|
||||
: themeV2.text.subdued
|
||||
}
|
||||
>
|
||||
{" "}
|
||||
@@ -279,10 +279,10 @@ export function DevToolsBar() {
|
||||
<text
|
||||
fg={
|
||||
panel() === "ui"
|
||||
? theme.text.action.primary.focused
|
||||
? themeV2.text.action.primary.focused
|
||||
: runtime() === "high"
|
||||
? theme.text.feedback.error.default
|
||||
: theme.text.subdued
|
||||
? themeV2.text.feedback.error.default
|
||||
: themeV2.text.subdued
|
||||
}
|
||||
>
|
||||
{statusIcon(runtime())}
|
||||
@@ -290,10 +290,10 @@ export function DevToolsBar() {
|
||||
<text
|
||||
fg={
|
||||
panel() === "ui"
|
||||
? theme.text.action.primary.focused
|
||||
? themeV2.text.action.primary.focused
|
||||
: runtime() === "high"
|
||||
? theme.text.feedback.error.default
|
||||
: theme.text.subdued
|
||||
? themeV2.text.feedback.error.default
|
||||
: themeV2.text.subdued
|
||||
}
|
||||
>
|
||||
{" "}
|
||||
@@ -320,11 +320,11 @@ export function DevToolsBar() {
|
||||
</Show>
|
||||
</BarItem>
|
||||
<BarItem active={panel() === "theme"} onClick={() => toggle("theme")}>
|
||||
<text fg={panel() === "theme" ? theme.text.action.primary.focused : theme.text.subdued}>Theme</text>
|
||||
<text fg={panel() === "theme" ? themeV2.text.action.primary.focused : themeV2.text.subdued}>Theme</text>
|
||||
<Show when={panel() === "theme"}>
|
||||
<PanelBox>
|
||||
<PanelTitle>Theme</PanelTitle>
|
||||
<Row label="Name" value={themes.selected} />
|
||||
<Row label="Name" value={theme.selected} />
|
||||
<Row label="Mode" value={mode()} />
|
||||
<For each={themePerformance()}>{(entry) => <Row label={entry.key} value={String(entry.value)} />}</For>
|
||||
<Show when={canSwitchMode()}>
|
||||
@@ -336,7 +336,7 @@ export function DevToolsBar() {
|
||||
</Show>
|
||||
</BarItem>
|
||||
<BarItem active={panel() === "tools"} onClick={() => toggle("tools")}>
|
||||
<text fg={panel() === "tools" ? theme.text.action.primary.focused : theme.text.subdued}>Tools</text>
|
||||
<text fg={panel() === "tools" ? themeV2.text.action.primary.focused : themeV2.text.subdued}>Tools</text>
|
||||
<Show when={panel() === "tools"}>
|
||||
<PanelBox>
|
||||
<PanelTitle>Tools</PanelTitle>
|
||||
@@ -406,14 +406,14 @@ export function DevToolsBar() {
|
||||
</Show>
|
||||
</BarItem>
|
||||
<box flexGrow={1} minWidth={0}>
|
||||
<TimeToFirstDraw visible={timing()} width="100%" fg={theme.text.subdued} label="Time to first draw" />
|
||||
<TimeToFirstDraw visible={timing()} width="100%" fg={themeV2.text.subdued} label="Time to first draw" />
|
||||
</box>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
|
||||
function BarItem(props: ParentProps<{ active: boolean; onClick: () => void }>) {
|
||||
const theme = useTheme()
|
||||
const { themeV2 } = useTheme()
|
||||
const renderer = useRenderer()
|
||||
return (
|
||||
<box
|
||||
@@ -423,7 +423,7 @@ function BarItem(props: ParentProps<{ active: boolean; onClick: () => void }>) {
|
||||
flexDirection="row"
|
||||
paddingLeft={1}
|
||||
paddingRight={1}
|
||||
backgroundColor={props.active ? theme.background.action.primary.focused : undefined}
|
||||
backgroundColor={props.active ? themeV2.background.action.primary.focused : undefined}
|
||||
onMouseUp={() => {
|
||||
if (renderer.getSelection()?.getSelectedText()) return
|
||||
props.onClick()
|
||||
@@ -435,7 +435,7 @@ function BarItem(props: ParentProps<{ active: boolean; onClick: () => void }>) {
|
||||
}
|
||||
|
||||
function PanelBox(props: ParentProps) {
|
||||
const theme = useThemes().contextual("elevated")
|
||||
const { themeV2 } = useTheme().contextual("elevated")
|
||||
const renderer = useRenderer()
|
||||
return (
|
||||
<box
|
||||
@@ -448,7 +448,7 @@ function PanelBox(props: ParentProps) {
|
||||
paddingRight={2}
|
||||
paddingTop={1}
|
||||
paddingBottom={1}
|
||||
backgroundColor={theme.background.default}
|
||||
backgroundColor={themeV2.background.default}
|
||||
flexDirection="column"
|
||||
onMouseUp={(event) => {
|
||||
if (renderer.getSelection()?.getSelectedText()) return
|
||||
@@ -461,32 +461,32 @@ function PanelBox(props: ParentProps) {
|
||||
}
|
||||
|
||||
function PanelTitle(props: ParentProps) {
|
||||
const theme = useThemes().contextual("elevated")
|
||||
const { themeV2 } = useTheme().contextual("elevated")
|
||||
return (
|
||||
<text fg={theme.text.default} attributes={TextAttributes.BOLD} marginBottom={1}>
|
||||
<text fg={themeV2.text.default} attributes={TextAttributes.BOLD} marginBottom={1}>
|
||||
{props.children}
|
||||
</text>
|
||||
)
|
||||
}
|
||||
|
||||
function Row(props: { label: string; value: string }) {
|
||||
const theme = useThemes().contextual("elevated")
|
||||
const { themeV2 } = useTheme().contextual("elevated")
|
||||
return (
|
||||
<box flexDirection="row">
|
||||
<text fg={theme.text.subdued}>{props.label}</text>
|
||||
<text fg={themeV2.text.subdued}>{props.label}</text>
|
||||
<box flexGrow={1} />
|
||||
<text fg={theme.text.default}>{props.value}</text>
|
||||
<text fg={themeV2.text.default}>{props.value}</text>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
|
||||
function Action(props: ParentProps<{ onClick: () => void; disabled?: boolean; hoverBackground?: boolean }>) {
|
||||
const theme = useThemes().contextual("elevated")
|
||||
const { themeV2 } = useTheme().contextual("elevated")
|
||||
const [hovered, setHovered] = createSignal(false)
|
||||
return (
|
||||
<box
|
||||
backgroundColor={
|
||||
props.hoverBackground && hovered() && !props.disabled ? theme.background.action.primary.hovered : undefined
|
||||
props.hoverBackground && hovered() && !props.disabled ? themeV2.background.action.primary.hovered : undefined
|
||||
}
|
||||
onMouseOver={() => setHovered(true)}
|
||||
onMouseOut={() => setHovered(false)}
|
||||
@@ -495,7 +495,7 @@ function Action(props: ParentProps<{ onClick: () => void; disabled?: boolean; ho
|
||||
if (!props.disabled) props.onClick()
|
||||
}}
|
||||
>
|
||||
<text fg={props.disabled ? theme.text.subdued : theme.text.action.primary.default}>{props.children}</text>
|
||||
<text fg={props.disabled ? themeV2.text.subdued : themeV2.text.action.primary.default}>{props.children}</text>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
@@ -506,7 +506,7 @@ function cpuPercent(microseconds: number, milliseconds: number) {
|
||||
}
|
||||
|
||||
function ProcessStat(props: { label: string; values: readonly number[]; unit: string; decimals?: number }) {
|
||||
const theme = useThemes().contextual("elevated")
|
||||
const { themeV2 } = useTheme().contextual("elevated")
|
||||
const value = () => {
|
||||
const value = props.values.at(-1)
|
||||
if (value === undefined) return "--"
|
||||
@@ -515,13 +515,13 @@ function ProcessStat(props: { label: string; values: readonly number[]; unit: st
|
||||
return (
|
||||
<box flexDirection="row">
|
||||
<box width={7}>
|
||||
<text fg={theme.text.subdued}>{props.label}</text>
|
||||
<text fg={themeV2.text.subdued}>{props.label}</text>
|
||||
</box>
|
||||
<box flexGrow={1}>
|
||||
<text fg={props.values.length ? theme.text.default : theme.text.subdued}>{brailleGraph(props.values)}</text>
|
||||
<text fg={props.values.length ? themeV2.text.default : themeV2.text.subdued}>{brailleGraph(props.values)}</text>
|
||||
</box>
|
||||
<box width={8} alignItems="flex-end">
|
||||
<text fg={props.values.length ? theme.text.default : theme.text.subdued}>{value()}</text>
|
||||
<text fg={props.values.length ? themeV2.text.default : themeV2.text.subdued}>{value()}</text>
|
||||
</box>
|
||||
</box>
|
||||
)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { createMemo, createSignal } from "solid-js"
|
||||
import { useConfig } from "../config"
|
||||
import { useThemes } from "../context/theme"
|
||||
import { useTheme } from "../context/theme"
|
||||
import { DialogSelect } from "../ui/dialog-select"
|
||||
import { useToast } from "../ui/toast"
|
||||
|
||||
@@ -93,14 +93,6 @@ export const settings: Setting[] = [
|
||||
values: ["none", "auto"],
|
||||
keywords: ["transcript", "messages"],
|
||||
},
|
||||
{
|
||||
title: "Enabled",
|
||||
category: "Tabs",
|
||||
path: ["tabs", "enabled"],
|
||||
default: false,
|
||||
values: [false, true],
|
||||
labels: ["off", "on"],
|
||||
},
|
||||
{
|
||||
title: "Layout",
|
||||
category: "Diffs",
|
||||
@@ -275,7 +267,7 @@ export function settingID(setting: Setting) {
|
||||
export function DialogConfig(props: { current?: string }) {
|
||||
const config = useConfig()
|
||||
const toast = useToast()
|
||||
const themes = useThemes()
|
||||
const themeState = useTheme()
|
||||
const current = Math.max(
|
||||
0,
|
||||
settings.findIndex((setting) => settingID(setting) === props.current),
|
||||
@@ -288,12 +280,12 @@ export function DialogConfig(props: { current?: string }) {
|
||||
if (!result || typeof result !== "object") return undefined
|
||||
return (result as Record<string, unknown>)[key]
|
||||
}, config.data)
|
||||
if (setting.path.join(".") === "theme.name") return current ?? themes.selected
|
||||
if (setting.path.join(".") === "theme.name") return current ?? themeState.selected
|
||||
return current ?? setting.default
|
||||
}
|
||||
const values = (setting: Setting) =>
|
||||
setting.path.join(".") === "theme.name"
|
||||
? Object.keys(themes.all()).sort((a, b) => a.localeCompare(b, undefined, { sensitivity: "base" }))
|
||||
? Object.keys(themeState.all()).sort((a, b) => a.localeCompare(b, undefined, { sensitivity: "base" }))
|
||||
: setting.values
|
||||
const display = (setting: Setting) => {
|
||||
const current = value(setting)
|
||||
|
||||
@@ -11,7 +11,7 @@ import { describeOS, describeTerminal } from "../util/system"
|
||||
import { useTuiApp } from "../context/runtime"
|
||||
|
||||
export function DialogDebug() {
|
||||
const theme = useTheme()
|
||||
const { themeV2 } = useTheme()
|
||||
const dialog = useDialog()
|
||||
const route = useRoute()
|
||||
const local = useLocal()
|
||||
@@ -55,10 +55,10 @@ export function DialogDebug() {
|
||||
return (
|
||||
<box paddingLeft={2} paddingRight={2} gap={1} paddingBottom={1}>
|
||||
<box flexDirection="row" justifyContent="space-between">
|
||||
<text fg={theme.text.default} attributes={TextAttributes.BOLD}>
|
||||
<text fg={themeV2.text.default} attributes={TextAttributes.BOLD}>
|
||||
Debug
|
||||
</text>
|
||||
<text fg={theme.text.subdued} onMouseUp={() => dialog.clear()}>
|
||||
<text fg={themeV2.text.subdued} onMouseUp={() => dialog.clear()}>
|
||||
esc
|
||||
</text>
|
||||
</box>
|
||||
@@ -68,10 +68,10 @@ export function DialogDebug() {
|
||||
<For each={entries()}>
|
||||
{(entry) => (
|
||||
<box flexDirection="row" gap={1}>
|
||||
<text flexShrink={0} fg={theme.text.subdued}>
|
||||
<text flexShrink={0} fg={themeV2.text.subdued}>
|
||||
{entry.label.padEnd(10)}
|
||||
</text>
|
||||
<text fg={theme.text.default} wrapMode="word">
|
||||
<text fg={themeV2.text.default} wrapMode="word">
|
||||
{entry.value}
|
||||
</text>
|
||||
</box>
|
||||
@@ -79,12 +79,12 @@ export function DialogDebug() {
|
||||
</For>
|
||||
</box>
|
||||
<box flexDirection="row" justifyContent="space-between">
|
||||
<text fg={theme.text.subdued}>Share this when reporting an issue.</text>
|
||||
<text fg={themeV2.text.subdued}>Share this when reporting an issue.</text>
|
||||
<text onMouseUp={copy}>
|
||||
<span style={{ fg: copied() ? theme.text.feedback.success.default : theme.text.default }}>
|
||||
<span style={{ fg: copied() ? themeV2.text.feedback.success.default : themeV2.text.default }}>
|
||||
<b>{copied() ? "✓ copied" : "copy"}</b>{" "}
|
||||
</span>
|
||||
<span style={{ fg: theme.text.subdued }}>enter</span>
|
||||
<span style={{ fg: themeV2.text.subdued }}>enter</span>
|
||||
</text>
|
||||
</box>
|
||||
</box>
|
||||
|
||||
@@ -11,7 +11,7 @@ import { useClipboard } from "../context/clipboard"
|
||||
import { useData } from "../context/data"
|
||||
import { useClient } from "../context/client"
|
||||
import { Keymap } from "../context/keymap"
|
||||
import { useThemes } from "../context/theme"
|
||||
import { useTheme } from "../context/theme"
|
||||
import { useDialog } from "../ui/dialog"
|
||||
import { DialogPrompt } from "../ui/dialog-prompt"
|
||||
import { DialogSelect } from "../ui/dialog-select"
|
||||
@@ -64,7 +64,7 @@ export function DialogIntegration(
|
||||
) {
|
||||
const data = useData()
|
||||
const dialog = useDialog()
|
||||
const theme = useThemes().contextual("elevated")
|
||||
const { themeV2 } = useTheme().contextual("elevated")
|
||||
const options = createMemo(() => {
|
||||
const providers = data.location.websearch.list() ?? []
|
||||
const providersByID = new Map(providers.map((provider) => [provider.id, provider]))
|
||||
@@ -87,7 +87,7 @@ export function DialogIntegration(
|
||||
disabled: methods.length === 0 && credentials.length === 0,
|
||||
gutter:
|
||||
integration.connections.length > 0
|
||||
? () => <text fg={theme.text.feedback.success.default}>✓</text>
|
||||
? () => <text fg={themeV2.text.feedback.success.default}>✓</text>
|
||||
: undefined,
|
||||
onSelect: () => {
|
||||
if (credentials.length) return manageConnections(integration, methods, dialog, props.onConnected)
|
||||
@@ -103,12 +103,12 @@ export function DialogIntegration(
|
||||
options={options()}
|
||||
emptyView={
|
||||
<box paddingLeft={4} paddingRight={4} paddingTop={1}>
|
||||
<text fg={theme.text.subdued}>No integrations available</text>
|
||||
<text fg={themeV2.text.subdued}>No integrations available</text>
|
||||
</box>
|
||||
}
|
||||
noMatchView={
|
||||
<box paddingLeft={4} paddingRight={4} paddingTop={1}>
|
||||
<text fg={theme.text.subdued}>No integrations found</text>
|
||||
<text fg={themeV2.text.subdued}>No integrations found</text>
|
||||
</box>
|
||||
}
|
||||
/>
|
||||
@@ -303,16 +303,16 @@ function CommandPending(props: {
|
||||
|
||||
function CommandView(props: { title: string; output: string; message: string }) {
|
||||
const dialog = useDialog()
|
||||
const theme = useThemes().contextual("elevated")
|
||||
const overlayTheme = useThemes().contextual("overlay")
|
||||
const { themeV2 } = useTheme().contextual("elevated")
|
||||
const { themeV2: overlayTheme } = useTheme().contextual("overlay")
|
||||
onMount(() => dialog.setSize("large"))
|
||||
return (
|
||||
<box gap={1} paddingBottom={1}>
|
||||
<box flexDirection="row" justifyContent="space-between" paddingLeft={2} paddingRight={2}>
|
||||
<text attributes={TextAttributes.BOLD} fg={theme.text.default}>
|
||||
<text attributes={TextAttributes.BOLD} fg={themeV2.text.default}>
|
||||
{props.title}
|
||||
</text>
|
||||
<text fg={theme.text.subdued} onMouseUp={() => dialog.clear()}>
|
||||
<text fg={themeV2.text.subdued} onMouseUp={() => dialog.clear()}>
|
||||
esc close
|
||||
</text>
|
||||
</box>
|
||||
@@ -326,7 +326,7 @@ function CommandView(props: { title: string; output: string; message: string })
|
||||
<text fg={overlayTheme.text.default}>{props.output.trim()}</text>
|
||||
</box>
|
||||
<box paddingLeft={2} paddingRight={2}>
|
||||
<text fg={theme.text.subdued}>{props.message}</text>
|
||||
<text fg={themeV2.text.subdued}>{props.message}</text>
|
||||
</box>
|
||||
</box>
|
||||
)
|
||||
@@ -341,7 +341,7 @@ function KeyMethod(props: {
|
||||
const dialog = useDialog()
|
||||
const client = useClient()
|
||||
const toast = useToast()
|
||||
const theme = useThemes().contextual("elevated")
|
||||
const { themeV2 } = useTheme().contextual("elevated")
|
||||
const [error, setError] = createSignal<string>()
|
||||
|
||||
return (
|
||||
@@ -360,7 +360,7 @@ function KeyMethod(props: {
|
||||
.catch((cause) => setError(message(cause)))
|
||||
}}
|
||||
description={() => (
|
||||
<Show when={error()}>{(value) => <text fg={theme.text.feedback.error.default}>{value()}</text>}</Show>
|
||||
<Show when={error()}>{(value) => <text fg={themeV2.text.feedback.error.default}>{value()}</text>}</Show>
|
||||
)}
|
||||
/>
|
||||
)
|
||||
@@ -516,7 +516,7 @@ function OAuthCode(props: {
|
||||
const dialog = useDialog()
|
||||
const client = useClient()
|
||||
const toast = useToast()
|
||||
const theme = useThemes().contextual("elevated")
|
||||
const { themeV2 } = useTheme().contextual("elevated")
|
||||
const [error, setError] = createSignal<string>()
|
||||
let settled = false
|
||||
|
||||
@@ -550,9 +550,9 @@ function OAuthCode(props: {
|
||||
}}
|
||||
description={() => (
|
||||
<box gap={1}>
|
||||
<text fg={theme.text.subdued}>{props.attempt.instructions}</text>
|
||||
<Link href={props.attempt.url} fg={theme.markdown.link} />
|
||||
<Show when={error()}>{(value) => <text fg={theme.text.feedback.error.default}>{value()}</text>}</Show>
|
||||
<text fg={themeV2.text.subdued}>{props.attempt.instructions}</text>
|
||||
<Link href={props.attempt.url} fg={themeV2.markdown.link} />
|
||||
<Show when={error()}>{(value) => <text fg={themeV2.text.feedback.error.default}>{value()}</text>}</Show>
|
||||
</box>
|
||||
)}
|
||||
/>
|
||||
@@ -561,31 +561,31 @@ function OAuthCode(props: {
|
||||
|
||||
function OAuthView(props: { title: string; url?: string; instructions?: string; message: string; copy?: boolean }) {
|
||||
const dialog = useDialog()
|
||||
const theme = useThemes().contextual("elevated")
|
||||
const { themeV2 } = useTheme().contextual("elevated")
|
||||
return (
|
||||
<box paddingLeft={2} paddingRight={2} gap={1} paddingBottom={1}>
|
||||
<box flexDirection="row" justifyContent="space-between">
|
||||
<text attributes={TextAttributes.BOLD} fg={theme.text.default}>
|
||||
<text attributes={TextAttributes.BOLD} fg={themeV2.text.default}>
|
||||
{props.title}
|
||||
</text>
|
||||
<text fg={theme.text.subdued} onMouseUp={() => dialog.clear()}>
|
||||
<text fg={themeV2.text.subdued} onMouseUp={() => dialog.clear()}>
|
||||
esc
|
||||
</text>
|
||||
</box>
|
||||
<Show when={props.url}>
|
||||
{(url) => (
|
||||
<box gap={1}>
|
||||
<Link href={url()} fg={theme.markdown.link} />
|
||||
<Link href={url()} fg={themeV2.markdown.link} />
|
||||
<Show when={props.instructions}>
|
||||
{(instructions) => <text fg={theme.text.subdued}>{instructions()}</text>}
|
||||
{(instructions) => <text fg={themeV2.text.subdued}>{instructions()}</text>}
|
||||
</Show>
|
||||
</box>
|
||||
)}
|
||||
</Show>
|
||||
<text fg={theme.text.subdued}>{props.message}</text>
|
||||
<text fg={themeV2.text.subdued}>{props.message}</text>
|
||||
<Show when={props.copy}>
|
||||
<text fg={theme.text.default}>
|
||||
c <span style={{ fg: theme.text.subdued }}>copy</span>
|
||||
<text fg={themeV2.text.default}>
|
||||
c <span style={{ fg: themeV2.text.subdued }}>copy</span>
|
||||
</text>
|
||||
</Show>
|
||||
</box>
|
||||
|
||||
@@ -5,7 +5,7 @@ import { Keymap } from "../context/keymap"
|
||||
import { pipe, sortBy } from "remeda"
|
||||
import { DialogSelect } from "../ui/dialog-select"
|
||||
import { useDialog } from "../ui/dialog"
|
||||
import { useThemes } from "../context/theme"
|
||||
import { useTheme } from "../context/theme"
|
||||
import { TextAttributes, type ScrollBoxRenderable } from "@opentui/core"
|
||||
import type { McpServer } from "@opencode-ai/client"
|
||||
import { useClipboard } from "../context/clipboard"
|
||||
@@ -20,12 +20,12 @@ function statusError(status: McpServer["status"]) {
|
||||
}
|
||||
|
||||
function Status(props: { enabled: boolean; loading: boolean }) {
|
||||
const theme = useThemes().contextual("elevated")
|
||||
if (props.loading) return <span style={{ fg: theme.text.subdued }}>⋯ Loading</span>
|
||||
const { themeV2 } = useTheme().contextual("elevated")
|
||||
if (props.loading) return <span style={{ fg: themeV2.text.subdued }}>⋯ Loading</span>
|
||||
if (props.enabled) {
|
||||
return <span style={{ fg: theme.text.feedback.success.default, attributes: TextAttributes.BOLD }}>✓ Enabled</span>
|
||||
return <span style={{ fg: themeV2.text.feedback.success.default, attributes: TextAttributes.BOLD }}>✓ Enabled</span>
|
||||
}
|
||||
return <span style={{ fg: theme.text.subdued }}>○ Disabled</span>
|
||||
return <span style={{ fg: themeV2.text.subdued }}>○ Disabled</span>
|
||||
}
|
||||
|
||||
export function DialogMcp() {
|
||||
@@ -33,7 +33,7 @@ export function DialogMcp() {
|
||||
const dialog = useDialog()
|
||||
const client = useClient()
|
||||
const toast = useToast()
|
||||
const theme = useThemes().contextual("elevated")
|
||||
const { themeV2 } = useTheme().contextual("elevated")
|
||||
const [focused, setFocused] = createSignal<string>()
|
||||
const [detail, setDetail] = createSignal<McpServer>()
|
||||
const [loading, setLoading] = createSignal<string | null>(null)
|
||||
@@ -110,7 +110,7 @@ export function DialogMcp() {
|
||||
]}
|
||||
footer={
|
||||
<Show when={focusedError()}>
|
||||
<text fg={theme.text.subdued}>enter to view error</text>
|
||||
<text fg={themeV2.text.subdued}>enter to view error</text>
|
||||
</Show>
|
||||
}
|
||||
/>
|
||||
@@ -134,8 +134,8 @@ function DialogMcpError(props: { server: McpServer; onBack: () => void }) {
|
||||
const dialog = useDialog()
|
||||
const clipboard = useClipboard()
|
||||
const toast = useToast()
|
||||
const theme = useThemes().contextual("elevated")
|
||||
const overlayTheme = useThemes().contextual("overlay")
|
||||
const { themeV2 } = useTheme().contextual("elevated")
|
||||
const { themeV2: overlayTheme } = useTheme().contextual("overlay")
|
||||
const dimensions = useTerminalDimensions()
|
||||
const config = useConfig().data
|
||||
const [copied, setCopied] = createSignal(false)
|
||||
@@ -171,14 +171,14 @@ function DialogMcpError(props: { server: McpServer; onBack: () => void }) {
|
||||
return (
|
||||
<box paddingLeft={4} paddingRight={4} paddingBottom={1} gap={1}>
|
||||
<box flexDirection="row" justifyContent="space-between">
|
||||
<text attributes={TextAttributes.BOLD} fg={theme.text.default}>
|
||||
<text attributes={TextAttributes.BOLD} fg={themeV2.text.default}>
|
||||
MCP server: {props.server.name}
|
||||
</text>
|
||||
<text fg={theme.text.subdued} onMouseUp={props.onBack}>
|
||||
<text fg={themeV2.text.subdued} onMouseUp={props.onBack}>
|
||||
esc back
|
||||
</text>
|
||||
</box>
|
||||
<text fg={theme.text.feedback.error.default}>✗ Failed</text>
|
||||
<text fg={themeV2.text.feedback.error.default}>✗ Failed</text>
|
||||
<box
|
||||
backgroundColor={overlayTheme.background.default}
|
||||
paddingLeft={2}
|
||||
@@ -198,8 +198,8 @@ function DialogMcpError(props: { server: McpServer; onBack: () => void }) {
|
||||
</scrollbox>
|
||||
</box>
|
||||
<box flexDirection="row" justifyContent="space-between">
|
||||
<text fg={theme.text.subdued}>↑↓ scroll</text>
|
||||
<text fg={theme.text.subdued} onMouseUp={copy}>
|
||||
<text fg={themeV2.text.subdued}>↑↓ scroll</text>
|
||||
<text fg={themeV2.text.subdued} onMouseUp={copy}>
|
||||
{copied() ? "✓ copied" : "c copy details"}
|
||||
</text>
|
||||
</box>
|
||||
|
||||
@@ -6,7 +6,7 @@ import { DialogSelect, type DialogSelectOption } from "../ui/dialog-select"
|
||||
import { useDialog } from "../ui/dialog"
|
||||
import { useClient } from "../context/client"
|
||||
import { Keymap } from "../context/keymap"
|
||||
import { useThemes } from "../context/theme"
|
||||
import { useTheme } from "../context/theme"
|
||||
import { useData } from "../context/data"
|
||||
import { abbreviateHome } from "../runtime"
|
||||
import { useTuiPaths } from "../context/runtime"
|
||||
@@ -38,7 +38,7 @@ export function DialogMoveSession(props: DialogMoveSessionProps) {
|
||||
const dialog = useDialog()
|
||||
const client = useClient()
|
||||
const dimensions = useTerminalDimensions()
|
||||
const theme = useThemes().contextual("elevated")
|
||||
const { themeV2 } = useTheme().contextual("elevated")
|
||||
const sessionData = useData()
|
||||
const route = useRoute()
|
||||
const toast = useToast()
|
||||
@@ -172,18 +172,18 @@ export function DialogMoveSession(props: DialogMoveSessionProps) {
|
||||
return {
|
||||
title,
|
||||
titleView: isRemoving ? (
|
||||
<span style={{ fg: theme.text.feedback.error.default }}>Deleting {item.location}</span>
|
||||
<span style={{ fg: themeV2.text.feedback.error.default }}>Deleting {item.location}</span>
|
||||
) : deleting ? (
|
||||
<span style={{ fg: theme.text.action.destructive.default }}>
|
||||
<span style={{ fg: themeV2.text.action.destructive.default }}>
|
||||
Press {shortcuts.get("dialog.move_session.delete")} again to confirm
|
||||
</span>
|
||||
) : suffix ? (
|
||||
<>
|
||||
{visible.slice(0, split)}
|
||||
<span style={{ fg: theme.text.subdued }}>{visible.slice(split)}</span>
|
||||
<span style={{ fg: themeV2.text.subdued }}>{visible.slice(split)}</span>
|
||||
</>
|
||||
) : undefined,
|
||||
bg: deleting ? theme.background.action.destructive.default : undefined,
|
||||
bg: deleting ? themeV2.background.action.destructive.default : undefined,
|
||||
value: {
|
||||
type: "directory",
|
||||
directory: item.location,
|
||||
@@ -316,7 +316,7 @@ export function DialogMoveSession(props: DialogMoveSessionProps) {
|
||||
title="Move session"
|
||||
titleView={
|
||||
<box flexDirection="row" gap={1}>
|
||||
<text fg={theme.text.default} attributes={TextAttributes.BOLD}>
|
||||
<text fg={themeV2.text.default} attributes={TextAttributes.BOLD}>
|
||||
Move session
|
||||
</text>
|
||||
<Show when={working() || directories.loading || loadedProject.loading}>
|
||||
@@ -329,25 +329,25 @@ export function DialogMoveSession(props: DialogMoveSessionProps) {
|
||||
emptyView={
|
||||
showError() ? (
|
||||
<box paddingLeft={4} paddingRight={4} paddingTop={1}>
|
||||
<text fg={theme.text.feedback.error.default} attributes={TextAttributes.BOLD}>
|
||||
<text fg={themeV2.text.feedback.error.default} attributes={TextAttributes.BOLD}>
|
||||
Could not load project directories
|
||||
</text>
|
||||
<text fg={theme.text.subdued}>{errorMessage(loadError())}</text>
|
||||
<text fg={theme.text.subdued}>Close and reopen Move session to try again.</text>
|
||||
<text fg={themeV2.text.subdued}>{errorMessage(loadError())}</text>
|
||||
<text fg={themeV2.text.subdued}>Close and reopen Move session to try again.</text>
|
||||
</box>
|
||||
) : directories.loading || loadedProject.loading ? (
|
||||
<box paddingLeft={4} paddingRight={4} paddingTop={1}>
|
||||
<text fg={theme.text.subdued}>Loading project directories…</text>
|
||||
<text fg={themeV2.text.subdued}>Loading project directories…</text>
|
||||
</box>
|
||||
) : (
|
||||
<box paddingLeft={4} paddingRight={4} paddingTop={1}>
|
||||
<text fg={theme.text.subdued}>No project directories available</text>
|
||||
<text fg={themeV2.text.subdued}>No project directories available</text>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
noMatchView={
|
||||
<box paddingLeft={4} paddingRight={4} paddingTop={1}>
|
||||
<text fg={theme.text.subdued}>No project directories found</text>
|
||||
<text fg={themeV2.text.subdued}>No project directories found</text>
|
||||
</box>
|
||||
}
|
||||
locked={showError() || directories.loading || loadedProject.loading || Boolean(removing())}
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useTerminalDimensions } from "@opentui/solid"
|
||||
import { createMemo, createResource, createSignal, For, Show } from "solid-js"
|
||||
import { renderUnicodeCompact } from "uqr"
|
||||
import { useClient } from "../context/client"
|
||||
import { useThemes } from "../context/theme"
|
||||
import { useTheme } from "../context/theme"
|
||||
import { useDialog } from "../ui/dialog"
|
||||
import { errorMessage } from "../util/error"
|
||||
|
||||
@@ -16,7 +16,7 @@ export function DialogPair(props: { credentials?: DialogPairCredentials }) {
|
||||
const client = useClient()
|
||||
const dialog = useDialog()
|
||||
const dimensions = useTerminalDimensions()
|
||||
const theme = useThemes().contextual("elevated")
|
||||
const { themeV2 } = useTheme().contextual("elevated")
|
||||
const [loadError, setLoadError] = createSignal<unknown>()
|
||||
const [showPassword, setShowPassword] = createSignal(false)
|
||||
const [passwordHover, setPasswordHover] = createSignal(false)
|
||||
@@ -47,17 +47,17 @@ export function DialogPair(props: { credentials?: DialogPairCredentials }) {
|
||||
<box flexDirection={horizontal() ? "row" : "column"} alignItems={horizontal() ? "flex-start" : "center"} gap={2}>
|
||||
<box width={horizontal() ? 29 : "100%"} flexShrink={0} gap={1}>
|
||||
<box>
|
||||
<text fg={theme.text.subdued}>URLs</text>
|
||||
<For each={value.urls}>{(url) => <text fg={theme.text.default}>{url}</text>}</For>
|
||||
<text fg={themeV2.text.subdued}>URLs</text>
|
||||
<For each={value.urls}>{(url) => <text fg={themeV2.text.default}>{url}</text>}</For>
|
||||
</box>
|
||||
<box>
|
||||
<text fg={theme.text.subdued}>Username</text>
|
||||
<text fg={theme.text.default}>{value.username}</text>
|
||||
<text fg={themeV2.text.subdued}>Username</text>
|
||||
<text fg={themeV2.text.default}>{value.username}</text>
|
||||
</box>
|
||||
<box>
|
||||
<text fg={theme.text.subdued}>Password</text>
|
||||
<text fg={themeV2.text.subdued}>Password</text>
|
||||
<text
|
||||
fg={passwordHover() ? theme.text.default : theme.text.subdued}
|
||||
fg={passwordHover() ? themeV2.text.default : themeV2.text.subdued}
|
||||
wrapMode="word"
|
||||
onMouseOver={() => setPasswordHover(true)}
|
||||
onMouseOut={() => setPasswordHover(false)}
|
||||
@@ -67,7 +67,7 @@ export function DialogPair(props: { credentials?: DialogPairCredentials }) {
|
||||
</text>
|
||||
</box>
|
||||
<Show when={value.urls.some((url) => ["localhost", "127.0.0.1", "[::1]"].includes(new URL(url).hostname))}>
|
||||
<text fg={theme.text.subdued} wrapMode="word">
|
||||
<text fg={themeV2.text.subdued} wrapMode="word">
|
||||
Run `opencode service set hostname 0.0.0.0` to access the service remotely.
|
||||
</text>
|
||||
</Show>
|
||||
@@ -78,7 +78,7 @@ export function DialogPair(props: { credentials?: DialogPairCredentials }) {
|
||||
flexShrink={0}
|
||||
alignItems={horizontal() ? "flex-end" : "center"}
|
||||
>
|
||||
<text fg={theme.text.default}>{renderUnicodeCompact(JSON.stringify(value), { border: 1 })}</text>
|
||||
<text fg={themeV2.text.default}>{renderUnicodeCompact(JSON.stringify(value), { border: 1 })}</text>
|
||||
</box>
|
||||
</box>
|
||||
)
|
||||
@@ -87,17 +87,17 @@ export function DialogPair(props: { credentials?: DialogPairCredentials }) {
|
||||
return (
|
||||
<box paddingLeft={2} paddingRight={2} paddingBottom={1} gap={1}>
|
||||
<box flexDirection="row" justifyContent="space-between">
|
||||
<text fg={theme.text.default} attributes={TextAttributes.BOLD}>
|
||||
<text fg={themeV2.text.default} attributes={TextAttributes.BOLD}>
|
||||
Pair
|
||||
</text>
|
||||
<text fg={theme.text.subdued} onMouseUp={() => dialog.clear()}>
|
||||
<text fg={themeV2.text.subdued} onMouseUp={() => dialog.clear()}>
|
||||
esc
|
||||
</text>
|
||||
</box>
|
||||
<Show
|
||||
when={loadError()}
|
||||
fallback={
|
||||
<Show when={info()} fallback={<text fg={theme.text.subdued}>Loading server information…</text>}>
|
||||
<Show when={info()} fallback={<text fg={themeV2.text.subdued}>Loading server information…</text>}>
|
||||
<Show
|
||||
when={dimensions().height >= 36}
|
||||
fallback={
|
||||
@@ -116,11 +116,11 @@ export function DialogPair(props: { credentials?: DialogPairCredentials }) {
|
||||
>
|
||||
{(error) => (
|
||||
<box>
|
||||
<text fg={theme.text.feedback.error.default} attributes={TextAttributes.BOLD}>
|
||||
<text fg={themeV2.text.feedback.error.default} attributes={TextAttributes.BOLD}>
|
||||
Could not load server information
|
||||
</text>
|
||||
<text fg={theme.text.subdued}>{errorMessage(error())}</text>
|
||||
<text fg={theme.text.subdued}>Close and reopen Pair to try again.</text>
|
||||
<text fg={themeV2.text.subdued}>{errorMessage(error())}</text>
|
||||
<text fg={themeV2.text.subdued}>Close and reopen Pair to try again.</text>
|
||||
</box>
|
||||
)}
|
||||
</Show>
|
||||
|
||||
@@ -2,12 +2,12 @@ import { InputRenderable, TextAttributes } from "@opentui/core"
|
||||
import { Slug } from "@opencode-ai/core/util/slug"
|
||||
import { createSignal, onMount } from "solid-js"
|
||||
import { Keymap } from "../context/keymap"
|
||||
import { useThemes } from "../context/theme"
|
||||
import { useTheme } from "../context/theme"
|
||||
import { useDialog, type DialogContext } from "../ui/dialog"
|
||||
|
||||
export function DialogProjectCopyName(props: { onConfirm: (name: string) => void }) {
|
||||
const dialog = useDialog()
|
||||
const theme = useThemes().contextual("elevated")
|
||||
const { themeV2 } = useTheme().contextual("elevated")
|
||||
const shortcuts = Keymap.useShortcuts()
|
||||
const [inputTarget, setInputTarget] = createSignal<InputRenderable>()
|
||||
let input: InputRenderable
|
||||
@@ -47,10 +47,10 @@ export function DialogProjectCopyName(props: { onConfirm: (name: string) => void
|
||||
return (
|
||||
<box paddingLeft={2} paddingRight={2} gap={1}>
|
||||
<box flexDirection="row" justifyContent="space-between">
|
||||
<text attributes={TextAttributes.BOLD} fg={theme.text.default}>
|
||||
<text attributes={TextAttributes.BOLD} fg={themeV2.text.default}>
|
||||
Name project copy
|
||||
</text>
|
||||
<text fg={theme.text.subdued} onMouseUp={() => dialog.clear()}>
|
||||
<text fg={themeV2.text.subdued} onMouseUp={() => dialog.clear()}>
|
||||
esc
|
||||
</text>
|
||||
</box>
|
||||
@@ -61,17 +61,17 @@ export function DialogProjectCopyName(props: { onConfirm: (name: string) => void
|
||||
}}
|
||||
onSubmit={confirm}
|
||||
placeholder="Project copy name"
|
||||
placeholderColor={theme.text.subdued}
|
||||
textColor={theme.text.formfield.default}
|
||||
focusedTextColor={theme.text.formfield.default}
|
||||
cursorColor={theme.text.formfield.default}
|
||||
placeholderColor={themeV2.text.subdued}
|
||||
textColor={themeV2.text.formfield.default}
|
||||
focusedTextColor={themeV2.text.formfield.default}
|
||||
cursorColor={themeV2.text.formfield.default}
|
||||
/>
|
||||
<box paddingBottom={1} flexDirection="row" gap={2}>
|
||||
<text fg={theme.text.default}>
|
||||
enter <span style={{ fg: theme.text.subdued }}>submit</span>
|
||||
<text fg={themeV2.text.default}>
|
||||
enter <span style={{ fg: themeV2.text.subdued }}>submit</span>
|
||||
</text>
|
||||
<text fg={theme.text.default}>
|
||||
{shortcuts.get("dialog.project_copy.generate")} <span style={{ fg: theme.text.subdued }}>generate one</span>
|
||||
<text fg={themeV2.text.default}>
|
||||
{shortcuts.get("dialog.project_copy.generate")} <span style={{ fg: themeV2.text.subdued }}>generate one</span>
|
||||
</text>
|
||||
</box>
|
||||
</box>
|
||||
|
||||
@@ -2,7 +2,7 @@ import { RGBA, TextAttributes } from "@opentui/core"
|
||||
import open from "open"
|
||||
import { createSignal } from "solid-js"
|
||||
import { Keymap } from "../context/keymap"
|
||||
import { useThemes } from "../context/theme"
|
||||
import { useTheme } from "../context/theme"
|
||||
import { useDialog, type DialogContext } from "../ui/dialog"
|
||||
import { Link } from "../ui/link"
|
||||
import { BgPulse } from "./bg-pulse"
|
||||
@@ -38,9 +38,9 @@ function panelOverlay(color: RGBA) {
|
||||
|
||||
export function DialogRetryAction(props: DialogRetryActionProps) {
|
||||
const dialog = useDialog()
|
||||
const theme = useThemes().contextual("elevated")
|
||||
const { themeV2 } = useTheme().contextual("elevated")
|
||||
const showGoTreatment = () => props.link === GO_URL
|
||||
const textBg = () => (showGoTreatment() ? panelOverlay(theme.background.default) : undefined)
|
||||
const textBg = () => (showGoTreatment() ? panelOverlay(themeV2.background.default) : undefined)
|
||||
const [selected, setSelected] = createSignal<"dismiss" | "action">("action")
|
||||
|
||||
Keymap.createLayer(() => ({
|
||||
@@ -85,26 +85,26 @@ export function DialogRetryAction(props: DialogRetryActionProps) {
|
||||
) : 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()}>
|
||||
<text attributes={TextAttributes.BOLD} fg={themeV2.text.default} bg={textBg()}>
|
||||
{props.title}
|
||||
</text>
|
||||
<text fg={theme.text.subdued} bg={textBg()} onMouseUp={() => dialog.clear()}>
|
||||
<text fg={themeV2.text.subdued} bg={textBg()} onMouseUp={() => dialog.clear()}>
|
||||
esc
|
||||
</text>
|
||||
</box>
|
||||
<box gap={0}>
|
||||
<text fg={theme.text.subdued} bg={textBg()}>
|
||||
<text fg={themeV2.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" />
|
||||
<Link href={props.link} fg={themeV2.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" />
|
||||
<Link href={props.link} fg={themeV2.markdown.link} wrapMode="none" />
|
||||
</box>
|
||||
)
|
||||
) : (
|
||||
@@ -115,13 +115,13 @@ export function DialogRetryAction(props: DialogRetryActionProps) {
|
||||
paddingLeft={2}
|
||||
paddingRight={2}
|
||||
backgroundColor={
|
||||
selected() === "dismiss" ? theme.background.action.primary.focused : RGBA.fromInts(0, 0, 0, 0)
|
||||
selected() === "dismiss" ? themeV2.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}
|
||||
fg={selected() === "dismiss" ? themeV2.text.action.primary.focused : themeV2.text.subdued}
|
||||
bg={selected() === "dismiss" ? undefined : textBg()}
|
||||
attributes={selected() === "dismiss" ? TextAttributes.BOLD : undefined}
|
||||
>
|
||||
@@ -132,13 +132,13 @@ export function DialogRetryAction(props: DialogRetryActionProps) {
|
||||
paddingLeft={2}
|
||||
paddingRight={2}
|
||||
backgroundColor={
|
||||
selected() === "action" ? theme.background.action.primary.focused : RGBA.fromInts(0, 0, 0, 0)
|
||||
selected() === "action" ? themeV2.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}
|
||||
fg={selected() === "action" ? themeV2.text.action.primary.focused : themeV2.text.default}
|
||||
bg={selected() === "action" ? undefined : textBg()}
|
||||
attributes={selected() === "action" ? TextAttributes.BOLD : undefined}
|
||||
>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { TextAttributes } from "@opentui/core"
|
||||
import { Keymap } from "../context/keymap"
|
||||
import { useThemes } from "../context/theme"
|
||||
import { useTheme } from "../context/theme"
|
||||
import { useDialog } from "../ui/dialog"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { For } from "solid-js"
|
||||
@@ -13,7 +13,7 @@ export function DialogSessionDeleteFailed(props: {
|
||||
onDone?: () => void
|
||||
}) {
|
||||
const dialog = useDialog()
|
||||
const theme = useThemes().contextual("elevated")
|
||||
const { themeV2 } = useTheme().contextual("elevated")
|
||||
const [store, setStore] = createStore({
|
||||
active: "delete" as "delete" | "restore",
|
||||
})
|
||||
@@ -64,17 +64,17 @@ export function DialogSessionDeleteFailed(props: {
|
||||
return (
|
||||
<box paddingLeft={2} paddingRight={2} gap={1}>
|
||||
<box flexDirection="row" justifyContent="space-between">
|
||||
<text attributes={TextAttributes.BOLD} fg={theme.text.default}>
|
||||
<text attributes={TextAttributes.BOLD} fg={themeV2.text.default}>
|
||||
Failed to Delete Session
|
||||
</text>
|
||||
<text fg={theme.text.subdued} onMouseUp={() => dialog.clear()}>
|
||||
<text fg={themeV2.text.subdued} onMouseUp={() => dialog.clear()}>
|
||||
esc
|
||||
</text>
|
||||
</box>
|
||||
<text fg={theme.text.subdued} wrapMode="word">
|
||||
<text fg={themeV2.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">
|
||||
<text fg={themeV2.text.subdued} wrapMode="word">
|
||||
Choose how you want to recover this broken workspace session.
|
||||
</text>
|
||||
<box flexDirection="column" paddingBottom={1} gap={1}>
|
||||
@@ -86,7 +86,7 @@ export function DialogSessionDeleteFailed(props: {
|
||||
paddingRight={1}
|
||||
paddingTop={1}
|
||||
paddingBottom={1}
|
||||
backgroundColor={item.id === store.active ? theme.background.action.primary.focused : undefined}
|
||||
backgroundColor={item.id === store.active ? themeV2.background.action.primary.focused : undefined}
|
||||
onMouseUp={() => {
|
||||
setStore("active", item.id)
|
||||
void confirm()
|
||||
@@ -94,12 +94,12 @@ export function DialogSessionDeleteFailed(props: {
|
||||
>
|
||||
<text
|
||||
attributes={TextAttributes.BOLD}
|
||||
fg={item.id === store.active ? theme.text.action.primary.focused : theme.text.default}
|
||||
fg={item.id === store.active ? themeV2.text.action.primary.focused : themeV2.text.default}
|
||||
>
|
||||
{item.title}
|
||||
</text>
|
||||
<text
|
||||
fg={item.id === store.active ? theme.text.action.primary.focused : theme.text.subdued}
|
||||
fg={item.id === store.active ? themeV2.text.action.primary.focused : themeV2.text.subdued}
|
||||
wrapMode="word"
|
||||
>
|
||||
{item.description}
|
||||
|
||||
@@ -7,7 +7,7 @@ import { useRoute } from "../context/route"
|
||||
import { useData } from "../context/data"
|
||||
import { Keymap } from "../context/keymap"
|
||||
import { Locale } from "../util/locale"
|
||||
import { useThemes } from "../context/theme"
|
||||
import { useTheme } from "../context/theme"
|
||||
import { useClient } from "../context/client"
|
||||
import { useLocal } from "../context/local"
|
||||
import { createDebouncedSignal } from "../util/signal"
|
||||
@@ -15,18 +15,14 @@ import { useToast } from "../ui/toast"
|
||||
import { DialogSessionRename } from "./dialog-session-rename"
|
||||
import { Spinner } from "./spinner"
|
||||
import { errorMessage } from "../util/error"
|
||||
import { useSessionTabs } from "../context/session-tabs"
|
||||
|
||||
export function DialogSessionList() {
|
||||
const dialog = useDialog()
|
||||
const route = useRoute()
|
||||
const data = useData()
|
||||
const themes = useThemes()
|
||||
const theme = themes.contextual("elevated")
|
||||
const mode = themes.mode
|
||||
const { themeV2, mode } = useTheme().contextual("elevated")
|
||||
const client = useClient()
|
||||
const local = useLocal()
|
||||
const sessionTabs = useSessionTabs()
|
||||
const toast = useToast()
|
||||
const [filter, setFilter] = createSignal("")
|
||||
const shortcuts = Keymap.useShortcuts()
|
||||
@@ -81,7 +77,6 @@ export function DialogSessionList() {
|
||||
})
|
||||
|
||||
const quickSwitchHint = createMemo(() => {
|
||||
if (sessionTabs.enabled()) return
|
||||
const first = shortcuts.get("session.quick_switch.1")
|
||||
const last = shortcuts.get("session.quick_switch.9")
|
||||
if (!first || !last) return
|
||||
@@ -99,7 +94,7 @@ export function DialogSessionList() {
|
||||
.filter((session) => !session.parentID)
|
||||
.map((session) => [session.id, session]),
|
||||
)
|
||||
const pinned = sessionTabs.enabled() ? [] : local.session.pinned().filter((sessionID) => sessionMap.has(sessionID))
|
||||
const pinned = local.session.pinned().filter((sessionID) => sessionMap.has(sessionID))
|
||||
const pinnedSet = new Set(pinned)
|
||||
const slotByID = new Map(local.session.slots().map((sessionID, index) => [sessionID, index + 1]))
|
||||
|
||||
@@ -107,20 +102,20 @@ export function DialogSessionList() {
|
||||
const directory = session.location.directory
|
||||
const footer =
|
||||
directory !== data.location.info()?.project.directory ? Locale.truncate(path.basename(directory), 20) : ""
|
||||
const slot = sessionTabs.enabled() ? undefined : slotByID.get(session.id)
|
||||
const slot = slotByID.get(session.id)
|
||||
const deleting = toDelete() === session.id
|
||||
return {
|
||||
title: deleting ? `Press ${shortcuts.get("session.delete")} again to confirm` : session.title,
|
||||
value: session.id,
|
||||
category,
|
||||
footer,
|
||||
bg: deleting ? theme.background.action.destructive.focused : undefined,
|
||||
fg: deleting ? theme.text.action.destructive.focused : undefined,
|
||||
bg: deleting ? themeV2.background.action.destructive.focused : undefined,
|
||||
fg: deleting ? themeV2.text.action.destructive.focused : undefined,
|
||||
gutter: data.session.family(session.id).some((id) => data.session.status(id) === "running")
|
||||
? () => <Spinner />
|
||||
: slot === undefined
|
||||
? undefined
|
||||
: () => <text fg={theme.hue.accent[mode() === "light" ? 800 : 200]}>{slot}</text>,
|
||||
: () => <text fg={themeV2.hue.accent[mode() === "light" ? 800 : 200]}>{slot}</text>,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -148,12 +143,12 @@ export function DialogSessionList() {
|
||||
}}
|
||||
emptyView={
|
||||
<box paddingLeft={4} paddingRight={4} paddingTop={1}>
|
||||
<text fg={theme.text.subdued}>No sessions available</text>
|
||||
<text fg={themeV2.text.subdued}>No sessions available</text>
|
||||
</box>
|
||||
}
|
||||
noMatchView={
|
||||
<box paddingLeft={4} paddingRight={4} paddingTop={1}>
|
||||
<text fg={searchState().error ? theme.text.feedback.error.default : theme.text.subdued}>
|
||||
<text fg={searchState().error ? themeV2.text.feedback.error.default : themeV2.text.subdued}>
|
||||
{searchState().message}
|
||||
</text>
|
||||
</box>
|
||||
@@ -167,8 +162,7 @@ export function DialogSessionList() {
|
||||
{
|
||||
command: "session.pin.toggle",
|
||||
title: "pin/unpin",
|
||||
hidden: sessionTabs.enabled(),
|
||||
onTrigger: (option) => local.session.togglePin(option.value),
|
||||
onTrigger: (option: { value: string }) => local.session.togglePin(option.value),
|
||||
},
|
||||
{
|
||||
command: "session.delete",
|
||||
|
||||
@@ -15,7 +15,7 @@ export type DialogSkillProps = {
|
||||
export function DialogSkill(props: DialogSkillProps) {
|
||||
const dialog = useDialog()
|
||||
const data = useData()
|
||||
const theme = useTheme()
|
||||
const { themeV2 } = useTheme()
|
||||
dialog.setSize("large")
|
||||
|
||||
const [loadError, setLoadError] = createSignal<unknown>()
|
||||
@@ -63,29 +63,29 @@ export function DialogSkill(props: DialogSkillProps) {
|
||||
<Switch
|
||||
fallback={
|
||||
<box paddingLeft={4} paddingRight={4} paddingTop={1}>
|
||||
<text fg={theme.text.subdued}>No skills available</text>
|
||||
<text fg={themeV2.text.subdued}>No skills available</text>
|
||||
</box>
|
||||
}
|
||||
>
|
||||
<Match when={showError()}>
|
||||
<box paddingLeft={4} paddingRight={4} paddingTop={1}>
|
||||
<text fg={theme.text.feedback.error.default} attributes={TextAttributes.BOLD}>
|
||||
<text fg={themeV2.text.feedback.error.default} attributes={TextAttributes.BOLD}>
|
||||
Could not load skills
|
||||
</text>
|
||||
<text fg={theme.text.subdued}>{errorMessage(loadError())}</text>
|
||||
<text fg={theme.text.subdued}>Close and reopen Skills to try again.</text>
|
||||
<text fg={themeV2.text.subdued}>{errorMessage(loadError())}</text>
|
||||
<text fg={themeV2.text.subdued}>Close and reopen Skills to try again.</text>
|
||||
</box>
|
||||
</Match>
|
||||
<Match when={skills.loading}>
|
||||
<box paddingLeft={4} paddingRight={4} paddingTop={1}>
|
||||
<text fg={theme.text.subdued}>Loading skills…</text>
|
||||
<text fg={themeV2.text.subdued}>Loading skills…</text>
|
||||
</box>
|
||||
</Match>
|
||||
</Switch>
|
||||
}
|
||||
noMatchView={
|
||||
<box paddingLeft={4} paddingRight={4} paddingTop={1}>
|
||||
<text fg={theme.text.subdued}>No skills found</text>
|
||||
<text fg={themeV2.text.subdued}>No skills found</text>
|
||||
</box>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -3,7 +3,7 @@ import { DialogSelect } from "../ui/dialog-select"
|
||||
import { createMemo, createSignal } from "solid-js"
|
||||
import { Locale } from "../util/locale"
|
||||
import { Keymap } from "../context/keymap"
|
||||
import { useThemes } from "../context/theme"
|
||||
import { useTheme } from "../context/theme"
|
||||
import { usePromptStash, type StashEntry } from "./prompt/stash"
|
||||
|
||||
function getRelativeTime(timestamp: number): string {
|
||||
@@ -29,7 +29,7 @@ function getStashPreview(input: string, maxLength: number = 50): string {
|
||||
export function DialogStash(props: { onSelect: (entry: StashEntry) => void }) {
|
||||
const dialog = useDialog()
|
||||
const stash = usePromptStash()
|
||||
const theme = useThemes().contextual("elevated")
|
||||
const { themeV2 } = useTheme().contextual("elevated")
|
||||
const shortcuts = Keymap.useShortcuts()
|
||||
|
||||
const [toDelete, setToDelete] = createSignal<number>()
|
||||
@@ -45,8 +45,8 @@ export function DialogStash(props: { onSelect: (entry: StashEntry) => void }) {
|
||||
title: isDeleting
|
||||
? `Press ${shortcuts.get("stash.delete")} again to confirm`
|
||||
: getStashPreview(entry.prompt.text),
|
||||
bg: isDeleting ? theme.background.action.destructive.focused : undefined,
|
||||
fg: isDeleting ? theme.text.action.destructive.focused : undefined,
|
||||
bg: isDeleting ? themeV2.background.action.destructive.focused : undefined,
|
||||
fg: isDeleting ? themeV2.text.action.destructive.focused : undefined,
|
||||
value: index,
|
||||
description: getRelativeTime(entry.timestamp),
|
||||
footer: lineCount > 1 ? `~${lineCount} lines` : undefined,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { TextAttributes } from "@opentui/core"
|
||||
import { useThemes } from "../context/theme"
|
||||
import { useTheme } from "../context/theme"
|
||||
import { useDialog } from "../ui/dialog"
|
||||
import { useData } from "../context/data"
|
||||
import { For, Match, Switch, Show, createMemo } from "solid-js"
|
||||
@@ -8,30 +8,30 @@ export type DialogStatusProps = {}
|
||||
|
||||
export function DialogStatus() {
|
||||
const data = useData()
|
||||
const theme = useThemes().contextual("elevated")
|
||||
const { themeV2 } = useTheme().contextual("elevated")
|
||||
const dialog = useDialog()
|
||||
|
||||
const mcp = createMemo(() => data.location.mcp.server.list() ?? [])
|
||||
const color = (status: string) => {
|
||||
if (status === "connected") return theme.text.feedback.success.default
|
||||
if (status === "failed") return theme.text.feedback.error.default
|
||||
if (status === "needs_auth") return theme.text.feedback.warning.default
|
||||
if (status === "needs_client_registration") return theme.text.feedback.error.default
|
||||
return theme.text.subdued
|
||||
if (status === "connected") return themeV2.text.feedback.success.default
|
||||
if (status === "failed") return themeV2.text.feedback.error.default
|
||||
if (status === "needs_auth") return themeV2.text.feedback.warning.default
|
||||
if (status === "needs_client_registration") return themeV2.text.feedback.error.default
|
||||
return themeV2.text.subdued
|
||||
}
|
||||
return (
|
||||
<box paddingLeft={2} paddingRight={2} gap={1} paddingBottom={1}>
|
||||
<box flexDirection="row" justifyContent="space-between">
|
||||
<text fg={theme.text.default} attributes={TextAttributes.BOLD}>
|
||||
<text fg={themeV2.text.default} attributes={TextAttributes.BOLD}>
|
||||
Status
|
||||
</text>
|
||||
<text fg={theme.text.subdued} onMouseUp={() => dialog.clear()}>
|
||||
<text fg={themeV2.text.subdued} onMouseUp={() => dialog.clear()}>
|
||||
esc
|
||||
</text>
|
||||
</box>
|
||||
<Show when={mcp().length > 0} fallback={<text fg={theme.text.default}>No MCP servers</text>}>
|
||||
<Show when={mcp().length > 0} fallback={<text fg={themeV2.text.default}>No MCP servers</text>}>
|
||||
<box>
|
||||
<text fg={theme.text.default}>
|
||||
<text fg={themeV2.text.default}>
|
||||
{mcp().length} MCP server{mcp().length === 1 ? "" : "s"}
|
||||
</text>
|
||||
<For each={mcp()}>
|
||||
@@ -40,9 +40,9 @@ export function DialogStatus() {
|
||||
<text flexShrink={0} style={{ fg: color(item.status.status) }}>
|
||||
•
|
||||
</text>
|
||||
<text fg={theme.text.default} wrapMode="word">
|
||||
<text fg={themeV2.text.default} wrapMode="word">
|
||||
<b>{item.name}</b>{" "}
|
||||
<span style={{ fg: theme.text.subdued }}>
|
||||
<span style={{ fg: themeV2.text.subdued }}>
|
||||
<Switch fallback={item.status.status}>
|
||||
<Match when={item.status.status === "connected"}>Connected</Match>
|
||||
<Match when={item.status.status === "failed" && item.status}>{(val) => val().error}</Match>
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { DialogSelect, type DialogSelectRef } from "../ui/dialog-select"
|
||||
import { useThemes } from "../context/theme"
|
||||
import { useTheme } from "../context/theme"
|
||||
import { useDialog } from "../ui/dialog"
|
||||
import { onCleanup } from "solid-js"
|
||||
|
||||
export function DialogThemeList() {
|
||||
const themes = useThemes()
|
||||
const options = Object.keys(themes.all())
|
||||
const theme = useTheme()
|
||||
const options = Object.keys(theme.all())
|
||||
.sort((a, b) => a.localeCompare(b, undefined, { sensitivity: "base" }))
|
||||
.map((value) => ({
|
||||
title: value,
|
||||
@@ -14,10 +14,10 @@ export function DialogThemeList() {
|
||||
const dialog = useDialog()
|
||||
let confirmed = false
|
||||
let ref: DialogSelectRef<string>
|
||||
const initial = themes.selected
|
||||
const initial = theme.selected
|
||||
|
||||
onCleanup(() => {
|
||||
if (!confirmed) themes.set(initial)
|
||||
if (!confirmed) theme.set(initial)
|
||||
})
|
||||
|
||||
return (
|
||||
@@ -26,10 +26,10 @@ export function DialogThemeList() {
|
||||
options={options}
|
||||
current={initial}
|
||||
onMove={(opt) => {
|
||||
themes.set(opt.value)
|
||||
theme.set(opt.value)
|
||||
}}
|
||||
onSelect={(opt) => {
|
||||
themes.set(opt.value)
|
||||
theme.set(opt.value)
|
||||
confirmed = true
|
||||
dialog.clear()
|
||||
}}
|
||||
@@ -38,12 +38,12 @@ export function DialogThemeList() {
|
||||
}}
|
||||
onFilter={(query) => {
|
||||
if (query.length === 0) {
|
||||
themes.set(initial)
|
||||
theme.set(initial)
|
||||
return
|
||||
}
|
||||
|
||||
const first = ref.filtered[0]
|
||||
if (first) themes.set(first.value)
|
||||
if (first) theme.set(first.value)
|
||||
}}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -4,7 +4,7 @@ import type { VcsFileStatus } from "@opencode-ai/client"
|
||||
import { createMemo, For } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { FilePath } from "../ui/file-path"
|
||||
import { useThemes } from "../context/theme"
|
||||
import { useTheme } from "../context/theme"
|
||||
import { useConfig } from "../config"
|
||||
import { useDialog, type DialogContext } from "../ui/dialog"
|
||||
import { getScrollAcceleration } from "../util/scroll"
|
||||
@@ -31,8 +31,8 @@ export function DialogWorkspaceFileChanges(props: {
|
||||
message?: string
|
||||
}) {
|
||||
const dialog = useDialog()
|
||||
const theme = useThemes().contextual("elevated")
|
||||
const overlayTheme = useThemes().contextual("overlay")
|
||||
const { themeV2 } = useTheme().contextual("elevated")
|
||||
const { themeV2: overlayTheme } = useTheme().contextual("overlay")
|
||||
const config = useConfig().data
|
||||
const dimensions = useTerminalDimensions()
|
||||
const scrollAcceleration = createMemo(() => getScrollAcceleration(config))
|
||||
@@ -72,15 +72,15 @@ export function DialogWorkspaceFileChanges(props: {
|
||||
return (
|
||||
<box gap={1}>
|
||||
<box flexDirection="row" justifyContent="space-between" paddingLeft={2} paddingRight={2}>
|
||||
<text attributes={TextAttributes.BOLD} fg={theme.text.default}>
|
||||
<text attributes={TextAttributes.BOLD} fg={themeV2.text.default}>
|
||||
{props.title ?? "File Changes Found"}
|
||||
</text>
|
||||
<text fg={theme.text.subdued} onMouseUp={() => dialog.clear()}>
|
||||
<text fg={themeV2.text.subdued} onMouseUp={() => dialog.clear()}>
|
||||
esc
|
||||
</text>
|
||||
</box>
|
||||
<box paddingLeft={2} paddingRight={2}>
|
||||
<text fg={theme.text.subdued} wrapMode="word">
|
||||
<text fg={themeV2.text.subdued} wrapMode="word">
|
||||
{props.message ?? "Do you want to move these changes with the session?"}
|
||||
</text>
|
||||
</box>
|
||||
@@ -118,14 +118,16 @@ export function DialogWorkspaceFileChanges(props: {
|
||||
<box
|
||||
paddingLeft={2}
|
||||
paddingRight={2}
|
||||
backgroundColor={item === store.active ? theme.background.action.primary.focused : undefined}
|
||||
backgroundColor={item === store.active ? themeV2.background.action.primary.focused : undefined}
|
||||
onMouseUp={() => {
|
||||
setStore("active", item)
|
||||
props.onSelect(item)
|
||||
dialog.clear()
|
||||
}}
|
||||
>
|
||||
<text fg={item === store.active ? theme.text.action.primary.focused : theme.text.subdued}>{item}</text>
|
||||
<text fg={item === store.active ? themeV2.text.action.primary.focused : themeV2.text.subdued}>
|
||||
{item}
|
||||
</text>
|
||||
</box>
|
||||
)}
|
||||
</For>
|
||||
|
||||
@@ -5,10 +5,10 @@ import { tint } from "../theme/color"
|
||||
import { logo } from "../logo"
|
||||
|
||||
export function Logo() {
|
||||
const theme = useTheme()
|
||||
const { themeV2 } = useTheme()
|
||||
|
||||
const renderLine = (line: string, fg: RGBA, bold: boolean): JSX.Element[] => {
|
||||
const shadow = tint(theme.background.default, fg, 0.25)
|
||||
const shadow = tint(themeV2.background.default, fg, 0.25)
|
||||
const attrs = bold ? TextAttributes.BOLD : undefined
|
||||
return Array.from(line).map((char) => {
|
||||
if (char === "_") {
|
||||
@@ -52,8 +52,8 @@ export function Logo() {
|
||||
<For each={logo.left}>
|
||||
{(line, index) => (
|
||||
<box flexDirection="row" gap={1}>
|
||||
<box flexDirection="row">{renderLine(line, theme.text.subdued, false)}</box>
|
||||
<box flexDirection="row">{renderLine(logo.right[index()], theme.text.default, true)}</box>
|
||||
<box flexDirection="row">{renderLine(line, themeV2.text.subdued, false)}</box>
|
||||
<box flexDirection="row">{renderLine(logo.right[index()], themeV2.text.default, true)}</box>
|
||||
</box>
|
||||
)}
|
||||
</For>
|
||||
|
||||
@@ -1,20 +1,20 @@
|
||||
import { useTheme } from "../context/theme"
|
||||
|
||||
export function PluginRouteMissing(props: { id: string; name: string; onHome: () => void }) {
|
||||
const theme = useTheme()
|
||||
const { themeV2 } = useTheme()
|
||||
|
||||
return (
|
||||
<box width="100%" height="100%" alignItems="center" justifyContent="center" flexDirection="column" gap={1}>
|
||||
<text fg={theme.text.feedback.warning.default}>
|
||||
<text fg={themeV2.text.feedback.warning.default}>
|
||||
Unknown plugin route: {props.id}/{props.name}
|
||||
</text>
|
||||
<box
|
||||
onMouseUp={props.onHome}
|
||||
backgroundColor={theme.background.action.primary.hovered}
|
||||
backgroundColor={themeV2.background.action.primary.hovered}
|
||||
paddingLeft={1}
|
||||
paddingRight={1}
|
||||
>
|
||||
<text fg={theme.text.action.primary.hovered}>go home</text>
|
||||
<text fg={themeV2.text.action.primary.hovered}>go home</text>
|
||||
</box>
|
||||
</box>
|
||||
)
|
||||
|
||||
@@ -12,7 +12,7 @@ import { getScrollAcceleration } from "../../util/scroll"
|
||||
import { useTuiPaths } from "../../context/runtime"
|
||||
import { useConfig } from "../../config"
|
||||
import { useLocation } from "../../context/location"
|
||||
import { useThemes } from "../../context/theme"
|
||||
import { useTheme } from "../../context/theme"
|
||||
import { SplitBorder } from "../../ui/border"
|
||||
import { useTerminalDimensions } from "@opentui/solid"
|
||||
import { Locale } from "../../util/locale"
|
||||
@@ -57,7 +57,7 @@ export function Autocomplete(props: {
|
||||
const data = useData()
|
||||
const keymap = Keymap.use()
|
||||
const keymapCommands = Keymap.useCommands()
|
||||
const theme = useThemes().contextual("overlay")
|
||||
const { themeV2 } = useTheme().contextual("overlay")
|
||||
const dimensions = useTerminalDimensions()
|
||||
const frecency = useFrecency()
|
||||
const config = useConfig().data
|
||||
@@ -698,11 +698,11 @@ export function Autocomplete(props: {
|
||||
width={position().width}
|
||||
zIndex={100}
|
||||
{...SplitBorder}
|
||||
borderColor={theme.border.default}
|
||||
borderColor={themeV2.border.default}
|
||||
>
|
||||
<scrollbox
|
||||
ref={(r: ScrollBoxRenderable) => (scroll = r)}
|
||||
backgroundColor={theme.background.default}
|
||||
backgroundColor={themeV2.background.default}
|
||||
height={height()}
|
||||
scrollbarOptions={{ visible: false }}
|
||||
scrollAcceleration={scrollAcceleration()}
|
||||
@@ -711,7 +711,9 @@ export function Autocomplete(props: {
|
||||
each={options()}
|
||||
fallback={
|
||||
<box paddingLeft={1} paddingRight={1}>
|
||||
<text fg={emptyError() ? theme.text.feedback.error.default : theme.text.subdued}>{emptyMessage()}</text>
|
||||
<text fg={emptyError() ? themeV2.text.feedback.error.default : themeV2.text.subdued}>
|
||||
{emptyMessage()}
|
||||
</text>
|
||||
</box>
|
||||
}
|
||||
>
|
||||
@@ -719,7 +721,7 @@ export function Autocomplete(props: {
|
||||
<box
|
||||
paddingLeft={1}
|
||||
paddingRight={1}
|
||||
backgroundColor={index === store.selected ? theme.background.action.primary.focused : undefined}
|
||||
backgroundColor={index === store.selected ? themeV2.background.action.primary.focused : undefined}
|
||||
flexDirection="row"
|
||||
onMouseMove={() => {
|
||||
setStore("input", "mouse")
|
||||
@@ -735,14 +737,14 @@ export function Autocomplete(props: {
|
||||
onMouseUp={() => select()}
|
||||
>
|
||||
<text
|
||||
fg={index === store.selected ? theme.text.action.primary.focused : theme.text.default}
|
||||
fg={index === store.selected ? themeV2.text.action.primary.focused : themeV2.text.default}
|
||||
flexShrink={0}
|
||||
>
|
||||
{option().display}
|
||||
</text>
|
||||
<Show when={option().description}>
|
||||
<text
|
||||
fg={index === store.selected ? theme.text.action.primary.focused : theme.text.subdued}
|
||||
fg={index === store.selected ? themeV2.text.action.primary.focused : themeV2.text.subdued}
|
||||
wrapMode="none"
|
||||
>
|
||||
{" " + option().description?.trimStart()}
|
||||
|
||||
@@ -12,7 +12,7 @@ import { registerOpencodeSpinner } from "../register-spinner"
|
||||
import path from "path"
|
||||
import { fileURLToPath } from "url"
|
||||
import { useLocal } from "../../context/local"
|
||||
import { useTheme, useThemes } from "../../context/theme"
|
||||
import { useTheme } from "../../context/theme"
|
||||
import { tint } from "../../theme/color"
|
||||
import { EmptyBorder, SplitBorder } from "../../ui/border"
|
||||
import { useTuiPaths, useTuiTerminalEnvironment } from "../../context/runtime"
|
||||
@@ -189,8 +189,7 @@ export function Prompt(props: PromptProps) {
|
||||
const renderer = useRenderer()
|
||||
const exit = useExit()
|
||||
const dimensions = useTerminalDimensions()
|
||||
const theme = useTheme()
|
||||
const { currentSyntax: syntax } = useThemes()
|
||||
const { themeV2, syntax } = useTheme()
|
||||
const animationsEnabled = createMemo(() => config.animations ?? true)
|
||||
const list = createMemo(() => props.placeholders?.normal ?? [])
|
||||
const shell = createMemo(() => props.placeholders?.shell ?? [])
|
||||
@@ -297,8 +296,8 @@ export function Prompt(props: PromptProps) {
|
||||
|
||||
createEffect(() => {
|
||||
if (!input || input.isDestroyed) return
|
||||
if (props.disabled) input.cursorColor = theme.background.surface.offset
|
||||
if (!props.disabled) input.cursorColor = theme.text.default
|
||||
if (props.disabled) input.cursorColor = themeV2.background.surface.offset
|
||||
if (!props.disabled) input.cursorColor = themeV2.text.default
|
||||
})
|
||||
|
||||
const usage = createMemo(() => {
|
||||
@@ -1300,10 +1299,10 @@ export function Prompt(props: PromptProps) {
|
||||
}
|
||||
|
||||
const highlight = createMemo(() => {
|
||||
if (leader()) return theme.border.default
|
||||
if (store.mode === "shell") return theme.text.action.primary.selected
|
||||
if (leader()) return themeV2.border.default
|
||||
if (store.mode === "shell") return themeV2.text.action.primary.selected
|
||||
const agent = local.agent.current()
|
||||
if (!agent) return theme.border.default
|
||||
if (!agent) return themeV2.border.default
|
||||
return local.agent.color(agent.id)
|
||||
})
|
||||
const agentLabel = createMemo(() => {
|
||||
@@ -1325,7 +1324,7 @@ export function Prompt(props: PromptProps) {
|
||||
() => !!local.agent.current() && store.mode === "normal" && showVariant(),
|
||||
animationsEnabled,
|
||||
)
|
||||
const borderHighlight = createMemo(() => tint(theme.border.default, highlight(), agentMetaAlpha()))
|
||||
const borderHighlight = createMemo(() => tint(themeV2.border.default, highlight(), agentMetaAlpha()))
|
||||
|
||||
const placeholderText = createMemo(() => {
|
||||
if (props.showPlaceholder === false) return undefined
|
||||
@@ -1345,7 +1344,7 @@ export function Prompt(props: PromptProps) {
|
||||
|
||||
const spinnerDef = createMemo(() => {
|
||||
const agent = status() === "running" ? local.agent.current() : local.agent.current()
|
||||
const color = agent ? local.agent.color(agent.id) : theme.border.default
|
||||
const color = agent ? local.agent.color(agent.id) : themeV2.border.default
|
||||
return {
|
||||
frames: createFrames({
|
||||
color,
|
||||
@@ -1365,7 +1364,7 @@ export function Prompt(props: PromptProps) {
|
||||
})
|
||||
const maxHeight = createMemo(() => Math.max(6, Math.floor(dimensions().height / 3)))
|
||||
|
||||
const promptBg = createMemo(() => theme.raise(theme.background.surface.offset))
|
||||
const promptBg = createMemo(() => themeV2.raise(themeV2.background.surface.offset))
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -1391,9 +1390,9 @@ export function Prompt(props: PromptProps) {
|
||||
<textarea
|
||||
width="100%"
|
||||
placeholder={placeholderText()}
|
||||
placeholderColor={theme.text.subdued}
|
||||
textColor={leader() ? theme.text.subdued : theme.text.default}
|
||||
focusedTextColor={leader() ? theme.text.subdued : theme.text.default}
|
||||
placeholderColor={themeV2.text.subdued}
|
||||
textColor={leader() ? themeV2.text.subdued : themeV2.text.default}
|
||||
focusedTextColor={leader() ? themeV2.text.subdued : themeV2.text.default}
|
||||
minHeight={1}
|
||||
maxHeight={maxHeight()}
|
||||
onContentChange={() => {
|
||||
@@ -1453,7 +1452,7 @@ export function Prompt(props: PromptProps) {
|
||||
setTimeout(() => {
|
||||
// setTimeout is a workaround and needs to be addressed properly
|
||||
if (!input || input.isDestroyed) return
|
||||
input.cursorColor = theme.text.default
|
||||
input.cursorColor = themeV2.text.default
|
||||
}, 0)
|
||||
}}
|
||||
onMouseDown={(r: MouseEvent) => {
|
||||
@@ -1461,7 +1460,7 @@ export function Prompt(props: PromptProps) {
|
||||
r.target?.focus()
|
||||
}}
|
||||
focusedBackgroundColor="transparent"
|
||||
cursorColor={props.disabled ? theme.background.surface.offset : theme.text.default}
|
||||
cursorColor={props.disabled ? themeV2.background.surface.offset : themeV2.text.default}
|
||||
syntaxStyle={syntax()}
|
||||
/>
|
||||
<box flexDirection="row" flexShrink={0} paddingTop={1} gap={1} justifyContent="space-between">
|
||||
@@ -1471,24 +1470,24 @@ export function Prompt(props: PromptProps) {
|
||||
<>
|
||||
<text fg={fadeColor(highlight(), agentMetaAlpha())}>{label()}</text>
|
||||
<Show when={store.mode === "normal" && local.permission.mode === "auto"}>
|
||||
<text fg={fadeColor(theme.text.subdued, agentMetaAlpha())}>auto</text>
|
||||
<text fg={fadeColor(themeV2.text.subdued, agentMetaAlpha())}>auto</text>
|
||||
</Show>
|
||||
<Show when={store.mode === "normal"}>
|
||||
<box flexDirection="row" gap={1}>
|
||||
<text fg={fadeColor(theme.text.subdued, modelMetaAlpha())}>·</text>
|
||||
<text fg={fadeColor(themeV2.text.subdued, modelMetaAlpha())}>·</text>
|
||||
<text
|
||||
flexShrink={0}
|
||||
fg={fadeColor(leader() ? theme.text.subdued : theme.text.default, modelMetaAlpha())}
|
||||
fg={fadeColor(leader() ? themeV2.text.subdued : themeV2.text.default, modelMetaAlpha())}
|
||||
>
|
||||
{local.model.parsed().model}
|
||||
</text>
|
||||
<text fg={fadeColor(theme.text.subdued, modelMetaAlpha())}>{currentProviderLabel()}</text>
|
||||
<text fg={fadeColor(themeV2.text.subdued, modelMetaAlpha())}>{currentProviderLabel()}</text>
|
||||
<Show when={showVariant()}>
|
||||
<text fg={fadeColor(theme.text.subdued, variantMetaAlpha())}>·</text>
|
||||
<text fg={fadeColor(themeV2.text.subdued, variantMetaAlpha())}>·</text>
|
||||
<text>
|
||||
<span
|
||||
style={{
|
||||
fg: fadeColor(theme.text.feedback.warning.default, variantMetaAlpha()),
|
||||
fg: fadeColor(themeV2.text.feedback.warning.default, variantMetaAlpha()),
|
||||
bold: true,
|
||||
}}
|
||||
>
|
||||
@@ -1542,12 +1541,12 @@ export function Prompt(props: PromptProps) {
|
||||
<Match when={status() === "running"}>
|
||||
<box flexDirection="row" gap={1} flexGrow={1} justifyContent="flex-start">
|
||||
<box marginLeft={1}>
|
||||
<Show when={config.animations ?? true} fallback={<text fg={theme.text.subdued}>[⋯]</text>}>
|
||||
<Show when={config.animations ?? true} fallback={<text fg={themeV2.text.subdued}>[⋯]</text>}>
|
||||
<spinner color={spinnerDef().color} frames={spinnerDef().frames} interval={40} />
|
||||
</Show>
|
||||
</box>
|
||||
<text
|
||||
fg={store.interrupt > 0 ? theme.background.action.primary.default : theme.text.default}
|
||||
fg={store.interrupt > 0 ? themeV2.background.action.primary.default : themeV2.text.default}
|
||||
wrapMode="none"
|
||||
truncate
|
||||
flexShrink={1}
|
||||
@@ -1555,7 +1554,7 @@ export function Prompt(props: PromptProps) {
|
||||
esc{" "}
|
||||
<span
|
||||
style={{
|
||||
fg: store.interrupt > 0 ? theme.background.action.primary.default : theme.text.subdued,
|
||||
fg: store.interrupt > 0 ? themeV2.background.action.primary.default : themeV2.text.subdued,
|
||||
}}
|
||||
>
|
||||
{store.interrupt > 0 ? "again to interrupt" : "interrupt"}
|
||||
@@ -1566,16 +1565,16 @@ export function Prompt(props: PromptProps) {
|
||||
<Match when={move.progress()}>
|
||||
{(progress) => (
|
||||
<box paddingLeft={3} height={1} minHeight={0} flexShrink={1}>
|
||||
<Spinner color={theme.hue.accent[500]}>
|
||||
<Spinner color={themeV2.hue.accent[500]}>
|
||||
{progress()}
|
||||
<span style={{ fg: theme.text.subdued }}>{".".repeat(move.creatingDots())}</span>
|
||||
<span style={{ fg: themeV2.text.subdued }}>{".".repeat(move.creatingDots())}</span>
|
||||
</Spinner>
|
||||
</box>
|
||||
)}
|
||||
</Match>
|
||||
<Match when={move.pendingNew()}>
|
||||
<box paddingLeft={3} height={1} minHeight={0} flexShrink={1}>
|
||||
<text fg={theme.hue.accent[500]} wrapMode="none" truncate>
|
||||
<text fg={themeV2.hue.accent[500]} wrapMode="none" truncate>
|
||||
(new working copy)
|
||||
</text>
|
||||
</box>
|
||||
@@ -1583,7 +1582,7 @@ export function Prompt(props: PromptProps) {
|
||||
<Match when={true}>
|
||||
<Show when={!props.hint && locationLabel()} fallback={props.hint ?? <text />}>
|
||||
{(location) => (
|
||||
<text fg={theme.text.subdued} wrapMode="none" truncate flexGrow={1} flexShrink={1}>
|
||||
<text fg={themeV2.text.subdued} wrapMode="none" truncate flexGrow={1} flexShrink={1}>
|
||||
{location()}
|
||||
</text>
|
||||
)}
|
||||
@@ -1597,7 +1596,7 @@ export function Prompt(props: PromptProps) {
|
||||
wrapMode="none"
|
||||
truncate
|
||||
flexShrink={1}
|
||||
fg={editorContextLabelState() === "pending" ? theme.hue.accent[500] : theme.text.subdued}
|
||||
fg={editorContextLabelState() === "pending" ? themeV2.hue.accent[500] : themeV2.text.subdued}
|
||||
>
|
||||
{file()}
|
||||
</text>
|
||||
@@ -1607,40 +1606,40 @@ export function Prompt(props: PromptProps) {
|
||||
<Match when={store.mode === "normal"}>
|
||||
<Switch>
|
||||
<Match when={liveWorkStatusVisible() || statusItems().length > 0}>
|
||||
<text fg={theme.text.subdued} wrapMode="none" truncate flexShrink={1}>
|
||||
<text fg={themeV2.text.subdued} wrapMode="none" truncate flexShrink={1}>
|
||||
<Show when={liveWorkStatusVisible() && liveWorkShortcut()}>
|
||||
{(shortcut) => <span style={{ fg: theme.text.default }}>{shortcut()} </span>}
|
||||
{(shortcut) => <span style={{ fg: themeV2.text.default }}>{shortcut()} </span>}
|
||||
</Show>
|
||||
<Show when={subagentStatusLabel()}>
|
||||
{(label) => <span style={{ fg: theme.text.subdued }}>{label()}</span>}
|
||||
{(label) => <span style={{ fg: themeV2.text.subdued }}>{label()}</span>}
|
||||
</Show>
|
||||
<Show when={subagentStatusLabel() && shellStatusLabel()}>
|
||||
<span style={{ fg: theme.text.subdued }}> · </span>
|
||||
<span style={{ fg: themeV2.text.subdued }}> · </span>
|
||||
</Show>
|
||||
<Show when={shellStatusLabel()}>
|
||||
{(label) => <span style={{ fg: theme.text.subdued }}>{label()}</span>}
|
||||
{(label) => <span style={{ fg: themeV2.text.subdued }}>{label()}</span>}
|
||||
</Show>
|
||||
<Show when={liveWorkStatusVisible() && statusItems().length > 0}>
|
||||
<span style={{ fg: theme.text.subdued }}> · </span>
|
||||
<span style={{ fg: themeV2.text.subdued }}> · </span>
|
||||
</Show>
|
||||
<Show when={statusItems().length > 0}>
|
||||
<span style={{ fg: theme.text.subdued }}>{statusItems().join(" · ")}</span>
|
||||
<span style={{ fg: themeV2.text.subdued }}>{statusItems().join(" · ")}</span>
|
||||
</Show>
|
||||
</text>
|
||||
</Match>
|
||||
<Match when={true}>
|
||||
<text fg={theme.text.default} flexShrink={0}>
|
||||
{agentShortcut()} <span style={{ fg: theme.text.subdued }}>agents</span>
|
||||
<text fg={themeV2.text.default} flexShrink={0}>
|
||||
{agentShortcut()} <span style={{ fg: themeV2.text.subdued }}>agents</span>
|
||||
</text>
|
||||
</Match>
|
||||
</Switch>
|
||||
<text fg={theme.text.default} flexShrink={0}>
|
||||
{paletteShortcut()} <span style={{ fg: theme.text.subdued }}>commands</span>
|
||||
<text fg={themeV2.text.default} flexShrink={0}>
|
||||
{paletteShortcut()} <span style={{ fg: themeV2.text.subdued }}>commands</span>
|
||||
</text>
|
||||
</Match>
|
||||
<Match when={store.mode === "shell"}>
|
||||
<text fg={theme.text.default} flexShrink={0}>
|
||||
esc <span style={{ fg: theme.text.subdued }}>exit shell mode</span>
|
||||
<text fg={themeV2.text.default} flexShrink={0}>
|
||||
esc <span style={{ fg: themeV2.text.subdued }}>exit shell mode</span>
|
||||
</text>
|
||||
</Match>
|
||||
</Switch>
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { RGBA } from "@opentui/core"
|
||||
import { useThemes } from "../context/theme"
|
||||
import { useTheme } from "../context/theme"
|
||||
import { Spinner } from "./spinner"
|
||||
|
||||
export function Reconnecting() {
|
||||
const theme = useThemes().contextual("elevated")
|
||||
const { themeV2 } = useTheme().contextual("elevated")
|
||||
|
||||
return (
|
||||
<box
|
||||
@@ -21,15 +21,15 @@ export function Reconnecting() {
|
||||
width={48}
|
||||
maxWidth="90%"
|
||||
flexDirection="column"
|
||||
backgroundColor={theme.background.default}
|
||||
backgroundColor={themeV2.background.default}
|
||||
paddingTop={1}
|
||||
paddingBottom={1}
|
||||
paddingLeft={2}
|
||||
paddingRight={2}
|
||||
gap={1}
|
||||
>
|
||||
<Spinner color={theme.text.default}>Restarting service...</Spinner>
|
||||
<text fg={theme.text.subdued}>Your session will resume automatically.</text>
|
||||
<Spinner color={themeV2.text.default}>Restarting service...</Spinner>
|
||||
<text fg={themeV2.text.subdued}>Your session will resume automatically.</text>
|
||||
</box>
|
||||
</box>
|
||||
)
|
||||
|
||||
@@ -1,236 +0,0 @@
|
||||
import { RGBA, TextAttributes } from "@opentui/core"
|
||||
import { For, Show, createEffect, createMemo, createSignal } from "solid-js"
|
||||
import { useTerminalDimensions } from "@opentui/solid"
|
||||
import { useConfig } from "../config"
|
||||
import { useSessionTabs } from "../context/session-tabs"
|
||||
import { useTheme, useThemes } from "../context/theme"
|
||||
import { adaptiveSessionTabLayout, sessionTabComplete, SESSION_TAB_OVERFLOW_WIDTH } from "../context/session-tabs-model"
|
||||
import { createAnimatable, spring } from "../ui/animation"
|
||||
import { Locale } from "../util/locale"
|
||||
import { stringWidth } from "../util/string-width"
|
||||
import { TabPulse } from "./tab-pulse"
|
||||
import { tint } from "../theme/color"
|
||||
|
||||
export function SessionTabs() {
|
||||
const tabs = useSessionTabs()
|
||||
const dimensions = useTerminalDimensions()
|
||||
const theme = useTheme()
|
||||
const { mode } = useThemes()
|
||||
const config = useConfig().data
|
||||
const [hovered, setHovered] = createSignal<string>()
|
||||
const hueStep = () => (mode() === "light" ? 800 : 200)
|
||||
const accent = () => theme.hue.accent[hueStep()]
|
||||
const activeNumber = () => tint(theme.hue.interactive[hueStep()], theme.background.default, 0.25)
|
||||
const idleNumber = () => tint(theme.text.subdued, theme.background.default, 0.35)
|
||||
const activeID = createMemo(tabs.current)
|
||||
const items = tabs.tabs
|
||||
const layout = createMemo((previous: ReturnType<typeof adaptiveSessionTabLayout> | undefined) =>
|
||||
adaptiveSessionTabLayout(items(), activeID(), dimensions().width, previous?.start),
|
||||
)
|
||||
const statuses = createMemo(
|
||||
() =>
|
||||
new Map(
|
||||
layout().tabs.map((tab) => {
|
||||
const status = tabs.status(tab.sessionID)
|
||||
return [
|
||||
tab.sessionID,
|
||||
{
|
||||
...status,
|
||||
complete: sessionTabComplete(status.unread, status.busy),
|
||||
},
|
||||
] as const
|
||||
}),
|
||||
),
|
||||
)
|
||||
const targets = createMemo(() => ({
|
||||
widths: layout().widths,
|
||||
selections: layout().tabs.map((tab) => Number(tab.sessionID === activeID())),
|
||||
activities: layout().tabs.map((tab) => Number(statuses().get(tab.sessionID)!.complete)),
|
||||
}))
|
||||
const motion = createAnimatable(targets(), {
|
||||
enabled: () => config.animations ?? true,
|
||||
transition: spring({ visualDuration: 0.1 }),
|
||||
})
|
||||
const identity = createMemo(() =>
|
||||
layout()
|
||||
.tabs.map((tab) => tab.sessionID)
|
||||
.join(":"),
|
||||
)
|
||||
let signature = ""
|
||||
let total = 0
|
||||
|
||||
createEffect(() => {
|
||||
const next = targets()
|
||||
const nextSignature = identity()
|
||||
const reset = (signature && signature !== nextSignature) || (total && total !== layout().total)
|
||||
signature = nextSignature
|
||||
total = layout().total
|
||||
if (reset) return motion.jump(next)
|
||||
motion.animate(next)
|
||||
})
|
||||
|
||||
const visuals = createMemo(() => {
|
||||
const current = signature === identity() && total === layout().total ? motion.value() : targets()
|
||||
const widths = current.widths.map((width) => Math.max(1, Math.round(width)))
|
||||
const active = layout().tabs.findIndex((tab) => tab.sessionID === activeID())
|
||||
if (active !== -1) widths[active]! += layout().total - widths.reduce((sum, width) => sum + width, 0)
|
||||
return new Map(
|
||||
layout().tabs.map((tab, index) => [
|
||||
tab.sessionID,
|
||||
{
|
||||
width: widths[index]!,
|
||||
selection: current.selections[index] ?? Number(tab.sessionID === activeID()),
|
||||
activity: current.activities[index] ?? Number(statuses().get(tab.sessionID)!.complete),
|
||||
},
|
||||
]),
|
||||
)
|
||||
})
|
||||
|
||||
return (
|
||||
<box
|
||||
height={1}
|
||||
flexShrink={0}
|
||||
position="relative"
|
||||
flexDirection="row"
|
||||
zIndex={1}
|
||||
renderAfter={function (buffer) {
|
||||
const x = Math.max(0, this.screenX)
|
||||
const y = this.screenY + this.height
|
||||
const width = Math.min(this.width, buffer.width - x)
|
||||
if (y < 0 || y >= buffer.height || width <= 0) return
|
||||
buffer.fillRect(
|
||||
x,
|
||||
y,
|
||||
width,
|
||||
1,
|
||||
RGBA.fromValues(
|
||||
theme.background.default.r,
|
||||
theme.background.default.g,
|
||||
theme.background.default.b,
|
||||
mode() === "light" ? 0.14 : 0.28,
|
||||
),
|
||||
)
|
||||
}}
|
||||
>
|
||||
<Show when={layout().before > 0}>
|
||||
<text width={SESSION_TAB_OVERFLOW_WIDTH} fg={theme.text.subdued}>
|
||||
‹{layout().before}
|
||||
</text>
|
||||
</Show>
|
||||
<For each={layout().tabs}>
|
||||
{(tab) => {
|
||||
const selected = () => activeID() === tab.sessionID
|
||||
const status = () => statuses().get(tab.sessionID)!
|
||||
const width = () => visuals().get(tab.sessionID)?.width ?? 1
|
||||
const selection = () => visuals().get(tab.sessionID)?.selection ?? Number(selected())
|
||||
const activity = () => visuals().get(tab.sessionID)?.activity ?? Number(status().complete)
|
||||
const background = () => {
|
||||
const base =
|
||||
hovered() === tab.sessionID && !selected()
|
||||
? theme.background.action.primary.hovered
|
||||
: theme.background.default
|
||||
return tint(base, theme.raise(theme.background.surface.offset), selection())
|
||||
}
|
||||
const pulseBackground = () => background()
|
||||
const pulseColor = () => tint(pulseBackground(), theme.text.default, 0.45)
|
||||
const title = () => tab.title ?? "Untitled session"
|
||||
const availableTitleWidth = () => Math.max(1, width() - 3)
|
||||
const visibleTitle = createMemo(() => Locale.takeWidth(title(), availableTitleWidth()))
|
||||
const visibleTitleParts = createMemo(() => Locale.graphemes(visibleTitle()))
|
||||
const fadeWidth = () => (hovered() === tab.sessionID ? 6 : 4)
|
||||
const fadedTitleParts = createMemo(() => visibleTitleParts().slice(-fadeWidth()))
|
||||
const titleFades = createMemo(
|
||||
() => stringWidth(title()) >= availableTitleWidth() && availableTitleWidth() > fadeWidth(),
|
||||
)
|
||||
const foreground = () => {
|
||||
if (hovered() === tab.sessionID) return theme.text.default
|
||||
return tint(theme.text.subdued, theme.text.default, selection())
|
||||
}
|
||||
const numberColor = () => {
|
||||
if (status().attention) return theme.text.feedback.warning.default
|
||||
if (status().unread === "error") return theme.text.feedback.error.default
|
||||
const base =
|
||||
hovered() === tab.sessionID && !selected()
|
||||
? foreground()
|
||||
: tint(idleNumber(), activeNumber(), selection())
|
||||
return tint(base, accent(), activity())
|
||||
}
|
||||
const closeColor = () => tint(theme.text.subdued, theme.text.default, 0.6)
|
||||
return (
|
||||
<box
|
||||
width={width()}
|
||||
position="relative"
|
||||
flexDirection="row"
|
||||
backgroundColor={background()}
|
||||
onMouseOver={() => setHovered(tab.sessionID)}
|
||||
onMouseOut={() => setHovered(undefined)}
|
||||
onMouseUp={() => tabs.select(tab.sessionID)}
|
||||
>
|
||||
<TabPulse
|
||||
enabled={config.animations ?? true}
|
||||
active={status().busy}
|
||||
complete={status().complete}
|
||||
glow={status().unread === "activity" && !status().busy && !selected() && !status().attention}
|
||||
color={pulseColor()}
|
||||
glowColor={accent()}
|
||||
completionColor={accent()}
|
||||
backgroundColor={pulseBackground()}
|
||||
/>
|
||||
<box zIndex={1} width="100%" flexDirection="row">
|
||||
<text width={1}> </text>
|
||||
<text width={2} fg={numberColor()} attributes={selected() ? TextAttributes.BOLD : undefined}>
|
||||
{items().findIndex((item) => item.sessionID === tab.sessionID) + 1}
|
||||
</text>
|
||||
<Show
|
||||
when={titleFades()}
|
||||
fallback={
|
||||
<text width={availableTitleWidth()} fg={foreground()} wrapMode="none">
|
||||
{visibleTitle()}
|
||||
</text>
|
||||
}
|
||||
>
|
||||
<text width={availableTitleWidth()} fg={foreground()} wrapMode="none">
|
||||
{visibleTitleParts().slice(0, -fadeWidth()).join("")}
|
||||
<For each={fadedTitleParts()}>
|
||||
{(character, index) => (
|
||||
<span
|
||||
style={{
|
||||
fg: tint(
|
||||
foreground(),
|
||||
pulseBackground(),
|
||||
0.2 + 0.72 * (index() / Math.max(1, fadedTitleParts().length - 1)),
|
||||
),
|
||||
}}
|
||||
>
|
||||
{character}
|
||||
</span>
|
||||
)}
|
||||
</For>
|
||||
</text>
|
||||
</Show>
|
||||
<text
|
||||
position="absolute"
|
||||
right={1}
|
||||
zIndex={2}
|
||||
width={1}
|
||||
fg={closeColor()}
|
||||
onMouseUp={(event) => {
|
||||
event.stopPropagation()
|
||||
tabs.close(tab.sessionID)
|
||||
}}
|
||||
>
|
||||
{hovered() === tab.sessionID ? "×" : ""}
|
||||
</text>
|
||||
</box>
|
||||
</box>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
<Show when={layout().after > 0}>
|
||||
<text width={SESSION_TAB_OVERFLOW_WIDTH} fg={theme.text.subdued}>
|
||||
{layout().after}›
|
||||
</text>
|
||||
</Show>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
@@ -11,9 +11,9 @@ export { SPINNER_FRAMES } from "./spinner-frames"
|
||||
registerOpencodeSpinner()
|
||||
|
||||
export function Spinner(props: { children?: JSX.Element; color?: RGBA }) {
|
||||
const theme = useTheme()
|
||||
const { themeV2 } = useTheme()
|
||||
const config = useConfig().data
|
||||
const color = () => props.color ?? theme.text.subdued
|
||||
const color = () => props.color ?? themeV2.text.subdued
|
||||
return (
|
||||
<Show
|
||||
when={config.animations ?? true}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { createEffect, createMemo, createSignal, onCleanup, Show } from "solid-js"
|
||||
import { useThemes } from "../context/theme"
|
||||
import { useTheme } from "../context/theme"
|
||||
import { Spinner } from "./spinner"
|
||||
|
||||
export function StartupLoading(props: { ready: () => boolean }) {
|
||||
const theme = useThemes().contextual("elevated")
|
||||
const { themeV2 } = useTheme().contextual("elevated")
|
||||
const [show, setShow] = createSignal(false)
|
||||
const text = createMemo(() => (props.ready() ? "Finishing startup..." : "Loading plugins..."))
|
||||
let wait: NodeJS.Timeout | undefined
|
||||
@@ -54,8 +54,8 @@ export function StartupLoading(props: { ready: () => boolean }) {
|
||||
return (
|
||||
<Show when={show()}>
|
||||
<box position="absolute" zIndex={5000} left={0} right={0} bottom={1} justifyContent="center" alignItems="center">
|
||||
<box backgroundColor={theme.background.default} paddingLeft={1} paddingRight={1}>
|
||||
<Spinner color={theme.text.subdued}>{text()}</Spinner>
|
||||
<box backgroundColor={themeV2.background.default} paddingLeft={1} paddingRight={1}>
|
||||
<Spinner color={themeV2.text.subdued}>{text()}</Spinner>
|
||||
</box>
|
||||
</box>
|
||||
</Show>
|
||||
|
||||
@@ -1,242 +0,0 @@
|
||||
import { OptimizedBuffer, Renderable, RGBA, type RenderableOptions, type RenderContext } from "@opentui/core"
|
||||
import { extend } from "@opentui/solid"
|
||||
import { tint } from "../theme/color"
|
||||
|
||||
type TabPulseOptions = RenderableOptions<TabPulseRenderable> & {
|
||||
enabled?: boolean
|
||||
active?: boolean
|
||||
complete?: boolean
|
||||
glow?: boolean
|
||||
color?: RGBA
|
||||
glowColor?: RGBA
|
||||
completionColor?: RGBA
|
||||
backgroundColor?: RGBA
|
||||
}
|
||||
|
||||
const clamp = (value: number) => Math.max(0, Math.min(1, value))
|
||||
const smootherstep = (value: number) => value * value * value * (value * (value * 6 - 15) + 10)
|
||||
const RUN_DURATION = 2_800
|
||||
const RUN_HEAD = 4
|
||||
const RUN_TAIL = 18
|
||||
const RUN_FADE_OUT = 500
|
||||
const COMPLETION_DURATION = 900
|
||||
const COMPLETION_ATTACK = 0.16
|
||||
const GLOW_TAIL = 12
|
||||
const GLOW_OPACITY = 0.16
|
||||
const intensityAt = (index: number, front: number, head: number, tail: number) => {
|
||||
const distance = front - index
|
||||
return distance < 0 ? smootherstep(clamp(1 + distance / head)) : smootherstep(clamp(1 - distance / tail))
|
||||
}
|
||||
const coast = (value: number) => {
|
||||
const ramp = 0.2
|
||||
if (value < ramp) return (value * value) / (2 * ramp * (1 - ramp))
|
||||
if (value > 1 - ramp) return 1 - ((1 - value) * (1 - value)) / (2 * ramp * (1 - ramp))
|
||||
return (value - ramp / 2) / (1 - ramp)
|
||||
}
|
||||
export const completionPulseOpacity = (progress: number) =>
|
||||
progress < COMPLETION_ATTACK
|
||||
? smootherstep(clamp(progress / COMPLETION_ATTACK))
|
||||
: 1 - smootherstep(clamp((progress - COMPLETION_ATTACK) / (1 - COMPLETION_ATTACK)))
|
||||
export const unreadGlowIntensity = (index: number, width: number) => {
|
||||
const tail = Math.min(GLOW_TAIL, Math.max(1, width - 2))
|
||||
return smootherstep(clamp(1 - Math.max(0, index - 1) / tail))
|
||||
}
|
||||
class TabPulseRenderable extends Renderable {
|
||||
private _enabled: boolean
|
||||
private _active: boolean
|
||||
private _complete: boolean
|
||||
private _glow: boolean
|
||||
private _color: RGBA
|
||||
private _glowColor: RGBA
|
||||
private _completionColor: RGBA
|
||||
private _backgroundColor: RGBA
|
||||
private clock = 0
|
||||
private fadeClock: number | undefined
|
||||
private completionClock: number | undefined
|
||||
private completionPending = false
|
||||
|
||||
constructor(ctx: RenderContext, options: TabPulseOptions = {}) {
|
||||
const enabled = options.enabled ?? true
|
||||
const active = options.active ?? false
|
||||
super(ctx, { ...options, height: 1, live: enabled && active })
|
||||
this._enabled = enabled
|
||||
this._active = active
|
||||
this._complete = options.complete ?? false
|
||||
this._glow = options.glow ?? false
|
||||
this._color = options.color ?? RGBA.defaultForeground()
|
||||
this._glowColor = options.glowColor ?? this._color
|
||||
this._completionColor = options.completionColor ?? this._color
|
||||
this._backgroundColor = options.backgroundColor ?? RGBA.defaultBackground()
|
||||
}
|
||||
|
||||
set enabled(value: boolean) {
|
||||
if (value === this._enabled) return
|
||||
this._enabled = value
|
||||
if (!value) {
|
||||
this.fadeClock = undefined
|
||||
this.completionClock = undefined
|
||||
this.completionPending = false
|
||||
this.live = false
|
||||
} else if (this._active) {
|
||||
this.live = true
|
||||
}
|
||||
this.requestRender()
|
||||
}
|
||||
|
||||
set active(value: boolean) {
|
||||
if (value === this._active) return
|
||||
this._active = value
|
||||
if (!this._enabled) return
|
||||
if (value) {
|
||||
this.fadeClock = undefined
|
||||
this.completionClock = undefined
|
||||
this.completionPending = false
|
||||
this.live = true
|
||||
} else {
|
||||
this.fadeClock = 0
|
||||
this.completionPending = true
|
||||
this.live = true
|
||||
}
|
||||
this.requestRender()
|
||||
}
|
||||
|
||||
set complete(value: boolean) {
|
||||
if (value === this._complete) return
|
||||
this._complete = value
|
||||
if (!value) {
|
||||
this.completionClock = undefined
|
||||
this.completionPending = false
|
||||
}
|
||||
if (value && this.completionPending) {
|
||||
this.completionClock = 0
|
||||
this.completionPending = false
|
||||
this.live = this._enabled
|
||||
}
|
||||
this.requestRender()
|
||||
}
|
||||
|
||||
set glow(value: boolean) {
|
||||
if (value === this._glow) return
|
||||
this._glow = value
|
||||
this.requestRender()
|
||||
}
|
||||
|
||||
set color(value: RGBA) {
|
||||
if (value.equals(this._color)) return
|
||||
this._color = value
|
||||
this.requestRender()
|
||||
}
|
||||
|
||||
set glowColor(value: RGBA) {
|
||||
if (value.equals(this._glowColor)) return
|
||||
this._glowColor = value
|
||||
this.requestRender()
|
||||
}
|
||||
|
||||
set completionColor(value: RGBA) {
|
||||
if (value.equals(this._completionColor)) return
|
||||
this._completionColor = value
|
||||
this.requestRender()
|
||||
}
|
||||
|
||||
set backgroundColor(value: RGBA) {
|
||||
if (value.equals(this._backgroundColor)) return
|
||||
this._backgroundColor = value
|
||||
this.requestRender()
|
||||
}
|
||||
|
||||
protected override onUpdate(deltaTime: number): void {
|
||||
if (!this._enabled) return
|
||||
if (this._active || this.fadeClock !== undefined) this.clock += deltaTime
|
||||
if (this.fadeClock !== undefined) {
|
||||
this.fadeClock += deltaTime
|
||||
if (this.fadeClock >= RUN_FADE_OUT) this.fadeClock = undefined
|
||||
}
|
||||
if (this.completionPending) {
|
||||
if (this._complete) {
|
||||
this.completionClock = 0
|
||||
this.completionPending = false
|
||||
} else if (this.fadeClock === undefined) {
|
||||
this.completionPending = false
|
||||
}
|
||||
}
|
||||
if (this.completionClock !== undefined) {
|
||||
this.completionClock += deltaTime
|
||||
if (this.completionClock >= COMPLETION_DURATION) this.completionClock = undefined
|
||||
}
|
||||
this.live = this._active || this.fadeClock !== undefined || this.completionClock !== undefined
|
||||
}
|
||||
|
||||
protected override renderSelf(buffer: OptimizedBuffer): void {
|
||||
if (!this.visible || this.isDestroyed || this.width <= 0) return
|
||||
const runningOpacity = !this._enabled
|
||||
? 0
|
||||
: this._active
|
||||
? 1
|
||||
: this.fadeClock === undefined
|
||||
? 0
|
||||
: 1 - smootherstep(clamp(this.fadeClock / RUN_FADE_OUT))
|
||||
const completionOpacity =
|
||||
!this._enabled || this.completionClock === undefined
|
||||
? 0
|
||||
: completionPulseOpacity(this.completionClock / COMPLETION_DURATION)
|
||||
if (!this._glow && runningOpacity === 0 && completionOpacity === 0) return
|
||||
const progress = (this.clock % RUN_DURATION) / RUN_DURATION
|
||||
const start = -RUN_HEAD
|
||||
const end = this.width - 1 + RUN_TAIL
|
||||
const front = start + coast(progress) * (end - start)
|
||||
const secondFront = start + coast((progress + 0.5) % 1) * (end - start)
|
||||
for (let index = 0; index < this.width; index++) {
|
||||
const intensity = Math.max(
|
||||
intensityAt(index, front, RUN_HEAD, RUN_TAIL),
|
||||
intensityAt(index, secondFront, RUN_HEAD, RUN_TAIL),
|
||||
)
|
||||
const background = this._glow
|
||||
? tint(this._backgroundColor, this._glowColor, unreadGlowIntensity(index, this.width) * GLOW_OPACITY)
|
||||
: this._backgroundColor
|
||||
const running = tint(background, this._color, intensity * 0.14 * runningOpacity)
|
||||
buffer.setCell(
|
||||
this.screenX + index,
|
||||
this.screenY,
|
||||
" ",
|
||||
RGBA.defaultForeground(),
|
||||
tint(running, this._completionColor, completionOpacity * 0.18),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
declare module "@opentui/solid" {
|
||||
interface OpenTUIComponents {
|
||||
tab_pulse: typeof TabPulseRenderable
|
||||
}
|
||||
}
|
||||
|
||||
extend({ tab_pulse: TabPulseRenderable })
|
||||
|
||||
export function TabPulse(props: {
|
||||
enabled?: boolean
|
||||
active: boolean
|
||||
complete?: boolean
|
||||
glow?: boolean
|
||||
color: RGBA
|
||||
glowColor?: RGBA
|
||||
completionColor?: RGBA
|
||||
backgroundColor: RGBA
|
||||
}) {
|
||||
return (
|
||||
<tab_pulse
|
||||
position="absolute"
|
||||
zIndex={0}
|
||||
width="100%"
|
||||
enabled={props.enabled ?? true}
|
||||
active={props.active}
|
||||
complete={props.complete ?? false}
|
||||
glow={props.glow ?? false}
|
||||
color={props.color}
|
||||
glowColor={props.glowColor ?? props.color}
|
||||
completionColor={props.completionColor ?? props.color}
|
||||
backgroundColor={props.backgroundColor}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -1,13 +1,13 @@
|
||||
import { onCleanup } from "solid-js"
|
||||
import { useThemes } from "../context/theme"
|
||||
import { useTheme } from "../context/theme"
|
||||
import { useToast } from "../ui/toast"
|
||||
|
||||
export function ThemeErrorToast() {
|
||||
const themes = useThemes()
|
||||
const theme = useTheme()
|
||||
const toast = useToast()
|
||||
|
||||
onCleanup(
|
||||
themes.onError(({ name, error }) =>
|
||||
theme.onError(({ name, error }) =>
|
||||
toast.show({
|
||||
variant: "error",
|
||||
title: `Failed to load theme: ${name}`,
|
||||
|
||||
@@ -122,13 +122,6 @@ export const Info = Schema.Struct({
|
||||
}),
|
||||
}),
|
||||
).annotate({ description: "Session transcript presentation settings" }),
|
||||
tabs: Schema.optional(
|
||||
Schema.Struct({
|
||||
enabled: Schema.optional(Schema.Boolean).annotate({
|
||||
description: "Use a persistent session tab strip instead of pinned quick-switch sessions",
|
||||
}),
|
||||
}),
|
||||
).annotate({ description: "Session tab settings" }),
|
||||
mini: Schema.optional(
|
||||
Schema.Struct({
|
||||
thinking: Schema.optional(Schema.Literals(["show", "hide"])).annotate({
|
||||
|
||||
@@ -87,13 +87,6 @@ export const Definitions = {
|
||||
session_move: keybind("none", "Move session"),
|
||||
session_new: keybind("<leader>n", "Create a new session"),
|
||||
session_list: keybind("<leader>l", "List all sessions"),
|
||||
session_tab_next: keybind("ctrl+tab,<leader>right", "Switch to next open session tab"),
|
||||
session_tab_previous: keybind("ctrl+shift+tab,<leader>left", "Switch to previous open session tab"),
|
||||
session_tab_history_back: keybind("ctrl+o", "Go back in session tab history"),
|
||||
session_tab_history_forward: keybind("ctrl+i", "Go forward in session tab history"),
|
||||
session_tab_next_unread: keybind("<leader>down", "Switch to next unread session tab"),
|
||||
session_tab_previous_unread: keybind("<leader>up", "Switch to previous unread session tab"),
|
||||
session_tab_close: keybind("<leader>w", "Close current session tab"),
|
||||
session_timeline: keybind("<leader>g", "Show session timeline"),
|
||||
session_fork: keybind("none", "Fork session from message"),
|
||||
session_rename: keybind("ctrl+r", "Rename session"),
|
||||
@@ -104,7 +97,7 @@ export const Definitions = {
|
||||
session_background: keybind("ctrl+b", "Background blocking session tools"),
|
||||
session_compact: keybind("<leader>c", "Compact the session"),
|
||||
session_queued_prompts: keybind("<leader>q", "View pending work"),
|
||||
session_child_first: keybind("down", "Toggle subagent picker"),
|
||||
session_child_first: keybind("down,<leader>down", "Toggle subagent picker"),
|
||||
session_child_cycle: keybind("right", "Go to next child session"),
|
||||
session_child_cycle_reverse: keybind("left", "Go to previous child session"),
|
||||
session_parent: keybind("up", "Go to parent session"),
|
||||
@@ -118,15 +111,6 @@ export const Definitions = {
|
||||
session_quick_switch_7: keybind("<leader>7", "Switch to session in quick slot 7"),
|
||||
session_quick_switch_8: keybind("<leader>8", "Switch to session in quick slot 8"),
|
||||
session_quick_switch_9: keybind("<leader>9", "Switch to session in quick slot 9"),
|
||||
session_tab_select_1: keybind("<leader>1,ctrl+1", "Switch to session tab 1"),
|
||||
session_tab_select_2: keybind("<leader>2,ctrl+2", "Switch to session tab 2"),
|
||||
session_tab_select_3: keybind("<leader>3,ctrl+3", "Switch to session tab 3"),
|
||||
session_tab_select_4: keybind("<leader>4,ctrl+4", "Switch to session tab 4"),
|
||||
session_tab_select_5: keybind("<leader>5,ctrl+5", "Switch to session tab 5"),
|
||||
session_tab_select_6: keybind("<leader>6,ctrl+6", "Switch to session tab 6"),
|
||||
session_tab_select_7: keybind("<leader>7,ctrl+7", "Switch to session tab 7"),
|
||||
session_tab_select_8: keybind("<leader>8,ctrl+8", "Switch to session tab 8"),
|
||||
session_tab_select_9: keybind("<leader>9,ctrl+9", "Switch to session tab 9"),
|
||||
|
||||
stash_delete: keybind("ctrl+d", "Delete stash entry"),
|
||||
model_provider_list: keybind("ctrl+a", "Open provider list from model dialog"),
|
||||
@@ -230,9 +214,24 @@ export const Definitions = {
|
||||
"permission.prompt.fullscreen": keybind("ctrl+f", "Toggle permission prompt fullscreen"),
|
||||
"plugins.toggle": keybind("space", "Toggle plugin"),
|
||||
"dialog.mcp.toggle": keybind("space", "Toggle MCP server"),
|
||||
"dialog.plugins.install": keybind("shift+i", "Install plugin from plugin dialog"),
|
||||
|
||||
terminal_suspend: keybind("ctrl+z", "Suspend terminal"),
|
||||
terminal_title_toggle: keybind("none", "Toggle terminal title"),
|
||||
plugin_manager: keybind("none", "Open plugin manager dialog"),
|
||||
plugin_install: keybind("none", "Install plugin"),
|
||||
|
||||
which_key_toggle: keybind("ctrl+alt+k", "Toggle which-key panel"),
|
||||
which_key_layout_toggle: keybind("ctrl+alt+shift+k", "Switch which-key layout"),
|
||||
which_key_pending_toggle: keybind("ctrl+alt+shift+p", "Toggle which-key pending preview"),
|
||||
which_key_group_previous: keybind("ctrl+alt+left,ctrl+alt+[", "Previous which-key group"),
|
||||
which_key_group_next: keybind("ctrl+alt+right,ctrl+alt+]", "Next which-key group"),
|
||||
which_key_scroll_up: keybind("ctrl+alt+up,ctrl+alt+p", "Scroll which-key up"),
|
||||
which_key_scroll_down: keybind("ctrl+alt+down,ctrl+alt+n", "Scroll which-key down"),
|
||||
which_key_page_up: keybind("ctrl+alt+pageup", "Page which-key up"),
|
||||
which_key_page_down: keybind("ctrl+alt+pagedown", "Page which-key down"),
|
||||
which_key_home: keybind("ctrl+alt+home", "Jump to first which-key binding"),
|
||||
which_key_end: keybind("ctrl+alt+end", "Jump to last which-key binding"),
|
||||
} satisfies Record<string, Definition>
|
||||
|
||||
type KeybindName = keyof typeof Definitions
|
||||
@@ -290,13 +289,6 @@ export const CommandMap = {
|
||||
session_move: "session.move",
|
||||
session_new: "session.new",
|
||||
session_list: "session.list",
|
||||
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_timeline: "session.timeline",
|
||||
session_fork: "session.fork",
|
||||
session_rename: "session.rename",
|
||||
@@ -321,15 +313,6 @@ export const CommandMap = {
|
||||
session_quick_switch_7: "session.quick_switch.7",
|
||||
session_quick_switch_8: "session.quick_switch.8",
|
||||
session_quick_switch_9: "session.quick_switch.9",
|
||||
session_tab_select_1: "session.tab.select.1",
|
||||
session_tab_select_2: "session.tab.select.2",
|
||||
session_tab_select_3: "session.tab.select.3",
|
||||
session_tab_select_4: "session.tab.select.4",
|
||||
session_tab_select_5: "session.tab.select.5",
|
||||
session_tab_select_6: "session.tab.select.6",
|
||||
session_tab_select_7: "session.tab.select.7",
|
||||
session_tab_select_8: "session.tab.select.8",
|
||||
session_tab_select_9: "session.tab.select.9",
|
||||
stash_delete: "stash.delete",
|
||||
model_provider_list: "model.dialog.provider",
|
||||
model_favorite_toggle: "model.dialog.favorite",
|
||||
@@ -410,6 +393,19 @@ export const CommandMap = {
|
||||
history_next: "prompt.history.next",
|
||||
terminal_suspend: "terminal.suspend",
|
||||
terminal_title_toggle: "terminal.title.toggle",
|
||||
plugin_manager: "plugins.list",
|
||||
plugin_install: "plugins.install",
|
||||
which_key_toggle: "which-key.toggle",
|
||||
which_key_layout_toggle: "which-key.layout.toggle",
|
||||
which_key_pending_toggle: "which-key.pending.toggle",
|
||||
which_key_group_previous: "which-key.group.previous",
|
||||
which_key_group_next: "which-key.group.next",
|
||||
which_key_scroll_up: "which-key.scroll.up",
|
||||
which_key_scroll_down: "which-key.scroll.down",
|
||||
which_key_page_up: "which-key.page.up",
|
||||
which_key_page_down: "which-key.page.down",
|
||||
which_key_home: "which-key.home",
|
||||
which_key_end: "which-key.end",
|
||||
} satisfies BindingCommandMap
|
||||
const CommandDescriptions = Object.fromEntries(
|
||||
Object.entries(Definitions).map(([name, item]) => [
|
||||
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
type ModelPreference,
|
||||
type ModelPreferenceModel,
|
||||
} from "../model-preference"
|
||||
import { useTheme, useThemes } from "./theme"
|
||||
import { useTheme } from "./theme"
|
||||
import { useToast } from "../ui/toast"
|
||||
import { useRoute } from "./route"
|
||||
import { useData } from "./data"
|
||||
@@ -50,8 +50,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
||||
const data = useData()
|
||||
const client = useClient()
|
||||
const toast = useToast()
|
||||
const theme = useTheme()
|
||||
const { mode } = useThemes()
|
||||
const { themeV2, mode } = useTheme()
|
||||
const route = useRoute()
|
||||
const paths = useTuiPaths()
|
||||
const args = useArgs()
|
||||
@@ -83,7 +82,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
||||
const colors = createMemo(() => {
|
||||
const step = mode() === "light" ? 800 : 200
|
||||
return dedupeWith(
|
||||
theme.categorical.map((scale) => scale[step]),
|
||||
themeV2.categorical.map((scale) => scale[step]),
|
||||
(first, second) => first.equals(second),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -1,153 +0,0 @@
|
||||
export type SessionTab = {
|
||||
sessionID: string
|
||||
title?: string
|
||||
}
|
||||
|
||||
export type SessionTabUnread = "activity" | "error"
|
||||
|
||||
export type SessionTabHistory = {
|
||||
entries: readonly string[]
|
||||
index: number
|
||||
}
|
||||
|
||||
export function sessionTabComplete(unread: SessionTabUnread | undefined, busy: boolean) {
|
||||
return unread === "activity" && !busy
|
||||
}
|
||||
|
||||
export const SESSION_TAB_WIDTH = 22
|
||||
export const SESSION_TAB_MAX_WIDTH = 32
|
||||
export const SESSION_TAB_MIN_WIDTH = 8
|
||||
export const SESSION_TAB_OVERFLOW_WIDTH = 3
|
||||
|
||||
export function openSessionTab(tabs: SessionTab[], tab: SessionTab): SessionTab[] {
|
||||
const index = tabs.findIndex((item) => item.sessionID === tab.sessionID)
|
||||
if (index === -1) return [...tabs, tab]
|
||||
if (!tab.title || tabs[index]?.title === tab.title) return tabs
|
||||
return tabs.map((item, position) => (position === index ? { ...item, title: tab.title } : item))
|
||||
}
|
||||
|
||||
export function closeSessionTab(tabs: readonly SessionTab[], sessionID: string) {
|
||||
const index = tabs.findIndex((tab) => tab.sessionID === sessionID)
|
||||
if (index === -1) return { tabs: [...tabs], next: undefined }
|
||||
return {
|
||||
tabs: tabs.filter((tab) => tab.sessionID !== sessionID),
|
||||
next: tabs[index + 1]?.sessionID ?? tabs[index - 1]?.sessionID,
|
||||
}
|
||||
}
|
||||
|
||||
export function cycleSessionTab(tabs: readonly SessionTab[], active: string | undefined, direction: 1 | -1) {
|
||||
if (tabs.length === 0) return
|
||||
const index = tabs.findIndex((tab) => tab.sessionID === active)
|
||||
const start = index === -1 ? (direction === 1 ? -1 : 0) : index
|
||||
return tabs[(start + direction + tabs.length) % tabs.length]
|
||||
}
|
||||
|
||||
export function recordSessionTabHistory(history: SessionTabHistory, sessionID: string): SessionTabHistory {
|
||||
if (history.entries[history.index] === sessionID) return history
|
||||
const entries = [...history.entries.slice(0, history.index + 1), sessionID]
|
||||
return { entries, index: entries.length - 1 }
|
||||
}
|
||||
|
||||
export function moveSessionTabHistory(
|
||||
history: SessionTabHistory,
|
||||
tabs: readonly SessionTab[],
|
||||
active: string | undefined,
|
||||
direction: 1 | -1,
|
||||
) {
|
||||
if (!active) {
|
||||
const sessionID = history.entries[history.index]
|
||||
return tabs.some((tab) => tab.sessionID === sessionID) ? { history, sessionID } : { history, sessionID: undefined }
|
||||
}
|
||||
const entries = history.entries.map((sessionID, index) => ({ sessionID, index }))
|
||||
const candidates = direction === -1 ? entries.slice(0, history.index).reverse() : entries.slice(history.index + 1)
|
||||
const target = candidates.find(
|
||||
(entry) => entry.sessionID !== active && tabs.some((tab) => tab.sessionID === entry.sessionID),
|
||||
)
|
||||
if (!target) return { history, sessionID: undefined }
|
||||
return { history: { ...history, index: target.index }, sessionID: target.sessionID }
|
||||
}
|
||||
|
||||
export function adaptiveSessionTabLayout(
|
||||
tabs: readonly SessionTab[],
|
||||
active: string | undefined,
|
||||
available: number,
|
||||
previousStart = 0,
|
||||
) {
|
||||
if (tabs.length === 0) return { tabs: [], widths: [], before: 0, after: 0, start: 0, total: 0 }
|
||||
|
||||
const activeIndex = tabs.findIndex((tab) => tab.sessionID === active)
|
||||
const fit = (width: number) =>
|
||||
Math.min(
|
||||
tabs.length,
|
||||
Math.max(
|
||||
1,
|
||||
activeIndex === -1
|
||||
? Math.floor(Math.max(0, width) / SESSION_TAB_MIN_WIDTH)
|
||||
: 1 + Math.floor((Math.max(0, width) - SESSION_TAB_WIDTH) / SESSION_TAB_MIN_WIDTH),
|
||||
),
|
||||
)
|
||||
const solve = (count: number, start: number, attempts: number): { count: number; start: number } => {
|
||||
const boundedStart = Math.min(Math.max(0, start), tabs.length - count)
|
||||
const nextStart = Math.min(
|
||||
Math.max(
|
||||
0,
|
||||
activeIndex === -1
|
||||
? boundedStart
|
||||
: activeIndex < boundedStart
|
||||
? activeIndex
|
||||
: activeIndex >= boundedStart + count
|
||||
? activeIndex - count + 1
|
||||
: boundedStart,
|
||||
),
|
||||
tabs.length - count,
|
||||
)
|
||||
const markers =
|
||||
(nextStart > 0 ? SESSION_TAB_OVERFLOW_WIDTH : 0) +
|
||||
(nextStart + count < tabs.length ? SESSION_TAB_OVERFLOW_WIDTH : 0)
|
||||
const nextCount = fit(available - markers)
|
||||
if (nextCount === count || attempts === 0) return { count, start: nextStart }
|
||||
return solve(nextCount, nextStart, attempts - 1)
|
||||
}
|
||||
const solved = solve(fit(available), previousStart, 3)
|
||||
const visible = tabs.slice(solved.start, solved.start + solved.count)
|
||||
const before = solved.start
|
||||
const after = tabs.length - solved.start - solved.count
|
||||
const contentWidth = Math.max(
|
||||
1,
|
||||
available - (before > 0 ? SESSION_TAB_OVERFLOW_WIDTH : 0) - (after > 0 ? SESSION_TAB_OVERFLOW_WIDTH : 0),
|
||||
)
|
||||
const roomy = contentWidth >= SESSION_TAB_WIDTH * visible.length
|
||||
const total = roomy ? Math.min(contentWidth, SESSION_TAB_MAX_WIDTH * visible.length) : contentWidth
|
||||
if (roomy || activeIndex === -1) {
|
||||
const width = Math.floor(total / visible.length)
|
||||
const remainder = total - width * visible.length
|
||||
return {
|
||||
tabs: visible,
|
||||
widths: visible.map((_, index) => width + Number(index < remainder)),
|
||||
before,
|
||||
after,
|
||||
start: solved.start,
|
||||
total,
|
||||
}
|
||||
}
|
||||
const inactiveWidth =
|
||||
visible.length === 1
|
||||
? 0
|
||||
: Math.min(
|
||||
SESSION_TAB_WIDTH,
|
||||
Math.max(
|
||||
SESSION_TAB_MIN_WIDTH,
|
||||
Math.floor((total - Math.min(SESSION_TAB_WIDTH, total)) / (visible.length - 1)),
|
||||
),
|
||||
)
|
||||
const activeWidth = visible.length === 1 ? total : total - inactiveWidth * (visible.length - 1)
|
||||
|
||||
return {
|
||||
tabs: visible,
|
||||
widths: visible.map((tab) => (tab.sessionID === active ? activeWidth : inactiveWidth)),
|
||||
before,
|
||||
after,
|
||||
start: solved.start,
|
||||
total,
|
||||
}
|
||||
}
|
||||
@@ -1,267 +0,0 @@
|
||||
import { batch, createEffect, onCleanup, untrack } from "solid-js"
|
||||
import { createStore, produce, reconcile } from "solid-js/store"
|
||||
import path from "path"
|
||||
import { isDeepEqual } from "remeda"
|
||||
import { createSimpleContext } from "./helper"
|
||||
import { useData } from "./data"
|
||||
import { useEvent } from "./event"
|
||||
import { useRoute } from "./route"
|
||||
import { useTuiPaths } from "./runtime"
|
||||
import { useConfig } from "../config"
|
||||
import { readJson, writeJsonAtomic } from "../util/persistence"
|
||||
import { isRecord } from "../util/record"
|
||||
import {
|
||||
closeSessionTab,
|
||||
cycleSessionTab,
|
||||
moveSessionTabHistory,
|
||||
openSessionTab,
|
||||
recordSessionTabHistory,
|
||||
type SessionTab,
|
||||
type SessionTabHistory,
|
||||
type SessionTabUnread,
|
||||
} from "./session-tabs-model"
|
||||
|
||||
type PersistedState = {
|
||||
tabs: SessionTab[]
|
||||
unread: Record<string, SessionTabUnread>
|
||||
}
|
||||
|
||||
export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimpleContext({
|
||||
name: "SessionTabs",
|
||||
init: () => {
|
||||
const route = useRoute()
|
||||
const data = useData()
|
||||
const event = useEvent()
|
||||
const config = useConfig().data
|
||||
const filePath = path.join(useTuiPaths().state, "session-tabs.json")
|
||||
const enabled = () => config.tabs?.enabled ?? false
|
||||
const state: {
|
||||
pending: boolean
|
||||
saving: boolean
|
||||
snapshot: string
|
||||
value?: PersistedState
|
||||
} = { pending: false, saving: false, snapshot: "" }
|
||||
const [store, setStore] = createStore<PersistedState & { ready: boolean }>({
|
||||
ready: false,
|
||||
tabs: [],
|
||||
unread: {},
|
||||
})
|
||||
let history: SessionTabHistory = { entries: [], index: -1 }
|
||||
|
||||
const root = (sessionID: string) => data.session.root(sessionID)
|
||||
const current = () => (route.data.type === "session" ? root(route.data.sessionID) : undefined)
|
||||
const status = (sessionID: string) => {
|
||||
const session = root(sessionID)
|
||||
const members = data.session.family(session)
|
||||
const family = members.length > 0 ? members : [session]
|
||||
return {
|
||||
unread: store.unread[session],
|
||||
attention: family.some(
|
||||
(id) => (data.session.permission.list(id)?.length ?? 0) > 0 || (data.session.form.list(id)?.length ?? 0) > 0,
|
||||
),
|
||||
busy: family.some((id) => data.session.status(id) === "running" || data.session.pending.list(id).length > 0),
|
||||
}
|
||||
}
|
||||
|
||||
function save() {
|
||||
if (!store.ready) {
|
||||
state.pending = true
|
||||
return
|
||||
}
|
||||
const value = { tabs: [...store.tabs], unread: { ...store.unread } }
|
||||
const snapshot = JSON.stringify(value)
|
||||
if (snapshot === state.snapshot && !state.saving) return
|
||||
state.value = value
|
||||
state.pending = true
|
||||
flush()
|
||||
}
|
||||
|
||||
function flush() {
|
||||
if (state.saving || !state.pending || !state.value) return
|
||||
const value = state.value
|
||||
const snapshot = JSON.stringify(value)
|
||||
state.pending = false
|
||||
if (snapshot === state.snapshot) return
|
||||
state.saving = true
|
||||
void writeJsonAtomic(filePath, value)
|
||||
.then(() => {
|
||||
state.snapshot = snapshot
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(() => {
|
||||
state.saving = false
|
||||
flush()
|
||||
})
|
||||
}
|
||||
|
||||
function open(sessionID: string) {
|
||||
const session = root(sessionID)
|
||||
const next = openSessionTab(store.tabs, { sessionID: session, title: data.session.get(session)?.title })
|
||||
if (next === store.tabs) return { sessionID: session, changed: false }
|
||||
setStore("tabs", reconcile(next))
|
||||
return { sessionID: session, changed: true }
|
||||
}
|
||||
|
||||
function clearUnread(sessionID: string) {
|
||||
const session = root(sessionID)
|
||||
if (!store.unread[session]) return false
|
||||
setStore(
|
||||
"unread",
|
||||
produce((draft) => {
|
||||
delete draft[session]
|
||||
}),
|
||||
)
|
||||
return true
|
||||
}
|
||||
|
||||
function markUnread(sessionID: string, unread: SessionTabUnread) {
|
||||
if (!enabled()) return
|
||||
const session = root(sessionID)
|
||||
if (current() === session || !store.tabs.some((tab) => tab.sessionID === session)) return
|
||||
if (store.unread[session] === unread) return
|
||||
setStore("unread", session, unread)
|
||||
save()
|
||||
}
|
||||
|
||||
readJson<unknown>(filePath)
|
||||
.then((value) => {
|
||||
if (!isRecord(value)) return
|
||||
const persisted = value
|
||||
if (Array.isArray(persisted.tabs))
|
||||
setStore(
|
||||
"tabs",
|
||||
persisted.tabs.flatMap((tab) => {
|
||||
if (!isRecord(tab) || typeof tab.sessionID !== "string") return []
|
||||
if ("title" in tab && tab.title !== undefined && typeof tab.title !== "string") return []
|
||||
return [{ sessionID: tab.sessionID, title: typeof tab.title === "string" ? tab.title : undefined }]
|
||||
}),
|
||||
)
|
||||
if (persisted.unread && typeof persisted.unread === "object")
|
||||
setStore(
|
||||
"unread",
|
||||
Object.fromEntries(
|
||||
Object.entries(persisted.unread).filter(
|
||||
(entry): entry is [string, SessionTabUnread] => entry[1] === "activity" || entry[1] === "error",
|
||||
),
|
||||
),
|
||||
)
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(() => {
|
||||
setStore("ready", true)
|
||||
if (state.pending) save()
|
||||
else state.snapshot = JSON.stringify({ tabs: store.tabs, unread: store.unread })
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
if (!enabled()) return
|
||||
if (!store.ready || route.data.type !== "session" || route.data.sessionID === "dummy") return
|
||||
const routeSessionID = route.data.sessionID
|
||||
batch(() => {
|
||||
const opened = open(routeSessionID)
|
||||
history = recordSessionTabHistory(history, opened.sessionID)
|
||||
const changed = clearUnread(opened.sessionID)
|
||||
if (opened.changed || changed) untrack(save)
|
||||
})
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
if (!enabled() || !store.ready) return
|
||||
const next = store.tabs.reduce<SessionTab[]>((tabs, tab) => {
|
||||
const sessionID = root(tab.sessionID)
|
||||
return openSessionTab(tabs, { sessionID, title: data.session.get(sessionID)?.title ?? tab.title })
|
||||
}, [])
|
||||
const unread = Object.entries(store.unread).reduce<Record<string, SessionTabUnread>>((result, entry) => {
|
||||
const sessionID = root(entry[0])
|
||||
result[sessionID] = result[sessionID] === "error" ? "error" : entry[1]
|
||||
return result
|
||||
}, {})
|
||||
if (isDeepEqual(next, store.tabs) && isDeepEqual(unread, store.unread)) return
|
||||
batch(() => {
|
||||
setStore("tabs", reconcile(next))
|
||||
setStore("unread", reconcile(unread))
|
||||
})
|
||||
save()
|
||||
})
|
||||
|
||||
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.error", (evt) => {
|
||||
if (evt.data.sessionID) markUnread(evt.data.sessionID, "error")
|
||||
}),
|
||||
)
|
||||
onCleanup(
|
||||
event.on("session.deleted", (evt) => {
|
||||
remove(evt.data.sessionID, enabled())
|
||||
}),
|
||||
)
|
||||
|
||||
function remove(sessionID: string, navigate: boolean) {
|
||||
const target = root(sessionID)
|
||||
const closed = closeSessionTab(store.tabs, target)
|
||||
if (closed.tabs.length === store.tabs.length) return
|
||||
const selected = navigate && current() === target
|
||||
const previous = selected
|
||||
? moveSessionTabHistory(recordSessionTabHistory(history, target), closed.tabs, target, -1)
|
||||
: { history, sessionID: undefined }
|
||||
const next = previous.sessionID ?? closed.next
|
||||
history = previous.history
|
||||
batch(() => {
|
||||
setStore("tabs", reconcile(closed.tabs))
|
||||
clearUnread(target)
|
||||
if (selected) route.navigate(next ? { type: "session", sessionID: next } : { type: "home" })
|
||||
})
|
||||
save()
|
||||
}
|
||||
|
||||
return {
|
||||
enabled,
|
||||
tabs() {
|
||||
return store.tabs
|
||||
},
|
||||
current,
|
||||
status,
|
||||
select(sessionID: string) {
|
||||
if (!enabled()) return
|
||||
route.navigate({ type: "session", sessionID: root(sessionID) })
|
||||
},
|
||||
close(sessionID?: string) {
|
||||
if (!enabled()) return
|
||||
const target = sessionID ? root(sessionID) : current()
|
||||
if (!target) {
|
||||
const previous = store.tabs.at(-1)
|
||||
if (route.data.type === "home" && previous) route.navigate({ type: "session", sessionID: previous.sessionID })
|
||||
return
|
||||
}
|
||||
remove(target, true)
|
||||
},
|
||||
cycle(direction: 1 | -1) {
|
||||
if (!enabled()) return
|
||||
const tab = cycleSessionTab(store.tabs, current(), direction)
|
||||
if (tab) route.navigate({ type: "session", sessionID: tab.sessionID })
|
||||
},
|
||||
cycleUnread(direction: 1 | -1) {
|
||||
if (!enabled()) return
|
||||
const tab = cycleSessionTab(
|
||||
store.tabs.filter((tab) => store.unread[tab.sessionID] || status(tab.sessionID).attention),
|
||||
current(),
|
||||
direction,
|
||||
)
|
||||
if (tab) route.navigate({ type: "session", sessionID: tab.sessionID })
|
||||
},
|
||||
history(direction: 1 | -1) {
|
||||
if (!enabled()) return
|
||||
const next = moveSessionTabHistory(history, store.tabs, current(), direction)
|
||||
history = next.history
|
||||
if (next.sessionID) route.navigate({ type: "session", sessionID: next.sessionID })
|
||||
},
|
||||
selectIndex(index: number) {
|
||||
if (!enabled()) return
|
||||
const tab = store.tabs[index]
|
||||
if (tab) route.navigate({ type: "session", sessionID: tab.sessionID })
|
||||
},
|
||||
}
|
||||
},
|
||||
})
|
||||
@@ -1,6 +1,5 @@
|
||||
import { CliRenderEvents, SyntaxStyle, type TerminalColors } from "@opentui/core"
|
||||
import { useRenderer } from "@opentui/solid"
|
||||
import { generateSyntax, resolveThemeDocument, themeModes } from "@opencode-ai/theme/tui"
|
||||
import {
|
||||
DEFAULT_THEMES,
|
||||
addTheme,
|
||||
@@ -15,9 +14,12 @@ import {
|
||||
type Theme,
|
||||
type ThemeDocumentSource,
|
||||
} from "../theme"
|
||||
import { generateSyntax } from "../theme/v2/syntax"
|
||||
import { generateSystem, terminalMode } from "../theme/system"
|
||||
import { discoverThemes, themeDirectories } from "../theme/discovery"
|
||||
import { createComponentTheme, type ComponentTheme } from "../theme/component"
|
||||
import { createComponentTheme, type ComponentTheme } from "../theme/v2/component"
|
||||
import { resolveThemeDocument } from "../theme/v2/resolve"
|
||||
import { themeModes } from "../theme/v2/select"
|
||||
import { createEffect, createMemo, onCleanup, onMount, type Accessor, type ParentProps } from "solid-js"
|
||||
import { createStore, produce } from "solid-js/store"
|
||||
import { createSimpleContext } from "./helper"
|
||||
@@ -96,13 +98,13 @@ type State = {
|
||||
}
|
||||
|
||||
type ContextName = "elevated" | "overlay"
|
||||
type Themes = {
|
||||
current: ComponentTheme
|
||||
contextual(context: ContextName): ComponentTheme
|
||||
type ThemeService = {
|
||||
themeV2: ComponentTheme
|
||||
contextual(context: ContextName): ThemeService
|
||||
readonly selected: string
|
||||
all: typeof allThemes
|
||||
has: typeof hasTheme
|
||||
currentSyntax: Accessor<SyntaxStyle>
|
||||
syntax: Accessor<SyntaxStyle>
|
||||
mode: Accessor<"dark" | "light">
|
||||
modes: Accessor<readonly ("dark" | "light")[]>
|
||||
supports(mode: "dark" | "light"): boolean
|
||||
@@ -308,7 +310,7 @@ const themeContext = createSimpleContext({
|
||||
const valuesV2 = () => selected().theme
|
||||
valuesV2()
|
||||
themePerformance.set("Init", `${(performance.now() - initStarted).toFixed(2)} ms`)
|
||||
const current = createComponentTheme(valuesV2, mode)
|
||||
const themeV2 = createComponentTheme(valuesV2, mode)
|
||||
const contextsV2 = {
|
||||
elevated: createComponentTheme(() => valuesV2().contexts["@context:elevated"] ?? valuesV2(), mode),
|
||||
overlay: createComponentTheme(() => valuesV2().contexts["@context:overlay"] ?? valuesV2(), mode),
|
||||
@@ -316,19 +318,19 @@ const themeContext = createSimpleContext({
|
||||
|
||||
createEffect(() => renderer.setBackgroundColor(valuesV2().background.default))
|
||||
|
||||
const currentSyntax = createSyntaxStyleMemo(() => generateSyntax(valuesV2(), mode()))
|
||||
const syntax = createSyntaxStyleMemo(() => generateSyntax(valuesV2(), mode()))
|
||||
function contextual(context: ContextName) {
|
||||
return contextsV2[context]
|
||||
return contextualServices[context]
|
||||
}
|
||||
const service: Themes = {
|
||||
current,
|
||||
currentSyntax,
|
||||
const service: ThemeService = {
|
||||
themeV2,
|
||||
contextual,
|
||||
get selected() {
|
||||
return store.active
|
||||
},
|
||||
all: allThemes,
|
||||
has: hasTheme,
|
||||
syntax,
|
||||
mode,
|
||||
modes,
|
||||
supports: (requested) => modes().includes(requested),
|
||||
@@ -355,28 +357,21 @@ const themeContext = createSimpleContext({
|
||||
return store.ready
|
||||
},
|
||||
}
|
||||
return {
|
||||
current,
|
||||
themes: service,
|
||||
get ready() {
|
||||
return service.ready
|
||||
},
|
||||
const contextualServices = {
|
||||
elevated: Object.assign(Object.create(service) as ThemeService, { themeV2: contextsV2.elevated }),
|
||||
overlay: Object.assign(Object.create(service) as ThemeService, { themeV2: contextsV2.overlay }),
|
||||
}
|
||||
return service
|
||||
},
|
||||
})
|
||||
|
||||
export function useThemes() {
|
||||
return themeContext.use().themes
|
||||
}
|
||||
export function useTheme() {
|
||||
return themeContext.use().current
|
||||
}
|
||||
export const useTheme = themeContext.use
|
||||
export const ThemeProvider = themeContext.provider
|
||||
|
||||
export function ThemeContextProvider(props: ParentProps<{ context: ContextName }>) {
|
||||
const themes = useThemes()
|
||||
const theme = useTheme()
|
||||
return (
|
||||
<themeContext.context.Provider value={{ current: themes.contextual(props.context), themes, ready: themes.ready }}>
|
||||
<themeContext.context.Provider value={theme.contextual(props.context)}>
|
||||
{props.children}
|
||||
</themeContext.context.Provider>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import type { TuiPlugin, TuiPluginApi, TuiPluginModule } from "@opencode-ai/plugin/v1/tui"
|
||||
import type { PluginRuntime } from "../plugin/runtime"
|
||||
import PluginManager from "./system/plugins"
|
||||
import WhichKey from "./system/which-key"
|
||||
|
||||
export type BuiltinTuiPlugin = Omit<TuiPluginModule, "id"> & {
|
||||
id: string
|
||||
tui: TuiPlugin
|
||||
enabled?: boolean
|
||||
}
|
||||
|
||||
export function createBuiltinPlugins(): BuiltinTuiPlugin[] {
|
||||
return [PluginManager, WhichKey]
|
||||
}
|
||||
|
||||
export async function loadBuiltinPlugins(api: TuiPluginApi, runtime: PluginRuntime) {
|
||||
const slots = runtime.setupSlots(api)
|
||||
const dispose: Array<() => void> = []
|
||||
|
||||
for (const plugin of createBuiltinPlugins()) {
|
||||
if (plugin.enabled === false) continue
|
||||
const scoped = Object.assign(Object.create(api), {
|
||||
slots: {
|
||||
register(input: Parameters<typeof slots.register>[0]) {
|
||||
dispose.push(slots.register({ ...input, id: plugin.id }))
|
||||
return plugin.id
|
||||
},
|
||||
},
|
||||
}) as TuiPluginApi
|
||||
const now = Date.now()
|
||||
await plugin.tui(scoped, undefined, {
|
||||
id: plugin.id,
|
||||
source: "internal",
|
||||
spec: plugin.id,
|
||||
target: plugin.id,
|
||||
first_time: now,
|
||||
last_time: now,
|
||||
time_changed: now,
|
||||
load_count: 1,
|
||||
fingerprint: plugin.id,
|
||||
state: "first",
|
||||
})
|
||||
}
|
||||
|
||||
return () => {
|
||||
for (const fn of dispose.reverse()) fn()
|
||||
slots.dispose()
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,15 @@
|
||||
import { Plugin } from "@opencode-ai/plugin/tui"
|
||||
import { createMemo, Match, Show, Switch } from "solid-js"
|
||||
import { useTerminalDimensions } from "@opentui/solid"
|
||||
import { useTuiApp, useTuiPaths } from "../../context/runtime"
|
||||
import { abbreviateHome } from "../../runtime"
|
||||
import { FilePath } from "../../ui/file-path"
|
||||
import { stringWidth } from "../../util/string-width"
|
||||
|
||||
function Directory(props: { context: Plugin.Context; maxWidth: number }) {
|
||||
const paths = useTuiPaths()
|
||||
const directory = createMemo(() =>
|
||||
props.context.location ? props.context.ui.format.path(props.context.location.directory) : undefined,
|
||||
props.context.location ? abbreviateHome(props.context.location.directory, paths.home) : undefined,
|
||||
)
|
||||
|
||||
return (
|
||||
@@ -49,6 +52,7 @@ function Mcp(props: { context: Plugin.Context }) {
|
||||
}
|
||||
|
||||
function View(props: { context: Plugin.Context }) {
|
||||
const app = useTuiApp()
|
||||
const dimensions = useTerminalDimensions()
|
||||
const mcpWidth = createMemo(() => {
|
||||
const list = props.context.data.location.mcp.server.list(props.context.location) ?? []
|
||||
@@ -70,12 +74,12 @@ function View(props: { context: Plugin.Context }) {
|
||||
>
|
||||
<Directory
|
||||
context={props.context}
|
||||
maxWidth={Math.max(2, dimensions().width - 8 - stringWidth(props.context.app.version) - mcpWidth())}
|
||||
maxWidth={Math.max(2, dimensions().width - 8 - stringWidth(app.version) - mcpWidth())}
|
||||
/>
|
||||
<Mcp context={props.context} />
|
||||
<box flexGrow={1} />
|
||||
<box flexShrink={0}>
|
||||
<text fg={props.context.theme.text.subdued}>{props.context.app.version}</text>
|
||||
<text fg={props.context.theme.text.subdued}>{app.version}</text>
|
||||
</box>
|
||||
</box>
|
||||
)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Plugin } from "@opencode-ai/plugin/tui"
|
||||
import { createMemo, Show } from "solid-js"
|
||||
import { useTheme } from "../../context/theme"
|
||||
import { contextUsage } from "../../util/session"
|
||||
|
||||
const money = new Intl.NumberFormat("en-US", {
|
||||
@@ -8,7 +9,7 @@ const money = new Intl.NumberFormat("en-US", {
|
||||
})
|
||||
|
||||
function View(props: { context: Plugin.Context; sessionID: string }) {
|
||||
const theme = props.context.theme
|
||||
const { themeV2 } = useTheme()
|
||||
const msg = createMemo(() => props.context.data.session.message.list(props.sessionID))
|
||||
const session = createMemo(() => props.context.data.session.get(props.sessionID))
|
||||
const cost = createMemo(() => props.context.data.session.cost(props.sessionID))
|
||||
@@ -19,20 +20,20 @@ function View(props: { context: Plugin.Context; sessionID: string }) {
|
||||
|
||||
return (
|
||||
<box>
|
||||
<text fg={theme.text.default}>
|
||||
<text fg={themeV2.text.default}>
|
||||
<b>Context</b>
|
||||
</text>
|
||||
<Show when={state()} fallback={<text fg={theme.text.subdued}>Not measured</text>}>
|
||||
<Show when={state()} fallback={<text fg={themeV2.text.subdued}>Not measured</text>}>
|
||||
{(value) => (
|
||||
<>
|
||||
<text fg={theme.text.subdued}>{value().tokens.toLocaleString()} tokens</text>
|
||||
<text fg={themeV2.text.subdued}>{value().tokens.toLocaleString()} tokens</text>
|
||||
<Show when={value().percent !== undefined}>
|
||||
<text fg={theme.text.subdued}>{value().percent}% used</text>
|
||||
<text fg={themeV2.text.subdued}>{value().percent}% used</text>
|
||||
</Show>
|
||||
</>
|
||||
)}
|
||||
</Show>
|
||||
<text fg={theme.text.subdued}>{money.format(cost())} spent</text>
|
||||
<text fg={themeV2.text.subdued}>{money.format(cost())} spent</text>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,15 +1,18 @@
|
||||
import { Plugin } from "@opencode-ai/plugin/tui"
|
||||
import { createMemo, Show } from "solid-js"
|
||||
import { useTuiPaths } from "../../context/runtime"
|
||||
import { useTheme } from "../../context/theme"
|
||||
import { abbreviateHome } from "../../runtime"
|
||||
import { FilePath } from "../../ui/file-path"
|
||||
|
||||
function View(props: { context: Plugin.Context }) {
|
||||
const { themeV2 } = useTheme()
|
||||
const paths = useTuiPaths()
|
||||
const directory = createMemo(() =>
|
||||
props.context.location ? props.context.ui.format.path(props.context.location.directory) : undefined,
|
||||
props.context.location ? abbreviateHome(props.context.location.directory, paths.home) : undefined,
|
||||
)
|
||||
return (
|
||||
<Show when={directory()}>
|
||||
{(value) => <FilePath value={value()} maxWidth={38} fg={props.context.theme.text.subdued} />}
|
||||
</Show>
|
||||
<Show when={directory()}>{(value) => <FilePath value={value()} maxWidth={38} fg={themeV2.text.subdued} />}</Show>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import { Plugin, usePlugin } from "@opencode-ai/plugin/tui"
|
||||
import { Plugin } from "@opencode-ai/plugin/tui"
|
||||
import { useTheme } from "../../context/theme"
|
||||
|
||||
function View() {
|
||||
const context = usePlugin()
|
||||
const theme = context.theme
|
||||
const { themeV2 } = useTheme()
|
||||
return (
|
||||
<box>
|
||||
<text fg={theme.text.default}>
|
||||
<text fg={themeV2.text.default}>
|
||||
<b>LSP</b>
|
||||
</text>
|
||||
<text fg={theme.text.subdued}>LSP status unavailable</text>
|
||||
<text fg={themeV2.text.subdued}>LSP status unavailable</text>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { Plugin } from "@opencode-ai/plugin/tui"
|
||||
import { createMemo, For, Match, Show, Switch, createSignal } from "solid-js"
|
||||
import { useTheme } from "../../context/theme"
|
||||
|
||||
function View(props: { context: Plugin.Context; sessionID: string }) {
|
||||
const [open, setOpen] = createSignal(true)
|
||||
const theme = props.context.theme
|
||||
const { themeV2 } = useTheme()
|
||||
const session = createMemo(() => props.context.data.session.get(props.sessionID))
|
||||
const list = createMemo(() => props.context.data.location.mcp.server.list(session()?.location) ?? [])
|
||||
const on = createMemo(() => list().filter((item) => item.status.status === "connected").length)
|
||||
@@ -18,12 +19,12 @@ function View(props: { context: Plugin.Context; sessionID: string }) {
|
||||
)
|
||||
|
||||
const dot = (status: string) => {
|
||||
if (status === "connected") return theme.text.feedback.success.default
|
||||
if (status === "failed") return theme.text.feedback.error.default
|
||||
if (status === "disabled") return theme.text.subdued
|
||||
if (status === "needs_auth") return theme.text.feedback.warning.default
|
||||
if (status === "needs_client_registration") return theme.text.feedback.error.default
|
||||
return theme.text.subdued
|
||||
if (status === "connected") return themeV2.text.feedback.success.default
|
||||
if (status === "failed") return themeV2.text.feedback.error.default
|
||||
if (status === "disabled") return themeV2.text.subdued
|
||||
if (status === "needs_auth") return themeV2.text.feedback.warning.default
|
||||
if (status === "needs_client_registration") return themeV2.text.feedback.error.default
|
||||
return themeV2.text.subdued
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -31,12 +32,12 @@ function View(props: { context: Plugin.Context; sessionID: string }) {
|
||||
<box>
|
||||
<box flexDirection="row" gap={1} onMouseDown={() => list().length > 2 && setOpen((x) => !x)}>
|
||||
<Show when={list().length > 2}>
|
||||
<text fg={theme.text.default}>{open() ? "▼" : "▶"}</text>
|
||||
<text fg={themeV2.text.default}>{open() ? "▼" : "▶"}</text>
|
||||
</Show>
|
||||
<text fg={theme.text.default}>
|
||||
<text fg={themeV2.text.default}>
|
||||
<b>MCP</b>
|
||||
<Show when={!open()}>
|
||||
<span style={{ fg: theme.text.subdued }}>
|
||||
<span style={{ fg: themeV2.text.subdued }}>
|
||||
{" "}
|
||||
({on()} active{bad() > 0 ? `, ${bad()} error${bad() > 1 ? "s" : ""}` : ""})
|
||||
</span>
|
||||
@@ -55,9 +56,9 @@ function View(props: { context: Plugin.Context; sessionID: string }) {
|
||||
>
|
||||
•
|
||||
</text>
|
||||
<text fg={theme.text.default} wrapMode="word">
|
||||
<text fg={themeV2.text.default} wrapMode="word">
|
||||
{item.name}{" "}
|
||||
<span style={{ fg: theme.text.subdued }}>
|
||||
<span style={{ fg: themeV2.text.subdued }}>
|
||||
<Switch fallback={item.status.status}>
|
||||
<Match when={item.status.status === "connected"}>Connected</Match>
|
||||
<Match when={item.status.status === "failed"}>
|
||||
|
||||
@@ -1,16 +1,15 @@
|
||||
/** @jsxImportSource @opentui/solid */
|
||||
import type { ScrollBoxRenderable } from "@opentui/core"
|
||||
import type { Plugin } from "@opencode-ai/plugin/tui"
|
||||
import { Locale } from "../../util/locale"
|
||||
import { tint } from "../../theme/color"
|
||||
import { createEffect, createMemo, For, Match, Switch } from "solid-js"
|
||||
import { buildFileTree, flattenFileTree, type FileTreeItem, type FileTreeRow } from "./diff-viewer-file-tree-utils"
|
||||
import { Panel } from "./diff-viewer-ui"
|
||||
import { useTheme } from "../../context/theme"
|
||||
|
||||
const FILE_TREE_STATUS_WIDTH = 2
|
||||
|
||||
export type DiffViewerFileTreeProps = {
|
||||
readonly context: Plugin.Context
|
||||
readonly width: number
|
||||
readonly files: readonly FileTreeItem[]
|
||||
readonly loading: boolean
|
||||
@@ -24,7 +23,7 @@ export type DiffViewerFileTreeProps = {
|
||||
}
|
||||
|
||||
export function DiffViewerFileTree(props: DiffViewerFileTreeProps) {
|
||||
const theme = props.context.theme
|
||||
const { themeV2 } = useTheme()
|
||||
const tree = createMemo(() => buildFileTree(props.files))
|
||||
const rows = createMemo(() => flattenFileTree(tree(), props.expandedNodes))
|
||||
let scroll: ScrollBoxRenderable | undefined
|
||||
@@ -39,10 +38,10 @@ export function DiffViewerFileTree(props: DiffViewerFileTreeProps) {
|
||||
requestAnimationFrame(scrollSelectedIntoView)
|
||||
})
|
||||
|
||||
const fadedColor = () => tint(theme.text.default, theme.background.default, 0.75)
|
||||
const fadedColor = () => tint(themeV2.text.default, themeV2.background.default, 0.75)
|
||||
|
||||
return (
|
||||
<Panel border="both" width={props.width} context={props.context}>
|
||||
<Panel border="both" width={props.width}>
|
||||
<scrollbox
|
||||
ref={(element: ScrollBoxRenderable) => (scroll = element)}
|
||||
verticalScrollbarOptions={{ visible: false }}
|
||||
@@ -53,7 +52,7 @@ export function DiffViewerFileTree(props: DiffViewerFileTreeProps) {
|
||||
<text />
|
||||
</Match>
|
||||
<Match when={props.files.length === 0}>
|
||||
<text fg={theme.text.default}>No files</text>
|
||||
<text fg={themeV2.text.default}>No files</text>
|
||||
</Match>
|
||||
<Match when={props.files.length > 0}>
|
||||
<For each={rows()}>
|
||||
@@ -72,11 +71,11 @@ export function DiffViewerFileTree(props: DiffViewerFileTreeProps) {
|
||||
<box
|
||||
flexDirection="row"
|
||||
width="100%"
|
||||
backgroundColor={highlighted() ? theme.background.action.primary.focused : undefined}
|
||||
backgroundColor={highlighted() ? themeV2.background.action.primary.focused : undefined}
|
||||
onMouseUp={() => props.onRowClick?.(row)}
|
||||
>
|
||||
<text
|
||||
fg={highlighted() ? theme.text.action.primary.focused : fadedColor()}
|
||||
fg={highlighted() ? themeV2.text.action.primary.focused : fadedColor()}
|
||||
wrapMode="none"
|
||||
flexShrink={0}
|
||||
>
|
||||
@@ -86,12 +85,12 @@ export function DiffViewerFileTree(props: DiffViewerFileTreeProps) {
|
||||
<text
|
||||
fg={
|
||||
highlighted()
|
||||
? theme.text.action.primary.focused
|
||||
? themeV2.text.action.primary.focused
|
||||
: selected()
|
||||
? theme.text.formfield.selected
|
||||
? themeV2.text.formfield.selected
|
||||
: reviewed() || row.kind === "directory"
|
||||
? theme.text.subdued
|
||||
: theme.text.default
|
||||
? themeV2.text.subdued
|
||||
: themeV2.text.default
|
||||
}
|
||||
wrapMode="none"
|
||||
>
|
||||
@@ -99,7 +98,7 @@ export function DiffViewerFileTree(props: DiffViewerFileTreeProps) {
|
||||
</text>
|
||||
</box>
|
||||
<text
|
||||
fg={highlighted() ? theme.text.action.primary.focused : theme.text.subdued}
|
||||
fg={highlighted() ? themeV2.text.action.primary.focused : themeV2.text.subdued}
|
||||
wrapMode="none"
|
||||
flexShrink={0}
|
||||
>
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import type { BorderSides, ColorInput } from "@opentui/core"
|
||||
import type { Plugin } from "@opencode-ai/plugin/tui"
|
||||
import type { JSX } from "@opentui/solid"
|
||||
import { useTheme } from "../../context/theme"
|
||||
import { createContext, Show, splitProps, useContext } from "solid-js"
|
||||
|
||||
export type Axis = "x" | "y"
|
||||
export type SeparatorEdge = "edge" | "edge-in" | "edge-out"
|
||||
export type PanelBorder = "start" | "end" | "both" | "none"
|
||||
|
||||
const PanelGroupContext = createContext<{ axis: Axis; context: Plugin.Context }>()
|
||||
const PanelGroupContext = createContext<{ axis: Axis }>()
|
||||
|
||||
function crossAxis(axis: Axis) {
|
||||
return axis === "x" ? "y" : "x"
|
||||
@@ -17,10 +17,10 @@ function usePanelGroup() {
|
||||
return useContext(PanelGroupContext)
|
||||
}
|
||||
|
||||
export function PanelGroup(props: JSX.IntrinsicElements["box"] & { axis: Axis; context: Plugin.Context }) {
|
||||
const [local, boxProps] = splitProps(props, ["axis", "context", "children"])
|
||||
export function PanelGroup(props: JSX.IntrinsicElements["box"] & { axis: Axis }) {
|
||||
const [local, boxProps] = splitProps(props, ["axis", "children"])
|
||||
return (
|
||||
<PanelGroupContext.Provider value={{ axis: local.axis, context: local.context }}>
|
||||
<PanelGroupContext.Provider value={{ axis: local.axis }}>
|
||||
<box minWidth={0} minHeight={0} padding={0} flexDirection={local.axis === "x" ? "row" : "column"} {...boxProps}>
|
||||
{local.children}
|
||||
</box>
|
||||
@@ -28,28 +28,24 @@ export function PanelGroup(props: JSX.IntrinsicElements["box"] & { axis: Axis; c
|
||||
)
|
||||
}
|
||||
|
||||
export function Panel(
|
||||
props: Omit<JSX.IntrinsicElements["box"], "border"> & { border?: PanelBorder; context?: Plugin.Context },
|
||||
) {
|
||||
export function Panel(props: Omit<JSX.IntrinsicElements["box"], "border"> & { border?: PanelBorder }) {
|
||||
const group = usePanelGroup()
|
||||
const [local, boxProps] = splitProps(props, ["border", "context"])
|
||||
const context = local.context ?? group?.context
|
||||
if (!context) throw new Error("Panel context is missing")
|
||||
const theme = context.theme
|
||||
const { themeV2 } = useTheme()
|
||||
const [local, boxProps] = splitProps(props, ["border"])
|
||||
const border = local.border ?? "start"
|
||||
const borderProps =
|
||||
border === "none"
|
||||
? {}
|
||||
: {
|
||||
border: panelBorderSides(group?.axis ?? "y", border),
|
||||
borderColor: theme.border.default,
|
||||
borderColor: themeV2.border.default,
|
||||
}
|
||||
|
||||
return (
|
||||
<box
|
||||
minWidth={0}
|
||||
minHeight={0}
|
||||
flexDirection={crossAxis(group?.axis ?? "y") === "x" ? "row" : "column"}
|
||||
flexDirection={crossAxis(group?.axis || "y") === "x" ? "row" : "column"}
|
||||
{...borderProps}
|
||||
{...boxProps}
|
||||
/>
|
||||
@@ -63,10 +59,9 @@ function panelBorderSides(axis: Axis, border: Exclude<PanelBorder, "none">): Bor
|
||||
|
||||
export function Separator(props: { axis?: Axis; color?: ColorInput; start?: SeparatorEdge; end?: SeparatorEdge }) {
|
||||
const group = usePanelGroup()
|
||||
if (!group) throw new Error("PanelGroup is missing")
|
||||
const theme = group.context.theme
|
||||
const color = () => props.color ?? theme.border.default
|
||||
const axis = () => props.axis ?? crossAxis(group.axis)
|
||||
const { themeV2 } = useTheme()
|
||||
const color = () => props.color ?? themeV2.border.default
|
||||
const axis = () => props.axis ?? crossAxis(group?.axis ?? "y")
|
||||
if (axis() === "y") {
|
||||
return (
|
||||
<Show
|
||||
|
||||
@@ -10,11 +10,13 @@ import {
|
||||
type ScrollBoxRenderable,
|
||||
} from "@opentui/core"
|
||||
import { LANGUAGE_EXTENSIONS } from "../../util/filetype"
|
||||
import { useTheme } from "../../context/theme"
|
||||
import { useTerminalDimensions } from "@opentui/solid"
|
||||
import path from "path"
|
||||
import { createEffect, createMemo, createResource, createSignal, For, Match, onCleanup, Show, Switch } from "solid-js"
|
||||
import { DiffViewerFileTree } from "./diff-viewer-file-tree"
|
||||
import { Panel, PanelGroup, Separator } from "./diff-viewer-ui"
|
||||
import { useDialog } from "../../ui/dialog"
|
||||
import { DialogSelect } from "../../ui/dialog-select"
|
||||
import { getScrollAcceleration } from "../../util/scroll"
|
||||
import { useConfig } from "../../config"
|
||||
@@ -82,7 +84,8 @@ function DiffViewer(props: { context: Plugin.Context }) {
|
||||
const dimensions = useTerminalDimensions()
|
||||
const config = useConfig()
|
||||
const dialog = props.context.ui.dialog
|
||||
const theme = props.context.theme
|
||||
const themeState = useTheme()
|
||||
const themeV2 = themeState.themeV2
|
||||
const params = () => {
|
||||
const route = props.context.ui.router.current()
|
||||
return (route.type === "plugin" ? route.data : undefined) as
|
||||
@@ -736,13 +739,13 @@ function DiffViewer(props: { context: Plugin.Context }) {
|
||||
|
||||
return (
|
||||
<box position="absolute" zIndex={2500} left={0} top={0} width={dimensions().width} height={dimensions().height}>
|
||||
<PanelGroup axis="y" context={props.context} width="100%" height="100%">
|
||||
<PanelGroup axis="y" width="100%" height="100%">
|
||||
<Panel border="none" flexShrink={0} padding={0} paddingLeft={1}>
|
||||
<text fg={theme.text.default}>Diff </text>
|
||||
<text fg={theme.text.subdued}>{diffSourceLabel(mode())}</text>
|
||||
<text fg={themeV2.text.default}>Diff </text>
|
||||
<text fg={themeV2.text.subdued}>{diffSourceLabel(mode())}</text>
|
||||
<box flexGrow={1} />
|
||||
<Show when={!diff.loading && !diff.error}>
|
||||
<text fg={theme.text.subdued}>
|
||||
<text fg={themeV2.text.subdued}>
|
||||
{files().length} {files().length === 1 ? "file" : "files"}
|
||||
</text>
|
||||
</Show>
|
||||
@@ -753,13 +756,13 @@ function DiffViewer(props: { context: Plugin.Context }) {
|
||||
<Match when={diff.loading}>
|
||||
<Separator axis="x" />
|
||||
<box flexGrow={1} paddingLeft={1}>
|
||||
<text fg={theme.text.subdued}>Loading diff…</text>
|
||||
<text fg={themeV2.text.subdued}>Loading diff…</text>
|
||||
</box>
|
||||
</Match>
|
||||
<Match when={!diff.loading && diff.error}>
|
||||
<Separator axis="x" />
|
||||
<box flexGrow={1} paddingLeft={1}>
|
||||
<text fg={theme.text.feedback.error.default}>
|
||||
<text fg={themeV2.text.feedback.error.default}>
|
||||
Could not load diff. Reopen the diff viewer to try again.
|
||||
</text>
|
||||
</box>
|
||||
@@ -767,14 +770,13 @@ function DiffViewer(props: { context: Plugin.Context }) {
|
||||
<Match when={!diff.loading && files().length === 0}>
|
||||
<Separator axis="x" />
|
||||
<box flexGrow={1} paddingLeft={1}>
|
||||
<text fg={theme.text.subdued}>No changes to show</text>
|
||||
<text fg={themeV2.text.subdued}>No changes to show</text>
|
||||
</box>
|
||||
</Match>
|
||||
<Match when={!diff.loading}>
|
||||
<PanelGroup axis="x" context={props.context}>
|
||||
<PanelGroup axis="x">
|
||||
<Show when={showFileTree()}>
|
||||
<DiffViewerFileTree
|
||||
context={props.context}
|
||||
files={files()}
|
||||
loading={diff.loading}
|
||||
error={diff.error}
|
||||
@@ -811,52 +813,56 @@ function DiffViewer(props: { context: Plugin.Context }) {
|
||||
paddingLeft={1}
|
||||
paddingRight={1}
|
||||
border={patchLeftBorder()}
|
||||
borderColor={theme.border.default}
|
||||
borderColor={themeV2.border.default}
|
||||
>
|
||||
<text fg={reviewed() ? theme.text.subdued : theme.text.default}>{entry.file.file}</text>
|
||||
<text fg={reviewed() ? themeV2.text.subdued : themeV2.text.default}>
|
||||
{entry.file.file}
|
||||
</text>
|
||||
<box flexGrow={1} />
|
||||
<text fg={reviewed() ? theme.text.subdued : theme.diff.text.added}>
|
||||
<text fg={reviewed() ? themeV2.text.subdued : themeV2.diff.text.added}>
|
||||
+{entry.file.additions}
|
||||
</text>
|
||||
<text fg={reviewed() ? theme.text.subdued : theme.diff.text.removed}>
|
||||
<text fg={reviewed() ? themeV2.text.subdued : themeV2.diff.text.removed}>
|
||||
-{entry.file.deletions}
|
||||
</text>
|
||||
</box>
|
||||
<Separator axis="x" start={showFileTree() ? "edge" : undefined} />
|
||||
<Show
|
||||
when={entry.file.patch}
|
||||
fallback={<text fg={theme.text.subdued}>No patch available for this file.</text>}
|
||||
fallback={<text fg={themeV2.text.subdued}>No patch available for this file.</text>}
|
||||
>
|
||||
{(patch) => (
|
||||
<box border={patchLeftBorder()} borderColor={theme.border.default}>
|
||||
<box border={patchLeftBorder()} borderColor={themeV2.border.default}>
|
||||
<diff
|
||||
ref={(element: DiffRenderable) => diffNodeByFileIndex.set(entry.fileIndex, element)}
|
||||
diff={patch()}
|
||||
view={view()}
|
||||
filetype={reviewed() ? PLAIN_TEXT_FILETYPE : filetype(entry.file.file)}
|
||||
syntaxStyle={theme.syntaxStyle()}
|
||||
syntaxStyle={themeState.syntax()}
|
||||
showLineNumbers={true}
|
||||
width="100%"
|
||||
wrapMode="char"
|
||||
fg={reviewed() ? theme.text.subdued : theme.text.default}
|
||||
fg={reviewed() ? themeV2.text.subdued : themeV2.text.default}
|
||||
addedBg={
|
||||
reviewed() ? theme.background.surface.overlay : theme.diff.background.added
|
||||
reviewed() ? themeV2.background.surface.overlay : themeV2.diff.background.added
|
||||
}
|
||||
removedBg={
|
||||
reviewed() ? theme.background.surface.overlay : theme.diff.background.removed
|
||||
reviewed() ? themeV2.background.surface.overlay : themeV2.diff.background.removed
|
||||
}
|
||||
addedSignColor={reviewed() ? theme.text.subdued : theme.diff.highlight.added}
|
||||
removedSignColor={reviewed() ? theme.text.subdued : theme.diff.highlight.removed}
|
||||
lineNumberFg={theme.diff.lineNumber.text}
|
||||
addedSignColor={reviewed() ? themeV2.text.subdued : themeV2.diff.highlight.added}
|
||||
removedSignColor={
|
||||
reviewed() ? themeV2.text.subdued : themeV2.diff.highlight.removed
|
||||
}
|
||||
lineNumberFg={themeV2.diff.lineNumber.text}
|
||||
addedLineNumberBg={
|
||||
reviewed()
|
||||
? theme.background.surface.overlay
|
||||
: theme.diff.lineNumber.background.added
|
||||
? themeV2.background.surface.overlay
|
||||
: themeV2.diff.lineNumber.background.added
|
||||
}
|
||||
removedLineNumberBg={
|
||||
reviewed()
|
||||
? theme.background.surface.overlay
|
||||
: theme.diff.lineNumber.background.removed
|
||||
? themeV2.background.surface.overlay
|
||||
: themeV2.diff.lineNumber.background.removed
|
||||
}
|
||||
/>
|
||||
</box>
|
||||
@@ -867,7 +873,11 @@ function DiffViewer(props: { context: Plugin.Context }) {
|
||||
}}
|
||||
</For>
|
||||
<Show when={patchFillerHeight() > 0}>
|
||||
<box height={patchFillerHeight()} border={patchLeftBorder()} borderColor={theme.border.default} />
|
||||
<box
|
||||
height={patchFillerHeight()}
|
||||
border={patchLeftBorder()}
|
||||
borderColor={themeV2.border.default}
|
||||
/>
|
||||
</Show>
|
||||
</scrollbox>
|
||||
<Separator axis="x" start={showFileTree() ? "edge-in" : undefined} />
|
||||
@@ -880,57 +890,57 @@ function DiffViewer(props: { context: Plugin.Context }) {
|
||||
<Panel flexShrink={0} gap={2} paddingLeft={1} border="none">
|
||||
<Show when={switchFocusShortcut()}>
|
||||
{(shortcut) => (
|
||||
<text fg={theme.text.default}>
|
||||
{shortcut()} <span style={{ fg: theme.text.subdued }}>focus file tree</span>
|
||||
<text fg={themeV2.text.default}>
|
||||
{shortcut()} <span style={{ fg: themeV2.text.subdued }}>focus file tree</span>
|
||||
</text>
|
||||
)}
|
||||
</Show>
|
||||
<Show when={nextFileShortcut()}>
|
||||
{(shortcut) => (
|
||||
<text fg={theme.text.default}>
|
||||
{shortcut()} <span style={{ fg: theme.text.subdued }}>next file</span>
|
||||
<text fg={themeV2.text.default}>
|
||||
{shortcut()} <span style={{ fg: themeV2.text.subdued }}>next file</span>
|
||||
</text>
|
||||
)}
|
||||
</Show>
|
||||
<Show when={nextHunkShortcut()}>
|
||||
{(shortcut) => (
|
||||
<text fg={theme.text.default}>
|
||||
{shortcut()} <span style={{ fg: theme.text.subdued }}>next hunk</span>
|
||||
<text fg={themeV2.text.default}>
|
||||
{shortcut()} <span style={{ fg: themeV2.text.subdued }}>next hunk</span>
|
||||
</text>
|
||||
)}
|
||||
</Show>
|
||||
<Show when={previousHunkShortcut()}>
|
||||
{(shortcut) => (
|
||||
<text fg={theme.text.default}>
|
||||
{shortcut()} <span style={{ fg: theme.text.subdued }}>previous hunk</span>
|
||||
<text fg={themeV2.text.default}>
|
||||
{shortcut()} <span style={{ fg: themeV2.text.subdued }}>previous hunk</span>
|
||||
</text>
|
||||
)}
|
||||
</Show>
|
||||
<Show when={previousFileShortcut()}>
|
||||
{(shortcut) => (
|
||||
<text fg={theme.text.default}>
|
||||
{shortcut()} <span style={{ fg: theme.text.subdued }}>previous file</span>
|
||||
<text fg={themeV2.text.default}>
|
||||
{shortcut()} <span style={{ fg: themeV2.text.subdued }}>previous file</span>
|
||||
</text>
|
||||
)}
|
||||
</Show>
|
||||
<Show when={switchSourceShortcut()}>
|
||||
{(shortcut) => (
|
||||
<text fg={theme.text.default}>
|
||||
{shortcut()} <span style={{ fg: theme.text.subdued }}>switch source</span>
|
||||
<text fg={themeV2.text.default}>
|
||||
{shortcut()} <span style={{ fg: themeV2.text.subdued }}>switch source</span>
|
||||
</text>
|
||||
)}
|
||||
</Show>
|
||||
<Show when={markReviewedShortcut()}>
|
||||
{(shortcut) => (
|
||||
<text fg={theme.text.default}>
|
||||
{shortcut()} <span style={{ fg: theme.text.subdued }}>mark reviewed</span>
|
||||
<text fg={themeV2.text.default}>
|
||||
{shortcut()} <span style={{ fg: themeV2.text.subdued }}>mark reviewed</span>
|
||||
</text>
|
||||
)}
|
||||
</Show>
|
||||
<Show when={helpShortcut()}>
|
||||
{(shortcut) => (
|
||||
<text fg={theme.text.default}>
|
||||
{shortcut()} <span style={{ fg: theme.text.subdued }}>all</span>
|
||||
<text fg={themeV2.text.default}>
|
||||
{shortcut()} <span style={{ fg: themeV2.text.subdued }}>all</span>
|
||||
</text>
|
||||
)}
|
||||
</Show>
|
||||
@@ -941,7 +951,7 @@ function DiffViewer(props: { context: Plugin.Context }) {
|
||||
}
|
||||
|
||||
function DiffViewerHelpDialog(props: { context: Plugin.Context }) {
|
||||
const theme = props.context.theme.contextual("elevated")
|
||||
const { themeV2 } = useTheme().contextual("elevated")
|
||||
const shortcut = (id: string) => () => props.context.keymap.shortcuts(id)[0]
|
||||
const rows = [
|
||||
{
|
||||
@@ -1009,30 +1019,30 @@ function DiffViewerHelpDialog(props: { context: Plugin.Context }) {
|
||||
return (
|
||||
<box paddingLeft={2} paddingRight={2} paddingBottom={1} gap={1}>
|
||||
<box flexDirection="row" justifyContent="space-between">
|
||||
<text attributes={TextAttributes.BOLD} fg={theme.text.default}>
|
||||
<text attributes={TextAttributes.BOLD} fg={themeV2.text.default}>
|
||||
Diff shortcuts
|
||||
</text>
|
||||
<text fg={theme.text.subdued}>esc</text>
|
||||
<text fg={themeV2.text.subdued}>esc</text>
|
||||
</box>
|
||||
<box flexDirection="row">
|
||||
<text fg={theme.text.subdued} width={5} wrapMode="none">
|
||||
<text fg={themeV2.text.subdued} width={5} wrapMode="none">
|
||||
Key
|
||||
</text>
|
||||
<text fg={theme.text.subdued} width={22} wrapMode="none">
|
||||
<text fg={themeV2.text.subdued} width={22} wrapMode="none">
|
||||
Action
|
||||
</text>
|
||||
<text fg={theme.text.subdued}>Description</text>
|
||||
<text fg={themeV2.text.subdued}>Description</text>
|
||||
</box>
|
||||
<For each={rows}>
|
||||
{(row) => (
|
||||
<box flexDirection="row">
|
||||
<text fg={theme.text.default} width={5} wrapMode="none">
|
||||
<text fg={themeV2.text.default} width={5} wrapMode="none">
|
||||
{row.shortcut() || "-"}
|
||||
</text>
|
||||
<text fg={theme.text.default} width={22} wrapMode="none">
|
||||
<text fg={themeV2.text.default} width={22} wrapMode="none">
|
||||
{row.action}
|
||||
</text>
|
||||
<text fg={theme.text.subdued}>{row.description}</text>
|
||||
<text fg={themeV2.text.subdued}>{row.description}</text>
|
||||
</box>
|
||||
)}
|
||||
</For>
|
||||
@@ -1041,6 +1051,7 @@ function DiffViewerHelpDialog(props: { context: Plugin.Context }) {
|
||||
}
|
||||
|
||||
function Commands(props: { context: Plugin.Context }) {
|
||||
const dialog = useDialog()
|
||||
props.context.keymap.layer(() => ({
|
||||
mode: "global",
|
||||
commands: [
|
||||
@@ -1072,7 +1083,7 @@ function Commands(props: { context: Plugin.Context }) {
|
||||
returnRoute,
|
||||
},
|
||||
})
|
||||
props.context.ui.dialog.clear()
|
||||
dialog.clear()
|
||||
},
|
||||
},
|
||||
],
|
||||
|
||||
@@ -1,90 +1,280 @@
|
||||
import { Plugin } from "@opencode-ai/plugin/tui"
|
||||
import { createMemo, createSignal } from "solid-js"
|
||||
import { usePlugin } from "../../plugin/context"
|
||||
import type { TuiPlugin, TuiPluginApi, TuiPluginStatus } from "@opencode-ai/plugin/v1/tui"
|
||||
import type { BuiltinTuiPlugin } from "../builtins"
|
||||
import { useTerminalDimensions } from "@opentui/solid"
|
||||
import { fileURLToPath } from "url"
|
||||
import { DialogSelect, type DialogSelectOption } from "../../ui/dialog-select"
|
||||
import { Show, createEffect, createMemo, createSignal } from "solid-js"
|
||||
import { Keymap } from "../../context/keymap"
|
||||
|
||||
const id = "opencode.plugins"
|
||||
const id = "internal:plugin-manager"
|
||||
|
||||
function View(props: { context: Plugin.Context; plugins: ReturnType<typeof usePlugin> }) {
|
||||
const [locked, setLocked] = createSignal(false)
|
||||
const options = createMemo(() =>
|
||||
props.plugins
|
||||
.registered()
|
||||
.filter((plugin) => plugin.id !== id)
|
||||
.sort((a, b) => a.id.localeCompare(b.id))
|
||||
.map(
|
||||
(plugin): DialogSelectOption<string> => ({
|
||||
title: plugin.id,
|
||||
value: plugin.id,
|
||||
category: plugin.source === "builtin" ? "Built-in" : "External",
|
||||
footer: (
|
||||
<span
|
||||
style={{
|
||||
fg: plugin.active
|
||||
? props.context.theme.text.feedback.success.default
|
||||
: props.context.theme.text.subdued,
|
||||
}}
|
||||
>
|
||||
{plugin.active ? "active" : "inactive"}
|
||||
</span>
|
||||
),
|
||||
}),
|
||||
),
|
||||
function state(api: TuiPluginApi, item: TuiPluginStatus) {
|
||||
if (!item.enabled) {
|
||||
return <span style={{ fg: api.theme.current.textMuted }}>disabled</span>
|
||||
}
|
||||
|
||||
return (
|
||||
<span style={{ fg: item.active ? api.theme.current.success : api.theme.current.error }}>
|
||||
{item.active ? "active" : "inactive"}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function source(spec: string) {
|
||||
if (!spec.startsWith("file://")) return
|
||||
return fileURLToPath(spec)
|
||||
}
|
||||
|
||||
function meta(item: TuiPluginStatus, width: number) {
|
||||
if (item.source === "internal") {
|
||||
if (width >= 120) return "Built-in plugin"
|
||||
return "Built-in"
|
||||
}
|
||||
const next = source(item.spec)
|
||||
if (next) return next
|
||||
return item.spec
|
||||
}
|
||||
|
||||
function Install(props: { api: TuiPluginApi }) {
|
||||
const [global, setGlobal] = createSignal(false)
|
||||
const [busy, setBusy] = createSignal(false)
|
||||
|
||||
Keymap.createLayer(() => ({
|
||||
mode: "modal",
|
||||
enabled: !busy(),
|
||||
commands: [
|
||||
{
|
||||
bind: "tab",
|
||||
title: "Toggle install scope",
|
||||
group: "Plugins",
|
||||
run: () => {
|
||||
setGlobal((value) => !value)
|
||||
},
|
||||
},
|
||||
],
|
||||
}))
|
||||
|
||||
return (
|
||||
<props.api.ui.DialogPrompt
|
||||
title="Install plugin"
|
||||
placeholder="npm package name"
|
||||
busy={busy()}
|
||||
busyText="Installing plugin..."
|
||||
description={() => (
|
||||
<box flexDirection="row" gap={1}>
|
||||
<text fg={props.api.theme.current.textMuted}>scope:</text>
|
||||
<text fg={busy() ? props.api.theme.current.textMuted : props.api.theme.current.text}>
|
||||
{global() ? "global" : "local"}
|
||||
</text>
|
||||
<Show when={!busy()}>
|
||||
<text fg={props.api.theme.current.textMuted}>(tab toggle)</text>
|
||||
</Show>
|
||||
</box>
|
||||
)}
|
||||
onConfirm={(raw) => {
|
||||
if (busy()) return
|
||||
const mod = raw.trim()
|
||||
if (!mod) {
|
||||
props.api.ui.toast({
|
||||
variant: "error",
|
||||
message: "Plugin package name is required",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
setBusy(true)
|
||||
void props.api.plugins
|
||||
.install(mod, { global: global() })
|
||||
.then((out) => {
|
||||
if (!out.ok) {
|
||||
props.api.ui.toast({
|
||||
variant: "error",
|
||||
message: out.message,
|
||||
})
|
||||
if (out.missing) {
|
||||
props.api.ui.toast({
|
||||
variant: "info",
|
||||
message: "Check npm registry/auth settings and try again.",
|
||||
})
|
||||
}
|
||||
show(props.api)
|
||||
return
|
||||
}
|
||||
|
||||
props.api.ui.toast({
|
||||
variant: "success",
|
||||
message: `Installed ${mod} (${global() ? "global" : "local"}: ${out.dir})`,
|
||||
})
|
||||
if (!out.tui) {
|
||||
props.api.ui.toast({
|
||||
variant: "info",
|
||||
message: "Package has no TUI target to load in this app.",
|
||||
})
|
||||
show(props.api)
|
||||
return
|
||||
}
|
||||
|
||||
return props.api.plugins.add(mod).then((ok) => {
|
||||
if (!ok) {
|
||||
props.api.ui.toast({
|
||||
variant: "warning",
|
||||
message: "Installed plugin, but runtime load failed. See console/logs; restart TUI to retry.",
|
||||
})
|
||||
show(props.api)
|
||||
return
|
||||
}
|
||||
|
||||
props.api.ui.toast({
|
||||
variant: "success",
|
||||
message: `Loaded ${mod} in current session.`,
|
||||
})
|
||||
show(props.api)
|
||||
})
|
||||
})
|
||||
.finally(() => {
|
||||
setBusy(false)
|
||||
})
|
||||
}}
|
||||
onCancel={() => {
|
||||
show(props.api)
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function row(api: TuiPluginApi, item: TuiPluginStatus, width: number): DialogSelectOption<string> {
|
||||
return {
|
||||
title: item.id,
|
||||
value: item.id,
|
||||
category: item.source === "internal" ? "Internal" : "External",
|
||||
description: meta(item, width),
|
||||
footer: state(api, item),
|
||||
disabled: item.id === id,
|
||||
}
|
||||
}
|
||||
|
||||
function showInstall(api: TuiPluginApi) {
|
||||
api.ui.dialog.replace(() => <Install api={api} />)
|
||||
}
|
||||
|
||||
function View(props: { api: TuiPluginApi }) {
|
||||
const size = useTerminalDimensions()
|
||||
const [list, setList] = createSignal(props.api.plugins.list())
|
||||
const [cur, setCur] = createSignal<string | undefined>()
|
||||
const [lock, setLock] = createSignal(false)
|
||||
|
||||
createEffect(() => {
|
||||
const width = size().width
|
||||
if (width >= 128) {
|
||||
props.api.ui.dialog.setSize("xlarge")
|
||||
return
|
||||
}
|
||||
if (width >= 96) {
|
||||
props.api.ui.dialog.setSize("large")
|
||||
return
|
||||
}
|
||||
props.api.ui.dialog.setSize("medium")
|
||||
})
|
||||
|
||||
const rows = createMemo(() =>
|
||||
[...list()]
|
||||
.sort((a, b) => {
|
||||
const x = a.source === "internal" ? 1 : 0
|
||||
const y = b.source === "internal" ? 1 : 0
|
||||
if (x !== y) return x - y
|
||||
return a.id.localeCompare(b.id)
|
||||
})
|
||||
.map((item) => row(props.api, item, size().width)),
|
||||
)
|
||||
|
||||
const toggle = (plugin: DialogSelectOption<string>) => {
|
||||
if (locked()) return
|
||||
const current = props.plugins.registered().find((item) => item.id === plugin.value)
|
||||
if (!current) return
|
||||
setLocked(true)
|
||||
void (current.active ? props.plugins.deactivate(current.id) : props.plugins.activate(current.id))
|
||||
const flip = (x: string) => {
|
||||
if (lock()) return
|
||||
const item = list().find((entry) => entry.id === x)
|
||||
if (!item) return
|
||||
setLock(true)
|
||||
const task = item.active ? props.api.plugins.deactivate(x) : props.api.plugins.activate(x)
|
||||
void task
|
||||
.then((ok) => {
|
||||
if (ok) return
|
||||
props.context.ui.toast.show({ variant: "error", message: `Failed to update plugin ${current.id}` })
|
||||
if (!ok) {
|
||||
props.api.ui.toast({
|
||||
variant: "error",
|
||||
message: `Failed to update plugin ${item.id}`,
|
||||
})
|
||||
}
|
||||
setList(props.api.plugins.list())
|
||||
})
|
||||
.catch((error) => {
|
||||
props.context.ui.toast.show({
|
||||
variant: "error",
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
})
|
||||
.finally(() => {
|
||||
setLock(false)
|
||||
})
|
||||
.finally(() => setLocked(false))
|
||||
}
|
||||
|
||||
return (
|
||||
<DialogSelect
|
||||
title="Plugins"
|
||||
options={options()}
|
||||
locked={locked()}
|
||||
preserveSelection={true}
|
||||
actions={[{ title: "toggle", command: "plugins.toggle", onTrigger: toggle }]}
|
||||
onSelect={toggle}
|
||||
options={rows()}
|
||||
current={cur()}
|
||||
onMove={(item) => setCur(item.value)}
|
||||
actions={[
|
||||
{
|
||||
title: "toggle",
|
||||
command: "plugins.toggle",
|
||||
hidden: lock(),
|
||||
onTrigger: (item) => {
|
||||
setCur(item.value)
|
||||
flip(item.value)
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "install",
|
||||
command: "dialog.plugins.install",
|
||||
selection: "none",
|
||||
hidden: lock(),
|
||||
onTrigger: () => {
|
||||
showInstall(props.api)
|
||||
},
|
||||
},
|
||||
]}
|
||||
onSelect={(item) => {
|
||||
setCur(item.value)
|
||||
flip(item.value)
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function Commands(props: { context: Plugin.Context }) {
|
||||
const plugins = usePlugin()
|
||||
props.context.keymap.layer(() => ({
|
||||
mode: "global",
|
||||
function show(api: TuiPluginApi) {
|
||||
api.ui.dialog.replace(() => <View api={api} />)
|
||||
}
|
||||
|
||||
const tui: TuiPlugin = async (api) => {
|
||||
api.keymap.registerLayer({
|
||||
commands: [
|
||||
{
|
||||
id: "plugins.list",
|
||||
name: "plugins.list",
|
||||
title: "Plugins",
|
||||
group: "System",
|
||||
palette: true,
|
||||
category: "System",
|
||||
namespace: "palette",
|
||||
run() {
|
||||
props.context.ui.dialog.show(() => <View context={props.context} plugins={plugins} />)
|
||||
show(api)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "plugins.install",
|
||||
title: "Install plugin",
|
||||
category: "System",
|
||||
namespace: "palette",
|
||||
run() {
|
||||
showInstall(api)
|
||||
},
|
||||
},
|
||||
],
|
||||
}))
|
||||
return null
|
||||
bindings: ["plugins.list", "plugins.install"].flatMap((command) => api.tuiConfig.keybinds.get(command)),
|
||||
})
|
||||
}
|
||||
|
||||
export default Plugin.define({
|
||||
const plugin: BuiltinTuiPlugin = {
|
||||
id,
|
||||
setup(context) {
|
||||
context.ui.slot("app", () => <Commands context={context} />)
|
||||
},
|
||||
})
|
||||
tui,
|
||||
}
|
||||
|
||||
export default plugin
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
import { Plugin } from "@opencode-ai/plugin/tui"
|
||||
import { useTerminalDimensions } from "@opentui/solid"
|
||||
import { Keymap } from "../../context/keymap"
|
||||
import { useTheme } from "../../context/theme"
|
||||
import { useDialog } from "../../ui/dialog"
|
||||
|
||||
function Commands(props: { context: Plugin.Context }) {
|
||||
props.context.keymap.layer(() => ({
|
||||
const dialog = useDialog()
|
||||
Keymap.createLayer(() => ({
|
||||
mode: "global",
|
||||
commands: [
|
||||
{
|
||||
@@ -12,7 +16,7 @@ function Commands(props: { context: Plugin.Context }) {
|
||||
palette: true,
|
||||
run() {
|
||||
props.context.ui.router.navigate({ type: "plugin", name: "scrap" })
|
||||
props.context.ui.dialog.clear()
|
||||
dialog.clear()
|
||||
},
|
||||
},
|
||||
],
|
||||
@@ -22,10 +26,10 @@ function Commands(props: { context: Plugin.Context }) {
|
||||
|
||||
function Scrap(props: { context: Plugin.Context }) {
|
||||
const dimensions = useTerminalDimensions()
|
||||
const theme = props.context.theme
|
||||
const elevatedTheme = props.context.theme.contextual("elevated")
|
||||
const { themeV2 } = useTheme()
|
||||
const { themeV2: elevatedTheme } = useTheme().contextual("elevated")
|
||||
|
||||
props.context.keymap.layer(() => ({
|
||||
Keymap.createLayer(() => ({
|
||||
commands: [
|
||||
{
|
||||
bind: "escape",
|
||||
@@ -39,7 +43,7 @@ function Scrap(props: { context: Plugin.Context }) {
|
||||
}))
|
||||
|
||||
return (
|
||||
<box width={dimensions().width} height={dimensions().height} backgroundColor={theme.background.default}>
|
||||
<box width={dimensions().width} height={dimensions().height} backgroundColor={themeV2.background.default}>
|
||||
<box flexGrow={1} />
|
||||
<box
|
||||
height={1}
|
||||
|
||||
@@ -0,0 +1,607 @@
|
||||
/** @jsxImportSource @opentui/solid */
|
||||
import { RGBA, TextAttributes, type KeyEvent, type Renderable } from "@opentui/core"
|
||||
import { useTerminalDimensions } from "@opentui/solid"
|
||||
import { createEffect, createMemo, createSignal, For, Show } from "solid-js"
|
||||
import { Keymap } from "../../context/keymap"
|
||||
import type { ActiveKey } from "@opentui/keymap"
|
||||
import type { TuiPlugin, TuiPluginApi } from "@opencode-ai/plugin/v1/tui"
|
||||
import type { BuiltinTuiPlugin } from "../builtins"
|
||||
|
||||
const command = {
|
||||
toggle: "which-key.toggle",
|
||||
toggleLayout: "which-key.layout.toggle",
|
||||
togglePending: "which-key.pending.toggle",
|
||||
groupPrevious: "which-key.group.previous",
|
||||
groupNext: "which-key.group.next",
|
||||
scrollUp: "which-key.scroll.up",
|
||||
scrollDown: "which-key.scroll.down",
|
||||
pageUp: "which-key.page.up",
|
||||
pageDown: "which-key.page.down",
|
||||
home: "which-key.home",
|
||||
end: "which-key.end",
|
||||
} as const
|
||||
|
||||
const LAYER_PRIORITY = 900
|
||||
const toggleCommands = [command.toggle, command.toggleLayout, command.togglePending] as const
|
||||
const scrollCommands = [
|
||||
command.scrollUp,
|
||||
command.scrollDown,
|
||||
command.pageUp,
|
||||
command.pageDown,
|
||||
command.home,
|
||||
command.end,
|
||||
] as const
|
||||
const panelCommands = [command.groupPrevious, command.groupNext, ...scrollCommands] as const
|
||||
const COLUMN_GAP = 4
|
||||
const TAB_GAP = 3
|
||||
const MIN_TAB_GAP = 1
|
||||
const TAB_CONTENT_GAP = 1
|
||||
const MIN_COLUMN_WIDTH = 28
|
||||
const MAX_COLUMN_WIDTH = 44
|
||||
const PANEL_HEIGHT_RATIO = 0.3
|
||||
const MIN_PANEL_HEIGHT = 8
|
||||
const MAX_PANEL_HEIGHT = 16
|
||||
const PANEL_TOP_PADDING = 1
|
||||
const FOOTER_HEIGHT = 1
|
||||
const FOOTER_MARGIN = 1
|
||||
const UNKNOWN = "Unknown"
|
||||
|
||||
type Layout = "dock" | "overlay"
|
||||
|
||||
type Color = RGBA | string
|
||||
|
||||
type Skin = {
|
||||
panel: Color
|
||||
text: Color
|
||||
muted: Color
|
||||
subtle: Color
|
||||
key: Color
|
||||
accent: Color
|
||||
tab: Color
|
||||
tabText: Color
|
||||
}
|
||||
|
||||
type Entry = {
|
||||
type: "entry"
|
||||
key: string
|
||||
label: string
|
||||
group: string
|
||||
continues: boolean
|
||||
}
|
||||
|
||||
type Group = {
|
||||
label: string
|
||||
entries: Entry[]
|
||||
}
|
||||
|
||||
type HeaderItem = { type: "tab"; group: Group } | { type: "scroll" }
|
||||
|
||||
type GroupHeader = {
|
||||
type: "group"
|
||||
label: string
|
||||
}
|
||||
|
||||
type Item = Entry | GroupHeader
|
||||
|
||||
function text(value: unknown) {
|
||||
if (typeof value !== "string") return undefined
|
||||
const trimmed = value.trim()
|
||||
return trimmed || undefined
|
||||
}
|
||||
|
||||
function ink(api: TuiPluginApi, name: string, fallback: string): Color {
|
||||
const value = Reflect.get(api.theme.current, name)
|
||||
if (typeof value === "string") return value
|
||||
if (value instanceof RGBA) return value
|
||||
return fallback
|
||||
}
|
||||
|
||||
function skin(api: TuiPluginApi): Skin {
|
||||
return {
|
||||
panel: ink(api, "backgroundMenu", "#1c1c1c"),
|
||||
text: ink(api, "text", "#f0f0f0"),
|
||||
muted: ink(api, "textMuted", "#a5a5a5"),
|
||||
subtle: ink(api, "borderSubtle", "#6f6f6f"),
|
||||
key: ink(api, "warning", "#ffd75f"),
|
||||
accent: ink(api, "primary", "#5f87ff"),
|
||||
tab: ink(api, "primary", "#5f87ff"),
|
||||
tabText: ink(api, "selectedListItemText", "#ffffff"),
|
||||
}
|
||||
}
|
||||
|
||||
function activeKeyLabel(active: ActiveKey<Renderable, KeyEvent>) {
|
||||
if (active.continues) return text(active.tokenName) ?? text(active.display) ?? UNKNOWN
|
||||
return (
|
||||
text(active.commandAttrs?.title) ?? text(active.bindingAttrs?.desc) ?? text(active.commandAttrs?.desc) ?? UNKNOWN
|
||||
)
|
||||
}
|
||||
|
||||
function activeKeyGroup(active: ActiveKey<Renderable, KeyEvent>) {
|
||||
if (active.continues) return "System"
|
||||
return text(active.commandAttrs?.category) ?? text(active.bindingAttrs?.group) ?? UNKNOWN
|
||||
}
|
||||
|
||||
function activeKeyEntry(api: TuiPluginApi, active: ActiveKey<Renderable, KeyEvent>): Entry {
|
||||
const key = api.keys.formatSequence([
|
||||
{
|
||||
stroke: active.stroke,
|
||||
display: active.display,
|
||||
tokenName: active.tokenName,
|
||||
},
|
||||
])
|
||||
const label = activeKeyLabel(active)
|
||||
return {
|
||||
type: "entry",
|
||||
key,
|
||||
label: active.continues ? `+${label}` : label,
|
||||
group: activeKeyGroup(active),
|
||||
continues: active.continues,
|
||||
}
|
||||
}
|
||||
|
||||
function grouped(entries: Entry[]): Group[] {
|
||||
const map = new Map<string, Entry[]>()
|
||||
for (const entry of entries) map.set(entry.group, [...(map.get(entry.group) ?? []), entry])
|
||||
return [...map]
|
||||
.map(([label, entries]) => ({
|
||||
label,
|
||||
entries: entries.toSorted(
|
||||
(a, b) =>
|
||||
Number(b.continues) - Number(a.continues) || a.label.localeCompare(b.label) || a.key.localeCompare(b.key),
|
||||
),
|
||||
}))
|
||||
.toSorted((a, b) => a.label.localeCompare(b.label))
|
||||
}
|
||||
|
||||
function commandShortcut(_api: TuiPluginApi, name: string) {
|
||||
const shortcuts = Keymap.useShortcuts()
|
||||
return () => shortcuts.get(name) ?? ""
|
||||
}
|
||||
|
||||
function layout(value: unknown): Layout {
|
||||
if (value === "overlay") return "overlay"
|
||||
return "dock"
|
||||
}
|
||||
|
||||
function HomeHint(props: { api: TuiPluginApi }) {
|
||||
const trigger = commandShortcut(props.api, command.toggle)
|
||||
const look = createMemo(() => skin(props.api))
|
||||
|
||||
return (
|
||||
<box width="100%" maxWidth={75} alignItems="center" paddingTop={1} flexShrink={0}>
|
||||
<text fg={look().muted} wrapMode="none">
|
||||
Show keyboard shortcuts with <span style={{ fg: look().subtle }}>{trigger() || command.toggle}</span>
|
||||
</text>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
|
||||
function WhichKeyPanel(props: {
|
||||
api: TuiPluginApi
|
||||
layout: Layout
|
||||
mode: () => Layout
|
||||
pendingPreview: () => boolean
|
||||
pinned: () => boolean
|
||||
}) {
|
||||
const dimensions = useTerminalDimensions()
|
||||
const [offset, setOffset] = createSignal(0)
|
||||
const [activeGroup, setActiveGroup] = createSignal<string | undefined>()
|
||||
const pending = Keymap.usePendingSequence()
|
||||
const active = Keymap.useActiveKeys()
|
||||
const pendingActive = createMemo(() => pending().length > 0 && active().length > 0)
|
||||
const pendingAutoVisible = createMemo(() => props.mode() === "overlay" && props.pendingPreview() && pendingActive())
|
||||
const visible = createMemo(() => props.pinned() || pendingAutoVisible())
|
||||
const pendingMode = createMemo(() => visible() && pendingActive())
|
||||
const left = 0
|
||||
const width = createMemo(() => Math.max(1, dimensions().width))
|
||||
const panelHeight = createMemo(() =>
|
||||
Math.max(MIN_PANEL_HEIGHT, Math.min(MAX_PANEL_HEIGHT, Math.floor(dimensions().height * PANEL_HEIGHT_RATIO))),
|
||||
)
|
||||
const contentWidth = createMemo(() => Math.max(1, width() - 2))
|
||||
const columns = createMemo(() =>
|
||||
Math.max(1, Math.min(3, Math.floor((contentWidth() + COLUMN_GAP) / (MAX_COLUMN_WIDTH + COLUMN_GAP)) || 1)),
|
||||
)
|
||||
const entries = createMemo(() => active().map((item) => activeKeyEntry(props.api, item)))
|
||||
const groups = createMemo(() => grouped(entries()))
|
||||
const tabsVisible = createMemo(() => !pendingMode() && groups().length > 0)
|
||||
const headerVisible = createMemo(() => tabsVisible() || pendingMode())
|
||||
const footerVisible = createMemo(() => !pendingMode())
|
||||
const rows = createMemo(() =>
|
||||
Math.max(
|
||||
1,
|
||||
panelHeight() -
|
||||
PANEL_TOP_PADDING -
|
||||
(headerVisible() ? 1 : 0) -
|
||||
(tabsVisible() ? TAB_CONTENT_GAP : 0) -
|
||||
(footerVisible() ? FOOTER_MARGIN + FOOTER_HEIGHT : 0),
|
||||
),
|
||||
)
|
||||
const pageSize = createMemo(() => rows() * columns())
|
||||
const currentGroup = createMemo(() => {
|
||||
const group = activeGroup()
|
||||
return groups().find((item) => item.label === group) ?? groups()[0]
|
||||
})
|
||||
const activeEntries = createMemo(() => currentGroup()?.entries ?? [])
|
||||
const items = createMemo<Item[]>(() => {
|
||||
if (!pendingMode()) return activeEntries()
|
||||
return groups().flatMap((group) => [{ type: "group", label: group.label } satisfies GroupHeader, ...group.entries])
|
||||
})
|
||||
const maxOffset = createMemo(() => Math.max(0, items().length - pageSize()))
|
||||
const shown = createMemo(() => {
|
||||
const columnsItems: Item[][] = []
|
||||
let index = offset()
|
||||
for (let column = 0; column < columns() && index < items().length; column++) {
|
||||
const list: Item[] = []
|
||||
while (list.length < rows() && index < items().length) {
|
||||
list.push(items()[index]!)
|
||||
index += 1
|
||||
}
|
||||
columnsItems.push(list)
|
||||
}
|
||||
return columnsItems
|
||||
})
|
||||
const rowIndexes = createMemo(() => Array.from({ length: rows() }, (_, index) => index))
|
||||
const trigger = commandShortcut(props.api, command.toggle)
|
||||
const modeTrigger = commandShortcut(props.api, command.toggleLayout)
|
||||
const upActive = createMemo(() => offset() > 0)
|
||||
const downActive = createMemo(() => offset() < maxOffset())
|
||||
const scrollable = createMemo(() => maxOffset() > 0)
|
||||
const headerItems = createMemo<HeaderItem[]>(() => [
|
||||
...(tabsVisible() ? groups().map((group) => ({ type: "tab" as const, group })) : []),
|
||||
...(scrollable() ? [{ type: "scroll" as const }] : []),
|
||||
])
|
||||
const tabGap = createMemo(() => {
|
||||
const itemCount = headerItems().length
|
||||
if (itemCount <= 1) return 0
|
||||
const itemWidth = headerItems().reduce(
|
||||
(sum, item) => sum + (item.type === "tab" ? item.group.label.length + 2 : 3),
|
||||
0,
|
||||
)
|
||||
return Math.max(MIN_TAB_GAP, Math.min(TAB_GAP, Math.floor((contentWidth() - itemWidth) / (itemCount - 1))))
|
||||
})
|
||||
const nextMode = createMemo(() => (props.mode() === "dock" ? "overlay" : "dock"))
|
||||
const look = createMemo(() => skin(props.api))
|
||||
const columnWidth = createMemo(() =>
|
||||
Math.max(1, Math.min(MAX_COLUMN_WIDTH, Math.floor((contentWidth() - (columns() - 1) * COLUMN_GAP) / columns()))),
|
||||
)
|
||||
const clamp = (value: number) => Math.max(0, Math.min(maxOffset(), value))
|
||||
const scroll = (delta: number) => setOffset((value) => clamp(value + delta))
|
||||
const moveGroup = (delta: number) => {
|
||||
if (pendingMode()) return
|
||||
const list = groups()
|
||||
if (!list.length) return
|
||||
const index = Math.max(
|
||||
0,
|
||||
list.findIndex((item) => item.label === currentGroup()?.label),
|
||||
)
|
||||
setActiveGroup(list[(index + delta + list.length) % list.length]!.label)
|
||||
setOffset(0)
|
||||
}
|
||||
|
||||
Keymap.createLayer(() => ({
|
||||
priority: 1000,
|
||||
enabled: visible(),
|
||||
commands: [
|
||||
{
|
||||
id: command.groupPrevious,
|
||||
bind: false,
|
||||
title: "Previous key binding group",
|
||||
description: "Show the previous which-key group",
|
||||
group: "System",
|
||||
run() {
|
||||
moveGroup(-1)
|
||||
},
|
||||
},
|
||||
{
|
||||
id: command.groupNext,
|
||||
bind: false,
|
||||
title: "Next key binding group",
|
||||
description: "Show the next which-key group",
|
||||
group: "System",
|
||||
run() {
|
||||
moveGroup(1)
|
||||
},
|
||||
},
|
||||
{
|
||||
id: command.scrollUp,
|
||||
bind: false,
|
||||
title: "Scroll key bindings up",
|
||||
description: "Scroll the which-key panel up",
|
||||
group: "System",
|
||||
run() {
|
||||
scroll(-columns())
|
||||
},
|
||||
},
|
||||
{
|
||||
id: command.scrollDown,
|
||||
bind: false,
|
||||
title: "Scroll key bindings down",
|
||||
description: "Scroll the which-key panel down",
|
||||
group: "System",
|
||||
run() {
|
||||
scroll(columns())
|
||||
},
|
||||
},
|
||||
{
|
||||
id: command.pageUp,
|
||||
bind: false,
|
||||
title: "Page key bindings up",
|
||||
description: "Page the which-key panel up",
|
||||
group: "System",
|
||||
run() {
|
||||
scroll(-pageSize())
|
||||
},
|
||||
},
|
||||
{
|
||||
id: command.pageDown,
|
||||
bind: false,
|
||||
title: "Page key bindings down",
|
||||
description: "Page the which-key panel down",
|
||||
group: "System",
|
||||
run() {
|
||||
scroll(pageSize())
|
||||
},
|
||||
},
|
||||
{
|
||||
id: command.home,
|
||||
bind: false,
|
||||
title: "First key binding",
|
||||
description: "Jump to the first which-key binding",
|
||||
group: "System",
|
||||
run() {
|
||||
setOffset(0)
|
||||
},
|
||||
},
|
||||
{
|
||||
id: command.end,
|
||||
bind: false,
|
||||
title: "Last key binding",
|
||||
description: "Jump to the last which-key binding",
|
||||
group: "System",
|
||||
run() {
|
||||
setOffset(maxOffset())
|
||||
},
|
||||
},
|
||||
],
|
||||
bindings: pendingMode() ? scrollCommands : panelCommands,
|
||||
}))
|
||||
|
||||
createEffect(() => {
|
||||
if (pendingMode()) return
|
||||
const group = currentGroup()
|
||||
if (group?.label === activeGroup()) return
|
||||
setActiveGroup(group?.label)
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
if (pendingMode()) return
|
||||
activeGroup()
|
||||
setOffset(0)
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
if (!visible()) setOffset(0)
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
pending()
|
||||
setOffset(0)
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
setOffset((value) => clamp(value))
|
||||
})
|
||||
|
||||
return (
|
||||
<Show when={visible()}>
|
||||
<box
|
||||
position={props.layout === "overlay" ? "absolute" : "relative"}
|
||||
zIndex={3500}
|
||||
left={left}
|
||||
bottom={props.layout === "overlay" ? 0 : undefined}
|
||||
width={dimensions().width}
|
||||
height={panelHeight()}
|
||||
backgroundColor={look().panel}
|
||||
paddingLeft={1}
|
||||
paddingRight={1}
|
||||
paddingTop={1}
|
||||
flexShrink={0}
|
||||
flexDirection="column"
|
||||
>
|
||||
<Show when={headerVisible()}>
|
||||
<box width="100%" flexDirection="row" justifyContent="center" gap={tabGap()} flexShrink={0}>
|
||||
<For each={headerItems()}>
|
||||
{(item) => (
|
||||
<Show
|
||||
when={item.type === "tab" ? item.group : undefined}
|
||||
fallback={
|
||||
<box flexShrink={0}>
|
||||
<text wrapMode="none">
|
||||
<span style={{ fg: upActive() ? look().text : look().muted }}>↑</span>
|
||||
<span style={{ fg: look().muted }}> </span>
|
||||
<span style={{ fg: downActive() ? look().text : look().muted }}>↓</span>
|
||||
</text>
|
||||
</box>
|
||||
}
|
||||
>
|
||||
{(group) => {
|
||||
const selected = createMemo(() => currentGroup()?.label === group().label)
|
||||
return (
|
||||
<box
|
||||
paddingLeft={1}
|
||||
paddingRight={1}
|
||||
flexShrink={0}
|
||||
backgroundColor={selected() ? look().tab : undefined}
|
||||
onMouseDown={() => {
|
||||
setActiveGroup(group().label)
|
||||
setOffset(0)
|
||||
}}
|
||||
>
|
||||
<text
|
||||
fg={selected() ? look().tabText : look().muted}
|
||||
attributes={selected() ? TextAttributes.BOLD : undefined}
|
||||
wrapMode="none"
|
||||
>
|
||||
{group().label}
|
||||
</text>
|
||||
</box>
|
||||
)
|
||||
}}
|
||||
</Show>
|
||||
)}
|
||||
</For>
|
||||
</box>
|
||||
</Show>
|
||||
<Show when={tabsVisible()}>
|
||||
<box height={TAB_CONTENT_GAP} flexShrink={0} />
|
||||
</Show>
|
||||
<box height={rows()} flexShrink={0} flexDirection="column">
|
||||
<Show when={shown().length > 0} fallback={<text fg={look().muted}>No reachable bindings</text>}>
|
||||
<For each={rowIndexes()}>
|
||||
{(row) => (
|
||||
<box width="100%" flexDirection="row" justifyContent="center" gap={COLUMN_GAP}>
|
||||
<For each={shown()}>
|
||||
{(column) => {
|
||||
const item = createMemo(() => column[row])
|
||||
const entry = createMemo(() => {
|
||||
const value = item()
|
||||
if (value?.type !== "entry") return undefined
|
||||
return value
|
||||
})
|
||||
return (
|
||||
<box width={columnWidth()} flexDirection="row" gap={1} justifyContent="space-between">
|
||||
<Show when={item()}>
|
||||
{(value) => (
|
||||
<Show
|
||||
when={entry()}
|
||||
fallback={
|
||||
<text fg={look().accent} attributes={TextAttributes.BOLD} wrapMode="none" truncate>
|
||||
{value().label}
|
||||
</text>
|
||||
}
|
||||
>
|
||||
{(binding) => (
|
||||
<>
|
||||
<box flexGrow={1} minWidth={0}>
|
||||
<text
|
||||
fg={binding().continues ? look().accent : look().muted}
|
||||
wrapMode="none"
|
||||
truncate
|
||||
>
|
||||
{binding().label}
|
||||
</text>
|
||||
</box>
|
||||
<box flexShrink={0}>
|
||||
<text fg={look().text} attributes={TextAttributes.BOLD} wrapMode="none" truncate>
|
||||
{binding().key}
|
||||
</text>
|
||||
</box>
|
||||
</>
|
||||
)}
|
||||
</Show>
|
||||
)}
|
||||
</Show>
|
||||
</box>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
</box>
|
||||
)}
|
||||
</For>
|
||||
</Show>
|
||||
</box>
|
||||
<Show when={footerVisible()}>
|
||||
<box height={FOOTER_MARGIN} flexShrink={0} />
|
||||
<box width="100%" flexDirection="row" justifyContent="space-between" flexShrink={0}>
|
||||
<box>
|
||||
<text fg={look().text} wrapMode="none">
|
||||
toggle <span style={{ fg: look().subtle }}>{trigger() || command.toggle}</span>
|
||||
</text>
|
||||
</box>
|
||||
<box>
|
||||
<text fg={look().text} wrapMode="none">
|
||||
{nextMode()} <span style={{ fg: look().subtle }}>{modeTrigger() || command.toggleLayout}</span>
|
||||
</text>
|
||||
</box>
|
||||
</box>
|
||||
</Show>
|
||||
</box>
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
|
||||
const tui: TuiPlugin = async (api) => {
|
||||
const [pinned, setPinned] = createSignal(false)
|
||||
const [mode, setMode] = createSignal(layout("dock"))
|
||||
const [pendingPreview, setPendingPreview] = createSignal(false)
|
||||
|
||||
api.keymap.registerLayer({
|
||||
priority: LAYER_PRIORITY,
|
||||
commands: [
|
||||
{
|
||||
name: command.toggle,
|
||||
title: "Show key bindings",
|
||||
desc: "Toggle which-key overlay",
|
||||
category: "System",
|
||||
run() {
|
||||
setPinned((value) => !value)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: command.toggleLayout,
|
||||
title: "Toggle key bindings layout",
|
||||
desc: "Switch which-key between dock and overlay mode",
|
||||
category: "System",
|
||||
run() {
|
||||
setMode((value) => {
|
||||
const next = value === "dock" ? "overlay" : "dock"
|
||||
return next
|
||||
})
|
||||
},
|
||||
},
|
||||
{
|
||||
name: command.togglePending,
|
||||
title: "Toggle pending key preview",
|
||||
desc: "Automatically show which-key for pending key sequences in overlay mode",
|
||||
category: "System",
|
||||
run() {
|
||||
setPendingPreview((value) => {
|
||||
return !value
|
||||
})
|
||||
},
|
||||
},
|
||||
],
|
||||
bindings: toggleCommands.flatMap((command) => api.tuiConfig.keybinds.get(command)),
|
||||
})
|
||||
|
||||
api.slots.register({
|
||||
order: 200,
|
||||
slots: {
|
||||
home_bottom() {
|
||||
return <HomeHint api={api} />
|
||||
},
|
||||
app() {
|
||||
return (
|
||||
<Show when={mode() === "overlay"}>
|
||||
<WhichKeyPanel api={api} layout="overlay" mode={mode} pendingPreview={pendingPreview} pinned={pinned} />
|
||||
</Show>
|
||||
)
|
||||
},
|
||||
app_bottom() {
|
||||
return (
|
||||
<Show when={mode() === "dock"}>
|
||||
<WhichKeyPanel api={api} layout="dock" mode={mode} pendingPreview={pendingPreview} pinned={pinned} />
|
||||
</Show>
|
||||
)
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const plugin: BuiltinTuiPlugin = {
|
||||
id: "which-key",
|
||||
enabled: false,
|
||||
tui,
|
||||
}
|
||||
|
||||
export default plugin
|
||||
@@ -0,0 +1,40 @@
|
||||
import type { TuiRouteDefinition } from "@opencode-ai/plugin/v1/tui"
|
||||
import { createSignal } from "solid-js"
|
||||
|
||||
type RouteEntry = {
|
||||
key: symbol
|
||||
render: TuiRouteDefinition["render"]
|
||||
}
|
||||
|
||||
export type RouteMap = Map<string, RouteEntry[]>
|
||||
|
||||
export function createPluginRoutes() {
|
||||
const routes: RouteMap = new Map()
|
||||
const [revision, setRevision] = createSignal(0)
|
||||
|
||||
return {
|
||||
register(list: TuiRouteDefinition[]) {
|
||||
const key = Symbol()
|
||||
list.forEach((item) => routes.set(item.name, [...(routes.get(item.name) ?? []), { key, render: item.render }]))
|
||||
setRevision((value) => value + 1)
|
||||
|
||||
return () => {
|
||||
list.forEach((item) => {
|
||||
const next = routes.get(item.name)?.filter((entry) => entry.key !== key) ?? []
|
||||
if (next.length) {
|
||||
routes.set(item.name, next)
|
||||
return
|
||||
}
|
||||
routes.delete(item.name)
|
||||
})
|
||||
setRevision((value) => value + 1)
|
||||
}
|
||||
},
|
||||
get(name: string) {
|
||||
revision()
|
||||
return routes.get(name)?.at(-1)?.render
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export type PluginRoutes = ReturnType<typeof createPluginRoutes>
|
||||
@@ -5,7 +5,6 @@ import SidebarLsp from "../feature-plugins/sidebar/lsp"
|
||||
import SidebarMcp from "../feature-plugins/sidebar/mcp"
|
||||
import DiffViewer from "../feature-plugins/system/diff-viewer"
|
||||
import Notifications from "../feature-plugins/system/notifications"
|
||||
import Plugins from "../feature-plugins/system/plugins"
|
||||
import Scrap from "../feature-plugins/system/scrap"
|
||||
|
||||
export const builtins = [
|
||||
@@ -15,7 +14,6 @@ export const builtins = [
|
||||
SidebarLsp,
|
||||
SidebarFooter,
|
||||
Notifications,
|
||||
Plugins,
|
||||
Scrap,
|
||||
DiffViewer,
|
||||
]
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
// Legacy `api.command` bridge for v1 plugins; remove in v2.
|
||||
import type { TuiCommand, TuiPluginApi } from "@opencode-ai/plugin/v1/tui"
|
||||
import { TuiKeybind } from "../config/keybind"
|
||||
import type { DialogContext } from "../ui/dialog"
|
||||
|
||||
const COMMAND_PALETTE_SHOW = "command.palette.show"
|
||||
const warned = new Set<string>()
|
||||
|
||||
type Warn = (api: string, replacement: string) => void
|
||||
type LegacyDialog = TuiPluginApi["ui"]["dialog"]
|
||||
type CommandShimDialog = DialogContext | LegacyDialog
|
||||
type LegacyKeybinds = TuiPluginApi["tuiConfig"]["keybinds"]
|
||||
|
||||
function warnCommandShim(api: string, replacement: string) {
|
||||
// Warn v1 plugins about deprecated `api.command`; remove this shim path in v2.
|
||||
console.warn("[tui.plugin] deprecated TUI plugin API", { api, replacement })
|
||||
}
|
||||
|
||||
function createCommandShimDialog(dialog: CommandShimDialog): LegacyDialog {
|
||||
if (!("stack" in dialog)) return dialog
|
||||
return {
|
||||
replace(render, onClose) {
|
||||
dialog.replace(render, onClose)
|
||||
},
|
||||
clear() {
|
||||
dialog.clear()
|
||||
},
|
||||
setSize(size) {
|
||||
dialog.setSize(size)
|
||||
},
|
||||
get size() {
|
||||
return dialog.size
|
||||
},
|
||||
get depth() {
|
||||
return dialog.stack.length
|
||||
},
|
||||
get open() {
|
||||
return dialog.stack.length > 0
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function warnOnce(api: string, replacement: string, warn: Warn) {
|
||||
if (warned.has(api)) return
|
||||
warned.add(api)
|
||||
warn(api, replacement)
|
||||
}
|
||||
|
||||
function toCommand(item: TuiCommand, dialog: LegacyDialog) {
|
||||
return {
|
||||
namespace: "palette",
|
||||
name: item.value,
|
||||
title: item.title,
|
||||
desc: item.description,
|
||||
category: item.category,
|
||||
suggested: item.suggested,
|
||||
hidden: item.hidden,
|
||||
enabled: item.enabled,
|
||||
slash: item.slash,
|
||||
run() {
|
||||
return item.onSelect?.(dialog)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function toBindings(commands: TuiCommand[], keybinds: LegacyKeybinds) {
|
||||
return commands.flatMap((item) =>
|
||||
item.keybind
|
||||
? keybinds.has(TuiKeybind.CommandMap[item.keybind as keyof typeof TuiKeybind.CommandMap] ?? item.keybind)
|
||||
? keybinds
|
||||
.get(TuiKeybind.CommandMap[item.keybind as keyof typeof TuiKeybind.CommandMap] ?? item.keybind)
|
||||
.map((binding) => ({ ...binding, cmd: item.value, desc: binding.desc ?? item.title }))
|
||||
: [
|
||||
{
|
||||
key: item.keybind,
|
||||
cmd: item.value,
|
||||
desc: item.title,
|
||||
},
|
||||
]
|
||||
: [],
|
||||
)
|
||||
}
|
||||
|
||||
export function createCommandShim(
|
||||
keymap: TuiPluginApi["keymap"],
|
||||
dialog: CommandShimDialog,
|
||||
keybinds: LegacyKeybinds,
|
||||
): TuiPluginApi["command"] {
|
||||
const shimDialog = createCommandShimDialog(dialog)
|
||||
return {
|
||||
register(cb) {
|
||||
warnOnce("api.command.register", "api.keymap.registerLayer({ commands, bindings })", warnCommandShim)
|
||||
const commands = cb()
|
||||
return keymap.registerLayer({
|
||||
commands: commands.map((item) => toCommand(item, shimDialog)),
|
||||
bindings: toBindings(commands, keybinds),
|
||||
})
|
||||
},
|
||||
trigger(value) {
|
||||
warnOnce("api.command.trigger", "api.keymap.dispatchCommand(name)", warnCommandShim)
|
||||
keymap.dispatchCommand(value)
|
||||
},
|
||||
show() {
|
||||
warnOnce("api.command.show", `api.keymap.dispatchCommand("${COMMAND_PALETTE_SHOW}")`, warnCommandShim)
|
||||
keymap.dispatchCommand(COMMAND_PALETTE_SHOW)
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { PluginContextProvider, type Plugin } from "@opencode-ai/plugin/tui"
|
||||
import type { Plugin } from "@opencode-ai/plugin/tui"
|
||||
import {
|
||||
batch,
|
||||
createContext,
|
||||
@@ -13,18 +13,17 @@ import {
|
||||
import path from "path"
|
||||
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 type { Context, Dialog, Page, Slot, Toast } from "@opencode-ai/plugin/tui/context"
|
||||
import { createStore, produce, reconcile as reconcileStore } from "solid-js/store"
|
||||
import { useRenderer } from "@opentui/solid"
|
||||
import { ensureRuntimePluginSupport } from "@opentui/solid/runtime-plugin-support/configure"
|
||||
import { useConfig } from "../config"
|
||||
import { useClient } from "../context/client"
|
||||
import { useData } from "../context/data"
|
||||
import { Keymap } from "../context/keymap"
|
||||
import { useRoute } from "../context/route"
|
||||
import { useTuiApp, useTuiLifecycle, useTuiPaths } from "../context/runtime"
|
||||
import { useTuiLifecycle } from "../context/runtime"
|
||||
import { useLocation } from "../context/location"
|
||||
import { useTheme, useThemes } from "../context/theme"
|
||||
import { useTheme } from "../context/theme"
|
||||
import { DialogAlert } from "../ui/dialog-alert"
|
||||
import { DialogConfirm } from "../ui/dialog-confirm"
|
||||
import { DialogPrompt } from "../ui/dialog-prompt"
|
||||
@@ -32,11 +31,7 @@ import { DialogSelect } from "../ui/dialog-select"
|
||||
import { useDialog } from "../ui/dialog"
|
||||
import { useToast } from "../ui/toast"
|
||||
import { useAttention } from "../context/attention"
|
||||
import { abbreviateHome } from "../util/path-format"
|
||||
import { builtins } from "./builtins"
|
||||
import { discoverTuiPlugins } from "./discovery"
|
||||
|
||||
ensureRuntimePluginSupport()
|
||||
|
||||
export interface PackageResolver {
|
||||
readonly resolve: (spec: string) => Promise<string | undefined>
|
||||
@@ -48,26 +43,19 @@ type State =
|
||||
| { readonly target: string; readonly status: "unsupported" }
|
||||
| { readonly target: string; readonly status: "failed"; readonly error: string }
|
||||
|
||||
type RegisteredPlugin = {
|
||||
readonly id: string
|
||||
readonly source: "builtin" | "external"
|
||||
readonly active: boolean
|
||||
}
|
||||
|
||||
type Value = {
|
||||
readonly ready: () => boolean
|
||||
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<Slot<Name>>
|
||||
readonly slot: (name: string) => ReadonlyArray<Slot>
|
||||
readonly activate: (id: string) => Promise<boolean>
|
||||
readonly deactivate: (id: string) => Promise<boolean>
|
||||
}
|
||||
|
||||
type Dispose = () => Promise<void>
|
||||
type Registration = {
|
||||
target: string
|
||||
plugin: Plugin.Definition
|
||||
source: RegisteredPlugin["source"]
|
||||
options?: Readonly<Record<string, any>>
|
||||
active: boolean
|
||||
routes: Record<string, Page>
|
||||
@@ -87,12 +75,8 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver }>
|
||||
const shortcuts = Keymap.useShortcuts()
|
||||
const keymapState = Keymap.useState()
|
||||
const lifecycle = useTuiLifecycle()
|
||||
const app = useTuiApp()
|
||||
const paths = useTuiPaths()
|
||||
const location = useLocation()
|
||||
const theme = useTheme()
|
||||
const themes = useThemes()
|
||||
const pluginTheme = createPluginTheme(theme, themes)
|
||||
const dialog = useDialog()
|
||||
const toast = useToast()
|
||||
const attention = useAttention()
|
||||
@@ -113,16 +97,30 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver }>
|
||||
setStore("registrations", id, "cleanups", [])
|
||||
})
|
||||
const owned: Dispose[] = []
|
||||
let context: Context
|
||||
let activeDialog: symbol | undefined
|
||||
const dialogApi: Dialog = {
|
||||
show(render, onClose) {
|
||||
dialog.replace(() => <PluginContextProvider value={context}>{render()}</PluginContextProvider>, onClose)
|
||||
const token = Symbol()
|
||||
let closed = false
|
||||
activeDialog = token
|
||||
dialog.replace(render, () => {
|
||||
if (closed) return
|
||||
closed = true
|
||||
if (activeDialog === token) activeDialog = undefined
|
||||
onClose?.()
|
||||
})
|
||||
return () => {
|
||||
if (closed || activeDialog !== token) return
|
||||
dialog.clear()
|
||||
}
|
||||
},
|
||||
set(options) {
|
||||
if (!activeDialog) return
|
||||
dialog.setSize(options.size ?? "medium")
|
||||
dialog.setCentered(options.centered ?? false)
|
||||
},
|
||||
clear() {
|
||||
if (!activeDialog) return
|
||||
dialog.clear()
|
||||
},
|
||||
alert(options) {
|
||||
@@ -214,17 +212,17 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver }>
|
||||
toast.show({ ...options, variant: options.variant ?? "info" })
|
||||
},
|
||||
}
|
||||
context = {
|
||||
owned.push(async () => dialogApi.clear())
|
||||
const context: Context = {
|
||||
options: item.options ?? {},
|
||||
get location() {
|
||||
return location.current
|
||||
},
|
||||
app: { version: app.version, channel: app.channel },
|
||||
renderer,
|
||||
client: client.api,
|
||||
data,
|
||||
attention,
|
||||
theme: pluginTheme,
|
||||
theme: theme.themeV2,
|
||||
keymap: {
|
||||
layer: Keymap.createLayer,
|
||||
dispatch: keymap.dispatch,
|
||||
@@ -237,17 +235,11 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver }>
|
||||
ui: {
|
||||
dialog: dialogApi,
|
||||
toast: toastApi,
|
||||
format: {
|
||||
path: (value) => abbreviateHome(value, paths.home),
|
||||
},
|
||||
router: {
|
||||
register(page) {
|
||||
if (store.registrations[item.plugin.id]?.routes[page.name])
|
||||
throw new Error(`Route already registered: ${page.name}`)
|
||||
setStore("registrations", item.plugin.id, "routes", page.name, {
|
||||
...page,
|
||||
render: (input) => <PluginContextProvider value={context}>{page.render(input)}</PluginContextProvider>,
|
||||
})
|
||||
setStore("registrations", item.plugin.id, "routes", page.name, page)
|
||||
let registered = true
|
||||
const unregister = () => {
|
||||
if (!registered) return
|
||||
@@ -277,9 +269,7 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver }>
|
||||
},
|
||||
slot(name, render) {
|
||||
if (store.registrations[item.plugin.id]?.slots[name]) throw new Error(`Slot already registered: ${name}`)
|
||||
setStore("registrations", item.plugin.id, "slots", name, () => (input: SlotMap[typeof name]) => (
|
||||
<PluginContextProvider value={context}>{render(input)}</PluginContextProvider>
|
||||
))
|
||||
setStore("registrations", item.plugin.id, "slots", name, () => render)
|
||||
let registered = true
|
||||
const unregister = () => {
|
||||
if (!registered) return
|
||||
@@ -346,7 +336,7 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver }>
|
||||
.filter(([, registration]) => registration.active)
|
||||
.map(([id]) => deactivate(id)),
|
||||
)
|
||||
const entries = [...(await discoverTuiPlugins(paths.cwd)), ...(config.data.plugins ?? [])]
|
||||
const entries = config.data.plugins ?? []
|
||||
batch(() => {
|
||||
setStore("registrations", reconcileStore({}))
|
||||
setStore("states", [])
|
||||
@@ -354,8 +344,8 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver }>
|
||||
|
||||
for (const plugin of builtins) {
|
||||
setStore("registrations", plugin.id, {
|
||||
target: plugin.id,
|
||||
plugin,
|
||||
source: "builtin",
|
||||
active: false,
|
||||
routes: {},
|
||||
slots: {},
|
||||
@@ -399,24 +389,23 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver }>
|
||||
continue
|
||||
}
|
||||
|
||||
setStore("registrations", plugin.id, {
|
||||
plugin,
|
||||
source: "external",
|
||||
options,
|
||||
const item = { target, plugin, options }
|
||||
setStore("registrations", item.plugin.id, {
|
||||
...item,
|
||||
active: false,
|
||||
routes: {},
|
||||
slots: {},
|
||||
cleanups: [],
|
||||
})
|
||||
const error = await activate(plugin.id).then(
|
||||
const error = await activate(item.plugin.id).then(
|
||||
() => undefined,
|
||||
(error) => (error instanceof Error ? error.message : String(error)),
|
||||
)
|
||||
setStore("states", (items) => [
|
||||
...items.filter((state) => state.target !== target && (!("id" in state) || state.id !== plugin.id)),
|
||||
...items.filter((state) => state.target !== item.target && (!("id" in state) || state.id !== item.plugin.id)),
|
||||
error
|
||||
? { target, status: "failed", error }
|
||||
: { target, id: plugin.id, status: "active" },
|
||||
? { target: item.target, status: "failed", error }
|
||||
: { target: item.target, id: item.plugin.id, status: "active" },
|
||||
])
|
||||
}
|
||||
}
|
||||
@@ -450,8 +439,6 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver }>
|
||||
value={{
|
||||
ready: () => store.ready,
|
||||
list: () => store.states,
|
||||
registered: () =>
|
||||
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.values(store.registrations).flatMap((registration) =>
|
||||
@@ -525,24 +512,6 @@ function isPlugin(value: unknown): value is Plugin.Definition {
|
||||
)
|
||||
}
|
||||
|
||||
type PluginTheme = ReturnType<typeof useTheme> & {
|
||||
contextual(context: "elevated" | "overlay"): PluginTheme
|
||||
syntaxStyle(): ReturnType<ReturnType<typeof useThemes>["currentSyntax"]>
|
||||
}
|
||||
|
||||
export function createPluginTheme(theme: ReturnType<typeof useTheme>, themes: ReturnType<typeof useThemes>): PluginTheme {
|
||||
return new Proxy(theme as PluginTheme, {
|
||||
get(target, property, receiver) {
|
||||
if (property === "contextual") {
|
||||
return (context: "elevated" | "overlay") => createPluginTheme(themes.contextual(context), themes)
|
||||
}
|
||||
if (property === "syntaxStyle") return themes.currentSyntax
|
||||
if (Reflect.has(target, property)) return Reflect.get(target, property, receiver)
|
||||
return Reflect.get(themes, property, themes)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function usePlugin() {
|
||||
const value = useContext(PluginContext)
|
||||
if (!value) throw new Error("PluginProvider is missing")
|
||||
@@ -561,16 +530,7 @@ export function PluginRoute(props: { readonly fallback: (id: string, name: strin
|
||||
return <>{content()}</>
|
||||
}
|
||||
|
||||
export function PluginSlot<Name extends SlotName>(props: {
|
||||
readonly name: Name
|
||||
readonly input: SlotMap[Name]
|
||||
readonly mode: "all" | "replace"
|
||||
}) {
|
||||
export function PluginSlot(props: { readonly name: string; readonly input?: Record<string, any> }) {
|
||||
const plugins = usePlugin()
|
||||
const renderers = createMemo(() => {
|
||||
const items = plugins.slot(props.name)
|
||||
if (props.mode === "replace") return items.slice(-1)
|
||||
return items
|
||||
})
|
||||
return <For each={renderers()}>{(render) => render(props.input)}</For>
|
||||
return <For each={plugins.slot(props.name)}>{(render) => render(props.input ?? {})}</For>
|
||||
}
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
import { readdir } from "node:fs/promises"
|
||||
import path from "node:path"
|
||||
|
||||
const extensions = new Set([".cjs", ".cts", ".js", ".jsx", ".mjs", ".mts", ".ts", ".tsx"])
|
||||
|
||||
export async function discoverTuiPlugins(cwd: string) {
|
||||
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)
|
||||
})
|
||||
return entries
|
||||
.filter((entry) => (entry.isFile() || entry.isSymbolicLink()) && extensions.has(path.extname(entry.name)))
|
||||
.map((entry) => path.join(directory, entry.name))
|
||||
.sort()
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import type {
|
||||
TuiPluginApi,
|
||||
TuiPluginInstallOptions,
|
||||
TuiPluginInstallResult,
|
||||
TuiPluginStatus,
|
||||
} from "@opencode-ai/plugin/v1/tui"
|
||||
import { createContext, createSignal, useContext, type JSX, type ParentProps } from "solid-js"
|
||||
import { createPluginRoutes } from "./api"
|
||||
import { createSlots, type HostSlots } from "./slots"
|
||||
|
||||
export function createPluginRuntime() {
|
||||
const [commands, setCommands] = createSignal<PluginRuntimeCommands>(emptyCommands)
|
||||
const [status, setStatus] = createSignal<ReadonlyArray<TuiPluginStatus>>([])
|
||||
const slots = createSlots()
|
||||
|
||||
return {
|
||||
Slot: slots.Slot,
|
||||
routes: createPluginRoutes(),
|
||||
commands,
|
||||
status,
|
||||
update(input: { commands?: PluginRuntimeCommands; status?: ReadonlyArray<TuiPluginStatus> }) {
|
||||
if (input.commands) setCommands(input.commands)
|
||||
if (input.status) setStatus(input.status)
|
||||
},
|
||||
clear() {
|
||||
setCommands(emptyCommands)
|
||||
setStatus([])
|
||||
slots.clear()
|
||||
},
|
||||
setupSlots(api: TuiPluginApi): HostSlots {
|
||||
return slots.setup(api)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export type PluginRuntimeCommands = {
|
||||
activate: (id: string) => Promise<boolean>
|
||||
deactivate: (id: string) => Promise<boolean>
|
||||
add: (spec: string) => Promise<boolean>
|
||||
install: (spec: string, options?: TuiPluginInstallOptions) => Promise<TuiPluginInstallResult>
|
||||
}
|
||||
|
||||
const emptyCommands: PluginRuntimeCommands = {
|
||||
async activate() {
|
||||
return false
|
||||
},
|
||||
async deactivate() {
|
||||
return false
|
||||
},
|
||||
async add() {
|
||||
return false
|
||||
},
|
||||
async install() {
|
||||
return { ok: false, message: "Plugin runtime is not available." }
|
||||
},
|
||||
}
|
||||
|
||||
export type PluginRuntime = ReturnType<typeof createPluginRuntime>
|
||||
|
||||
export type TuiPluginHost = {
|
||||
start(input: {
|
||||
api: TuiPluginApi
|
||||
runtime: PluginRuntime
|
||||
dispose?: () => void
|
||||
}): Promise<void>
|
||||
dispose(): Promise<void>
|
||||
}
|
||||
|
||||
const Context = createContext<PluginRuntime>()
|
||||
|
||||
export function PluginRuntimeProvider(props: ParentProps<{ value: PluginRuntime }>): JSX.Element {
|
||||
return <Context.Provider value={props.value}>{props.children}</Context.Provider>
|
||||
}
|
||||
|
||||
export function usePluginRuntime() {
|
||||
const runtime = useContext(Context)
|
||||
if (!runtime) throw new Error("usePluginRuntime must be used within PluginRuntimeProvider")
|
||||
return runtime
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import type { TuiPluginApi, TuiSlotContext, TuiSlotMap, TuiSlotProps } from "@opencode-ai/plugin/v1/tui"
|
||||
import { createSlot, createSolidSlotRegistry, type JSX, type SolidPlugin } from "@opentui/solid"
|
||||
import { createSignal } from "solid-js"
|
||||
import { isRecord } from "../util/record"
|
||||
|
||||
type RuntimeSlotMap = TuiSlotMap<Record<string, object>>
|
||||
type SlotView = <Name extends string>(props: TuiSlotProps<Name>) => JSX.Element | null
|
||||
|
||||
export type HostSlotPlugin<Slots extends Record<string, object> = {}> = SolidPlugin<TuiSlotMap<Slots>, TuiSlotContext>
|
||||
export type HostPluginApi = TuiPluginApi
|
||||
export type HostSlots = {
|
||||
register: {
|
||||
(plugin: HostSlotPlugin): () => void
|
||||
<Slots extends Record<string, object>>(plugin: HostSlotPlugin<Slots>): () => void
|
||||
}
|
||||
dispose: () => void
|
||||
}
|
||||
|
||||
function isHostSlotPlugin(value: unknown): value is HostSlotPlugin<Record<string, object>> {
|
||||
if (!isRecord(value)) return false
|
||||
if (typeof value.id !== "string") return false
|
||||
return isRecord(value.slots)
|
||||
}
|
||||
|
||||
export function createSlots() {
|
||||
const empty: SlotView = (props) => props.children ?? null
|
||||
const [view, setView] = createSignal<SlotView>(empty)
|
||||
const Slot: SlotView = (props) => view()(props)
|
||||
|
||||
return {
|
||||
Slot,
|
||||
setup(api: HostPluginApi): HostSlots {
|
||||
const registry = createSolidSlotRegistry<RuntimeSlotMap, TuiSlotContext>(
|
||||
api.renderer,
|
||||
{ theme: api.theme },
|
||||
{
|
||||
onPluginError(event) {
|
||||
console.error("[tui.slot] plugin error", {
|
||||
plugin: event.pluginId,
|
||||
slot: event.slot,
|
||||
phase: event.phase,
|
||||
source: event.source,
|
||||
message: event.error.message,
|
||||
})
|
||||
},
|
||||
},
|
||||
)
|
||||
const slot = createSlot<RuntimeSlotMap, TuiSlotContext>(registry)
|
||||
setView(() => (props: TuiSlotProps<string>) => slot(props))
|
||||
|
||||
return {
|
||||
register(plugin: HostSlotPlugin) {
|
||||
if (!isHostSlotPlugin(plugin)) return () => {}
|
||||
return registry.register(plugin)
|
||||
},
|
||||
dispose() {
|
||||
setView(() => empty)
|
||||
},
|
||||
}
|
||||
},
|
||||
clear() {
|
||||
setView(() => empty)
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { useArgs } from "../context/args"
|
||||
import { useRouteData } from "../context/route"
|
||||
import { usePromptRef } from "../context/prompt"
|
||||
import { useLocal } from "../context/local"
|
||||
import { usePluginRuntime } from "../plugin/runtime"
|
||||
import { useEditorContext } from "../context/editor"
|
||||
import { useData } from "../context/data"
|
||||
import { useLocation } from "../context/location"
|
||||
@@ -18,6 +19,7 @@ const placeholder = {
|
||||
}
|
||||
|
||||
export function Home() {
|
||||
const pluginRuntime = usePluginRuntime()
|
||||
const route = useRouteData("home")
|
||||
const promptRef = usePromptRef()
|
||||
const [ref, setRef] = createSignal<PromptRef | undefined>()
|
||||
@@ -68,16 +70,26 @@ export function Home() {
|
||||
<box flexGrow={1} minHeight={0} />
|
||||
<box height={4} minHeight={0} flexShrink={1} />
|
||||
<box flexShrink={0}>
|
||||
<Logo />
|
||||
<pluginRuntime.Slot name="home_logo" mode="replace">
|
||||
<Logo />
|
||||
</pluginRuntime.Slot>
|
||||
</box>
|
||||
<box height={1} minHeight={0} flexShrink={1} />
|
||||
<box width="100%" maxWidth={75} zIndex={1000} paddingTop={1} flexShrink={0}>
|
||||
<Prompt ref={bind} placeholders={placeholder} disabled={forms().length > 0} />
|
||||
<pluginRuntime.Slot name="home_prompt" mode="replace" ref={bind}>
|
||||
<Prompt
|
||||
ref={bind}
|
||||
right={<pluginRuntime.Slot name="home_prompt_right" />}
|
||||
placeholders={placeholder}
|
||||
disabled={forms().length > 0}
|
||||
/>
|
||||
</pluginRuntime.Slot>
|
||||
</box>
|
||||
<PluginSlot name="home.bottom" />
|
||||
<box flexGrow={1} minHeight={0} />
|
||||
</box>
|
||||
<box width="100%" flexShrink={0}>
|
||||
<PluginSlot name="home.footer" input={{}} mode="replace" />
|
||||
<PluginSlot name="home.footer" />
|
||||
</box>
|
||||
<Show when={forms()[0]?.id} keyed>
|
||||
{(_) => {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { createEffect, createMemo, For, onCleanup, Show, useContext, createContext } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { TextAttributes } from "@opentui/core"
|
||||
import { useThemes } from "../../../context/theme"
|
||||
import { useTheme } from "../../../context/theme"
|
||||
import { SplitBorder } from "../../../ui/border"
|
||||
import { Keymap } from "../../../context/keymap"
|
||||
import { SubagentsTab } from "./subagents-tab"
|
||||
@@ -39,7 +39,7 @@ export type ComposerProps = {
|
||||
}
|
||||
|
||||
export function Composer(props: ComposerProps) {
|
||||
const theme = useThemes().contextual("elevated")
|
||||
const { themeV2 } = useTheme().contextual("elevated")
|
||||
|
||||
const [store, setStore] = createStore({
|
||||
tabs: {} as Record<string, Tab>,
|
||||
@@ -98,6 +98,12 @@ export function Composer(props: ComposerProps) {
|
||||
{ bind: "left", title: "Previous tab", group: "Composer", run: () => switchTab(-1) },
|
||||
{ bind: "right", title: "Next tab", group: "Composer", run: () => switchTab(1) },
|
||||
{ bind: "escape", title: "Close composer", group: "Composer", run: close },
|
||||
{
|
||||
bind: "<leader>down",
|
||||
title: "Toggle composer",
|
||||
group: "Composer",
|
||||
run: close,
|
||||
},
|
||||
],
|
||||
}))
|
||||
|
||||
@@ -107,8 +113,8 @@ export function Composer(props: ComposerProps) {
|
||||
<box
|
||||
{...SplitBorder}
|
||||
border={["left"]}
|
||||
borderColor={theme.border.default}
|
||||
backgroundColor={theme.background.default}
|
||||
borderColor={themeV2.border.default}
|
||||
backgroundColor={themeV2.background.default}
|
||||
paddingLeft={1}
|
||||
paddingRight={2}
|
||||
paddingTop={1}
|
||||
@@ -119,7 +125,7 @@ export function Composer(props: ComposerProps) {
|
||||
<Show
|
||||
when={tabList().length > 1}
|
||||
fallback={
|
||||
<text fg={theme.text.default} attributes={TextAttributes.BOLD}>
|
||||
<text fg={themeV2.text.default} attributes={TextAttributes.BOLD}>
|
||||
{tabList()[0]?.label ?? ""}
|
||||
</text>
|
||||
}
|
||||
@@ -130,7 +136,7 @@ export function Composer(props: ComposerProps) {
|
||||
const isActive = createMemo(() => store.active === t.id)
|
||||
return (
|
||||
<text
|
||||
fg={isActive() ? theme.text.default : theme.text.subdued}
|
||||
fg={isActive() ? themeV2.text.default : themeV2.text.subdued}
|
||||
attributes={isActive() ? TextAttributes.BOLD : undefined}
|
||||
>
|
||||
{t.label}
|
||||
@@ -140,7 +146,7 @@ export function Composer(props: ComposerProps) {
|
||||
</For>
|
||||
</box>
|
||||
</Show>
|
||||
<text fg={theme.text.subdued} onMouseUp={close}>
|
||||
<text fg={themeV2.text.subdued} onMouseUp={close}>
|
||||
esc
|
||||
</text>
|
||||
</box>
|
||||
@@ -150,19 +156,19 @@ export function Composer(props: ComposerProps) {
|
||||
<For each={footerHints()}>
|
||||
{(hint) => (
|
||||
<text>
|
||||
<span style={{ fg: theme.text.default }}>
|
||||
<span style={{ fg: themeV2.text.default }}>
|
||||
<b>{hint.label}</b>{" "}
|
||||
</span>
|
||||
<span style={{ fg: theme.text.subdued }}>{hint.shortcut}</span>
|
||||
<span style={{ fg: themeV2.text.subdued }}>{hint.shortcut}</span>
|
||||
</text>
|
||||
)}
|
||||
</For>
|
||||
<Show when={tabList().length > 1}>
|
||||
<text>
|
||||
<span style={{ fg: theme.text.default }}>
|
||||
<span style={{ fg: themeV2.text.default }}>
|
||||
<b>tabs</b>{" "}
|
||||
</span>
|
||||
<span style={{ fg: theme.text.subdued }}>←/→</span>
|
||||
<span style={{ fg: themeV2.text.subdued }}>←/→</span>
|
||||
</text>
|
||||
</Show>
|
||||
</box>
|
||||
|
||||
@@ -12,7 +12,7 @@ export function ShellTab(props: { sessionID: string }) {
|
||||
const data = useData()
|
||||
const location = useLocation()
|
||||
const client = useClient()
|
||||
const theme = useTheme()
|
||||
const { themeV2 } = useTheme()
|
||||
const composer = useComposerTab()
|
||||
const shortcuts = Keymap.useShortcuts()
|
||||
|
||||
@@ -98,7 +98,7 @@ export function ShellTab(props: { sessionID: string }) {
|
||||
return (
|
||||
<Show when={composer.active("shell")}>
|
||||
<scrollbox scrollbarOptions={{ visible: false }} maxHeight={5} ref={(r: ScrollBoxRenderable) => (scroll = r)}>
|
||||
<Show when={entries().length > 0} fallback={<text fg={theme.text.subdued}> No shell commands</text>}>
|
||||
<Show when={entries().length > 0} fallback={<text fg={themeV2.text.subdued}> No shell commands</text>}>
|
||||
<For each={entries()}>
|
||||
{(shell, index) => {
|
||||
const active = createMemo(() => index() === store.selected)
|
||||
@@ -108,12 +108,12 @@ export function ShellTab(props: { sessionID: string }) {
|
||||
paddingLeft={1}
|
||||
paddingRight={1}
|
||||
backgroundColor={
|
||||
active() ? theme.background.action.primary.focused : theme.background.action.primary.default
|
||||
active() ? themeV2.background.action.primary.focused : themeV2.background.action.primary.default
|
||||
}
|
||||
onMouseOver={() => setStore("selected", index())}
|
||||
>
|
||||
<text
|
||||
fg={active() ? theme.text.action.primary.focused : theme.text.action.primary.default}
|
||||
fg={active() ? themeV2.text.action.primary.focused : themeV2.text.action.primary.default}
|
||||
attributes={active() ? TextAttributes.BOLD : undefined}
|
||||
wrapMode="none"
|
||||
>
|
||||
|
||||
@@ -21,7 +21,7 @@ export function SubagentsTab(props: { sessionID: string }) {
|
||||
const route = useRouteData("session")
|
||||
const data = useData()
|
||||
const client = useClient()
|
||||
const theme = useTheme()
|
||||
const { themeV2 } = useTheme()
|
||||
const navigate = useRoute().navigate
|
||||
const composer = useComposerTab()
|
||||
const shortcuts = Keymap.useShortcuts()
|
||||
@@ -206,7 +206,7 @@ export function SubagentsTab(props: { sessionID: string }) {
|
||||
return (
|
||||
<Show when={composer.active("subagents")}>
|
||||
<scrollbox scrollbarOptions={{ visible: false }} maxHeight={5} ref={(r: ScrollBoxRenderable) => (scroll = r)}>
|
||||
<Show when={entries().length > 0} fallback={<text fg={theme.text.subdued}> No subagents</text>}>
|
||||
<Show when={entries().length > 0} fallback={<text fg={themeV2.text.subdued}> No subagents</text>}>
|
||||
<For each={entries()}>
|
||||
{(entry, index) => {
|
||||
const active = createMemo(() => index() === selected())
|
||||
@@ -221,10 +221,10 @@ export function SubagentsTab(props: { sessionID: string }) {
|
||||
paddingRight={1}
|
||||
backgroundColor={
|
||||
active()
|
||||
? theme.background.action.primary.focused
|
||||
? themeV2.background.action.primary.focused
|
||||
: entry.current
|
||||
? theme.background.action.primary.selected
|
||||
: theme.background.action.primary.default
|
||||
? themeV2.background.action.primary.selected
|
||||
: themeV2.background.action.primary.default
|
||||
}
|
||||
onMouseOver={() => setStore("selected", index())}
|
||||
onMouseUp={() => {
|
||||
@@ -236,10 +236,10 @@ export function SubagentsTab(props: { sessionID: string }) {
|
||||
<text
|
||||
fg={
|
||||
active()
|
||||
? theme.text.action.primary.focused
|
||||
? themeV2.text.action.primary.focused
|
||||
: entry.current
|
||||
? theme.text.action.primary.selected
|
||||
: theme.text.action.primary.default
|
||||
? themeV2.text.action.primary.selected
|
||||
: themeV2.text.action.primary.default
|
||||
}
|
||||
attributes={active() ? TextAttributes.BOLD : undefined}
|
||||
wrapMode="none"
|
||||
@@ -248,7 +248,7 @@ export function SubagentsTab(props: { sessionID: string }) {
|
||||
</text>
|
||||
</box>
|
||||
<Show when={status()}>
|
||||
<text fg={active() ? theme.text.action.primary.focused : theme.text.subdued} wrapMode="none">
|
||||
<text fg={active() ? themeV2.text.action.primary.focused : themeV2.text.subdued} wrapMode="none">
|
||||
{status()}
|
||||
</text>
|
||||
</Show>
|
||||
|
||||
@@ -5,13 +5,11 @@ 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 { themeV2 } = 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,
|
||||
)
|
||||
@@ -54,35 +52,35 @@ export function Footer() {
|
||||
|
||||
return (
|
||||
<box flexDirection="row" justifyContent="space-between" gap={1} flexShrink={0}>
|
||||
<text fg={theme.text.subdued}>{directory()}</text>
|
||||
<text fg={themeV2.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 fg={themeV2.text.default}>
|
||||
Get started <span style={{ fg: themeV2.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
|
||||
<Show when={permissions().length > 0}>
|
||||
<text fg={themeV2.text.feedback.warning.default}>
|
||||
<span style={{ fg: themeV2.text.feedback.warning.default }}>△</span> {permissions().length} Permission
|
||||
{permissions().length > 1 ? "s" : ""}
|
||||
</text>
|
||||
</Show>
|
||||
<Show when={mcp()}>
|
||||
<text fg={theme.text.default}>
|
||||
<text fg={themeV2.text.default}>
|
||||
<Switch>
|
||||
<Match when={mcpError()}>
|
||||
<span style={{ fg: theme.text.feedback.error.default }}>⊙ </span>
|
||||
<span style={{ fg: themeV2.text.feedback.error.default }}>⊙ </span>
|
||||
</Match>
|
||||
<Match when={true}>
|
||||
<span style={{ fg: theme.text.feedback.success.default }}>⊙ </span>
|
||||
<span style={{ fg: themeV2.text.feedback.success.default }}>⊙ </span>
|
||||
</Match>
|
||||
</Switch>
|
||||
{mcp()} MCP
|
||||
</text>
|
||||
</Show>
|
||||
<text fg={theme.text.subdued}>/status</text>
|
||||
<text fg={themeV2.text.subdued}>/status</text>
|
||||
</Match>
|
||||
</Switch>
|
||||
</box>
|
||||
|
||||
@@ -3,7 +3,7 @@ import { createMemo, createSignal, For, onCleanup, onMount, Show } from "solid-j
|
||||
import { useRenderer, useTerminalDimensions } from "@opentui/solid"
|
||||
import type { ScrollBoxRenderable, TextareaRenderable } from "@opentui/core"
|
||||
import open from "open"
|
||||
import { useThemes } from "../../context/theme"
|
||||
import { useTheme } from "../../context/theme"
|
||||
import type { FormField, FormValue } from "@opencode-ai/client"
|
||||
import type { FormWithLocation } from "../../context/data"
|
||||
import { useClient } from "../../context/client"
|
||||
@@ -44,9 +44,7 @@ function requestOptions(form: FormWithLocation) {
|
||||
|
||||
export function FormPrompt(props: { form: FormWithLocation }) {
|
||||
const client = useClient()
|
||||
const themes = useThemes()
|
||||
const theme = themes.contextual("elevated")
|
||||
const themeMode = themes.mode
|
||||
const { themeV2, mode: themeMode } = useTheme().contextual("elevated")
|
||||
const renderer = useRenderer()
|
||||
const dimensions = useTerminalDimensions()
|
||||
const keymap = Keymap.use()
|
||||
@@ -626,27 +624,27 @@ export function FormPrompt(props: { form: FormWithLocation }) {
|
||||
|
||||
return (
|
||||
<box
|
||||
backgroundColor={theme.background.default}
|
||||
backgroundColor={themeV2.background.default}
|
||||
border={["left"]}
|
||||
borderColor={theme.hue.interactive[themeMode() === "light" ? 800 : 200]}
|
||||
borderColor={themeV2.hue.interactive[themeMode() === "light" ? 800 : 200]}
|
||||
customBorderChars={SplitBorder.customBorderChars}
|
||||
>
|
||||
<box gap={1} paddingLeft={1} paddingRight={3} paddingTop={1} paddingBottom={1}>
|
||||
<box paddingLeft={1}>
|
||||
<text fg={theme.text.subdued}>{props.form.title}</text>
|
||||
<text fg={themeV2.text.subdued}>{props.form.title}</text>
|
||||
</box>
|
||||
<Show when={message()}>
|
||||
<box paddingLeft={1}>
|
||||
<text fg={theme.text.default}>{message()}</text>
|
||||
<text fg={themeV2.text.default}>{message()}</text>
|
||||
</box>
|
||||
</Show>
|
||||
<Show when={!single() && !tabbed()}>
|
||||
<box flexDirection="row" gap={1} paddingLeft={1}>
|
||||
<text fg={theme.text.subdued}>
|
||||
<text fg={themeV2.text.subdued}>
|
||||
{confirm() ? "Review" : `Field ${Math.min(store.tab, fields().length - 1) + 1} of ${fields().length}`}
|
||||
</text>
|
||||
<Show when={fields().length > 0}>
|
||||
<text fg={theme.text.subdued}>
|
||||
<text fg={themeV2.text.subdued}>
|
||||
· {answered()}/{fields().length} completed
|
||||
</text>
|
||||
</Show>
|
||||
@@ -663,10 +661,10 @@ export function FormPrompt(props: { form: FormWithLocation }) {
|
||||
paddingRight={2}
|
||||
backgroundColor={
|
||||
isTab()
|
||||
? theme.background.formfield.selected
|
||||
? themeV2.background.formfield.selected
|
||||
: tabHover() === index()
|
||||
? theme.background.formfield.focused
|
||||
: theme.background.default
|
||||
? themeV2.background.formfield.focused
|
||||
: themeV2.background.default
|
||||
}
|
||||
onMouseOver={() => setTabHover(index())}
|
||||
onMouseOut={() => setTabHover(null)}
|
||||
@@ -678,12 +676,12 @@ export function FormPrompt(props: { form: FormWithLocation }) {
|
||||
<text
|
||||
fg={
|
||||
isTab()
|
||||
? theme.text.formfield.selected
|
||||
? themeV2.text.formfield.selected
|
||||
: tabHover() === index()
|
||||
? theme.text.formfield.focused
|
||||
? themeV2.text.formfield.focused
|
||||
: isAnswered()
|
||||
? theme.text.default
|
||||
: theme.text.subdued
|
||||
? themeV2.text.default
|
||||
: themeV2.text.subdued
|
||||
}
|
||||
>
|
||||
{truncate(formLabel(item), 24)}
|
||||
@@ -695,10 +693,10 @@ export function FormPrompt(props: { form: FormWithLocation }) {
|
||||
<box
|
||||
backgroundColor={
|
||||
confirm()
|
||||
? theme.background.formfield.selected
|
||||
? themeV2.background.formfield.selected
|
||||
: tabHover() === "confirm"
|
||||
? theme.background.formfield.focused
|
||||
: theme.background.default
|
||||
? themeV2.background.formfield.focused
|
||||
: themeV2.background.default
|
||||
}
|
||||
onMouseOver={() => setTabHover("confirm")}
|
||||
onMouseOut={() => setTabHover(null)}
|
||||
@@ -707,7 +705,7 @@ export function FormPrompt(props: { form: FormWithLocation }) {
|
||||
selectTabFromMouse()
|
||||
}}
|
||||
>
|
||||
<text fg={confirm() ? theme.text.formfield.selected : theme.text.formfield.default}>Confirm</text>
|
||||
<text fg={confirm() ? themeV2.text.formfield.selected : themeV2.text.formfield.default}>Confirm</text>
|
||||
</box>
|
||||
</box>
|
||||
</Show>
|
||||
@@ -716,13 +714,13 @@ export function FormPrompt(props: { form: FormWithLocation }) {
|
||||
{(external) => (
|
||||
<box paddingLeft={1} gap={1}>
|
||||
<Show when={external().title}>
|
||||
<text fg={theme.text.default}>{external().title}</text>
|
||||
<text fg={themeV2.text.default}>{external().title}</text>
|
||||
</Show>
|
||||
<Show when={external().description}>
|
||||
<text fg={theme.text.subdued}>{external().description}</text>
|
||||
<text fg={themeV2.text.subdued}>{external().description}</text>
|
||||
</Show>
|
||||
<text
|
||||
fg={theme.background.action.primary.default}
|
||||
fg={themeV2.background.action.primary.default}
|
||||
onMouseUp={() => {
|
||||
if (renderer.getSelection()?.getSelectedText()) return
|
||||
openExternal()
|
||||
@@ -731,7 +729,9 @@ export function FormPrompt(props: { form: FormWithLocation }) {
|
||||
{external().url}
|
||||
</text>
|
||||
<text
|
||||
fg={store.answers[external().key] === true ? theme.text.feedback.success.default : theme.text.subdued}
|
||||
fg={
|
||||
store.answers[external().key] === true ? themeV2.text.feedback.success.default : themeV2.text.subdued
|
||||
}
|
||||
>
|
||||
{store.answers[external().key] === true
|
||||
? "✓ Acknowledged"
|
||||
@@ -746,7 +746,7 @@ export function FormPrompt(props: { form: FormWithLocation }) {
|
||||
<Show when={!confirm() && answerField()}>
|
||||
<box paddingLeft={1} gap={1}>
|
||||
<box>
|
||||
<text fg={theme.text.default}>{answerField()!.description ?? formLabel(answerField()!)}</text>
|
||||
<text fg={themeV2.text.default}>{answerField()!.description ?? formLabel(answerField()!)}</text>
|
||||
</box>
|
||||
<Show when={textual() ? answerField()!.key : undefined} keyed>
|
||||
<box paddingLeft={1}>
|
||||
@@ -763,12 +763,12 @@ export function FormPrompt(props: { form: FormWithLocation }) {
|
||||
input() || formDisplayValue(answerField()!, store.answers[answerField()!.key], "(none)")
|
||||
}
|
||||
placeholder={placeholder()}
|
||||
placeholderColor={theme.text.subdued}
|
||||
placeholderColor={themeV2.text.subdued}
|
||||
minHeight={1}
|
||||
maxHeight={6}
|
||||
textColor={theme.text.default}
|
||||
focusedTextColor={theme.text.default}
|
||||
cursorColor={theme.text.default}
|
||||
textColor={themeV2.text.default}
|
||||
focusedTextColor={themeV2.text.default}
|
||||
cursorColor={themeV2.text.default}
|
||||
/>
|
||||
</box>
|
||||
</Show>
|
||||
@@ -793,35 +793,39 @@ export function FormPrompt(props: { form: FormWithLocation }) {
|
||||
>
|
||||
<box flexDirection="row">
|
||||
<box
|
||||
backgroundColor={active() ? theme.background.formfield.focused : theme.background.default}
|
||||
backgroundColor={
|
||||
active() ? themeV2.background.formfield.focused : themeV2.background.default
|
||||
}
|
||||
paddingRight={1}
|
||||
>
|
||||
<text
|
||||
fg={active() ? theme.text.formfield.focused : theme.text.formfield.default}
|
||||
fg={active() ? themeV2.text.formfield.focused : themeV2.text.formfield.default}
|
||||
>{`${i() + 1}.`}</text>
|
||||
</box>
|
||||
<box
|
||||
backgroundColor={active() ? theme.background.formfield.focused : theme.background.default}
|
||||
backgroundColor={
|
||||
active() ? themeV2.background.formfield.focused : themeV2.background.default
|
||||
}
|
||||
>
|
||||
<text
|
||||
fg={
|
||||
active()
|
||||
? theme.text.formfield.focused
|
||||
? themeV2.text.formfield.focused
|
||||
: picked()
|
||||
? theme.text.formfield.selected
|
||||
: theme.text.formfield.default
|
||||
? themeV2.text.formfield.selected
|
||||
: themeV2.text.formfield.default
|
||||
}
|
||||
>
|
||||
{multi() ? `[${picked() ? "✓" : " "}] ${row.label}` : row.label}
|
||||
</text>
|
||||
</box>
|
||||
<Show when={!multi()}>
|
||||
<text fg={theme.text.formfield.selected}>{picked() ? " ✓" : ""}</text>
|
||||
<text fg={themeV2.text.formfield.selected}>{picked() ? " ✓" : ""}</text>
|
||||
</Show>
|
||||
</box>
|
||||
<Show when={row.description}>
|
||||
<box paddingLeft={3}>
|
||||
<text fg={theme.text.subdued}>{row.description}</text>
|
||||
<text fg={themeV2.text.subdued}>{row.description}</text>
|
||||
</box>
|
||||
</Show>
|
||||
</box>
|
||||
@@ -839,28 +843,30 @@ export function FormPrompt(props: { form: FormWithLocation }) {
|
||||
>
|
||||
<box flexDirection="row">
|
||||
<box
|
||||
backgroundColor={other() ? theme.background.formfield.focused : theme.background.default}
|
||||
backgroundColor={other() ? themeV2.background.formfield.focused : themeV2.background.default}
|
||||
paddingRight={1}
|
||||
>
|
||||
<text fg={other() ? theme.text.formfield.focused : theme.text.formfield.default}>
|
||||
<text fg={other() ? themeV2.text.formfield.focused : themeV2.text.formfield.default}>
|
||||
{`${rows().length + 1}.`}
|
||||
</text>
|
||||
</box>
|
||||
<box backgroundColor={other() ? theme.background.formfield.focused : theme.background.default}>
|
||||
<box
|
||||
backgroundColor={other() ? themeV2.background.formfield.focused : themeV2.background.default}
|
||||
>
|
||||
<text
|
||||
fg={
|
||||
other()
|
||||
? theme.text.formfield.focused
|
||||
? themeV2.text.formfield.focused
|
||||
: customPicked()
|
||||
? theme.text.feedback.success.default
|
||||
: theme.text.default
|
||||
? themeV2.text.feedback.success.default
|
||||
: themeV2.text.default
|
||||
}
|
||||
>
|
||||
{multi() ? `[${customPicked() ? "✓" : " "}] Type your own answer` : "Type your own answer"}
|
||||
</text>
|
||||
</box>
|
||||
<Show when={!multi()}>
|
||||
<text fg={theme.text.feedback.success.default}>{customPicked() ? " ✓" : ""}</text>
|
||||
<text fg={themeV2.text.feedback.success.default}>{customPicked() ? " ✓" : ""}</text>
|
||||
</Show>
|
||||
</box>
|
||||
<Show when={store.editing}>
|
||||
@@ -876,18 +882,18 @@ export function FormPrompt(props: { form: FormWithLocation }) {
|
||||
}}
|
||||
initialValue={input()}
|
||||
placeholder="Type your own answer"
|
||||
placeholderColor={theme.text.subdued}
|
||||
placeholderColor={themeV2.text.subdued}
|
||||
minHeight={1}
|
||||
maxHeight={6}
|
||||
textColor={theme.text.default}
|
||||
focusedTextColor={theme.text.default}
|
||||
cursorColor={theme.text.default}
|
||||
textColor={themeV2.text.default}
|
||||
focusedTextColor={themeV2.text.default}
|
||||
cursorColor={themeV2.text.default}
|
||||
/>
|
||||
</box>
|
||||
</Show>
|
||||
<Show when={!store.editing && input()}>
|
||||
<box paddingLeft={3}>
|
||||
<text fg={theme.text.subdued}>{input()}</text>
|
||||
<text fg={themeV2.text.subdued}>{input()}</text>
|
||||
</box>
|
||||
</Show>
|
||||
</box>
|
||||
@@ -900,7 +906,7 @@ export function FormPrompt(props: { form: FormWithLocation }) {
|
||||
<Show when={confirm()}>
|
||||
<Show when={tabbed()}>
|
||||
<box paddingLeft={1}>
|
||||
<text fg={theme.text.default}>Review</text>
|
||||
<text fg={themeV2.text.default}>Review</text>
|
||||
</box>
|
||||
</Show>
|
||||
<scrollbox
|
||||
@@ -915,12 +921,12 @@ export function FormPrompt(props: { form: FormWithLocation }) {
|
||||
return (
|
||||
<box paddingLeft={1}>
|
||||
<text>
|
||||
<span style={{ fg: theme.text.subdued }}>{truncate(formLabel(item), 40)}:</span>{" "}
|
||||
<span style={{ fg: themeV2.text.subdued }}>{truncate(formLabel(item), 40)}:</span>{" "}
|
||||
<span
|
||||
style={{
|
||||
fg: acknowledged()
|
||||
? theme.text.feedback.success.default
|
||||
: theme.text.feedback.error.default,
|
||||
? themeV2.text.feedback.success.default
|
||||
: themeV2.text.feedback.error.default,
|
||||
}}
|
||||
>
|
||||
{acknowledged() ? "Acknowledged" : "(acknowledgement required)"}
|
||||
@@ -936,15 +942,15 @@ export function FormPrompt(props: { form: FormWithLocation }) {
|
||||
return (
|
||||
<box paddingLeft={1}>
|
||||
<text>
|
||||
<span style={{ fg: theme.text.subdued }}>{truncate(formLabel(item), 40)}:</span>{" "}
|
||||
<span style={{ fg: themeV2.text.subdued }}>{truncate(formLabel(item), 40)}:</span>{" "}
|
||||
<span
|
||||
style={{
|
||||
fg:
|
||||
invalid() || missing()
|
||||
? theme.text.feedback.error.default
|
||||
? themeV2.text.feedback.error.default
|
||||
: answered()
|
||||
? theme.text.default
|
||||
: theme.text.subdued,
|
||||
? themeV2.text.default
|
||||
: themeV2.text.subdued,
|
||||
}}
|
||||
>
|
||||
{invalid() ?? (answered() ? value() : missing() ? "(required)" : "(not answered)")}
|
||||
@@ -968,41 +974,41 @@ export function FormPrompt(props: { form: FormWithLocation }) {
|
||||
>
|
||||
<box flexDirection="row" gap={2}>
|
||||
<Show when={!single()}>
|
||||
<text fg={theme.text.default}>
|
||||
{"⇆"} <span style={{ fg: theme.text.subdued }}>tab</span>
|
||||
<text fg={themeV2.text.default}>
|
||||
{"⇆"} <span style={{ fg: themeV2.text.subdued }}>tab</span>
|
||||
</text>
|
||||
</Show>
|
||||
<Show when={!confirm() && !textual() && !externalField()}>
|
||||
<text fg={theme.text.default}>
|
||||
{"↑↓"} <span style={{ fg: theme.text.subdued }}>select</span>
|
||||
<text fg={themeV2.text.default}>
|
||||
{"↑↓"} <span style={{ fg: themeV2.text.subdued }}>select</span>
|
||||
</text>
|
||||
</Show>
|
||||
<Show when={confirm() && fields().length > 0}>
|
||||
<text fg={theme.text.default}>
|
||||
{"↑↓"} <span style={{ fg: theme.text.subdued }}>scroll</span>
|
||||
<text fg={themeV2.text.default}>
|
||||
{"↑↓"} <span style={{ fg: themeV2.text.subdued }}>scroll</span>
|
||||
</text>
|
||||
</Show>
|
||||
<text
|
||||
fg={theme.text.default}
|
||||
fg={themeV2.text.default}
|
||||
onMouseUp={() => {
|
||||
if (renderer.getSelection()?.getSelectedText()) return
|
||||
if (confirm()) submit()
|
||||
if (externalField()) acknowledgeExternal()
|
||||
}}
|
||||
>
|
||||
enter <span style={{ fg: theme.text.subdued }}>{actionLabel()}</span>
|
||||
enter <span style={{ fg: themeV2.text.subdued }}>{actionLabel()}</span>
|
||||
</text>
|
||||
<Show when={externalField() && clipboard.write}>
|
||||
<text fg={theme.text.default} onMouseUp={copyExternal}>
|
||||
c <span style={{ fg: theme.text.subdued }}>copy</span>
|
||||
<text fg={themeV2.text.default} onMouseUp={copyExternal}>
|
||||
c <span style={{ fg: themeV2.text.subdued }}>copy</span>
|
||||
</text>
|
||||
</Show>
|
||||
<text fg={theme.text.default} onMouseUp={cancel}>
|
||||
esc <span style={{ fg: theme.text.subdued }}>dismiss</span>
|
||||
<text fg={themeV2.text.default} onMouseUp={cancel}>
|
||||
esc <span style={{ fg: themeV2.text.subdued }}>dismiss</span>
|
||||
</text>
|
||||
</box>
|
||||
<Show when={store.error}>
|
||||
<text fg={theme.text.feedback.error.default}>{store.error}</text>
|
||||
<text fg={themeV2.text.feedback.error.default}>{store.error}</text>
|
||||
</Show>
|
||||
</box>
|
||||
</box>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -2,7 +2,7 @@ import { createStore } from "solid-js/store"
|
||||
import { createMemo, For, Match, Show, Switch } from "solid-js"
|
||||
import { Portal, useRenderer, useTerminalDimensions, type JSX } from "@opentui/solid"
|
||||
import type { TextareaRenderable } from "@opentui/core"
|
||||
import { useTheme, useThemes } from "../../context/theme"
|
||||
import { useTheme } from "../../context/theme"
|
||||
import type { PermissionRequest } from "@opencode-ai/client"
|
||||
import { useClient } from "../../context/client"
|
||||
import { SplitBorder } from "../../ui/border"
|
||||
@@ -18,9 +18,9 @@ import { SimulationSemantics } from "../../simulation/semantics"
|
||||
type PermissionStage = "permission" | "always" | "reject"
|
||||
|
||||
function EditBody(props: { file?: string; diff?: string; patch?: string }) {
|
||||
const theme = useTheme()
|
||||
const themes = useThemes()
|
||||
const syntax = themes.currentSyntax
|
||||
const themeState = useTheme()
|
||||
const themeV2 = themeState.themeV2
|
||||
const syntax = themeState.syntax
|
||||
const config = useConfig().data
|
||||
const dimensions = useTerminalDimensions()
|
||||
|
||||
@@ -45,8 +45,8 @@ function EditBody(props: { file?: string; diff?: string; patch?: string }) {
|
||||
scrollAcceleration={scrollAcceleration()}
|
||||
verticalScrollbarOptions={{
|
||||
trackOptions: {
|
||||
backgroundColor: theme.background.default,
|
||||
foregroundColor: theme.scrollbar.default,
|
||||
backgroundColor: themeV2.background.default,
|
||||
foregroundColor: themeV2.scrollbar.default,
|
||||
},
|
||||
}}
|
||||
>
|
||||
@@ -58,16 +58,16 @@ function EditBody(props: { file?: string; diff?: string; patch?: string }) {
|
||||
showLineNumbers={true}
|
||||
width="100%"
|
||||
wrapMode="word"
|
||||
fg={theme.text.default}
|
||||
addedBg={theme.diff.background.added}
|
||||
removedBg={theme.diff.background.removed}
|
||||
contextBg={theme.diff.background.context}
|
||||
addedSignColor={theme.diff.highlight.added}
|
||||
removedSignColor={theme.diff.highlight.removed}
|
||||
lineNumberFg={theme.diff.lineNumber.text}
|
||||
lineNumberBg={theme.diff.background.context}
|
||||
addedLineNumberBg={theme.diff.lineNumber.background.added}
|
||||
removedLineNumberBg={theme.diff.lineNumber.background.removed}
|
||||
fg={themeV2.text.default}
|
||||
addedBg={themeV2.diff.background.added}
|
||||
removedBg={themeV2.diff.background.removed}
|
||||
contextBg={themeV2.diff.background.context}
|
||||
addedSignColor={themeV2.diff.highlight.added}
|
||||
removedSignColor={themeV2.diff.highlight.removed}
|
||||
lineNumberFg={themeV2.diff.lineNumber.text}
|
||||
lineNumberBg={themeV2.diff.background.context}
|
||||
addedLineNumberBg={themeV2.diff.lineNumber.background.added}
|
||||
removedLineNumberBg={themeV2.diff.lineNumber.background.removed}
|
||||
/>
|
||||
</scrollbox>
|
||||
</Show>
|
||||
@@ -76,7 +76,7 @@ function EditBody(props: { file?: string; diff?: string; patch?: string }) {
|
||||
when={props.patch}
|
||||
fallback={
|
||||
<box paddingLeft={1}>
|
||||
<text fg={theme.text.subdued}>No diff provided</text>
|
||||
<text fg={themeV2.text.subdued}>No diff provided</text>
|
||||
</box>
|
||||
}
|
||||
>
|
||||
@@ -86,8 +86,8 @@ function EditBody(props: { file?: string; diff?: string; patch?: string }) {
|
||||
scrollAcceleration={scrollAcceleration()}
|
||||
verticalScrollbarOptions={{
|
||||
trackOptions: {
|
||||
backgroundColor: theme.background.default,
|
||||
foregroundColor: theme.scrollbar.default,
|
||||
backgroundColor: themeV2.background.default,
|
||||
foregroundColor: themeV2.scrollbar.default,
|
||||
},
|
||||
}}
|
||||
>
|
||||
@@ -97,7 +97,7 @@ function EditBody(props: { file?: string; diff?: string; patch?: string }) {
|
||||
streaming={true}
|
||||
syntaxStyle={syntax()}
|
||||
content={patch()}
|
||||
fg={theme.text.subdued}
|
||||
fg={themeV2.text.subdued}
|
||||
/>
|
||||
</scrollbox>
|
||||
)}
|
||||
@@ -128,7 +128,7 @@ export function PermissionPrompt(props: { request: PermissionRequest; directory?
|
||||
return { input: undefined, metadata: undefined }
|
||||
})
|
||||
|
||||
const theme = useTheme()
|
||||
const { themeV2 } = useTheme()
|
||||
|
||||
return (
|
||||
<Switch>
|
||||
@@ -140,7 +140,7 @@ export function PermissionPrompt(props: { request: PermissionRequest; directory?
|
||||
body={
|
||||
<box paddingLeft={1} gap={1}>
|
||||
<For each={permissionAlwaysLines(props.request)}>
|
||||
{(line, index) => <text fg={index() === 0 ? theme.text.subdued : theme.text.default}>{line}</text>}
|
||||
{(line, index) => <text fg={index() === 0 ? themeV2.text.subdued : themeV2.text.default}>{line}</text>}
|
||||
</For>
|
||||
</box>
|
||||
}
|
||||
@@ -192,9 +192,9 @@ export function PermissionPrompt(props: { request: PermissionRequest; directory?
|
||||
) : props.request.action === "external_directory" ? (
|
||||
<Show when={current.lines.length > 0}>
|
||||
<box paddingLeft={1} gap={1}>
|
||||
<text fg={theme.text.subdued}>Patterns</text>
|
||||
<text fg={themeV2.text.subdued}>Patterns</text>
|
||||
<box>
|
||||
<For each={current.lines}>{(line) => <text fg={theme.text.default}>{line}</text>}</For>
|
||||
<For each={current.lines}>{(line) => <text fg={themeV2.text.default}>{line}</text>}</For>
|
||||
</box>
|
||||
</box>
|
||||
</Show>
|
||||
@@ -207,8 +207,8 @@ export function PermissionPrompt(props: { request: PermissionRequest; directory?
|
||||
props.request.action === "shell" ||
|
||||
props.request.action === "subagent" ||
|
||||
props.request.action === "task"
|
||||
? theme.text.default
|
||||
: theme.text.subdued
|
||||
? themeV2.text.default
|
||||
: themeV2.text.subdued
|
||||
}
|
||||
>
|
||||
{line}
|
||||
@@ -221,15 +221,15 @@ export function PermissionPrompt(props: { request: PermissionRequest; directory?
|
||||
const header = () => (
|
||||
<box flexDirection="column" gap={0}>
|
||||
<box flexDirection="row" gap={1} flexShrink={0}>
|
||||
<text fg={theme.text.feedback.warning.default}>{"△"}</text>
|
||||
<text fg={theme.text.default}>Permission required</text>
|
||||
<text fg={themeV2.text.feedback.warning.default}>{"△"}</text>
|
||||
<text fg={themeV2.text.default}>Permission required</text>
|
||||
</box>
|
||||
<Show when={props.request.action !== "shell" && current.title}>
|
||||
<box flexDirection="row" gap={1} paddingLeft={2} flexShrink={0}>
|
||||
<text fg={theme.text.subdued} flexShrink={0}>
|
||||
<text fg={themeV2.text.subdued} flexShrink={0}>
|
||||
{current.icon}
|
||||
</text>
|
||||
<text fg={theme.text.default}>{current.title}</text>
|
||||
<text fg={themeV2.text.default}>{current.title}</text>
|
||||
</box>
|
||||
</Show>
|
||||
</box>
|
||||
@@ -297,7 +297,7 @@ function RejectPrompt(props: {
|
||||
onCancel: () => void
|
||||
}) {
|
||||
let input: TextareaRenderable
|
||||
const theme = useThemes().contextual("elevated")
|
||||
const { themeV2 } = useTheme().contextual("elevated")
|
||||
const dimensions = useTerminalDimensions()
|
||||
const narrow = createMemo(() => dimensions().width < 80)
|
||||
Keymap.createLayer(() => ({
|
||||
@@ -329,18 +329,18 @@ function RejectPrompt(props: {
|
||||
role: "dialog",
|
||||
label: `Reject permission: ${props.action}`,
|
||||
}))}
|
||||
backgroundColor={theme.background.default}
|
||||
backgroundColor={themeV2.background.default}
|
||||
border={["left"]}
|
||||
borderColor={theme.text.feedback.error.default}
|
||||
borderColor={themeV2.text.feedback.error.default}
|
||||
customBorderChars={SplitBorder.customBorderChars}
|
||||
>
|
||||
<box gap={1} paddingLeft={1} paddingRight={3} paddingTop={1} paddingBottom={1}>
|
||||
<box flexDirection="row" gap={1} paddingLeft={1}>
|
||||
<text fg={theme.text.feedback.error.default}>{"△"}</text>
|
||||
<text fg={theme.text.default}>Reject permission</text>
|
||||
<text fg={themeV2.text.feedback.error.default}>{"△"}</text>
|
||||
<text fg={themeV2.text.default}>Reject permission</text>
|
||||
</box>
|
||||
<box paddingLeft={1}>
|
||||
<text fg={theme.text.subdued}>Tell OpenCode what to do differently</text>
|
||||
<text fg={themeV2.text.subdued}>Tell OpenCode what to do differently</text>
|
||||
</box>
|
||||
</box>
|
||||
<box
|
||||
@@ -350,7 +350,7 @@ function RejectPrompt(props: {
|
||||
paddingLeft={2}
|
||||
paddingRight={3}
|
||||
paddingBottom={1}
|
||||
backgroundColor={theme.raise(theme.background.default)}
|
||||
backgroundColor={themeV2.raise(themeV2.background.default)}
|
||||
justifyContent={narrow() ? "flex-start" : "space-between"}
|
||||
alignItems={narrow() ? "flex-start" : "center"}
|
||||
gap={1}
|
||||
@@ -369,9 +369,9 @@ function RejectPrompt(props: {
|
||||
val.traits = { status: "REJECT" }
|
||||
}}
|
||||
focused
|
||||
textColor={theme.text.default}
|
||||
focusedTextColor={theme.text.default}
|
||||
cursorColor={theme.text.default}
|
||||
textColor={themeV2.text.default}
|
||||
focusedTextColor={themeV2.text.default}
|
||||
cursorColor={themeV2.text.default}
|
||||
/>
|
||||
<box
|
||||
id="session.permission.reject.actions"
|
||||
@@ -394,8 +394,8 @@ function RejectPrompt(props: {
|
||||
}))}
|
||||
onMouseUp={() => props.onConfirm(input.plainText)}
|
||||
>
|
||||
<text fg={theme.text.default}>
|
||||
enter <span style={{ fg: theme.text.subdued }}>confirm</span>
|
||||
<text fg={themeV2.text.default}>
|
||||
enter <span style={{ fg: themeV2.text.subdued }}>confirm</span>
|
||||
</text>
|
||||
</box>
|
||||
<box
|
||||
@@ -408,8 +408,8 @@ function RejectPrompt(props: {
|
||||
}))}
|
||||
onMouseUp={props.onCancel}
|
||||
>
|
||||
<text fg={theme.text.default}>
|
||||
esc <span style={{ fg: theme.text.subdued }}>cancel</span>
|
||||
<text fg={themeV2.text.default}>
|
||||
esc <span style={{ fg: themeV2.text.subdued }}>cancel</span>
|
||||
</text>
|
||||
</box>
|
||||
</box>
|
||||
@@ -429,7 +429,7 @@ function Prompt<const T extends Record<string, string>>(props: {
|
||||
fullscreen?: boolean
|
||||
onSelect: (option: keyof T) => void
|
||||
}) {
|
||||
const theme = useThemes().contextual("elevated")
|
||||
const { themeV2 } = useTheme().contextual("elevated")
|
||||
const dimensions = useTerminalDimensions()
|
||||
const keys = Object.keys(props.options) as (keyof T)[]
|
||||
const [store, setStore] = createStore({
|
||||
@@ -534,9 +534,9 @@ function Prompt<const T extends Record<string, string>>(props: {
|
||||
label: props.semanticLabel ?? props.title,
|
||||
expanded: store.expanded,
|
||||
}))}
|
||||
backgroundColor={theme.background.default}
|
||||
backgroundColor={themeV2.background.default}
|
||||
border={["left"]}
|
||||
borderColor={theme.background.action.primary.focused}
|
||||
borderColor={themeV2.background.action.primary.focused}
|
||||
customBorderChars={SplitBorder.customBorderChars}
|
||||
{...(store.expanded
|
||||
? { top: dimensions().height * -1 + 1, bottom: 1, left: 2, right: 2, position: "absolute" }
|
||||
@@ -554,8 +554,8 @@ function Prompt<const T extends Record<string, string>>(props: {
|
||||
when={props.header}
|
||||
fallback={
|
||||
<box flexDirection="row" gap={1} paddingLeft={1} flexShrink={0}>
|
||||
<text fg={theme.text.feedback.warning.default}>{"△"}</text>
|
||||
<text fg={theme.text.default}>{props.title}</text>
|
||||
<text fg={themeV2.text.feedback.warning.default}>{"△"}</text>
|
||||
<text fg={themeV2.text.default}>{props.title}</text>
|
||||
</box>
|
||||
}
|
||||
>
|
||||
@@ -573,7 +573,7 @@ function Prompt<const T extends Record<string, string>>(props: {
|
||||
paddingLeft={2}
|
||||
paddingRight={3}
|
||||
paddingBottom={1}
|
||||
backgroundColor={theme.raise(theme.background.default)}
|
||||
backgroundColor={themeV2.raise(themeV2.background.default)}
|
||||
justifyContent={narrow() ? "flex-start" : "space-between"}
|
||||
alignItems={narrow() ? "flex-start" : "center"}
|
||||
>
|
||||
@@ -604,8 +604,8 @@ function Prompt<const T extends Record<string, string>>(props: {
|
||||
paddingRight={1}
|
||||
backgroundColor={
|
||||
option === store.selected
|
||||
? theme.background.action.primary.focused
|
||||
: theme.background.action.primary.default
|
||||
? themeV2.background.action.primary.focused
|
||||
: themeV2.background.action.primary.default
|
||||
}
|
||||
onMouseOver={() => setStore("selected", option)}
|
||||
onMouseUp={() => {
|
||||
@@ -614,7 +614,11 @@ function Prompt<const T extends Record<string, string>>(props: {
|
||||
}}
|
||||
>
|
||||
<text
|
||||
fg={option === store.selected ? theme.text.action.primary.focused : theme.text.action.primary.default}
|
||||
fg={
|
||||
option === store.selected
|
||||
? themeV2.text.action.primary.focused
|
||||
: themeV2.text.action.primary.default
|
||||
}
|
||||
>
|
||||
{props.options[option]}
|
||||
</text>
|
||||
@@ -624,15 +628,15 @@ function Prompt<const T extends Record<string, string>>(props: {
|
||||
</box>
|
||||
<box flexDirection="row" gap={2} flexShrink={0}>
|
||||
<Show when={props.fullscreen}>
|
||||
<text fg={theme.text.default}>
|
||||
{shortcuts.get("permission.prompt.fullscreen")} <span style={{ fg: theme.text.subdued }}>{hint()}</span>
|
||||
<text fg={themeV2.text.default}>
|
||||
{shortcuts.get("permission.prompt.fullscreen")} <span style={{ fg: themeV2.text.subdued }}>{hint()}</span>
|
||||
</text>
|
||||
</Show>
|
||||
<text fg={theme.text.default}>
|
||||
{"⇆"} <span style={{ fg: theme.text.subdued }}>select</span>
|
||||
<text fg={themeV2.text.default}>
|
||||
{"⇆"} <span style={{ fg: themeV2.text.subdued }}>select</span>
|
||||
</text>
|
||||
<text fg={theme.text.default}>
|
||||
enter <span style={{ fg: theme.text.subdued }}>confirm</span>
|
||||
<text fg={themeV2.text.default}>
|
||||
enter <span style={{ fg: themeV2.text.subdued }}>confirm</span>
|
||||
</text>
|
||||
</box>
|
||||
</box>
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
import { useData } from "../../context/data"
|
||||
import { createMemo, Show } from "solid-js"
|
||||
import { useThemes } from "../../context/theme"
|
||||
import { useTheme } from "../../context/theme"
|
||||
import { useConfig } from "../../config"
|
||||
import { usePluginRuntime } from "../../plugin/runtime"
|
||||
import { PluginSlot } from "../../plugin/context"
|
||||
|
||||
import { getScrollAcceleration } from "../../util/scroll"
|
||||
|
||||
export function Sidebar(props: { sessionID: string; overlay?: boolean }) {
|
||||
const pluginRuntime = usePluginRuntime()
|
||||
const data = useData()
|
||||
const theme = useThemes().contextual("elevated")
|
||||
const { themeV2 } = useTheme().contextual("elevated")
|
||||
const config = useConfig().data
|
||||
const session = createMemo(() => data.session.get(props.sessionID))
|
||||
const scrollAcceleration = createMemo(() => getScrollAcceleration(config))
|
||||
@@ -16,7 +18,7 @@ export function Sidebar(props: { sessionID: string; overlay?: boolean }) {
|
||||
return (
|
||||
<Show when={session()}>
|
||||
<box
|
||||
backgroundColor={theme.background.default}
|
||||
backgroundColor={themeV2.background.default}
|
||||
width={42}
|
||||
height="100%"
|
||||
paddingTop={1}
|
||||
@@ -30,26 +32,33 @@ export function Sidebar(props: { sessionID: string; overlay?: boolean }) {
|
||||
scrollAcceleration={scrollAcceleration()}
|
||||
verticalScrollbarOptions={{
|
||||
trackOptions: {
|
||||
backgroundColor: theme.background.default,
|
||||
foregroundColor: theme.scrollbar.default,
|
||||
backgroundColor: themeV2.background.default,
|
||||
foregroundColor: themeV2.scrollbar.default,
|
||||
},
|
||||
}}
|
||||
>
|
||||
<box flexShrink={0} gap={1} paddingRight={1}>
|
||||
<box paddingRight={1}>
|
||||
<text fg={theme.text.default}>
|
||||
<b>{session()!.title}</b>
|
||||
</text>
|
||||
<Show when={session()!.location.workspaceID}>
|
||||
<text fg={theme.text.subdued}>{session()!.location.workspaceID}</text>
|
||||
</Show>
|
||||
</box>
|
||||
<PluginSlot name="sidebar.content" input={{ sessionID: props.sessionID }} mode="all" />
|
||||
<pluginRuntime.Slot
|
||||
name="sidebar_title"
|
||||
mode="single_winner"
|
||||
session_id={props.sessionID}
|
||||
title={session()!.title}
|
||||
>
|
||||
<box paddingRight={1}>
|
||||
<text fg={themeV2.text.default}>
|
||||
<b>{session()!.title}</b>
|
||||
</text>
|
||||
<Show when={session()!.location.workspaceID}>
|
||||
<text fg={themeV2.text.subdued}>{session()!.location.workspaceID}</text>
|
||||
</Show>
|
||||
</box>
|
||||
</pluginRuntime.Slot>
|
||||
<PluginSlot name="sidebar.content" input={{ sessionID: props.sessionID }} />
|
||||
</box>
|
||||
</scrollbox>
|
||||
|
||||
<box flexShrink={0} gap={1} paddingTop={1}>
|
||||
<PluginSlot name="sidebar.footer" input={{}} mode="replace" />
|
||||
<PluginSlot name="sidebar.footer" />
|
||||
</box>
|
||||
</box>
|
||||
</Show>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { createMemo, createSignal, Show } from "solid-js"
|
||||
import { useRouteData } from "../../context/route"
|
||||
import { useData } from "../../context/data"
|
||||
import { useThemes } from "../../context/theme"
|
||||
import { useTheme } from "../../context/theme"
|
||||
import { SplitBorder } from "../../ui/border"
|
||||
import { Locale } from "../../util/locale"
|
||||
import { useTerminalDimensions } from "@opentui/solid"
|
||||
@@ -42,7 +42,7 @@ export function SubagentFooter() {
|
||||
}
|
||||
})
|
||||
|
||||
const theme = useThemes().contextual("elevated")
|
||||
const { themeV2 } = useTheme().contextual("elevated")
|
||||
const keymap = Keymap.use()
|
||||
const shortcuts = Keymap.useShortcuts()
|
||||
const [hover, setHover] = createSignal<"parent" | "prev" | "next" | null>(null)
|
||||
@@ -57,18 +57,18 @@ export function SubagentFooter() {
|
||||
paddingRight={1}
|
||||
{...SplitBorder}
|
||||
border={["left"]}
|
||||
borderColor={theme.border.default}
|
||||
borderColor={themeV2.border.default}
|
||||
flexShrink={0}
|
||||
backgroundColor={theme.background.default}
|
||||
backgroundColor={themeV2.background.default}
|
||||
>
|
||||
<box flexDirection="row" justifyContent="space-between" gap={1}>
|
||||
<box flexDirection="row" gap={1}>
|
||||
<text fg={theme.text.default}>
|
||||
<text fg={themeV2.text.default}>
|
||||
<b>{subagentInfo()}</b>
|
||||
</text>
|
||||
<Show when={usage()}>
|
||||
{(item) => (
|
||||
<text fg={theme.text.subdued} wrapMode="none">
|
||||
<text fg={themeV2.text.subdued} wrapMode="none">
|
||||
{[item().context, item().cost].filter(Boolean).join(" · ")}
|
||||
</text>
|
||||
)}
|
||||
@@ -80,31 +80,35 @@ export function SubagentFooter() {
|
||||
onMouseOut={() => setHover(null)}
|
||||
onMouseUp={() => keymap.dispatch("session.parent")}
|
||||
backgroundColor={
|
||||
hover() === "parent" ? theme.background.action.primary.hovered : theme.background.default
|
||||
hover() === "parent" ? themeV2.background.action.primary.hovered : themeV2.background.default
|
||||
}
|
||||
>
|
||||
<text fg={theme.text.default}>
|
||||
Parent <span style={{ fg: theme.text.subdued }}>{shortcuts.get("session.parent")}</span>
|
||||
<text fg={themeV2.text.default}>
|
||||
Parent <span style={{ fg: themeV2.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}
|
||||
backgroundColor={
|
||||
hover() === "prev" ? themeV2.background.action.primary.hovered : themeV2.background.default
|
||||
}
|
||||
>
|
||||
<text fg={theme.text.default}>
|
||||
Prev <span style={{ fg: theme.text.subdued }}>{shortcuts.get("session.child.previous")}</span>
|
||||
<text fg={themeV2.text.default}>
|
||||
Prev <span style={{ fg: themeV2.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}
|
||||
backgroundColor={
|
||||
hover() === "next" ? themeV2.background.action.primary.hovered : themeV2.background.default
|
||||
}
|
||||
>
|
||||
<text fg={theme.text.default}>
|
||||
Next <span style={{ fg: theme.text.subdued }}>{shortcuts.get("session.child.next")}</span>
|
||||
<text fg={themeV2.text.default}>
|
||||
Next <span style={{ fg: themeV2.text.subdued }}>{shortcuts.get("session.child.next")}</span>
|
||||
</text>
|
||||
</box>
|
||||
</box>
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { Schema } from "effect"
|
||||
import { migrateV1, resolveThemeDocument, ThemeDocument, themeDecodeError } from "@opencode-ai/theme/tui"
|
||||
import { resolveThemeColors } from "./resolve"
|
||||
import { DEFAULT_THEMES, type Theme, type ThemeV1Json } from "./v1"
|
||||
import { resolveThemeDocument, themeDecodeError } from "./v2/resolve"
|
||||
import { ThemeDocument } from "./v2/schema"
|
||||
import { migrateV1 } from "./v2/v1-migrate"
|
||||
|
||||
export { DEFAULT_THEMES, generateSyntax, selectedForeground, type Theme, type ThemeV1Json } from "./v1"
|
||||
export { resolveThemeDocument, type ThemeDocument }
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { RGBA, SyntaxStyle } from "@opentui/core"
|
||||
import type { Theme, ThemeV1Json } from "@opencode-ai/theme/tui/v1"
|
||||
import aura from "./assets/aura.json" with { type: "json" }
|
||||
import ayu from "./assets/ayu.json" with { type: "json" }
|
||||
import carbonfox from "./assets/carbonfox.json" with { type: "json" }
|
||||
@@ -34,7 +33,80 @@ import vercel from "./assets/vercel.json" with { type: "json" }
|
||||
import vesper from "./assets/vesper.json" with { type: "json" }
|
||||
import zenburn from "./assets/zenburn.json" with { type: "json" }
|
||||
|
||||
export type { ColorValue, HexColor, RefName, Theme, ThemeColor, ThemeV1Json, Variant } from "@opencode-ai/theme/tui/v1"
|
||||
export type Theme = {
|
||||
readonly primary: RGBA
|
||||
readonly secondary: RGBA
|
||||
readonly accent: RGBA
|
||||
readonly error: RGBA
|
||||
readonly warning: RGBA
|
||||
readonly success: RGBA
|
||||
readonly info: RGBA
|
||||
readonly text: RGBA
|
||||
readonly textMuted: RGBA
|
||||
readonly selectedListItemText: RGBA
|
||||
readonly background: RGBA
|
||||
readonly backgroundPanel: RGBA
|
||||
readonly backgroundElement: RGBA
|
||||
readonly backgroundMenu: RGBA
|
||||
readonly border: RGBA
|
||||
readonly borderActive: RGBA
|
||||
readonly borderSubtle: RGBA
|
||||
readonly diffAdded: RGBA
|
||||
readonly diffRemoved: RGBA
|
||||
readonly diffContext: RGBA
|
||||
readonly diffHunkHeader: RGBA
|
||||
readonly diffHighlightAdded: RGBA
|
||||
readonly diffHighlightRemoved: RGBA
|
||||
readonly diffAddedBg: RGBA
|
||||
readonly diffRemovedBg: RGBA
|
||||
readonly diffContextBg: RGBA
|
||||
readonly diffLineNumber: RGBA
|
||||
readonly diffAddedLineNumberBg: RGBA
|
||||
readonly diffRemovedLineNumberBg: RGBA
|
||||
readonly markdownText: RGBA
|
||||
readonly markdownHeading: RGBA
|
||||
readonly markdownLink: RGBA
|
||||
readonly markdownLinkText: RGBA
|
||||
readonly markdownCode: RGBA
|
||||
readonly markdownBlockQuote: RGBA
|
||||
readonly markdownEmph: RGBA
|
||||
readonly markdownStrong: RGBA
|
||||
readonly markdownHorizontalRule: RGBA
|
||||
readonly markdownListItem: RGBA
|
||||
readonly markdownListEnumeration: RGBA
|
||||
readonly markdownImage: RGBA
|
||||
readonly markdownImageText: RGBA
|
||||
readonly markdownCodeBlock: RGBA
|
||||
readonly syntaxComment: RGBA
|
||||
readonly syntaxKeyword: RGBA
|
||||
readonly syntaxFunction: RGBA
|
||||
readonly syntaxVariable: RGBA
|
||||
readonly syntaxString: RGBA
|
||||
readonly syntaxNumber: RGBA
|
||||
readonly syntaxType: RGBA
|
||||
readonly syntaxOperator: RGBA
|
||||
readonly syntaxPunctuation: RGBA
|
||||
readonly thinkingOpacity: number
|
||||
_hasSelectedListItemText: boolean
|
||||
}
|
||||
|
||||
export type ThemeColor = Exclude<keyof Theme, "thinkingOpacity" | "_hasSelectedListItemText">
|
||||
export type HexColor = `#${string}`
|
||||
export type RefName = string
|
||||
export type Variant = {
|
||||
dark: HexColor | RefName
|
||||
light: HexColor | RefName
|
||||
}
|
||||
export type ColorValue = HexColor | RefName | Variant | RGBA | number
|
||||
export type ThemeV1Json = {
|
||||
$schema?: string
|
||||
defs?: Record<string, HexColor | RefName>
|
||||
theme: Omit<Record<ThemeColor, ColorValue>, "selectedListItemText" | "backgroundMenu"> & {
|
||||
selectedListItemText?: ColorValue
|
||||
backgroundMenu?: ColorValue
|
||||
thinkingOpacity?: number
|
||||
}
|
||||
}
|
||||
|
||||
export const DEFAULT_THEMES: Record<string, ThemeV1Json> = {
|
||||
aura,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { RGBA } from "@opentui/core"
|
||||
import type { Accessor } from "solid-js"
|
||||
import type { Mode, ResolvedThemeView } from "@opencode-ai/theme/tui"
|
||||
import type { Mode, ResolvedThemeView } from "./index"
|
||||
|
||||
export function createComponentTheme(current: Accessor<ResolvedThemeView>, mode: Accessor<Mode>) {
|
||||
return {
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user