Compare commits

...

14 Commits

Author SHA1 Message Date
Kit Langton ea57633ec6 refactor(tui): remove dead session renderer 2026-07-28 21:09:27 -04:00
Kit Langton 1c8175a61a fix(tui): preserve tab context on home and close (#39421) 2026-07-29 01:03:17 +00:00
Dax Raad fe91698ed6 fix(tui): initialize external plugin runtime 2026-07-28 21:00:09 -04:00
Dax Raad 068c32df39 feat(tui): discover project plugins 2026-07-28 20:21:38 -04:00
Kit Langton a2885d1662 feat(tui): add session tab history (#39411) 2026-07-28 19:38:46 -04:00
Kit Langton 38a3dbb4c4 fix(tui): fade full-width tab titles (#39409) 2026-07-28 19:38:28 -04:00
Kit Langton 43383d4fba fix(tui): hide single session tab (#39408) 2026-07-28 18:25:46 -04:00
opencode-agent[bot] 40c4c3918a feat(core): enable fff in node runtimes (#38776)
Co-authored-by: Aiden Cline <rekram1-node@users.noreply.github.com>
2026-07-28 17:14:11 -05:00
Aiden Cline 754ea99d86 fix(core): preserve shell output tail (#39403) 2026-07-28 16:32:11 -05:00
Kit Langton 37a1b80d5a feat(tui): add adaptive session tabs (#39396) 2026-07-28 17:12:07 -04:00
Aiden Cline f95d04fea0 feat(core): improve shell tool guidance (#39401) 2026-07-28 15:53:19 -05:00
James Long 08b80da931 refactor(tui): split theme hooks (#39395) 2026-07-28 16:25:32 -04:00
Aiden Cline f6fb1a7cdd fix(ai): retry transient client statuses (#39391) 2026-07-28 14:25:33 -05:00
Dax Raad 5bcc0016a6 feat(tui): add plugin context hook 2026-07-28 14:57:59 -04:00
92 changed files with 2956 additions and 1330 deletions
+16
View File
@@ -0,0 +1,16 @@
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)
},
}
+40
View File
@@ -147,6 +147,11 @@
"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",
@@ -164,6 +169,11 @@
"@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:",
},
@@ -364,6 +374,7 @@
"@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:*",
@@ -596,17 +607,20 @@
"@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": {
@@ -1693,6 +1707,8 @@
"@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=="],
@@ -3269,6 +3285,28 @@
"@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=="],
@@ -4033,6 +4071,8 @@
"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=="],
+1 -2
View File
@@ -135,7 +135,7 @@ export function classifyProviderFailure(input: ProviderFailure): LLMError["reaso
rateLimit: input.rateLimit,
})
}
if (input.status !== undefined && input.status >= 500)
if (input.status === 408 || input.status === 409 || (input.status !== undefined && input.status >= 500))
return new ProviderInternalReason({
...common,
status: input.status,
@@ -145,7 +145,6 @@ export function classifyProviderFailure(input: ProviderFailure): LLMError["reaso
if (
input.status === 400 ||
input.status === 404 ||
input.status === 409 ||
input.status === 413 ||
input.status === 422
)
+6
View File
@@ -58,6 +58,12 @@ 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(
[
+10
View File
@@ -56,6 +56,16 @@
"@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",
+2
View File
@@ -37,6 +37,8 @@ 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)),
+6
View File
@@ -11,6 +11,8 @@ 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,
@@ -19,6 +21,10 @@ 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`,
}
}
+44
View File
@@ -30,6 +30,44 @@ 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" },
@@ -156,6 +194,11 @@ 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"`
}
@@ -174,6 +217,7 @@ export function mainConfig(input: NodeBuildInput): UserConfig {
plugins: [
rawTextPlugin(),
runtimeRequirePlugin(),
fffNodePlugin(),
solid({
solid: {
generate: "universal",
+1
View File
@@ -92,6 +92,7 @@
"@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:*",
+50 -50
View File
@@ -1,71 +1,52 @@
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 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 type Init = InitOptions
export interface Search {
items: File[]
scores: Array<{ total: number }>
items: FileItem[]
scores: SearchResult["scores"]
totalMatched: number
totalFiles: number
}
export interface DirSearch {
items: Directory[]
scores: Array<{ total: number }>
items: DirItem[]
scores: DirSearchResult["scores"]
totalMatched: number
totalDirs: number
}
export interface MixedSearch {
items: Mixed[]
scores: Array<{ total: number }>
items: MixedItem[]
scores: MixedSearchResult["scores"]
totalMatched: number
totalFiles: number
totalDirs: number
}
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 type File = FileItem
export type Directory = DirItem
export type Mixed = MixedItem
export type Cursor = GrepCursor | null
export type Hit = GrepMatch
export interface Grep {
items: Hit[]
items: GrepResult["items"]
totalMatched: number
totalFilesSearched: number
totalFiles: number
@@ -128,11 +109,30 @@ export interface Picker {
}
export function available() {
return false
return FileFinder?.isAvailable() ?? false
}
export function create(_opts: Init): Result<Picker> {
return { ok: false, error: "fff unavailable on node runtime" }
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 * as Fff from "./fff.node"
+10 -3
View File
@@ -44,6 +44,7 @@ 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[]>
@@ -134,6 +135,13 @@ 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
@@ -167,8 +175,7 @@ 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 configShell = Config.latest(yield* config.entries(), "shell")
const shell = ShellSelect.preferred(configShell, options)
const shell = yield* resolve()
const args = ShellSelect.args(shell, input.command)
const file = path.join(outputDir, `${id}.out`)
const env = {
@@ -312,7 +319,7 @@ export const layer = (options?: ShellSelect.Options) => Layer.effect(
return session.info
})
return Service.of({ create, list, get, wait, timeout, output, remove })
return Service.of({ name, create, list, get, wait, timeout, output, remove })
}),
)
+39 -11
View File
@@ -15,22 +15,40 @@ 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. Defaults to the active Location; relative paths resolve from that Location.",
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.`,
}),
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.",
@@ -69,13 +87,11 @@ 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")
@@ -144,7 +160,7 @@ export const Plugin = {
({
name,
options: { codemode: false },
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.`,
description: description(),
input: Input,
output: Output,
execute: (input, context) =>
@@ -189,8 +205,12 @@ export const Plugin = {
yield* context.progress({ shellID: info.id })
const captureShell = Effect.fn("ShellTool.captureShell")(function* () {
const page = yield* shell.output(info.id, { limit: MAX_CAPTURE_BYTES })
const truncated = page.size > page.cursor
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 notice = truncated ? `\n\n[output truncated; full output saved to: ${info.file}]` : ""
return {
output: `${page.output || "(no output)"}${notice}`,
@@ -291,5 +311,13 @@ 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())
}),
)
}),
}
+11 -6
View File
@@ -165,8 +165,8 @@ const bodyExitCommand = isWindows
: "printf body && exit 7"
const overflowCommand = (bytes: number) =>
isWindows
? `[Console]::Out.Write(('x' * ${bytes})); Start-Sleep -Milliseconds 100`
: `head -c ${bytes} /dev/zero | tr '\\0' 'x'`
? `[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`
const progressOverflowCommand = (bytes: number, release: string) =>
isWindows
? `[Console]::Out.Write(('x' * ${bytes})); while (!(Test-Path -LiteralPath '${release}')) { Start-Sleep -Milliseconds 50 }`
@@ -200,10 +200,11 @@ describe("ShellTool", () => {
return withSession(tmp.path, (registry) =>
Effect.gen(function* () {
const definitions = yield* toolDefinitions(registry)
const shell = definitions.find((tool) => tool.name === "shell")
expect(shell).toBeDefined()
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")
// Code Mode receives the declared output schema, including the command output text.
expect(shell?.outputSchema).toHaveProperty("properties.output")
expect(definition?.outputSchema).toHaveProperty("properties.output")
expect(
(yield* toolDefinitions(registry, [{ action: "shell", resource: "*", effect: "deny" }])).map(
(tool) => tool.name,
@@ -409,7 +410,11 @@ describe("ShellTool", () => {
Effect.andThen((settled) =>
Effect.sync(() => {
expect(settled.metadata).toMatchObject({ exit: 0, truncated: true })
expect(settled.content?.[0]).toMatchObject({
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({
type: "text",
text: expect.stringContaining("output truncated; full output saved to:"),
})
+6 -1
View File
@@ -32,7 +32,8 @@
"peerDependencies": {
"@opentui/core": ">=0.4.5",
"@opentui/keymap": ">=0.4.5",
"@opentui/solid": ">=0.4.5"
"@opentui/solid": ">=0.4.5",
"solid-js": ">=1.9.0"
},
"peerDependenciesMeta": {
"@opentui/core": {
@@ -43,6 +44,9 @@
},
"@opentui/solid": {
"optional": true
},
"solid-js": {
"optional": true
}
},
"devDependencies": {
@@ -52,6 +56,7 @@
"@tsconfig/bun": "catalog:",
"@tsconfig/node22": "catalog:",
"@types/node": "catalog:",
"solid-js": "catalog:",
"typescript": "catalog:",
"@typescript/native-preview": "catalog:"
}
+1
View File
@@ -1 +1,2 @@
export * as Plugin from "./plugin.js"
export { PluginContextProvider, usePlugin } from "./solid.js"
+19
View File
@@ -0,0 +1,19 @@
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
}
+209 -112
View File
@@ -51,6 +51,7 @@ 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"
@@ -65,8 +66,9 @@ 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 } from "./context/theme"
import { ThemeProvider, useTheme, useThemes } from "./context/theme"
import { Home } from "./routes/home"
import { Session } from "./routes/session"
import { PromptHistoryProvider } from "./component/prompt/history"
@@ -92,9 +94,28 @@ import { AttentionProvider } from "./context/attention"
registerOpencodeSpinner()
const appGlobalBindingCommands = [
"session.list",
"session.new",
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 = [
"session.quick_switch.1",
"session.quick_switch.2",
"session.quick_switch.3",
@@ -277,113 +298,115 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
<ErrorBoundary
fallback={(error, reset) => <ErrorComponent error={error} reset={reset} mode={mode} />}
>
<TuiPathsProvider
value={{
cwd: process.cwd(),
home: global.home,
state: global.state,
worktree: global.data + "/worktree",
}}
>
<TuiLifecycleProvider
<TuiPathsProvider
value={{
add(finalizer) {
finalizers.add(finalizer)
return () => finalizers.delete(finalizer)
},
cwd: process.cwd(),
home: global.home,
state: global.state,
worktree: global.data + "/worktree",
}}
>
<TuiTerminalEnvironmentProvider
<TuiLifecycleProvider
value={{
platform: process.platform,
multiplexer: process.env.TMUX ? "tmux" : process.env.STY ? "screen" : undefined,
displayServer: process.env.WAYLAND_DISPLAY
? "wayland"
: process.env.DISPLAY
? "x11"
: undefined,
add(finalizer) {
finalizers.add(finalizer)
return () => finalizers.delete(finalizer)
},
}}
>
<TuiStartupProvider
<TuiTerminalEnvironmentProvider
value={{
initialRoute: process.env.OPENCODE_SCRAP
? { type: "plugin", id: "scrap", name: "scrap" }
: process.env.OPENCODE_ROUTE
? JSON.parse(process.env.OPENCODE_ROUTE)
platform: process.platform,
multiplexer: process.env.TMUX ? "tmux" : process.env.STY ? "screen" : undefined,
displayServer: process.env.WAYLAND_DISPLAY
? "wayland"
: process.env.DISPLAY
? "x11"
: 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
}
>
<PluginRuntimeProvider value={pluginRuntime}>
<ClientProvider api={api} service={service}>
<PermissionProvider>
<DataProvider>
<LocationProvider>
<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>
</PluginRuntimeProvider>
</RouteProvider>
</ToastProvider>
</Keymap.Provider>
</ConfigProvider>
</ArgsProvider>
</ClipboardProvider>
</TuiStartupProvider>
</TuiTerminalEnvironmentProvider>
</TuiLifecycleProvider>
</TuiPathsProvider>
<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
}
>
<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>
</LocationProvider>
</DataProvider>
</PermissionProvider>
</ClientProvider>
</PluginRuntimeProvider>
</RouteProvider>
</ToastProvider>
</Keymap.Provider>
</ConfigProvider>
</ArgsProvider>
</ClipboardProvider>
</TuiStartupProvider>
</TuiTerminalEnvironmentProvider>
</TuiLifecycleProvider>
</TuiPathsProvider>
</ErrorBoundary>
</TuiAppProvider>
</EpilogueProvider>
@@ -419,12 +442,13 @@ 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 themeState = useTheme()
const { themeV2, mode, supports, setMode, locked, lock, unlock } = themeState
const theme = useTheme()
const { mode, supports, setMode, locked, lock, unlock } = useThemes()
const data = useData()
const location = useLocation()
const exit = useExit()
@@ -623,9 +647,71 @@ function App(props: { pair?: DialogPairCredentials }) {
title: `Switch to session in quick slot ${i + 1}`,
category: "Session",
palette: undefined,
run: () => {
local.session.quickSwitch(i + 1)
},
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),
})),
{
name: "model.list",
@@ -1004,6 +1090,18 @@ 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
@@ -1090,7 +1188,7 @@ function App(props: { pair?: DialogPairCredentials }) {
width={dimensions().width}
height={dimensions().height}
flexDirection="column"
backgroundColor={themeV2.background.default}
backgroundColor={theme.background.default}
onMouseDown={(evt) => {
if (copyOnSelectEnabled()) return
if (evt.button !== MouseButton.RIGHT) return
@@ -1099,16 +1197,15 @@ 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 />
+7 -5
View File
@@ -7,7 +7,7 @@ import {
} from "@opentui/core"
import { extend, useRenderer } from "@opentui/solid"
import { onCleanup, onMount } from "solid-js"
import { useTheme } from "../context/theme"
import { useThemes } from "../context/theme"
import { tint } from "../theme/color"
import { GoUpsellArtPainter } from "./bg-pulse-render"
@@ -70,7 +70,9 @@ declare module "@opentui/solid" {
extend({ go_upsell_art: GoUpsellArtRenderable })
export function BgPulse() {
const { themeV2, mode } = useTheme().contextual("elevated")
const themes = useThemes()
const theme = themes.contextual("elevated")
const mode = themes.mode
const renderer = useRenderer()
let targetFps = renderer.targetFps
let maxFps = renderer.maxFps
@@ -91,9 +93,9 @@ export function BgPulse() {
<go_upsell_art
width="100%"
height="100%"
backgroundPanel={themeV2.background.default}
primary={themeV2.hue.interactive[mode() === "light" ? 800 : 200]}
logoBase={tint(themeV2.background.default, themeV2.text.default, 0.62)}
backgroundPanel={theme.background.default}
primary={theme.hue.interactive[mode() === "light" ? 800 : 200]}
logoBase={tint(theme.background.default, theme.text.default, 0.62)}
live
/>
)
+40 -40
View File
@@ -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 } from "../context/theme"
import { useTheme, useThemes } 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 theme = useTheme()
const themes = useThemes()
const keymap = Keymap.use()
const renderer = useRenderer()
const dimensions = useTerminalDimensions()
const { themeV2, mode, supports, setMode } = theme
const elevatedTheme = theme.contextual("elevated").themeV2
const { current: theme, mode, supports, setMode } = themes
const elevatedTheme = themes.contextual("elevated")
const [panel, setPanel] = createSignal<Panel>()
const [dumping, setDumping] = createSignal(false)
const [dumpPath, setDumpPath] = createSignal<string>()
@@ -196,8 +196,8 @@ export function DevToolsBar() {
})),
},
theme: {
name: theme.selected,
mode: theme.mode(),
name: themes.selected,
mode: themes.mode(),
},
},
null,
@@ -213,7 +213,7 @@ export function DevToolsBar() {
}
return (
<box height={1} flexShrink={0} flexDirection="row" backgroundColor={themeV2.raise(themeV2.background.default)}>
<box height={1} flexShrink={0} flexDirection="row" backgroundColor={theme.raise(theme.background.default)}>
<Show when={panel()}>
<box
position="absolute"
@@ -230,12 +230,12 @@ export function DevToolsBar() {
<text
fg={
panel() === "server"
? themeV2.text.action.primary.focused
? theme.text.action.primary.focused
: serverIndicator().state === "connected"
? themeV2.text.feedback.success.default
? theme.text.feedback.success.default
: serverIndicator().state === "disconnected"
? themeV2.text.feedback.error.default
: themeV2.text.default
? theme.text.feedback.error.default
: theme.text.default
}
>
{serverIndicator().icon}
@@ -243,10 +243,10 @@ export function DevToolsBar() {
<text
fg={
panel() === "server"
? themeV2.text.action.primary.focused
? theme.text.action.primary.focused
: serverIndicator().state === "disconnected"
? themeV2.text.feedback.error.default
: themeV2.text.subdued
? theme.text.feedback.error.default
: theme.text.subdued
}
>
{" "}
@@ -279,10 +279,10 @@ export function DevToolsBar() {
<text
fg={
panel() === "ui"
? themeV2.text.action.primary.focused
? theme.text.action.primary.focused
: runtime() === "high"
? themeV2.text.feedback.error.default
: themeV2.text.subdued
? theme.text.feedback.error.default
: theme.text.subdued
}
>
{statusIcon(runtime())}
@@ -290,10 +290,10 @@ export function DevToolsBar() {
<text
fg={
panel() === "ui"
? themeV2.text.action.primary.focused
? theme.text.action.primary.focused
: runtime() === "high"
? themeV2.text.feedback.error.default
: themeV2.text.subdued
? theme.text.feedback.error.default
: theme.text.subdued
}
>
{" "}
@@ -320,11 +320,11 @@ export function DevToolsBar() {
</Show>
</BarItem>
<BarItem active={panel() === "theme"} onClick={() => toggle("theme")}>
<text fg={panel() === "theme" ? themeV2.text.action.primary.focused : themeV2.text.subdued}>Theme</text>
<text fg={panel() === "theme" ? theme.text.action.primary.focused : theme.text.subdued}>Theme</text>
<Show when={panel() === "theme"}>
<PanelBox>
<PanelTitle>Theme</PanelTitle>
<Row label="Name" value={theme.selected} />
<Row label="Name" value={themes.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" ? themeV2.text.action.primary.focused : themeV2.text.subdued}>Tools</text>
<text fg={panel() === "tools" ? theme.text.action.primary.focused : theme.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={themeV2.text.subdued} label="Time to first draw" />
<TimeToFirstDraw visible={timing()} width="100%" fg={theme.text.subdued} label="Time to first draw" />
</box>
</box>
)
}
function BarItem(props: ParentProps<{ active: boolean; onClick: () => void }>) {
const { themeV2 } = useTheme()
const theme = 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 ? themeV2.background.action.primary.focused : undefined}
backgroundColor={props.active ? theme.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 { themeV2 } = useTheme().contextual("elevated")
const theme = useThemes().contextual("elevated")
const renderer = useRenderer()
return (
<box
@@ -448,7 +448,7 @@ function PanelBox(props: ParentProps) {
paddingRight={2}
paddingTop={1}
paddingBottom={1}
backgroundColor={themeV2.background.default}
backgroundColor={theme.background.default}
flexDirection="column"
onMouseUp={(event) => {
if (renderer.getSelection()?.getSelectedText()) return
@@ -461,32 +461,32 @@ function PanelBox(props: ParentProps) {
}
function PanelTitle(props: ParentProps) {
const { themeV2 } = useTheme().contextual("elevated")
const theme = useThemes().contextual("elevated")
return (
<text fg={themeV2.text.default} attributes={TextAttributes.BOLD} marginBottom={1}>
<text fg={theme.text.default} attributes={TextAttributes.BOLD} marginBottom={1}>
{props.children}
</text>
)
}
function Row(props: { label: string; value: string }) {
const { themeV2 } = useTheme().contextual("elevated")
const theme = useThemes().contextual("elevated")
return (
<box flexDirection="row">
<text fg={themeV2.text.subdued}>{props.label}</text>
<text fg={theme.text.subdued}>{props.label}</text>
<box flexGrow={1} />
<text fg={themeV2.text.default}>{props.value}</text>
<text fg={theme.text.default}>{props.value}</text>
</box>
)
}
function Action(props: ParentProps<{ onClick: () => void; disabled?: boolean; hoverBackground?: boolean }>) {
const { themeV2 } = useTheme().contextual("elevated")
const theme = useThemes().contextual("elevated")
const [hovered, setHovered] = createSignal(false)
return (
<box
backgroundColor={
props.hoverBackground && hovered() && !props.disabled ? themeV2.background.action.primary.hovered : undefined
props.hoverBackground && hovered() && !props.disabled ? theme.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 ? themeV2.text.subdued : themeV2.text.action.primary.default}>{props.children}</text>
<text fg={props.disabled ? theme.text.subdued : theme.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 { themeV2 } = useTheme().contextual("elevated")
const theme = useThemes().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={themeV2.text.subdued}>{props.label}</text>
<text fg={theme.text.subdued}>{props.label}</text>
</box>
<box flexGrow={1}>
<text fg={props.values.length ? themeV2.text.default : themeV2.text.subdued}>{brailleGraph(props.values)}</text>
<text fg={props.values.length ? theme.text.default : theme.text.subdued}>{brailleGraph(props.values)}</text>
</box>
<box width={8} alignItems="flex-end">
<text fg={props.values.length ? themeV2.text.default : themeV2.text.subdued}>{value()}</text>
<text fg={props.values.length ? theme.text.default : theme.text.subdued}>{value()}</text>
</box>
</box>
)
+12 -4
View File
@@ -1,6 +1,6 @@
import { createMemo, createSignal } from "solid-js"
import { useConfig } from "../config"
import { useTheme } from "../context/theme"
import { useThemes } from "../context/theme"
import { DialogSelect } from "../ui/dialog-select"
import { useToast } from "../ui/toast"
@@ -93,6 +93,14 @@ export const settings: Setting[] = [
values: ["none", "auto"],
keywords: ["transcript", "messages"],
},
{
title: "Tabs",
category: "Session",
path: ["session", "tabs"],
default: false,
values: [false, true],
labels: ["off", "on"],
},
{
title: "Layout",
category: "Diffs",
@@ -267,7 +275,7 @@ export function settingID(setting: Setting) {
export function DialogConfig(props: { current?: string }) {
const config = useConfig()
const toast = useToast()
const themeState = useTheme()
const themes = useThemes()
const current = Math.max(
0,
settings.findIndex((setting) => settingID(setting) === props.current),
@@ -280,12 +288,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 ?? themeState.selected
if (setting.path.join(".") === "theme.name") return current ?? themes.selected
return current ?? setting.default
}
const values = (setting: Setting) =>
setting.path.join(".") === "theme.name"
? Object.keys(themeState.all()).sort((a, b) => a.localeCompare(b, undefined, { sensitivity: "base" }))
? Object.keys(themes.all()).sort((a, b) => a.localeCompare(b, undefined, { sensitivity: "base" }))
: setting.values
const display = (setting: Setting) => {
const current = value(setting)
+8 -8
View File
@@ -11,7 +11,7 @@ import { describeOS, describeTerminal } from "../util/system"
import { useTuiApp } from "../context/runtime"
export function DialogDebug() {
const { themeV2 } = useTheme()
const theme = 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={themeV2.text.default} attributes={TextAttributes.BOLD}>
<text fg={theme.text.default} attributes={TextAttributes.BOLD}>
Debug
</text>
<text fg={themeV2.text.subdued} onMouseUp={() => dialog.clear()}>
<text fg={theme.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={themeV2.text.subdued}>
<text flexShrink={0} fg={theme.text.subdued}>
{entry.label.padEnd(10)}
</text>
<text fg={themeV2.text.default} wrapMode="word">
<text fg={theme.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={themeV2.text.subdued}>Share this when reporting an issue.</text>
<text fg={theme.text.subdued}>Share this when reporting an issue.</text>
<text onMouseUp={copy}>
<span style={{ fg: copied() ? themeV2.text.feedback.success.default : themeV2.text.default }}>
<span style={{ fg: copied() ? theme.text.feedback.success.default : theme.text.default }}>
<b>{copied() ? "✓ copied" : "copy"}</b>{" "}
</span>
<span style={{ fg: themeV2.text.subdued }}>enter</span>
<span style={{ fg: theme.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 { useTheme } from "../context/theme"
import { useThemes } 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 { themeV2 } = useTheme().contextual("elevated")
const theme = useThemes().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={themeV2.text.feedback.success.default}></text>
? () => <text fg={theme.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={themeV2.text.subdued}>No integrations available</text>
<text fg={theme.text.subdued}>No integrations available</text>
</box>
}
noMatchView={
<box paddingLeft={4} paddingRight={4} paddingTop={1}>
<text fg={themeV2.text.subdued}>No integrations found</text>
<text fg={theme.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 { themeV2 } = useTheme().contextual("elevated")
const { themeV2: overlayTheme } = useTheme().contextual("overlay")
const theme = useThemes().contextual("elevated")
const overlayTheme = useThemes().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={themeV2.text.default}>
<text attributes={TextAttributes.BOLD} fg={theme.text.default}>
{props.title}
</text>
<text fg={themeV2.text.subdued} onMouseUp={() => dialog.clear()}>
<text fg={theme.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={themeV2.text.subdued}>{props.message}</text>
<text fg={theme.text.subdued}>{props.message}</text>
</box>
</box>
)
@@ -341,7 +341,7 @@ function KeyMethod(props: {
const dialog = useDialog()
const client = useClient()
const toast = useToast()
const { themeV2 } = useTheme().contextual("elevated")
const theme = useThemes().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={themeV2.text.feedback.error.default}>{value()}</text>}</Show>
<Show when={error()}>{(value) => <text fg={theme.text.feedback.error.default}>{value()}</text>}</Show>
)}
/>
)
@@ -516,7 +516,7 @@ function OAuthCode(props: {
const dialog = useDialog()
const client = useClient()
const toast = useToast()
const { themeV2 } = useTheme().contextual("elevated")
const theme = useThemes().contextual("elevated")
const [error, setError] = createSignal<string>()
let settled = false
@@ -550,9 +550,9 @@ function OAuthCode(props: {
}}
description={() => (
<box gap={1}>
<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>
<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>
</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 { themeV2 } = useTheme().contextual("elevated")
const theme = useThemes().contextual("elevated")
return (
<box paddingLeft={2} paddingRight={2} gap={1} paddingBottom={1}>
<box flexDirection="row" justifyContent="space-between">
<text attributes={TextAttributes.BOLD} fg={themeV2.text.default}>
<text attributes={TextAttributes.BOLD} fg={theme.text.default}>
{props.title}
</text>
<text fg={themeV2.text.subdued} onMouseUp={() => dialog.clear()}>
<text fg={theme.text.subdued} onMouseUp={() => dialog.clear()}>
esc
</text>
</box>
<Show when={props.url}>
{(url) => (
<box gap={1}>
<Link href={url()} fg={themeV2.markdown.link} />
<Link href={url()} fg={theme.markdown.link} />
<Show when={props.instructions}>
{(instructions) => <text fg={themeV2.text.subdued}>{instructions()}</text>}
{(instructions) => <text fg={theme.text.subdued}>{instructions()}</text>}
</Show>
</box>
)}
</Show>
<text fg={themeV2.text.subdued}>{props.message}</text>
<text fg={theme.text.subdued}>{props.message}</text>
<Show when={props.copy}>
<text fg={themeV2.text.default}>
c <span style={{ fg: themeV2.text.subdued }}>copy</span>
<text fg={theme.text.default}>
c <span style={{ fg: theme.text.subdued }}>copy</span>
</text>
</Show>
</box>
+14 -14
View File
@@ -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 { useTheme } from "../context/theme"
import { useThemes } 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 { themeV2 } = useTheme().contextual("elevated")
if (props.loading) return <span style={{ fg: themeV2.text.subdued }}> Loading</span>
const theme = useThemes().contextual("elevated")
if (props.loading) return <span style={{ fg: theme.text.subdued }}> Loading</span>
if (props.enabled) {
return <span style={{ fg: themeV2.text.feedback.success.default, attributes: TextAttributes.BOLD }}> Enabled</span>
return <span style={{ fg: theme.text.feedback.success.default, attributes: TextAttributes.BOLD }}> Enabled</span>
}
return <span style={{ fg: themeV2.text.subdued }}> Disabled</span>
return <span style={{ fg: theme.text.subdued }}> Disabled</span>
}
export function DialogMcp() {
@@ -33,7 +33,7 @@ export function DialogMcp() {
const dialog = useDialog()
const client = useClient()
const toast = useToast()
const { themeV2 } = useTheme().contextual("elevated")
const theme = useThemes().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={themeV2.text.subdued}>enter to view error</text>
<text fg={theme.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 { themeV2 } = useTheme().contextual("elevated")
const { themeV2: overlayTheme } = useTheme().contextual("overlay")
const theme = useThemes().contextual("elevated")
const overlayTheme = useThemes().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={themeV2.text.default}>
<text attributes={TextAttributes.BOLD} fg={theme.text.default}>
MCP server: {props.server.name}
</text>
<text fg={themeV2.text.subdued} onMouseUp={props.onBack}>
<text fg={theme.text.subdued} onMouseUp={props.onBack}>
esc back
</text>
</box>
<text fg={themeV2.text.feedback.error.default}> Failed</text>
<text fg={theme.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={themeV2.text.subdued}> scroll</text>
<text fg={themeV2.text.subdued} onMouseUp={copy}>
<text fg={theme.text.subdued}> scroll</text>
<text fg={theme.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 { useTheme } from "../context/theme"
import { useThemes } 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 { themeV2 } = useTheme().contextual("elevated")
const theme = useThemes().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: themeV2.text.feedback.error.default }}>Deleting {item.location}</span>
<span style={{ fg: theme.text.feedback.error.default }}>Deleting {item.location}</span>
) : deleting ? (
<span style={{ fg: themeV2.text.action.destructive.default }}>
<span style={{ fg: theme.text.action.destructive.default }}>
Press {shortcuts.get("dialog.move_session.delete")} again to confirm
</span>
) : suffix ? (
<>
{visible.slice(0, split)}
<span style={{ fg: themeV2.text.subdued }}>{visible.slice(split)}</span>
<span style={{ fg: theme.text.subdued }}>{visible.slice(split)}</span>
</>
) : undefined,
bg: deleting ? themeV2.background.action.destructive.default : undefined,
bg: deleting ? theme.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={themeV2.text.default} attributes={TextAttributes.BOLD}>
<text fg={theme.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={themeV2.text.feedback.error.default} attributes={TextAttributes.BOLD}>
<text fg={theme.text.feedback.error.default} attributes={TextAttributes.BOLD}>
Could not load project directories
</text>
<text fg={themeV2.text.subdued}>{errorMessage(loadError())}</text>
<text fg={themeV2.text.subdued}>Close and reopen Move session to try again.</text>
<text fg={theme.text.subdued}>{errorMessage(loadError())}</text>
<text fg={theme.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={themeV2.text.subdued}>Loading project directories</text>
<text fg={theme.text.subdued}>Loading project directories</text>
</box>
) : (
<box paddingLeft={4} paddingRight={4} paddingTop={1}>
<text fg={themeV2.text.subdued}>No project directories available</text>
<text fg={theme.text.subdued}>No project directories available</text>
</box>
)
}
noMatchView={
<box paddingLeft={4} paddingRight={4} paddingTop={1}>
<text fg={themeV2.text.subdued}>No project directories found</text>
<text fg={theme.text.subdued}>No project directories found</text>
</box>
}
locked={showError() || directories.loading || loadedProject.loading || Boolean(removing())}
+16 -16
View File
@@ -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 { useTheme } from "../context/theme"
import { useThemes } 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 { themeV2 } = useTheme().contextual("elevated")
const theme = useThemes().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={themeV2.text.subdued}>URLs</text>
<For each={value.urls}>{(url) => <text fg={themeV2.text.default}>{url}</text>}</For>
<text fg={theme.text.subdued}>URLs</text>
<For each={value.urls}>{(url) => <text fg={theme.text.default}>{url}</text>}</For>
</box>
<box>
<text fg={themeV2.text.subdued}>Username</text>
<text fg={themeV2.text.default}>{value.username}</text>
<text fg={theme.text.subdued}>Username</text>
<text fg={theme.text.default}>{value.username}</text>
</box>
<box>
<text fg={themeV2.text.subdued}>Password</text>
<text fg={theme.text.subdued}>Password</text>
<text
fg={passwordHover() ? themeV2.text.default : themeV2.text.subdued}
fg={passwordHover() ? theme.text.default : theme.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={themeV2.text.subdued} wrapMode="word">
<text fg={theme.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={themeV2.text.default}>{renderUnicodeCompact(JSON.stringify(value), { border: 1 })}</text>
<text fg={theme.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={themeV2.text.default} attributes={TextAttributes.BOLD}>
<text fg={theme.text.default} attributes={TextAttributes.BOLD}>
Pair
</text>
<text fg={themeV2.text.subdued} onMouseUp={() => dialog.clear()}>
<text fg={theme.text.subdued} onMouseUp={() => dialog.clear()}>
esc
</text>
</box>
<Show
when={loadError()}
fallback={
<Show when={info()} fallback={<text fg={themeV2.text.subdued}>Loading server information</text>}>
<Show when={info()} fallback={<text fg={theme.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={themeV2.text.feedback.error.default} attributes={TextAttributes.BOLD}>
<text fg={theme.text.feedback.error.default} attributes={TextAttributes.BOLD}>
Could not load server information
</text>
<text fg={themeV2.text.subdued}>{errorMessage(error())}</text>
<text fg={themeV2.text.subdued}>Close and reopen Pair to try again.</text>
<text fg={theme.text.subdued}>{errorMessage(error())}</text>
<text fg={theme.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 { useTheme } from "../context/theme"
import { useThemes } from "../context/theme"
import { useDialog, type DialogContext } from "../ui/dialog"
export function DialogProjectCopyName(props: { onConfirm: (name: string) => void }) {
const dialog = useDialog()
const { themeV2 } = useTheme().contextual("elevated")
const theme = useThemes().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={themeV2.text.default}>
<text attributes={TextAttributes.BOLD} fg={theme.text.default}>
Name project copy
</text>
<text fg={themeV2.text.subdued} onMouseUp={() => dialog.clear()}>
<text fg={theme.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={themeV2.text.subdued}
textColor={themeV2.text.formfield.default}
focusedTextColor={themeV2.text.formfield.default}
cursorColor={themeV2.text.formfield.default}
placeholderColor={theme.text.subdued}
textColor={theme.text.formfield.default}
focusedTextColor={theme.text.formfield.default}
cursorColor={theme.text.formfield.default}
/>
<box paddingBottom={1} flexDirection="row" gap={2}>
<text fg={themeV2.text.default}>
enter <span style={{ fg: themeV2.text.subdued }}>submit</span>
<text fg={theme.text.default}>
enter <span style={{ fg: theme.text.subdued }}>submit</span>
</text>
<text fg={themeV2.text.default}>
{shortcuts.get("dialog.project_copy.generate")} <span style={{ fg: themeV2.text.subdued }}>generate one</span>
<text fg={theme.text.default}>
{shortcuts.get("dialog.project_copy.generate")} <span style={{ fg: theme.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 { useTheme } from "../context/theme"
import { useThemes } 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 { themeV2 } = useTheme().contextual("elevated")
const theme = useThemes().contextual("elevated")
const showGoTreatment = () => props.link === GO_URL
const textBg = () => (showGoTreatment() ? panelOverlay(themeV2.background.default) : undefined)
const textBg = () => (showGoTreatment() ? panelOverlay(theme.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={themeV2.text.default} bg={textBg()}>
<text attributes={TextAttributes.BOLD} fg={theme.text.default} bg={textBg()}>
{props.title}
</text>
<text fg={themeV2.text.subdued} bg={textBg()} onMouseUp={() => dialog.clear()}>
<text fg={theme.text.subdued} bg={textBg()} onMouseUp={() => dialog.clear()}>
esc
</text>
</box>
<box gap={0}>
<text fg={themeV2.text.subdued} bg={textBg()}>
<text fg={theme.text.subdued} bg={textBg()}>
{props.message}
</text>
</box>
{props.link ? (
showGoTreatment() ? (
<box alignItems="center" justifyContent="flex-end" height={7} paddingBottom={1}>
<Link href={props.link} fg={themeV2.markdown.link} bg={textBg()} wrapMode="none" />
<Link href={props.link} fg={theme.markdown.link} bg={textBg()} wrapMode="none" />
</box>
) : (
<box width="100%" flexDirection="row" justifyContent="center" paddingBottom={1}>
<Link href={props.link} fg={themeV2.markdown.link} wrapMode="none" />
<Link href={props.link} fg={theme.markdown.link} wrapMode="none" />
</box>
)
) : (
@@ -115,13 +115,13 @@ export function DialogRetryAction(props: DialogRetryActionProps) {
paddingLeft={2}
paddingRight={2}
backgroundColor={
selected() === "dismiss" ? themeV2.background.action.primary.focused : RGBA.fromInts(0, 0, 0, 0)
selected() === "dismiss" ? theme.background.action.primary.focused : RGBA.fromInts(0, 0, 0, 0)
}
onMouseOver={() => setSelected("dismiss")}
onMouseUp={() => dismiss(props, dialog)}
>
<text
fg={selected() === "dismiss" ? themeV2.text.action.primary.focused : themeV2.text.subdued}
fg={selected() === "dismiss" ? theme.text.action.primary.focused : theme.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" ? themeV2.background.action.primary.focused : RGBA.fromInts(0, 0, 0, 0)
selected() === "action" ? theme.background.action.primary.focused : RGBA.fromInts(0, 0, 0, 0)
}
onMouseOver={() => setSelected("action")}
onMouseUp={() => runAction(props, dialog)}
>
<text
fg={selected() === "action" ? themeV2.text.action.primary.focused : themeV2.text.default}
fg={selected() === "action" ? theme.text.action.primary.focused : theme.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 { useTheme } from "../context/theme"
import { useThemes } 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 { themeV2 } = useTheme().contextual("elevated")
const theme = useThemes().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={themeV2.text.default}>
<text attributes={TextAttributes.BOLD} fg={theme.text.default}>
Failed to Delete Session
</text>
<text fg={themeV2.text.subdued} onMouseUp={() => dialog.clear()}>
<text fg={theme.text.subdued} onMouseUp={() => dialog.clear()}>
esc
</text>
</box>
<text fg={themeV2.text.subdued} wrapMode="word">
<text fg={theme.text.subdued} wrapMode="word">
{`The session "${props.session}" could not be deleted because the workspace "${props.workspace}" is not available.`}
</text>
<text fg={themeV2.text.subdued} wrapMode="word">
<text fg={theme.text.subdued} wrapMode="word">
Choose how you want to recover this broken workspace session.
</text>
<box flexDirection="column" paddingBottom={1} gap={1}>
@@ -86,7 +86,7 @@ export function DialogSessionDeleteFailed(props: {
paddingRight={1}
paddingTop={1}
paddingBottom={1}
backgroundColor={item.id === store.active ? themeV2.background.action.primary.focused : undefined}
backgroundColor={item.id === store.active ? theme.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 ? themeV2.text.action.primary.focused : themeV2.text.default}
fg={item.id === store.active ? theme.text.action.primary.focused : theme.text.default}
>
{item.title}
</text>
<text
fg={item.id === store.active ? themeV2.text.action.primary.focused : themeV2.text.subdued}
fg={item.id === store.active ? theme.text.action.primary.focused : theme.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 { useTheme } from "../context/theme"
import { useThemes } from "../context/theme"
import { useClient } from "../context/client"
import { useLocal } from "../context/local"
import { createDebouncedSignal } from "../util/signal"
@@ -15,14 +15,18 @@ 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 { themeV2, mode } = useTheme().contextual("elevated")
const themes = useThemes()
const theme = themes.contextual("elevated")
const mode = themes.mode
const client = useClient()
const local = useLocal()
const sessionTabs = useSessionTabs()
const toast = useToast()
const [filter, setFilter] = createSignal("")
const shortcuts = Keymap.useShortcuts()
@@ -77,6 +81,7 @@ 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
@@ -94,7 +99,7 @@ export function DialogSessionList() {
.filter((session) => !session.parentID)
.map((session) => [session.id, session]),
)
const pinned = local.session.pinned().filter((sessionID) => sessionMap.has(sessionID))
const pinned = sessionTabs.enabled() ? [] : 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]))
@@ -102,20 +107,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 = slotByID.get(session.id)
const slot = sessionTabs.enabled() ? undefined : slotByID.get(session.id)
const deleting = toDelete() === session.id
return {
title: deleting ? `Press ${shortcuts.get("session.delete")} again to confirm` : session.title,
value: session.id,
category,
footer,
bg: deleting ? themeV2.background.action.destructive.focused : undefined,
fg: deleting ? themeV2.text.action.destructive.focused : undefined,
bg: deleting ? theme.background.action.destructive.focused : undefined,
fg: deleting ? theme.text.action.destructive.focused : undefined,
gutter: data.session.family(session.id).some((id) => data.session.status(id) === "running")
? () => <Spinner />
: slot === undefined
? undefined
: () => <text fg={themeV2.hue.accent[mode() === "light" ? 800 : 200]}>{slot}</text>,
: () => <text fg={theme.hue.accent[mode() === "light" ? 800 : 200]}>{slot}</text>,
}
}
@@ -143,12 +148,12 @@ export function DialogSessionList() {
}}
emptyView={
<box paddingLeft={4} paddingRight={4} paddingTop={1}>
<text fg={themeV2.text.subdued}>No sessions available</text>
<text fg={theme.text.subdued}>No sessions available</text>
</box>
}
noMatchView={
<box paddingLeft={4} paddingRight={4} paddingTop={1}>
<text fg={searchState().error ? themeV2.text.feedback.error.default : themeV2.text.subdued}>
<text fg={searchState().error ? theme.text.feedback.error.default : theme.text.subdued}>
{searchState().message}
</text>
</box>
@@ -162,7 +167,8 @@ export function DialogSessionList() {
{
command: "session.pin.toggle",
title: "pin/unpin",
onTrigger: (option: { value: string }) => local.session.togglePin(option.value),
hidden: sessionTabs.enabled(),
onTrigger: (option) => local.session.togglePin(option.value),
},
{
command: "session.delete",
+7 -7
View File
@@ -15,7 +15,7 @@ export type DialogSkillProps = {
export function DialogSkill(props: DialogSkillProps) {
const dialog = useDialog()
const data = useData()
const { themeV2 } = useTheme()
const theme = 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={themeV2.text.subdued}>No skills available</text>
<text fg={theme.text.subdued}>No skills available</text>
</box>
}
>
<Match when={showError()}>
<box paddingLeft={4} paddingRight={4} paddingTop={1}>
<text fg={themeV2.text.feedback.error.default} attributes={TextAttributes.BOLD}>
<text fg={theme.text.feedback.error.default} attributes={TextAttributes.BOLD}>
Could not load skills
</text>
<text fg={themeV2.text.subdued}>{errorMessage(loadError())}</text>
<text fg={themeV2.text.subdued}>Close and reopen Skills to try again.</text>
<text fg={theme.text.subdued}>{errorMessage(loadError())}</text>
<text fg={theme.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={themeV2.text.subdued}>Loading skills</text>
<text fg={theme.text.subdued}>Loading skills</text>
</box>
</Match>
</Switch>
}
noMatchView={
<box paddingLeft={4} paddingRight={4} paddingTop={1}>
<text fg={themeV2.text.subdued}>No skills found</text>
<text fg={theme.text.subdued}>No skills found</text>
</box>
}
/>
+4 -4
View File
@@ -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 { useTheme } from "../context/theme"
import { useThemes } 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 { themeV2 } = useTheme().contextual("elevated")
const theme = useThemes().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 ? themeV2.background.action.destructive.focused : undefined,
fg: isDeleting ? themeV2.text.action.destructive.focused : undefined,
bg: isDeleting ? theme.background.action.destructive.focused : undefined,
fg: isDeleting ? theme.text.action.destructive.focused : undefined,
value: index,
description: getRelativeTime(entry.timestamp),
footer: lineCount > 1 ? `~${lineCount} lines` : undefined,
+13 -13
View File
@@ -1,5 +1,5 @@
import { TextAttributes } from "@opentui/core"
import { useTheme } from "../context/theme"
import { useThemes } 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 { themeV2 } = useTheme().contextual("elevated")
const theme = useThemes().contextual("elevated")
const dialog = useDialog()
const mcp = createMemo(() => data.location.mcp.server.list() ?? [])
const color = (status: string) => {
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
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
}
return (
<box paddingLeft={2} paddingRight={2} gap={1} paddingBottom={1}>
<box flexDirection="row" justifyContent="space-between">
<text fg={themeV2.text.default} attributes={TextAttributes.BOLD}>
<text fg={theme.text.default} attributes={TextAttributes.BOLD}>
Status
</text>
<text fg={themeV2.text.subdued} onMouseUp={() => dialog.clear()}>
<text fg={theme.text.subdued} onMouseUp={() => dialog.clear()}>
esc
</text>
</box>
<Show when={mcp().length > 0} fallback={<text fg={themeV2.text.default}>No MCP servers</text>}>
<Show when={mcp().length > 0} fallback={<text fg={theme.text.default}>No MCP servers</text>}>
<box>
<text fg={themeV2.text.default}>
<text fg={theme.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={themeV2.text.default} wrapMode="word">
<text fg={theme.text.default} wrapMode="word">
<b>{item.name}</b>{" "}
<span style={{ fg: themeV2.text.subdued }}>
<span style={{ fg: theme.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 { useTheme } from "../context/theme"
import { useThemes } from "../context/theme"
import { useDialog } from "../ui/dialog"
import { onCleanup } from "solid-js"
export function DialogThemeList() {
const theme = useTheme()
const options = Object.keys(theme.all())
const themes = useThemes()
const options = Object.keys(themes.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 = theme.selected
const initial = themes.selected
onCleanup(() => {
if (!confirmed) theme.set(initial)
if (!confirmed) themes.set(initial)
})
return (
@@ -26,10 +26,10 @@ export function DialogThemeList() {
options={options}
current={initial}
onMove={(opt) => {
theme.set(opt.value)
themes.set(opt.value)
}}
onSelect={(opt) => {
theme.set(opt.value)
themes.set(opt.value)
confirmed = true
dialog.clear()
}}
@@ -38,12 +38,12 @@ export function DialogThemeList() {
}}
onFilter={(query) => {
if (query.length === 0) {
theme.set(initial)
themes.set(initial)
return
}
const first = ref.filtered[0]
if (first) theme.set(first.value)
if (first) themes.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 { useTheme } from "../context/theme"
import { useThemes } 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 { themeV2 } = useTheme().contextual("elevated")
const { themeV2: overlayTheme } = useTheme().contextual("overlay")
const theme = useThemes().contextual("elevated")
const overlayTheme = useThemes().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={themeV2.text.default}>
<text attributes={TextAttributes.BOLD} fg={theme.text.default}>
{props.title ?? "File Changes Found"}
</text>
<text fg={themeV2.text.subdued} onMouseUp={() => dialog.clear()}>
<text fg={theme.text.subdued} onMouseUp={() => dialog.clear()}>
esc
</text>
</box>
<box paddingLeft={2} paddingRight={2}>
<text fg={themeV2.text.subdued} wrapMode="word">
<text fg={theme.text.subdued} wrapMode="word">
{props.message ?? "Do you want to move these changes with the session?"}
</text>
</box>
@@ -118,16 +118,14 @@ export function DialogWorkspaceFileChanges(props: {
<box
paddingLeft={2}
paddingRight={2}
backgroundColor={item === store.active ? themeV2.background.action.primary.focused : undefined}
backgroundColor={item === store.active ? theme.background.action.primary.focused : undefined}
onMouseUp={() => {
setStore("active", item)
props.onSelect(item)
dialog.clear()
}}
>
<text fg={item === store.active ? themeV2.text.action.primary.focused : themeV2.text.subdued}>
{item}
</text>
<text fg={item === store.active ? theme.text.action.primary.focused : theme.text.subdued}>{item}</text>
</box>
)}
</For>
+4 -4
View File
@@ -5,10 +5,10 @@ import { tint } from "../theme/color"
import { logo } from "../logo"
export function Logo() {
const { themeV2 } = useTheme()
const theme = useTheme()
const renderLine = (line: string, fg: RGBA, bold: boolean): JSX.Element[] => {
const shadow = tint(themeV2.background.default, fg, 0.25)
const shadow = tint(theme.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, themeV2.text.subdued, false)}</box>
<box flexDirection="row">{renderLine(logo.right[index()], themeV2.text.default, true)}</box>
<box flexDirection="row">{renderLine(line, theme.text.subdued, false)}</box>
<box flexDirection="row">{renderLine(logo.right[index()], theme.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 { themeV2 } = useTheme()
const theme = useTheme()
return (
<box width="100%" height="100%" alignItems="center" justifyContent="center" flexDirection="column" gap={1}>
<text fg={themeV2.text.feedback.warning.default}>
<text fg={theme.text.feedback.warning.default}>
Unknown plugin route: {props.id}/{props.name}
</text>
<box
onMouseUp={props.onHome}
backgroundColor={themeV2.background.action.primary.hovered}
backgroundColor={theme.background.action.primary.hovered}
paddingLeft={1}
paddingRight={1}
>
<text fg={themeV2.text.action.primary.hovered}>go home</text>
<text fg={theme.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 { useTheme } from "../../context/theme"
import { useThemes } 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 { themeV2 } = useTheme().contextual("overlay")
const theme = useThemes().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={themeV2.border.default}
borderColor={theme.border.default}
>
<scrollbox
ref={(r: ScrollBoxRenderable) => (scroll = r)}
backgroundColor={themeV2.background.default}
backgroundColor={theme.background.default}
height={height()}
scrollbarOptions={{ visible: false }}
scrollAcceleration={scrollAcceleration()}
@@ -711,9 +711,7 @@ export function Autocomplete(props: {
each={options()}
fallback={
<box paddingLeft={1} paddingRight={1}>
<text fg={emptyError() ? themeV2.text.feedback.error.default : themeV2.text.subdued}>
{emptyMessage()}
</text>
<text fg={emptyError() ? theme.text.feedback.error.default : theme.text.subdued}>{emptyMessage()}</text>
</box>
}
>
@@ -721,7 +719,7 @@ export function Autocomplete(props: {
<box
paddingLeft={1}
paddingRight={1}
backgroundColor={index === store.selected ? themeV2.background.action.primary.focused : undefined}
backgroundColor={index === store.selected ? theme.background.action.primary.focused : undefined}
flexDirection="row"
onMouseMove={() => {
setStore("input", "mouse")
@@ -737,14 +735,14 @@ export function Autocomplete(props: {
onMouseUp={() => select()}
>
<text
fg={index === store.selected ? themeV2.text.action.primary.focused : themeV2.text.default}
fg={index === store.selected ? theme.text.action.primary.focused : theme.text.default}
flexShrink={0}
>
{option().display}
</text>
<Show when={option().description}>
<text
fg={index === store.selected ? themeV2.text.action.primary.focused : themeV2.text.subdued}
fg={index === store.selected ? theme.text.action.primary.focused : theme.text.subdued}
wrapMode="none"
>
{" " + option().description?.trimStart()}
+43 -42
View File
@@ -12,7 +12,7 @@ import { registerOpencodeSpinner } from "../register-spinner"
import path from "path"
import { fileURLToPath } from "url"
import { useLocal } from "../../context/local"
import { useTheme } from "../../context/theme"
import { useTheme, useThemes } from "../../context/theme"
import { tint } from "../../theme/color"
import { EmptyBorder, SplitBorder } from "../../ui/border"
import { useTuiPaths, useTuiTerminalEnvironment } from "../../context/runtime"
@@ -189,7 +189,8 @@ export function Prompt(props: PromptProps) {
const renderer = useRenderer()
const exit = useExit()
const dimensions = useTerminalDimensions()
const { themeV2, syntax } = useTheme()
const theme = useTheme()
const { currentSyntax: syntax } = useThemes()
const animationsEnabled = createMemo(() => config.animations ?? true)
const list = createMemo(() => props.placeholders?.normal ?? [])
const shell = createMemo(() => props.placeholders?.shell ?? [])
@@ -296,8 +297,8 @@ export function Prompt(props: PromptProps) {
createEffect(() => {
if (!input || input.isDestroyed) return
if (props.disabled) input.cursorColor = themeV2.background.surface.offset
if (!props.disabled) input.cursorColor = themeV2.text.default
if (props.disabled) input.cursorColor = theme.background.surface.offset
if (!props.disabled) input.cursorColor = theme.text.default
})
const usage = createMemo(() => {
@@ -1299,10 +1300,10 @@ export function Prompt(props: PromptProps) {
}
const highlight = createMemo(() => {
if (leader()) return themeV2.border.default
if (store.mode === "shell") return themeV2.text.action.primary.selected
if (leader()) return theme.border.default
if (store.mode === "shell") return theme.text.action.primary.selected
const agent = local.agent.current()
if (!agent) return themeV2.border.default
if (!agent) return theme.border.default
return local.agent.color(agent.id)
})
const agentLabel = createMemo(() => {
@@ -1324,7 +1325,7 @@ export function Prompt(props: PromptProps) {
() => !!local.agent.current() && store.mode === "normal" && showVariant(),
animationsEnabled,
)
const borderHighlight = createMemo(() => tint(themeV2.border.default, highlight(), agentMetaAlpha()))
const borderHighlight = createMemo(() => tint(theme.border.default, highlight(), agentMetaAlpha()))
const placeholderText = createMemo(() => {
if (props.showPlaceholder === false) return undefined
@@ -1344,7 +1345,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) : themeV2.border.default
const color = agent ? local.agent.color(agent.id) : theme.border.default
return {
frames: createFrames({
color,
@@ -1364,7 +1365,7 @@ export function Prompt(props: PromptProps) {
})
const maxHeight = createMemo(() => Math.max(6, Math.floor(dimensions().height / 3)))
const promptBg = createMemo(() => themeV2.raise(themeV2.background.surface.offset))
const promptBg = createMemo(() => theme.raise(theme.background.surface.offset))
return (
<>
@@ -1390,9 +1391,9 @@ export function Prompt(props: PromptProps) {
<textarea
width="100%"
placeholder={placeholderText()}
placeholderColor={themeV2.text.subdued}
textColor={leader() ? themeV2.text.subdued : themeV2.text.default}
focusedTextColor={leader() ? themeV2.text.subdued : themeV2.text.default}
placeholderColor={theme.text.subdued}
textColor={leader() ? theme.text.subdued : theme.text.default}
focusedTextColor={leader() ? theme.text.subdued : theme.text.default}
minHeight={1}
maxHeight={maxHeight()}
onContentChange={() => {
@@ -1452,7 +1453,7 @@ export function Prompt(props: PromptProps) {
setTimeout(() => {
// setTimeout is a workaround and needs to be addressed properly
if (!input || input.isDestroyed) return
input.cursorColor = themeV2.text.default
input.cursorColor = theme.text.default
}, 0)
}}
onMouseDown={(r: MouseEvent) => {
@@ -1460,7 +1461,7 @@ export function Prompt(props: PromptProps) {
r.target?.focus()
}}
focusedBackgroundColor="transparent"
cursorColor={props.disabled ? themeV2.background.surface.offset : themeV2.text.default}
cursorColor={props.disabled ? theme.background.surface.offset : theme.text.default}
syntaxStyle={syntax()}
/>
<box flexDirection="row" flexShrink={0} paddingTop={1} gap={1} justifyContent="space-between">
@@ -1470,24 +1471,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(themeV2.text.subdued, agentMetaAlpha())}>auto</text>
<text fg={fadeColor(theme.text.subdued, agentMetaAlpha())}>auto</text>
</Show>
<Show when={store.mode === "normal"}>
<box flexDirection="row" gap={1}>
<text fg={fadeColor(themeV2.text.subdued, modelMetaAlpha())}>·</text>
<text fg={fadeColor(theme.text.subdued, modelMetaAlpha())}>·</text>
<text
flexShrink={0}
fg={fadeColor(leader() ? themeV2.text.subdued : themeV2.text.default, modelMetaAlpha())}
fg={fadeColor(leader() ? theme.text.subdued : theme.text.default, modelMetaAlpha())}
>
{local.model.parsed().model}
</text>
<text fg={fadeColor(themeV2.text.subdued, modelMetaAlpha())}>{currentProviderLabel()}</text>
<text fg={fadeColor(theme.text.subdued, modelMetaAlpha())}>{currentProviderLabel()}</text>
<Show when={showVariant()}>
<text fg={fadeColor(themeV2.text.subdued, variantMetaAlpha())}>·</text>
<text fg={fadeColor(theme.text.subdued, variantMetaAlpha())}>·</text>
<text>
<span
style={{
fg: fadeColor(themeV2.text.feedback.warning.default, variantMetaAlpha()),
fg: fadeColor(theme.text.feedback.warning.default, variantMetaAlpha()),
bold: true,
}}
>
@@ -1541,12 +1542,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={themeV2.text.subdued}>[]</text>}>
<Show when={config.animations ?? true} fallback={<text fg={theme.text.subdued}>[]</text>}>
<spinner color={spinnerDef().color} frames={spinnerDef().frames} interval={40} />
</Show>
</box>
<text
fg={store.interrupt > 0 ? themeV2.background.action.primary.default : themeV2.text.default}
fg={store.interrupt > 0 ? theme.background.action.primary.default : theme.text.default}
wrapMode="none"
truncate
flexShrink={1}
@@ -1554,7 +1555,7 @@ export function Prompt(props: PromptProps) {
esc{" "}
<span
style={{
fg: store.interrupt > 0 ? themeV2.background.action.primary.default : themeV2.text.subdued,
fg: store.interrupt > 0 ? theme.background.action.primary.default : theme.text.subdued,
}}
>
{store.interrupt > 0 ? "again to interrupt" : "interrupt"}
@@ -1565,16 +1566,16 @@ export function Prompt(props: PromptProps) {
<Match when={move.progress()}>
{(progress) => (
<box paddingLeft={3} height={1} minHeight={0} flexShrink={1}>
<Spinner color={themeV2.hue.accent[500]}>
<Spinner color={theme.hue.accent[500]}>
{progress()}
<span style={{ fg: themeV2.text.subdued }}>{".".repeat(move.creatingDots())}</span>
<span style={{ fg: theme.text.subdued }}>{".".repeat(move.creatingDots())}</span>
</Spinner>
</box>
)}
</Match>
<Match when={move.pendingNew()}>
<box paddingLeft={3} height={1} minHeight={0} flexShrink={1}>
<text fg={themeV2.hue.accent[500]} wrapMode="none" truncate>
<text fg={theme.hue.accent[500]} wrapMode="none" truncate>
(new working copy)
</text>
</box>
@@ -1582,7 +1583,7 @@ export function Prompt(props: PromptProps) {
<Match when={true}>
<Show when={!props.hint && locationLabel()} fallback={props.hint ?? <text />}>
{(location) => (
<text fg={themeV2.text.subdued} wrapMode="none" truncate flexGrow={1} flexShrink={1}>
<text fg={theme.text.subdued} wrapMode="none" truncate flexGrow={1} flexShrink={1}>
{location()}
</text>
)}
@@ -1596,7 +1597,7 @@ export function Prompt(props: PromptProps) {
wrapMode="none"
truncate
flexShrink={1}
fg={editorContextLabelState() === "pending" ? themeV2.hue.accent[500] : themeV2.text.subdued}
fg={editorContextLabelState() === "pending" ? theme.hue.accent[500] : theme.text.subdued}
>
{file()}
</text>
@@ -1606,40 +1607,40 @@ export function Prompt(props: PromptProps) {
<Match when={store.mode === "normal"}>
<Switch>
<Match when={liveWorkStatusVisible() || statusItems().length > 0}>
<text fg={themeV2.text.subdued} wrapMode="none" truncate flexShrink={1}>
<text fg={theme.text.subdued} wrapMode="none" truncate flexShrink={1}>
<Show when={liveWorkStatusVisible() && liveWorkShortcut()}>
{(shortcut) => <span style={{ fg: themeV2.text.default }}>{shortcut()} </span>}
{(shortcut) => <span style={{ fg: theme.text.default }}>{shortcut()} </span>}
</Show>
<Show when={subagentStatusLabel()}>
{(label) => <span style={{ fg: themeV2.text.subdued }}>{label()}</span>}
{(label) => <span style={{ fg: theme.text.subdued }}>{label()}</span>}
</Show>
<Show when={subagentStatusLabel() && shellStatusLabel()}>
<span style={{ fg: themeV2.text.subdued }}> · </span>
<span style={{ fg: theme.text.subdued }}> · </span>
</Show>
<Show when={shellStatusLabel()}>
{(label) => <span style={{ fg: themeV2.text.subdued }}>{label()}</span>}
{(label) => <span style={{ fg: theme.text.subdued }}>{label()}</span>}
</Show>
<Show when={liveWorkStatusVisible() && statusItems().length > 0}>
<span style={{ fg: themeV2.text.subdued }}> · </span>
<span style={{ fg: theme.text.subdued }}> · </span>
</Show>
<Show when={statusItems().length > 0}>
<span style={{ fg: themeV2.text.subdued }}>{statusItems().join(" · ")}</span>
<span style={{ fg: theme.text.subdued }}>{statusItems().join(" · ")}</span>
</Show>
</text>
</Match>
<Match when={true}>
<text fg={themeV2.text.default} flexShrink={0}>
{agentShortcut()} <span style={{ fg: themeV2.text.subdued }}>agents</span>
<text fg={theme.text.default} flexShrink={0}>
{agentShortcut()} <span style={{ fg: theme.text.subdued }}>agents</span>
</text>
</Match>
</Switch>
<text fg={themeV2.text.default} flexShrink={0}>
{paletteShortcut()} <span style={{ fg: themeV2.text.subdued }}>commands</span>
<text fg={theme.text.default} flexShrink={0}>
{paletteShortcut()} <span style={{ fg: theme.text.subdued }}>commands</span>
</text>
</Match>
<Match when={store.mode === "shell"}>
<text fg={themeV2.text.default} flexShrink={0}>
esc <span style={{ fg: themeV2.text.subdued }}>exit shell mode</span>
<text fg={theme.text.default} flexShrink={0}>
esc <span style={{ fg: theme.text.subdued }}>exit shell mode</span>
</text>
</Match>
</Switch>
+5 -5
View File
@@ -1,9 +1,9 @@
import { RGBA } from "@opentui/core"
import { useTheme } from "../context/theme"
import { useThemes } from "../context/theme"
import { Spinner } from "./spinner"
export function Reconnecting() {
const { themeV2 } = useTheme().contextual("elevated")
const theme = useThemes().contextual("elevated")
return (
<box
@@ -21,15 +21,15 @@ export function Reconnecting() {
width={48}
maxWidth="90%"
flexDirection="column"
backgroundColor={themeV2.background.default}
backgroundColor={theme.background.default}
paddingTop={1}
paddingBottom={1}
paddingLeft={2}
paddingRight={2}
gap={1}
>
<Spinner color={themeV2.text.default}>Restarting service...</Spinner>
<text fg={themeV2.text.subdued}>Your session will resume automatically.</text>
<Spinner color={theme.text.default}>Restarting service...</Spinner>
<text fg={theme.text.subdued}>Your session will resume automatically.</text>
</box>
</box>
)
+234
View File
@@ -0,0 +1,234 @@
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}
color={pulseColor()}
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>
)
}
+2 -2
View File
@@ -11,9 +11,9 @@ export { SPINNER_FRAMES } from "./spinner-frames"
registerOpencodeSpinner()
export function Spinner(props: { children?: JSX.Element; color?: RGBA }) {
const { themeV2 } = useTheme()
const theme = useTheme()
const config = useConfig().data
const color = () => props.color ?? themeV2.text.subdued
const color = () => props.color ?? theme.text.subdued
return (
<Show
when={config.animations ?? true}
@@ -1,9 +1,9 @@
import { createEffect, createMemo, createSignal, onCleanup, Show } from "solid-js"
import { useTheme } from "../context/theme"
import { useThemes } from "../context/theme"
import { Spinner } from "./spinner"
export function StartupLoading(props: { ready: () => boolean }) {
const { themeV2 } = useTheme().contextual("elevated")
const theme = useThemes().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={themeV2.background.default} paddingLeft={1} paddingRight={1}>
<Spinner color={themeV2.text.subdued}>{text()}</Spinner>
<box backgroundColor={theme.background.default} paddingLeft={1} paddingRight={1}>
<Spinner color={theme.text.subdued}>{text()}</Spinner>
</box>
</box>
</Show>
+207
View File
@@ -0,0 +1,207 @@
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
color?: 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 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)))
class TabPulseRenderable extends Renderable {
private _enabled: boolean
private _active: boolean
private _complete: boolean
private _color: 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._color = options.color ?? RGBA.defaultForeground()
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 color(value: RGBA) {
if (value.equals(this._color)) return
this._color = 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._enabled || this.width <= 0) return
const runningOpacity = this._active
? 1
: this.fadeClock === undefined
? 0
: 1 - smootherstep(clamp(this.fadeClock / RUN_FADE_OUT))
const completionOpacity =
this.completionClock === undefined ? 0 : completionPulseOpacity(this.completionClock / COMPLETION_DURATION)
if (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 running = tint(this._backgroundColor, 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
color: 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}
color={props.color}
completionColor={props.completionColor ?? props.color}
backgroundColor={props.backgroundColor}
/>
)
}
@@ -1,13 +1,13 @@
import { onCleanup } from "solid-js"
import { useTheme } from "../context/theme"
import { useThemes } from "../context/theme"
import { useToast } from "../ui/toast"
export function ThemeErrorToast() {
const theme = useTheme()
const themes = useThemes()
const toast = useToast()
onCleanup(
theme.onError(({ name, error }) =>
themes.onError(({ name, error }) =>
toast.show({
variant: "error",
title: `Failed to load theme: ${name}`,
+3
View File
@@ -120,6 +120,9 @@ export const Info = Schema.Struct({
markdown: Schema.optional(Schema.Literals(["source", "rendered"])).annotate({
description: "Show Markdown syntax markers or conceal them in rendered transcript content",
}),
tabs: Schema.optional(Schema.Boolean).annotate({
description: "Use a persistent session tab strip instead of pinned quick-switch sessions",
}),
}),
).annotate({ description: "Session transcript presentation settings" }),
mini: Schema.optional(
+33 -1
View File
@@ -87,6 +87,13 @@ export const Definitions = {
session_move: keybind("none", "Move session"),
session_new: keybind("<leader>n", "Create a new session"),
session_list: keybind("<leader>l", "List all sessions"),
session_tab_next: keybind("ctrl+tab,<leader>right", "Switch to next open session tab"),
session_tab_previous: keybind("ctrl+shift+tab,<leader>left", "Switch to previous open session tab"),
session_tab_history_back: keybind("ctrl+o", "Go back in session tab history"),
session_tab_history_forward: keybind("ctrl+i", "Go forward in session tab history"),
session_tab_next_unread: keybind("<leader>down", "Switch to next unread session tab"),
session_tab_previous_unread: keybind("<leader>up", "Switch to previous unread session tab"),
session_tab_close: keybind("<leader>w", "Close current session tab"),
session_timeline: keybind("<leader>g", "Show session timeline"),
session_fork: keybind("none", "Fork session from message"),
session_rename: keybind("ctrl+r", "Rename session"),
@@ -97,7 +104,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,<leader>down", "Toggle subagent picker"),
session_child_first: keybind("down", "Toggle subagent picker"),
session_child_cycle: keybind("right", "Go to next child session"),
session_child_cycle_reverse: keybind("left", "Go to previous child session"),
session_parent: keybind("up", "Go to parent session"),
@@ -111,6 +118,15 @@ export const Definitions = {
session_quick_switch_7: keybind("<leader>7", "Switch to session in quick slot 7"),
session_quick_switch_8: keybind("<leader>8", "Switch to session in quick slot 8"),
session_quick_switch_9: keybind("<leader>9", "Switch to session in quick slot 9"),
session_tab_select_1: keybind("<leader>1,ctrl+1", "Switch to session tab 1"),
session_tab_select_2: keybind("<leader>2,ctrl+2", "Switch to session tab 2"),
session_tab_select_3: keybind("<leader>3,ctrl+3", "Switch to session tab 3"),
session_tab_select_4: keybind("<leader>4,ctrl+4", "Switch to session tab 4"),
session_tab_select_5: keybind("<leader>5,ctrl+5", "Switch to session tab 5"),
session_tab_select_6: keybind("<leader>6,ctrl+6", "Switch to session tab 6"),
session_tab_select_7: keybind("<leader>7,ctrl+7", "Switch to session tab 7"),
session_tab_select_8: keybind("<leader>8,ctrl+8", "Switch to session tab 8"),
session_tab_select_9: keybind("<leader>9,ctrl+9", "Switch to session tab 9"),
stash_delete: keybind("ctrl+d", "Delete stash entry"),
model_provider_list: keybind("ctrl+a", "Open provider list from model dialog"),
@@ -289,6 +305,13 @@ 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",
@@ -313,6 +336,15 @@ 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",
+4 -3
View File
@@ -17,7 +17,7 @@ import {
type ModelPreference,
type ModelPreferenceModel,
} from "../model-preference"
import { useTheme } from "./theme"
import { useTheme, useThemes } from "./theme"
import { useToast } from "../ui/toast"
import { useRoute } from "./route"
import { useData } from "./data"
@@ -50,7 +50,8 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
const data = useData()
const client = useClient()
const toast = useToast()
const { themeV2, mode } = useTheme()
const theme = useTheme()
const { mode } = useThemes()
const route = useRoute()
const paths = useTuiPaths()
const args = useArgs()
@@ -82,7 +83,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
const colors = createMemo(() => {
const step = mode() === "light" ? 800 : 200
return dedupeWith(
themeV2.categorical.map((scale) => scale[step]),
theme.categorical.map((scale) => scale[step]),
(first, second) => first.equals(second),
)
})
@@ -0,0 +1,153 @@
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,
}
}
+267
View File
@@ -0,0 +1,267 @@
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.session?.tabs ?? 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 })
},
}
},
})
+24 -17
View File
@@ -96,13 +96,13 @@ type State = {
}
type ContextName = "elevated" | "overlay"
type ThemeService = {
themeV2: ComponentTheme
contextual(context: ContextName): ThemeService
type Themes = {
current: ComponentTheme
contextual(context: ContextName): ComponentTheme
readonly selected: string
all: typeof allThemes
has: typeof hasTheme
syntax: Accessor<SyntaxStyle>
currentSyntax: Accessor<SyntaxStyle>
mode: Accessor<"dark" | "light">
modes: Accessor<readonly ("dark" | "light")[]>
supports(mode: "dark" | "light"): boolean
@@ -308,7 +308,7 @@ const themeContext = createSimpleContext({
const valuesV2 = () => selected().theme
valuesV2()
themePerformance.set("Init", `${(performance.now() - initStarted).toFixed(2)} ms`)
const themeV2 = createComponentTheme(valuesV2, mode)
const current = createComponentTheme(valuesV2, mode)
const contextsV2 = {
elevated: createComponentTheme(() => valuesV2().contexts["@context:elevated"] ?? valuesV2(), mode),
overlay: createComponentTheme(() => valuesV2().contexts["@context:overlay"] ?? valuesV2(), mode),
@@ -316,19 +316,19 @@ const themeContext = createSimpleContext({
createEffect(() => renderer.setBackgroundColor(valuesV2().background.default))
const syntax = createSyntaxStyleMemo(() => generateSyntax(valuesV2(), mode()))
const currentSyntax = createSyntaxStyleMemo(() => generateSyntax(valuesV2(), mode()))
function contextual(context: ContextName) {
return contextualServices[context]
return contextsV2[context]
}
const service: ThemeService = {
themeV2,
const service: Themes = {
current,
currentSyntax,
contextual,
get selected() {
return store.active
},
all: allThemes,
has: hasTheme,
syntax,
mode,
modes,
supports: (requested) => modes().includes(requested),
@@ -355,21 +355,28 @@ const themeContext = createSimpleContext({
return store.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 {
current,
themes: service,
get ready() {
return service.ready
},
}
return service
},
})
export const useTheme = themeContext.use
export function useThemes() {
return themeContext.use().themes
}
export function useTheme() {
return themeContext.use().current
}
export const ThemeProvider = themeContext.provider
export function ThemeContextProvider(props: ParentProps<{ context: ContextName }>) {
const theme = useTheme()
const themes = useThemes()
return (
<themeContext.context.Provider value={theme.contextual(props.context)}>
<themeContext.context.Provider value={{ current: themes.contextual(props.context), themes, ready: themes.ready }}>
{props.children}
</themeContext.context.Provider>
)
@@ -11,7 +11,7 @@ function Directory(props: { context: Plugin.Context; maxWidth: number }) {
return (
<Show when={directory()}>
{(value) => <FilePath value={value()} maxWidth={props.maxWidth} fg={props.context.theme.themeV2.text.subdued} />}
{(value) => <FilePath value={value()} maxWidth={props.maxWidth} fg={props.context.theme.text.subdued} />}
</Show>
)
}
@@ -24,18 +24,16 @@ function Mcp(props: { context: Plugin.Context }) {
return (
<Show when={list().length}>
<box gap={1} flexDirection="row" flexShrink={0}>
<text fg={props.context.theme.themeV2.text.default}>
<text fg={props.context.theme.text.default}>
<Switch>
<Match when={failed()}>
<span style={{ fg: props.context.theme.themeV2.text.feedback.error.default }}> </span>
<span style={{ fg: props.context.theme.text.feedback.error.default }}> </span>
</Match>
<Match when={true}>
<span
style={{
fg:
count() > 0
? props.context.theme.themeV2.text.feedback.success.default
: props.context.theme.themeV2.text.subdued,
count() > 0 ? props.context.theme.text.feedback.success.default : props.context.theme.text.subdued,
}}
>
{" "}
@@ -44,7 +42,7 @@ function Mcp(props: { context: Plugin.Context }) {
</Switch>
{count()} MCP
</text>
<text fg={props.context.theme.themeV2.text.subdued}>/status</text>
<text fg={props.context.theme.text.subdued}>/status</text>
</box>
</Show>
)
@@ -77,7 +75,7 @@ function View(props: { context: Plugin.Context }) {
<Mcp context={props.context} />
<box flexGrow={1} />
<box flexShrink={0}>
<text fg={props.context.theme.themeV2.text.subdued}>{props.context.app.version}</text>
<text fg={props.context.theme.text.subdued}>{props.context.app.version}</text>
</box>
</box>
)
@@ -1,6 +1,5 @@
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", {
@@ -9,7 +8,7 @@ const money = new Intl.NumberFormat("en-US", {
})
function View(props: { context: Plugin.Context; sessionID: string }) {
const { themeV2 } = useTheme()
const theme = props.context.theme
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))
@@ -20,20 +19,20 @@ function View(props: { context: Plugin.Context; sessionID: string }) {
return (
<box>
<text fg={themeV2.text.default}>
<text fg={theme.text.default}>
<b>Context</b>
</text>
<Show when={state()} fallback={<text fg={themeV2.text.subdued}>Not measured</text>}>
<Show when={state()} fallback={<text fg={theme.text.subdued}>Not measured</text>}>
{(value) => (
<>
<text fg={themeV2.text.subdued}>{value().tokens.toLocaleString()} tokens</text>
<text fg={theme.text.subdued}>{value().tokens.toLocaleString()} tokens</text>
<Show when={value().percent !== undefined}>
<text fg={themeV2.text.subdued}>{value().percent}% used</text>
<text fg={theme.text.subdued}>{value().percent}% used</text>
</Show>
</>
)}
</Show>
<text fg={themeV2.text.subdued}>{money.format(cost())} spent</text>
<text fg={theme.text.subdued}>{money.format(cost())} spent</text>
</box>
)
}
@@ -8,7 +8,7 @@ function View(props: { context: Plugin.Context }) {
)
return (
<Show when={directory()}>
{(value) => <FilePath value={value()} maxWidth={38} fg={props.context.theme.themeV2.text.subdued} />}
{(value) => <FilePath value={value()} maxWidth={38} fg={props.context.theme.text.subdued} />}
</Show>
)
}
@@ -1,14 +1,14 @@
import { Plugin } from "@opencode-ai/plugin/tui"
import { useTheme } from "../../context/theme"
import { Plugin, usePlugin } from "@opencode-ai/plugin/tui"
function View() {
const { themeV2 } = useTheme()
const context = usePlugin()
const theme = context.theme
return (
<box>
<text fg={themeV2.text.default}>
<text fg={theme.text.default}>
<b>LSP</b>
</text>
<text fg={themeV2.text.subdued}>LSP status unavailable</text>
<text fg={theme.text.subdued}>LSP status unavailable</text>
</box>
)
}
@@ -1,10 +1,9 @@
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 { themeV2 } = useTheme()
const theme = props.context.theme
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)
@@ -19,12 +18,12 @@ function View(props: { context: Plugin.Context; sessionID: string }) {
)
const dot = (status: string) => {
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
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
}
return (
@@ -32,12 +31,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={themeV2.text.default}>{open() ? "▼" : "▶"}</text>
<text fg={theme.text.default}>{open() ? "▼" : "▶"}</text>
</Show>
<text fg={themeV2.text.default}>
<text fg={theme.text.default}>
<b>MCP</b>
<Show when={!open()}>
<span style={{ fg: themeV2.text.subdued }}>
<span style={{ fg: theme.text.subdued }}>
{" "}
({on()} active{bad() > 0 ? `, ${bad()} error${bad() > 1 ? "s" : ""}` : ""})
</span>
@@ -56,9 +55,9 @@ function View(props: { context: Plugin.Context; sessionID: string }) {
>
</text>
<text fg={themeV2.text.default} wrapMode="word">
<text fg={theme.text.default} wrapMode="word">
{item.name}{" "}
<span style={{ fg: themeV2.text.subdued }}>
<span style={{ fg: theme.text.subdued }}>
<Switch fallback={item.status.status}>
<Match when={item.status.status === "connected"}>Connected</Match>
<Match when={item.status.status === "failed"}>
@@ -1,15 +1,16 @@
/** @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
@@ -23,7 +24,7 @@ export type DiffViewerFileTreeProps = {
}
export function DiffViewerFileTree(props: DiffViewerFileTreeProps) {
const { themeV2 } = useTheme()
const theme = props.context.theme
const tree = createMemo(() => buildFileTree(props.files))
const rows = createMemo(() => flattenFileTree(tree(), props.expandedNodes))
let scroll: ScrollBoxRenderable | undefined
@@ -38,10 +39,10 @@ export function DiffViewerFileTree(props: DiffViewerFileTreeProps) {
requestAnimationFrame(scrollSelectedIntoView)
})
const fadedColor = () => tint(themeV2.text.default, themeV2.background.default, 0.75)
const fadedColor = () => tint(theme.text.default, theme.background.default, 0.75)
return (
<Panel border="both" width={props.width}>
<Panel border="both" width={props.width} context={props.context}>
<scrollbox
ref={(element: ScrollBoxRenderable) => (scroll = element)}
verticalScrollbarOptions={{ visible: false }}
@@ -52,7 +53,7 @@ export function DiffViewerFileTree(props: DiffViewerFileTreeProps) {
<text />
</Match>
<Match when={props.files.length === 0}>
<text fg={themeV2.text.default}>No files</text>
<text fg={theme.text.default}>No files</text>
</Match>
<Match when={props.files.length > 0}>
<For each={rows()}>
@@ -71,11 +72,11 @@ export function DiffViewerFileTree(props: DiffViewerFileTreeProps) {
<box
flexDirection="row"
width="100%"
backgroundColor={highlighted() ? themeV2.background.action.primary.focused : undefined}
backgroundColor={highlighted() ? theme.background.action.primary.focused : undefined}
onMouseUp={() => props.onRowClick?.(row)}
>
<text
fg={highlighted() ? themeV2.text.action.primary.focused : fadedColor()}
fg={highlighted() ? theme.text.action.primary.focused : fadedColor()}
wrapMode="none"
flexShrink={0}
>
@@ -85,12 +86,12 @@ export function DiffViewerFileTree(props: DiffViewerFileTreeProps) {
<text
fg={
highlighted()
? themeV2.text.action.primary.focused
? theme.text.action.primary.focused
: selected()
? themeV2.text.formfield.selected
? theme.text.formfield.selected
: reviewed() || row.kind === "directory"
? themeV2.text.subdued
: themeV2.text.default
? theme.text.subdued
: theme.text.default
}
wrapMode="none"
>
@@ -98,7 +99,7 @@ export function DiffViewerFileTree(props: DiffViewerFileTreeProps) {
</text>
</box>
<text
fg={highlighted() ? themeV2.text.action.primary.focused : themeV2.text.subdued}
fg={highlighted() ? theme.text.action.primary.focused : theme.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 }>()
const PanelGroupContext = createContext<{ axis: Axis; context: Plugin.Context }>()
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 }) {
const [local, boxProps] = splitProps(props, ["axis", "children"])
export function PanelGroup(props: JSX.IntrinsicElements["box"] & { axis: Axis; context: Plugin.Context }) {
const [local, boxProps] = splitProps(props, ["axis", "context", "children"])
return (
<PanelGroupContext.Provider value={{ axis: local.axis }}>
<PanelGroupContext.Provider value={{ axis: local.axis, context: local.context }}>
<box minWidth={0} minHeight={0} padding={0} flexDirection={local.axis === "x" ? "row" : "column"} {...boxProps}>
{local.children}
</box>
@@ -28,24 +28,28 @@ export function PanelGroup(props: JSX.IntrinsicElements["box"] & { axis: Axis })
)
}
export function Panel(props: Omit<JSX.IntrinsicElements["box"], "border"> & { border?: PanelBorder }) {
export function Panel(
props: Omit<JSX.IntrinsicElements["box"], "border"> & { border?: PanelBorder; context?: Plugin.Context },
) {
const group = usePanelGroup()
const { themeV2 } = useTheme()
const [local, boxProps] = splitProps(props, ["border"])
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 border = local.border ?? "start"
const borderProps =
border === "none"
? {}
: {
border: panelBorderSides(group?.axis ?? "y", border),
borderColor: themeV2.border.default,
borderColor: theme.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}
/>
@@ -59,9 +63,10 @@ function panelBorderSides(axis: Axis, border: Exclude<PanelBorder, "none">): Bor
export function Separator(props: { axis?: Axis; color?: ColorInput; start?: SeparatorEdge; end?: SeparatorEdge }) {
const group = usePanelGroup()
const { themeV2 } = useTheme()
const color = () => props.color ?? themeV2.border.default
const axis = () => props.axis ?? crossAxis(group?.axis ?? "y")
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)
if (axis() === "y") {
return (
<Show
@@ -10,7 +10,6 @@ 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"
@@ -83,8 +82,7 @@ function DiffViewer(props: { context: Plugin.Context }) {
const dimensions = useTerminalDimensions()
const config = useConfig()
const dialog = props.context.ui.dialog
const themeState = useTheme()
const themeV2 = themeState.themeV2
const theme = props.context.theme
const params = () => {
const route = props.context.ui.router.current()
return (route.type === "plugin" ? route.data : undefined) as
@@ -738,13 +736,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" width="100%" height="100%">
<PanelGroup axis="y" context={props.context} width="100%" height="100%">
<Panel border="none" flexShrink={0} padding={0} paddingLeft={1}>
<text fg={themeV2.text.default}>Diff </text>
<text fg={themeV2.text.subdued}>{diffSourceLabel(mode())}</text>
<text fg={theme.text.default}>Diff </text>
<text fg={theme.text.subdued}>{diffSourceLabel(mode())}</text>
<box flexGrow={1} />
<Show when={!diff.loading && !diff.error}>
<text fg={themeV2.text.subdued}>
<text fg={theme.text.subdued}>
{files().length} {files().length === 1 ? "file" : "files"}
</text>
</Show>
@@ -755,13 +753,13 @@ function DiffViewer(props: { context: Plugin.Context }) {
<Match when={diff.loading}>
<Separator axis="x" />
<box flexGrow={1} paddingLeft={1}>
<text fg={themeV2.text.subdued}>Loading diff</text>
<text fg={theme.text.subdued}>Loading diff</text>
</box>
</Match>
<Match when={!diff.loading && diff.error}>
<Separator axis="x" />
<box flexGrow={1} paddingLeft={1}>
<text fg={themeV2.text.feedback.error.default}>
<text fg={theme.text.feedback.error.default}>
Could not load diff. Reopen the diff viewer to try again.
</text>
</box>
@@ -769,13 +767,14 @@ function DiffViewer(props: { context: Plugin.Context }) {
<Match when={!diff.loading && files().length === 0}>
<Separator axis="x" />
<box flexGrow={1} paddingLeft={1}>
<text fg={themeV2.text.subdued}>No changes to show</text>
<text fg={theme.text.subdued}>No changes to show</text>
</box>
</Match>
<Match when={!diff.loading}>
<PanelGroup axis="x">
<PanelGroup axis="x" context={props.context}>
<Show when={showFileTree()}>
<DiffViewerFileTree
context={props.context}
files={files()}
loading={diff.loading}
error={diff.error}
@@ -812,56 +811,52 @@ function DiffViewer(props: { context: Plugin.Context }) {
paddingLeft={1}
paddingRight={1}
border={patchLeftBorder()}
borderColor={themeV2.border.default}
borderColor={theme.border.default}
>
<text fg={reviewed() ? themeV2.text.subdued : themeV2.text.default}>
{entry.file.file}
</text>
<text fg={reviewed() ? theme.text.subdued : theme.text.default}>{entry.file.file}</text>
<box flexGrow={1} />
<text fg={reviewed() ? themeV2.text.subdued : themeV2.diff.text.added}>
<text fg={reviewed() ? theme.text.subdued : theme.diff.text.added}>
+{entry.file.additions}
</text>
<text fg={reviewed() ? themeV2.text.subdued : themeV2.diff.text.removed}>
<text fg={reviewed() ? theme.text.subdued : theme.diff.text.removed}>
-{entry.file.deletions}
</text>
</box>
<Separator axis="x" start={showFileTree() ? "edge" : undefined} />
<Show
when={entry.file.patch}
fallback={<text fg={themeV2.text.subdued}>No patch available for this file.</text>}
fallback={<text fg={theme.text.subdued}>No patch available for this file.</text>}
>
{(patch) => (
<box border={patchLeftBorder()} borderColor={themeV2.border.default}>
<box border={patchLeftBorder()} borderColor={theme.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={themeState.syntax()}
syntaxStyle={theme.syntaxStyle()}
showLineNumbers={true}
width="100%"
wrapMode="char"
fg={reviewed() ? themeV2.text.subdued : themeV2.text.default}
fg={reviewed() ? theme.text.subdued : theme.text.default}
addedBg={
reviewed() ? themeV2.background.surface.overlay : themeV2.diff.background.added
reviewed() ? theme.background.surface.overlay : theme.diff.background.added
}
removedBg={
reviewed() ? themeV2.background.surface.overlay : themeV2.diff.background.removed
reviewed() ? theme.background.surface.overlay : theme.diff.background.removed
}
addedSignColor={reviewed() ? themeV2.text.subdued : themeV2.diff.highlight.added}
removedSignColor={
reviewed() ? themeV2.text.subdued : themeV2.diff.highlight.removed
}
lineNumberFg={themeV2.diff.lineNumber.text}
addedSignColor={reviewed() ? theme.text.subdued : theme.diff.highlight.added}
removedSignColor={reviewed() ? theme.text.subdued : theme.diff.highlight.removed}
lineNumberFg={theme.diff.lineNumber.text}
addedLineNumberBg={
reviewed()
? themeV2.background.surface.overlay
: themeV2.diff.lineNumber.background.added
? theme.background.surface.overlay
: theme.diff.lineNumber.background.added
}
removedLineNumberBg={
reviewed()
? themeV2.background.surface.overlay
: themeV2.diff.lineNumber.background.removed
? theme.background.surface.overlay
: theme.diff.lineNumber.background.removed
}
/>
</box>
@@ -872,11 +867,7 @@ function DiffViewer(props: { context: Plugin.Context }) {
}}
</For>
<Show when={patchFillerHeight() > 0}>
<box
height={patchFillerHeight()}
border={patchLeftBorder()}
borderColor={themeV2.border.default}
/>
<box height={patchFillerHeight()} border={patchLeftBorder()} borderColor={theme.border.default} />
</Show>
</scrollbox>
<Separator axis="x" start={showFileTree() ? "edge-in" : undefined} />
@@ -889,57 +880,57 @@ function DiffViewer(props: { context: Plugin.Context }) {
<Panel flexShrink={0} gap={2} paddingLeft={1} border="none">
<Show when={switchFocusShortcut()}>
{(shortcut) => (
<text fg={themeV2.text.default}>
{shortcut()} <span style={{ fg: themeV2.text.subdued }}>focus file tree</span>
<text fg={theme.text.default}>
{shortcut()} <span style={{ fg: theme.text.subdued }}>focus file tree</span>
</text>
)}
</Show>
<Show when={nextFileShortcut()}>
{(shortcut) => (
<text fg={themeV2.text.default}>
{shortcut()} <span style={{ fg: themeV2.text.subdued }}>next file</span>
<text fg={theme.text.default}>
{shortcut()} <span style={{ fg: theme.text.subdued }}>next file</span>
</text>
)}
</Show>
<Show when={nextHunkShortcut()}>
{(shortcut) => (
<text fg={themeV2.text.default}>
{shortcut()} <span style={{ fg: themeV2.text.subdued }}>next hunk</span>
<text fg={theme.text.default}>
{shortcut()} <span style={{ fg: theme.text.subdued }}>next hunk</span>
</text>
)}
</Show>
<Show when={previousHunkShortcut()}>
{(shortcut) => (
<text fg={themeV2.text.default}>
{shortcut()} <span style={{ fg: themeV2.text.subdued }}>previous hunk</span>
<text fg={theme.text.default}>
{shortcut()} <span style={{ fg: theme.text.subdued }}>previous hunk</span>
</text>
)}
</Show>
<Show when={previousFileShortcut()}>
{(shortcut) => (
<text fg={themeV2.text.default}>
{shortcut()} <span style={{ fg: themeV2.text.subdued }}>previous file</span>
<text fg={theme.text.default}>
{shortcut()} <span style={{ fg: theme.text.subdued }}>previous file</span>
</text>
)}
</Show>
<Show when={switchSourceShortcut()}>
{(shortcut) => (
<text fg={themeV2.text.default}>
{shortcut()} <span style={{ fg: themeV2.text.subdued }}>switch source</span>
<text fg={theme.text.default}>
{shortcut()} <span style={{ fg: theme.text.subdued }}>switch source</span>
</text>
)}
</Show>
<Show when={markReviewedShortcut()}>
{(shortcut) => (
<text fg={themeV2.text.default}>
{shortcut()} <span style={{ fg: themeV2.text.subdued }}>mark reviewed</span>
<text fg={theme.text.default}>
{shortcut()} <span style={{ fg: theme.text.subdued }}>mark reviewed</span>
</text>
)}
</Show>
<Show when={helpShortcut()}>
{(shortcut) => (
<text fg={themeV2.text.default}>
{shortcut()} <span style={{ fg: themeV2.text.subdued }}>all</span>
<text fg={theme.text.default}>
{shortcut()} <span style={{ fg: theme.text.subdued }}>all</span>
</text>
)}
</Show>
@@ -950,7 +941,7 @@ function DiffViewer(props: { context: Plugin.Context }) {
}
function DiffViewerHelpDialog(props: { context: Plugin.Context }) {
const { themeV2 } = useTheme().contextual("elevated")
const theme = props.context.theme.contextual("elevated")
const shortcut = (id: string) => () => props.context.keymap.shortcuts(id)[0]
const rows = [
{
@@ -1018,30 +1009,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={themeV2.text.default}>
<text attributes={TextAttributes.BOLD} fg={theme.text.default}>
Diff shortcuts
</text>
<text fg={themeV2.text.subdued}>esc</text>
<text fg={theme.text.subdued}>esc</text>
</box>
<box flexDirection="row">
<text fg={themeV2.text.subdued} width={5} wrapMode="none">
<text fg={theme.text.subdued} width={5} wrapMode="none">
Key
</text>
<text fg={themeV2.text.subdued} width={22} wrapMode="none">
<text fg={theme.text.subdued} width={22} wrapMode="none">
Action
</text>
<text fg={themeV2.text.subdued}>Description</text>
<text fg={theme.text.subdued}>Description</text>
</box>
<For each={rows}>
{(row) => (
<box flexDirection="row">
<text fg={themeV2.text.default} width={5} wrapMode="none">
<text fg={theme.text.default} width={5} wrapMode="none">
{row.shortcut() || "-"}
</text>
<text fg={themeV2.text.default} width={22} wrapMode="none">
<text fg={theme.text.default} width={22} wrapMode="none">
{row.action}
</text>
<text fg={themeV2.text.subdued}>{row.description}</text>
<text fg={theme.text.subdued}>{row.description}</text>
</box>
)}
</For>
@@ -1,6 +1,5 @@
import { Plugin } from "@opencode-ai/plugin/tui"
import { useTerminalDimensions } from "@opentui/solid"
import { useTheme } from "../../context/theme"
function Commands(props: { context: Plugin.Context }) {
props.context.keymap.layer(() => ({
@@ -23,8 +22,8 @@ function Commands(props: { context: Plugin.Context }) {
function Scrap(props: { context: Plugin.Context }) {
const dimensions = useTerminalDimensions()
const { themeV2 } = useTheme()
const { themeV2: elevatedTheme } = useTheme().contextual("elevated")
const theme = props.context.theme
const elevatedTheme = props.context.theme.contextual("elevated")
props.context.keymap.layer(() => ({
commands: [
@@ -40,7 +39,7 @@ function Scrap(props: { context: Plugin.Context }) {
}))
return (
<box width={dimensions().width} height={dimensions().height} backgroundColor={themeV2.background.default}>
<box width={dimensions().width} height={dimensions().height} backgroundColor={theme.background.default}>
<box flexGrow={1} />
<box
height={1}
+46 -13
View File
@@ -1,4 +1,4 @@
import type { Plugin } from "@opencode-ai/plugin/tui"
import { PluginContextProvider, type Plugin } from "@opencode-ai/plugin/tui"
import {
batch,
createContext,
@@ -16,6 +16,7 @@ import { fileURLToPath, pathToFileURL } from "url"
import type { Context, Dialog, Page, Slot, SlotMap, SlotName, Toast } from "@opencode-ai/plugin/tui/context"
import { createStore, produce, reconcile as reconcileStore } from "solid-js/store"
import { 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"
@@ -23,7 +24,7 @@ import { Keymap } from "../context/keymap"
import { useRoute } from "../context/route"
import { useTuiApp, useTuiLifecycle, useTuiPaths } from "../context/runtime"
import { useLocation } from "../context/location"
import { useTheme } from "../context/theme"
import { useTheme, useThemes } from "../context/theme"
import { DialogAlert } from "../ui/dialog-alert"
import { DialogConfirm } from "../ui/dialog-confirm"
import { DialogPrompt } from "../ui/dialog-prompt"
@@ -33,6 +34,9 @@ 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>
@@ -80,6 +84,8 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver }>
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()
@@ -100,18 +106,22 @@ 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) {
const token = Symbol()
let closed = false
activeDialog = token
dialog.replace(render, () => {
if (closed) return
closed = true
if (activeDialog === token) activeDialog = undefined
onClose?.()
})
dialog.replace(
() => <PluginContextProvider value={context}>{render()}</PluginContextProvider>,
() => {
if (closed) return
closed = true
if (activeDialog === token) activeDialog = undefined
onClose?.()
},
)
return () => {
if (closed || activeDialog !== token) return
dialog.clear()
@@ -215,7 +225,7 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver }>
},
}
owned.push(async () => dialogApi.clear())
const context: Context = {
context = {
options: item.options ?? {},
get location() {
return location.current
@@ -225,7 +235,7 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver }>
client: client.api,
data,
attention,
theme,
theme: pluginTheme,
keymap: {
layer: Keymap.createLayer,
dispatch: keymap.dispatch,
@@ -245,7 +255,10 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver }>
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)
setStore("registrations", item.plugin.id, "routes", page.name, {
...page,
render: (input) => <PluginContextProvider value={context}>{page.render(input)}</PluginContextProvider>,
})
let registered = true
const unregister = () => {
if (!registered) return
@@ -275,7 +288,9 @@ 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, () => render)
setStore("registrations", item.plugin.id, "slots", name, () => (input: SlotMap[typeof name]) => (
<PluginContextProvider value={context}>{render(input)}</PluginContextProvider>
))
let registered = true
const unregister = () => {
if (!registered) return
@@ -342,7 +357,7 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver }>
.filter(([, registration]) => registration.active)
.map(([id]) => deactivate(id)),
)
const entries = config.data.plugins ?? []
const entries = [...(await discoverTuiPlugins(paths.cwd)), ...(config.data.plugins ?? [])]
batch(() => {
setStore("registrations", reconcileStore({}))
setStore("states", [])
@@ -518,6 +533,24 @@ 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")
+16
View File
@@ -0,0 +1,16 @@
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()
}
@@ -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 { useTheme } from "../../../context/theme"
import { useThemes } 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 { themeV2 } = useTheme().contextual("elevated")
const theme = useThemes().contextual("elevated")
const [store, setStore] = createStore({
tabs: {} as Record<string, Tab>,
@@ -98,12 +98,6 @@ 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,
},
],
}))
@@ -113,8 +107,8 @@ export function Composer(props: ComposerProps) {
<box
{...SplitBorder}
border={["left"]}
borderColor={themeV2.border.default}
backgroundColor={themeV2.background.default}
borderColor={theme.border.default}
backgroundColor={theme.background.default}
paddingLeft={1}
paddingRight={2}
paddingTop={1}
@@ -125,7 +119,7 @@ export function Composer(props: ComposerProps) {
<Show
when={tabList().length > 1}
fallback={
<text fg={themeV2.text.default} attributes={TextAttributes.BOLD}>
<text fg={theme.text.default} attributes={TextAttributes.BOLD}>
{tabList()[0]?.label ?? ""}
</text>
}
@@ -136,7 +130,7 @@ export function Composer(props: ComposerProps) {
const isActive = createMemo(() => store.active === t.id)
return (
<text
fg={isActive() ? themeV2.text.default : themeV2.text.subdued}
fg={isActive() ? theme.text.default : theme.text.subdued}
attributes={isActive() ? TextAttributes.BOLD : undefined}
>
{t.label}
@@ -146,7 +140,7 @@ export function Composer(props: ComposerProps) {
</For>
</box>
</Show>
<text fg={themeV2.text.subdued} onMouseUp={close}>
<text fg={theme.text.subdued} onMouseUp={close}>
esc
</text>
</box>
@@ -156,19 +150,19 @@ export function Composer(props: ComposerProps) {
<For each={footerHints()}>
{(hint) => (
<text>
<span style={{ fg: themeV2.text.default }}>
<span style={{ fg: theme.text.default }}>
<b>{hint.label}</b>{" "}
</span>
<span style={{ fg: themeV2.text.subdued }}>{hint.shortcut}</span>
<span style={{ fg: theme.text.subdued }}>{hint.shortcut}</span>
</text>
)}
</For>
<Show when={tabList().length > 1}>
<text>
<span style={{ fg: themeV2.text.default }}>
<span style={{ fg: theme.text.default }}>
<b>tabs</b>{" "}
</span>
<span style={{ fg: themeV2.text.subdued }}>/</span>
<span style={{ fg: theme.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 { themeV2 } = useTheme()
const theme = 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={themeV2.text.subdued}> No shell commands</text>}>
<Show when={entries().length > 0} fallback={<text fg={theme.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() ? themeV2.background.action.primary.focused : themeV2.background.action.primary.default
active() ? theme.background.action.primary.focused : theme.background.action.primary.default
}
onMouseOver={() => setStore("selected", index())}
>
<text
fg={active() ? themeV2.text.action.primary.focused : themeV2.text.action.primary.default}
fg={active() ? theme.text.action.primary.focused : theme.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 { themeV2 } = useTheme()
const theme = 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={themeV2.text.subdued}> No subagents</text>}>
<Show when={entries().length > 0} fallback={<text fg={theme.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()
? themeV2.background.action.primary.focused
? theme.background.action.primary.focused
: entry.current
? themeV2.background.action.primary.selected
: themeV2.background.action.primary.default
? theme.background.action.primary.selected
: theme.background.action.primary.default
}
onMouseOver={() => setStore("selected", index())}
onMouseUp={() => {
@@ -236,10 +236,10 @@ export function SubagentsTab(props: { sessionID: string }) {
<text
fg={
active()
? themeV2.text.action.primary.focused
? theme.text.action.primary.focused
: entry.current
? themeV2.text.action.primary.selected
: themeV2.text.action.primary.default
? theme.text.action.primary.selected
: theme.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() ? themeV2.text.action.primary.focused : themeV2.text.subdued} wrapMode="none">
<text fg={active() ? theme.text.action.primary.focused : theme.text.subdued} wrapMode="none">
{status()}
</text>
</Show>
+10 -10
View File
@@ -8,7 +8,7 @@ import { useRoute } from "../../context/route"
import { usePermission } from "../../context/permission"
export function Footer() {
const { themeV2 } = useTheme()
const theme = useTheme()
const data = useData()
const route = useRoute()
const permission = usePermission()
@@ -54,35 +54,35 @@ export function Footer() {
return (
<box flexDirection="row" justifyContent="space-between" gap={1} flexShrink={0}>
<text fg={themeV2.text.subdued}>{directory()}</text>
<text fg={theme.text.subdued}>{directory()}</text>
<box gap={2} flexDirection="row" flexShrink={0}>
<Switch>
<Match when={store.welcome}>
<text fg={themeV2.text.default}>
Get started <span style={{ fg: themeV2.text.subdued }}>/connect</span>
<text fg={theme.text.default}>
Get started <span style={{ fg: theme.text.subdued }}>/connect</span>
</text>
</Match>
<Match when={connected()}>
<Show when={permission.mode !== "auto" && permissions().length > 0}>
<text fg={themeV2.text.feedback.warning.default}>
<span style={{ fg: themeV2.text.feedback.warning.default }}></span> {permissions().length} Permission
<text fg={theme.text.feedback.warning.default}>
<span style={{ fg: theme.text.feedback.warning.default }}></span> {permissions().length} Permission
{permissions().length > 1 ? "s" : ""}
</text>
</Show>
<Show when={mcp()}>
<text fg={themeV2.text.default}>
<text fg={theme.text.default}>
<Switch>
<Match when={mcpError()}>
<span style={{ fg: themeV2.text.feedback.error.default }}> </span>
<span style={{ fg: theme.text.feedback.error.default }}> </span>
</Match>
<Match when={true}>
<span style={{ fg: themeV2.text.feedback.success.default }}> </span>
<span style={{ fg: theme.text.feedback.success.default }}> </span>
</Match>
</Switch>
{mcp()} MCP
</text>
</Show>
<text fg={themeV2.text.subdued}>/status</text>
<text fg={theme.text.subdued}>/status</text>
</Match>
</Switch>
</box>
+71 -77
View File
@@ -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 { useTheme } from "../../context/theme"
import { useThemes } from "../../context/theme"
import type { FormField, FormValue } from "@opencode-ai/client"
import type { FormWithLocation } from "../../context/data"
import { useClient } from "../../context/client"
@@ -44,7 +44,9 @@ function requestOptions(form: FormWithLocation) {
export function FormPrompt(props: { form: FormWithLocation }) {
const client = useClient()
const { themeV2, mode: themeMode } = useTheme().contextual("elevated")
const themes = useThemes()
const theme = themes.contextual("elevated")
const themeMode = themes.mode
const renderer = useRenderer()
const dimensions = useTerminalDimensions()
const keymap = Keymap.use()
@@ -624,27 +626,27 @@ export function FormPrompt(props: { form: FormWithLocation }) {
return (
<box
backgroundColor={themeV2.background.default}
backgroundColor={theme.background.default}
border={["left"]}
borderColor={themeV2.hue.interactive[themeMode() === "light" ? 800 : 200]}
borderColor={theme.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={themeV2.text.subdued}>{props.form.title}</text>
<text fg={theme.text.subdued}>{props.form.title}</text>
</box>
<Show when={message()}>
<box paddingLeft={1}>
<text fg={themeV2.text.default}>{message()}</text>
<text fg={theme.text.default}>{message()}</text>
</box>
</Show>
<Show when={!single() && !tabbed()}>
<box flexDirection="row" gap={1} paddingLeft={1}>
<text fg={themeV2.text.subdued}>
<text fg={theme.text.subdued}>
{confirm() ? "Review" : `Field ${Math.min(store.tab, fields().length - 1) + 1} of ${fields().length}`}
</text>
<Show when={fields().length > 0}>
<text fg={themeV2.text.subdued}>
<text fg={theme.text.subdued}>
· {answered()}/{fields().length} completed
</text>
</Show>
@@ -661,10 +663,10 @@ export function FormPrompt(props: { form: FormWithLocation }) {
paddingRight={2}
backgroundColor={
isTab()
? themeV2.background.formfield.selected
? theme.background.formfield.selected
: tabHover() === index()
? themeV2.background.formfield.focused
: themeV2.background.default
? theme.background.formfield.focused
: theme.background.default
}
onMouseOver={() => setTabHover(index())}
onMouseOut={() => setTabHover(null)}
@@ -676,12 +678,12 @@ export function FormPrompt(props: { form: FormWithLocation }) {
<text
fg={
isTab()
? themeV2.text.formfield.selected
? theme.text.formfield.selected
: tabHover() === index()
? themeV2.text.formfield.focused
? theme.text.formfield.focused
: isAnswered()
? themeV2.text.default
: themeV2.text.subdued
? theme.text.default
: theme.text.subdued
}
>
{truncate(formLabel(item), 24)}
@@ -693,10 +695,10 @@ export function FormPrompt(props: { form: FormWithLocation }) {
<box
backgroundColor={
confirm()
? themeV2.background.formfield.selected
? theme.background.formfield.selected
: tabHover() === "confirm"
? themeV2.background.formfield.focused
: themeV2.background.default
? theme.background.formfield.focused
: theme.background.default
}
onMouseOver={() => setTabHover("confirm")}
onMouseOut={() => setTabHover(null)}
@@ -705,7 +707,7 @@ export function FormPrompt(props: { form: FormWithLocation }) {
selectTabFromMouse()
}}
>
<text fg={confirm() ? themeV2.text.formfield.selected : themeV2.text.formfield.default}>Confirm</text>
<text fg={confirm() ? theme.text.formfield.selected : theme.text.formfield.default}>Confirm</text>
</box>
</box>
</Show>
@@ -714,13 +716,13 @@ export function FormPrompt(props: { form: FormWithLocation }) {
{(external) => (
<box paddingLeft={1} gap={1}>
<Show when={external().title}>
<text fg={themeV2.text.default}>{external().title}</text>
<text fg={theme.text.default}>{external().title}</text>
</Show>
<Show when={external().description}>
<text fg={themeV2.text.subdued}>{external().description}</text>
<text fg={theme.text.subdued}>{external().description}</text>
</Show>
<text
fg={themeV2.background.action.primary.default}
fg={theme.background.action.primary.default}
onMouseUp={() => {
if (renderer.getSelection()?.getSelectedText()) return
openExternal()
@@ -729,9 +731,7 @@ export function FormPrompt(props: { form: FormWithLocation }) {
{external().url}
</text>
<text
fg={
store.answers[external().key] === true ? themeV2.text.feedback.success.default : themeV2.text.subdued
}
fg={store.answers[external().key] === true ? theme.text.feedback.success.default : theme.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={themeV2.text.default}>{answerField()!.description ?? formLabel(answerField()!)}</text>
<text fg={theme.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={themeV2.text.subdued}
placeholderColor={theme.text.subdued}
minHeight={1}
maxHeight={6}
textColor={themeV2.text.default}
focusedTextColor={themeV2.text.default}
cursorColor={themeV2.text.default}
textColor={theme.text.default}
focusedTextColor={theme.text.default}
cursorColor={theme.text.default}
/>
</box>
</Show>
@@ -793,39 +793,35 @@ export function FormPrompt(props: { form: FormWithLocation }) {
>
<box flexDirection="row">
<box
backgroundColor={
active() ? themeV2.background.formfield.focused : themeV2.background.default
}
backgroundColor={active() ? theme.background.formfield.focused : theme.background.default}
paddingRight={1}
>
<text
fg={active() ? themeV2.text.formfield.focused : themeV2.text.formfield.default}
fg={active() ? theme.text.formfield.focused : theme.text.formfield.default}
>{`${i() + 1}.`}</text>
</box>
<box
backgroundColor={
active() ? themeV2.background.formfield.focused : themeV2.background.default
}
backgroundColor={active() ? theme.background.formfield.focused : theme.background.default}
>
<text
fg={
active()
? themeV2.text.formfield.focused
? theme.text.formfield.focused
: picked()
? themeV2.text.formfield.selected
: themeV2.text.formfield.default
? theme.text.formfield.selected
: theme.text.formfield.default
}
>
{multi() ? `[${picked() ? "✓" : " "}] ${row.label}` : row.label}
</text>
</box>
<Show when={!multi()}>
<text fg={themeV2.text.formfield.selected}>{picked() ? " ✓" : ""}</text>
<text fg={theme.text.formfield.selected}>{picked() ? " ✓" : ""}</text>
</Show>
</box>
<Show when={row.description}>
<box paddingLeft={3}>
<text fg={themeV2.text.subdued}>{row.description}</text>
<text fg={theme.text.subdued}>{row.description}</text>
</box>
</Show>
</box>
@@ -843,30 +839,28 @@ export function FormPrompt(props: { form: FormWithLocation }) {
>
<box flexDirection="row">
<box
backgroundColor={other() ? themeV2.background.formfield.focused : themeV2.background.default}
backgroundColor={other() ? theme.background.formfield.focused : theme.background.default}
paddingRight={1}
>
<text fg={other() ? themeV2.text.formfield.focused : themeV2.text.formfield.default}>
<text fg={other() ? theme.text.formfield.focused : theme.text.formfield.default}>
{`${rows().length + 1}.`}
</text>
</box>
<box
backgroundColor={other() ? themeV2.background.formfield.focused : themeV2.background.default}
>
<box backgroundColor={other() ? theme.background.formfield.focused : theme.background.default}>
<text
fg={
other()
? themeV2.text.formfield.focused
? theme.text.formfield.focused
: customPicked()
? themeV2.text.feedback.success.default
: themeV2.text.default
? theme.text.feedback.success.default
: theme.text.default
}
>
{multi() ? `[${customPicked() ? "✓" : " "}] Type your own answer` : "Type your own answer"}
</text>
</box>
<Show when={!multi()}>
<text fg={themeV2.text.feedback.success.default}>{customPicked() ? " ✓" : ""}</text>
<text fg={theme.text.feedback.success.default}>{customPicked() ? " ✓" : ""}</text>
</Show>
</box>
<Show when={store.editing}>
@@ -882,18 +876,18 @@ export function FormPrompt(props: { form: FormWithLocation }) {
}}
initialValue={input()}
placeholder="Type your own answer"
placeholderColor={themeV2.text.subdued}
placeholderColor={theme.text.subdued}
minHeight={1}
maxHeight={6}
textColor={themeV2.text.default}
focusedTextColor={themeV2.text.default}
cursorColor={themeV2.text.default}
textColor={theme.text.default}
focusedTextColor={theme.text.default}
cursorColor={theme.text.default}
/>
</box>
</Show>
<Show when={!store.editing && input()}>
<box paddingLeft={3}>
<text fg={themeV2.text.subdued}>{input()}</text>
<text fg={theme.text.subdued}>{input()}</text>
</box>
</Show>
</box>
@@ -906,7 +900,7 @@ export function FormPrompt(props: { form: FormWithLocation }) {
<Show when={confirm()}>
<Show when={tabbed()}>
<box paddingLeft={1}>
<text fg={themeV2.text.default}>Review</text>
<text fg={theme.text.default}>Review</text>
</box>
</Show>
<scrollbox
@@ -921,12 +915,12 @@ export function FormPrompt(props: { form: FormWithLocation }) {
return (
<box paddingLeft={1}>
<text>
<span style={{ fg: themeV2.text.subdued }}>{truncate(formLabel(item), 40)}:</span>{" "}
<span style={{ fg: theme.text.subdued }}>{truncate(formLabel(item), 40)}:</span>{" "}
<span
style={{
fg: acknowledged()
? themeV2.text.feedback.success.default
: themeV2.text.feedback.error.default,
? theme.text.feedback.success.default
: theme.text.feedback.error.default,
}}
>
{acknowledged() ? "Acknowledged" : "(acknowledgement required)"}
@@ -942,15 +936,15 @@ export function FormPrompt(props: { form: FormWithLocation }) {
return (
<box paddingLeft={1}>
<text>
<span style={{ fg: themeV2.text.subdued }}>{truncate(formLabel(item), 40)}:</span>{" "}
<span style={{ fg: theme.text.subdued }}>{truncate(formLabel(item), 40)}:</span>{" "}
<span
style={{
fg:
invalid() || missing()
? themeV2.text.feedback.error.default
? theme.text.feedback.error.default
: answered()
? themeV2.text.default
: themeV2.text.subdued,
? theme.text.default
: theme.text.subdued,
}}
>
{invalid() ?? (answered() ? value() : missing() ? "(required)" : "(not answered)")}
@@ -974,41 +968,41 @@ export function FormPrompt(props: { form: FormWithLocation }) {
>
<box flexDirection="row" gap={2}>
<Show when={!single()}>
<text fg={themeV2.text.default}>
{"⇆"} <span style={{ fg: themeV2.text.subdued }}>tab</span>
<text fg={theme.text.default}>
{"⇆"} <span style={{ fg: theme.text.subdued }}>tab</span>
</text>
</Show>
<Show when={!confirm() && !textual() && !externalField()}>
<text fg={themeV2.text.default}>
{"↑↓"} <span style={{ fg: themeV2.text.subdued }}>select</span>
<text fg={theme.text.default}>
{"↑↓"} <span style={{ fg: theme.text.subdued }}>select</span>
</text>
</Show>
<Show when={confirm() && fields().length > 0}>
<text fg={themeV2.text.default}>
{"↑↓"} <span style={{ fg: themeV2.text.subdued }}>scroll</span>
<text fg={theme.text.default}>
{"↑↓"} <span style={{ fg: theme.text.subdued }}>scroll</span>
</text>
</Show>
<text
fg={themeV2.text.default}
fg={theme.text.default}
onMouseUp={() => {
if (renderer.getSelection()?.getSelectedText()) return
if (confirm()) submit()
if (externalField()) acknowledgeExternal()
}}
>
enter <span style={{ fg: themeV2.text.subdued }}>{actionLabel()}</span>
enter <span style={{ fg: theme.text.subdued }}>{actionLabel()}</span>
</text>
<Show when={externalField() && clipboard.write}>
<text fg={themeV2.text.default} onMouseUp={copyExternal}>
c <span style={{ fg: themeV2.text.subdued }}>copy</span>
<text fg={theme.text.default} onMouseUp={copyExternal}>
c <span style={{ fg: theme.text.subdued }}>copy</span>
</text>
</Show>
<text fg={themeV2.text.default} onMouseUp={cancel}>
esc <span style={{ fg: themeV2.text.subdued }}>dismiss</span>
<text fg={theme.text.default} onMouseUp={cancel}>
esc <span style={{ fg: theme.text.subdued }}>dismiss</span>
</text>
</box>
<Show when={store.error}>
<text fg={themeV2.text.feedback.error.default}>{store.error}</text>
<text fg={theme.text.feedback.error.default}>{store.error}</text>
</Show>
</box>
</box>
File diff suppressed because it is too large Load Diff
+59 -63
View File
@@ -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 } from "../../context/theme"
import { useTheme, useThemes } 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 themeState = useTheme()
const themeV2 = themeState.themeV2
const syntax = themeState.syntax
const theme = useTheme()
const themes = useThemes()
const syntax = themes.currentSyntax
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: themeV2.background.default,
foregroundColor: themeV2.scrollbar.default,
backgroundColor: theme.background.default,
foregroundColor: theme.scrollbar.default,
},
}}
>
@@ -58,16 +58,16 @@ function EditBody(props: { file?: string; diff?: string; patch?: string }) {
showLineNumbers={true}
width="100%"
wrapMode="word"
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}
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}
/>
</scrollbox>
</Show>
@@ -76,7 +76,7 @@ function EditBody(props: { file?: string; diff?: string; patch?: string }) {
when={props.patch}
fallback={
<box paddingLeft={1}>
<text fg={themeV2.text.subdued}>No diff provided</text>
<text fg={theme.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: themeV2.background.default,
foregroundColor: themeV2.scrollbar.default,
backgroundColor: theme.background.default,
foregroundColor: theme.scrollbar.default,
},
}}
>
@@ -97,7 +97,7 @@ function EditBody(props: { file?: string; diff?: string; patch?: string }) {
streaming={true}
syntaxStyle={syntax()}
content={patch()}
fg={themeV2.text.subdued}
fg={theme.text.subdued}
/>
</scrollbox>
)}
@@ -128,7 +128,7 @@ export function PermissionPrompt(props: { request: PermissionRequest; directory?
return { input: undefined, metadata: undefined }
})
const { themeV2 } = useTheme()
const theme = 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 ? themeV2.text.subdued : themeV2.text.default}>{line}</text>}
{(line, index) => <text fg={index() === 0 ? theme.text.subdued : theme.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={themeV2.text.subdued}>Patterns</text>
<text fg={theme.text.subdued}>Patterns</text>
<box>
<For each={current.lines}>{(line) => <text fg={themeV2.text.default}>{line}</text>}</For>
<For each={current.lines}>{(line) => <text fg={theme.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"
? themeV2.text.default
: themeV2.text.subdued
? theme.text.default
: theme.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={themeV2.text.feedback.warning.default}>{"△"}</text>
<text fg={themeV2.text.default}>Permission required</text>
<text fg={theme.text.feedback.warning.default}>{"△"}</text>
<text fg={theme.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={themeV2.text.subdued} flexShrink={0}>
<text fg={theme.text.subdued} flexShrink={0}>
{current.icon}
</text>
<text fg={themeV2.text.default}>{current.title}</text>
<text fg={theme.text.default}>{current.title}</text>
</box>
</Show>
</box>
@@ -297,7 +297,7 @@ function RejectPrompt(props: {
onCancel: () => void
}) {
let input: TextareaRenderable
const { themeV2 } = useTheme().contextual("elevated")
const theme = useThemes().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={themeV2.background.default}
backgroundColor={theme.background.default}
border={["left"]}
borderColor={themeV2.text.feedback.error.default}
borderColor={theme.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={themeV2.text.feedback.error.default}>{"△"}</text>
<text fg={themeV2.text.default}>Reject permission</text>
<text fg={theme.text.feedback.error.default}>{"△"}</text>
<text fg={theme.text.default}>Reject permission</text>
</box>
<box paddingLeft={1}>
<text fg={themeV2.text.subdued}>Tell OpenCode what to do differently</text>
<text fg={theme.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={themeV2.raise(themeV2.background.default)}
backgroundColor={theme.raise(theme.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={themeV2.text.default}
focusedTextColor={themeV2.text.default}
cursorColor={themeV2.text.default}
textColor={theme.text.default}
focusedTextColor={theme.text.default}
cursorColor={theme.text.default}
/>
<box
id="session.permission.reject.actions"
@@ -394,8 +394,8 @@ function RejectPrompt(props: {
}))}
onMouseUp={() => props.onConfirm(input.plainText)}
>
<text fg={themeV2.text.default}>
enter <span style={{ fg: themeV2.text.subdued }}>confirm</span>
<text fg={theme.text.default}>
enter <span style={{ fg: theme.text.subdued }}>confirm</span>
</text>
</box>
<box
@@ -408,8 +408,8 @@ function RejectPrompt(props: {
}))}
onMouseUp={props.onCancel}
>
<text fg={themeV2.text.default}>
esc <span style={{ fg: themeV2.text.subdued }}>cancel</span>
<text fg={theme.text.default}>
esc <span style={{ fg: theme.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 { themeV2 } = useTheme().contextual("elevated")
const theme = useThemes().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={themeV2.background.default}
backgroundColor={theme.background.default}
border={["left"]}
borderColor={themeV2.background.action.primary.focused}
borderColor={theme.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={themeV2.text.feedback.warning.default}>{"△"}</text>
<text fg={themeV2.text.default}>{props.title}</text>
<text fg={theme.text.feedback.warning.default}>{"△"}</text>
<text fg={theme.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={themeV2.raise(themeV2.background.default)}
backgroundColor={theme.raise(theme.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
? themeV2.background.action.primary.focused
: themeV2.background.action.primary.default
? theme.background.action.primary.focused
: theme.background.action.primary.default
}
onMouseOver={() => setStore("selected", option)}
onMouseUp={() => {
@@ -614,11 +614,7 @@ function Prompt<const T extends Record<string, string>>(props: {
}}
>
<text
fg={
option === store.selected
? themeV2.text.action.primary.focused
: themeV2.text.action.primary.default
}
fg={option === store.selected ? theme.text.action.primary.focused : theme.text.action.primary.default}
>
{props.options[option]}
</text>
@@ -628,15 +624,15 @@ function Prompt<const T extends Record<string, string>>(props: {
</box>
<box flexDirection="row" gap={2} flexShrink={0}>
<Show when={props.fullscreen}>
<text fg={themeV2.text.default}>
{shortcuts.get("permission.prompt.fullscreen")} <span style={{ fg: themeV2.text.subdued }}>{hint()}</span>
<text fg={theme.text.default}>
{shortcuts.get("permission.prompt.fullscreen")} <span style={{ fg: theme.text.subdued }}>{hint()}</span>
</text>
</Show>
<text fg={themeV2.text.default}>
{"⇆"} <span style={{ fg: themeV2.text.subdued }}>select</span>
<text fg={theme.text.default}>
{"⇆"} <span style={{ fg: theme.text.subdued }}>select</span>
</text>
<text fg={themeV2.text.default}>
enter <span style={{ fg: themeV2.text.subdued }}>confirm</span>
<text fg={theme.text.default}>
enter <span style={{ fg: theme.text.subdued }}>confirm</span>
</text>
</box>
</box>
+7 -7
View File
@@ -1,6 +1,6 @@
import { useData } from "../../context/data"
import { createMemo, Show } from "solid-js"
import { useTheme } from "../../context/theme"
import { useThemes } from "../../context/theme"
import { useConfig } from "../../config"
import { usePluginRuntime } from "../../plugin/runtime"
import { PluginSlot } from "../../plugin/context"
@@ -10,7 +10,7 @@ import { getScrollAcceleration } from "../../util/scroll"
export function Sidebar(props: { sessionID: string; overlay?: boolean }) {
const pluginRuntime = usePluginRuntime()
const data = useData()
const { themeV2 } = useTheme().contextual("elevated")
const theme = useThemes().contextual("elevated")
const config = useConfig().data
const session = createMemo(() => data.session.get(props.sessionID))
const scrollAcceleration = createMemo(() => getScrollAcceleration(config))
@@ -18,7 +18,7 @@ export function Sidebar(props: { sessionID: string; overlay?: boolean }) {
return (
<Show when={session()}>
<box
backgroundColor={themeV2.background.default}
backgroundColor={theme.background.default}
width={42}
height="100%"
paddingTop={1}
@@ -32,8 +32,8 @@ export function Sidebar(props: { sessionID: string; overlay?: boolean }) {
scrollAcceleration={scrollAcceleration()}
verticalScrollbarOptions={{
trackOptions: {
backgroundColor: themeV2.background.default,
foregroundColor: themeV2.scrollbar.default,
backgroundColor: theme.background.default,
foregroundColor: theme.scrollbar.default,
},
}}
>
@@ -45,11 +45,11 @@ export function Sidebar(props: { sessionID: string; overlay?: boolean }) {
title={session()!.title}
>
<box paddingRight={1}>
<text fg={themeV2.text.default}>
<text fg={theme.text.default}>
<b>{session()!.title}</b>
</text>
<Show when={session()!.location.workspaceID}>
<text fg={themeV2.text.subdued}>{session()!.location.workspaceID}</text>
<text fg={theme.text.subdued}>{session()!.location.workspaceID}</text>
</Show>
</box>
</pluginRuntime.Slot>
@@ -1,7 +1,7 @@
import { createMemo, createSignal, Show } from "solid-js"
import { useRouteData } from "../../context/route"
import { useData } from "../../context/data"
import { useTheme } from "../../context/theme"
import { useThemes } 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 { themeV2 } = useTheme().contextual("elevated")
const theme = useThemes().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={themeV2.border.default}
borderColor={theme.border.default}
flexShrink={0}
backgroundColor={themeV2.background.default}
backgroundColor={theme.background.default}
>
<box flexDirection="row" justifyContent="space-between" gap={1}>
<box flexDirection="row" gap={1}>
<text fg={themeV2.text.default}>
<text fg={theme.text.default}>
<b>{subagentInfo()}</b>
</text>
<Show when={usage()}>
{(item) => (
<text fg={themeV2.text.subdued} wrapMode="none">
<text fg={theme.text.subdued} wrapMode="none">
{[item().context, item().cost].filter(Boolean).join(" · ")}
</text>
)}
@@ -80,35 +80,31 @@ export function SubagentFooter() {
onMouseOut={() => setHover(null)}
onMouseUp={() => keymap.dispatch("session.parent")}
backgroundColor={
hover() === "parent" ? themeV2.background.action.primary.hovered : themeV2.background.default
hover() === "parent" ? theme.background.action.primary.hovered : theme.background.default
}
>
<text fg={themeV2.text.default}>
Parent <span style={{ fg: themeV2.text.subdued }}>{shortcuts.get("session.parent")}</span>
<text fg={theme.text.default}>
Parent <span style={{ fg: theme.text.subdued }}>{shortcuts.get("session.parent")}</span>
</text>
</box>
<box
onMouseOver={() => setHover("prev")}
onMouseOut={() => setHover(null)}
onMouseUp={() => keymap.dispatch("session.child.previous")}
backgroundColor={
hover() === "prev" ? themeV2.background.action.primary.hovered : themeV2.background.default
}
backgroundColor={hover() === "prev" ? theme.background.action.primary.hovered : theme.background.default}
>
<text fg={themeV2.text.default}>
Prev <span style={{ fg: themeV2.text.subdued }}>{shortcuts.get("session.child.previous")}</span>
<text fg={theme.text.default}>
Prev <span style={{ fg: theme.text.subdued }}>{shortcuts.get("session.child.previous")}</span>
</text>
</box>
<box
onMouseOver={() => setHover("next")}
onMouseOut={() => setHover(null)}
onMouseUp={() => keymap.dispatch("session.child.next")}
backgroundColor={
hover() === "next" ? themeV2.background.action.primary.hovered : themeV2.background.default
}
backgroundColor={hover() === "next" ? theme.background.action.primary.hovered : theme.background.default}
>
<text fg={themeV2.text.default}>
Next <span style={{ fg: themeV2.text.subdued }}>{shortcuts.get("session.child.next")}</span>
<text fg={theme.text.default}>
Next <span style={{ fg: theme.text.subdued }}>{shortcuts.get("session.child.next")}</span>
</text>
</box>
</box>
+235
View File
@@ -0,0 +1,235 @@
import { createEffect, createSignal, onCleanup, type Accessor } from "solid-js"
type AnimatableValue = number | readonly number[]
type AnimatableTarget = Record<string, AnimatableValue>
type Ease = (progress: number) => number
type SpringTransition = {
type: "spring"
visualDuration: number
restDelta: number
restSpeed: number
}
type TweenTransition = {
type: "tween"
duration: number
ease: Ease
}
type Transition = SpringTransition | TweenTransition
type ValueState = {
scalar: boolean
value: number[]
target: number[]
velocity: number[]
from: number[]
}
type AnimationTask = (now: number) => boolean
const tasks = new Set<AnimationTask>()
let timer: ReturnType<typeof setInterval> | undefined
const smoothstep = (progress: number) => progress * progress * (3 - 2 * progress)
export function spring(options: { visualDuration: number; restDelta?: number; restSpeed?: number }): Transition {
return {
type: "spring",
visualDuration: options.visualDuration,
restDelta: options.restDelta ?? 0.002,
restSpeed: options.restSpeed ?? 0.002,
}
}
export function tween(options: { duration: number; ease?: Ease }): Transition {
return {
type: "tween",
duration: options.duration,
ease: options.ease ?? smoothstep,
}
}
export function createAnimatable<T extends AnimatableTarget>(
initial: T,
options: {
transition: Transition
enabled?: Accessor<boolean>
},
) {
const enabled = options.enabled ?? (() => true)
const state = new Map<string, ValueState>()
const [value, setValue] = createSignal(clone(initial), { equals: false })
let target = clone(initial)
let started = performance.now()
let previous = started
rebuild(initial)
const step: AnimationTask = (now) => {
if (!enabled()) {
jump(target)
return false
}
const delta = Math.min(0.05, (now - previous) / 1_000)
previous = now
const moving = options.transition.type === "spring" ? advanceSpring(delta) : advanceTween(now)
if (!moving) {
jump(target)
return false
}
setValue(() => read())
return true
}
function animate(next: T) {
if (sameTarget(next)) return
target = clone(next)
if (!enabled() || !sameShape(next)) return jump(next)
started = performance.now()
previous = started
for (const [key, current] of state) {
current.from = [...current.value]
current.target = values(next[key]!)
}
if (settled()) return jump(next)
schedule(step)
}
function jump(next: T) {
target = clone(next)
unschedule(step)
rebuild(next)
setValue(() => clone(next))
}
function stop() {
unschedule(step)
}
function rebuild(next: T) {
state.clear()
for (const [key, nextValue] of Object.entries(next)) {
const value = values(nextValue)
state.set(key, {
scalar: typeof nextValue === "number",
value,
target: [...value],
velocity: value.map(() => 0),
from: [...value],
})
}
started = performance.now()
previous = started
}
function sameShape(next: T) {
const entries = Object.entries(next)
if (entries.length !== state.size) return false
return entries.every(([key, nextValue]) => {
const current = state.get(key)
if (!current || current.scalar !== (typeof nextValue === "number")) return false
return current.value.length === values(nextValue).length
})
}
function sameTarget(next: T) {
if (!sameShape(next)) return false
return Object.entries(next).every(([key, value]) =>
values(value).every((part, index) => part === state.get(key)!.target[index]),
)
}
function settled() {
return [...state.values()].every(
(current) =>
current.value.every((value, index) => value === current.target[index]) &&
current.velocity.every((velocity) => velocity === 0),
)
}
function advanceSpring(delta: number) {
const transition = options.transition
if (transition.type !== "spring") return false
const frequency = (2 * Math.PI) / (Math.max(0.001, transition.visualDuration) * 1.2)
let moving = false
for (const current of state.values()) {
current.value.forEach((value, index) => {
const target = current.target[index]!
const velocity = current.velocity[index]!
const offset = value - target
const decay = Math.exp(-frequency * delta)
const nextVelocity = velocity + frequency * offset
current.value[index] = target + (offset + nextVelocity * delta) * decay
current.velocity[index] = (velocity - frequency * nextVelocity * delta) * decay
moving ||=
Math.abs(current.value[index]! - target) > transition.restDelta ||
Math.abs(current.velocity[index]!) > transition.restSpeed
})
}
return moving
}
function advanceTween(now: number) {
const transition = options.transition
if (transition.type !== "tween") return false
const progress = Math.min(1, (now - started) / 1_000 / Math.max(0.001, transition.duration))
const eased = transition.ease(progress)
for (const current of state.values())
current.value.forEach((_, index) => {
const from = current.from[index]!
current.value[index] = from + (current.target[index]! - from) * eased
current.velocity[index] = 0
})
return progress < 1
}
function read() {
return Object.fromEntries(
[...state].map(([key, current]) => [key, current.scalar ? current.value[0]! : [...current.value]]),
) as T
}
createEffect(() => {
if (enabled()) return
jump(target)
})
onCleanup(stop)
return { value, animate, jump }
}
function values(value: AnimatableValue) {
return typeof value === "number" ? [value] : [...value]
}
function clone<T extends AnimatableTarget>(target: T) {
return Object.fromEntries(
Object.entries(target).map(([key, value]) => [key, typeof value === "number" ? value : [...value]]),
) as T
}
function schedule(task: AnimationTask) {
tasks.add(task)
if (timer) return
timer = setInterval(tick, 16)
}
function unschedule(task: AnimationTask) {
tasks.delete(task)
if (tasks.size > 0 || !timer) return
clearInterval(timer)
timer = undefined
}
function tick() {
const now = performance.now()
for (const task of tasks) if (!task(now)) tasks.delete(task)
if (tasks.size > 0 || !timer) return
clearInterval(timer)
timer = undefined
}
+7 -7
View File
@@ -1,6 +1,6 @@
import { TextAttributes } from "@opentui/core"
import { Keymap } from "../context/keymap"
import { useTheme } from "../context/theme"
import { useThemes } from "../context/theme"
import { useDialog, type DialogContext } from "./dialog"
export type DialogAlertProps = {
@@ -11,7 +11,7 @@ export type DialogAlertProps = {
export function DialogAlert(props: DialogAlertProps) {
const dialog = useDialog()
const { themeV2 } = useTheme().contextual("elevated")
const theme = useThemes().contextual("elevated")
Keymap.createLayer(() => ({
mode: "modal",
@@ -30,27 +30,27 @@ export function DialogAlert(props: DialogAlertProps) {
return (
<box paddingLeft={2} paddingRight={2} gap={1}>
<box flexDirection="row" justifyContent="space-between">
<text attributes={TextAttributes.BOLD} fg={themeV2.text.default}>
<text attributes={TextAttributes.BOLD} fg={theme.text.default}>
{props.title}
</text>
<text fg={themeV2.text.subdued} onMouseUp={() => dialog.clear()}>
<text fg={theme.text.subdued} onMouseUp={() => dialog.clear()}>
esc
</text>
</box>
<box paddingBottom={1}>
<text fg={themeV2.text.subdued}>{props.message}</text>
<text fg={theme.text.subdued}>{props.message}</text>
</box>
<box flexDirection="row" justifyContent="flex-end" paddingBottom={1}>
<box
paddingLeft={3}
paddingRight={3}
backgroundColor={themeV2.background.action.primary.focused}
backgroundColor={theme.background.action.primary.focused}
onMouseUp={() => {
props.onConfirm?.()
dialog.clear()
}}
>
<text fg={themeV2.text.action.primary.focused}>ok</text>
<text fg={theme.text.action.primary.focused}>ok</text>
</box>
</box>
</box>
+7 -7
View File
@@ -1,6 +1,6 @@
import { TextAttributes } from "@opentui/core"
import { Keymap } from "../context/keymap"
import { useTheme } from "../context/theme"
import { useThemes } from "../context/theme"
import { useDialog, type DialogContext } from "./dialog"
import { createStore } from "solid-js/store"
import { For } from "solid-js"
@@ -21,7 +21,7 @@ export type DialogConfirmResult = boolean | undefined
export function DialogConfirm(props: DialogConfirmProps) {
const dialog = useDialog()
const { themeV2 } = useTheme().contextual("elevated")
const theme = useThemes().contextual("elevated")
const [store, setStore] = createStore({
active: "confirm" as "confirm" | "cancel",
})
@@ -60,15 +60,15 @@ export function DialogConfirm(props: DialogConfirmProps) {
return (
<box paddingLeft={2} paddingRight={2} gap={1}>
<box flexDirection="row" justifyContent="space-between">
<text attributes={TextAttributes.BOLD} fg={themeV2.text.default}>
<text attributes={TextAttributes.BOLD} fg={theme.text.default}>
{props.title}
</text>
<text fg={themeV2.text.subdued} onMouseUp={() => dialog.clear()}>
<text fg={theme.text.subdued} onMouseUp={() => dialog.clear()}>
esc
</text>
</box>
<box paddingBottom={1}>
<text fg={themeV2.text.subdued}>{props.message}</text>
<text fg={theme.text.subdued}>{props.message}</text>
</box>
<box flexDirection="row" justifyContent="flex-end" paddingBottom={1}>
<For each={["cancel", "confirm"] as const}>
@@ -76,14 +76,14 @@ export function DialogConfirm(props: DialogConfirmProps) {
<box
paddingLeft={1}
paddingRight={1}
backgroundColor={key === store.active ? themeV2.background.action.primary.focused : undefined}
backgroundColor={key === store.active ? theme.background.action.primary.focused : undefined}
onMouseUp={() => {
if (key === "confirm") props.onConfirm?.()
if (key === "cancel") props.onCancel?.()
dialog.clear()
}}
>
<text fg={key === store.active ? themeV2.text.action.primary.focused : themeV2.text.subdued}>
<text fg={key === store.active ? theme.text.action.primary.focused : theme.text.subdued}>
{Locale.titlecase(props.label?.[key] ?? key)}
</text>
</box>
+24 -26
View File
@@ -1,6 +1,6 @@
import { TextAttributes } from "@opentui/core"
import { Keymap } from "../context/keymap"
import { useTheme } from "../context/theme"
import { useThemes } from "../context/theme"
import { useDialog, type DialogContext } from "./dialog"
import { createStore } from "solid-js/store"
import { For, Show } from "solid-js"
@@ -17,8 +17,8 @@ type Active = ExportFormat | "thinking" | "copy" | "export"
export function DialogExportOptions(props: DialogExportOptionsProps) {
const dialog = useDialog()
const { themeV2 } = useTheme().contextual("elevated")
const { themeV2: overlayTheme } = useTheme().contextual("overlay")
const theme = useThemes().contextual("elevated")
const overlayTheme = useThemes().contextual("overlay")
const [store, setStore] = createStore({
format: "markdown" as ExportFormat,
thinking: props.defaultThinking,
@@ -73,15 +73,15 @@ export function DialogExportOptions(props: DialogExportOptionsProps) {
return (
<box paddingLeft={2} paddingRight={2} gap={1}>
<box flexDirection="row" justifyContent="space-between">
<text attributes={TextAttributes.BOLD} fg={themeV2.text.default}>
<text attributes={TextAttributes.BOLD} fg={theme.text.default}>
Export session
</text>
<text fg={themeV2.text.subdued} onMouseUp={() => dialog.clear()}>
<text fg={theme.text.subdued} onMouseUp={() => dialog.clear()}>
esc
</text>
</box>
<box flexDirection="row" gap={1}>
<text fg={themeV2.text.default}>Export as:</text>
<text fg={theme.text.default}>Export as:</text>
<box flexDirection="row" gap={1}>
<For each={["markdown", "json"] as const}>
{(format) => (
@@ -90,20 +90,20 @@ export function DialogExportOptions(props: DialogExportOptionsProps) {
paddingRight={1}
backgroundColor={
store.active === format
? themeV2.background.formfield.focused
? theme.background.formfield.focused
: store.format === format
? themeV2.background.formfield.selected
: themeV2.background.formfield.default
? theme.background.formfield.selected
: theme.background.formfield.default
}
onMouseUp={() => selectFormat(format)}
>
<text
fg={
store.active === format
? themeV2.text.formfield.focused
? theme.text.formfield.focused
: store.format === format
? themeV2.text.formfield.selected
: themeV2.text.formfield.default
? theme.text.formfield.selected
: theme.text.formfield.default
}
>
{store.format === format ? "◉" : "○"} {format === "markdown" ? "Markdown" : "JSON"}
@@ -119,10 +119,10 @@ export function DialogExportOptions(props: DialogExportOptionsProps) {
gap={1}
backgroundColor={
store.active === "thinking"
? themeV2.background.formfield.focused
? theme.background.formfield.focused
: store.thinking
? themeV2.background.formfield.selected
: themeV2.background.formfield.default
? theme.background.formfield.selected
: theme.background.formfield.default
}
onMouseUp={() => {
setStore("active", "thinking")
@@ -132,10 +132,10 @@ export function DialogExportOptions(props: DialogExportOptionsProps) {
<text
fg={
store.active === "thinking"
? themeV2.text.formfield.focused
? theme.text.formfield.focused
: store.thinking
? themeV2.text.formfield.selected
: themeV2.text.formfield.default
? theme.text.formfield.selected
: theme.text.formfield.default
}
>
{store.thinking ? "[x]" : "[ ]"}
@@ -143,10 +143,10 @@ export function DialogExportOptions(props: DialogExportOptionsProps) {
<text
fg={
store.active === "thinking"
? themeV2.text.formfield.focused
? theme.text.formfield.focused
: store.thinking
? themeV2.text.formfield.selected
: themeV2.text.formfield.default
? theme.text.formfield.selected
: theme.text.formfield.default
}
>
Include thinking
@@ -167,14 +167,12 @@ export function DialogExportOptions(props: DialogExportOptionsProps) {
paddingRight={4}
backgroundColor={
store.active === "export"
? themeV2.background.action.primary.focused
: themeV2.background.action.primary.default
? theme.background.action.primary.focused
: theme.background.action.primary.default
}
onMouseUp={() => confirm("export")}
>
<text
fg={store.active === "export" ? themeV2.text.action.primary.focused : themeV2.text.action.primary.default}
>
<text fg={store.active === "export" ? theme.text.action.primary.focused : theme.text.action.primary.default}>
Export
</text>
</box>
+7 -7
View File
@@ -1,11 +1,11 @@
import { TextAttributes } from "@opentui/core"
import { Keymap } from "../context/keymap"
import { useTheme } from "../context/theme"
import { useThemes } from "../context/theme"
import { useDialog, type DialogContext } from "./dialog"
export function DialogExportResult(props: { path: string; onClose?: () => void }) {
const dialog = useDialog()
const { themeV2 } = useTheme().contextual("elevated")
const theme = useThemes().contextual("elevated")
const close = () => {
props.onClose?.()
@@ -27,24 +27,24 @@ export function DialogExportResult(props: { path: string; onClose?: () => void }
return (
<box paddingLeft={2} paddingRight={2} gap={1}>
<box flexDirection="row" justifyContent="space-between">
<text attributes={TextAttributes.BOLD} fg={themeV2.text.default}>
<text attributes={TextAttributes.BOLD} fg={theme.text.default}>
Session exported
</text>
<text fg={themeV2.text.subdued} onMouseUp={close}>
<text fg={theme.text.subdued} onMouseUp={close}>
esc
</text>
</box>
<box>
<text fg={themeV2.text.default}>{props.path}</text>
<text fg={theme.text.default}>{props.path}</text>
</box>
<box flexDirection="row" justifyContent="flex-end" gap={1} paddingBottom={1}>
<box
paddingLeft={3}
paddingRight={3}
backgroundColor={themeV2.background.action.primary.focused}
backgroundColor={theme.background.action.primary.focused}
onMouseUp={close}
>
<text fg={themeV2.text.action.primary.focused}>Close</text>
<text fg={theme.text.action.primary.focused}>Close</text>
</box>
</box>
</box>
+7 -7
View File
@@ -1,11 +1,11 @@
import { TextAttributes } from "@opentui/core"
import { Keymap } from "../context/keymap"
import { useTheme } from "../context/theme"
import { useThemes } from "../context/theme"
import { useDialog } from "./dialog"
export function DialogHelp() {
const dialog = useDialog()
const { themeV2 } = useTheme().contextual("elevated")
const theme = useThemes().contextual("elevated")
const shortcuts = Keymap.useShortcuts()
Keymap.createLayer(() => ({
@@ -19,15 +19,15 @@ export function DialogHelp() {
return (
<box paddingLeft={2} paddingRight={2} gap={1}>
<box flexDirection="row" justifyContent="space-between">
<text attributes={TextAttributes.BOLD} fg={themeV2.text.default}>
<text attributes={TextAttributes.BOLD} fg={theme.text.default}>
Help
</text>
<text fg={themeV2.text.subdued} onMouseUp={() => dialog.clear()}>
<text fg={theme.text.subdued} onMouseUp={() => dialog.clear()}>
esc/enter
</text>
</box>
<box paddingBottom={1}>
<text fg={themeV2.text.subdued}>
<text fg={theme.text.subdued}>
Press {shortcuts.get("command.palette.show")} to see all available actions and commands in any context.
</text>
</box>
@@ -35,10 +35,10 @@ export function DialogHelp() {
<box
paddingLeft={3}
paddingRight={3}
backgroundColor={themeV2.background.action.primary.focused}
backgroundColor={theme.background.action.primary.focused}
onMouseUp={() => dialog.clear()}
>
<text fg={themeV2.text.action.primary.focused}>ok</text>
<text fg={theme.text.action.primary.focused}>ok</text>
</box>
</box>
</box>
+12 -12
View File
@@ -1,6 +1,6 @@
import { TextareaRenderable, TextAttributes } from "@opentui/core"
import { Keymap } from "../context/keymap"
import { useTheme } from "../context/theme"
import { useThemes } from "../context/theme"
import { useDialog, type DialogContext } from "./dialog"
import { Show, createEffect, createSignal, onMount, type JSX } from "solid-js"
import { Spinner } from "../component/spinner"
@@ -18,7 +18,7 @@ export type DialogPromptProps = {
export function DialogPrompt(props: DialogPromptProps) {
const dialog = useDialog()
const { themeV2 } = useTheme().contextual("elevated")
const theme = useThemes().contextual("elevated")
const shortcuts = Keymap.useShortcuts()
const [textareaTarget, setTextareaTarget] = createSignal<TextareaRenderable>()
let textarea: TextareaRenderable
@@ -74,10 +74,10 @@ export function DialogPrompt(props: DialogPromptProps) {
return (
<box paddingLeft={2} paddingRight={2} gap={1}>
<box flexDirection="row" justifyContent="space-between">
<text attributes={TextAttributes.BOLD} fg={themeV2.text.default}>
<text attributes={TextAttributes.BOLD} fg={theme.text.default}>
{props.title}
</text>
<text fg={themeV2.text.subdued} onMouseUp={() => dialog.clear()}>
<text fg={theme.text.subdued} onMouseUp={() => dialog.clear()}>
esc
</text>
</box>
@@ -91,20 +91,20 @@ export function DialogPrompt(props: DialogPromptProps) {
}}
initialValue={props.value}
placeholder={props.placeholder ?? "Enter text"}
placeholderColor={themeV2.text.subdued}
textColor={props.busy ? themeV2.text.formfield.disabled : themeV2.text.formfield.default}
focusedTextColor={props.busy ? themeV2.text.formfield.disabled : themeV2.text.formfield.default}
cursorColor={props.busy ? themeV2.background.formfield.disabled : themeV2.text.default}
placeholderColor={theme.text.subdued}
textColor={props.busy ? theme.text.formfield.disabled : theme.text.formfield.default}
focusedTextColor={props.busy ? theme.text.formfield.disabled : theme.text.formfield.default}
cursorColor={props.busy ? theme.background.formfield.disabled : theme.text.default}
/>
<Show when={props.busy}>
<Spinner color={themeV2.text.subdued}>{props.busyText ?? "Working..."}</Spinner>
<Spinner color={theme.text.subdued}>{props.busyText ?? "Working..."}</Spinner>
</Show>
</box>
<box paddingBottom={1} gap={1} flexDirection="row">
<Show when={!props.busy} fallback={<text fg={themeV2.text.subdued}>processing...</text>}>
<Show when={!props.busy} fallback={<text fg={theme.text.subdued}>processing...</text>}>
<Show when={shortcuts.get("dialog.prompt.submit")}>
<text fg={themeV2.text.default}>
{shortcuts.get("dialog.prompt.submit")} <span style={{ fg: themeV2.text.subdued }}>submit</span>
<text fg={theme.text.default}>
{shortcuts.get("dialog.prompt.submit")} <span style={{ fg: theme.text.subdued }}>submit</span>
</text>
</Show>
</Show>
+32 -33
View File
@@ -1,6 +1,6 @@
import { InputRenderable, RGBA, ScrollBoxRenderable, TextAttributes } from "@opentui/core"
import { Keymap, type KeymapCommand } from "../context/keymap"
import { useTheme } from "../context/theme"
import { useThemes } from "../context/theme"
import { entries, filter, flatMap, groupBy, pipe } from "remeda"
import { batch, createEffect, createMemo, createSignal, For, Show, type JSX, on, onCleanup } from "solid-js"
import { createStore } from "solid-js/store"
@@ -95,7 +95,9 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
type VisibleAction = (Action & { label: string }) | FooterHint
const dialog = useDialog()
const { themeV2, mode } = useTheme().contextual("elevated")
const themes = useThemes()
const theme = themes.contextual("elevated")
const mode = themes.mode
const config = useConfig().data
const scrollAcceleration = createMemo(() => getScrollAcceleration(config))
@@ -522,10 +524,10 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
if (!isActionItem(action.item))
return (
<text>
<span style={{ fg: themeV2.text.default }}>
<span style={{ fg: theme.text.default }}>
<b>{action.item.title}</b>{" "}
</span>
<span style={{ fg: themeV2.text.subdued }}>{action.item.label}</span>
<span style={{ fg: theme.text.subdued }}>{action.item.label}</span>
</text>
)
const item = action.item
@@ -534,16 +536,16 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
return (
<box
flexDirection="row"
backgroundColor={active() ? themeV2.background.action.primary.focused : RGBA.fromInts(0, 0, 0, 0)}
backgroundColor={active() ? theme.background.action.primary.focused : RGBA.fromInts(0, 0, 0, 0)}
onMouseUp={() => trigger(item)}
>
<text
fg={
disabled()
? themeV2.text.action.primary.disabled
? theme.text.action.primary.disabled
: active()
? themeV2.text.action.primary.focused
: themeV2.text.default
? theme.text.action.primary.focused
: theme.text.default
}
attributes={active() ? TextAttributes.BOLD : undefined}
>
@@ -552,10 +554,10 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
<text
fg={
disabled()
? themeV2.text.action.primary.disabled
? theme.text.action.primary.disabled
: active()
? themeV2.text.action.primary.focused
: themeV2.text.subdued
? theme.text.action.primary.focused
: theme.text.subdued
}
>
{" " + item.label}
@@ -569,11 +571,11 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
<box paddingLeft={4} paddingRight={4}>
<box flexDirection="row" justifyContent="space-between">
{props.titleView ?? (
<text fg={themeV2.text.default} attributes={TextAttributes.BOLD}>
<text fg={theme.text.default} attributes={TextAttributes.BOLD}>
{props.title}
</text>
)}
<text fg={themeV2.text.subdued} onMouseUp={() => dialog.clear()}>
<text fg={theme.text.subdued} onMouseUp={() => dialog.clear()}>
esc
</text>
</box>
@@ -587,9 +589,9 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
props.onFilter?.(e)
})
}}
focusedBackgroundColor={themeV2.background.formfield.focused}
cursorColor={themeV2.text.formfield.focused}
focusedTextColor={themeV2.text.formfield.focused}
focusedBackgroundColor={theme.background.formfield.focused}
cursorColor={theme.text.formfield.focused}
focusedTextColor={theme.text.formfield.focused}
ref={(r) => {
input = r
input.traits = { status: "FILTER" }
@@ -600,7 +602,7 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
}, 1)
}}
placeholder={props.placeholder ?? "Search"}
placeholderColor={themeV2.text.subdued}
placeholderColor={theme.text.subdued}
/>
</box>
</Show>
@@ -614,14 +616,14 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
fallback={
props.emptyView ?? (
<box paddingLeft={4} paddingRight={4} paddingTop={1}>
<text fg={themeV2.text.subdued}>No items available</text>
<text fg={theme.text.subdued}>No items available</text>
</box>
)
}
>
{props.noMatchView ?? (
<box paddingLeft={4} paddingRight={4} paddingTop={1}>
<text fg={themeV2.text.subdued}>No results found</text>
<text fg={theme.text.subdued}>No results found</text>
</box>
)}
</Show>
@@ -643,10 +645,7 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
<Show
when={options[0]?.categoryView}
fallback={
<text
fg={themeV2.hue.accent[mode() === "light" ? 800 : 200]}
attributes={TextAttributes.BOLD}
>
<text fg={theme.hue.accent[mode() === "light" ? 800 : 200]} attributes={TextAttributes.BOLD}>
{category}
</text>
}
@@ -695,8 +694,8 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
backgroundColor={
active()
? actionFocused()
? themeV2.background.surface.overlay
: (option.bg ?? themeV2.background.action.primary.focused)
? theme.background.surface.overlay
: (option.bg ?? theme.background.action.primary.focused)
: RGBA.fromInts(0, 0, 0, 0)
}
>
@@ -725,7 +724,7 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
{(detail) => (
<box paddingLeft={3} paddingRight={3}>
<text
fg={option.detailsColor ?? themeV2.text.subdued}
fg={option.detailsColor ?? theme.text.subdued}
wrapMode={option.detailsWrap ? "word" : "none"}
>
{option.detailsWrap
@@ -774,12 +773,12 @@ function Option(props: {
activeColor?: RGBA
onMouseOver?: () => void
}) {
const { themeV2 } = useTheme().contextual("elevated")
const theme = useThemes().contextual("elevated")
const text = createMemo(() => {
if (props.active && !props.muted) return props.activeColor ?? themeV2.text.action.primary.focused
if (props.muted && (props.active || props.current)) return themeV2.text.subdued
if (props.current) return themeV2.text.formfield.selected
return themeV2.text.default
if (props.active && !props.muted) return props.activeColor ?? theme.text.action.primary.focused
if (props.muted && (props.active || props.current)) return theme.text.subdued
if (props.current) return theme.text.formfield.selected
return theme.text.default
})
return (
@@ -809,14 +808,14 @@ function Option(props: {
? Locale.truncateLeft(props.title, props.titleWidth ?? 61)
: Locale.truncate(props.title, props.titleWidth ?? 61))}
<Show when={props.description}>
<span style={{ fg: props.active && !props.muted ? text() : themeV2.text.subdued }}>
<span style={{ fg: props.active && !props.muted ? text() : theme.text.subdued }}>
{" " + props.description}
</span>
</Show>
</text>
<Show when={props.footer}>
<box flexShrink={0}>
<text fg={props.active && !props.muted ? text() : themeV2.text.subdued}>{props.footer}</text>
<text fg={props.active && !props.muted ? text() : theme.text.subdued}>{props.footer}</text>
</box>
</Show>
</>
+3 -3
View File
@@ -1,7 +1,7 @@
import { useRenderer, useTerminalDimensions } from "@opentui/solid"
import { batch, createContext, createEffect, onCleanup, Show, useContext, type JSX, type ParentProps } from "solid-js"
import { Keymap } from "../context/keymap"
import { useTheme } from "../context/theme"
import { useThemes } from "../context/theme"
import { MouseButton, Renderable, RGBA } from "@opentui/core"
import { createStore } from "solid-js/store"
import { useToast } from "./toast"
@@ -16,7 +16,7 @@ export function Dialog(
}>,
) {
const dimensions = useTerminalDimensions()
const { themeV2 } = useTheme().contextual("elevated")
const theme = useThemes().contextual("elevated")
const renderer = useRenderer()
let dismiss = false
@@ -59,7 +59,7 @@ export function Dialog(
}}
width={width()}
maxWidth={dimensions().width - 2}
backgroundColor={themeV2.background.default}
backgroundColor={theme.background.default}
paddingTop={1}
>
{props.children}
+6 -6
View File
@@ -1,6 +1,6 @@
import { createContext, useContext, type ParentProps, Show } from "solid-js"
import { createStore } from "solid-js/store"
import { useTheme } from "../context/theme"
import { useThemes } from "../context/theme"
import { useTerminalDimensions } from "@opentui/solid"
import { SplitBorder } from "./border"
import { TextAttributes } from "@opentui/core"
@@ -14,7 +14,7 @@ type ToastInput = Omit<ToastOptions, "duration"> & { duration?: number }
export function Toast() {
const toast = useToast()
const { themeV2 } = useTheme().contextual("overlay")
const theme = useThemes().contextual("overlay")
const dimensions = useTerminalDimensions()
return (
@@ -31,17 +31,17 @@ export function Toast() {
paddingRight={2}
paddingTop={1}
paddingBottom={1}
backgroundColor={themeV2.background.default}
borderColor={themeV2.text.feedback[current().variant].default}
backgroundColor={theme.background.default}
borderColor={theme.text.feedback[current().variant].default}
border={["left", "right"]}
customBorderChars={SplitBorder.customBorderChars}
>
<Show when={current().title}>
<text attributes={TextAttributes.BOLD} marginBottom={1} fg={themeV2.text.default}>
<text attributes={TextAttributes.BOLD} marginBottom={1} fg={theme.text.default}>
{current().title}
</text>
</Show>
<text fg={themeV2.text.default} wrapMode="word" width="100%">
<text fg={theme.text.default} wrapMode="word" width="100%">
{current().message}
</text>
</box>
+20 -10
View File
@@ -67,20 +67,30 @@ export function truncate(str: string, len: number): string {
const graphemeSegmenter = new Intl.Segmenter(undefined, { granularity: "grapheme" })
export function graphemes(str: string) {
return Array.from(graphemeSegmenter.segment(str), (item) => item.segment)
}
export function takeWidth(str: string, width: number) {
if (width <= 0) return ""
if (stringWidth(str) <= width) return str
const result: string[] = []
let used = 0
for (const segment of graphemes(str)) {
const next = stringWidth(segment)
if (used + next > width) break
result.push(segment)
used += next
}
return result.join("")
}
export function truncateWidth(str: string, width: number): string {
if (width <= 0) return ""
if (stringWidth(str) <= width) return str
if (width === 1) return "…"
const result: string[] = []
let used = 0
for (const item of graphemeSegmenter.segment(str)) {
const next = stringWidth(item.segment)
if (used + next > width - 1) break
result.push(item.segment)
used += next
}
return result.join("") + "…"
return takeWidth(str, width - 1) + "…"
}
export function truncateLeft(str: string, len: number): string {
+12 -14
View File
@@ -1,4 +1,5 @@
import { createEffect, createSignal, on, onCleanup, type Accessor } from "solid-js"
import { createAnimatable, tween } from "../ui/animation"
export function createDebouncedSignal<T>(value: T, ms: number): [Accessor<T>, (value: T) => void] {
const [get, set] = createSignal(value)
@@ -17,35 +18,32 @@ export function createDebouncedSignal<T>(value: T, ms: number): [Accessor<T>, (v
}
export function createFadeIn(show: Accessor<boolean>, enabled: Accessor<boolean>) {
const [alpha, setAlpha] = createSignal(show() ? 1 : 0)
const alpha = createAnimatable(
{ value: show() ? 1 : 0 },
{
enabled,
transition: tween({ duration: 0.16 }),
},
)
let revealed = show()
createEffect(
on([show, enabled], ([visible, animate]) => {
if (!visible) {
setAlpha(0)
alpha.jump({ value: 0 })
return
}
if (!animate || revealed) {
revealed = true
setAlpha(1)
alpha.jump({ value: 1 })
return
}
const start = performance.now()
revealed = true
setAlpha(0)
const timer = setInterval(() => {
const progress = Math.min((performance.now() - start) / 160, 1)
setAlpha(progress * progress * (3 - 2 * progress))
if (progress >= 1) clearInterval(timer)
}, 16)
onCleanup(() => clearInterval(timer))
alpha.animate({ value: 1 })
}),
)
return alpha
return () => alpha.value().value
}
@@ -4,10 +4,15 @@ import { testRender } from "@opentui/solid"
import type { JSX } from "solid-js"
import { onMount, type ParentProps } from "solid-js"
import { createTuiResolvedConfig } from "../../fixture/tui-runtime"
import { ThemeProvider } from "../../../src/context/theme"
import { ThemeProvider, useTheme, useThemes } from "../../../src/context/theme"
import type { Plugin } from "@opencode-ai/plugin/tui"
import { ConfigProvider } from "../../../src/config"
import { DiffViewerFileTree } from "../../../src/feature-plugins/system/diff-viewer-file-tree"
import {
DiffViewerFileTree,
type DiffViewerFileTreeProps,
} from "../../../src/feature-plugins/system/diff-viewer-file-tree"
import { TestTuiContexts } from "../../fixture/tui-environment"
import { createPluginTheme } from "../../../src/plugin/context"
import {
allExpandedFileTreeDirectories,
buildFileTree,
@@ -17,7 +22,7 @@ describe("DiffViewerFileTree", () => {
test.skip("renders sorted hierarchical file rows", async () => {
const lines = visibleLines(
await renderFrame(() => (
<DiffViewerFileTree
<ThemedDiffViewerFileTree
width={32}
files={[
{ file: "z-file.ts" },
@@ -45,13 +50,13 @@ describe("DiffViewerFileTree", () => {
test("keeps loading and error quiet while rendering an empty settled state", async () => {
const loading = await renderFrame(() => (
<DiffViewerFileTree width={32} files={[]} loading={true} error={undefined} />
<ThemedDiffViewerFileTree width={32} files={[]} loading={true} error={undefined} />
))
const failed = await renderFrame(() => (
<DiffViewerFileTree width={32} files={[]} loading={false} error={new Error("nope")} />
<ThemedDiffViewerFileTree width={32} files={[]} loading={false} error={new Error("nope")} />
))
const empty = await renderFrame(() => (
<DiffViewerFileTree width={32} files={[]} loading={false} error={undefined} />
<ThemedDiffViewerFileTree width={32} files={[]} loading={false} error={undefined} />
))
expect(loading).not.toContain("Loading diff...")
@@ -67,7 +72,7 @@ describe("DiffViewerFileTree", () => {
const focused = visibleLines(
await renderFrame(() => (
<DiffViewerFileTree
<ThemedDiffViewerFileTree
width={32}
files={files}
loading={false}
@@ -78,7 +83,7 @@ describe("DiffViewerFileTree", () => {
)),
)
const unfocused = visibleLines(
await renderFrame(() => <DiffViewerFileTree width={32} files={files} loading={false} error={undefined} />),
await renderFrame(() => <ThemedDiffViewerFileTree width={32} files={files} loading={false} error={undefined} />),
)
expect(focused).toContain("▾ src/config")
@@ -97,7 +102,13 @@ describe("DiffViewerFileTree", () => {
expect(
visibleLines(
await renderFrame(() => (
<DiffViewerFileTree width={32} files={files} loading={false} error={undefined} expandedNodes={collapsed} />
<ThemedDiffViewerFileTree
width={32}
files={files}
loading={false}
error={undefined}
expandedNodes={collapsed}
/>
)),
),
).toEqual(["▸ src/config"])
@@ -105,7 +116,7 @@ describe("DiffViewerFileTree", () => {
expect(
visibleLines(
await renderFrame(() => (
<DiffViewerFileTree
<ThemedDiffViewerFileTree
files={files}
width={32}
loading={false}
@@ -118,6 +129,10 @@ describe("DiffViewerFileTree", () => {
})
})
function ThemedDiffViewerFileTree(props: Omit<DiffViewerFileTreeProps, "context">) {
return <DiffViewerFileTree {...props} context={{ theme: createPluginTheme(useTheme(), useThemes()) } as Plugin.Context} />
}
async function renderFrame(component: () => JSX.Element) {
const mounted = Promise.withResolvers<void>()
const app = await testRender(() => withTheme(component, mounted.resolve), { width: 40, height: 10 })
@@ -11,7 +11,7 @@ import type {
Route,
Slot,
} from "@opencode-ai/plugin/tui/context"
import { ThemeProvider } from "../../../src/context/theme"
import { ThemeProvider, useTheme, useThemes } from "../../../src/context/theme"
import { ConfigProvider } from "../../../src/config"
import { TuiKeybind } from "../../../src/config/keybind"
import { Keymap } from "../../../src/context/keymap"
@@ -21,6 +21,7 @@ import { TestTuiContexts } from "../../fixture/tui-environment"
import { createApi, createEventStream, createFetch, json } from "../../fixture/tui-client"
import { DialogProvider } from "../../../src/ui/dialog"
import { ToastProvider } from "../../../src/ui/toast"
import { createPluginTheme } from "../../../src/plugin/context"
test("closing the diff viewer returns to the route it opened from", async () => {
const viewer = await renderDiffViewer([])
@@ -157,6 +158,7 @@ async function renderDiffViewer(vcsDiff: unknown[], height = 20, initialRoute?:
})
}, createEventStream())
function Harness() {
let theme: ReturnType<typeof createPluginTheme>
const context = {
options: {},
client: createApi(transport.fetch),
@@ -164,6 +166,9 @@ async function renderDiffViewer(vcsDiff: unknown[], height = 20, initialRoute?:
session: { get: () => session },
location: { default: () => ({ directory: "/repo/default" }) },
},
get theme() {
return theme
},
keymap: {
layer(input: () => KeymapLayer) {
input().commands?.forEach((command) => {
@@ -202,6 +207,7 @@ async function renderDiffViewer(vcsDiff: unknown[], height = 20, initialRoute?:
void diffViewerPlugin.setup(context)
function Content() {
theme = createPluginTheme(useTheme(), useThemes())
const commandView = renderCommands?.({})
if (current.type !== "plugin") commands.get("diff.open")?.run()
return (
+36 -21
View File
@@ -6,7 +6,7 @@ import { DEFAULT_THEME, selectTheme } from "@opencode-ai/theme/tui"
import { createTuiResolvedConfig } from "../../fixture/tui-runtime"
import { DEFAULT_THEMES } from "../../../src/theme"
import { ConfigProvider } from "../../../src/config"
import { ThemeProvider, useTheme, type ThemeError } from "../../../src/context/theme"
import { ThemeContextProvider, ThemeProvider, useTheme, useThemes, type ThemeError } from "../../../src/context/theme"
async function wait(fn: () => boolean) {
const started = Date.now()
@@ -27,17 +27,17 @@ test("uses an available mode while retaining the pinned preference", async () =>
darkOnly.theme.background = "#111111"
darkOnly.theme.text = "#eeeeee"
const native = { version: 2, dark: { text: { default: "#abcdef" } } } as const
let theme: ReturnType<typeof useTheme> | undefined
let themes: ReturnType<typeof useThemes> | undefined
function Probe() {
const value = useTheme()
theme = value
const value = useThemes()
themes = value
return <text>{value.mode()}</text>
}
function current() {
if (!theme) throw new Error("Theme provider is not mounted")
return theme
if (!themes) throw new Error("Theme provider is not mounted")
return themes
}
const app = await testRender(
@@ -56,7 +56,7 @@ test("uses an available mode while retaining the pinned preference", async () =>
app.renderer.start()
try {
await wait(() => theme?.ready === true)
await wait(() => themes?.ready === true)
expect(current().mode()).toBe("light")
expect(current().modes()).toEqual(["light"])
expect(current().supports("dark")).toBeFalse()
@@ -72,7 +72,7 @@ test("uses an available mode while retaining the pinned preference", async () =>
expect(current().set("native")).toBeTrue()
await wait(() => current().selected === "native")
expect(current().modes()).toEqual(["dark"])
expect(current().themeV2.text.default.equals(RGBA.fromHex("#abcdef"))).toBeTrue()
expect(current().current.text.default.equals(RGBA.fromHex("#abcdef"))).toBeTrue()
} finally {
app.renderer.destroy()
}
@@ -83,13 +83,13 @@ test.each([
["mode merging", { version: 2, light: { mergeMode: true } }],
["token reference", { version: 2, light: { text: { default: "$missing" } } }],
] as const)("falls back to OpenCode when configured V2 theme %s is invalid", async (_label, source) => {
let theme: ReturnType<typeof useTheme> | undefined
let themes: ReturnType<typeof useThemes> | undefined
let failure: ThemeError | undefined
let unsubscribe: (() => void) | undefined
function Probe() {
const value = useTheme()
theme = value
const value = useThemes()
themes = value
unsubscribe = value.onError((error) => (failure = error))
return <text>{value.selected}</text>
}
@@ -107,8 +107,8 @@ test.each([
app.renderer.start()
try {
await wait(() => theme?.ready === true)
expect(theme?.selected).toBe("opencode")
await wait(() => themes?.ready === true)
expect(themes?.selected).toBe("opencode")
expect(failure?.name).toBe("invalid")
expect(failure?.error).toBeInstanceOf(Error)
expect(failure?.error.message.length).toBeGreaterThan(0)
@@ -118,17 +118,30 @@ test.each([
}
})
test("contextual themes fall back to a standalone theme's base view", async () => {
test("contextual hooks resolve overrides and fall back to a standalone theme's base view", async () => {
const standalone = {
version: 2,
standalone: true,
dark: { hue: selectTheme(DEFAULT_THEME, "dark").hue },
dark: {
hue: selectTheme(DEFAULT_THEME, "dark").hue,
"@context:elevated": { text: { default: "#abcdef" } },
},
} as const
let themes: ReturnType<typeof useThemes> | undefined
let theme: ReturnType<typeof useTheme> | undefined
function Probe() {
function ContextProbe() {
theme = useTheme()
return <text>{theme.selected}</text>
return <text>{theme.text.default.toString()}</text>
}
function Probe() {
themes = useThemes()
return (
<ThemeContextProvider context="elevated">
<ContextProbe />
</ThemeContextProvider>
)
}
const app = await testRender(
@@ -144,10 +157,12 @@ test("contextual themes fall back to a standalone theme's base view", async () =
app.renderer.start()
try {
await wait(() => theme?.ready === true)
if (!theme) throw new Error("Theme provider is not mounted")
expect(theme.contextual("elevated").themeV2.text.default).toBe(theme.themeV2.text.default)
expect(theme.contextual("overlay").themeV2.background.default).toBe(theme.themeV2.background.default)
await wait(() => themes?.ready === true)
if (!themes) throw new Error("Theme provider is not mounted")
if (!theme) throw new Error("Contextual theme is not mounted")
expect(theme.text.default.equals(RGBA.fromHex("#abcdef"))).toBeTrue()
expect(theme.text.default).toBe(themes.contextual("elevated").text.default)
expect(themes.contextual("overlay").background.default).toBe(themes.current.background.default)
} finally {
app.renderer.destroy()
}
@@ -0,0 +1,10 @@
import { expect, test } from "bun:test"
import { completionPulseOpacity } from "../../src/component/tab-pulse"
test("completion pulse rises quickly and fades over the remaining duration", () => {
expect(completionPulseOpacity(0)).toBe(0)
expect(completionPulseOpacity(0.08)).toBeCloseTo(0.5)
expect(completionPulseOpacity(0.16)).toBe(1)
expect(completionPulseOpacity(0.58)).toBeCloseTo(0.5)
expect(completionPulseOpacity(1)).toBe(0)
})
+8 -7
View File
@@ -2,13 +2,7 @@
import { testRender } from "@opentui/solid"
import { expect, test } from "bun:test"
import { Schema } from "effect"
import {
resolve,
ConfigProvider,
Info,
useConfig,
type Interface,
} from "../src/config"
import { resolve, ConfigProvider, Info, useConfig, type Interface } from "../src/config"
test("validates mini replay settings", () => {
const decode = Schema.decodeUnknownSync(Info)
@@ -20,6 +14,13 @@ test("validates mini replay settings", () => {
expect(() => decode({ mini: { replay_limit: 1.5 } })).toThrow()
})
test("validates the session tabs setting", () => {
const decode = Schema.decodeUnknownSync(Info)
expect(decode({ session: { tabs: true } })).toEqual({ session: { tabs: true } })
expect(() => decode({ session: { tabs: "on" } })).toThrow()
})
test("resolves nested config and keybind defaults", () => {
const config = resolve(
{
+20 -1
View File
@@ -100,7 +100,26 @@ test("resolves message navigation defaults", () => {
test("opens the subagent picker with down", () => {
const config = resolve({}, { terminalSuspend: true })
expect(config.keybinds.get("session.child.first")).toMatchObject([{ key: "down,<leader>down" }])
expect(config.keybinds.get("session.child.first")).toMatchObject([{ key: "down" }])
})
test("navigates session tabs with leader arrows", () => {
const config = resolve({}, { terminalSuspend: true })
expect(config.keybinds.get("session.tab.next")).toMatchObject([{ key: "ctrl+tab,<leader>right" }])
expect(config.keybinds.get("session.tab.previous")).toMatchObject([{ key: "ctrl+shift+tab,<leader>left" }])
expect(config.keybinds.get("session.tab.history.back")).toMatchObject([{ key: "ctrl+o" }])
expect(config.keybinds.get("session.tab.history.forward")).toMatchObject([{ key: "ctrl+i" }])
expect(config.keybinds.get("session.tab.next_unread")).toMatchObject([{ key: "<leader>down" }])
expect(config.keybinds.get("session.tab.previous_unread")).toMatchObject([{ key: "<leader>up" }])
})
test("preserves pinned session bindings alongside tab bindings", () => {
const config = resolve({}, { terminalSuspend: true })
expect(config.keybinds.get("session.pin.toggle")).toMatchObject([{ key: "ctrl+f" }])
expect(config.keybinds.get("session.quick_switch.1")).toMatchObject([{ key: "<leader>1" }])
expect(config.keybinds.get("session.tab.select.1")).toMatchObject([{ key: "<leader>1,ctrl+1" }])
})
test("disables suspend and assigns ctrl+z to undo when unsupported", () => {
@@ -0,0 +1,174 @@
import { describe, expect, test } from "bun:test"
import {
adaptiveSessionTabLayout,
closeSessionTab,
cycleSessionTab,
moveSessionTabHistory,
openSessionTab,
recordSessionTabHistory,
sessionTabComplete,
} from "../../src/context/session-tabs-model"
describe("session tabs", () => {
test("opens each session once and refreshes its title", () => {
const tabs = openSessionTab([{ sessionID: "a", title: "Old" }], { sessionID: "a", title: "New" })
expect(tabs).toEqual([{ sessionID: "a", title: "New" }])
expect(openSessionTab(tabs, { sessionID: "b" })).toEqual([{ sessionID: "a", title: "New" }, { sessionID: "b" }])
expect(openSessionTab(tabs, { sessionID: "a", title: "New" })).toBe(tabs)
})
test("selects the right tab then the left tab after closing", () => {
expect(closeSessionTab([{ sessionID: "a" }, { sessionID: "b" }, { sessionID: "c" }], "b")).toEqual({
tabs: [{ sessionID: "a" }, { sessionID: "c" }],
next: "c",
})
expect(closeSessionTab([{ sessionID: "a" }, { sessionID: "b" }], "b").next).toBe("a")
expect(closeSessionTab([{ sessionID: "a" }], "a").next).toBeUndefined()
})
test("cycles through a filtered tab set in either direction", () => {
const tabs = ["a", "c", "e"].map((sessionID) => ({ sessionID }))
expect(cycleSessionTab(tabs, "c", 1)?.sessionID).toBe("e")
expect(cycleSessionTab(tabs, "c", -1)?.sessionID).toBe("a")
expect(cycleSessionTab(tabs, "e", 1)?.sessionID).toBe("a")
expect(cycleSessionTab(tabs, "b", 1)?.sessionID).toBe("a")
})
test("moves backward and forward through selection history", () => {
const tabs = ["a", "b", "c", "d"].map((sessionID) => ({ sessionID }))
const history = ["a", "b", "c", "d"].reduce(recordSessionTabHistory, { entries: [], index: -1 })
const backToC = moveSessionTabHistory(history, tabs, "d", -1)
const backToB = moveSessionTabHistory(backToC.history, tabs, "c", -1)
const forwardToC = moveSessionTabHistory(backToB.history, tabs, "b", 1)
const forwardToD = moveSessionTabHistory(forwardToC.history, tabs, "c", 1)
expect([backToC.sessionID, backToB.sessionID, forwardToC.sessionID, forwardToD.sessionID]).toEqual([
"c",
"b",
"c",
"d",
])
})
test("truncates forward history after a new selection", () => {
const tabs = ["a", "b", "c", "d"].map((sessionID) => ({ sessionID }))
const history = ["a", "b", "c"].reduce(recordSessionTabHistory, { entries: [], index: -1 })
const back = moveSessionTabHistory(history, tabs, "c", -1)
const branched = recordSessionTabHistory(back.history, "d")
expect(branched).toEqual({ entries: ["a", "b", "d"], index: 2 })
expect(moveSessionTabHistory(branched, tabs, "d", 1).sessionID).toBeUndefined()
})
test("skips closed tabs and duplicate entries for the active tab", () => {
const tabs = ["a", "b"].map((sessionID) => ({ sessionID }))
const history = ["a", "b", "c", "b"].reduce(recordSessionTabHistory, { entries: [], index: -1 })
expect(moveSessionTabHistory(history, tabs, "b", -1).sessionID).toBe("a")
expect(recordSessionTabHistory(history, "b")).toBe(history)
})
test("returns to the latest history entry when no tab is active", () => {
const tabs = ["a", "b"].map((sessionID) => ({ sessionID }))
const history = ["a", "b"].reduce(recordSessionTabHistory, { entries: [], index: -1 })
expect(moveSessionTabHistory(history, tabs, undefined, -1).sessionID).toBe("b")
})
test("returns to the previous selected open tab after closing the active tab", () => {
const tabs = ["a", "b", "c"].map((sessionID) => ({ sessionID }))
const history = ["a", "c"].reduce(recordSessionTabHistory, { entries: [], index: -1 })
const closed = closeSessionTab(tabs, "b")
const current = recordSessionTabHistory(history, "b")
expect(moveSessionTabHistory(current, closed.tabs, "b", -1).sessionID).toBe("c")
})
test("reveals completion activity only after session work becomes idle", () => {
expect(sessionTabComplete("activity", true)).toBe(false)
expect(sessionTabComplete("activity", false)).toBe(true)
expect(sessionTabComplete("error", false)).toBe(false)
})
test("expands the active tab and keeps inactive widths equal", () => {
const tabs = ["a", "b", "c", "d", "e", "f", "g"].map((sessionID) => ({ sessionID }))
const layout = adaptiveSessionTabLayout(tabs, "d", 76)
expect(layout).toMatchObject({ before: 0, after: 0, start: 0, total: 76 })
expect(layout.widths).toEqual([9, 9, 9, 22, 9, 9, 9])
expect(layout.widths.reduce((total, width) => total + width, 0)).toBe(76)
})
test("does not reserve an active tab slot on the new session page", () => {
const tabs = ["a", "b", "c", "d", "e"].map((sessionID) => ({ sessionID }))
const layout = adaptiveSessionTabLayout(tabs, "dummy", 40)
expect(layout.tabs).toEqual(tabs)
expect(layout.widths).toEqual([8, 8, 8, 8, 8])
expect(layout.widths.reduce((total, width) => total + width, 0)).toBe(layout.total)
})
test("keeps the visible tab window stable on the new session page", () => {
const tabs = Array.from({ length: 10 }, (_, index) => ({ sessionID: String(index) }))
const selected = adaptiveSessionTabLayout(tabs, "7", 70)
const home = adaptiveSessionTabLayout(tabs, undefined, 70, selected.start)
expect(selected.start).toBeGreaterThan(0)
expect(home.start).toBe(selected.start)
})
test("only swaps old and new active width inside a sticky window", () => {
const tabs = ["a", "b", "c", "d", "e", "f", "g"].map((sessionID) => ({ sessionID }))
const before = adaptiveSessionTabLayout(tabs, "c", 76)
const after = adaptiveSessionTabLayout(tabs, "d", 76, before.start)
expect(before.start).toBe(after.start)
expect(before.widths).toEqual([9, 9, 22, 9, 9, 9, 9])
expect(after.widths).toEqual([9, 9, 9, 22, 9, 9, 9])
})
test("shares roomy width equally without changing widths on selection", () => {
const tabs = ["a", "b", "c"].map((sessionID) => ({ sessionID }))
const before = adaptiveSessionTabLayout(tabs, "a", 100)
const after = adaptiveSessionTabLayout(tabs, "c", 100, before.start)
expect(before.widths).toEqual([32, 32, 32])
expect(after.widths).toEqual(before.widths)
expect(before.total).toBe(96)
})
test("caps a single tab instead of stretching it across the terminal", () => {
const layout = adaptiveSessionTabLayout([{ sessionID: "a" }], "a", 100)
expect(layout.widths).toEqual([32])
expect(layout.total).toBe(32)
})
test("fills roomy space equally below the maximum width", () => {
const tabs = ["a", "b", "c", "d"].map((sessionID) => ({ sessionID }))
expect(adaptiveSessionTabLayout(tabs, "b", 100).widths).toEqual([25, 25, 25, 25])
})
test("expands only the active tab under compact pressure", () => {
const tabs = ["a", "b", "c", "d", "e"].map((sessionID) => ({ sessionID }))
const before = adaptiveSessionTabLayout(tabs, "c", 100)
const after = adaptiveSessionTabLayout(tabs, "d", 100, before.start)
expect(before.widths).toEqual([19, 19, 24, 19, 19])
expect(after.widths).toEqual([19, 19, 19, 24, 19])
})
test("moves the window only after selection crosses its edge", () => {
const tabs = Array.from({ length: 10 }, (_, index) => ({ sessionID: String(index) }))
const initial = adaptiveSessionTabLayout(tabs, "3", 70)
const inside = adaptiveSessionTabLayout(tabs, "4", 70, initial.start)
const crossed = adaptiveSessionTabLayout(tabs, "7", 70, inside.start)
expect(initial.start).toBe(0)
expect(inside.start).toBe(0)
expect(crossed.start).toBeGreaterThan(0)
expect(crossed.tabs.some((tab) => tab.sessionID === "7")).toBe(true)
expect(crossed.widths.reduce((total, width) => total + width, 0)).toBe(crossed.total)
})
})
@@ -0,0 +1,27 @@
import { mkdir, writeFile } from "node:fs/promises"
import path from "node:path"
import { expect, test } from "bun:test"
import { discoverTuiPlugins } from "../src/plugin/discovery"
import { tmpdir } from "./fixture/fixture"
test("discovers project TUI plugin files in stable order", async () => {
await using tmp = await tmpdir()
const directory = path.join(tmp.path, ".opencode", "plugins", "tui")
await mkdir(path.join(directory, "nested"), { recursive: true })
await Promise.all([
writeFile(path.join(directory, "second.tsx"), "export default {}"),
writeFile(path.join(directory, "first.js"), "export default {}"),
writeFile(path.join(directory, "ignored.json"), "{}"),
writeFile(path.join(directory, "nested", "ignored.ts"), "export default {}"),
])
expect(await discoverTuiPlugins(tmp.path)).toEqual([
path.join(directory, "first.js"),
path.join(directory, "second.tsx"),
])
})
test("returns no project TUI plugins when the directory is absent", async () => {
await using tmp = await tmpdir()
expect(await discoverTuiPlugins(tmp.path)).toEqual([])
})
+45
View File
@@ -0,0 +1,45 @@
import { expect, test } from "bun:test"
import { createRoot } from "solid-js"
import { createAnimatable, spring, tween } from "../../src/ui/animation"
test("animates numeric objects and arrays to their targets", async () => {
let dispose = () => {}
const visual = createRoot((nextDispose) => {
dispose = nextDispose
return createAnimatable(
{ widths: [8, 8], selection: 0 },
{ transition: tween({ duration: 0.02, ease: (progress) => progress }) },
)
})
try {
visual.animate({ widths: [12, 4], selection: 1 })
await Bun.sleep(80)
expect(visual.value()).toEqual({ widths: [12, 4], selection: 1 })
} finally {
dispose()
}
})
test("retains spring state while retargeting and supports immediate jumps", async () => {
let dispose = () => {}
const visual = createRoot((nextDispose) => {
dispose = nextDispose
return createAnimatable({ value: 0 }, { transition: spring({ visualDuration: 0.02 }) })
})
try {
visual.animate({ value: 1 })
await Bun.sleep(20)
const target = visual.value().value
visual.animate({ value: target })
await Bun.sleep(20)
expect(visual.value().value).not.toBe(target)
await Bun.sleep(80)
expect(visual.value().value).toBeCloseTo(target)
visual.jump({ value: 0 })
expect(visual.value().value).toBe(0)
} finally {
dispose()
}
})
+5
View File
@@ -7,3 +7,8 @@ test("truncates text from the right by terminal width", () => {
expect(Locale.truncateWidth("abcdefgh", 1)).toBe("…")
expect(Locale.truncateWidth("abcdefgh", 0)).toBe("")
})
test("takes whole graphemes within terminal width", () => {
expect(Locale.takeWidth("ab界cd", 4)).toBe("ab界")
expect(Locale.graphemes("a👨‍👩‍👧‍👦b")).toEqual(["a", "👨‍👩‍👧‍👦", "b"])
})