mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-03 08:46:15 -04:00
Compare commits
23 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 28dc3cab22 | |||
| b47cfbee7c | |||
| 5504245f7b | |||
| f64b50d71b | |||
| a7b2ea94e5 | |||
| 7b775c2582 | |||
| 12a931a220 | |||
| 90100c1365 | |||
| 139c9febe4 | |||
| 06290907a9 | |||
| 1c8175a61a | |||
| fe91698ed6 | |||
| 068c32df39 | |||
| a2885d1662 | |||
| 38a3dbb4c4 | |||
| 43383d4fba | |||
| 40c4c3918a | |||
| 754ea99d86 | |||
| 37a1b80d5a | |||
| f95d04fea0 | |||
| 08b80da931 | |||
| f6fb1a7cdd | |||
| 5bcc0016a6 |
@@ -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)
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -147,6 +147,11 @@
|
|||||||
"ws": "8.21.0",
|
"ws": "8.21.0",
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"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-arm64": "1.2.0-beta.12",
|
||||||
"@lydell/node-pty-darwin-x64": "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",
|
"@lydell/node-pty-linux-arm64": "1.2.0-beta.12",
|
||||||
@@ -164,6 +169,11 @@
|
|||||||
"@types/bun": "catalog:",
|
"@types/bun": "catalog:",
|
||||||
"@types/semver": "catalog:",
|
"@types/semver": "catalog:",
|
||||||
"@typescript/native-preview": "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": "catalog:",
|
||||||
"vite-plugin-solid": "catalog:",
|
"vite-plugin-solid": "catalog:",
|
||||||
},
|
},
|
||||||
@@ -364,6 +374,7 @@
|
|||||||
"@effect/platform-node": "catalog:",
|
"@effect/platform-node": "catalog:",
|
||||||
"@effect/sql-sqlite-bun": "catalog:",
|
"@effect/sql-sqlite-bun": "catalog:",
|
||||||
"@ff-labs/fff-bun": "0.10.1",
|
"@ff-labs/fff-bun": "0.10.1",
|
||||||
|
"@ff-labs/fff-node": "0.10.1",
|
||||||
"@lydell/node-pty": "catalog:",
|
"@lydell/node-pty": "catalog:",
|
||||||
"@modelcontextprotocol/sdk": "1.29.0",
|
"@modelcontextprotocol/sdk": "1.29.0",
|
||||||
"@opencode-ai/ai": "workspace:*",
|
"@opencode-ai/ai": "workspace:*",
|
||||||
@@ -596,17 +607,20 @@
|
|||||||
"@tsconfig/node22": "catalog:",
|
"@tsconfig/node22": "catalog:",
|
||||||
"@types/node": "catalog:",
|
"@types/node": "catalog:",
|
||||||
"@typescript/native-preview": "catalog:",
|
"@typescript/native-preview": "catalog:",
|
||||||
|
"solid-js": "catalog:",
|
||||||
"typescript": "catalog:",
|
"typescript": "catalog:",
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@opentui/core": ">=0.4.5",
|
"@opentui/core": ">=0.4.5",
|
||||||
"@opentui/keymap": ">=0.4.5",
|
"@opentui/keymap": ">=0.4.5",
|
||||||
"@opentui/solid": ">=0.4.5",
|
"@opentui/solid": ">=0.4.5",
|
||||||
|
"solid-js": ">=1.9.0",
|
||||||
},
|
},
|
||||||
"optionalPeers": [
|
"optionalPeers": [
|
||||||
"@opentui/core",
|
"@opentui/core",
|
||||||
"@opentui/keymap",
|
"@opentui/keymap",
|
||||||
"@opentui/solid",
|
"@opentui/solid",
|
||||||
|
"solid-js",
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
"packages/protocol": {
|
"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-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/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=="],
|
"@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=="],
|
"@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=="],
|
"@zip.js/zip.js": ["@zip.js/zip.js@2.7.62", "", {}, "sha512-OaLvZ8j4gCkLn048ypkZu29KX30r8/OfFF2w4Jo5WXFr+J04J+lzJ5TKZBVgFXhlvSkqNFQdfnY1Q8TMTCyBVA=="],
|
||||||
|
|
||||||
"abbrev": ["abbrev@4.0.0", "", {}, "sha512-a1wflyaL0tHtJSmLSOVybYhy22vRih4eduhhrkcjgrWGnRfrZtovJ2FRjxuTtkkj47O/baf0R86QU5OuYpz8fA=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="],
|
||||||
|
|||||||
@@ -135,7 +135,7 @@ export function classifyProviderFailure(input: ProviderFailure): LLMError["reaso
|
|||||||
rateLimit: input.rateLimit,
|
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({
|
return new ProviderInternalReason({
|
||||||
...common,
|
...common,
|
||||||
status: input.status,
|
status: input.status,
|
||||||
@@ -145,7 +145,6 @@ export function classifyProviderFailure(input: ProviderFailure): LLMError["reaso
|
|||||||
if (
|
if (
|
||||||
input.status === 400 ||
|
input.status === 400 ||
|
||||||
input.status === 404 ||
|
input.status === 404 ||
|
||||||
input.status === 409 ||
|
|
||||||
input.status === 413 ||
|
input.status === 413 ||
|
||||||
input.status === 422
|
input.status === 422
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -58,6 +58,12 @@ describe("provider error classification", () => {
|
|||||||
).toEqual(["ProviderInternal", "ProviderInternal"])
|
).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", () => {
|
test("classifies nested provider codes when a top-level code is also present", () => {
|
||||||
expect(
|
expect(
|
||||||
[
|
[
|
||||||
|
|||||||
@@ -6,22 +6,17 @@ import { Icon } from "@opencode-ai/ui/icon"
|
|||||||
import { IconButton } from "@opencode-ai/ui/icon-button"
|
import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||||
import { List } from "@opencode-ai/ui/list"
|
import { List } from "@opencode-ai/ui/list"
|
||||||
import { TextField } from "@opencode-ai/ui/text-field"
|
import { TextField } from "@opencode-ai/ui/text-field"
|
||||||
import { useMutation } from "@tanstack/solid-query"
|
import { Show } from "solid-js"
|
||||||
import { showToast } from "@/utils/toast"
|
|
||||||
import { useNavigate } from "@solidjs/router"
|
|
||||||
import { createEffect, createMemo, createResource, Show } from "solid-js"
|
|
||||||
import { createStore } from "solid-js/store"
|
|
||||||
import { ServerHealthIndicator, ServerRow } from "@/components/server/server-row"
|
import { ServerHealthIndicator, ServerRow } from "@/components/server/server-row"
|
||||||
import { useGlobal } from "@/context/global"
|
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { usePlatform } from "@/context/platform"
|
import { ServerConnection } from "@/context/server"
|
||||||
import { normalizeServerUrl, ServerConnection, useServer } from "@/context/server"
|
|
||||||
import { detectServerProtocol } from "@/utils/server-protocol"
|
|
||||||
import { type ServerHealth, useCheckServerHealth } from "@/utils/server-health"
|
|
||||||
import { useSettings } from "@/context/settings"
|
import { useSettings } from "@/context/settings"
|
||||||
import { useTabs } from "@/context/tabs"
|
import {
|
||||||
|
type ServerDomainController,
|
||||||
const DEFAULT_USERNAME = "opencode"
|
type ServerFormController,
|
||||||
|
useServerDomainController,
|
||||||
|
useServerFormController,
|
||||||
|
} from "@/components/server/server-management-controller"
|
||||||
|
|
||||||
interface ServerFormProps {
|
interface ServerFormProps {
|
||||||
value: string
|
value: string
|
||||||
@@ -40,76 +35,6 @@ interface ServerFormProps {
|
|||||||
onBack: () => void
|
onBack: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
function showRequestError(language: ReturnType<typeof useLanguage>, err: unknown) {
|
|
||||||
showToast({
|
|
||||||
variant: "error",
|
|
||||||
title: language.t("common.requestFailed"),
|
|
||||||
description: err instanceof Error ? err.message : String(err),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function useDefaultServer() {
|
|
||||||
const language = useLanguage()
|
|
||||||
const platform = usePlatform()
|
|
||||||
const [defaultKey, defaultUrlActions] = createResource(
|
|
||||||
async () => {
|
|
||||||
try {
|
|
||||||
const key = await platform.getDefaultServer?.()
|
|
||||||
if (!key) return null
|
|
||||||
return key
|
|
||||||
} catch (err) {
|
|
||||||
showRequestError(language, err)
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{ initialValue: null },
|
|
||||||
)
|
|
||||||
|
|
||||||
const canDefault = createMemo(() => !!platform.getDefaultServer && !!platform.setDefaultServer)
|
|
||||||
const setDefault = async (key: ServerConnection.Key | null) => {
|
|
||||||
try {
|
|
||||||
await platform.setDefaultServer?.(key)
|
|
||||||
defaultUrlActions.mutate(key)
|
|
||||||
} catch (err) {
|
|
||||||
showRequestError(language, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return { defaultKey: () => defaultKey.latest, canDefault, setDefault }
|
|
||||||
}
|
|
||||||
|
|
||||||
function useServerPreview() {
|
|
||||||
const checkServerHealth = useCheckServerHealth()
|
|
||||||
|
|
||||||
const looksComplete = (value: string) => {
|
|
||||||
const normalized = normalizeServerUrl(value)
|
|
||||||
if (!normalized) return false
|
|
||||||
const host = normalized.replace(/^https?:\/\//, "").split("/")[0]
|
|
||||||
if (!host) return false
|
|
||||||
if (host.includes("localhost") || host.startsWith("127.0.0.1")) return true
|
|
||||||
return host.includes(".") || host.includes(":")
|
|
||||||
}
|
|
||||||
|
|
||||||
const previewStatus = async (
|
|
||||||
value: string,
|
|
||||||
username: string,
|
|
||||||
password: string,
|
|
||||||
setStatus: (value: boolean | undefined) => void,
|
|
||||||
) => {
|
|
||||||
setStatus(undefined)
|
|
||||||
if (!looksComplete(value)) return
|
|
||||||
const normalized = normalizeServerUrl(value)
|
|
||||||
if (!normalized) return
|
|
||||||
const http: ServerConnection.HttpBase = { url: normalized }
|
|
||||||
if (username) http.username = username
|
|
||||||
if (password) http.password = password
|
|
||||||
const result = await checkServerHealth(http)
|
|
||||||
setStatus(result.healthy)
|
|
||||||
}
|
|
||||||
|
|
||||||
return { previewStatus }
|
|
||||||
}
|
|
||||||
|
|
||||||
function ServerForm(props: ServerFormProps) {
|
function ServerForm(props: ServerFormProps) {
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
const keyDown = (event: KeyboardEvent) => {
|
const keyDown = (event: KeyboardEvent) => {
|
||||||
@@ -177,401 +102,40 @@ function ServerForm(props: ServerFormProps) {
|
|||||||
|
|
||||||
export function DialogSelectServer() {
|
export function DialogSelectServer() {
|
||||||
const dialog = useDialog()
|
const dialog = useDialog()
|
||||||
const controller = useServerManagementController({ onSelect: dialog.close })
|
const language = useLanguage()
|
||||||
|
const domain = useServerDomainController({ onSelect: () => dialog.close() })
|
||||||
|
const form = useServerFormController({ onSelect: () => dialog.close() })
|
||||||
|
const title = () => {
|
||||||
|
if (!form.state.open()) return language.t("dialog.server.title")
|
||||||
|
return (
|
||||||
|
<div class="flex items-center gap-2 -ml-2">
|
||||||
|
<IconButton icon="arrow-left" variant="ghost" onClick={form.reset} aria-label={language.t("common.goBack")} />
|
||||||
|
<span>
|
||||||
|
{form.state.adding() ? language.t("dialog.server.add.title") : language.t("dialog.server.edit.title")}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog title={controller.formTitle()}>
|
<Dialog title={title()}>
|
||||||
<div class="flex flex-1 min-h-0 flex-col px-5">
|
<div class="flex flex-1 min-h-0 flex-col px-5">
|
||||||
<Show when={controller.isFormMode()} fallback={<ServerConnectionList controller={controller} />}>
|
<Show
|
||||||
<ServerConnectionForm controller={controller} />
|
when={form.state.open()}
|
||||||
|
fallback={<ServerConnectionList domain={domain} onAdd={form.start.add} onEdit={form.start.edit} />}
|
||||||
|
>
|
||||||
|
<ServerConnectionForm form={form} />
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useServerManagementController(options: { onSelect?: () => void; navigateOnAdd?: boolean } = {}) {
|
export function ServerConnectionList(props: {
|
||||||
const navigate = useNavigate()
|
domain: ServerDomainController
|
||||||
const server = useServer()
|
onAdd: () => void
|
||||||
const tabs = useTabs()
|
onEdit: (server: ServerConnection.Http) => void
|
||||||
const global = useGlobal()
|
}) {
|
||||||
const platform = usePlatform()
|
|
||||||
const language = useLanguage()
|
|
||||||
const { defaultKey, canDefault, setDefault } = useDefaultServer()
|
|
||||||
const { previewStatus } = useServerPreview()
|
|
||||||
const checkServerHealth = useCheckServerHealth()
|
|
||||||
const [store, setStore] = createStore({
|
|
||||||
addServer: {
|
|
||||||
url: "",
|
|
||||||
name: "",
|
|
||||||
username: DEFAULT_USERNAME,
|
|
||||||
password: "",
|
|
||||||
error: "",
|
|
||||||
showForm: false,
|
|
||||||
status: undefined as boolean | undefined,
|
|
||||||
},
|
|
||||||
editServer: {
|
|
||||||
id: undefined as string | undefined,
|
|
||||||
value: "",
|
|
||||||
name: "",
|
|
||||||
username: "",
|
|
||||||
password: "",
|
|
||||||
error: "",
|
|
||||||
status: undefined as boolean | undefined,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const resetAdd = () => {
|
|
||||||
setStore("addServer", {
|
|
||||||
url: "",
|
|
||||||
name: "",
|
|
||||||
username: DEFAULT_USERNAME,
|
|
||||||
password: "",
|
|
||||||
error: "",
|
|
||||||
showForm: false,
|
|
||||||
status: undefined,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
const resetEdit = () => {
|
|
||||||
setStore("editServer", {
|
|
||||||
id: undefined,
|
|
||||||
value: "",
|
|
||||||
name: "",
|
|
||||||
username: "",
|
|
||||||
password: "",
|
|
||||||
error: "",
|
|
||||||
status: undefined,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const addMutation = useMutation(() => ({
|
|
||||||
mutationFn: async (value: string) => {
|
|
||||||
const normalized = normalizeServerUrl(value)
|
|
||||||
if (!normalized) {
|
|
||||||
resetAdd()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const conn: ServerConnection.Http = {
|
|
||||||
type: "http",
|
|
||||||
http: { url: normalized },
|
|
||||||
}
|
|
||||||
if (store.addServer.name.trim()) conn.displayName = store.addServer.name.trim()
|
|
||||||
if (store.addServer.password) conn.http.password = store.addServer.password
|
|
||||||
if (store.addServer.password && store.addServer.username) conn.http.username = store.addServer.username
|
|
||||||
const result = await checkServerHealth(conn.http)
|
|
||||||
if (!result.healthy) {
|
|
||||||
setStore("addServer", { error: language.t("dialog.server.add.error") })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
!settings.general.newLayoutDesigns() &&
|
|
||||||
(await detectServerProtocol(conn.http, platform.fetch ?? globalThis.fetch)) === "v2"
|
|
||||||
) {
|
|
||||||
setStore("addServer", { error: language.t("dialog.server.add.error") })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
resetAdd()
|
|
||||||
if (options.navigateOnAdd === false) {
|
|
||||||
server.add(conn)
|
|
||||||
options.onSelect?.()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
await select(conn, true)
|
|
||||||
},
|
|
||||||
}))
|
|
||||||
|
|
||||||
const editMutation = useMutation(() => ({
|
|
||||||
mutationFn: async (input: { original: ServerConnection.Any; value: string }) => {
|
|
||||||
if (input.original.type !== "http") return
|
|
||||||
const normalized = normalizeServerUrl(input.value)
|
|
||||||
if (!normalized) {
|
|
||||||
resetEdit()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const name = store.editServer.name.trim() || undefined
|
|
||||||
const username = store.editServer.username || undefined
|
|
||||||
const password = store.editServer.password || undefined
|
|
||||||
const existingName = input.original.displayName
|
|
||||||
if (
|
|
||||||
normalized === input.original.http.url &&
|
|
||||||
name === existingName &&
|
|
||||||
username === input.original.http.username &&
|
|
||||||
password === input.original.http.password
|
|
||||||
) {
|
|
||||||
resetEdit()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const conn: ServerConnection.Http = {
|
|
||||||
type: "http",
|
|
||||||
displayName: name,
|
|
||||||
http: { url: normalized, username, password },
|
|
||||||
}
|
|
||||||
const result = await checkServerHealth(conn.http)
|
|
||||||
if (!result.healthy) {
|
|
||||||
setStore("editServer", { error: language.t("dialog.server.add.error") })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
!settings.general.newLayoutDesigns() &&
|
|
||||||
(await detectServerProtocol(conn.http, platform.fetch ?? globalThis.fetch)) === "v2"
|
|
||||||
) {
|
|
||||||
setStore("editServer", { error: language.t("dialog.server.add.error") })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (normalized === input.original.http.url) {
|
|
||||||
server.add(conn)
|
|
||||||
} else {
|
|
||||||
replaceServer(input.original, conn)
|
|
||||||
}
|
|
||||||
|
|
||||||
resetEdit()
|
|
||||||
},
|
|
||||||
}))
|
|
||||||
|
|
||||||
const replaceServer = (original: ServerConnection.Http, next: ServerConnection.Http) => {
|
|
||||||
const originalKey = ServerConnection.key(original)
|
|
||||||
const active = server.key
|
|
||||||
tabs.removeServer(originalKey)
|
|
||||||
const newConn = server.add(next)
|
|
||||||
if (!newConn) return
|
|
||||||
const nextActive = active === originalKey ? ServerConnection.key(newConn) : active
|
|
||||||
if (nextActive) server.setActive(nextActive)
|
|
||||||
server.remove(originalKey)
|
|
||||||
}
|
|
||||||
|
|
||||||
const items = createMemo(() => {
|
|
||||||
const current = server.current
|
|
||||||
const list = server.list
|
|
||||||
if (!current) return list
|
|
||||||
if (!list.includes(current)) return [current, ...list]
|
|
||||||
return [current, ...list.filter((x) => x !== current)]
|
|
||||||
})
|
|
||||||
|
|
||||||
const settings = useSettings()
|
|
||||||
const current = createMemo<ServerConnection.Any | undefined>(() =>
|
|
||||||
settings.general.newLayoutDesigns()
|
|
||||||
? undefined
|
|
||||||
: (items().find((x) => ServerConnection.key(x) === server.key) ?? items()[0]),
|
|
||||||
)
|
|
||||||
|
|
||||||
const sortedItems = createMemo(() => {
|
|
||||||
const raw = items()
|
|
||||||
const list = settings.general.newLayoutDesigns()
|
|
||||||
? raw
|
|
||||||
: raw.filter((x) => global.ensureServerCtx(x).sdk.protocolKind() !== "v2")
|
|
||||||
if (!list.length) return list
|
|
||||||
const active = current()
|
|
||||||
const order = new Map(list.map((url, index) => [url, index] as const))
|
|
||||||
const rank = (value?: ServerHealth) => {
|
|
||||||
if (value?.healthy === true) return 0
|
|
||||||
if (value?.healthy === false) return 2
|
|
||||||
return 1
|
|
||||||
}
|
|
||||||
return list.slice().sort((a, b) => {
|
|
||||||
if (a === active) return -1
|
|
||||||
if (b === active) return 1
|
|
||||||
const diff =
|
|
||||||
rank(global.servers.health[ServerConnection.key(a)]) - rank(global.servers.health[ServerConnection.key(b)])
|
|
||||||
if (diff !== 0) return diff
|
|
||||||
return (order.get(a) ?? 0) - (order.get(b) ?? 0)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
async function select(conn: ServerConnection.Any, persist?: boolean) {
|
|
||||||
if (!persist && global.servers.health[ServerConnection.key(conn)]?.healthy === false) return
|
|
||||||
options.onSelect?.()
|
|
||||||
if (persist && conn.type === "http") {
|
|
||||||
server.add(conn)
|
|
||||||
navigate("/")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
navigate("/")
|
|
||||||
queueMicrotask(() => server.setActive(ServerConnection.key(conn)))
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleAddChange = (value: string) => {
|
|
||||||
if (addMutation.isPending) return
|
|
||||||
setStore("addServer", { url: value, error: "" })
|
|
||||||
void previewStatus(value, store.addServer.username, store.addServer.password, (next) =>
|
|
||||||
setStore("addServer", { status: next }),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleAddNameChange = (value: string) => {
|
|
||||||
if (addMutation.isPending) return
|
|
||||||
setStore("addServer", { name: value, error: "" })
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleAddUsernameChange = (value: string) => {
|
|
||||||
if (addMutation.isPending) return
|
|
||||||
setStore("addServer", { username: value, error: "" })
|
|
||||||
void previewStatus(store.addServer.url, value, store.addServer.password, (next) =>
|
|
||||||
setStore("addServer", { status: next }),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleAddPasswordChange = (value: string) => {
|
|
||||||
if (addMutation.isPending) return
|
|
||||||
setStore("addServer", { password: value, error: "" })
|
|
||||||
void previewStatus(store.addServer.url, store.addServer.username, value, (next) =>
|
|
||||||
setStore("addServer", { status: next }),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleEditChange = (value: string) => {
|
|
||||||
if (editMutation.isPending) return
|
|
||||||
setStore("editServer", { value, error: "" })
|
|
||||||
void previewStatus(value, store.editServer.username, store.editServer.password, (next) =>
|
|
||||||
setStore("editServer", { status: next }),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleEditNameChange = (value: string) => {
|
|
||||||
if (editMutation.isPending) return
|
|
||||||
setStore("editServer", { name: value, error: "" })
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleEditUsernameChange = (value: string) => {
|
|
||||||
if (editMutation.isPending) return
|
|
||||||
setStore("editServer", { username: value, error: "" })
|
|
||||||
void previewStatus(store.editServer.value, value, store.editServer.password, (next) =>
|
|
||||||
setStore("editServer", { status: next }),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleEditPasswordChange = (value: string) => {
|
|
||||||
if (editMutation.isPending) return
|
|
||||||
setStore("editServer", { password: value, error: "" })
|
|
||||||
void previewStatus(store.editServer.value, store.editServer.username, value, (next) =>
|
|
||||||
setStore("editServer", { status: next }),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const mode = createMemo<"list" | "add" | "edit">(() => {
|
|
||||||
if (store.editServer.id) return "edit"
|
|
||||||
if (store.addServer.showForm) return "add"
|
|
||||||
return "list"
|
|
||||||
})
|
|
||||||
|
|
||||||
const editing = createMemo(() => {
|
|
||||||
if (!store.editServer.id) return
|
|
||||||
return items().find((x) => x.type === "http" && x.http.url === store.editServer.id)
|
|
||||||
})
|
|
||||||
|
|
||||||
const resetForm = () => {
|
|
||||||
resetAdd()
|
|
||||||
resetEdit()
|
|
||||||
}
|
|
||||||
|
|
||||||
const startAdd = () => {
|
|
||||||
resetEdit()
|
|
||||||
setStore("addServer", {
|
|
||||||
showForm: true,
|
|
||||||
url: "",
|
|
||||||
name: "",
|
|
||||||
username: DEFAULT_USERNAME,
|
|
||||||
password: "",
|
|
||||||
error: "",
|
|
||||||
status: undefined,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const startEdit = (conn: ServerConnection.Http) => {
|
|
||||||
resetAdd()
|
|
||||||
setStore("editServer", {
|
|
||||||
id: conn.http.url,
|
|
||||||
value: conn.http.url,
|
|
||||||
name: conn.displayName ?? "",
|
|
||||||
username: conn.http.username ?? "",
|
|
||||||
password: conn.http.password ?? "",
|
|
||||||
error: "",
|
|
||||||
status: global.servers.health[ServerConnection.key(conn)]?.healthy,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const submitForm = () => {
|
|
||||||
if (mode() === "add") {
|
|
||||||
if (addMutation.isPending) return
|
|
||||||
setStore("addServer", { error: "" })
|
|
||||||
addMutation.mutate(store.addServer.url)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const original = editing()
|
|
||||||
if (!original) return
|
|
||||||
if (editMutation.isPending) return
|
|
||||||
setStore("editServer", { error: "" })
|
|
||||||
editMutation.mutate({ original, value: store.editServer.value })
|
|
||||||
}
|
|
||||||
|
|
||||||
const isFormMode = createMemo(() => mode() !== "list")
|
|
||||||
const isAddMode = createMemo(() => mode() === "add")
|
|
||||||
const formBusy = createMemo(() => (isAddMode() ? addMutation.isPending : editMutation.isPending))
|
|
||||||
|
|
||||||
const formTitle = createMemo(() => {
|
|
||||||
if (!isFormMode()) return language.t("dialog.server.title")
|
|
||||||
return (
|
|
||||||
<div class="flex items-center gap-2 -ml-2">
|
|
||||||
<IconButton icon="arrow-left" variant="ghost" onClick={resetForm} aria-label={language.t("common.goBack")} />
|
|
||||||
<span>{isAddMode() ? language.t("dialog.server.add.title") : language.t("dialog.server.edit.title")}</span>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
createEffect(() => {
|
|
||||||
if (!store.editServer.id) return
|
|
||||||
if (editing()) return
|
|
||||||
resetEdit()
|
|
||||||
})
|
|
||||||
|
|
||||||
async function handleRemove(key: ServerConnection.Key) {
|
|
||||||
try {
|
|
||||||
if (key.startsWith("wsl:")) await platform.wslServers?.removeServer(key)
|
|
||||||
tabs.removeServer(key)
|
|
||||||
server.remove(key)
|
|
||||||
if ((await platform.getDefaultServer?.()) === key) {
|
|
||||||
await setDefault(null)
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
showRequestError(language, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
defaultKey,
|
|
||||||
canDefault,
|
|
||||||
current,
|
|
||||||
sortedItems,
|
|
||||||
status: () => global.servers.health,
|
|
||||||
isFormMode,
|
|
||||||
isAddMode,
|
|
||||||
formTitle,
|
|
||||||
formBusy,
|
|
||||||
formValue: () => (isAddMode() ? store.addServer.url : store.editServer.value),
|
|
||||||
formName: () => (isAddMode() ? store.addServer.name : store.editServer.name),
|
|
||||||
formUsername: () => (isAddMode() ? store.addServer.username : store.editServer.username),
|
|
||||||
formPassword: () => (isAddMode() ? store.addServer.password : store.editServer.password),
|
|
||||||
formError: () => (isAddMode() ? store.addServer.error : store.editServer.error),
|
|
||||||
formStatus: () => (isAddMode() ? store.addServer.status : store.editServer.status),
|
|
||||||
select,
|
|
||||||
setDefault,
|
|
||||||
startAdd,
|
|
||||||
startEdit,
|
|
||||||
resetForm,
|
|
||||||
submitForm,
|
|
||||||
canRemove: server.canRemove,
|
|
||||||
handleRemove,
|
|
||||||
handleFormChange: () => (isAddMode() ? handleAddChange : handleEditChange),
|
|
||||||
handleFormNameChange: () => (isAddMode() ? handleAddNameChange : handleEditNameChange),
|
|
||||||
handleFormUsernameChange: () => (isAddMode() ? handleAddUsernameChange : handleEditUsernameChange),
|
|
||||||
handleFormPasswordChange: () => (isAddMode() ? handleAddPasswordChange : handleEditPasswordChange),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ServerConnectionList(props: { controller: ReturnType<typeof useServerManagementController> }) {
|
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
const settings = useSettings()
|
const settings = useSettings()
|
||||||
|
|
||||||
@@ -585,10 +149,10 @@ export function ServerConnectionList(props: { controller: ReturnType<typeof useS
|
|||||||
}}
|
}}
|
||||||
noInitialSelection
|
noInitialSelection
|
||||||
emptyMessage={language.t("dialog.server.empty")}
|
emptyMessage={language.t("dialog.server.empty")}
|
||||||
items={props.controller.sortedItems}
|
items={props.domain.collection.items}
|
||||||
key={(x) => x.http.url}
|
key={(x) => x.http.url}
|
||||||
onSelect={(x) => {
|
onSelect={(x) => {
|
||||||
if (x && !settings.general.newLayoutDesigns()) void props.controller.select(x)
|
if (x && !settings.general.newLayoutDesigns()) void props.domain.selection.select(x)
|
||||||
}}
|
}}
|
||||||
divider={true}
|
divider={true}
|
||||||
>
|
>
|
||||||
@@ -597,15 +161,15 @@ export function ServerConnectionList(props: { controller: ReturnType<typeof useS
|
|||||||
return (
|
return (
|
||||||
<div class="flex items-center gap-3 min-w-0 flex-1 w-full group/item">
|
<div class="flex items-center gap-3 min-w-0 flex-1 w-full group/item">
|
||||||
<div class="flex flex-col h-full items-center w-5">
|
<div class="flex flex-col h-full items-center w-5">
|
||||||
<ServerHealthIndicator health={props.controller.status()[key]} />
|
<ServerHealthIndicator health={props.domain.collection.health()[key]} />
|
||||||
</div>
|
</div>
|
||||||
<ServerRow
|
<ServerRow
|
||||||
conn={i}
|
conn={i}
|
||||||
dimmed={props.controller.status()[key]?.healthy === false}
|
dimmed={props.domain.collection.health()[key]?.healthy === false}
|
||||||
status={props.controller.status()[key]}
|
status={props.domain.collection.health()[key]}
|
||||||
class="flex items-center gap-3 min-w-0 flex-1"
|
class="flex items-center gap-3 min-w-0 flex-1"
|
||||||
badge={
|
badge={
|
||||||
<Show when={props.controller.defaultKey() === ServerConnection.key(i)}>
|
<Show when={props.domain.defaults.key() === ServerConnection.key(i)}>
|
||||||
<span class="text-text-base bg-surface-base text-14-regular px-1.5 rounded-xs">
|
<span class="text-text-base bg-surface-base text-14-regular px-1.5 rounded-xs">
|
||||||
{language.t("dialog.server.status.default")}
|
{language.t("dialog.server.status.default")}
|
||||||
</span>
|
</span>
|
||||||
@@ -614,7 +178,12 @@ export function ServerConnectionList(props: { controller: ReturnType<typeof useS
|
|||||||
showCredentials
|
showCredentials
|
||||||
/>
|
/>
|
||||||
<div class="flex items-center justify-center gap-4 pl-4">
|
<div class="flex items-center justify-center gap-4 pl-4">
|
||||||
<Show when={props.controller.current() && ServerConnection.key(props.controller.current()!) === key}>
|
<Show
|
||||||
|
when={
|
||||||
|
props.domain.collection.current() &&
|
||||||
|
ServerConnection.key(props.domain.collection.current()!) === key
|
||||||
|
}
|
||||||
|
>
|
||||||
<Icon name="check" class="h-6" />
|
<Icon name="check" class="h-6" />
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
@@ -633,27 +202,27 @@ export function ServerConnectionList(props: { controller: ReturnType<typeof useS
|
|||||||
<DropdownMenu.Item
|
<DropdownMenu.Item
|
||||||
onSelect={() => {
|
onSelect={() => {
|
||||||
if (i.type !== "http") return
|
if (i.type !== "http") return
|
||||||
props.controller.startEdit(i)
|
props.onEdit(i)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<DropdownMenu.ItemLabel>{language.t("dialog.server.menu.edit")}</DropdownMenu.ItemLabel>
|
<DropdownMenu.ItemLabel>{language.t("dialog.server.menu.edit")}</DropdownMenu.ItemLabel>
|
||||||
</DropdownMenu.Item>
|
</DropdownMenu.Item>
|
||||||
<Show when={props.controller.canDefault() && props.controller.defaultKey() !== key}>
|
<Show when={props.domain.defaults.available() && props.domain.defaults.key() !== key}>
|
||||||
<DropdownMenu.Item onSelect={() => props.controller.setDefault(key)}>
|
<DropdownMenu.Item onSelect={() => props.domain.defaults.set(key)}>
|
||||||
<DropdownMenu.ItemLabel>{language.t("dialog.server.menu.default")}</DropdownMenu.ItemLabel>
|
<DropdownMenu.ItemLabel>{language.t("dialog.server.menu.default")}</DropdownMenu.ItemLabel>
|
||||||
</DropdownMenu.Item>
|
</DropdownMenu.Item>
|
||||||
</Show>
|
</Show>
|
||||||
<Show when={props.controller.canDefault() && props.controller.defaultKey() === key}>
|
<Show when={props.domain.defaults.available() && props.domain.defaults.key() === key}>
|
||||||
<DropdownMenu.Item onSelect={() => props.controller.setDefault(null)}>
|
<DropdownMenu.Item onSelect={() => props.domain.defaults.set(null)}>
|
||||||
<DropdownMenu.ItemLabel>
|
<DropdownMenu.ItemLabel>
|
||||||
{language.t("dialog.server.menu.defaultRemove")}
|
{language.t("dialog.server.menu.defaultRemove")}
|
||||||
</DropdownMenu.ItemLabel>
|
</DropdownMenu.ItemLabel>
|
||||||
</DropdownMenu.Item>
|
</DropdownMenu.Item>
|
||||||
</Show>
|
</Show>
|
||||||
<Show when={props.controller.canRemove(key)}>
|
<Show when={props.domain.connection.canRemove(key)}>
|
||||||
<DropdownMenu.Separator />
|
<DropdownMenu.Separator />
|
||||||
<DropdownMenu.Item
|
<DropdownMenu.Item
|
||||||
onSelect={() => props.controller.handleRemove(ServerConnection.key(i))}
|
onSelect={() => props.domain.connection.remove(key)}
|
||||||
class="text-text-on-critical-base hover:bg-surface-critical-weak"
|
class="text-text-on-critical-base hover:bg-surface-critical-weak"
|
||||||
>
|
>
|
||||||
<DropdownMenu.ItemLabel>{language.t("dialog.server.menu.delete")}</DropdownMenu.ItemLabel>
|
<DropdownMenu.ItemLabel>{language.t("dialog.server.menu.delete")}</DropdownMenu.ItemLabel>
|
||||||
@@ -674,7 +243,7 @@ export function ServerConnectionList(props: { controller: ReturnType<typeof useS
|
|||||||
variant="secondary"
|
variant="secondary"
|
||||||
icon="plus-small"
|
icon="plus-small"
|
||||||
size="large"
|
size="large"
|
||||||
onClick={props.controller.startAdd}
|
onClick={props.onAdd}
|
||||||
class="py-1.5 pl-1.5 pr-3 flex items-center gap-1.5"
|
class="py-1.5 pl-1.5 pr-3 flex items-center gap-1.5"
|
||||||
>
|
>
|
||||||
{language.t("dialog.server.add.button")}
|
{language.t("dialog.server.add.button")}
|
||||||
@@ -684,38 +253,38 @@ export function ServerConnectionList(props: { controller: ReturnType<typeof useS
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ServerConnectionForm(props: { controller: ReturnType<typeof useServerManagementController> }) {
|
export function ServerConnectionForm(props: { form: ServerFormController }) {
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class="flex flex-1 min-h-0 flex-col gap-4">
|
<div class="flex flex-1 min-h-0 flex-col gap-4">
|
||||||
<ServerForm
|
<ServerForm
|
||||||
value={props.controller.formValue()}
|
value={props.form.state.value()}
|
||||||
name={props.controller.formName()}
|
name={props.form.state.name()}
|
||||||
username={props.controller.formUsername()}
|
username={props.form.state.username()}
|
||||||
password={props.controller.formPassword()}
|
password={props.form.state.password()}
|
||||||
placeholder={language.t("dialog.server.add.placeholder")}
|
placeholder={language.t("dialog.server.add.placeholder")}
|
||||||
busy={props.controller.formBusy()}
|
busy={props.form.state.busy()}
|
||||||
error={props.controller.formError()}
|
error={props.form.state.error()}
|
||||||
status={props.controller.formStatus()}
|
status={props.form.state.status()}
|
||||||
onChange={props.controller.handleFormChange()}
|
onChange={props.form.change.value}
|
||||||
onNameChange={props.controller.handleFormNameChange()}
|
onNameChange={props.form.change.name}
|
||||||
onUsernameChange={props.controller.handleFormUsernameChange()}
|
onUsernameChange={props.form.change.username}
|
||||||
onPasswordChange={props.controller.handleFormPasswordChange()}
|
onPasswordChange={props.form.change.password}
|
||||||
onSubmit={props.controller.submitForm}
|
onSubmit={props.form.submit}
|
||||||
onBack={props.controller.resetForm}
|
onBack={props.form.reset}
|
||||||
/>
|
/>
|
||||||
<div class="shrink-0 pb-5">
|
<div class="shrink-0 pb-5">
|
||||||
<Button
|
<Button
|
||||||
variant="primary"
|
variant="primary"
|
||||||
size="large"
|
size="large"
|
||||||
onClick={props.controller.submitForm}
|
onClick={props.form.submit}
|
||||||
disabled={props.controller.formBusy()}
|
disabled={props.form.state.busy()}
|
||||||
class="px-3 py-1.5"
|
class="px-3 py-1.5"
|
||||||
>
|
>
|
||||||
{props.controller.formBusy()
|
{props.form.state.busy()
|
||||||
? language.t("dialog.server.add.checking")
|
? language.t("dialog.server.add.checking")
|
||||||
: props.controller.isAddMode()
|
: props.form.state.adding()
|
||||||
? language.t("dialog.server.add.button")
|
? language.t("dialog.server.add.button")
|
||||||
: language.t("common.save")}
|
: language.t("common.save")}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -0,0 +1,336 @@
|
|||||||
|
import { useNavigate } from "@solidjs/router"
|
||||||
|
import { useMutation } from "@tanstack/solid-query"
|
||||||
|
import { createEffect, createMemo, createResource, onCleanup } from "solid-js"
|
||||||
|
import { createStore } from "solid-js/store"
|
||||||
|
import { useGlobal } from "@/context/global"
|
||||||
|
import { useLanguage } from "@/context/language"
|
||||||
|
import { usePlatform } from "@/context/platform"
|
||||||
|
import { normalizeServerUrl, ServerConnection, useServer } from "@/context/server"
|
||||||
|
import { useSettings } from "@/context/settings"
|
||||||
|
import { useTabs } from "@/context/tabs"
|
||||||
|
import { type ServerHealth, useCheckServerHealth } from "@/utils/server-health"
|
||||||
|
import { detectServerProtocol } from "@/utils/server-protocol"
|
||||||
|
import { showToast } from "@/utils/toast"
|
||||||
|
import { createServerHealthPreview, replaceServerConnection, type ServerFormValues } from "./server-management"
|
||||||
|
|
||||||
|
const DEFAULT_USERNAME = "opencode"
|
||||||
|
|
||||||
|
type FormMode = "list" | "add" | "edit"
|
||||||
|
|
||||||
|
function showRequestError(language: ReturnType<typeof useLanguage>, err: unknown) {
|
||||||
|
showToast({
|
||||||
|
variant: "error",
|
||||||
|
title: language.t("common.requestFailed"),
|
||||||
|
description: err instanceof Error ? err.message : String(err),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function useDefaultServer() {
|
||||||
|
const language = useLanguage()
|
||||||
|
const platform = usePlatform()
|
||||||
|
const [defaultKey, defaultKeyActions] = createResource(
|
||||||
|
async () => {
|
||||||
|
try {
|
||||||
|
return (await platform.getDefaultServer?.()) ?? null
|
||||||
|
} catch (err) {
|
||||||
|
showRequestError(language, err)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ initialValue: null },
|
||||||
|
)
|
||||||
|
|
||||||
|
const set = async (key: ServerConnection.Key | null) => {
|
||||||
|
try {
|
||||||
|
await platform.setDefaultServer?.(key)
|
||||||
|
defaultKeyActions.mutate(key)
|
||||||
|
} catch (err) {
|
||||||
|
showRequestError(language, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
key: () => defaultKey.latest,
|
||||||
|
available: createMemo(() => !!platform.getDefaultServer && !!platform.setDefaultServer),
|
||||||
|
set,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function useServerMutations() {
|
||||||
|
const server = useServer()
|
||||||
|
const tabs = useTabs()
|
||||||
|
|
||||||
|
return {
|
||||||
|
add: (connection: ServerConnection.Http) => server.add(connection),
|
||||||
|
replace: (original: ServerConnection.Http, next: ServerConnection.Http) =>
|
||||||
|
replaceServerConnection(original, next, {
|
||||||
|
active: () => server.key,
|
||||||
|
removeTabs: (key) => tabs.removeServer(key),
|
||||||
|
add: (connection) => server.add(connection),
|
||||||
|
setActive: (key) => server.setActive(key),
|
||||||
|
remove: (key) => server.remove(key),
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useServerActionsController() {
|
||||||
|
const server = useServer()
|
||||||
|
const tabs = useTabs()
|
||||||
|
const platform = usePlatform()
|
||||||
|
const language = useLanguage()
|
||||||
|
const defaults = useDefaultServer()
|
||||||
|
|
||||||
|
const remove = async (key: ServerConnection.Key) => {
|
||||||
|
try {
|
||||||
|
if (key.startsWith("wsl:")) await platform.wslServers?.removeServer(key)
|
||||||
|
tabs.removeServer(key)
|
||||||
|
server.remove(key)
|
||||||
|
if ((await platform.getDefaultServer?.()) === key) await defaults.set(null)
|
||||||
|
} catch (err) {
|
||||||
|
showRequestError(language, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { defaults, connection: { canRemove: server.canRemove, remove } }
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ServerActionsController = ReturnType<typeof useServerActionsController>
|
||||||
|
|
||||||
|
export function useServerCollectionController() {
|
||||||
|
const server = useServer()
|
||||||
|
const global = useGlobal()
|
||||||
|
const settings = useSettings()
|
||||||
|
const actions = useServerActionsController()
|
||||||
|
|
||||||
|
const items = createMemo(() => {
|
||||||
|
const current = server.current
|
||||||
|
const list = server.list
|
||||||
|
if (!current) return list
|
||||||
|
if (!list.includes(current)) return [current, ...list]
|
||||||
|
return [current, ...list.filter((item) => item !== current)]
|
||||||
|
})
|
||||||
|
const current = createMemo<ServerConnection.Any | undefined>(() =>
|
||||||
|
settings.general.newLayoutDesigns()
|
||||||
|
? undefined
|
||||||
|
: (items().find((item) => ServerConnection.key(item) === server.key) ?? items()[0]),
|
||||||
|
)
|
||||||
|
const sorted = createMemo(() => {
|
||||||
|
const raw = items()
|
||||||
|
const list = settings.general.newLayoutDesigns()
|
||||||
|
? raw
|
||||||
|
: raw.filter((item) => global.ensureServerCtx(item).sdk.protocolKind() !== "v2")
|
||||||
|
if (!list.length) return list
|
||||||
|
const active = current()
|
||||||
|
const order = new Map(list.map((item, index) => [item, index] as const))
|
||||||
|
const rank = (value?: ServerHealth) => {
|
||||||
|
if (value?.healthy === true) return 0
|
||||||
|
if (value?.healthy === false) return 2
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
return list.slice().sort((a, b) => {
|
||||||
|
if (a === active) return -1
|
||||||
|
if (b === active) return 1
|
||||||
|
const diff =
|
||||||
|
rank(global.servers.health[ServerConnection.key(a)]) - rank(global.servers.health[ServerConnection.key(b)])
|
||||||
|
if (diff !== 0) return diff
|
||||||
|
return (order.get(a) ?? 0) - (order.get(b) ?? 0)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
collection: {
|
||||||
|
items: sorted,
|
||||||
|
current,
|
||||||
|
health: () => global.servers.health,
|
||||||
|
},
|
||||||
|
...actions,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ServerCollectionController = ReturnType<typeof useServerCollectionController>
|
||||||
|
|
||||||
|
export function useServerDomainController(options: { onSelect?: () => void } = {}) {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const server = useServer()
|
||||||
|
const global = useGlobal()
|
||||||
|
const collection = useServerCollectionController()
|
||||||
|
|
||||||
|
const select = async (connection: ServerConnection.Any) => {
|
||||||
|
if (global.servers.health[ServerConnection.key(connection)]?.healthy === false) return
|
||||||
|
options.onSelect?.()
|
||||||
|
navigate("/")
|
||||||
|
queueMicrotask(() => server.setActive(ServerConnection.key(connection)))
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ...collection, selection: { select } }
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ServerDomainController = ReturnType<typeof useServerDomainController>
|
||||||
|
|
||||||
|
export function useServerFormController(options: { onSelect?: () => void; navigateOnAdd?: boolean } = {}) {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const server = useServer()
|
||||||
|
const global = useGlobal()
|
||||||
|
const platform = usePlatform()
|
||||||
|
const language = useLanguage()
|
||||||
|
const settings = useSettings()
|
||||||
|
const mutations = useServerMutations()
|
||||||
|
const checkServerHealth = useCheckServerHealth()
|
||||||
|
const healthPreview = createServerHealthPreview(checkServerHealth)
|
||||||
|
const [store, setStore] = createStore({
|
||||||
|
mode: "list" as FormMode,
|
||||||
|
originalUrl: undefined as string | undefined,
|
||||||
|
values: { url: "", name: "", username: DEFAULT_USERNAME, password: "" },
|
||||||
|
error: "",
|
||||||
|
status: undefined as boolean | undefined,
|
||||||
|
})
|
||||||
|
|
||||||
|
onCleanup(healthPreview.cancel)
|
||||||
|
|
||||||
|
const reset = () => {
|
||||||
|
healthPreview.cancel()
|
||||||
|
setStore({
|
||||||
|
mode: "list",
|
||||||
|
originalUrl: undefined,
|
||||||
|
values: { url: "", name: "", username: DEFAULT_USERNAME, password: "" },
|
||||||
|
error: "",
|
||||||
|
status: undefined,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
const allServers = () => {
|
||||||
|
if (!server.current || server.list.includes(server.current)) return server.list
|
||||||
|
return [server.current, ...server.list]
|
||||||
|
}
|
||||||
|
const editing = createMemo(() =>
|
||||||
|
allServers().find((item) => item.type === "http" && item.http.url === store.originalUrl),
|
||||||
|
)
|
||||||
|
|
||||||
|
const request = useMutation(() => ({
|
||||||
|
mutationFn: async () => {
|
||||||
|
const normalized = normalizeServerUrl(store.values.url)
|
||||||
|
if (!normalized) {
|
||||||
|
reset()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const original = store.mode === "edit" ? editing() : undefined
|
||||||
|
if (store.mode === "edit" && !original) return
|
||||||
|
const name = store.values.name.trim() || undefined
|
||||||
|
const username = store.values.username || undefined
|
||||||
|
const password = store.values.password || undefined
|
||||||
|
if (
|
||||||
|
original?.type === "http" &&
|
||||||
|
normalized === original.http.url &&
|
||||||
|
name === original.displayName &&
|
||||||
|
username === original.http.username &&
|
||||||
|
password === original.http.password
|
||||||
|
) {
|
||||||
|
reset()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const connection: ServerConnection.Http = {
|
||||||
|
type: "http",
|
||||||
|
displayName: name,
|
||||||
|
http: {
|
||||||
|
url: normalized,
|
||||||
|
username: store.mode === "add" && !password ? undefined : username,
|
||||||
|
password,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
const result = await checkServerHealth(connection.http)
|
||||||
|
if (!result.healthy) {
|
||||||
|
setStore("error", language.t("dialog.server.add.error"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
!settings.general.newLayoutDesigns() &&
|
||||||
|
(await detectServerProtocol(connection.http, platform.fetch ?? globalThis.fetch)) === "v2"
|
||||||
|
) {
|
||||||
|
setStore("error", language.t("dialog.server.add.error"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (original?.type === "http") {
|
||||||
|
if (normalized === original.http.url) mutations.add(connection)
|
||||||
|
if (normalized !== original.http.url) mutations.replace(original, connection)
|
||||||
|
reset()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
reset()
|
||||||
|
if (options.navigateOnAdd === false) {
|
||||||
|
mutations.add(connection)
|
||||||
|
options.onSelect?.()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
mutations.add(connection)
|
||||||
|
options.onSelect?.()
|
||||||
|
navigate("/")
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
const preview = () => void healthPreview.preview(store.values, (status) => setStore("status", status))
|
||||||
|
const change = (field: keyof ServerFormValues, value: string) => {
|
||||||
|
if (request.isPending) return
|
||||||
|
setStore("values", field, value)
|
||||||
|
setStore("error", "")
|
||||||
|
if (field !== "name") preview()
|
||||||
|
}
|
||||||
|
const startAdd = () => {
|
||||||
|
reset()
|
||||||
|
setStore("mode", "add")
|
||||||
|
}
|
||||||
|
const startEdit = (connection: ServerConnection.Http) => {
|
||||||
|
reset()
|
||||||
|
setStore({
|
||||||
|
mode: "edit",
|
||||||
|
originalUrl: connection.http.url,
|
||||||
|
values: {
|
||||||
|
url: connection.http.url,
|
||||||
|
name: connection.displayName ?? "",
|
||||||
|
username: connection.http.username ?? "",
|
||||||
|
password: connection.http.password ?? "",
|
||||||
|
},
|
||||||
|
error: "",
|
||||||
|
status: global.servers.health[ServerConnection.key(connection)]?.healthy,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
const submit = () => {
|
||||||
|
if (store.mode === "list" || request.isPending) return
|
||||||
|
setStore("error", "")
|
||||||
|
request.mutate()
|
||||||
|
}
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
if (store.mode !== "edit") return
|
||||||
|
if (editing()) return
|
||||||
|
reset()
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
state: {
|
||||||
|
mode: () => store.mode,
|
||||||
|
open: () => store.mode !== "list",
|
||||||
|
adding: () => store.mode === "add",
|
||||||
|
busy: () => request.isPending,
|
||||||
|
value: () => store.values.url,
|
||||||
|
name: () => store.values.name,
|
||||||
|
username: () => store.values.username,
|
||||||
|
password: () => store.values.password,
|
||||||
|
error: () => store.error,
|
||||||
|
status: () => store.status,
|
||||||
|
},
|
||||||
|
change: {
|
||||||
|
value: (value: string) => change("url", value),
|
||||||
|
name: (value: string) => change("name", value),
|
||||||
|
username: (value: string) => change("username", value),
|
||||||
|
password: (value: string) => change("password", value),
|
||||||
|
},
|
||||||
|
start: { add: startAdd, edit: startEdit },
|
||||||
|
reset,
|
||||||
|
submit,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ServerFormController = ReturnType<typeof useServerFormController>
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
import { describe, expect, test } from "bun:test"
|
||||||
|
import { ServerConnection } from "@/context/server"
|
||||||
|
import { createServerHealthPreview, replaceServerConnection, type ServerFormValues } from "./server-management"
|
||||||
|
|
||||||
|
function deferred<T>() {
|
||||||
|
let resolve!: (value: T) => void
|
||||||
|
const promise = new Promise<T>((done) => {
|
||||||
|
resolve = done
|
||||||
|
})
|
||||||
|
return { promise, resolve }
|
||||||
|
}
|
||||||
|
|
||||||
|
const values = (url: string): ServerFormValues => ({ url, name: "", username: "opencode", password: "" })
|
||||||
|
|
||||||
|
describe("createServerHealthPreview", () => {
|
||||||
|
test("ignores an older response that resolves after the latest response", async () => {
|
||||||
|
const first = deferred<{ healthy: boolean }>()
|
||||||
|
const second = deferred<{ healthy: boolean }>()
|
||||||
|
const requests = [first, second]
|
||||||
|
const status: Array<boolean | undefined> = []
|
||||||
|
const preview = createServerHealthPreview(() => requests.shift()!.promise)
|
||||||
|
|
||||||
|
const older = preview.preview(values("old.example.com"), (value) => status.push(value))
|
||||||
|
const latest = preview.preview(values("new.example.com"), (value) => status.push(value))
|
||||||
|
second.resolve({ healthy: true })
|
||||||
|
await latest
|
||||||
|
first.resolve({ healthy: false })
|
||||||
|
await older
|
||||||
|
|
||||||
|
expect(status).toEqual([undefined, undefined, true])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("an incomplete value invalidates an in-flight response", async () => {
|
||||||
|
const request = deferred<{ healthy: boolean }>()
|
||||||
|
const status: Array<boolean | undefined> = []
|
||||||
|
const preview = createServerHealthPreview(() => request.promise)
|
||||||
|
|
||||||
|
const pending = preview.preview(values("server.example.com"), (value) => status.push(value))
|
||||||
|
await preview.preview(values("server"), (value) => status.push(value))
|
||||||
|
request.resolve({ healthy: true })
|
||||||
|
await pending
|
||||||
|
|
||||||
|
expect(status).toEqual([undefined, undefined])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("cancellation prevents an in-flight response from updating status", async () => {
|
||||||
|
const request = deferred<{ healthy: boolean }>()
|
||||||
|
const status: Array<boolean | undefined> = []
|
||||||
|
const preview = createServerHealthPreview(() => request.promise)
|
||||||
|
|
||||||
|
const pending = preview.preview(values("server.example.com"), (value) => status.push(value))
|
||||||
|
preview.cancel()
|
||||||
|
request.resolve({ healthy: true })
|
||||||
|
await pending
|
||||||
|
|
||||||
|
expect(status).toEqual([undefined])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("replaceServerConnection", () => {
|
||||||
|
const original: ServerConnection.Http = { type: "http", http: { url: "https://old.example.com" } }
|
||||||
|
const next: ServerConnection.Http = { type: "http", http: { url: "https://new.example.com" } }
|
||||||
|
|
||||||
|
test("moves active selection after adding the replacement and removes the original", () => {
|
||||||
|
const calls: string[] = []
|
||||||
|
|
||||||
|
replaceServerConnection(original, next, {
|
||||||
|
active: () => ServerConnection.key(original),
|
||||||
|
removeTabs: (key) => calls.push(`tabs:${key}`),
|
||||||
|
add: (server) => {
|
||||||
|
calls.push(`add:${ServerConnection.key(server)}`)
|
||||||
|
return server
|
||||||
|
},
|
||||||
|
setActive: (key) => calls.push(`active:${key}`),
|
||||||
|
remove: (key) => calls.push(`remove:${key}`),
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(calls).toEqual([
|
||||||
|
"tabs:https://old.example.com",
|
||||||
|
"add:https://new.example.com",
|
||||||
|
"active:https://new.example.com",
|
||||||
|
"remove:https://old.example.com",
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("keeps the original when the replacement cannot be added", () => {
|
||||||
|
const removed: ServerConnection.Key[] = []
|
||||||
|
|
||||||
|
replaceServerConnection(original, next, {
|
||||||
|
active: () => ServerConnection.key(original),
|
||||||
|
removeTabs: () => {},
|
||||||
|
add: () => undefined,
|
||||||
|
setActive: () => {},
|
||||||
|
remove: (key) => removed.push(key),
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(removed).toEqual([])
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import { normalizeServerUrl, ServerConnection } from "@/context/server"
|
||||||
|
import type { ServerHealth } from "@/utils/server-health"
|
||||||
|
|
||||||
|
export type ServerFormValues = {
|
||||||
|
url: string
|
||||||
|
name: string
|
||||||
|
username: string
|
||||||
|
password: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createServerHealthPreview(
|
||||||
|
check: (server: ServerConnection.HttpBase) => Promise<Pick<ServerHealth, "healthy">>,
|
||||||
|
) {
|
||||||
|
let generation = 0
|
||||||
|
|
||||||
|
const cancel = () => {
|
||||||
|
generation += 1
|
||||||
|
}
|
||||||
|
|
||||||
|
const preview = async (values: ServerFormValues, setStatus: (value: boolean | undefined) => void) => {
|
||||||
|
const current = ++generation
|
||||||
|
setStatus(undefined)
|
||||||
|
const normalized = normalizeServerUrl(values.url)
|
||||||
|
if (!normalized) return
|
||||||
|
const host = normalized.replace(/^https?:\/\//, "").split("/")[0]
|
||||||
|
if (!host) return
|
||||||
|
if (!host.includes("localhost") && !host.startsWith("127.0.0.1") && !host.includes(".") && !host.includes(":"))
|
||||||
|
return
|
||||||
|
|
||||||
|
const http: ServerConnection.HttpBase = { url: normalized }
|
||||||
|
if (values.username) http.username = values.username
|
||||||
|
if (values.password) http.password = values.password
|
||||||
|
const result = await check(http)
|
||||||
|
if (current !== generation) return
|
||||||
|
setStatus(result.healthy)
|
||||||
|
}
|
||||||
|
|
||||||
|
return { cancel, preview }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function replaceServerConnection(
|
||||||
|
original: ServerConnection.Http,
|
||||||
|
next: ServerConnection.Http,
|
||||||
|
operations: {
|
||||||
|
active: () => ServerConnection.Key | undefined
|
||||||
|
removeTabs: (key: ServerConnection.Key) => void
|
||||||
|
add: (server: ServerConnection.Http) => ServerConnection.Any | undefined
|
||||||
|
setActive: (key: ServerConnection.Key) => void
|
||||||
|
remove: (key: ServerConnection.Key) => void
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
const originalKey = ServerConnection.key(original)
|
||||||
|
const active = operations.active()
|
||||||
|
operations.removeTabs(originalKey)
|
||||||
|
const added = operations.add(next)
|
||||||
|
if (!added) return
|
||||||
|
const nextActive = active === originalKey ? ServerConnection.key(added) : active
|
||||||
|
if (nextActive) operations.setActive(nextActive)
|
||||||
|
operations.remove(originalKey)
|
||||||
|
}
|
||||||
@@ -2,13 +2,13 @@ import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
|
|||||||
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
|
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
|
||||||
import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2"
|
import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2"
|
||||||
import { type Component, Show } from "solid-js"
|
import { type Component, Show } from "solid-js"
|
||||||
import { useServerManagementController } from "@/components/dialog-select-server"
|
import type { ServerActionsController } from "@/components/server/server-management-controller"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { ServerConnection } from "@/context/server"
|
import { ServerConnection } from "@/context/server"
|
||||||
|
|
||||||
export const ServerRowMenu: Component<{
|
export const ServerRowMenu: Component<{
|
||||||
server: ServerConnection.Any
|
server: ServerConnection.Any
|
||||||
controller: ReturnType<typeof useServerManagementController>
|
domain: ServerActionsController
|
||||||
onEdit: (server: ServerConnection.Http) => void
|
onEdit: (server: ServerConnection.Http) => void
|
||||||
open?: boolean
|
open?: boolean
|
||||||
onOpenChange?: (open: boolean) => void
|
onOpenChange?: (open: boolean) => void
|
||||||
@@ -19,13 +19,13 @@ export const ServerRowMenu: Component<{
|
|||||||
<ServerRowMenuView
|
<ServerRowMenuView
|
||||||
server={props.server}
|
server={props.server}
|
||||||
labels={serverMenuLabels(language)}
|
labels={serverMenuLabels(language)}
|
||||||
canDefault={props.controller.canDefault()}
|
canDefault={props.domain.defaults.available()}
|
||||||
isDefault={props.controller.defaultKey() === key}
|
isDefault={props.domain.defaults.key() === key}
|
||||||
canRemove={props.controller.canRemove(key)}
|
canRemove={props.domain.connection.canRemove(key)}
|
||||||
onEdit={props.onEdit}
|
onEdit={props.onEdit}
|
||||||
onSetDefault={() => props.controller.setDefault(key)}
|
onSetDefault={() => props.domain.defaults.set(key)}
|
||||||
onRemoveDefault={() => props.controller.setDefault(null)}
|
onRemoveDefault={() => props.domain.defaults.set(null)}
|
||||||
onRemove={() => props.controller.handleRemove(key)}
|
onRemove={() => props.domain.connection.remove(key)}
|
||||||
open={props.open}
|
open={props.open}
|
||||||
onOpenChange={props.onOpenChange}
|
onOpenChange={props.onOpenChange}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,16 +1,19 @@
|
|||||||
import { Show, type Component } from "solid-js"
|
import { Show, type Component } from "solid-js"
|
||||||
|
import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { ServerConnectionForm, ServerConnectionList, useServerManagementController } from "./dialog-select-server"
|
import { useServerDomainController, useServerFormController } from "./server/server-management-controller"
|
||||||
|
import { ServerConnectionForm, ServerConnectionList } from "./dialog-select-server"
|
||||||
|
|
||||||
export const SettingsServers: Component = () => {
|
export const SettingsServers: Component = () => {
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
const controller = useServerManagementController()
|
const domain = useServerDomainController()
|
||||||
|
const form = useServerFormController()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class="flex flex-col h-full overflow-y-auto no-scrollbar px-4 pb-10 sm:px-10 sm:pb-10">
|
<div class="flex flex-col h-full overflow-y-auto no-scrollbar px-4 pb-10 sm:px-10 sm:pb-10">
|
||||||
<div class="flex flex-col flex-1 min-h-0 max-w-[720px]">
|
<div class="flex flex-col flex-1 min-h-0 max-w-[720px]">
|
||||||
<Show
|
<Show
|
||||||
when={controller.isFormMode()}
|
when={form.state.open()}
|
||||||
fallback={
|
fallback={
|
||||||
<>
|
<>
|
||||||
<div class="sticky top-0 z-10 bg-[linear-gradient(to_bottom,var(--surface-stronger-non-alpha)_calc(100%_-_24px),transparent)]">
|
<div class="sticky top-0 z-10 bg-[linear-gradient(to_bottom,var(--surface-stronger-non-alpha)_calc(100%_-_24px),transparent)]">
|
||||||
@@ -18,13 +21,25 @@ export const SettingsServers: Component = () => {
|
|||||||
<h2 class="text-16-medium text-text-strong">{language.t("status.popover.tab.servers")}</h2>
|
<h2 class="text-16-medium text-text-strong">{language.t("status.popover.tab.servers")}</h2>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<ServerConnectionList controller={controller} />
|
<ServerConnectionList domain={domain} onAdd={form.start.add} onEdit={form.start.edit} />
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<div class="flex flex-1 min-h-0 flex-col gap-4 pt-6">
|
<div class="flex flex-1 min-h-0 flex-col gap-4 pt-6">
|
||||||
<div class="text-16-medium text-text-strong">{controller.formTitle()}</div>
|
<div class="text-16-medium text-text-strong">
|
||||||
<ServerConnectionForm controller={controller} />
|
<div class="flex items-center gap-2 -ml-2">
|
||||||
|
<IconButton
|
||||||
|
icon="arrow-left"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={form.reset}
|
||||||
|
aria-label={language.t("common.goBack")}
|
||||||
|
/>
|
||||||
|
<span>
|
||||||
|
{form.state.adding() ? language.t("dialog.server.add.title") : language.t("dialog.server.edit.title")}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ServerConnectionForm form={form} />
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { useDialog } from "@opencode-ai/ui/context/dialog"
|
|||||||
import { type Component, Show, createEffect, createSignal, onCleanup, onMount } from "solid-js"
|
import { type Component, Show, createEffect, createSignal, onCleanup, onMount } from "solid-js"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { type ServerConnection } from "@/context/server"
|
import { type ServerConnection } from "@/context/server"
|
||||||
import { useServerManagementController } from "../dialog-select-server"
|
import { useServerFormController } from "../server/server-management-controller"
|
||||||
import "./settings-v2.css"
|
import "./settings-v2.css"
|
||||||
|
|
||||||
export const DialogServerV2: Component<{
|
export const DialogServerV2: Component<{
|
||||||
@@ -15,39 +15,39 @@ export const DialogServerV2: Component<{
|
|||||||
}> = (props) => {
|
}> = (props) => {
|
||||||
const dialog = useDialog()
|
const dialog = useDialog()
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
const controller = useServerManagementController({
|
const form = useServerFormController({
|
||||||
onSelect: () => dialog.close(),
|
onSelect: () => dialog.close(),
|
||||||
navigateOnAdd: false,
|
navigateOnAdd: false,
|
||||||
})
|
})
|
||||||
const [opened, setOpened] = createSignal(false)
|
const [opened, setOpened] = createSignal(false)
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
if (props.mode === "add") controller.startAdd()
|
if (props.mode === "add") form.start.add()
|
||||||
if (props.mode === "edit" && props.server) controller.startEdit(props.server)
|
if (props.mode === "edit" && props.server) form.start.edit(props.server)
|
||||||
setOpened(true)
|
setOpened(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
onCleanup(() => {
|
onCleanup(() => {
|
||||||
controller.resetForm()
|
form.reset()
|
||||||
})
|
})
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
if (!opened()) return
|
if (!opened()) return
|
||||||
if (controller.isFormMode()) return
|
if (form.state.open()) return
|
||||||
dialog.close()
|
dialog.close()
|
||||||
})
|
})
|
||||||
|
|
||||||
const keyDown = (event: KeyboardEvent) => {
|
const keyDown = (event: KeyboardEvent) => {
|
||||||
if (event.key !== "Enter" || event.isComposing) return
|
if (event.key !== "Enter" || event.isComposing) return
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
controller.submitForm()
|
form.submit()
|
||||||
}
|
}
|
||||||
|
|
||||||
const title = () =>
|
const title = () =>
|
||||||
props.mode === "add" ? language.t("dialog.server.add.title") : language.t("dialog.server.edit.title")
|
props.mode === "add" ? language.t("dialog.server.add.title") : language.t("dialog.server.edit.title")
|
||||||
|
|
||||||
const submitLabel = () => {
|
const submitLabel = () => {
|
||||||
if (controller.formBusy()) return language.t("dialog.server.add.checking")
|
if (form.state.busy()) return language.t("dialog.server.add.checking")
|
||||||
if (props.mode === "add") return language.t("dialog.server.add.button")
|
if (props.mode === "add") return language.t("dialog.server.add.button")
|
||||||
return language.t("common.save")
|
return language.t("common.save")
|
||||||
}
|
}
|
||||||
@@ -66,16 +66,16 @@ export const DialogServerV2: Component<{
|
|||||||
type="text"
|
type="text"
|
||||||
appearance="large"
|
appearance="large"
|
||||||
class="!w-full self-stretch"
|
class="!w-full self-stretch"
|
||||||
value={controller.formValue()}
|
value={form.state.value()}
|
||||||
placeholder={language.t("dialog.server.add.placeholder")}
|
placeholder={language.t("dialog.server.add.placeholder")}
|
||||||
invalid={!!controller.formError()}
|
invalid={!!form.state.error()}
|
||||||
disabled={controller.formBusy()}
|
disabled={form.state.busy()}
|
||||||
autofocus
|
autofocus
|
||||||
onInput={(event) => controller.handleFormChange()(event.currentTarget.value)}
|
onInput={(event) => form.change.value(event.currentTarget.value)}
|
||||||
onKeyDown={keyDown}
|
onKeyDown={keyDown}
|
||||||
/>
|
/>
|
||||||
<Show when={controller.formError()}>
|
<Show when={form.state.error()}>
|
||||||
<span class="settings-v2-server-dialog-error">{controller.formError()}</span>
|
<span class="settings-v2-server-dialog-error">{form.state.error()}</span>
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex w-full min-w-0 flex-col gap-2">
|
<div class="flex w-full min-w-0 flex-col gap-2">
|
||||||
@@ -84,10 +84,10 @@ export const DialogServerV2: Component<{
|
|||||||
type="text"
|
type="text"
|
||||||
appearance="large"
|
appearance="large"
|
||||||
class="!w-full self-stretch"
|
class="!w-full self-stretch"
|
||||||
value={controller.formName()}
|
value={form.state.name()}
|
||||||
placeholder={language.t("dialog.server.add.namePlaceholder")}
|
placeholder={language.t("dialog.server.add.namePlaceholder")}
|
||||||
disabled={controller.formBusy()}
|
disabled={form.state.busy()}
|
||||||
onInput={(event) => controller.handleFormNameChange()(event.currentTarget.value)}
|
onInput={(event) => form.change.name(event.currentTarget.value)}
|
||||||
onKeyDown={keyDown}
|
onKeyDown={keyDown}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -98,10 +98,10 @@ export const DialogServerV2: Component<{
|
|||||||
type="text"
|
type="text"
|
||||||
appearance="large"
|
appearance="large"
|
||||||
class="!w-full self-stretch"
|
class="!w-full self-stretch"
|
||||||
value={controller.formUsername()}
|
value={form.state.username()}
|
||||||
placeholder={language.t("dialog.server.add.usernamePlaceholder")}
|
placeholder={language.t("dialog.server.add.usernamePlaceholder")}
|
||||||
disabled={controller.formBusy()}
|
disabled={form.state.busy()}
|
||||||
onInput={(event) => controller.handleFormUsernameChange()(event.currentTarget.value)}
|
onInput={(event) => form.change.username(event.currentTarget.value)}
|
||||||
onKeyDown={keyDown}
|
onKeyDown={keyDown}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -111,10 +111,10 @@ export const DialogServerV2: Component<{
|
|||||||
type="password"
|
type="password"
|
||||||
appearance="large"
|
appearance="large"
|
||||||
class="!w-full self-stretch"
|
class="!w-full self-stretch"
|
||||||
value={controller.formPassword()}
|
value={form.state.password()}
|
||||||
placeholder={language.t("dialog.server.add.passwordPlaceholder")}
|
placeholder={language.t("dialog.server.add.passwordPlaceholder")}
|
||||||
disabled={controller.formBusy()}
|
disabled={form.state.busy()}
|
||||||
onInput={(event) => controller.handleFormPasswordChange()(event.currentTarget.value)}
|
onInput={(event) => form.change.password(event.currentTarget.value)}
|
||||||
onKeyDown={keyDown}
|
onKeyDown={keyDown}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -122,10 +122,10 @@ export const DialogServerV2: Component<{
|
|||||||
</div>
|
</div>
|
||||||
</DialogBody>
|
</DialogBody>
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<ButtonV2 variant="neutral" disabled={controller.formBusy()} onClick={() => dialog.close()}>
|
<ButtonV2 variant="neutral" disabled={form.state.busy()} onClick={() => dialog.close()}>
|
||||||
{language.t("common.cancel")}
|
{language.t("common.cancel")}
|
||||||
</ButtonV2>
|
</ButtonV2>
|
||||||
<ButtonV2 variant="contrast" disabled={controller.formBusy()} onClick={controller.submitForm}>
|
<ButtonV2 variant="contrast" disabled={form.state.busy()} onClick={form.submit}>
|
||||||
{submitLabel()}
|
{submitLabel()}
|
||||||
</ButtonV2>
|
</ButtonV2>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import { ServerRowMenu } from "@/components/server/server-row-menu"
|
|||||||
import { ServerHealthIndicator } from "@/components/server/server-row"
|
import { ServerHealthIndicator } from "@/components/server/server-row"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { ServerConnection, serverName } from "@/context/server"
|
import { ServerConnection, serverName } from "@/context/server"
|
||||||
import { useServerManagementController } from "../dialog-select-server"
|
import { useServerCollectionController } from "../server/server-management-controller"
|
||||||
import { DialogServerV2 } from "./dialog-server-v2"
|
import { DialogServerV2 } from "./dialog-server-v2"
|
||||||
import { SettingsListV2 } from "./parts/list"
|
import { SettingsListV2 } from "./parts/list"
|
||||||
import { AddServerMenu, isWslServer, useFilteredWslServers, WslServerSettings } from "@/wsl/settings"
|
import { AddServerMenu, isWslServer, useFilteredWslServers, WslServerSettings } from "@/wsl/settings"
|
||||||
@@ -19,16 +19,16 @@ import "./settings-v2.css"
|
|||||||
export const SettingsServersV2: Component = () => {
|
export const SettingsServersV2: Component = () => {
|
||||||
const dialog = useDialog()
|
const dialog = useDialog()
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
const controller = useServerManagementController()
|
const domain = useServerCollectionController()
|
||||||
const [store, setStore] = createStore({ filter: "" })
|
const [store, setStore] = createStore({ filter: "" })
|
||||||
const wslServers = useFilteredWslServers(() => store.filter)
|
const wslServers = useFilteredWslServers(() => store.filter)
|
||||||
|
|
||||||
const showSearch = createMemo(
|
const showSearch = createMemo(
|
||||||
() => controller.sortedItems().filter((item) => !isWslServer(item)).length + wslServers().length > 1,
|
() => domain.collection.items().filter((item) => !isWslServer(item)).length + wslServers().length > 1,
|
||||||
)
|
)
|
||||||
|
|
||||||
const filtered = createMemo(() => {
|
const filtered = createMemo(() => {
|
||||||
const items = controller.sortedItems().filter((item) => !isWslServer(item))
|
const items = domain.collection.items().filter((item) => !isWslServer(item))
|
||||||
const query = store.filter.trim()
|
const query = store.filter.trim()
|
||||||
if (!query) return items
|
if (!query) return items
|
||||||
return fuzzysort
|
return fuzzysort
|
||||||
@@ -39,11 +39,11 @@ export const SettingsServersV2: Component = () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const openAdd = () => {
|
const openAdd = () => {
|
||||||
dialog.push(() => <DialogServerV2 mode="add" />)
|
void dialog.push(() => <DialogServerV2 mode="add" />)
|
||||||
}
|
}
|
||||||
|
|
||||||
const openEdit = (server: ServerConnection.Http) => {
|
const openEdit = (server: ServerConnection.Http) => {
|
||||||
dialog.push(() => <DialogServerV2 mode="edit" server={server} />)
|
void dialog.push(() => <DialogServerV2 mode="edit" server={server} />)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -97,12 +97,12 @@ export const SettingsServersV2: Component = () => {
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
<SettingsListV2>
|
<SettingsListV2>
|
||||||
<WslServerSettings controller={controller} servers={wslServers} />
|
<WslServerSettings domain={domain} servers={wslServers} />
|
||||||
<For each={filtered()}>
|
<For each={filtered()}>
|
||||||
{(item) => {
|
{(item) => {
|
||||||
const key = ServerConnection.key(item)
|
const key = ServerConnection.key(item)
|
||||||
const health = () => controller.status()[key]
|
const health = () => domain.collection.health()[key]
|
||||||
const isDefault = () => controller.defaultKey() === key
|
const isDefault = () => domain.defaults.key() === key
|
||||||
return (
|
return (
|
||||||
<div class="settings-v2-servers-row">
|
<div class="settings-v2-servers-row">
|
||||||
<div class="settings-v2-servers-lead">
|
<div class="settings-v2-servers-lead">
|
||||||
@@ -122,10 +122,10 @@ export const SettingsServersV2: Component = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="settings-v2-servers-actions">
|
<div class="settings-v2-servers-actions">
|
||||||
<Show when={controller.canDefault() && isDefault()}>
|
<Show when={domain.defaults.available() && isDefault()}>
|
||||||
<Tag>{language.t("dialog.server.status.default")}</Tag>
|
<Tag>{language.t("dialog.server.status.default")}</Tag>
|
||||||
</Show>
|
</Show>
|
||||||
<ServerRowMenu server={item} controller={controller} onEdit={openEdit} />
|
<ServerRowMenu server={item} domain={domain} onEdit={openEdit} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useDirectoryPicker } from "@/components/directory-picker"
|
import { useDirectoryPicker } from "@/components/directory-picker"
|
||||||
import { useServerManagementController } from "@/components/dialog-select-server"
|
import { useServerActionsController } from "@/components/server/server-management-controller"
|
||||||
import { useSettingsCommand } from "@/components/settings-dialog"
|
import { useSettingsCommand } from "@/components/settings-dialog"
|
||||||
import { DialogServerV2 } from "@/components/settings-v2/dialog-server-v2"
|
import { DialogServerV2 } from "@/components/settings-v2/dialog-server-v2"
|
||||||
import { type LocalProject } from "@/context/layout"
|
import { type LocalProject } from "@/context/layout"
|
||||||
@@ -22,7 +22,7 @@ export function createHomeProjectsController(home: HomeController) {
|
|||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
const notification = useNotification()
|
const notification = useNotification()
|
||||||
const openSettings = useSettingsCommand()
|
const openSettings = useSettingsCommand()
|
||||||
const serverManagement = useServerManagementController({ navigateOnAdd: false })
|
const serverManagement = useServerActionsController()
|
||||||
const [_state, setState, _, ready] = persisted(
|
const [_state, setState, _, ready] = persisted(
|
||||||
Persist.global("home.servers", ["home.servers.v1"]),
|
Persist.global("home.servers", ["home.servers.v1"]),
|
||||||
createStore({ collapsed: {} as Record<string, boolean> }),
|
createStore({ collapsed: {} as Record<string, boolean> }),
|
||||||
@@ -56,12 +56,12 @@ export function createHomeProjectsController(home: HomeController) {
|
|||||||
const key = ServerConnection.key(conn)
|
const key = ServerConnection.key(conn)
|
||||||
setState("collapsed", key, !state().collapsed[key])
|
setState("collapsed", key, !state().collapsed[key])
|
||||||
},
|
},
|
||||||
canDefault: serverManagement.canDefault,
|
canDefault: serverManagement.defaults.available,
|
||||||
defaultKey: serverManagement.defaultKey,
|
defaultKey: serverManagement.defaults.key,
|
||||||
setDefault: (conn: ServerConnection.Any | undefined) =>
|
setDefault: (conn: ServerConnection.Any | undefined) =>
|
||||||
serverManagement.setDefault(conn ? ServerConnection.key(conn) : null),
|
serverManagement.defaults.set(conn ? ServerConnection.key(conn) : null),
|
||||||
canRemove: (conn: ServerConnection.Any) => serverManagement.canRemove(ServerConnection.key(conn)),
|
canRemove: (conn: ServerConnection.Any) => serverManagement.connection.canRemove(ServerConnection.key(conn)),
|
||||||
remove: (conn: ServerConnection.Any) => serverManagement.handleRemove(ServerConnection.key(conn)),
|
remove: (conn: ServerConnection.Any) => serverManagement.connection.remove(ServerConnection.key(conn)),
|
||||||
edit: (conn: ServerConnection.Http) => dialog.show(() => <DialogServerV2 mode="edit" server={conn} />),
|
edit: (conn: ServerConnection.Http) => dialog.show(() => <DialogServerV2 mode="edit" server={conn} />),
|
||||||
focus: home.selection.focusServer,
|
focus: home.selection.focusServer,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2"
|
|||||||
import { useMutation } from "@tanstack/solid-query"
|
import { useMutation } from "@tanstack/solid-query"
|
||||||
import fuzzysort from "fuzzysort"
|
import fuzzysort from "fuzzysort"
|
||||||
import { type Accessor, For, Show, createMemo } from "solid-js"
|
import { type Accessor, For, Show, createMemo } from "solid-js"
|
||||||
import type { useServerManagementController } from "@/components/dialog-select-server"
|
import type { ServerCollectionController } from "@/components/server/server-management-controller"
|
||||||
import { ServerHealthIndicator } from "@/components/server/server-row"
|
import { ServerHealthIndicator } from "@/components/server/server-row"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { usePlatform } from "@/context/platform"
|
import { usePlatform } from "@/context/platform"
|
||||||
@@ -17,8 +17,6 @@ import { DialogAddWslServer } from "./dialog-add-server"
|
|||||||
import { useWslServers } from "./context"
|
import { useWslServers } from "./context"
|
||||||
import { wslOpencodeAction, wslRuntimeRetryable } from "./settings-model"
|
import { wslOpencodeAction, wslRuntimeRetryable } from "./settings-model"
|
||||||
|
|
||||||
type Controller = ReturnType<typeof useServerManagementController>
|
|
||||||
|
|
||||||
export function isWslServer(server: ServerConnection.Any) {
|
export function isWslServer(server: ServerConnection.Any) {
|
||||||
return server.type === "sidecar" && server.variant === "wsl"
|
return server.type === "sidecar" && server.variant === "wsl"
|
||||||
}
|
}
|
||||||
@@ -28,7 +26,7 @@ export function AddServerMenu(props: { onAddServer: () => void }) {
|
|||||||
const dialog = useDialog()
|
const dialog = useDialog()
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
const openAddWsl = () => {
|
const openAddWsl = () => {
|
||||||
dialog.push(() => <DialogAddWslServer />)
|
void dialog.push(() => <DialogAddWslServer />)
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<Show
|
<Show
|
||||||
@@ -67,7 +65,7 @@ export function useFilteredWslServers(filter: Accessor<string>) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function WslServerSettings(props: {
|
export function WslServerSettings(props: {
|
||||||
controller: Controller
|
domain: Pick<ServerCollectionController, "collection" | "defaults" | "connection">
|
||||||
servers: ReturnType<typeof useFilteredWslServers>
|
servers: ReturnType<typeof useFilteredWslServers>
|
||||||
}) {
|
}) {
|
||||||
const platform = usePlatform()
|
const platform = usePlatform()
|
||||||
@@ -86,7 +84,7 @@ export function WslServerSettings(props: {
|
|||||||
}))
|
}))
|
||||||
|
|
||||||
const remove = (key: ServerConnection.Key) => {
|
const remove = (key: ServerConnection.Key) => {
|
||||||
request.mutate(() => props.controller.handleRemove(key))
|
request.mutate(() => props.domain.connection.remove(key))
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -100,7 +98,7 @@ export function WslServerSettings(props: {
|
|||||||
return (
|
return (
|
||||||
<div class="settings-v2-servers-row">
|
<div class="settings-v2-servers-row">
|
||||||
<div class="settings-v2-servers-lead">
|
<div class="settings-v2-servers-lead">
|
||||||
<ServerHealthIndicator health={props.controller.status()[key]} />
|
<ServerHealthIndicator health={props.domain.collection.health()[key]} />
|
||||||
<div class="settings-v2-servers-copy">
|
<div class="settings-v2-servers-copy">
|
||||||
<span class="flex min-w-0 items-center gap-1">
|
<span class="flex min-w-0 items-center gap-1">
|
||||||
<span class="settings-v2-servers-name">{item.config.distro}</span>
|
<span class="settings-v2-servers-name">{item.config.distro}</span>
|
||||||
@@ -114,7 +112,7 @@ export function WslServerSettings(props: {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="settings-v2-servers-actions">
|
<div class="settings-v2-servers-actions">
|
||||||
<Show when={props.controller.canDefault() && props.controller.defaultKey() === key}>
|
<Show when={props.domain.defaults.available() && props.domain.defaults.key() === key}>
|
||||||
<Tag>{language.t("dialog.server.status.default")}</Tag>
|
<Tag>{language.t("dialog.server.status.default")}</Tag>
|
||||||
</Show>
|
</Show>
|
||||||
<Show when={opencodeAction()}>
|
<Show when={opencodeAction()}>
|
||||||
@@ -145,13 +143,13 @@ export function WslServerSettings(props: {
|
|||||||
{language.t("wsl.server.retryStart")}
|
{language.t("wsl.server.retryStart")}
|
||||||
</MenuV2.Item>
|
</MenuV2.Item>
|
||||||
</Show>
|
</Show>
|
||||||
<Show when={props.controller.canDefault() && props.controller.defaultKey() !== key}>
|
<Show when={props.domain.defaults.available() && props.domain.defaults.key() !== key}>
|
||||||
<MenuV2.Item onSelect={() => props.controller.setDefault(key)}>
|
<MenuV2.Item onSelect={() => props.domain.defaults.set(key)}>
|
||||||
{language.t("dialog.server.menu.default")}
|
{language.t("dialog.server.menu.default")}
|
||||||
</MenuV2.Item>
|
</MenuV2.Item>
|
||||||
</Show>
|
</Show>
|
||||||
<Show when={props.controller.canDefault() && props.controller.defaultKey() === key}>
|
<Show when={props.domain.defaults.available() && props.domain.defaults.key() === key}>
|
||||||
<MenuV2.Item onSelect={() => props.controller.setDefault(null)}>
|
<MenuV2.Item onSelect={() => props.domain.defaults.set(null)}>
|
||||||
{language.t("dialog.server.menu.defaultRemove")}
|
{language.t("dialog.server.menu.defaultRemove")}
|
||||||
</MenuV2.Item>
|
</MenuV2.Item>
|
||||||
</Show>
|
</Show>
|
||||||
|
|||||||
@@ -56,6 +56,16 @@
|
|||||||
"@lydell/node-pty-linux-x64": "1.2.0-beta.12",
|
"@lydell/node-pty-linux-x64": "1.2.0-beta.12",
|
||||||
"@lydell/node-pty-win32-arm64": "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",
|
"@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-darwin-arm64": "2.5.1",
|
||||||
"@parcel/watcher-linux-arm64-glibc": "2.5.1",
|
"@parcel/watcher-linux-arm64-glibc": "2.5.1",
|
||||||
"@parcel/watcher-linux-x64-glibc": "2.5.1",
|
"@parcel/watcher-linux-x64-glibc": "2.5.1",
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { rm } from "fs/promises"
|
|||||||
import path from "path"
|
import path from "path"
|
||||||
import { Script } from "@opencode-ai/script"
|
import { Script } from "@opencode-ai/script"
|
||||||
import { createSolidTransformPlugin } from "@opentui/solid/bun-plugin"
|
import { createSolidTransformPlugin } from "@opentui/solid/bun-plugin"
|
||||||
|
import type { BunPlugin } from "bun"
|
||||||
import pkg from "../package.json"
|
import pkg from "../package.json"
|
||||||
import { modelsData } from "./generate"
|
import { modelsData } from "./generate"
|
||||||
|
|
||||||
@@ -22,7 +23,7 @@ await rm(outdir, { recursive: true, force: true })
|
|||||||
const singleFlag = process.argv.includes("--single")
|
const singleFlag = process.argv.includes("--single")
|
||||||
const baselineFlag = process.argv.includes("--baseline")
|
const baselineFlag = process.argv.includes("--baseline")
|
||||||
const skipInstall = process.argv.includes("--skip-install")
|
const skipInstall = process.argv.includes("--skip-install")
|
||||||
const plugin = createSolidTransformPlugin()
|
const solidPlugin = createSolidTransformPlugin()
|
||||||
|
|
||||||
const allTargets: {
|
const allTargets: {
|
||||||
os: string
|
os: string
|
||||||
@@ -55,6 +56,16 @@ const targets = singleFlag
|
|||||||
if (!skipInstall) await $`bun install --os="*" --cpu="*" @opentui/core@${pkg.dependencies["@opentui/core"]}`
|
if (!skipInstall) await $`bun install --os="*" --cpu="*" @opentui/core@${pkg.dependencies["@opentui/core"]}`
|
||||||
|
|
||||||
for (const item of targets) {
|
for (const item of targets) {
|
||||||
|
const parcelWatcherPackage = `@parcel/watcher-${item.os}-${item.arch}${item.os === "linux" ? `-${item.abi ?? "glibc"}` : ""}`
|
||||||
|
const parcelWatcherPlugin: BunPlugin = {
|
||||||
|
name: "parcel-watcher-binding",
|
||||||
|
setup(build) {
|
||||||
|
build.onLoad({ filter: /filesystem\/watcher-binding\.ts$/ }, () => ({
|
||||||
|
contents: `import binding from ${JSON.stringify(parcelWatcherPackage)}; export default () => binding`,
|
||||||
|
loader: "js",
|
||||||
|
}))
|
||||||
|
},
|
||||||
|
}
|
||||||
const target = [
|
const target = [
|
||||||
binary,
|
binary,
|
||||||
item.os === "win32" ? "windows" : item.os,
|
item.os === "win32" ? "windows" : item.os,
|
||||||
@@ -69,7 +80,7 @@ for (const item of targets) {
|
|||||||
const result = await Bun.build({
|
const result = await Bun.build({
|
||||||
entrypoints: ["./src/index.ts"],
|
entrypoints: ["./src/index.ts"],
|
||||||
tsconfig: "./tsconfig.json",
|
tsconfig: "./tsconfig.json",
|
||||||
plugins: [plugin],
|
plugins: [solidPlugin, parcelWatcherPlugin],
|
||||||
external: ["node-gyp"],
|
external: ["node-gyp"],
|
||||||
format: "esm",
|
format: "esm",
|
||||||
minify: true,
|
minify: true,
|
||||||
|
|||||||
@@ -37,6 +37,8 @@ export async function collectNodeAssets(target: NodeTarget) {
|
|||||||
...(target.platform === "linux" ? { libc: "glibc" as const } : {}),
|
...(target.platform === "linux" ? { libc: "glibc" as const } : {}),
|
||||||
}),
|
}),
|
||||||
{ key: target.parcelWatcherAsset, source: fileURLToPath(import.meta.resolve(target.parcelWatcherPackage)) },
|
{ 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,
|
key: photonWasmAsset,
|
||||||
source: fileURLToPath(import.meta.resolve(photonWasmAsset)),
|
source: fileURLToPath(import.meta.resolve(photonWasmAsset)),
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ const directory = path.join(import.meta.dir, "..", "dist", ...(nodeBuild ? ["nod
|
|||||||
const binary = path.join(directory, `opencode2${nodeBuild ? "-node" : ""}${process.platform === "win32" ? ".exe" : ""}`)
|
const binary = path.join(directory, `opencode2${nodeBuild ? "-node" : ""}${process.platform === "win32" ? ".exe" : ""}`)
|
||||||
if (!(await Bun.file(binary).exists())) throw new Error(`Missing compiled CLI in ${directory}`)
|
if (!(await Bun.file(binary).exists())) throw new Error(`Missing compiled CLI in ${directory}`)
|
||||||
|
|
||||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-service-smoke-"))
|
const root = await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), "opencode-service-smoke-")))
|
||||||
const env = {
|
const env = {
|
||||||
...process.env,
|
...process.env,
|
||||||
HOME: root,
|
HOME: root,
|
||||||
@@ -29,6 +29,7 @@ const processes: Array<ReturnType<typeof Bun.spawn>> = []
|
|||||||
const errors: Array<Promise<string>> = []
|
const errors: Array<Promise<string>> = []
|
||||||
let failure: unknown
|
let failure: unknown
|
||||||
try {
|
try {
|
||||||
|
await fs.mkdir(path.join(root, ".opencode"))
|
||||||
spawnService()
|
spawnService()
|
||||||
spawnService()
|
spawnService()
|
||||||
const registration = await waitForRegistration()
|
const registration = await waitForRegistration()
|
||||||
@@ -49,6 +50,11 @@ try {
|
|||||||
{ signal: AbortSignal.timeout(5_000) },
|
{ signal: AbortSignal.timeout(5_000) },
|
||||||
)
|
)
|
||||||
if (tokenOpenApi.status !== 200) throw new Error("Compiled application rejected query authentication")
|
if (tokenOpenApi.status !== 200) throw new Error("Compiled application rejected query authentication")
|
||||||
|
if ((await pluginIDs(info.url, headers)).includes("smoke")) throw new Error("Smoke plugin existed before creation")
|
||||||
|
const plugin = path.join(root, ".opencode", "plugins", "smoke.ts")
|
||||||
|
await fs.mkdir(path.dirname(plugin), { recursive: true })
|
||||||
|
await fs.writeFile(plugin, pluginSource())
|
||||||
|
await waitForPlugin(info.url, headers)
|
||||||
|
|
||||||
const unauthorizedHealth = await fetch(new URL("/api/health", info.url), {
|
const unauthorizedHealth = await fetch(new URL("/api/health", info.url), {
|
||||||
signal: AbortSignal.timeout(5_000),
|
signal: AbortSignal.timeout(5_000),
|
||||||
@@ -88,6 +94,7 @@ try {
|
|||||||
} finally {
|
} finally {
|
||||||
processes.forEach((process) => process.kill())
|
processes.forEach((process) => process.kill())
|
||||||
await Promise.all(processes.map((process) => process.exited))
|
await Promise.all(processes.map((process) => process.exited))
|
||||||
|
if (failure) errors.push(fs.readFile(path.join(root, "data", "opencode", "log", "opencode.log"), "utf8").catch(() => ""))
|
||||||
}
|
}
|
||||||
|
|
||||||
const output = await Promise.all(errors)
|
const output = await Promise.all(errors)
|
||||||
@@ -133,3 +140,31 @@ async function waitForReady(url: string, headers: HeadersInit) {
|
|||||||
function exitsWithin(process: Bun.Subprocess, milliseconds: number) {
|
function exitsWithin(process: Bun.Subprocess, milliseconds: number) {
|
||||||
return Promise.race([process.exited.then(() => true), Bun.sleep(milliseconds).then(() => false)])
|
return Promise.race([process.exited.then(() => true), Bun.sleep(milliseconds).then(() => false)])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function pluginSource() {
|
||||||
|
return 'export default { id: "smoke", setup: async () => {} }\n'
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pluginIDs(url: string, headers: HeadersInit) {
|
||||||
|
const endpoint = new URL("/api/plugin", url)
|
||||||
|
endpoint.searchParams.set("location[directory]", root)
|
||||||
|
const response = await fetch(endpoint, { headers, signal: AbortSignal.timeout(5_000) })
|
||||||
|
const body: unknown = await response.json()
|
||||||
|
if (typeof body !== "object" || body === null || !("data" in body) || !Array.isArray(body.data)) {
|
||||||
|
throw new Error("Compiled service returned an invalid plugin list")
|
||||||
|
}
|
||||||
|
return body.data.flatMap((plugin) =>
|
||||||
|
typeof plugin === "object" && plugin !== null && "id" in plugin && typeof plugin.id === "string"
|
||||||
|
? [plugin.id]
|
||||||
|
: [],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitForPlugin(url: string, headers: HeadersInit) {
|
||||||
|
const deadline = Date.now() + 10_000
|
||||||
|
while (Date.now() < deadline) {
|
||||||
|
if ((await pluginIDs(url, headers)).includes("smoke")) return
|
||||||
|
await Bun.sleep(25)
|
||||||
|
}
|
||||||
|
throw new Error("Compiled service did not discover the created plugin")
|
||||||
|
}
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ export function nodeTarget(platform: string, arch: string) {
|
|||||||
const targetArch = arch as "arm64" | "x64"
|
const targetArch = arch as "arm64" | "x64"
|
||||||
const nodePtyPackage = `@lydell/node-pty-${targetPlatform}-${targetArch}`
|
const nodePtyPackage = `@lydell/node-pty-${targetPlatform}-${targetArch}`
|
||||||
const parcelWatcherPackage = `@parcel/watcher-${targetPlatform}-${targetArch}${targetPlatform === "linux" ? "-glibc" : ""}`
|
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 {
|
return {
|
||||||
platform: targetPlatform,
|
platform: targetPlatform,
|
||||||
@@ -19,6 +21,10 @@ export function nodeTarget(platform: string, arch: string) {
|
|||||||
nodePtyEntryAsset: `${nodePtyPackage}/lib/index.js`,
|
nodePtyEntryAsset: `${nodePtyPackage}/lib/index.js`,
|
||||||
parcelWatcherPackage,
|
parcelWatcherPackage,
|
||||||
parcelWatcherAsset: `${parcelWatcherPackage}/watcher.node`,
|
parcelWatcherAsset: `${parcelWatcherPackage}/watcher.node`,
|
||||||
|
fffPackage,
|
||||||
|
fffAsset: `${fffPackage}/${targetPlatform === "darwin" ? "libfff_c.dylib" : targetPlatform === "win32" ? "fff_c.dll" : "libfff_c.so"}`,
|
||||||
|
fffFfiPackage,
|
||||||
|
fffFfiAsset: `${fffFfiPackage}/ffi-rs.${targetPlatform}-${targetArch}${targetPlatform === "linux" ? "-gnu" : targetPlatform === "win32" ? "-msvc" : ""}.node`,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -30,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 = {
|
const resolve = {
|
||||||
alias: [
|
alias: [
|
||||||
{ find: /^solid-js\/store$/, replacement: "solid-js/store/dist/store.js" },
|
{ 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_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_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.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
|
globalThis.__OPENCODE_PHOTON_WASM_PATH = process.env.OPENCODE_PHOTON_WASM_PATH
|
||||||
if (process.platform === "linux") process.env.OPENTUI_LIBC = "glibc"`
|
if (process.platform === "linux") process.env.OPENTUI_LIBC = "glibc"`
|
||||||
}
|
}
|
||||||
@@ -174,6 +217,7 @@ export function mainConfig(input: NodeBuildInput): UserConfig {
|
|||||||
plugins: [
|
plugins: [
|
||||||
rawTextPlugin(),
|
rawTextPlugin(),
|
||||||
runtimeRequirePlugin(),
|
runtimeRequirePlugin(),
|
||||||
|
fffNodePlugin(),
|
||||||
solid({
|
solid({
|
||||||
solid: {
|
solid: {
|
||||||
generate: "universal",
|
generate: "universal",
|
||||||
|
|||||||
@@ -92,6 +92,7 @@
|
|||||||
"@lydell/node-pty": "catalog:",
|
"@lydell/node-pty": "catalog:",
|
||||||
"@modelcontextprotocol/sdk": "1.29.0",
|
"@modelcontextprotocol/sdk": "1.29.0",
|
||||||
"@ff-labs/fff-bun": "0.10.1",
|
"@ff-labs/fff-bun": "0.10.1",
|
||||||
|
"@ff-labs/fff-node": "0.10.1",
|
||||||
"@opencode-ai/codemode": "workspace:*",
|
"@opencode-ai/codemode": "workspace:*",
|
||||||
"@opencode-ai/effect-drizzle-sqlite": "workspace:*",
|
"@opencode-ai/effect-drizzle-sqlite": "workspace:*",
|
||||||
"@opencode-ai/effect-sqlite-node": "workspace:*",
|
"@opencode-ai/effect-sqlite-node": "workspace:*",
|
||||||
|
|||||||
@@ -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 type Result<T> = { ok: true; value: T } | { ok: false; error: string }
|
||||||
|
|
||||||
export interface Init {
|
export type Init = InitOptions
|
||||||
basePath: string
|
|
||||||
frecencyDbPath?: string
|
|
||||||
historyDbPath?: string
|
|
||||||
useUnsafeNoLock?: boolean
|
|
||||||
disableMmapCache?: boolean
|
|
||||||
disableContentIndexing?: boolean
|
|
||||||
disableWatch?: boolean
|
|
||||||
aiMode?: boolean
|
|
||||||
logFilePath?: string
|
|
||||||
logLevel?: "trace" | "debug" | "info" | "warn" | "error"
|
|
||||||
enableFsRootScanning?: boolean
|
|
||||||
enableHomeDirScanning?: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface File {
|
|
||||||
relativePath: string
|
|
||||||
fileName: string
|
|
||||||
modified: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Directory {
|
|
||||||
relativePath: string
|
|
||||||
dirName: string
|
|
||||||
maxAccessFrecency: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export type Mixed = { type: "file"; item: File } | { type: "directory"; item: Directory }
|
|
||||||
|
|
||||||
export interface Search {
|
export interface Search {
|
||||||
items: File[]
|
items: FileItem[]
|
||||||
scores: Array<{ total: number }>
|
scores: SearchResult["scores"]
|
||||||
totalMatched: number
|
totalMatched: number
|
||||||
totalFiles: number
|
totalFiles: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DirSearch {
|
export interface DirSearch {
|
||||||
items: Directory[]
|
items: DirItem[]
|
||||||
scores: Array<{ total: number }>
|
scores: DirSearchResult["scores"]
|
||||||
totalMatched: number
|
totalMatched: number
|
||||||
totalDirs: number
|
totalDirs: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MixedSearch {
|
export interface MixedSearch {
|
||||||
items: Mixed[]
|
items: MixedItem[]
|
||||||
scores: Array<{ total: number }>
|
scores: MixedSearchResult["scores"]
|
||||||
totalMatched: number
|
totalMatched: number
|
||||||
totalFiles: number
|
totalFiles: number
|
||||||
totalDirs: number
|
totalDirs: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export type Cursor = null
|
export type File = FileItem
|
||||||
|
export type Directory = DirItem
|
||||||
export interface Hit {
|
export type Mixed = MixedItem
|
||||||
relativePath: string
|
export type Cursor = GrepCursor | null
|
||||||
fileName: string
|
export type Hit = GrepMatch
|
||||||
lineNumber: number
|
|
||||||
byteOffset: number
|
|
||||||
lineContent: string
|
|
||||||
matchRanges: [number, number][]
|
|
||||||
contextBefore?: string[]
|
|
||||||
contextAfter?: string[]
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Grep {
|
export interface Grep {
|
||||||
items: Hit[]
|
items: GrepResult["items"]
|
||||||
totalMatched: number
|
totalMatched: number
|
||||||
totalFilesSearched: number
|
totalFilesSearched: number
|
||||||
totalFiles: number
|
totalFiles: number
|
||||||
@@ -128,11 +109,30 @@ export interface Picker {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function available() {
|
export function available() {
|
||||||
return false
|
return FileFinder?.isAvailable() ?? false
|
||||||
}
|
}
|
||||||
|
|
||||||
export function create(_opts: Init): Result<Picker> {
|
export function create(opts: Init): Result<Picker> {
|
||||||
return { ok: false, error: "fff unavailable on node runtime" }
|
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"
|
export * as Fff from "./fff.node"
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { createRequire } from "node:module"
|
||||||
|
|
||||||
|
declare const OPENCODE_LIBC: string | undefined
|
||||||
|
|
||||||
|
const require = createRequire(import.meta.url)
|
||||||
|
|
||||||
|
export default function load() {
|
||||||
|
const libc = typeof OPENCODE_LIBC === "undefined" ? undefined : OPENCODE_LIBC
|
||||||
|
return require(
|
||||||
|
process.env.OPENCODE_PARCEL_WATCHER_PATH ??
|
||||||
|
`@parcel/watcher-${process.platform}-${process.arch}${process.platform === "linux" ? `-${libc || "glibc"}` : ""}`,
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -9,23 +9,14 @@ import { Cause, Context, Effect, Layer, PubSub, RcMap, Schema, Stream } from "ef
|
|||||||
import { lazy } from "../util/lazy"
|
import { lazy } from "../util/lazy"
|
||||||
import { watch as watchFileSystem } from "node:fs"
|
import { watch as watchFileSystem } from "node:fs"
|
||||||
import path from "path"
|
import path from "path"
|
||||||
import { createRequire } from "node:module"
|
import loadBinding from "./watcher-binding"
|
||||||
|
|
||||||
declare const OPENCODE_LIBC: string | undefined
|
|
||||||
|
|
||||||
const SUBSCRIBE_TIMEOUT_MS = 10_000
|
const SUBSCRIBE_TIMEOUT_MS = 10_000
|
||||||
const require = createRequire(import.meta.url)
|
|
||||||
|
|
||||||
export const Event = { Updated: FileSystem.Event.Changed }
|
export const Event = { Updated: FileSystem.Event.Changed }
|
||||||
|
|
||||||
const watcher = lazy((): typeof import("@parcel/watcher") | undefined => {
|
const watcher = lazy((): typeof import("@parcel/watcher") | undefined => {
|
||||||
try {
|
try {
|
||||||
const libc = typeof OPENCODE_LIBC === "undefined" ? undefined : OPENCODE_LIBC
|
return createWrapper(loadBinding()) as typeof import("@parcel/watcher")
|
||||||
const binding = require(
|
|
||||||
process.env.OPENCODE_PARCEL_WATCHER_PATH ??
|
|
||||||
`@parcel/watcher-${process.platform}-${process.arch}${process.platform === "linux" ? `-${libc || "glibc"}` : ""}`,
|
|
||||||
)
|
|
||||||
return createWrapper(binding) as typeof import("@parcel/watcher")
|
|
||||||
} catch {
|
} catch {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ type Active = {
|
|||||||
* here; callers (e.g. `ShellTool`) own that association and store the shell ID.
|
* here; callers (e.g. `ShellTool`) own that association and store the shell ID.
|
||||||
*/
|
*/
|
||||||
export interface Interface {
|
export interface Interface {
|
||||||
|
readonly name: () => Effect.Effect<string>
|
||||||
readonly create: (input: Shell.CreateInput) => Effect.Effect<Shell.Info>
|
readonly create: (input: Shell.CreateInput) => Effect.Effect<Shell.Info>
|
||||||
// Currently running commands only; exited shells are retained for get/output but excluded here.
|
// Currently running commands only; exited shells are retained for get/output but excluded here.
|
||||||
readonly list: () => Effect.Effect<Shell.Info[]>
|
readonly list: () => Effect.Effect<Shell.Info[]>
|
||||||
@@ -134,6 +135,13 @@ export const layer = (options?: ShellSelect.Options) => Layer.effect(
|
|||||||
return session.info
|
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 output = Effect.fn("Shell.output")(function* (id: Shell.ID, input?: Shell.OutputInput) {
|
||||||
const session = yield* require(id)
|
const session = yield* require(id)
|
||||||
const cursor = input?.cursor ?? 0
|
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 create = Effect.fn("Shell.create")(function* (input: Shell.CreateInput) {
|
||||||
const id = Shell.ID.ascending()
|
const id = Shell.ID.ascending()
|
||||||
const cwd = input.cwd ?? location.directory
|
const cwd = input.cwd ?? location.directory
|
||||||
const configShell = Config.latest(yield* config.entries(), "shell")
|
const shell = yield* resolve()
|
||||||
const shell = ShellSelect.preferred(configShell, options)
|
|
||||||
const args = ShellSelect.args(shell, input.command)
|
const args = ShellSelect.args(shell, input.command)
|
||||||
const file = path.join(outputDir, `${id}.out`)
|
const file = path.join(outputDir, `${id}.out`)
|
||||||
const env = {
|
const env = {
|
||||||
@@ -312,7 +319,7 @@ export const layer = (options?: ShellSelect.Options) => Layer.effect(
|
|||||||
return session.info
|
return session.info
|
||||||
})
|
})
|
||||||
|
|
||||||
return Service.of({ create, list, get, wait, timeout, output, remove })
|
return Service.of({ name, create, list, get, wait, timeout, output, remove })
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -15,21 +15,39 @@ import { Shell } from "../../shell"
|
|||||||
|
|
||||||
export const name = "shell"
|
export const name = "shell"
|
||||||
export const DEFAULT_TIMEOUT_MS = 2 * 60 * 1_000
|
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
|
export const MAX_CAPTURE_BYTES = 1024 * 1024
|
||||||
|
|
||||||
const BACKGROUND_STARTED = "The command was moved to the background."
|
const BACKGROUND_STARTED = "The command was moved to the background."
|
||||||
const BACKGROUND_INSTRUCTION =
|
const BACKGROUND_INSTRUCTION =
|
||||||
"You will be notified automatically when the command finishes. DO NOT sleep, poll, or proactively check on its progress."
|
"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({
|
export const Input = Schema.Struct({
|
||||||
command: Schema.String.annotate({ description: "Shell command string to execute" }),
|
command: Schema.String.annotate({ description: "Shell command string to execute" }),
|
||||||
workdir: Schema.optionalKey(Schema.String).annotate({
|
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.check(Schema.isLessThanOrEqualTo(MAX_TIMEOUT_MS)))
|
timeout: Schema.optionalKey(NonNegativeInt).annotate({
|
||||||
.annotate({
|
description: `Timeout in milliseconds. Set to 0 to disable the timeout. Defaults to ${DEFAULT_TIMEOUT_MS} for foreground commands. Background commands have no timeout by default.`,
|
||||||
description: `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({
|
background: Schema.optionalKey(Schema.Boolean).annotate({
|
||||||
description:
|
description:
|
||||||
@@ -69,13 +87,11 @@ const modelOutput = (output: Output): string | undefined => {
|
|||||||
// TODO: Port tree-sitter bash / PowerShell parser-based approval reduction.
|
// TODO: Port tree-sitter bash / PowerShell parser-based approval reduction.
|
||||||
// TODO: Port BashArity reusable command-prefix approvals.
|
// TODO: Port BashArity reusable command-prefix approvals.
|
||||||
// TODO: Replace token-based command-argument external-directory advisories with parser-based detection.
|
// 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: Add plugin shell.env environment augmentation once plugin hooks exist.
|
||||||
// TODO: Persist job status and define restart recovery before exposing remote observation.
|
// 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: 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 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: 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 shellTokens = (command: string) => command.match(/(?:[^\s"']+|"[^"]*"|'[^']*')+/g) ?? []
|
||||||
const unquote = (value: string) => value.replace(/^(['"])(.*)\1$/, "$2")
|
const unquote = (value: string) => value.replace(/^(['"])(.*)\1$/, "$2")
|
||||||
@@ -144,7 +160,7 @@ export const Plugin = {
|
|||||||
({
|
({
|
||||||
name,
|
name,
|
||||||
options: { codemode: false },
|
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,
|
input: Input,
|
||||||
output: Output,
|
output: Output,
|
||||||
execute: (input, context) =>
|
execute: (input, context) =>
|
||||||
@@ -189,8 +205,12 @@ export const Plugin = {
|
|||||||
yield* context.progress({ shellID: info.id })
|
yield* context.progress({ shellID: info.id })
|
||||||
|
|
||||||
const captureShell = Effect.fn("ShellTool.captureShell")(function* () {
|
const captureShell = Effect.fn("ShellTool.captureShell")(function* () {
|
||||||
const page = yield* shell.output(info.id, { limit: MAX_CAPTURE_BYTES })
|
const latest = yield* shell.output(info.id, { cursor: Number.MAX_SAFE_INTEGER })
|
||||||
const truncated = page.size > page.cursor
|
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}]` : ""
|
const notice = truncated ? `\n\n[output truncated; full output saved to: ${info.file}]` : ""
|
||||||
return {
|
return {
|
||||||
output: `${page.output || "(no output)"}${notice}`,
|
output: `${page.output || "(no output)"}${notice}`,
|
||||||
@@ -291,5 +311,13 @@ export const Plugin = {
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
.pipe(Effect.orDie)
|
.pipe(Effect.orDie)
|
||||||
|
|
||||||
|
yield* ctx.session.hook("context", (event) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const tool = event.tools[name]
|
||||||
|
if (!tool) return
|
||||||
|
tool.description = description(yield* shell.name())
|
||||||
|
}),
|
||||||
|
)
|
||||||
}),
|
}),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -165,8 +165,8 @@ const bodyExitCommand = isWindows
|
|||||||
: "printf body && exit 7"
|
: "printf body && exit 7"
|
||||||
const overflowCommand = (bytes: number) =>
|
const overflowCommand = (bytes: number) =>
|
||||||
isWindows
|
isWindows
|
||||||
? `[Console]::Out.Write(('x' * ${bytes})); Start-Sleep -Milliseconds 100`
|
? `[Console]::Out.Write('output-start' + ('x' * ${bytes}) + 'output-end'); Start-Sleep -Milliseconds 100`
|
||||||
: `head -c ${bytes} /dev/zero | tr '\\0' 'x'`
|
: `printf output-start; head -c ${bytes} /dev/zero | tr '\\0' 'x'; printf output-end`
|
||||||
const progressOverflowCommand = (bytes: number, release: string) =>
|
const progressOverflowCommand = (bytes: number, release: string) =>
|
||||||
isWindows
|
isWindows
|
||||||
? `[Console]::Out.Write(('x' * ${bytes})); while (!(Test-Path -LiteralPath '${release}')) { Start-Sleep -Milliseconds 50 }`
|
? `[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) =>
|
return withSession(tmp.path, (registry) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const definitions = yield* toolDefinitions(registry)
|
const definitions = yield* toolDefinitions(registry)
|
||||||
const shell = definitions.find((tool) => tool.name === "shell")
|
const definition = definitions.find((tool) => tool.name === "shell")
|
||||||
expect(shell).toBeDefined()
|
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.
|
// 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(
|
expect(
|
||||||
(yield* toolDefinitions(registry, [{ action: "shell", resource: "*", effect: "deny" }])).map(
|
(yield* toolDefinitions(registry, [{ action: "shell", resource: "*", effect: "deny" }])).map(
|
||||||
(tool) => tool.name,
|
(tool) => tool.name,
|
||||||
@@ -409,7 +410,11 @@ describe("ShellTool", () => {
|
|||||||
Effect.andThen((settled) =>
|
Effect.andThen((settled) =>
|
||||||
Effect.sync(() => {
|
Effect.sync(() => {
|
||||||
expect(settled.metadata).toMatchObject({ exit: 0, truncated: true })
|
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",
|
type: "text",
|
||||||
text: expect.stringContaining("output truncated; full output saved to:"),
|
text: expect.stringContaining("output truncated; full output saved to:"),
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -32,7 +32,8 @@
|
|||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@opentui/core": ">=0.4.5",
|
"@opentui/core": ">=0.4.5",
|
||||||
"@opentui/keymap": ">=0.4.5",
|
"@opentui/keymap": ">=0.4.5",
|
||||||
"@opentui/solid": ">=0.4.5"
|
"@opentui/solid": ">=0.4.5",
|
||||||
|
"solid-js": ">=1.9.0"
|
||||||
},
|
},
|
||||||
"peerDependenciesMeta": {
|
"peerDependenciesMeta": {
|
||||||
"@opentui/core": {
|
"@opentui/core": {
|
||||||
@@ -43,6 +44,9 @@
|
|||||||
},
|
},
|
||||||
"@opentui/solid": {
|
"@opentui/solid": {
|
||||||
"optional": true
|
"optional": true
|
||||||
|
},
|
||||||
|
"solid-js": {
|
||||||
|
"optional": true
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -52,6 +56,7 @@
|
|||||||
"@tsconfig/bun": "catalog:",
|
"@tsconfig/bun": "catalog:",
|
||||||
"@tsconfig/node22": "catalog:",
|
"@tsconfig/node22": "catalog:",
|
||||||
"@types/node": "catalog:",
|
"@types/node": "catalog:",
|
||||||
|
"solid-js": "catalog:",
|
||||||
"typescript": "catalog:",
|
"typescript": "catalog:",
|
||||||
"@typescript/native-preview": "catalog:"
|
"@typescript/native-preview": "catalog:"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -230,11 +230,11 @@ export interface DialogSelectOptions<Value> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface Dialog {
|
export interface Dialog {
|
||||||
/** Shows a dialog and returns a function that closes it. */
|
/** Shows a dialog. */
|
||||||
show(render: () => JSX.Element, onClose?: () => void): () => void
|
show(render: () => JSX.Element, onClose?: () => void): void
|
||||||
/** Sets the presentation options for this plugin's active dialog. */
|
/** Sets the active dialog's presentation options. */
|
||||||
set(options: DialogOptions): void
|
set(options: DialogOptions): void
|
||||||
/** Closes this plugin's active dialog. */
|
/** Closes the active dialog. */
|
||||||
clear(): void
|
clear(): void
|
||||||
alert(options: DialogAlertOptions): Promise<void>
|
alert(options: DialogAlertOptions): Promise<void>
|
||||||
confirm(options: DialogConfirmOptions): Promise<boolean | undefined>
|
confirm(options: DialogConfirmOptions): Promise<boolean | undefined>
|
||||||
|
|||||||
@@ -1 +1,2 @@
|
|||||||
export * as Plugin from "./plugin.js"
|
export * as Plugin from "./plugin.js"
|
||||||
|
export { PluginContextProvider, usePlugin } from "./solid.js"
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -11,7 +11,6 @@
|
|||||||
},
|
},
|
||||||
"exports": {
|
"exports": {
|
||||||
".": "./src/index.tsx",
|
".": "./src/index.tsx",
|
||||||
"./builtins": "./src/feature-plugins/builtins.ts",
|
|
||||||
"./config": "./src/config/index.tsx",
|
"./config": "./src/config/index.tsx",
|
||||||
"./config/keybind": "./src/config/keybind.ts",
|
"./config/keybind": "./src/config/keybind.ts",
|
||||||
"./config/v1": "./src/config/v1/index.tsx",
|
"./config/v1": "./src/config/v1/index.tsx",
|
||||||
@@ -37,9 +36,6 @@
|
|||||||
"./context/keymap": "./src/context/keymap.tsx",
|
"./context/keymap": "./src/context/keymap.tsx",
|
||||||
"./prompt/content": "./src/prompt/content.ts",
|
"./prompt/content": "./src/prompt/content.ts",
|
||||||
"./prompt/display": "./src/prompt/display.ts",
|
"./prompt/display": "./src/prompt/display.ts",
|
||||||
"./plugin/runtime": "./src/plugin/runtime.tsx",
|
|
||||||
"./plugin/slots": "./src/plugin/slots.tsx",
|
|
||||||
"./plugin/command-shim": "./src/plugin/command-shim.ts",
|
|
||||||
"./parsers-config": "./src/parsers-config.ts",
|
"./parsers-config": "./src/parsers-config.ts",
|
||||||
"./util/error": "./src/util/error.ts",
|
"./util/error": "./src/util/error.ts",
|
||||||
"./util/filetype": "./src/util/filetype.ts",
|
"./util/filetype": "./src/util/filetype.ts",
|
||||||
|
|||||||
+111
-20
@@ -51,6 +51,7 @@ import { StartupLoading } from "./component/startup-loading"
|
|||||||
import { DevToolsBar } from "./component/devtools-bar"
|
import { DevToolsBar } from "./component/devtools-bar"
|
||||||
import { Reconnecting } from "./component/reconnecting"
|
import { Reconnecting } from "./component/reconnecting"
|
||||||
import { DataProvider, useData } from "./context/data"
|
import { DataProvider, useData } from "./context/data"
|
||||||
|
import { SessionTabsProvider, useSessionTabs } from "./context/session-tabs"
|
||||||
import { LocationProvider, useLocation } from "./context/location"
|
import { LocationProvider, useLocation } from "./context/location"
|
||||||
import { LocalProvider, useLocal } from "./context/local"
|
import { LocalProvider, useLocal } from "./context/local"
|
||||||
import { PermissionProvider } from "./context/permission"
|
import { PermissionProvider } from "./context/permission"
|
||||||
@@ -65,8 +66,9 @@ import { DialogThemeList } from "./component/dialog-theme-list"
|
|||||||
import { DialogHelp } from "./ui/dialog-help"
|
import { DialogHelp } from "./ui/dialog-help"
|
||||||
import { DialogAgent } from "./component/dialog-agent"
|
import { DialogAgent } from "./component/dialog-agent"
|
||||||
import { DialogSessionList } from "./component/dialog-session-list"
|
import { DialogSessionList } from "./component/dialog-session-list"
|
||||||
|
import { SessionTabs } from "./component/session-tabs"
|
||||||
import { ThemeErrorToast } from "./component/theme-error-toast"
|
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 { Home } from "./routes/home"
|
||||||
import { Session } from "./routes/session"
|
import { Session } from "./routes/session"
|
||||||
import { PromptHistoryProvider } from "./component/prompt/history"
|
import { PromptHistoryProvider } from "./component/prompt/history"
|
||||||
@@ -79,7 +81,6 @@ import { ArgsProvider, useArgs, type Args } from "./context/args"
|
|||||||
import open from "open"
|
import open from "open"
|
||||||
import { PromptRefProvider, usePromptRef } from "./context/prompt"
|
import { PromptRefProvider, usePromptRef } from "./context/prompt"
|
||||||
import { Config, ConfigProvider, useConfig } from "./config"
|
import { Config, ConfigProvider, useConfig } from "./config"
|
||||||
import { createPluginRuntime, PluginRuntimeProvider, usePluginRuntime } from "./plugin/runtime"
|
|
||||||
import { PluginProvider, PluginRoute, PluginSlot, usePlugin, type PackageResolver } from "./plugin/context"
|
import { PluginProvider, PluginRoute, PluginSlot, usePlugin, type PackageResolver } from "./plugin/context"
|
||||||
import { CommandPaletteDialog } from "./component/command-palette"
|
import { CommandPaletteDialog } from "./component/command-palette"
|
||||||
import { COMMAND_PALETTE_COMMAND, Keymap, type KeymapCommand } from "./context/keymap"
|
import { COMMAND_PALETTE_COMMAND, Keymap, type KeymapCommand } from "./context/keymap"
|
||||||
@@ -92,9 +93,28 @@ import { AttentionProvider } from "./context/attention"
|
|||||||
|
|
||||||
registerOpencodeSpinner()
|
registerOpencodeSpinner()
|
||||||
|
|
||||||
const appGlobalBindingCommands = [
|
const appGlobalBindingCommands = ["session.list", "session.new"] as const
|
||||||
"session.list",
|
|
||||||
"session.new",
|
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.1",
|
||||||
"session.quick_switch.2",
|
"session.quick_switch.2",
|
||||||
"session.quick_switch.3",
|
"session.quick_switch.3",
|
||||||
@@ -254,8 +274,6 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
|
|||||||
() => Effect.sync(() => process.off("SIGHUP", onSighup)),
|
() => Effect.sync(() => process.off("SIGHUP", onSighup)),
|
||||||
)
|
)
|
||||||
renderer.once("destroy", () => Deferred.doneUnsafe(shutdown, Effect.void))
|
renderer.once("destroy", () => Deferred.doneUnsafe(shutdown, Effect.void))
|
||||||
const pluginRuntime = createPluginRuntime()
|
|
||||||
|
|
||||||
yield* Effect.tryPromise(async () => {
|
yield* Effect.tryPromise(async () => {
|
||||||
// Prewarm palette before ThemeProvider mounts so `system` theme avoids a first-paint fallback flash.
|
// Prewarm palette before ThemeProvider mounts so `system` theme avoids a first-paint fallback flash.
|
||||||
void renderer.getPalette({ size: 16 }).catch(() => undefined)
|
void renderer.getPalette({ size: 16 }).catch(() => undefined)
|
||||||
@@ -333,11 +351,11 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
|
|||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<PluginRuntimeProvider value={pluginRuntime}>
|
|
||||||
<ClientProvider api={api} service={service}>
|
<ClientProvider api={api} service={service}>
|
||||||
<PermissionProvider>
|
<PermissionProvider>
|
||||||
<DataProvider>
|
<DataProvider>
|
||||||
<LocationProvider>
|
<LocationProvider>
|
||||||
|
<SessionTabsProvider>
|
||||||
<ThemeProvider mode={mode}>
|
<ThemeProvider mode={mode}>
|
||||||
<ThemeErrorToast />
|
<ThemeErrorToast />
|
||||||
<LocalProvider>
|
<LocalProvider>
|
||||||
@@ -369,11 +387,11 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
|
|||||||
</PromptStashProvider>
|
</PromptStashProvider>
|
||||||
</LocalProvider>
|
</LocalProvider>
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
|
</SessionTabsProvider>
|
||||||
</LocationProvider>
|
</LocationProvider>
|
||||||
</DataProvider>
|
</DataProvider>
|
||||||
</PermissionProvider>
|
</PermissionProvider>
|
||||||
</ClientProvider>
|
</ClientProvider>
|
||||||
</PluginRuntimeProvider>
|
|
||||||
</RouteProvider>
|
</RouteProvider>
|
||||||
</ToastProvider>
|
</ToastProvider>
|
||||||
</Keymap.Provider>
|
</Keymap.Provider>
|
||||||
@@ -419,17 +437,17 @@ function App(props: { pair?: DialogPairCredentials }) {
|
|||||||
const renderer = useRenderer()
|
const renderer = useRenderer()
|
||||||
const dialog = useDialog()
|
const dialog = useDialog()
|
||||||
const local = useLocal()
|
const local = useLocal()
|
||||||
|
const sessionTabs = useSessionTabs()
|
||||||
const keymap = Keymap.use()
|
const keymap = Keymap.use()
|
||||||
const event = useEvent()
|
const event = useEvent()
|
||||||
const client = useClient()
|
const client = useClient()
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
const themeState = useTheme()
|
const theme = useTheme()
|
||||||
const { themeV2, mode, supports, setMode, locked, lock, unlock } = themeState
|
const { mode, supports, setMode, locked, lock, unlock } = useThemes()
|
||||||
const data = useData()
|
const data = useData()
|
||||||
const location = useLocation()
|
const location = useLocation()
|
||||||
const exit = useExit()
|
const exit = useExit()
|
||||||
const promptRef = usePromptRef()
|
const promptRef = usePromptRef()
|
||||||
const pluginRuntime = usePluginRuntime()
|
|
||||||
const plugins = usePlugin()
|
const plugins = usePlugin()
|
||||||
const clipboard = useClipboard()
|
const clipboard = useClipboard()
|
||||||
|
|
||||||
@@ -623,9 +641,71 @@ function App(props: { pair?: DialogPairCredentials }) {
|
|||||||
title: `Switch to session in quick slot ${i + 1}`,
|
title: `Switch to session in quick slot ${i + 1}`,
|
||||||
category: "Session",
|
category: "Session",
|
||||||
palette: undefined,
|
palette: undefined,
|
||||||
run: () => {
|
enabled: () => !sessionTabs.enabled(),
|
||||||
local.session.quickSwitch(i + 1)
|
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",
|
name: "model.list",
|
||||||
@@ -1004,6 +1084,18 @@ function App(props: { pair?: DialogPairCredentials }) {
|
|||||||
bindings: appGlobalBindingCommands,
|
bindings: appGlobalBindingCommands,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
Keymap.createLayer(() => ({
|
||||||
|
mode: "global",
|
||||||
|
enabled: sessionTabs.enabled,
|
||||||
|
bindings: sessionTabBindingCommands,
|
||||||
|
}))
|
||||||
|
|
||||||
|
Keymap.createLayer(() => ({
|
||||||
|
mode: "global",
|
||||||
|
enabled: () => !sessionTabs.enabled(),
|
||||||
|
bindings: pinnedSessionBindingCommands,
|
||||||
|
}))
|
||||||
|
|
||||||
Keymap.createLayer(() => ({
|
Keymap.createLayer(() => ({
|
||||||
enabled: () => {
|
enabled: () => {
|
||||||
const current = promptRef.current
|
const current = promptRef.current
|
||||||
@@ -1090,7 +1182,7 @@ function App(props: { pair?: DialogPairCredentials }) {
|
|||||||
width={dimensions().width}
|
width={dimensions().width}
|
||||||
height={dimensions().height}
|
height={dimensions().height}
|
||||||
flexDirection="column"
|
flexDirection="column"
|
||||||
backgroundColor={themeV2.background.default}
|
backgroundColor={theme.background.default}
|
||||||
onMouseDown={(evt) => {
|
onMouseDown={(evt) => {
|
||||||
if (copyOnSelectEnabled()) return
|
if (copyOnSelectEnabled()) return
|
||||||
if (evt.button !== MouseButton.RIGHT) return
|
if (evt.button !== MouseButton.RIGHT) return
|
||||||
@@ -1099,16 +1191,15 @@ function App(props: { pair?: DialogPairCredentials }) {
|
|||||||
evt.preventDefault()
|
evt.preventDefault()
|
||||||
evt.stopPropagation()
|
evt.stopPropagation()
|
||||||
}}
|
}}
|
||||||
onMouseUp={
|
onMouseUp={copyOnSelectEnabled() ? () => Selection.copy(renderer, toast, clipboard) : undefined}
|
||||||
copyOnSelectEnabled()
|
|
||||||
? () => Selection.copy(renderer, toast, clipboard)
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
<box flexGrow={1} minHeight={0} flexDirection="row">
|
<box flexGrow={1} minHeight={0} flexDirection="row">
|
||||||
<box flexGrow={1} minWidth={0} flexDirection="column">
|
<box flexGrow={1} minWidth={0} flexDirection="column">
|
||||||
<Show when={plugins.ready()}>
|
<Show when={plugins.ready()}>
|
||||||
<box flexGrow={1} minHeight={0} flexDirection="column">
|
<box flexGrow={1} minHeight={0} flexDirection="column">
|
||||||
|
<Show when={sessionTabs.enabled() && sessionTabs.tabs().length > 0 && route.data.type !== "plugin"}>
|
||||||
|
<SessionTabs />
|
||||||
|
</Show>
|
||||||
<Switch>
|
<Switch>
|
||||||
<Match when={route.data.type === "home"}>
|
<Match when={route.data.type === "home"}>
|
||||||
<Home />
|
<Home />
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import {
|
|||||||
} from "@opentui/core"
|
} from "@opentui/core"
|
||||||
import { extend, useRenderer } from "@opentui/solid"
|
import { extend, useRenderer } from "@opentui/solid"
|
||||||
import { onCleanup, onMount } from "solid-js"
|
import { onCleanup, onMount } from "solid-js"
|
||||||
import { useTheme } from "../context/theme"
|
import { useThemes } from "../context/theme"
|
||||||
import { tint } from "../theme/color"
|
import { tint } from "../theme/color"
|
||||||
import { GoUpsellArtPainter } from "./bg-pulse-render"
|
import { GoUpsellArtPainter } from "./bg-pulse-render"
|
||||||
|
|
||||||
@@ -70,7 +70,9 @@ declare module "@opentui/solid" {
|
|||||||
extend({ go_upsell_art: GoUpsellArtRenderable })
|
extend({ go_upsell_art: GoUpsellArtRenderable })
|
||||||
|
|
||||||
export function BgPulse() {
|
export function BgPulse() {
|
||||||
const { themeV2, mode } = useTheme().contextual("elevated")
|
const themes = useThemes()
|
||||||
|
const theme = themes.contextual("elevated")
|
||||||
|
const mode = themes.mode
|
||||||
const renderer = useRenderer()
|
const renderer = useRenderer()
|
||||||
let targetFps = renderer.targetFps
|
let targetFps = renderer.targetFps
|
||||||
let maxFps = renderer.maxFps
|
let maxFps = renderer.maxFps
|
||||||
@@ -91,9 +93,9 @@ export function BgPulse() {
|
|||||||
<go_upsell_art
|
<go_upsell_art
|
||||||
width="100%"
|
width="100%"
|
||||||
height="100%"
|
height="100%"
|
||||||
backgroundPanel={themeV2.background.default}
|
backgroundPanel={theme.background.default}
|
||||||
primary={themeV2.hue.interactive[mode() === "light" ? 800 : 200]}
|
primary={theme.hue.interactive[mode() === "light" ? 800 : 200]}
|
||||||
logoBase={tint(themeV2.background.default, themeV2.text.default, 0.62)}
|
logoBase={tint(theme.background.default, theme.text.default, 0.62)}
|
||||||
live
|
live
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import { useData } from "../context/data"
|
|||||||
import { useLocation } from "../context/location"
|
import { useLocation } from "../context/location"
|
||||||
import { useRoute } from "../context/route"
|
import { useRoute } from "../context/route"
|
||||||
import { Keymap } from "../context/keymap"
|
import { Keymap } from "../context/keymap"
|
||||||
import { useTheme } from "../context/theme"
|
import { useTheme, useThemes } from "../context/theme"
|
||||||
import { DevTools } from "../devtools"
|
import { DevTools } from "../devtools"
|
||||||
import { usePlugin } from "../plugin/context"
|
import { usePlugin } from "../plugin/context"
|
||||||
import { errorMessage } from "../util/error"
|
import { errorMessage } from "../util/error"
|
||||||
@@ -31,12 +31,12 @@ export function DevToolsBar() {
|
|||||||
const location = useLocation()
|
const location = useLocation()
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const plugins = usePlugin()
|
const plugins = usePlugin()
|
||||||
const theme = useTheme()
|
const themes = useThemes()
|
||||||
const keymap = Keymap.use()
|
const keymap = Keymap.use()
|
||||||
const renderer = useRenderer()
|
const renderer = useRenderer()
|
||||||
const dimensions = useTerminalDimensions()
|
const dimensions = useTerminalDimensions()
|
||||||
const { themeV2, mode, supports, setMode } = theme
|
const { current: theme, mode, supports, setMode } = themes
|
||||||
const elevatedTheme = theme.contextual("elevated").themeV2
|
const elevatedTheme = themes.contextual("elevated")
|
||||||
const [panel, setPanel] = createSignal<Panel>()
|
const [panel, setPanel] = createSignal<Panel>()
|
||||||
const [dumping, setDumping] = createSignal(false)
|
const [dumping, setDumping] = createSignal(false)
|
||||||
const [dumpPath, setDumpPath] = createSignal<string>()
|
const [dumpPath, setDumpPath] = createSignal<string>()
|
||||||
@@ -196,8 +196,8 @@ export function DevToolsBar() {
|
|||||||
})),
|
})),
|
||||||
},
|
},
|
||||||
theme: {
|
theme: {
|
||||||
name: theme.selected,
|
name: themes.selected,
|
||||||
mode: theme.mode(),
|
mode: themes.mode(),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
null,
|
null,
|
||||||
@@ -213,7 +213,7 @@ export function DevToolsBar() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
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()}>
|
<Show when={panel()}>
|
||||||
<box
|
<box
|
||||||
position="absolute"
|
position="absolute"
|
||||||
@@ -230,12 +230,12 @@ export function DevToolsBar() {
|
|||||||
<text
|
<text
|
||||||
fg={
|
fg={
|
||||||
panel() === "server"
|
panel() === "server"
|
||||||
? themeV2.text.action.primary.focused
|
? theme.text.action.primary.focused
|
||||||
: serverIndicator().state === "connected"
|
: serverIndicator().state === "connected"
|
||||||
? themeV2.text.feedback.success.default
|
? theme.text.feedback.success.default
|
||||||
: serverIndicator().state === "disconnected"
|
: serverIndicator().state === "disconnected"
|
||||||
? themeV2.text.feedback.error.default
|
? theme.text.feedback.error.default
|
||||||
: themeV2.text.default
|
: theme.text.default
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{serverIndicator().icon}
|
{serverIndicator().icon}
|
||||||
@@ -243,10 +243,10 @@ export function DevToolsBar() {
|
|||||||
<text
|
<text
|
||||||
fg={
|
fg={
|
||||||
panel() === "server"
|
panel() === "server"
|
||||||
? themeV2.text.action.primary.focused
|
? theme.text.action.primary.focused
|
||||||
: serverIndicator().state === "disconnected"
|
: serverIndicator().state === "disconnected"
|
||||||
? themeV2.text.feedback.error.default
|
? theme.text.feedback.error.default
|
||||||
: themeV2.text.subdued
|
: theme.text.subdued
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{" "}
|
{" "}
|
||||||
@@ -279,10 +279,10 @@ export function DevToolsBar() {
|
|||||||
<text
|
<text
|
||||||
fg={
|
fg={
|
||||||
panel() === "ui"
|
panel() === "ui"
|
||||||
? themeV2.text.action.primary.focused
|
? theme.text.action.primary.focused
|
||||||
: runtime() === "high"
|
: runtime() === "high"
|
||||||
? themeV2.text.feedback.error.default
|
? theme.text.feedback.error.default
|
||||||
: themeV2.text.subdued
|
: theme.text.subdued
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{statusIcon(runtime())}
|
{statusIcon(runtime())}
|
||||||
@@ -290,10 +290,10 @@ export function DevToolsBar() {
|
|||||||
<text
|
<text
|
||||||
fg={
|
fg={
|
||||||
panel() === "ui"
|
panel() === "ui"
|
||||||
? themeV2.text.action.primary.focused
|
? theme.text.action.primary.focused
|
||||||
: runtime() === "high"
|
: runtime() === "high"
|
||||||
? themeV2.text.feedback.error.default
|
? theme.text.feedback.error.default
|
||||||
: themeV2.text.subdued
|
: theme.text.subdued
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{" "}
|
{" "}
|
||||||
@@ -320,11 +320,11 @@ export function DevToolsBar() {
|
|||||||
</Show>
|
</Show>
|
||||||
</BarItem>
|
</BarItem>
|
||||||
<BarItem active={panel() === "theme"} onClick={() => toggle("theme")}>
|
<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"}>
|
<Show when={panel() === "theme"}>
|
||||||
<PanelBox>
|
<PanelBox>
|
||||||
<PanelTitle>Theme</PanelTitle>
|
<PanelTitle>Theme</PanelTitle>
|
||||||
<Row label="Name" value={theme.selected} />
|
<Row label="Name" value={themes.selected} />
|
||||||
<Row label="Mode" value={mode()} />
|
<Row label="Mode" value={mode()} />
|
||||||
<For each={themePerformance()}>{(entry) => <Row label={entry.key} value={String(entry.value)} />}</For>
|
<For each={themePerformance()}>{(entry) => <Row label={entry.key} value={String(entry.value)} />}</For>
|
||||||
<Show when={canSwitchMode()}>
|
<Show when={canSwitchMode()}>
|
||||||
@@ -336,7 +336,7 @@ export function DevToolsBar() {
|
|||||||
</Show>
|
</Show>
|
||||||
</BarItem>
|
</BarItem>
|
||||||
<BarItem active={panel() === "tools"} onClick={() => toggle("tools")}>
|
<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"}>
|
<Show when={panel() === "tools"}>
|
||||||
<PanelBox>
|
<PanelBox>
|
||||||
<PanelTitle>Tools</PanelTitle>
|
<PanelTitle>Tools</PanelTitle>
|
||||||
@@ -406,14 +406,14 @@ export function DevToolsBar() {
|
|||||||
</Show>
|
</Show>
|
||||||
</BarItem>
|
</BarItem>
|
||||||
<box flexGrow={1} minWidth={0}>
|
<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>
|
||||||
</box>
|
</box>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function BarItem(props: ParentProps<{ active: boolean; onClick: () => void }>) {
|
function BarItem(props: ParentProps<{ active: boolean; onClick: () => void }>) {
|
||||||
const { themeV2 } = useTheme()
|
const theme = useTheme()
|
||||||
const renderer = useRenderer()
|
const renderer = useRenderer()
|
||||||
return (
|
return (
|
||||||
<box
|
<box
|
||||||
@@ -423,7 +423,7 @@ function BarItem(props: ParentProps<{ active: boolean; onClick: () => void }>) {
|
|||||||
flexDirection="row"
|
flexDirection="row"
|
||||||
paddingLeft={1}
|
paddingLeft={1}
|
||||||
paddingRight={1}
|
paddingRight={1}
|
||||||
backgroundColor={props.active ? themeV2.background.action.primary.focused : undefined}
|
backgroundColor={props.active ? theme.background.action.primary.focused : undefined}
|
||||||
onMouseUp={() => {
|
onMouseUp={() => {
|
||||||
if (renderer.getSelection()?.getSelectedText()) return
|
if (renderer.getSelection()?.getSelectedText()) return
|
||||||
props.onClick()
|
props.onClick()
|
||||||
@@ -435,7 +435,7 @@ function BarItem(props: ParentProps<{ active: boolean; onClick: () => void }>) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function PanelBox(props: ParentProps) {
|
function PanelBox(props: ParentProps) {
|
||||||
const { themeV2 } = useTheme().contextual("elevated")
|
const theme = useThemes().contextual("elevated")
|
||||||
const renderer = useRenderer()
|
const renderer = useRenderer()
|
||||||
return (
|
return (
|
||||||
<box
|
<box
|
||||||
@@ -448,7 +448,7 @@ function PanelBox(props: ParentProps) {
|
|||||||
paddingRight={2}
|
paddingRight={2}
|
||||||
paddingTop={1}
|
paddingTop={1}
|
||||||
paddingBottom={1}
|
paddingBottom={1}
|
||||||
backgroundColor={themeV2.background.default}
|
backgroundColor={theme.background.default}
|
||||||
flexDirection="column"
|
flexDirection="column"
|
||||||
onMouseUp={(event) => {
|
onMouseUp={(event) => {
|
||||||
if (renderer.getSelection()?.getSelectedText()) return
|
if (renderer.getSelection()?.getSelectedText()) return
|
||||||
@@ -461,32 +461,32 @@ function PanelBox(props: ParentProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function PanelTitle(props: ParentProps) {
|
function PanelTitle(props: ParentProps) {
|
||||||
const { themeV2 } = useTheme().contextual("elevated")
|
const theme = useThemes().contextual("elevated")
|
||||||
return (
|
return (
|
||||||
<text fg={themeV2.text.default} attributes={TextAttributes.BOLD} marginBottom={1}>
|
<text fg={theme.text.default} attributes={TextAttributes.BOLD} marginBottom={1}>
|
||||||
{props.children}
|
{props.children}
|
||||||
</text>
|
</text>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function Row(props: { label: string; value: string }) {
|
function Row(props: { label: string; value: string }) {
|
||||||
const { themeV2 } = useTheme().contextual("elevated")
|
const theme = useThemes().contextual("elevated")
|
||||||
return (
|
return (
|
||||||
<box flexDirection="row">
|
<box flexDirection="row">
|
||||||
<text fg={themeV2.text.subdued}>{props.label}</text>
|
<text fg={theme.text.subdued}>{props.label}</text>
|
||||||
<box flexGrow={1} />
|
<box flexGrow={1} />
|
||||||
<text fg={themeV2.text.default}>{props.value}</text>
|
<text fg={theme.text.default}>{props.value}</text>
|
||||||
</box>
|
</box>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function Action(props: ParentProps<{ onClick: () => void; disabled?: boolean; hoverBackground?: boolean }>) {
|
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)
|
const [hovered, setHovered] = createSignal(false)
|
||||||
return (
|
return (
|
||||||
<box
|
<box
|
||||||
backgroundColor={
|
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)}
|
onMouseOver={() => setHovered(true)}
|
||||||
onMouseOut={() => setHovered(false)}
|
onMouseOut={() => setHovered(false)}
|
||||||
@@ -495,7 +495,7 @@ function Action(props: ParentProps<{ onClick: () => void; disabled?: boolean; ho
|
|||||||
if (!props.disabled) props.onClick()
|
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>
|
</box>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -506,7 +506,7 @@ function cpuPercent(microseconds: number, milliseconds: number) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function ProcessStat(props: { label: string; values: readonly number[]; unit: string; decimals?: 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 = () => {
|
||||||
const value = props.values.at(-1)
|
const value = props.values.at(-1)
|
||||||
if (value === undefined) return "--"
|
if (value === undefined) return "--"
|
||||||
@@ -515,13 +515,13 @@ function ProcessStat(props: { label: string; values: readonly number[]; unit: st
|
|||||||
return (
|
return (
|
||||||
<box flexDirection="row">
|
<box flexDirection="row">
|
||||||
<box width={7}>
|
<box width={7}>
|
||||||
<text fg={themeV2.text.subdued}>{props.label}</text>
|
<text fg={theme.text.subdued}>{props.label}</text>
|
||||||
</box>
|
</box>
|
||||||
<box flexGrow={1}>
|
<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>
|
||||||
<box width={8} alignItems="flex-end">
|
<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>
|
||||||
</box>
|
</box>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { createMemo, createSignal } from "solid-js"
|
import { createMemo, createSignal } from "solid-js"
|
||||||
import { useConfig } from "../config"
|
import { useConfig } from "../config"
|
||||||
import { useTheme } from "../context/theme"
|
import { useThemes } from "../context/theme"
|
||||||
import { DialogSelect } from "../ui/dialog-select"
|
import { DialogSelect } from "../ui/dialog-select"
|
||||||
import { useToast } from "../ui/toast"
|
import { useToast } from "../ui/toast"
|
||||||
|
|
||||||
@@ -93,6 +93,14 @@ export const settings: Setting[] = [
|
|||||||
values: ["none", "auto"],
|
values: ["none", "auto"],
|
||||||
keywords: ["transcript", "messages"],
|
keywords: ["transcript", "messages"],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: "Enabled",
|
||||||
|
category: "Tabs",
|
||||||
|
path: ["tabs", "enabled"],
|
||||||
|
default: false,
|
||||||
|
values: [false, true],
|
||||||
|
labels: ["off", "on"],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: "Layout",
|
title: "Layout",
|
||||||
category: "Diffs",
|
category: "Diffs",
|
||||||
@@ -267,7 +275,7 @@ export function settingID(setting: Setting) {
|
|||||||
export function DialogConfig(props: { current?: string }) {
|
export function DialogConfig(props: { current?: string }) {
|
||||||
const config = useConfig()
|
const config = useConfig()
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
const themeState = useTheme()
|
const themes = useThemes()
|
||||||
const current = Math.max(
|
const current = Math.max(
|
||||||
0,
|
0,
|
||||||
settings.findIndex((setting) => settingID(setting) === props.current),
|
settings.findIndex((setting) => settingID(setting) === props.current),
|
||||||
@@ -280,12 +288,12 @@ export function DialogConfig(props: { current?: string }) {
|
|||||||
if (!result || typeof result !== "object") return undefined
|
if (!result || typeof result !== "object") return undefined
|
||||||
return (result as Record<string, unknown>)[key]
|
return (result as Record<string, unknown>)[key]
|
||||||
}, config.data)
|
}, 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
|
return current ?? setting.default
|
||||||
}
|
}
|
||||||
const values = (setting: Setting) =>
|
const values = (setting: Setting) =>
|
||||||
setting.path.join(".") === "theme.name"
|
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
|
: setting.values
|
||||||
const display = (setting: Setting) => {
|
const display = (setting: Setting) => {
|
||||||
const current = value(setting)
|
const current = value(setting)
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import { describeOS, describeTerminal } from "../util/system"
|
|||||||
import { useTuiApp } from "../context/runtime"
|
import { useTuiApp } from "../context/runtime"
|
||||||
|
|
||||||
export function DialogDebug() {
|
export function DialogDebug() {
|
||||||
const { themeV2 } = useTheme()
|
const theme = useTheme()
|
||||||
const dialog = useDialog()
|
const dialog = useDialog()
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const local = useLocal()
|
const local = useLocal()
|
||||||
@@ -55,10 +55,10 @@ export function DialogDebug() {
|
|||||||
return (
|
return (
|
||||||
<box paddingLeft={2} paddingRight={2} gap={1} paddingBottom={1}>
|
<box paddingLeft={2} paddingRight={2} gap={1} paddingBottom={1}>
|
||||||
<box flexDirection="row" justifyContent="space-between">
|
<box flexDirection="row" justifyContent="space-between">
|
||||||
<text fg={themeV2.text.default} attributes={TextAttributes.BOLD}>
|
<text fg={theme.text.default} attributes={TextAttributes.BOLD}>
|
||||||
Debug
|
Debug
|
||||||
</text>
|
</text>
|
||||||
<text fg={themeV2.text.subdued} onMouseUp={() => dialog.clear()}>
|
<text fg={theme.text.subdued} onMouseUp={() => dialog.clear()}>
|
||||||
esc
|
esc
|
||||||
</text>
|
</text>
|
||||||
</box>
|
</box>
|
||||||
@@ -68,10 +68,10 @@ export function DialogDebug() {
|
|||||||
<For each={entries()}>
|
<For each={entries()}>
|
||||||
{(entry) => (
|
{(entry) => (
|
||||||
<box flexDirection="row" gap={1}>
|
<box flexDirection="row" gap={1}>
|
||||||
<text flexShrink={0} fg={themeV2.text.subdued}>
|
<text flexShrink={0} fg={theme.text.subdued}>
|
||||||
{entry.label.padEnd(10)}
|
{entry.label.padEnd(10)}
|
||||||
</text>
|
</text>
|
||||||
<text fg={themeV2.text.default} wrapMode="word">
|
<text fg={theme.text.default} wrapMode="word">
|
||||||
{entry.value}
|
{entry.value}
|
||||||
</text>
|
</text>
|
||||||
</box>
|
</box>
|
||||||
@@ -79,12 +79,12 @@ export function DialogDebug() {
|
|||||||
</For>
|
</For>
|
||||||
</box>
|
</box>
|
||||||
<box flexDirection="row" justifyContent="space-between">
|
<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}>
|
<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>{" "}
|
<b>{copied() ? "✓ copied" : "copy"}</b>{" "}
|
||||||
</span>
|
</span>
|
||||||
<span style={{ fg: themeV2.text.subdued }}>enter</span>
|
<span style={{ fg: theme.text.subdued }}>enter</span>
|
||||||
</text>
|
</text>
|
||||||
</box>
|
</box>
|
||||||
</box>
|
</box>
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import { useClipboard } from "../context/clipboard"
|
|||||||
import { useData } from "../context/data"
|
import { useData } from "../context/data"
|
||||||
import { useClient } from "../context/client"
|
import { useClient } from "../context/client"
|
||||||
import { Keymap } from "../context/keymap"
|
import { Keymap } from "../context/keymap"
|
||||||
import { useTheme } from "../context/theme"
|
import { useThemes } from "../context/theme"
|
||||||
import { useDialog } from "../ui/dialog"
|
import { useDialog } from "../ui/dialog"
|
||||||
import { DialogPrompt } from "../ui/dialog-prompt"
|
import { DialogPrompt } from "../ui/dialog-prompt"
|
||||||
import { DialogSelect } from "../ui/dialog-select"
|
import { DialogSelect } from "../ui/dialog-select"
|
||||||
@@ -64,7 +64,7 @@ export function DialogIntegration(
|
|||||||
) {
|
) {
|
||||||
const data = useData()
|
const data = useData()
|
||||||
const dialog = useDialog()
|
const dialog = useDialog()
|
||||||
const { themeV2 } = useTheme().contextual("elevated")
|
const theme = useThemes().contextual("elevated")
|
||||||
const options = createMemo(() => {
|
const options = createMemo(() => {
|
||||||
const providers = data.location.websearch.list() ?? []
|
const providers = data.location.websearch.list() ?? []
|
||||||
const providersByID = new Map(providers.map((provider) => [provider.id, provider]))
|
const providersByID = new Map(providers.map((provider) => [provider.id, provider]))
|
||||||
@@ -87,7 +87,7 @@ export function DialogIntegration(
|
|||||||
disabled: methods.length === 0 && credentials.length === 0,
|
disabled: methods.length === 0 && credentials.length === 0,
|
||||||
gutter:
|
gutter:
|
||||||
integration.connections.length > 0
|
integration.connections.length > 0
|
||||||
? () => <text fg={themeV2.text.feedback.success.default}>✓</text>
|
? () => <text fg={theme.text.feedback.success.default}>✓</text>
|
||||||
: undefined,
|
: undefined,
|
||||||
onSelect: () => {
|
onSelect: () => {
|
||||||
if (credentials.length) return manageConnections(integration, methods, dialog, props.onConnected)
|
if (credentials.length) return manageConnections(integration, methods, dialog, props.onConnected)
|
||||||
@@ -103,12 +103,12 @@ export function DialogIntegration(
|
|||||||
options={options()}
|
options={options()}
|
||||||
emptyView={
|
emptyView={
|
||||||
<box paddingLeft={4} paddingRight={4} paddingTop={1}>
|
<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>
|
</box>
|
||||||
}
|
}
|
||||||
noMatchView={
|
noMatchView={
|
||||||
<box paddingLeft={4} paddingRight={4} paddingTop={1}>
|
<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>
|
</box>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
@@ -303,16 +303,16 @@ function CommandPending(props: {
|
|||||||
|
|
||||||
function CommandView(props: { title: string; output: string; message: string }) {
|
function CommandView(props: { title: string; output: string; message: string }) {
|
||||||
const dialog = useDialog()
|
const dialog = useDialog()
|
||||||
const { themeV2 } = useTheme().contextual("elevated")
|
const theme = useThemes().contextual("elevated")
|
||||||
const { themeV2: overlayTheme } = useTheme().contextual("overlay")
|
const overlayTheme = useThemes().contextual("overlay")
|
||||||
onMount(() => dialog.setSize("large"))
|
onMount(() => dialog.setSize("large"))
|
||||||
return (
|
return (
|
||||||
<box gap={1} paddingBottom={1}>
|
<box gap={1} paddingBottom={1}>
|
||||||
<box flexDirection="row" justifyContent="space-between" paddingLeft={2} paddingRight={2}>
|
<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}
|
{props.title}
|
||||||
</text>
|
</text>
|
||||||
<text fg={themeV2.text.subdued} onMouseUp={() => dialog.clear()}>
|
<text fg={theme.text.subdued} onMouseUp={() => dialog.clear()}>
|
||||||
esc close
|
esc close
|
||||||
</text>
|
</text>
|
||||||
</box>
|
</box>
|
||||||
@@ -326,7 +326,7 @@ function CommandView(props: { title: string; output: string; message: string })
|
|||||||
<text fg={overlayTheme.text.default}>{props.output.trim()}</text>
|
<text fg={overlayTheme.text.default}>{props.output.trim()}</text>
|
||||||
</box>
|
</box>
|
||||||
<box paddingLeft={2} paddingRight={2}>
|
<box paddingLeft={2} paddingRight={2}>
|
||||||
<text fg={themeV2.text.subdued}>{props.message}</text>
|
<text fg={theme.text.subdued}>{props.message}</text>
|
||||||
</box>
|
</box>
|
||||||
</box>
|
</box>
|
||||||
)
|
)
|
||||||
@@ -341,7 +341,7 @@ function KeyMethod(props: {
|
|||||||
const dialog = useDialog()
|
const dialog = useDialog()
|
||||||
const client = useClient()
|
const client = useClient()
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
const { themeV2 } = useTheme().contextual("elevated")
|
const theme = useThemes().contextual("elevated")
|
||||||
const [error, setError] = createSignal<string>()
|
const [error, setError] = createSignal<string>()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -360,7 +360,7 @@ function KeyMethod(props: {
|
|||||||
.catch((cause) => setError(message(cause)))
|
.catch((cause) => setError(message(cause)))
|
||||||
}}
|
}}
|
||||||
description={() => (
|
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 dialog = useDialog()
|
||||||
const client = useClient()
|
const client = useClient()
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
const { themeV2 } = useTheme().contextual("elevated")
|
const theme = useThemes().contextual("elevated")
|
||||||
const [error, setError] = createSignal<string>()
|
const [error, setError] = createSignal<string>()
|
||||||
let settled = false
|
let settled = false
|
||||||
|
|
||||||
@@ -550,9 +550,9 @@ function OAuthCode(props: {
|
|||||||
}}
|
}}
|
||||||
description={() => (
|
description={() => (
|
||||||
<box gap={1}>
|
<box gap={1}>
|
||||||
<text fg={themeV2.text.subdued}>{props.attempt.instructions}</text>
|
<text fg={theme.text.subdued}>{props.attempt.instructions}</text>
|
||||||
<Link href={props.attempt.url} fg={themeV2.markdown.link} />
|
<Link href={props.attempt.url} fg={theme.markdown.link} />
|
||||||
<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>
|
||||||
</box>
|
</box>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
@@ -561,31 +561,31 @@ function OAuthCode(props: {
|
|||||||
|
|
||||||
function OAuthView(props: { title: string; url?: string; instructions?: string; message: string; copy?: boolean }) {
|
function OAuthView(props: { title: string; url?: string; instructions?: string; message: string; copy?: boolean }) {
|
||||||
const dialog = useDialog()
|
const dialog = useDialog()
|
||||||
const { themeV2 } = useTheme().contextual("elevated")
|
const theme = useThemes().contextual("elevated")
|
||||||
return (
|
return (
|
||||||
<box paddingLeft={2} paddingRight={2} gap={1} paddingBottom={1}>
|
<box paddingLeft={2} paddingRight={2} gap={1} paddingBottom={1}>
|
||||||
<box flexDirection="row" justifyContent="space-between">
|
<box flexDirection="row" justifyContent="space-between">
|
||||||
<text attributes={TextAttributes.BOLD} fg={themeV2.text.default}>
|
<text attributes={TextAttributes.BOLD} fg={theme.text.default}>
|
||||||
{props.title}
|
{props.title}
|
||||||
</text>
|
</text>
|
||||||
<text fg={themeV2.text.subdued} onMouseUp={() => dialog.clear()}>
|
<text fg={theme.text.subdued} onMouseUp={() => dialog.clear()}>
|
||||||
esc
|
esc
|
||||||
</text>
|
</text>
|
||||||
</box>
|
</box>
|
||||||
<Show when={props.url}>
|
<Show when={props.url}>
|
||||||
{(url) => (
|
{(url) => (
|
||||||
<box gap={1}>
|
<box gap={1}>
|
||||||
<Link href={url()} fg={themeV2.markdown.link} />
|
<Link href={url()} fg={theme.markdown.link} />
|
||||||
<Show when={props.instructions}>
|
<Show when={props.instructions}>
|
||||||
{(instructions) => <text fg={themeV2.text.subdued}>{instructions()}</text>}
|
{(instructions) => <text fg={theme.text.subdued}>{instructions()}</text>}
|
||||||
</Show>
|
</Show>
|
||||||
</box>
|
</box>
|
||||||
)}
|
)}
|
||||||
</Show>
|
</Show>
|
||||||
<text fg={themeV2.text.subdued}>{props.message}</text>
|
<text fg={theme.text.subdued}>{props.message}</text>
|
||||||
<Show when={props.copy}>
|
<Show when={props.copy}>
|
||||||
<text fg={themeV2.text.default}>
|
<text fg={theme.text.default}>
|
||||||
c <span style={{ fg: themeV2.text.subdued }}>copy</span>
|
c <span style={{ fg: theme.text.subdued }}>copy</span>
|
||||||
</text>
|
</text>
|
||||||
</Show>
|
</Show>
|
||||||
</box>
|
</box>
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { Keymap } from "../context/keymap"
|
|||||||
import { pipe, sortBy } from "remeda"
|
import { pipe, sortBy } from "remeda"
|
||||||
import { DialogSelect } from "../ui/dialog-select"
|
import { DialogSelect } from "../ui/dialog-select"
|
||||||
import { useDialog } from "../ui/dialog"
|
import { useDialog } from "../ui/dialog"
|
||||||
import { useTheme } from "../context/theme"
|
import { useThemes } from "../context/theme"
|
||||||
import { TextAttributes, type ScrollBoxRenderable } from "@opentui/core"
|
import { TextAttributes, type ScrollBoxRenderable } from "@opentui/core"
|
||||||
import type { McpServer } from "@opencode-ai/client"
|
import type { McpServer } from "@opencode-ai/client"
|
||||||
import { useClipboard } from "../context/clipboard"
|
import { useClipboard } from "../context/clipboard"
|
||||||
@@ -20,12 +20,12 @@ function statusError(status: McpServer["status"]) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function Status(props: { enabled: boolean; loading: boolean }) {
|
function Status(props: { enabled: boolean; loading: boolean }) {
|
||||||
const { themeV2 } = useTheme().contextual("elevated")
|
const theme = useThemes().contextual("elevated")
|
||||||
if (props.loading) return <span style={{ fg: themeV2.text.subdued }}>⋯ Loading</span>
|
if (props.loading) return <span style={{ fg: theme.text.subdued }}>⋯ Loading</span>
|
||||||
if (props.enabled) {
|
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() {
|
export function DialogMcp() {
|
||||||
@@ -33,7 +33,7 @@ export function DialogMcp() {
|
|||||||
const dialog = useDialog()
|
const dialog = useDialog()
|
||||||
const client = useClient()
|
const client = useClient()
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
const { themeV2 } = useTheme().contextual("elevated")
|
const theme = useThemes().contextual("elevated")
|
||||||
const [focused, setFocused] = createSignal<string>()
|
const [focused, setFocused] = createSignal<string>()
|
||||||
const [detail, setDetail] = createSignal<McpServer>()
|
const [detail, setDetail] = createSignal<McpServer>()
|
||||||
const [loading, setLoading] = createSignal<string | null>(null)
|
const [loading, setLoading] = createSignal<string | null>(null)
|
||||||
@@ -110,7 +110,7 @@ export function DialogMcp() {
|
|||||||
]}
|
]}
|
||||||
footer={
|
footer={
|
||||||
<Show when={focusedError()}>
|
<Show when={focusedError()}>
|
||||||
<text fg={themeV2.text.subdued}>enter to view error</text>
|
<text fg={theme.text.subdued}>enter to view error</text>
|
||||||
</Show>
|
</Show>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
@@ -134,8 +134,8 @@ function DialogMcpError(props: { server: McpServer; onBack: () => void }) {
|
|||||||
const dialog = useDialog()
|
const dialog = useDialog()
|
||||||
const clipboard = useClipboard()
|
const clipboard = useClipboard()
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
const { themeV2 } = useTheme().contextual("elevated")
|
const theme = useThemes().contextual("elevated")
|
||||||
const { themeV2: overlayTheme } = useTheme().contextual("overlay")
|
const overlayTheme = useThemes().contextual("overlay")
|
||||||
const dimensions = useTerminalDimensions()
|
const dimensions = useTerminalDimensions()
|
||||||
const config = useConfig().data
|
const config = useConfig().data
|
||||||
const [copied, setCopied] = createSignal(false)
|
const [copied, setCopied] = createSignal(false)
|
||||||
@@ -171,14 +171,14 @@ function DialogMcpError(props: { server: McpServer; onBack: () => void }) {
|
|||||||
return (
|
return (
|
||||||
<box paddingLeft={4} paddingRight={4} paddingBottom={1} gap={1}>
|
<box paddingLeft={4} paddingRight={4} paddingBottom={1} gap={1}>
|
||||||
<box flexDirection="row" justifyContent="space-between">
|
<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}
|
MCP server: {props.server.name}
|
||||||
</text>
|
</text>
|
||||||
<text fg={themeV2.text.subdued} onMouseUp={props.onBack}>
|
<text fg={theme.text.subdued} onMouseUp={props.onBack}>
|
||||||
esc back
|
esc back
|
||||||
</text>
|
</text>
|
||||||
</box>
|
</box>
|
||||||
<text fg={themeV2.text.feedback.error.default}>✗ Failed</text>
|
<text fg={theme.text.feedback.error.default}>✗ Failed</text>
|
||||||
<box
|
<box
|
||||||
backgroundColor={overlayTheme.background.default}
|
backgroundColor={overlayTheme.background.default}
|
||||||
paddingLeft={2}
|
paddingLeft={2}
|
||||||
@@ -198,8 +198,8 @@ function DialogMcpError(props: { server: McpServer; onBack: () => void }) {
|
|||||||
</scrollbox>
|
</scrollbox>
|
||||||
</box>
|
</box>
|
||||||
<box flexDirection="row" justifyContent="space-between">
|
<box flexDirection="row" justifyContent="space-between">
|
||||||
<text fg={themeV2.text.subdued}>↑↓ scroll</text>
|
<text fg={theme.text.subdued}>↑↓ scroll</text>
|
||||||
<text fg={themeV2.text.subdued} onMouseUp={copy}>
|
<text fg={theme.text.subdued} onMouseUp={copy}>
|
||||||
{copied() ? "✓ copied" : "c copy details"}
|
{copied() ? "✓ copied" : "c copy details"}
|
||||||
</text>
|
</text>
|
||||||
</box>
|
</box>
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { DialogSelect, type DialogSelectOption } from "../ui/dialog-select"
|
|||||||
import { useDialog } from "../ui/dialog"
|
import { useDialog } from "../ui/dialog"
|
||||||
import { useClient } from "../context/client"
|
import { useClient } from "../context/client"
|
||||||
import { Keymap } from "../context/keymap"
|
import { Keymap } from "../context/keymap"
|
||||||
import { useTheme } from "../context/theme"
|
import { useThemes } from "../context/theme"
|
||||||
import { useData } from "../context/data"
|
import { useData } from "../context/data"
|
||||||
import { abbreviateHome } from "../runtime"
|
import { abbreviateHome } from "../runtime"
|
||||||
import { useTuiPaths } from "../context/runtime"
|
import { useTuiPaths } from "../context/runtime"
|
||||||
@@ -38,7 +38,7 @@ export function DialogMoveSession(props: DialogMoveSessionProps) {
|
|||||||
const dialog = useDialog()
|
const dialog = useDialog()
|
||||||
const client = useClient()
|
const client = useClient()
|
||||||
const dimensions = useTerminalDimensions()
|
const dimensions = useTerminalDimensions()
|
||||||
const { themeV2 } = useTheme().contextual("elevated")
|
const theme = useThemes().contextual("elevated")
|
||||||
const sessionData = useData()
|
const sessionData = useData()
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
@@ -172,18 +172,18 @@ export function DialogMoveSession(props: DialogMoveSessionProps) {
|
|||||||
return {
|
return {
|
||||||
title,
|
title,
|
||||||
titleView: isRemoving ? (
|
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 ? (
|
) : 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
|
Press {shortcuts.get("dialog.move_session.delete")} again to confirm
|
||||||
</span>
|
</span>
|
||||||
) : suffix ? (
|
) : suffix ? (
|
||||||
<>
|
<>
|
||||||
{visible.slice(0, split)}
|
{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,
|
) : undefined,
|
||||||
bg: deleting ? themeV2.background.action.destructive.default : undefined,
|
bg: deleting ? theme.background.action.destructive.default : undefined,
|
||||||
value: {
|
value: {
|
||||||
type: "directory",
|
type: "directory",
|
||||||
directory: item.location,
|
directory: item.location,
|
||||||
@@ -316,7 +316,7 @@ export function DialogMoveSession(props: DialogMoveSessionProps) {
|
|||||||
title="Move session"
|
title="Move session"
|
||||||
titleView={
|
titleView={
|
||||||
<box flexDirection="row" gap={1}>
|
<box flexDirection="row" gap={1}>
|
||||||
<text fg={themeV2.text.default} attributes={TextAttributes.BOLD}>
|
<text fg={theme.text.default} attributes={TextAttributes.BOLD}>
|
||||||
Move session
|
Move session
|
||||||
</text>
|
</text>
|
||||||
<Show when={working() || directories.loading || loadedProject.loading}>
|
<Show when={working() || directories.loading || loadedProject.loading}>
|
||||||
@@ -329,25 +329,25 @@ export function DialogMoveSession(props: DialogMoveSessionProps) {
|
|||||||
emptyView={
|
emptyView={
|
||||||
showError() ? (
|
showError() ? (
|
||||||
<box paddingLeft={4} paddingRight={4} paddingTop={1}>
|
<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
|
Could not load project directories
|
||||||
</text>
|
</text>
|
||||||
<text fg={themeV2.text.subdued}>{errorMessage(loadError())}</text>
|
<text fg={theme.text.subdued}>{errorMessage(loadError())}</text>
|
||||||
<text fg={themeV2.text.subdued}>Close and reopen Move session to try again.</text>
|
<text fg={theme.text.subdued}>Close and reopen Move session to try again.</text>
|
||||||
</box>
|
</box>
|
||||||
) : directories.loading || loadedProject.loading ? (
|
) : directories.loading || loadedProject.loading ? (
|
||||||
<box paddingLeft={4} paddingRight={4} paddingTop={1}>
|
<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>
|
||||||
) : (
|
) : (
|
||||||
<box paddingLeft={4} paddingRight={4} paddingTop={1}>
|
<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>
|
</box>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
noMatchView={
|
noMatchView={
|
||||||
<box paddingLeft={4} paddingRight={4} paddingTop={1}>
|
<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>
|
</box>
|
||||||
}
|
}
|
||||||
locked={showError() || directories.loading || loadedProject.loading || Boolean(removing())}
|
locked={showError() || directories.loading || loadedProject.loading || Boolean(removing())}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { useTerminalDimensions } from "@opentui/solid"
|
|||||||
import { createMemo, createResource, createSignal, For, Show } from "solid-js"
|
import { createMemo, createResource, createSignal, For, Show } from "solid-js"
|
||||||
import { renderUnicodeCompact } from "uqr"
|
import { renderUnicodeCompact } from "uqr"
|
||||||
import { useClient } from "../context/client"
|
import { useClient } from "../context/client"
|
||||||
import { useTheme } from "../context/theme"
|
import { useThemes } from "../context/theme"
|
||||||
import { useDialog } from "../ui/dialog"
|
import { useDialog } from "../ui/dialog"
|
||||||
import { errorMessage } from "../util/error"
|
import { errorMessage } from "../util/error"
|
||||||
|
|
||||||
@@ -16,7 +16,7 @@ export function DialogPair(props: { credentials?: DialogPairCredentials }) {
|
|||||||
const client = useClient()
|
const client = useClient()
|
||||||
const dialog = useDialog()
|
const dialog = useDialog()
|
||||||
const dimensions = useTerminalDimensions()
|
const dimensions = useTerminalDimensions()
|
||||||
const { themeV2 } = useTheme().contextual("elevated")
|
const theme = useThemes().contextual("elevated")
|
||||||
const [loadError, setLoadError] = createSignal<unknown>()
|
const [loadError, setLoadError] = createSignal<unknown>()
|
||||||
const [showPassword, setShowPassword] = createSignal(false)
|
const [showPassword, setShowPassword] = createSignal(false)
|
||||||
const [passwordHover, setPasswordHover] = 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 flexDirection={horizontal() ? "row" : "column"} alignItems={horizontal() ? "flex-start" : "center"} gap={2}>
|
||||||
<box width={horizontal() ? 29 : "100%"} flexShrink={0} gap={1}>
|
<box width={horizontal() ? 29 : "100%"} flexShrink={0} gap={1}>
|
||||||
<box>
|
<box>
|
||||||
<text fg={themeV2.text.subdued}>URLs</text>
|
<text fg={theme.text.subdued}>URLs</text>
|
||||||
<For each={value.urls}>{(url) => <text fg={themeV2.text.default}>{url}</text>}</For>
|
<For each={value.urls}>{(url) => <text fg={theme.text.default}>{url}</text>}</For>
|
||||||
</box>
|
</box>
|
||||||
<box>
|
<box>
|
||||||
<text fg={themeV2.text.subdued}>Username</text>
|
<text fg={theme.text.subdued}>Username</text>
|
||||||
<text fg={themeV2.text.default}>{value.username}</text>
|
<text fg={theme.text.default}>{value.username}</text>
|
||||||
</box>
|
</box>
|
||||||
<box>
|
<box>
|
||||||
<text fg={themeV2.text.subdued}>Password</text>
|
<text fg={theme.text.subdued}>Password</text>
|
||||||
<text
|
<text
|
||||||
fg={passwordHover() ? themeV2.text.default : themeV2.text.subdued}
|
fg={passwordHover() ? theme.text.default : theme.text.subdued}
|
||||||
wrapMode="word"
|
wrapMode="word"
|
||||||
onMouseOver={() => setPasswordHover(true)}
|
onMouseOver={() => setPasswordHover(true)}
|
||||||
onMouseOut={() => setPasswordHover(false)}
|
onMouseOut={() => setPasswordHover(false)}
|
||||||
@@ -67,7 +67,7 @@ export function DialogPair(props: { credentials?: DialogPairCredentials }) {
|
|||||||
</text>
|
</text>
|
||||||
</box>
|
</box>
|
||||||
<Show when={value.urls.some((url) => ["localhost", "127.0.0.1", "[::1]"].includes(new URL(url).hostname))}>
|
<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.
|
Run `opencode service set hostname 0.0.0.0` to access the service remotely.
|
||||||
</text>
|
</text>
|
||||||
</Show>
|
</Show>
|
||||||
@@ -78,7 +78,7 @@ export function DialogPair(props: { credentials?: DialogPairCredentials }) {
|
|||||||
flexShrink={0}
|
flexShrink={0}
|
||||||
alignItems={horizontal() ? "flex-end" : "center"}
|
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>
|
||||||
</box>
|
</box>
|
||||||
)
|
)
|
||||||
@@ -87,17 +87,17 @@ export function DialogPair(props: { credentials?: DialogPairCredentials }) {
|
|||||||
return (
|
return (
|
||||||
<box paddingLeft={2} paddingRight={2} paddingBottom={1} gap={1}>
|
<box paddingLeft={2} paddingRight={2} paddingBottom={1} gap={1}>
|
||||||
<box flexDirection="row" justifyContent="space-between">
|
<box flexDirection="row" justifyContent="space-between">
|
||||||
<text fg={themeV2.text.default} attributes={TextAttributes.BOLD}>
|
<text fg={theme.text.default} attributes={TextAttributes.BOLD}>
|
||||||
Pair
|
Pair
|
||||||
</text>
|
</text>
|
||||||
<text fg={themeV2.text.subdued} onMouseUp={() => dialog.clear()}>
|
<text fg={theme.text.subdued} onMouseUp={() => dialog.clear()}>
|
||||||
esc
|
esc
|
||||||
</text>
|
</text>
|
||||||
</box>
|
</box>
|
||||||
<Show
|
<Show
|
||||||
when={loadError()}
|
when={loadError()}
|
||||||
fallback={
|
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
|
<Show
|
||||||
when={dimensions().height >= 36}
|
when={dimensions().height >= 36}
|
||||||
fallback={
|
fallback={
|
||||||
@@ -116,11 +116,11 @@ export function DialogPair(props: { credentials?: DialogPairCredentials }) {
|
|||||||
>
|
>
|
||||||
{(error) => (
|
{(error) => (
|
||||||
<box>
|
<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
|
Could not load server information
|
||||||
</text>
|
</text>
|
||||||
<text fg={themeV2.text.subdued}>{errorMessage(error())}</text>
|
<text fg={theme.text.subdued}>{errorMessage(error())}</text>
|
||||||
<text fg={themeV2.text.subdued}>Close and reopen Pair to try again.</text>
|
<text fg={theme.text.subdued}>Close and reopen Pair to try again.</text>
|
||||||
</box>
|
</box>
|
||||||
)}
|
)}
|
||||||
</Show>
|
</Show>
|
||||||
|
|||||||
@@ -2,12 +2,12 @@ import { InputRenderable, TextAttributes } from "@opentui/core"
|
|||||||
import { Slug } from "@opencode-ai/core/util/slug"
|
import { Slug } from "@opencode-ai/core/util/slug"
|
||||||
import { createSignal, onMount } from "solid-js"
|
import { createSignal, onMount } from "solid-js"
|
||||||
import { Keymap } from "../context/keymap"
|
import { Keymap } from "../context/keymap"
|
||||||
import { useTheme } from "../context/theme"
|
import { useThemes } from "../context/theme"
|
||||||
import { useDialog, type DialogContext } from "../ui/dialog"
|
import { useDialog, type DialogContext } from "../ui/dialog"
|
||||||
|
|
||||||
export function DialogProjectCopyName(props: { onConfirm: (name: string) => void }) {
|
export function DialogProjectCopyName(props: { onConfirm: (name: string) => void }) {
|
||||||
const dialog = useDialog()
|
const dialog = useDialog()
|
||||||
const { themeV2 } = useTheme().contextual("elevated")
|
const theme = useThemes().contextual("elevated")
|
||||||
const shortcuts = Keymap.useShortcuts()
|
const shortcuts = Keymap.useShortcuts()
|
||||||
const [inputTarget, setInputTarget] = createSignal<InputRenderable>()
|
const [inputTarget, setInputTarget] = createSignal<InputRenderable>()
|
||||||
let input: InputRenderable
|
let input: InputRenderable
|
||||||
@@ -47,10 +47,10 @@ export function DialogProjectCopyName(props: { onConfirm: (name: string) => void
|
|||||||
return (
|
return (
|
||||||
<box paddingLeft={2} paddingRight={2} gap={1}>
|
<box paddingLeft={2} paddingRight={2} gap={1}>
|
||||||
<box flexDirection="row" justifyContent="space-between">
|
<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
|
Name project copy
|
||||||
</text>
|
</text>
|
||||||
<text fg={themeV2.text.subdued} onMouseUp={() => dialog.clear()}>
|
<text fg={theme.text.subdued} onMouseUp={() => dialog.clear()}>
|
||||||
esc
|
esc
|
||||||
</text>
|
</text>
|
||||||
</box>
|
</box>
|
||||||
@@ -61,17 +61,17 @@ export function DialogProjectCopyName(props: { onConfirm: (name: string) => void
|
|||||||
}}
|
}}
|
||||||
onSubmit={confirm}
|
onSubmit={confirm}
|
||||||
placeholder="Project copy name"
|
placeholder="Project copy name"
|
||||||
placeholderColor={themeV2.text.subdued}
|
placeholderColor={theme.text.subdued}
|
||||||
textColor={themeV2.text.formfield.default}
|
textColor={theme.text.formfield.default}
|
||||||
focusedTextColor={themeV2.text.formfield.default}
|
focusedTextColor={theme.text.formfield.default}
|
||||||
cursorColor={themeV2.text.formfield.default}
|
cursorColor={theme.text.formfield.default}
|
||||||
/>
|
/>
|
||||||
<box paddingBottom={1} flexDirection="row" gap={2}>
|
<box paddingBottom={1} flexDirection="row" gap={2}>
|
||||||
<text fg={themeV2.text.default}>
|
<text fg={theme.text.default}>
|
||||||
enter <span style={{ fg: themeV2.text.subdued }}>submit</span>
|
enter <span style={{ fg: theme.text.subdued }}>submit</span>
|
||||||
</text>
|
</text>
|
||||||
<text fg={themeV2.text.default}>
|
<text fg={theme.text.default}>
|
||||||
{shortcuts.get("dialog.project_copy.generate")} <span style={{ fg: themeV2.text.subdued }}>generate one</span>
|
{shortcuts.get("dialog.project_copy.generate")} <span style={{ fg: theme.text.subdued }}>generate one</span>
|
||||||
</text>
|
</text>
|
||||||
</box>
|
</box>
|
||||||
</box>
|
</box>
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { RGBA, TextAttributes } from "@opentui/core"
|
|||||||
import open from "open"
|
import open from "open"
|
||||||
import { createSignal } from "solid-js"
|
import { createSignal } from "solid-js"
|
||||||
import { Keymap } from "../context/keymap"
|
import { Keymap } from "../context/keymap"
|
||||||
import { useTheme } from "../context/theme"
|
import { useThemes } from "../context/theme"
|
||||||
import { useDialog, type DialogContext } from "../ui/dialog"
|
import { useDialog, type DialogContext } from "../ui/dialog"
|
||||||
import { Link } from "../ui/link"
|
import { Link } from "../ui/link"
|
||||||
import { BgPulse } from "./bg-pulse"
|
import { BgPulse } from "./bg-pulse"
|
||||||
@@ -38,9 +38,9 @@ function panelOverlay(color: RGBA) {
|
|||||||
|
|
||||||
export function DialogRetryAction(props: DialogRetryActionProps) {
|
export function DialogRetryAction(props: DialogRetryActionProps) {
|
||||||
const dialog = useDialog()
|
const dialog = useDialog()
|
||||||
const { themeV2 } = useTheme().contextual("elevated")
|
const theme = useThemes().contextual("elevated")
|
||||||
const showGoTreatment = () => props.link === GO_URL
|
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")
|
const [selected, setSelected] = createSignal<"dismiss" | "action">("action")
|
||||||
|
|
||||||
Keymap.createLayer(() => ({
|
Keymap.createLayer(() => ({
|
||||||
@@ -85,26 +85,26 @@ export function DialogRetryAction(props: DialogRetryActionProps) {
|
|||||||
) : null}
|
) : null}
|
||||||
<box zIndex={1} paddingLeft={PAD_X} paddingRight={PAD_X} paddingBottom={1} gap={1}>
|
<box zIndex={1} paddingLeft={PAD_X} paddingRight={PAD_X} paddingBottom={1} gap={1}>
|
||||||
<box flexDirection="row" justifyContent="space-between">
|
<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}
|
{props.title}
|
||||||
</text>
|
</text>
|
||||||
<text fg={themeV2.text.subdued} bg={textBg()} onMouseUp={() => dialog.clear()}>
|
<text fg={theme.text.subdued} bg={textBg()} onMouseUp={() => dialog.clear()}>
|
||||||
esc
|
esc
|
||||||
</text>
|
</text>
|
||||||
</box>
|
</box>
|
||||||
<box gap={0}>
|
<box gap={0}>
|
||||||
<text fg={themeV2.text.subdued} bg={textBg()}>
|
<text fg={theme.text.subdued} bg={textBg()}>
|
||||||
{props.message}
|
{props.message}
|
||||||
</text>
|
</text>
|
||||||
</box>
|
</box>
|
||||||
{props.link ? (
|
{props.link ? (
|
||||||
showGoTreatment() ? (
|
showGoTreatment() ? (
|
||||||
<box alignItems="center" justifyContent="flex-end" height={7} paddingBottom={1}>
|
<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>
|
||||||
) : (
|
) : (
|
||||||
<box width="100%" flexDirection="row" justifyContent="center" paddingBottom={1}>
|
<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>
|
</box>
|
||||||
)
|
)
|
||||||
) : (
|
) : (
|
||||||
@@ -115,13 +115,13 @@ export function DialogRetryAction(props: DialogRetryActionProps) {
|
|||||||
paddingLeft={2}
|
paddingLeft={2}
|
||||||
paddingRight={2}
|
paddingRight={2}
|
||||||
backgroundColor={
|
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")}
|
onMouseOver={() => setSelected("dismiss")}
|
||||||
onMouseUp={() => dismiss(props, dialog)}
|
onMouseUp={() => dismiss(props, dialog)}
|
||||||
>
|
>
|
||||||
<text
|
<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()}
|
bg={selected() === "dismiss" ? undefined : textBg()}
|
||||||
attributes={selected() === "dismiss" ? TextAttributes.BOLD : undefined}
|
attributes={selected() === "dismiss" ? TextAttributes.BOLD : undefined}
|
||||||
>
|
>
|
||||||
@@ -132,13 +132,13 @@ export function DialogRetryAction(props: DialogRetryActionProps) {
|
|||||||
paddingLeft={2}
|
paddingLeft={2}
|
||||||
paddingRight={2}
|
paddingRight={2}
|
||||||
backgroundColor={
|
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")}
|
onMouseOver={() => setSelected("action")}
|
||||||
onMouseUp={() => runAction(props, dialog)}
|
onMouseUp={() => runAction(props, dialog)}
|
||||||
>
|
>
|
||||||
<text
|
<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()}
|
bg={selected() === "action" ? undefined : textBg()}
|
||||||
attributes={selected() === "action" ? TextAttributes.BOLD : undefined}
|
attributes={selected() === "action" ? TextAttributes.BOLD : undefined}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { TextAttributes } from "@opentui/core"
|
import { TextAttributes } from "@opentui/core"
|
||||||
import { Keymap } from "../context/keymap"
|
import { Keymap } from "../context/keymap"
|
||||||
import { useTheme } from "../context/theme"
|
import { useThemes } from "../context/theme"
|
||||||
import { useDialog } from "../ui/dialog"
|
import { useDialog } from "../ui/dialog"
|
||||||
import { createStore } from "solid-js/store"
|
import { createStore } from "solid-js/store"
|
||||||
import { For } from "solid-js"
|
import { For } from "solid-js"
|
||||||
@@ -13,7 +13,7 @@ export function DialogSessionDeleteFailed(props: {
|
|||||||
onDone?: () => void
|
onDone?: () => void
|
||||||
}) {
|
}) {
|
||||||
const dialog = useDialog()
|
const dialog = useDialog()
|
||||||
const { themeV2 } = useTheme().contextual("elevated")
|
const theme = useThemes().contextual("elevated")
|
||||||
const [store, setStore] = createStore({
|
const [store, setStore] = createStore({
|
||||||
active: "delete" as "delete" | "restore",
|
active: "delete" as "delete" | "restore",
|
||||||
})
|
})
|
||||||
@@ -64,17 +64,17 @@ export function DialogSessionDeleteFailed(props: {
|
|||||||
return (
|
return (
|
||||||
<box paddingLeft={2} paddingRight={2} gap={1}>
|
<box paddingLeft={2} paddingRight={2} gap={1}>
|
||||||
<box flexDirection="row" justifyContent="space-between">
|
<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
|
Failed to Delete Session
|
||||||
</text>
|
</text>
|
||||||
<text fg={themeV2.text.subdued} onMouseUp={() => dialog.clear()}>
|
<text fg={theme.text.subdued} onMouseUp={() => dialog.clear()}>
|
||||||
esc
|
esc
|
||||||
</text>
|
</text>
|
||||||
</box>
|
</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.`}
|
{`The session "${props.session}" could not be deleted because the workspace "${props.workspace}" is not available.`}
|
||||||
</text>
|
</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.
|
Choose how you want to recover this broken workspace session.
|
||||||
</text>
|
</text>
|
||||||
<box flexDirection="column" paddingBottom={1} gap={1}>
|
<box flexDirection="column" paddingBottom={1} gap={1}>
|
||||||
@@ -86,7 +86,7 @@ export function DialogSessionDeleteFailed(props: {
|
|||||||
paddingRight={1}
|
paddingRight={1}
|
||||||
paddingTop={1}
|
paddingTop={1}
|
||||||
paddingBottom={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={() => {
|
onMouseUp={() => {
|
||||||
setStore("active", item.id)
|
setStore("active", item.id)
|
||||||
void confirm()
|
void confirm()
|
||||||
@@ -94,12 +94,12 @@ export function DialogSessionDeleteFailed(props: {
|
|||||||
>
|
>
|
||||||
<text
|
<text
|
||||||
attributes={TextAttributes.BOLD}
|
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}
|
{item.title}
|
||||||
</text>
|
</text>
|
||||||
<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"
|
wrapMode="word"
|
||||||
>
|
>
|
||||||
{item.description}
|
{item.description}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { useRoute } from "../context/route"
|
|||||||
import { useData } from "../context/data"
|
import { useData } from "../context/data"
|
||||||
import { Keymap } from "../context/keymap"
|
import { Keymap } from "../context/keymap"
|
||||||
import { Locale } from "../util/locale"
|
import { Locale } from "../util/locale"
|
||||||
import { useTheme } from "../context/theme"
|
import { useThemes } from "../context/theme"
|
||||||
import { useClient } from "../context/client"
|
import { useClient } from "../context/client"
|
||||||
import { useLocal } from "../context/local"
|
import { useLocal } from "../context/local"
|
||||||
import { createDebouncedSignal } from "../util/signal"
|
import { createDebouncedSignal } from "../util/signal"
|
||||||
@@ -15,14 +15,18 @@ import { useToast } from "../ui/toast"
|
|||||||
import { DialogSessionRename } from "./dialog-session-rename"
|
import { DialogSessionRename } from "./dialog-session-rename"
|
||||||
import { Spinner } from "./spinner"
|
import { Spinner } from "./spinner"
|
||||||
import { errorMessage } from "../util/error"
|
import { errorMessage } from "../util/error"
|
||||||
|
import { useSessionTabs } from "../context/session-tabs"
|
||||||
|
|
||||||
export function DialogSessionList() {
|
export function DialogSessionList() {
|
||||||
const dialog = useDialog()
|
const dialog = useDialog()
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const data = useData()
|
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 client = useClient()
|
||||||
const local = useLocal()
|
const local = useLocal()
|
||||||
|
const sessionTabs = useSessionTabs()
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
const [filter, setFilter] = createSignal("")
|
const [filter, setFilter] = createSignal("")
|
||||||
const shortcuts = Keymap.useShortcuts()
|
const shortcuts = Keymap.useShortcuts()
|
||||||
@@ -77,6 +81,7 @@ export function DialogSessionList() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const quickSwitchHint = createMemo(() => {
|
const quickSwitchHint = createMemo(() => {
|
||||||
|
if (sessionTabs.enabled()) return
|
||||||
const first = shortcuts.get("session.quick_switch.1")
|
const first = shortcuts.get("session.quick_switch.1")
|
||||||
const last = shortcuts.get("session.quick_switch.9")
|
const last = shortcuts.get("session.quick_switch.9")
|
||||||
if (!first || !last) return
|
if (!first || !last) return
|
||||||
@@ -94,7 +99,7 @@ export function DialogSessionList() {
|
|||||||
.filter((session) => !session.parentID)
|
.filter((session) => !session.parentID)
|
||||||
.map((session) => [session.id, session]),
|
.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 pinnedSet = new Set(pinned)
|
||||||
const slotByID = new Map(local.session.slots().map((sessionID, index) => [sessionID, index + 1]))
|
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 directory = session.location.directory
|
||||||
const footer =
|
const footer =
|
||||||
directory !== data.location.info()?.project.directory ? Locale.truncate(path.basename(directory), 20) : ""
|
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
|
const deleting = toDelete() === session.id
|
||||||
return {
|
return {
|
||||||
title: deleting ? `Press ${shortcuts.get("session.delete")} again to confirm` : session.title,
|
title: deleting ? `Press ${shortcuts.get("session.delete")} again to confirm` : session.title,
|
||||||
value: session.id,
|
value: session.id,
|
||||||
category,
|
category,
|
||||||
footer,
|
footer,
|
||||||
bg: deleting ? themeV2.background.action.destructive.focused : undefined,
|
bg: deleting ? theme.background.action.destructive.focused : undefined,
|
||||||
fg: deleting ? themeV2.text.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")
|
gutter: data.session.family(session.id).some((id) => data.session.status(id) === "running")
|
||||||
? () => <Spinner />
|
? () => <Spinner />
|
||||||
: slot === undefined
|
: slot === undefined
|
||||||
? 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={
|
emptyView={
|
||||||
<box paddingLeft={4} paddingRight={4} paddingTop={1}>
|
<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>
|
</box>
|
||||||
}
|
}
|
||||||
noMatchView={
|
noMatchView={
|
||||||
<box paddingLeft={4} paddingRight={4} paddingTop={1}>
|
<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}
|
{searchState().message}
|
||||||
</text>
|
</text>
|
||||||
</box>
|
</box>
|
||||||
@@ -162,7 +167,8 @@ export function DialogSessionList() {
|
|||||||
{
|
{
|
||||||
command: "session.pin.toggle",
|
command: "session.pin.toggle",
|
||||||
title: "pin/unpin",
|
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",
|
command: "session.delete",
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ export type DialogSkillProps = {
|
|||||||
export function DialogSkill(props: DialogSkillProps) {
|
export function DialogSkill(props: DialogSkillProps) {
|
||||||
const dialog = useDialog()
|
const dialog = useDialog()
|
||||||
const data = useData()
|
const data = useData()
|
||||||
const { themeV2 } = useTheme()
|
const theme = useTheme()
|
||||||
dialog.setSize("large")
|
dialog.setSize("large")
|
||||||
|
|
||||||
const [loadError, setLoadError] = createSignal<unknown>()
|
const [loadError, setLoadError] = createSignal<unknown>()
|
||||||
@@ -63,29 +63,29 @@ export function DialogSkill(props: DialogSkillProps) {
|
|||||||
<Switch
|
<Switch
|
||||||
fallback={
|
fallback={
|
||||||
<box paddingLeft={4} paddingRight={4} paddingTop={1}>
|
<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>
|
</box>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Match when={showError()}>
|
<Match when={showError()}>
|
||||||
<box paddingLeft={4} paddingRight={4} paddingTop={1}>
|
<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
|
Could not load skills
|
||||||
</text>
|
</text>
|
||||||
<text fg={themeV2.text.subdued}>{errorMessage(loadError())}</text>
|
<text fg={theme.text.subdued}>{errorMessage(loadError())}</text>
|
||||||
<text fg={themeV2.text.subdued}>Close and reopen Skills to try again.</text>
|
<text fg={theme.text.subdued}>Close and reopen Skills to try again.</text>
|
||||||
</box>
|
</box>
|
||||||
</Match>
|
</Match>
|
||||||
<Match when={skills.loading}>
|
<Match when={skills.loading}>
|
||||||
<box paddingLeft={4} paddingRight={4} paddingTop={1}>
|
<box paddingLeft={4} paddingRight={4} paddingTop={1}>
|
||||||
<text fg={themeV2.text.subdued}>Loading skills…</text>
|
<text fg={theme.text.subdued}>Loading skills…</text>
|
||||||
</box>
|
</box>
|
||||||
</Match>
|
</Match>
|
||||||
</Switch>
|
</Switch>
|
||||||
}
|
}
|
||||||
noMatchView={
|
noMatchView={
|
||||||
<box paddingLeft={4} paddingRight={4} paddingTop={1}>
|
<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>
|
</box>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { DialogSelect } from "../ui/dialog-select"
|
|||||||
import { createMemo, createSignal } from "solid-js"
|
import { createMemo, createSignal } from "solid-js"
|
||||||
import { Locale } from "../util/locale"
|
import { Locale } from "../util/locale"
|
||||||
import { Keymap } from "../context/keymap"
|
import { Keymap } from "../context/keymap"
|
||||||
import { useTheme } from "../context/theme"
|
import { useThemes } from "../context/theme"
|
||||||
import { usePromptStash, type StashEntry } from "./prompt/stash"
|
import { usePromptStash, type StashEntry } from "./prompt/stash"
|
||||||
|
|
||||||
function getRelativeTime(timestamp: number): string {
|
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 }) {
|
export function DialogStash(props: { onSelect: (entry: StashEntry) => void }) {
|
||||||
const dialog = useDialog()
|
const dialog = useDialog()
|
||||||
const stash = usePromptStash()
|
const stash = usePromptStash()
|
||||||
const { themeV2 } = useTheme().contextual("elevated")
|
const theme = useThemes().contextual("elevated")
|
||||||
const shortcuts = Keymap.useShortcuts()
|
const shortcuts = Keymap.useShortcuts()
|
||||||
|
|
||||||
const [toDelete, setToDelete] = createSignal<number>()
|
const [toDelete, setToDelete] = createSignal<number>()
|
||||||
@@ -45,8 +45,8 @@ export function DialogStash(props: { onSelect: (entry: StashEntry) => void }) {
|
|||||||
title: isDeleting
|
title: isDeleting
|
||||||
? `Press ${shortcuts.get("stash.delete")} again to confirm`
|
? `Press ${shortcuts.get("stash.delete")} again to confirm`
|
||||||
: getStashPreview(entry.prompt.text),
|
: getStashPreview(entry.prompt.text),
|
||||||
bg: isDeleting ? themeV2.background.action.destructive.focused : undefined,
|
bg: isDeleting ? theme.background.action.destructive.focused : undefined,
|
||||||
fg: isDeleting ? themeV2.text.action.destructive.focused : undefined,
|
fg: isDeleting ? theme.text.action.destructive.focused : undefined,
|
||||||
value: index,
|
value: index,
|
||||||
description: getRelativeTime(entry.timestamp),
|
description: getRelativeTime(entry.timestamp),
|
||||||
footer: lineCount > 1 ? `~${lineCount} lines` : undefined,
|
footer: lineCount > 1 ? `~${lineCount} lines` : undefined,
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { TextAttributes } from "@opentui/core"
|
import { TextAttributes } from "@opentui/core"
|
||||||
import { useTheme } from "../context/theme"
|
import { useThemes } from "../context/theme"
|
||||||
import { useDialog } from "../ui/dialog"
|
import { useDialog } from "../ui/dialog"
|
||||||
import { useData } from "../context/data"
|
import { useData } from "../context/data"
|
||||||
import { For, Match, Switch, Show, createMemo } from "solid-js"
|
import { For, Match, Switch, Show, createMemo } from "solid-js"
|
||||||
@@ -8,30 +8,30 @@ export type DialogStatusProps = {}
|
|||||||
|
|
||||||
export function DialogStatus() {
|
export function DialogStatus() {
|
||||||
const data = useData()
|
const data = useData()
|
||||||
const { themeV2 } = useTheme().contextual("elevated")
|
const theme = useThemes().contextual("elevated")
|
||||||
const dialog = useDialog()
|
const dialog = useDialog()
|
||||||
|
|
||||||
const mcp = createMemo(() => data.location.mcp.server.list() ?? [])
|
const mcp = createMemo(() => data.location.mcp.server.list() ?? [])
|
||||||
const color = (status: string) => {
|
const color = (status: string) => {
|
||||||
if (status === "connected") return themeV2.text.feedback.success.default
|
if (status === "connected") return theme.text.feedback.success.default
|
||||||
if (status === "failed") return themeV2.text.feedback.error.default
|
if (status === "failed") return theme.text.feedback.error.default
|
||||||
if (status === "needs_auth") return themeV2.text.feedback.warning.default
|
if (status === "needs_auth") return theme.text.feedback.warning.default
|
||||||
if (status === "needs_client_registration") return themeV2.text.feedback.error.default
|
if (status === "needs_client_registration") return theme.text.feedback.error.default
|
||||||
return themeV2.text.subdued
|
return theme.text.subdued
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<box paddingLeft={2} paddingRight={2} gap={1} paddingBottom={1}>
|
<box paddingLeft={2} paddingRight={2} gap={1} paddingBottom={1}>
|
||||||
<box flexDirection="row" justifyContent="space-between">
|
<box flexDirection="row" justifyContent="space-between">
|
||||||
<text fg={themeV2.text.default} attributes={TextAttributes.BOLD}>
|
<text fg={theme.text.default} attributes={TextAttributes.BOLD}>
|
||||||
Status
|
Status
|
||||||
</text>
|
</text>
|
||||||
<text fg={themeV2.text.subdued} onMouseUp={() => dialog.clear()}>
|
<text fg={theme.text.subdued} onMouseUp={() => dialog.clear()}>
|
||||||
esc
|
esc
|
||||||
</text>
|
</text>
|
||||||
</box>
|
</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>
|
<box>
|
||||||
<text fg={themeV2.text.default}>
|
<text fg={theme.text.default}>
|
||||||
{mcp().length} MCP server{mcp().length === 1 ? "" : "s"}
|
{mcp().length} MCP server{mcp().length === 1 ? "" : "s"}
|
||||||
</text>
|
</text>
|
||||||
<For each={mcp()}>
|
<For each={mcp()}>
|
||||||
@@ -40,9 +40,9 @@ export function DialogStatus() {
|
|||||||
<text flexShrink={0} style={{ fg: color(item.status.status) }}>
|
<text flexShrink={0} style={{ fg: color(item.status.status) }}>
|
||||||
•
|
•
|
||||||
</text>
|
</text>
|
||||||
<text fg={themeV2.text.default} wrapMode="word">
|
<text fg={theme.text.default} wrapMode="word">
|
||||||
<b>{item.name}</b>{" "}
|
<b>{item.name}</b>{" "}
|
||||||
<span style={{ fg: themeV2.text.subdued }}>
|
<span style={{ fg: theme.text.subdued }}>
|
||||||
<Switch fallback={item.status.status}>
|
<Switch fallback={item.status.status}>
|
||||||
<Match when={item.status.status === "connected"}>Connected</Match>
|
<Match when={item.status.status === "connected"}>Connected</Match>
|
||||||
<Match when={item.status.status === "failed" && item.status}>{(val) => val().error}</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 { DialogSelect, type DialogSelectRef } from "../ui/dialog-select"
|
||||||
import { useTheme } from "../context/theme"
|
import { useThemes } from "../context/theme"
|
||||||
import { useDialog } from "../ui/dialog"
|
import { useDialog } from "../ui/dialog"
|
||||||
import { onCleanup } from "solid-js"
|
import { onCleanup } from "solid-js"
|
||||||
|
|
||||||
export function DialogThemeList() {
|
export function DialogThemeList() {
|
||||||
const theme = useTheme()
|
const themes = useThemes()
|
||||||
const options = Object.keys(theme.all())
|
const options = Object.keys(themes.all())
|
||||||
.sort((a, b) => a.localeCompare(b, undefined, { sensitivity: "base" }))
|
.sort((a, b) => a.localeCompare(b, undefined, { sensitivity: "base" }))
|
||||||
.map((value) => ({
|
.map((value) => ({
|
||||||
title: value,
|
title: value,
|
||||||
@@ -14,10 +14,10 @@ export function DialogThemeList() {
|
|||||||
const dialog = useDialog()
|
const dialog = useDialog()
|
||||||
let confirmed = false
|
let confirmed = false
|
||||||
let ref: DialogSelectRef<string>
|
let ref: DialogSelectRef<string>
|
||||||
const initial = theme.selected
|
const initial = themes.selected
|
||||||
|
|
||||||
onCleanup(() => {
|
onCleanup(() => {
|
||||||
if (!confirmed) theme.set(initial)
|
if (!confirmed) themes.set(initial)
|
||||||
})
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -26,10 +26,10 @@ export function DialogThemeList() {
|
|||||||
options={options}
|
options={options}
|
||||||
current={initial}
|
current={initial}
|
||||||
onMove={(opt) => {
|
onMove={(opt) => {
|
||||||
theme.set(opt.value)
|
themes.set(opt.value)
|
||||||
}}
|
}}
|
||||||
onSelect={(opt) => {
|
onSelect={(opt) => {
|
||||||
theme.set(opt.value)
|
themes.set(opt.value)
|
||||||
confirmed = true
|
confirmed = true
|
||||||
dialog.clear()
|
dialog.clear()
|
||||||
}}
|
}}
|
||||||
@@ -38,12 +38,12 @@ export function DialogThemeList() {
|
|||||||
}}
|
}}
|
||||||
onFilter={(query) => {
|
onFilter={(query) => {
|
||||||
if (query.length === 0) {
|
if (query.length === 0) {
|
||||||
theme.set(initial)
|
themes.set(initial)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const first = ref.filtered[0]
|
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 { createMemo, For } from "solid-js"
|
||||||
import { createStore } from "solid-js/store"
|
import { createStore } from "solid-js/store"
|
||||||
import { FilePath } from "../ui/file-path"
|
import { FilePath } from "../ui/file-path"
|
||||||
import { useTheme } from "../context/theme"
|
import { useThemes } from "../context/theme"
|
||||||
import { useConfig } from "../config"
|
import { useConfig } from "../config"
|
||||||
import { useDialog, type DialogContext } from "../ui/dialog"
|
import { useDialog, type DialogContext } from "../ui/dialog"
|
||||||
import { getScrollAcceleration } from "../util/scroll"
|
import { getScrollAcceleration } from "../util/scroll"
|
||||||
@@ -31,8 +31,8 @@ export function DialogWorkspaceFileChanges(props: {
|
|||||||
message?: string
|
message?: string
|
||||||
}) {
|
}) {
|
||||||
const dialog = useDialog()
|
const dialog = useDialog()
|
||||||
const { themeV2 } = useTheme().contextual("elevated")
|
const theme = useThemes().contextual("elevated")
|
||||||
const { themeV2: overlayTheme } = useTheme().contextual("overlay")
|
const overlayTheme = useThemes().contextual("overlay")
|
||||||
const config = useConfig().data
|
const config = useConfig().data
|
||||||
const dimensions = useTerminalDimensions()
|
const dimensions = useTerminalDimensions()
|
||||||
const scrollAcceleration = createMemo(() => getScrollAcceleration(config))
|
const scrollAcceleration = createMemo(() => getScrollAcceleration(config))
|
||||||
@@ -72,15 +72,15 @@ export function DialogWorkspaceFileChanges(props: {
|
|||||||
return (
|
return (
|
||||||
<box gap={1}>
|
<box gap={1}>
|
||||||
<box flexDirection="row" justifyContent="space-between" paddingLeft={2} paddingRight={2}>
|
<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"}
|
{props.title ?? "File Changes Found"}
|
||||||
</text>
|
</text>
|
||||||
<text fg={themeV2.text.subdued} onMouseUp={() => dialog.clear()}>
|
<text fg={theme.text.subdued} onMouseUp={() => dialog.clear()}>
|
||||||
esc
|
esc
|
||||||
</text>
|
</text>
|
||||||
</box>
|
</box>
|
||||||
<box paddingLeft={2} paddingRight={2}>
|
<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?"}
|
{props.message ?? "Do you want to move these changes with the session?"}
|
||||||
</text>
|
</text>
|
||||||
</box>
|
</box>
|
||||||
@@ -118,16 +118,14 @@ export function DialogWorkspaceFileChanges(props: {
|
|||||||
<box
|
<box
|
||||||
paddingLeft={2}
|
paddingLeft={2}
|
||||||
paddingRight={2}
|
paddingRight={2}
|
||||||
backgroundColor={item === store.active ? themeV2.background.action.primary.focused : undefined}
|
backgroundColor={item === store.active ? theme.background.action.primary.focused : undefined}
|
||||||
onMouseUp={() => {
|
onMouseUp={() => {
|
||||||
setStore("active", item)
|
setStore("active", item)
|
||||||
props.onSelect(item)
|
props.onSelect(item)
|
||||||
dialog.clear()
|
dialog.clear()
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<text fg={item === store.active ? themeV2.text.action.primary.focused : themeV2.text.subdued}>
|
<text fg={item === store.active ? theme.text.action.primary.focused : theme.text.subdued}>{item}</text>
|
||||||
{item}
|
|
||||||
</text>
|
|
||||||
</box>
|
</box>
|
||||||
)}
|
)}
|
||||||
</For>
|
</For>
|
||||||
|
|||||||
@@ -5,10 +5,10 @@ import { tint } from "../theme/color"
|
|||||||
import { logo } from "../logo"
|
import { logo } from "../logo"
|
||||||
|
|
||||||
export function Logo() {
|
export function Logo() {
|
||||||
const { themeV2 } = useTheme()
|
const theme = useTheme()
|
||||||
|
|
||||||
const renderLine = (line: string, fg: RGBA, bold: boolean): JSX.Element[] => {
|
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
|
const attrs = bold ? TextAttributes.BOLD : undefined
|
||||||
return Array.from(line).map((char) => {
|
return Array.from(line).map((char) => {
|
||||||
if (char === "_") {
|
if (char === "_") {
|
||||||
@@ -52,8 +52,8 @@ export function Logo() {
|
|||||||
<For each={logo.left}>
|
<For each={logo.left}>
|
||||||
{(line, index) => (
|
{(line, index) => (
|
||||||
<box flexDirection="row" gap={1}>
|
<box flexDirection="row" gap={1}>
|
||||||
<box flexDirection="row">{renderLine(line, themeV2.text.subdued, false)}</box>
|
<box flexDirection="row">{renderLine(line, theme.text.subdued, false)}</box>
|
||||||
<box flexDirection="row">{renderLine(logo.right[index()], themeV2.text.default, true)}</box>
|
<box flexDirection="row">{renderLine(logo.right[index()], theme.text.default, true)}</box>
|
||||||
</box>
|
</box>
|
||||||
)}
|
)}
|
||||||
</For>
|
</For>
|
||||||
|
|||||||
@@ -1,20 +1,20 @@
|
|||||||
import { useTheme } from "../context/theme"
|
import { useTheme } from "../context/theme"
|
||||||
|
|
||||||
export function PluginRouteMissing(props: { id: string; name: string; onHome: () => void }) {
|
export function PluginRouteMissing(props: { id: string; name: string; onHome: () => void }) {
|
||||||
const { themeV2 } = useTheme()
|
const theme = useTheme()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<box width="100%" height="100%" alignItems="center" justifyContent="center" flexDirection="column" gap={1}>
|
<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}
|
Unknown plugin route: {props.id}/{props.name}
|
||||||
</text>
|
</text>
|
||||||
<box
|
<box
|
||||||
onMouseUp={props.onHome}
|
onMouseUp={props.onHome}
|
||||||
backgroundColor={themeV2.background.action.primary.hovered}
|
backgroundColor={theme.background.action.primary.hovered}
|
||||||
paddingLeft={1}
|
paddingLeft={1}
|
||||||
paddingRight={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>
|
||||||
</box>
|
</box>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import { getScrollAcceleration } from "../../util/scroll"
|
|||||||
import { useTuiPaths } from "../../context/runtime"
|
import { useTuiPaths } from "../../context/runtime"
|
||||||
import { useConfig } from "../../config"
|
import { useConfig } from "../../config"
|
||||||
import { useLocation } from "../../context/location"
|
import { useLocation } from "../../context/location"
|
||||||
import { useTheme } from "../../context/theme"
|
import { useThemes } from "../../context/theme"
|
||||||
import { SplitBorder } from "../../ui/border"
|
import { SplitBorder } from "../../ui/border"
|
||||||
import { useTerminalDimensions } from "@opentui/solid"
|
import { useTerminalDimensions } from "@opentui/solid"
|
||||||
import { Locale } from "../../util/locale"
|
import { Locale } from "../../util/locale"
|
||||||
@@ -57,7 +57,7 @@ export function Autocomplete(props: {
|
|||||||
const data = useData()
|
const data = useData()
|
||||||
const keymap = Keymap.use()
|
const keymap = Keymap.use()
|
||||||
const keymapCommands = Keymap.useCommands()
|
const keymapCommands = Keymap.useCommands()
|
||||||
const { themeV2 } = useTheme().contextual("overlay")
|
const theme = useThemes().contextual("overlay")
|
||||||
const dimensions = useTerminalDimensions()
|
const dimensions = useTerminalDimensions()
|
||||||
const frecency = useFrecency()
|
const frecency = useFrecency()
|
||||||
const config = useConfig().data
|
const config = useConfig().data
|
||||||
@@ -698,11 +698,11 @@ export function Autocomplete(props: {
|
|||||||
width={position().width}
|
width={position().width}
|
||||||
zIndex={100}
|
zIndex={100}
|
||||||
{...SplitBorder}
|
{...SplitBorder}
|
||||||
borderColor={themeV2.border.default}
|
borderColor={theme.border.default}
|
||||||
>
|
>
|
||||||
<scrollbox
|
<scrollbox
|
||||||
ref={(r: ScrollBoxRenderable) => (scroll = r)}
|
ref={(r: ScrollBoxRenderable) => (scroll = r)}
|
||||||
backgroundColor={themeV2.background.default}
|
backgroundColor={theme.background.default}
|
||||||
height={height()}
|
height={height()}
|
||||||
scrollbarOptions={{ visible: false }}
|
scrollbarOptions={{ visible: false }}
|
||||||
scrollAcceleration={scrollAcceleration()}
|
scrollAcceleration={scrollAcceleration()}
|
||||||
@@ -711,9 +711,7 @@ export function Autocomplete(props: {
|
|||||||
each={options()}
|
each={options()}
|
||||||
fallback={
|
fallback={
|
||||||
<box paddingLeft={1} paddingRight={1}>
|
<box paddingLeft={1} paddingRight={1}>
|
||||||
<text fg={emptyError() ? themeV2.text.feedback.error.default : themeV2.text.subdued}>
|
<text fg={emptyError() ? theme.text.feedback.error.default : theme.text.subdued}>{emptyMessage()}</text>
|
||||||
{emptyMessage()}
|
|
||||||
</text>
|
|
||||||
</box>
|
</box>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
@@ -721,7 +719,7 @@ export function Autocomplete(props: {
|
|||||||
<box
|
<box
|
||||||
paddingLeft={1}
|
paddingLeft={1}
|
||||||
paddingRight={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"
|
flexDirection="row"
|
||||||
onMouseMove={() => {
|
onMouseMove={() => {
|
||||||
setStore("input", "mouse")
|
setStore("input", "mouse")
|
||||||
@@ -737,14 +735,14 @@ export function Autocomplete(props: {
|
|||||||
onMouseUp={() => select()}
|
onMouseUp={() => select()}
|
||||||
>
|
>
|
||||||
<text
|
<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}
|
flexShrink={0}
|
||||||
>
|
>
|
||||||
{option().display}
|
{option().display}
|
||||||
</text>
|
</text>
|
||||||
<Show when={option().description}>
|
<Show when={option().description}>
|
||||||
<text
|
<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"
|
wrapMode="none"
|
||||||
>
|
>
|
||||||
{" " + option().description?.trimStart()}
|
{" " + option().description?.trimStart()}
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import { registerOpencodeSpinner } from "../register-spinner"
|
|||||||
import path from "path"
|
import path from "path"
|
||||||
import { fileURLToPath } from "url"
|
import { fileURLToPath } from "url"
|
||||||
import { useLocal } from "../../context/local"
|
import { useLocal } from "../../context/local"
|
||||||
import { useTheme } from "../../context/theme"
|
import { useTheme, useThemes } from "../../context/theme"
|
||||||
import { tint } from "../../theme/color"
|
import { tint } from "../../theme/color"
|
||||||
import { EmptyBorder, SplitBorder } from "../../ui/border"
|
import { EmptyBorder, SplitBorder } from "../../ui/border"
|
||||||
import { useTuiPaths, useTuiTerminalEnvironment } from "../../context/runtime"
|
import { useTuiPaths, useTuiTerminalEnvironment } from "../../context/runtime"
|
||||||
@@ -189,7 +189,8 @@ export function Prompt(props: PromptProps) {
|
|||||||
const renderer = useRenderer()
|
const renderer = useRenderer()
|
||||||
const exit = useExit()
|
const exit = useExit()
|
||||||
const dimensions = useTerminalDimensions()
|
const dimensions = useTerminalDimensions()
|
||||||
const { themeV2, syntax } = useTheme()
|
const theme = useTheme()
|
||||||
|
const { currentSyntax: syntax } = useThemes()
|
||||||
const animationsEnabled = createMemo(() => config.animations ?? true)
|
const animationsEnabled = createMemo(() => config.animations ?? true)
|
||||||
const list = createMemo(() => props.placeholders?.normal ?? [])
|
const list = createMemo(() => props.placeholders?.normal ?? [])
|
||||||
const shell = createMemo(() => props.placeholders?.shell ?? [])
|
const shell = createMemo(() => props.placeholders?.shell ?? [])
|
||||||
@@ -296,8 +297,8 @@ export function Prompt(props: PromptProps) {
|
|||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
if (!input || input.isDestroyed) return
|
if (!input || input.isDestroyed) return
|
||||||
if (props.disabled) input.cursorColor = themeV2.background.surface.offset
|
if (props.disabled) input.cursorColor = theme.background.surface.offset
|
||||||
if (!props.disabled) input.cursorColor = themeV2.text.default
|
if (!props.disabled) input.cursorColor = theme.text.default
|
||||||
})
|
})
|
||||||
|
|
||||||
const usage = createMemo(() => {
|
const usage = createMemo(() => {
|
||||||
@@ -1299,10 +1300,10 @@ export function Prompt(props: PromptProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const highlight = createMemo(() => {
|
const highlight = createMemo(() => {
|
||||||
if (leader()) return themeV2.border.default
|
if (leader()) return theme.border.default
|
||||||
if (store.mode === "shell") return themeV2.text.action.primary.selected
|
if (store.mode === "shell") return theme.text.action.primary.selected
|
||||||
const agent = local.agent.current()
|
const agent = local.agent.current()
|
||||||
if (!agent) return themeV2.border.default
|
if (!agent) return theme.border.default
|
||||||
return local.agent.color(agent.id)
|
return local.agent.color(agent.id)
|
||||||
})
|
})
|
||||||
const agentLabel = createMemo(() => {
|
const agentLabel = createMemo(() => {
|
||||||
@@ -1324,7 +1325,7 @@ export function Prompt(props: PromptProps) {
|
|||||||
() => !!local.agent.current() && store.mode === "normal" && showVariant(),
|
() => !!local.agent.current() && store.mode === "normal" && showVariant(),
|
||||||
animationsEnabled,
|
animationsEnabled,
|
||||||
)
|
)
|
||||||
const borderHighlight = createMemo(() => tint(themeV2.border.default, highlight(), agentMetaAlpha()))
|
const borderHighlight = createMemo(() => tint(theme.border.default, highlight(), agentMetaAlpha()))
|
||||||
|
|
||||||
const placeholderText = createMemo(() => {
|
const placeholderText = createMemo(() => {
|
||||||
if (props.showPlaceholder === false) return undefined
|
if (props.showPlaceholder === false) return undefined
|
||||||
@@ -1344,7 +1345,7 @@ export function Prompt(props: PromptProps) {
|
|||||||
|
|
||||||
const spinnerDef = createMemo(() => {
|
const spinnerDef = createMemo(() => {
|
||||||
const agent = status() === "running" ? local.agent.current() : local.agent.current()
|
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 {
|
return {
|
||||||
frames: createFrames({
|
frames: createFrames({
|
||||||
color,
|
color,
|
||||||
@@ -1364,7 +1365,7 @@ export function Prompt(props: PromptProps) {
|
|||||||
})
|
})
|
||||||
const maxHeight = createMemo(() => Math.max(6, Math.floor(dimensions().height / 3)))
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -1390,9 +1391,9 @@ export function Prompt(props: PromptProps) {
|
|||||||
<textarea
|
<textarea
|
||||||
width="100%"
|
width="100%"
|
||||||
placeholder={placeholderText()}
|
placeholder={placeholderText()}
|
||||||
placeholderColor={themeV2.text.subdued}
|
placeholderColor={theme.text.subdued}
|
||||||
textColor={leader() ? themeV2.text.subdued : themeV2.text.default}
|
textColor={leader() ? theme.text.subdued : theme.text.default}
|
||||||
focusedTextColor={leader() ? themeV2.text.subdued : themeV2.text.default}
|
focusedTextColor={leader() ? theme.text.subdued : theme.text.default}
|
||||||
minHeight={1}
|
minHeight={1}
|
||||||
maxHeight={maxHeight()}
|
maxHeight={maxHeight()}
|
||||||
onContentChange={() => {
|
onContentChange={() => {
|
||||||
@@ -1452,7 +1453,7 @@ export function Prompt(props: PromptProps) {
|
|||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
// setTimeout is a workaround and needs to be addressed properly
|
// setTimeout is a workaround and needs to be addressed properly
|
||||||
if (!input || input.isDestroyed) return
|
if (!input || input.isDestroyed) return
|
||||||
input.cursorColor = themeV2.text.default
|
input.cursorColor = theme.text.default
|
||||||
}, 0)
|
}, 0)
|
||||||
}}
|
}}
|
||||||
onMouseDown={(r: MouseEvent) => {
|
onMouseDown={(r: MouseEvent) => {
|
||||||
@@ -1460,7 +1461,7 @@ export function Prompt(props: PromptProps) {
|
|||||||
r.target?.focus()
|
r.target?.focus()
|
||||||
}}
|
}}
|
||||||
focusedBackgroundColor="transparent"
|
focusedBackgroundColor="transparent"
|
||||||
cursorColor={props.disabled ? themeV2.background.surface.offset : themeV2.text.default}
|
cursorColor={props.disabled ? theme.background.surface.offset : theme.text.default}
|
||||||
syntaxStyle={syntax()}
|
syntaxStyle={syntax()}
|
||||||
/>
|
/>
|
||||||
<box flexDirection="row" flexShrink={0} paddingTop={1} gap={1} justifyContent="space-between">
|
<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>
|
<text fg={fadeColor(highlight(), agentMetaAlpha())}>{label()}</text>
|
||||||
<Show when={store.mode === "normal" && local.permission.mode === "auto"}>
|
<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>
|
||||||
<Show when={store.mode === "normal"}>
|
<Show when={store.mode === "normal"}>
|
||||||
<box flexDirection="row" gap={1}>
|
<box flexDirection="row" gap={1}>
|
||||||
<text fg={fadeColor(themeV2.text.subdued, modelMetaAlpha())}>·</text>
|
<text fg={fadeColor(theme.text.subdued, modelMetaAlpha())}>·</text>
|
||||||
<text
|
<text
|
||||||
flexShrink={0}
|
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}
|
{local.model.parsed().model}
|
||||||
</text>
|
</text>
|
||||||
<text fg={fadeColor(themeV2.text.subdued, modelMetaAlpha())}>{currentProviderLabel()}</text>
|
<text fg={fadeColor(theme.text.subdued, modelMetaAlpha())}>{currentProviderLabel()}</text>
|
||||||
<Show when={showVariant()}>
|
<Show when={showVariant()}>
|
||||||
<text fg={fadeColor(themeV2.text.subdued, variantMetaAlpha())}>·</text>
|
<text fg={fadeColor(theme.text.subdued, variantMetaAlpha())}>·</text>
|
||||||
<text>
|
<text>
|
||||||
<span
|
<span
|
||||||
style={{
|
style={{
|
||||||
fg: fadeColor(themeV2.text.feedback.warning.default, variantMetaAlpha()),
|
fg: fadeColor(theme.text.feedback.warning.default, variantMetaAlpha()),
|
||||||
bold: true,
|
bold: true,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -1541,12 +1542,12 @@ export function Prompt(props: PromptProps) {
|
|||||||
<Match when={status() === "running"}>
|
<Match when={status() === "running"}>
|
||||||
<box flexDirection="row" gap={1} flexGrow={1} justifyContent="flex-start">
|
<box flexDirection="row" gap={1} flexGrow={1} justifyContent="flex-start">
|
||||||
<box marginLeft={1}>
|
<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} />
|
<spinner color={spinnerDef().color} frames={spinnerDef().frames} interval={40} />
|
||||||
</Show>
|
</Show>
|
||||||
</box>
|
</box>
|
||||||
<text
|
<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"
|
wrapMode="none"
|
||||||
truncate
|
truncate
|
||||||
flexShrink={1}
|
flexShrink={1}
|
||||||
@@ -1554,7 +1555,7 @@ export function Prompt(props: PromptProps) {
|
|||||||
esc{" "}
|
esc{" "}
|
||||||
<span
|
<span
|
||||||
style={{
|
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"}
|
{store.interrupt > 0 ? "again to interrupt" : "interrupt"}
|
||||||
@@ -1565,16 +1566,16 @@ export function Prompt(props: PromptProps) {
|
|||||||
<Match when={move.progress()}>
|
<Match when={move.progress()}>
|
||||||
{(progress) => (
|
{(progress) => (
|
||||||
<box paddingLeft={3} height={1} minHeight={0} flexShrink={1}>
|
<box paddingLeft={3} height={1} minHeight={0} flexShrink={1}>
|
||||||
<Spinner color={themeV2.hue.accent[500]}>
|
<Spinner color={theme.hue.accent[500]}>
|
||||||
{progress()}
|
{progress()}
|
||||||
<span style={{ fg: themeV2.text.subdued }}>{".".repeat(move.creatingDots())}</span>
|
<span style={{ fg: theme.text.subdued }}>{".".repeat(move.creatingDots())}</span>
|
||||||
</Spinner>
|
</Spinner>
|
||||||
</box>
|
</box>
|
||||||
)}
|
)}
|
||||||
</Match>
|
</Match>
|
||||||
<Match when={move.pendingNew()}>
|
<Match when={move.pendingNew()}>
|
||||||
<box paddingLeft={3} height={1} minHeight={0} flexShrink={1}>
|
<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)
|
(new working copy)
|
||||||
</text>
|
</text>
|
||||||
</box>
|
</box>
|
||||||
@@ -1582,7 +1583,7 @@ export function Prompt(props: PromptProps) {
|
|||||||
<Match when={true}>
|
<Match when={true}>
|
||||||
<Show when={!props.hint && locationLabel()} fallback={props.hint ?? <text />}>
|
<Show when={!props.hint && locationLabel()} fallback={props.hint ?? <text />}>
|
||||||
{(location) => (
|
{(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()}
|
{location()}
|
||||||
</text>
|
</text>
|
||||||
)}
|
)}
|
||||||
@@ -1596,7 +1597,7 @@ export function Prompt(props: PromptProps) {
|
|||||||
wrapMode="none"
|
wrapMode="none"
|
||||||
truncate
|
truncate
|
||||||
flexShrink={1}
|
flexShrink={1}
|
||||||
fg={editorContextLabelState() === "pending" ? themeV2.hue.accent[500] : themeV2.text.subdued}
|
fg={editorContextLabelState() === "pending" ? theme.hue.accent[500] : theme.text.subdued}
|
||||||
>
|
>
|
||||||
{file()}
|
{file()}
|
||||||
</text>
|
</text>
|
||||||
@@ -1606,40 +1607,40 @@ export function Prompt(props: PromptProps) {
|
|||||||
<Match when={store.mode === "normal"}>
|
<Match when={store.mode === "normal"}>
|
||||||
<Switch>
|
<Switch>
|
||||||
<Match when={liveWorkStatusVisible() || statusItems().length > 0}>
|
<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()}>
|
<Show when={liveWorkStatusVisible() && liveWorkShortcut()}>
|
||||||
{(shortcut) => <span style={{ fg: themeV2.text.default }}>{shortcut()} </span>}
|
{(shortcut) => <span style={{ fg: theme.text.default }}>{shortcut()} </span>}
|
||||||
</Show>
|
</Show>
|
||||||
<Show when={subagentStatusLabel()}>
|
<Show when={subagentStatusLabel()}>
|
||||||
{(label) => <span style={{ fg: themeV2.text.subdued }}>{label()}</span>}
|
{(label) => <span style={{ fg: theme.text.subdued }}>{label()}</span>}
|
||||||
</Show>
|
</Show>
|
||||||
<Show when={subagentStatusLabel() && shellStatusLabel()}>
|
<Show when={subagentStatusLabel() && shellStatusLabel()}>
|
||||||
<span style={{ fg: themeV2.text.subdued }}> · </span>
|
<span style={{ fg: theme.text.subdued }}> · </span>
|
||||||
</Show>
|
</Show>
|
||||||
<Show when={shellStatusLabel()}>
|
<Show when={shellStatusLabel()}>
|
||||||
{(label) => <span style={{ fg: themeV2.text.subdued }}>{label()}</span>}
|
{(label) => <span style={{ fg: theme.text.subdued }}>{label()}</span>}
|
||||||
</Show>
|
</Show>
|
||||||
<Show when={liveWorkStatusVisible() && statusItems().length > 0}>
|
<Show when={liveWorkStatusVisible() && statusItems().length > 0}>
|
||||||
<span style={{ fg: themeV2.text.subdued }}> · </span>
|
<span style={{ fg: theme.text.subdued }}> · </span>
|
||||||
</Show>
|
</Show>
|
||||||
<Show when={statusItems().length > 0}>
|
<Show when={statusItems().length > 0}>
|
||||||
<span style={{ fg: themeV2.text.subdued }}>{statusItems().join(" · ")}</span>
|
<span style={{ fg: theme.text.subdued }}>{statusItems().join(" · ")}</span>
|
||||||
</Show>
|
</Show>
|
||||||
</text>
|
</text>
|
||||||
</Match>
|
</Match>
|
||||||
<Match when={true}>
|
<Match when={true}>
|
||||||
<text fg={themeV2.text.default} flexShrink={0}>
|
<text fg={theme.text.default} flexShrink={0}>
|
||||||
{agentShortcut()} <span style={{ fg: themeV2.text.subdued }}>agents</span>
|
{agentShortcut()} <span style={{ fg: theme.text.subdued }}>agents</span>
|
||||||
</text>
|
</text>
|
||||||
</Match>
|
</Match>
|
||||||
</Switch>
|
</Switch>
|
||||||
<text fg={themeV2.text.default} flexShrink={0}>
|
<text fg={theme.text.default} flexShrink={0}>
|
||||||
{paletteShortcut()} <span style={{ fg: themeV2.text.subdued }}>commands</span>
|
{paletteShortcut()} <span style={{ fg: theme.text.subdued }}>commands</span>
|
||||||
</text>
|
</text>
|
||||||
</Match>
|
</Match>
|
||||||
<Match when={store.mode === "shell"}>
|
<Match when={store.mode === "shell"}>
|
||||||
<text fg={themeV2.text.default} flexShrink={0}>
|
<text fg={theme.text.default} flexShrink={0}>
|
||||||
esc <span style={{ fg: themeV2.text.subdued }}>exit shell mode</span>
|
esc <span style={{ fg: theme.text.subdued }}>exit shell mode</span>
|
||||||
</text>
|
</text>
|
||||||
</Match>
|
</Match>
|
||||||
</Switch>
|
</Switch>
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { RGBA } from "@opentui/core"
|
import { RGBA } from "@opentui/core"
|
||||||
import { useTheme } from "../context/theme"
|
import { useThemes } from "../context/theme"
|
||||||
import { Spinner } from "./spinner"
|
import { Spinner } from "./spinner"
|
||||||
|
|
||||||
export function Reconnecting() {
|
export function Reconnecting() {
|
||||||
const { themeV2 } = useTheme().contextual("elevated")
|
const theme = useThemes().contextual("elevated")
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<box
|
<box
|
||||||
@@ -21,15 +21,15 @@ export function Reconnecting() {
|
|||||||
width={48}
|
width={48}
|
||||||
maxWidth="90%"
|
maxWidth="90%"
|
||||||
flexDirection="column"
|
flexDirection="column"
|
||||||
backgroundColor={themeV2.background.default}
|
backgroundColor={theme.background.default}
|
||||||
paddingTop={1}
|
paddingTop={1}
|
||||||
paddingBottom={1}
|
paddingBottom={1}
|
||||||
paddingLeft={2}
|
paddingLeft={2}
|
||||||
paddingRight={2}
|
paddingRight={2}
|
||||||
gap={1}
|
gap={1}
|
||||||
>
|
>
|
||||||
<Spinner color={themeV2.text.default}>Restarting service...</Spinner>
|
<Spinner color={theme.text.default}>Restarting service...</Spinner>
|
||||||
<text fg={themeV2.text.subdued}>Your session will resume automatically.</text>
|
<text fg={theme.text.subdued}>Your session will resume automatically.</text>
|
||||||
</box>
|
</box>
|
||||||
</box>
|
</box>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,250 @@
|
|||||||
|
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,
|
||||||
|
type SessionTabUnread,
|
||||||
|
} 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"
|
||||||
|
|
||||||
|
type ContextController = ReturnType<typeof useSessionTabs>
|
||||||
|
export type SessionTabsStatus = Omit<ReturnType<ContextController["status"]>, "unread"> & {
|
||||||
|
unread: SessionTabUnread | undefined
|
||||||
|
}
|
||||||
|
export type SessionTabsController = Pick<ContextController, "tabs" | "current" | "select" | "close"> & {
|
||||||
|
status(sessionID: string): SessionTabsStatus
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SessionTabs(props: { controller?: SessionTabsController; animations?: boolean } = {}) {
|
||||||
|
const tabs = props.controller ?? useSessionTabs()
|
||||||
|
const dimensions = useTerminalDimensions()
|
||||||
|
const theme = useTheme()
|
||||||
|
const { mode } = useThemes()
|
||||||
|
const config = useConfig().data
|
||||||
|
const animations = () => props.animations ?? config.animations ?? true
|
||||||
|
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: animations,
|
||||||
|
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={animations()}
|
||||||
|
active={status().busy}
|
||||||
|
complete={status().complete}
|
||||||
|
glow={status().unread === "activity" && !status().busy && !selected() && !status().attention}
|
||||||
|
color={pulseColor()}
|
||||||
|
glowColor={accent()}
|
||||||
|
completionColor={accent()}
|
||||||
|
backgroundColor={pulseBackground()}
|
||||||
|
/>
|
||||||
|
<box zIndex={1} width="100%" flexDirection="row">
|
||||||
|
<text width={1}> </text>
|
||||||
|
<text width={2} fg={numberColor()} attributes={selected() ? TextAttributes.BOLD : undefined}>
|
||||||
|
{items().findIndex((item) => item.sessionID === tab.sessionID) + 1}
|
||||||
|
</text>
|
||||||
|
<Show
|
||||||
|
when={titleFades()}
|
||||||
|
fallback={
|
||||||
|
<text width={availableTitleWidth()} fg={foreground()} wrapMode="none">
|
||||||
|
{visibleTitle()}
|
||||||
|
</text>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<text width={availableTitleWidth()} fg={foreground()} wrapMode="none">
|
||||||
|
{visibleTitleParts().slice(0, -fadeWidth()).join("")}
|
||||||
|
<For each={fadedTitleParts()}>
|
||||||
|
{(character, index) => (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fg: tint(
|
||||||
|
foreground(),
|
||||||
|
pulseBackground(),
|
||||||
|
0.2 + 0.72 * (index() / Math.max(1, fadedTitleParts().length - 1)),
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{character}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</text>
|
||||||
|
</Show>
|
||||||
|
<text
|
||||||
|
position="absolute"
|
||||||
|
right={1}
|
||||||
|
zIndex={2}
|
||||||
|
width={1}
|
||||||
|
fg={closeColor()}
|
||||||
|
onMouseUp={(event) => {
|
||||||
|
event.stopPropagation()
|
||||||
|
tabs.close(tab.sessionID)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{hovered() === tab.sessionID ? "×" : ""}
|
||||||
|
</text>
|
||||||
|
</box>
|
||||||
|
</box>
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</For>
|
||||||
|
<Show when={layout().after > 0}>
|
||||||
|
<text width={SESSION_TAB_OVERFLOW_WIDTH} fg={theme.text.subdued}>
|
||||||
|
{layout().after}›
|
||||||
|
</text>
|
||||||
|
</Show>
|
||||||
|
</box>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -11,9 +11,9 @@ export { SPINNER_FRAMES } from "./spinner-frames"
|
|||||||
registerOpencodeSpinner()
|
registerOpencodeSpinner()
|
||||||
|
|
||||||
export function Spinner(props: { children?: JSX.Element; color?: RGBA }) {
|
export function Spinner(props: { children?: JSX.Element; color?: RGBA }) {
|
||||||
const { themeV2 } = useTheme()
|
const theme = useTheme()
|
||||||
const config = useConfig().data
|
const config = useConfig().data
|
||||||
const color = () => props.color ?? themeV2.text.subdued
|
const color = () => props.color ?? theme.text.subdued
|
||||||
return (
|
return (
|
||||||
<Show
|
<Show
|
||||||
when={config.animations ?? true}
|
when={config.animations ?? true}
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { createEffect, createMemo, createSignal, onCleanup, Show } from "solid-js"
|
import { createEffect, createMemo, createSignal, onCleanup, Show } from "solid-js"
|
||||||
import { useTheme } from "../context/theme"
|
import { useThemes } from "../context/theme"
|
||||||
import { Spinner } from "./spinner"
|
import { Spinner } from "./spinner"
|
||||||
|
|
||||||
export function StartupLoading(props: { ready: () => boolean }) {
|
export function StartupLoading(props: { ready: () => boolean }) {
|
||||||
const { themeV2 } = useTheme().contextual("elevated")
|
const theme = useThemes().contextual("elevated")
|
||||||
const [show, setShow] = createSignal(false)
|
const [show, setShow] = createSignal(false)
|
||||||
const text = createMemo(() => (props.ready() ? "Finishing startup..." : "Loading plugins..."))
|
const text = createMemo(() => (props.ready() ? "Finishing startup..." : "Loading plugins..."))
|
||||||
let wait: NodeJS.Timeout | undefined
|
let wait: NodeJS.Timeout | undefined
|
||||||
@@ -54,8 +54,8 @@ export function StartupLoading(props: { ready: () => boolean }) {
|
|||||||
return (
|
return (
|
||||||
<Show when={show()}>
|
<Show when={show()}>
|
||||||
<box position="absolute" zIndex={5000} left={0} right={0} bottom={1} justifyContent="center" alignItems="center">
|
<box position="absolute" zIndex={5000} left={0} right={0} bottom={1} justifyContent="center" alignItems="center">
|
||||||
<box backgroundColor={themeV2.background.default} paddingLeft={1} paddingRight={1}>
|
<box backgroundColor={theme.background.default} paddingLeft={1} paddingRight={1}>
|
||||||
<Spinner color={themeV2.text.subdued}>{text()}</Spinner>
|
<Spinner color={theme.text.subdued}>{text()}</Spinner>
|
||||||
</box>
|
</box>
|
||||||
</box>
|
</box>
|
||||||
</Show>
|
</Show>
|
||||||
|
|||||||
@@ -0,0 +1,266 @@
|
|||||||
|
import { OptimizedBuffer, Renderable, RGBA, type RenderableOptions, type RenderContext } from "@opentui/core"
|
||||||
|
import { extend } from "@opentui/solid"
|
||||||
|
|
||||||
|
type TabPulseOptions = RenderableOptions<TabPulseRenderable> & {
|
||||||
|
enabled?: boolean
|
||||||
|
active?: boolean
|
||||||
|
complete?: boolean
|
||||||
|
glow?: boolean
|
||||||
|
color?: RGBA
|
||||||
|
glowColor?: RGBA
|
||||||
|
completionColor?: RGBA
|
||||||
|
backgroundColor?: RGBA
|
||||||
|
}
|
||||||
|
|
||||||
|
const clamp = (value: number) => Math.max(0, Math.min(1, value))
|
||||||
|
const smootherstep = (value: number) => value * value * value * (value * (value * 6 - 15) + 10)
|
||||||
|
const RUN_DURATION = 2_800
|
||||||
|
const RUN_HEAD = 4
|
||||||
|
const RUN_TAIL = 18
|
||||||
|
const RUN_FADE_OUT = 500
|
||||||
|
const COMPLETION_DURATION = 900
|
||||||
|
const COMPLETION_ATTACK = 0.16
|
||||||
|
const GLOW_TAIL = 12
|
||||||
|
const GLOW_OPACITY = 0.16
|
||||||
|
const DEFAULT_FOREGROUND = RGBA.defaultForeground()
|
||||||
|
const intensityAt = (index: number, front: number, head: number, tail: number) => {
|
||||||
|
const distance = front - index
|
||||||
|
return distance < 0 ? smootherstep(clamp(1 + distance / head)) : smootherstep(clamp(1 - distance / tail))
|
||||||
|
}
|
||||||
|
const coast = (value: number) => {
|
||||||
|
const ramp = 0.2
|
||||||
|
if (value < ramp) return (value * value) / (2 * ramp * (1 - ramp))
|
||||||
|
if (value > 1 - ramp) return 1 - ((1 - value) * (1 - value)) / (2 * ramp * (1 - ramp))
|
||||||
|
return (value - ramp / 2) / (1 - ramp)
|
||||||
|
}
|
||||||
|
export const completionPulseOpacity = (progress: number) =>
|
||||||
|
progress < COMPLETION_ATTACK
|
||||||
|
? smootherstep(clamp(progress / COMPLETION_ATTACK))
|
||||||
|
: 1 - smootherstep(clamp((progress - COMPLETION_ATTACK) / (1 - COMPLETION_ATTACK)))
|
||||||
|
export const unreadGlowIntensity = (index: number, width: number) => {
|
||||||
|
const tail = Math.min(GLOW_TAIL, Math.max(1, width - 2))
|
||||||
|
return smootherstep(clamp(1 - Math.max(0, index - 1) / tail))
|
||||||
|
}
|
||||||
|
export function blendTabPulseColor(
|
||||||
|
output: RGBA,
|
||||||
|
background: RGBA,
|
||||||
|
glowColor: RGBA,
|
||||||
|
runningColor: RGBA,
|
||||||
|
completionColor: RGBA,
|
||||||
|
glow: number,
|
||||||
|
running: number,
|
||||||
|
completion: number,
|
||||||
|
) {
|
||||||
|
output.r = background.r + (glowColor.r - background.r) * glow
|
||||||
|
output.g = background.g + (glowColor.g - background.g) * glow
|
||||||
|
output.b = background.b + (glowColor.b - background.b) * glow
|
||||||
|
output.r += (runningColor.r - output.r) * running
|
||||||
|
output.g += (runningColor.g - output.g) * running
|
||||||
|
output.b += (runningColor.b - output.b) * running
|
||||||
|
output.r += (completionColor.r - output.r) * completion
|
||||||
|
output.g += (completionColor.g - output.g) * completion
|
||||||
|
output.b += (completionColor.b - output.b) * completion
|
||||||
|
}
|
||||||
|
class TabPulseRenderable extends Renderable {
|
||||||
|
private _enabled: boolean
|
||||||
|
private _active: boolean
|
||||||
|
private _complete: boolean
|
||||||
|
private _glow: boolean
|
||||||
|
private _color: RGBA
|
||||||
|
private _glowColor: RGBA
|
||||||
|
private _completionColor: RGBA
|
||||||
|
private _backgroundColor: RGBA
|
||||||
|
private clock = 0
|
||||||
|
private fadeClock: number | undefined
|
||||||
|
private completionClock: number | undefined
|
||||||
|
private completionPending = false
|
||||||
|
private renderColor = RGBA.fromInts(0, 0, 0)
|
||||||
|
|
||||||
|
constructor(ctx: RenderContext, options: TabPulseOptions = {}) {
|
||||||
|
const enabled = options.enabled ?? true
|
||||||
|
const active = options.active ?? false
|
||||||
|
super(ctx, { ...options, height: 1, live: enabled && active })
|
||||||
|
this._enabled = enabled
|
||||||
|
this._active = active
|
||||||
|
this._complete = options.complete ?? false
|
||||||
|
this._glow = options.glow ?? false
|
||||||
|
this._color = options.color ?? RGBA.defaultForeground()
|
||||||
|
this._glowColor = options.glowColor ?? this._color
|
||||||
|
this._completionColor = options.completionColor ?? this._color
|
||||||
|
this._backgroundColor = options.backgroundColor ?? RGBA.defaultBackground()
|
||||||
|
}
|
||||||
|
|
||||||
|
set enabled(value: boolean) {
|
||||||
|
if (value === this._enabled) return
|
||||||
|
this._enabled = value
|
||||||
|
if (!value) {
|
||||||
|
this.fadeClock = undefined
|
||||||
|
this.completionClock = undefined
|
||||||
|
this.completionPending = false
|
||||||
|
this.live = false
|
||||||
|
} else if (this._active) {
|
||||||
|
this.live = true
|
||||||
|
}
|
||||||
|
this.requestRender()
|
||||||
|
}
|
||||||
|
|
||||||
|
set active(value: boolean) {
|
||||||
|
if (value === this._active) return
|
||||||
|
this._active = value
|
||||||
|
if (!this._enabled) return
|
||||||
|
if (value) {
|
||||||
|
this.fadeClock = undefined
|
||||||
|
this.completionClock = undefined
|
||||||
|
this.completionPending = false
|
||||||
|
this.live = true
|
||||||
|
} else {
|
||||||
|
this.fadeClock = 0
|
||||||
|
this.completionPending = true
|
||||||
|
this.live = true
|
||||||
|
}
|
||||||
|
this.requestRender()
|
||||||
|
}
|
||||||
|
|
||||||
|
set complete(value: boolean) {
|
||||||
|
if (value === this._complete) return
|
||||||
|
this._complete = value
|
||||||
|
if (!value) {
|
||||||
|
this.completionClock = undefined
|
||||||
|
this.completionPending = false
|
||||||
|
}
|
||||||
|
if (value && this.completionPending) {
|
||||||
|
this.completionClock = 0
|
||||||
|
this.completionPending = false
|
||||||
|
this.live = this._enabled
|
||||||
|
}
|
||||||
|
this.requestRender()
|
||||||
|
}
|
||||||
|
|
||||||
|
set glow(value: boolean) {
|
||||||
|
if (value === this._glow) return
|
||||||
|
this._glow = value
|
||||||
|
this.requestRender()
|
||||||
|
}
|
||||||
|
|
||||||
|
set color(value: RGBA) {
|
||||||
|
if (value.equals(this._color)) return
|
||||||
|
this._color = value
|
||||||
|
this.requestRender()
|
||||||
|
}
|
||||||
|
|
||||||
|
set glowColor(value: RGBA) {
|
||||||
|
if (value.equals(this._glowColor)) return
|
||||||
|
this._glowColor = value
|
||||||
|
this.requestRender()
|
||||||
|
}
|
||||||
|
|
||||||
|
set completionColor(value: RGBA) {
|
||||||
|
if (value.equals(this._completionColor)) return
|
||||||
|
this._completionColor = value
|
||||||
|
this.requestRender()
|
||||||
|
}
|
||||||
|
|
||||||
|
set backgroundColor(value: RGBA) {
|
||||||
|
if (value.equals(this._backgroundColor)) return
|
||||||
|
this._backgroundColor = value
|
||||||
|
this.requestRender()
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override onUpdate(deltaTime: number): void {
|
||||||
|
if (!this._enabled) return
|
||||||
|
if (this._active || this.fadeClock !== undefined) this.clock += deltaTime
|
||||||
|
if (this.fadeClock !== undefined) {
|
||||||
|
this.fadeClock += deltaTime
|
||||||
|
if (this.fadeClock >= RUN_FADE_OUT) this.fadeClock = undefined
|
||||||
|
}
|
||||||
|
if (this.completionPending) {
|
||||||
|
if (this._complete) {
|
||||||
|
this.completionClock = 0
|
||||||
|
this.completionPending = false
|
||||||
|
} else if (this.fadeClock === undefined) {
|
||||||
|
this.completionPending = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (this.completionClock !== undefined) {
|
||||||
|
this.completionClock += deltaTime
|
||||||
|
if (this.completionClock >= COMPLETION_DURATION) this.completionClock = undefined
|
||||||
|
}
|
||||||
|
this.live = this._active || this.fadeClock !== undefined || this.completionClock !== undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override renderSelf(buffer: OptimizedBuffer): void {
|
||||||
|
if (!this.visible || this.isDestroyed || this.width <= 0) return
|
||||||
|
const runningOpacity = !this._enabled
|
||||||
|
? 0
|
||||||
|
: this._active
|
||||||
|
? 1
|
||||||
|
: this.fadeClock === undefined
|
||||||
|
? 0
|
||||||
|
: 1 - smootherstep(clamp(this.fadeClock / RUN_FADE_OUT))
|
||||||
|
const completionOpacity =
|
||||||
|
!this._enabled || this.completionClock === undefined
|
||||||
|
? 0
|
||||||
|
: completionPulseOpacity(this.completionClock / COMPLETION_DURATION)
|
||||||
|
if (!this._glow && runningOpacity === 0 && completionOpacity === 0) return
|
||||||
|
const progress = (this.clock % RUN_DURATION) / RUN_DURATION
|
||||||
|
const start = -RUN_HEAD
|
||||||
|
const end = this.width - 1 + RUN_TAIL
|
||||||
|
const front = start + coast(progress) * (end - start)
|
||||||
|
const secondFront = start + coast((progress + 0.5) % 1) * (end - start)
|
||||||
|
for (let index = 0; index < this.width; index++) {
|
||||||
|
const intensity = Math.max(
|
||||||
|
intensityAt(index, front, RUN_HEAD, RUN_TAIL),
|
||||||
|
intensityAt(index, secondFront, RUN_HEAD, RUN_TAIL),
|
||||||
|
)
|
||||||
|
const glow = this._glow ? unreadGlowIntensity(index, this.width) * GLOW_OPACITY : 0
|
||||||
|
const running = intensity * 0.14 * runningOpacity
|
||||||
|
const completion = completionOpacity * 0.18
|
||||||
|
blendTabPulseColor(
|
||||||
|
this.renderColor,
|
||||||
|
this._backgroundColor,
|
||||||
|
this._glowColor,
|
||||||
|
this._color,
|
||||||
|
this._completionColor,
|
||||||
|
glow,
|
||||||
|
running,
|
||||||
|
completion,
|
||||||
|
)
|
||||||
|
buffer.setCell(this.screenX + index, this.screenY, " ", DEFAULT_FOREGROUND, this.renderColor)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
declare module "@opentui/solid" {
|
||||||
|
interface OpenTUIComponents {
|
||||||
|
tab_pulse: typeof TabPulseRenderable
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extend({ tab_pulse: TabPulseRenderable })
|
||||||
|
|
||||||
|
export function TabPulse(props: {
|
||||||
|
enabled?: boolean
|
||||||
|
active: boolean
|
||||||
|
complete?: boolean
|
||||||
|
glow?: boolean
|
||||||
|
color: RGBA
|
||||||
|
glowColor?: RGBA
|
||||||
|
completionColor?: RGBA
|
||||||
|
backgroundColor: RGBA
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<tab_pulse
|
||||||
|
position="absolute"
|
||||||
|
zIndex={0}
|
||||||
|
width="100%"
|
||||||
|
enabled={props.enabled ?? true}
|
||||||
|
active={props.active}
|
||||||
|
complete={props.complete ?? false}
|
||||||
|
glow={props.glow ?? false}
|
||||||
|
color={props.color}
|
||||||
|
glowColor={props.glowColor ?? props.color}
|
||||||
|
completionColor={props.completionColor ?? props.color}
|
||||||
|
backgroundColor={props.backgroundColor}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,13 +1,13 @@
|
|||||||
import { onCleanup } from "solid-js"
|
import { onCleanup } from "solid-js"
|
||||||
import { useTheme } from "../context/theme"
|
import { useThemes } from "../context/theme"
|
||||||
import { useToast } from "../ui/toast"
|
import { useToast } from "../ui/toast"
|
||||||
|
|
||||||
export function ThemeErrorToast() {
|
export function ThemeErrorToast() {
|
||||||
const theme = useTheme()
|
const themes = useThemes()
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
|
|
||||||
onCleanup(
|
onCleanup(
|
||||||
theme.onError(({ name, error }) =>
|
themes.onError(({ name, error }) =>
|
||||||
toast.show({
|
toast.show({
|
||||||
variant: "error",
|
variant: "error",
|
||||||
title: `Failed to load theme: ${name}`,
|
title: `Failed to load theme: ${name}`,
|
||||||
|
|||||||
@@ -122,6 +122,13 @@ export const Info = Schema.Struct({
|
|||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
).annotate({ description: "Session transcript presentation settings" }),
|
).annotate({ description: "Session transcript presentation settings" }),
|
||||||
|
tabs: Schema.optional(
|
||||||
|
Schema.Struct({
|
||||||
|
enabled: Schema.optional(Schema.Boolean).annotate({
|
||||||
|
description: "Use a persistent session tab strip instead of pinned quick-switch sessions",
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
).annotate({ description: "Session tab settings" }),
|
||||||
mini: Schema.optional(
|
mini: Schema.optional(
|
||||||
Schema.Struct({
|
Schema.Struct({
|
||||||
thinking: Schema.optional(Schema.Literals(["show", "hide"])).annotate({
|
thinking: Schema.optional(Schema.Literals(["show", "hide"])).annotate({
|
||||||
|
|||||||
@@ -87,6 +87,13 @@ export const Definitions = {
|
|||||||
session_move: keybind("none", "Move session"),
|
session_move: keybind("none", "Move session"),
|
||||||
session_new: keybind("<leader>n", "Create a new session"),
|
session_new: keybind("<leader>n", "Create a new session"),
|
||||||
session_list: keybind("<leader>l", "List all sessions"),
|
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_timeline: keybind("<leader>g", "Show session timeline"),
|
||||||
session_fork: keybind("none", "Fork session from message"),
|
session_fork: keybind("none", "Fork session from message"),
|
||||||
session_rename: keybind("ctrl+r", "Rename session"),
|
session_rename: keybind("ctrl+r", "Rename session"),
|
||||||
@@ -97,7 +104,7 @@ export const Definitions = {
|
|||||||
session_background: keybind("ctrl+b", "Background blocking session tools"),
|
session_background: keybind("ctrl+b", "Background blocking session tools"),
|
||||||
session_compact: keybind("<leader>c", "Compact the session"),
|
session_compact: keybind("<leader>c", "Compact the session"),
|
||||||
session_queued_prompts: keybind("<leader>q", "View pending work"),
|
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: keybind("right", "Go to next child session"),
|
||||||
session_child_cycle_reverse: keybind("left", "Go to previous child session"),
|
session_child_cycle_reverse: keybind("left", "Go to previous child session"),
|
||||||
session_parent: keybind("up", "Go to parent 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_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_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_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"),
|
stash_delete: keybind("ctrl+d", "Delete stash entry"),
|
||||||
model_provider_list: keybind("ctrl+a", "Open provider list from model dialog"),
|
model_provider_list: keybind("ctrl+a", "Open provider list from model dialog"),
|
||||||
@@ -214,24 +230,9 @@ export const Definitions = {
|
|||||||
"permission.prompt.fullscreen": keybind("ctrl+f", "Toggle permission prompt fullscreen"),
|
"permission.prompt.fullscreen": keybind("ctrl+f", "Toggle permission prompt fullscreen"),
|
||||||
"plugins.toggle": keybind("space", "Toggle plugin"),
|
"plugins.toggle": keybind("space", "Toggle plugin"),
|
||||||
"dialog.mcp.toggle": keybind("space", "Toggle MCP server"),
|
"dialog.mcp.toggle": keybind("space", "Toggle MCP server"),
|
||||||
"dialog.plugins.install": keybind("shift+i", "Install plugin from plugin dialog"),
|
|
||||||
|
|
||||||
terminal_suspend: keybind("ctrl+z", "Suspend terminal"),
|
terminal_suspend: keybind("ctrl+z", "Suspend terminal"),
|
||||||
terminal_title_toggle: keybind("none", "Toggle terminal title"),
|
terminal_title_toggle: keybind("none", "Toggle terminal title"),
|
||||||
plugin_manager: keybind("none", "Open plugin manager dialog"),
|
|
||||||
plugin_install: keybind("none", "Install plugin"),
|
|
||||||
|
|
||||||
which_key_toggle: keybind("ctrl+alt+k", "Toggle which-key panel"),
|
|
||||||
which_key_layout_toggle: keybind("ctrl+alt+shift+k", "Switch which-key layout"),
|
|
||||||
which_key_pending_toggle: keybind("ctrl+alt+shift+p", "Toggle which-key pending preview"),
|
|
||||||
which_key_group_previous: keybind("ctrl+alt+left,ctrl+alt+[", "Previous which-key group"),
|
|
||||||
which_key_group_next: keybind("ctrl+alt+right,ctrl+alt+]", "Next which-key group"),
|
|
||||||
which_key_scroll_up: keybind("ctrl+alt+up,ctrl+alt+p", "Scroll which-key up"),
|
|
||||||
which_key_scroll_down: keybind("ctrl+alt+down,ctrl+alt+n", "Scroll which-key down"),
|
|
||||||
which_key_page_up: keybind("ctrl+alt+pageup", "Page which-key up"),
|
|
||||||
which_key_page_down: keybind("ctrl+alt+pagedown", "Page which-key down"),
|
|
||||||
which_key_home: keybind("ctrl+alt+home", "Jump to first which-key binding"),
|
|
||||||
which_key_end: keybind("ctrl+alt+end", "Jump to last which-key binding"),
|
|
||||||
} satisfies Record<string, Definition>
|
} satisfies Record<string, Definition>
|
||||||
|
|
||||||
type KeybindName = keyof typeof Definitions
|
type KeybindName = keyof typeof Definitions
|
||||||
@@ -289,6 +290,13 @@ export const CommandMap = {
|
|||||||
session_move: "session.move",
|
session_move: "session.move",
|
||||||
session_new: "session.new",
|
session_new: "session.new",
|
||||||
session_list: "session.list",
|
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_timeline: "session.timeline",
|
||||||
session_fork: "session.fork",
|
session_fork: "session.fork",
|
||||||
session_rename: "session.rename",
|
session_rename: "session.rename",
|
||||||
@@ -313,6 +321,15 @@ export const CommandMap = {
|
|||||||
session_quick_switch_7: "session.quick_switch.7",
|
session_quick_switch_7: "session.quick_switch.7",
|
||||||
session_quick_switch_8: "session.quick_switch.8",
|
session_quick_switch_8: "session.quick_switch.8",
|
||||||
session_quick_switch_9: "session.quick_switch.9",
|
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",
|
stash_delete: "stash.delete",
|
||||||
model_provider_list: "model.dialog.provider",
|
model_provider_list: "model.dialog.provider",
|
||||||
model_favorite_toggle: "model.dialog.favorite",
|
model_favorite_toggle: "model.dialog.favorite",
|
||||||
@@ -393,19 +410,6 @@ export const CommandMap = {
|
|||||||
history_next: "prompt.history.next",
|
history_next: "prompt.history.next",
|
||||||
terminal_suspend: "terminal.suspend",
|
terminal_suspend: "terminal.suspend",
|
||||||
terminal_title_toggle: "terminal.title.toggle",
|
terminal_title_toggle: "terminal.title.toggle",
|
||||||
plugin_manager: "plugins.list",
|
|
||||||
plugin_install: "plugins.install",
|
|
||||||
which_key_toggle: "which-key.toggle",
|
|
||||||
which_key_layout_toggle: "which-key.layout.toggle",
|
|
||||||
which_key_pending_toggle: "which-key.pending.toggle",
|
|
||||||
which_key_group_previous: "which-key.group.previous",
|
|
||||||
which_key_group_next: "which-key.group.next",
|
|
||||||
which_key_scroll_up: "which-key.scroll.up",
|
|
||||||
which_key_scroll_down: "which-key.scroll.down",
|
|
||||||
which_key_page_up: "which-key.page.up",
|
|
||||||
which_key_page_down: "which-key.page.down",
|
|
||||||
which_key_home: "which-key.home",
|
|
||||||
which_key_end: "which-key.end",
|
|
||||||
} satisfies BindingCommandMap
|
} satisfies BindingCommandMap
|
||||||
const CommandDescriptions = Object.fromEntries(
|
const CommandDescriptions = Object.fromEntries(
|
||||||
Object.entries(Definitions).map(([name, item]) => [
|
Object.entries(Definitions).map(([name, item]) => [
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ import {
|
|||||||
type ModelPreference,
|
type ModelPreference,
|
||||||
type ModelPreferenceModel,
|
type ModelPreferenceModel,
|
||||||
} from "../model-preference"
|
} from "../model-preference"
|
||||||
import { useTheme } from "./theme"
|
import { useTheme, useThemes } from "./theme"
|
||||||
import { useToast } from "../ui/toast"
|
import { useToast } from "../ui/toast"
|
||||||
import { useRoute } from "./route"
|
import { useRoute } from "./route"
|
||||||
import { useData } from "./data"
|
import { useData } from "./data"
|
||||||
@@ -50,7 +50,8 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
|||||||
const data = useData()
|
const data = useData()
|
||||||
const client = useClient()
|
const client = useClient()
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
const { themeV2, mode } = useTheme()
|
const theme = useTheme()
|
||||||
|
const { mode } = useThemes()
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const paths = useTuiPaths()
|
const paths = useTuiPaths()
|
||||||
const args = useArgs()
|
const args = useArgs()
|
||||||
@@ -82,7 +83,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
|||||||
const colors = createMemo(() => {
|
const colors = createMemo(() => {
|
||||||
const step = mode() === "light" ? 800 : 200
|
const step = mode() === "light" ? 800 : 200
|
||||||
return dedupeWith(
|
return dedupeWith(
|
||||||
themeV2.categorical.map((scale) => scale[step]),
|
theme.categorical.map((scale) => scale[step]),
|
||||||
(first, second) => first.equals(second),
|
(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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.tabs?.enabled ?? false
|
||||||
|
const state: {
|
||||||
|
pending: boolean
|
||||||
|
saving: boolean
|
||||||
|
snapshot: string
|
||||||
|
value?: PersistedState
|
||||||
|
} = { pending: false, saving: false, snapshot: "" }
|
||||||
|
const [store, setStore] = createStore<PersistedState & { ready: boolean }>({
|
||||||
|
ready: false,
|
||||||
|
tabs: [],
|
||||||
|
unread: {},
|
||||||
|
})
|
||||||
|
let history: SessionTabHistory = { entries: [], index: -1 }
|
||||||
|
|
||||||
|
const root = (sessionID: string) => data.session.root(sessionID)
|
||||||
|
const current = () => (route.data.type === "session" ? root(route.data.sessionID) : undefined)
|
||||||
|
const status = (sessionID: string) => {
|
||||||
|
const session = root(sessionID)
|
||||||
|
const members = data.session.family(session)
|
||||||
|
const family = members.length > 0 ? members : [session]
|
||||||
|
return {
|
||||||
|
unread: store.unread[session],
|
||||||
|
attention: family.some(
|
||||||
|
(id) => (data.session.permission.list(id)?.length ?? 0) > 0 || (data.session.form.list(id)?.length ?? 0) > 0,
|
||||||
|
),
|
||||||
|
busy: family.some((id) => data.session.status(id) === "running" || data.session.pending.list(id).length > 0),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function save() {
|
||||||
|
if (!store.ready) {
|
||||||
|
state.pending = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const value = { tabs: [...store.tabs], unread: { ...store.unread } }
|
||||||
|
const snapshot = JSON.stringify(value)
|
||||||
|
if (snapshot === state.snapshot && !state.saving) return
|
||||||
|
state.value = value
|
||||||
|
state.pending = true
|
||||||
|
flush()
|
||||||
|
}
|
||||||
|
|
||||||
|
function flush() {
|
||||||
|
if (state.saving || !state.pending || !state.value) return
|
||||||
|
const value = state.value
|
||||||
|
const snapshot = JSON.stringify(value)
|
||||||
|
state.pending = false
|
||||||
|
if (snapshot === state.snapshot) return
|
||||||
|
state.saving = true
|
||||||
|
void writeJsonAtomic(filePath, value)
|
||||||
|
.then(() => {
|
||||||
|
state.snapshot = snapshot
|
||||||
|
})
|
||||||
|
.catch(() => {})
|
||||||
|
.finally(() => {
|
||||||
|
state.saving = false
|
||||||
|
flush()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function open(sessionID: string) {
|
||||||
|
const session = root(sessionID)
|
||||||
|
const next = openSessionTab(store.tabs, { sessionID: session, title: data.session.get(session)?.title })
|
||||||
|
if (next === store.tabs) return { sessionID: session, changed: false }
|
||||||
|
setStore("tabs", reconcile(next))
|
||||||
|
return { sessionID: session, changed: true }
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearUnread(sessionID: string) {
|
||||||
|
const session = root(sessionID)
|
||||||
|
if (!store.unread[session]) return false
|
||||||
|
setStore(
|
||||||
|
"unread",
|
||||||
|
produce((draft) => {
|
||||||
|
delete draft[session]
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
function markUnread(sessionID: string, unread: SessionTabUnread) {
|
||||||
|
if (!enabled()) return
|
||||||
|
const session = root(sessionID)
|
||||||
|
if (current() === session || !store.tabs.some((tab) => tab.sessionID === session)) return
|
||||||
|
if (store.unread[session] === unread) return
|
||||||
|
setStore("unread", session, unread)
|
||||||
|
save()
|
||||||
|
}
|
||||||
|
|
||||||
|
readJson<unknown>(filePath)
|
||||||
|
.then((value) => {
|
||||||
|
if (!isRecord(value)) return
|
||||||
|
const persisted = value
|
||||||
|
if (Array.isArray(persisted.tabs))
|
||||||
|
setStore(
|
||||||
|
"tabs",
|
||||||
|
persisted.tabs.flatMap((tab) => {
|
||||||
|
if (!isRecord(tab) || typeof tab.sessionID !== "string") return []
|
||||||
|
if ("title" in tab && tab.title !== undefined && typeof tab.title !== "string") return []
|
||||||
|
return [{ sessionID: tab.sessionID, title: typeof tab.title === "string" ? tab.title : undefined }]
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
if (persisted.unread && typeof persisted.unread === "object")
|
||||||
|
setStore(
|
||||||
|
"unread",
|
||||||
|
Object.fromEntries(
|
||||||
|
Object.entries(persisted.unread).filter(
|
||||||
|
(entry): entry is [string, SessionTabUnread] => entry[1] === "activity" || entry[1] === "error",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.catch(() => {})
|
||||||
|
.finally(() => {
|
||||||
|
setStore("ready", true)
|
||||||
|
if (state.pending) save()
|
||||||
|
else state.snapshot = JSON.stringify({ tabs: store.tabs, unread: store.unread })
|
||||||
|
})
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
if (!enabled()) return
|
||||||
|
if (!store.ready || route.data.type !== "session" || route.data.sessionID === "dummy") return
|
||||||
|
const routeSessionID = route.data.sessionID
|
||||||
|
batch(() => {
|
||||||
|
const opened = open(routeSessionID)
|
||||||
|
history = recordSessionTabHistory(history, opened.sessionID)
|
||||||
|
const changed = clearUnread(opened.sessionID)
|
||||||
|
if (opened.changed || changed) untrack(save)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
if (!enabled() || !store.ready) return
|
||||||
|
const next = store.tabs.reduce<SessionTab[]>((tabs, tab) => {
|
||||||
|
const sessionID = root(tab.sessionID)
|
||||||
|
return openSessionTab(tabs, { sessionID, title: data.session.get(sessionID)?.title ?? tab.title })
|
||||||
|
}, [])
|
||||||
|
const unread = Object.entries(store.unread).reduce<Record<string, SessionTabUnread>>((result, entry) => {
|
||||||
|
const sessionID = root(entry[0])
|
||||||
|
result[sessionID] = result[sessionID] === "error" ? "error" : entry[1]
|
||||||
|
return result
|
||||||
|
}, {})
|
||||||
|
if (isDeepEqual(next, store.tabs) && isDeepEqual(unread, store.unread)) return
|
||||||
|
batch(() => {
|
||||||
|
setStore("tabs", reconcile(next))
|
||||||
|
setStore("unread", reconcile(unread))
|
||||||
|
})
|
||||||
|
save()
|
||||||
|
})
|
||||||
|
|
||||||
|
onCleanup(event.on("session.execution.succeeded", (evt) => markUnread(evt.data.sessionID, "activity")))
|
||||||
|
onCleanup(event.on("session.execution.interrupted", (evt) => markUnread(evt.data.sessionID, "activity")))
|
||||||
|
onCleanup(event.on("session.execution.failed", (evt) => markUnread(evt.data.sessionID, "error")))
|
||||||
|
onCleanup(
|
||||||
|
event.on("session.error", (evt) => {
|
||||||
|
if (evt.data.sessionID) markUnread(evt.data.sessionID, "error")
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
onCleanup(
|
||||||
|
event.on("session.deleted", (evt) => {
|
||||||
|
remove(evt.data.sessionID, enabled())
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
function remove(sessionID: string, navigate: boolean) {
|
||||||
|
const target = root(sessionID)
|
||||||
|
const closed = closeSessionTab(store.tabs, target)
|
||||||
|
if (closed.tabs.length === store.tabs.length) return
|
||||||
|
const selected = navigate && current() === target
|
||||||
|
const previous = selected
|
||||||
|
? moveSessionTabHistory(recordSessionTabHistory(history, target), closed.tabs, target, -1)
|
||||||
|
: { history, sessionID: undefined }
|
||||||
|
const next = previous.sessionID ?? closed.next
|
||||||
|
history = previous.history
|
||||||
|
batch(() => {
|
||||||
|
setStore("tabs", reconcile(closed.tabs))
|
||||||
|
clearUnread(target)
|
||||||
|
if (selected) route.navigate(next ? { type: "session", sessionID: next } : { type: "home" })
|
||||||
|
})
|
||||||
|
save()
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
enabled,
|
||||||
|
tabs() {
|
||||||
|
return store.tabs
|
||||||
|
},
|
||||||
|
current,
|
||||||
|
status,
|
||||||
|
select(sessionID: string) {
|
||||||
|
if (!enabled()) return
|
||||||
|
route.navigate({ type: "session", sessionID: root(sessionID) })
|
||||||
|
},
|
||||||
|
close(sessionID?: string) {
|
||||||
|
if (!enabled()) return
|
||||||
|
const target = sessionID ? root(sessionID) : current()
|
||||||
|
if (!target) {
|
||||||
|
const previous = store.tabs.at(-1)
|
||||||
|
if (route.data.type === "home" && previous) route.navigate({ type: "session", sessionID: previous.sessionID })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
remove(target, true)
|
||||||
|
},
|
||||||
|
cycle(direction: 1 | -1) {
|
||||||
|
if (!enabled()) return
|
||||||
|
const tab = cycleSessionTab(store.tabs, current(), direction)
|
||||||
|
if (tab) route.navigate({ type: "session", sessionID: tab.sessionID })
|
||||||
|
},
|
||||||
|
cycleUnread(direction: 1 | -1) {
|
||||||
|
if (!enabled()) return
|
||||||
|
const tab = cycleSessionTab(
|
||||||
|
store.tabs.filter((tab) => store.unread[tab.sessionID] || status(tab.sessionID).attention),
|
||||||
|
current(),
|
||||||
|
direction,
|
||||||
|
)
|
||||||
|
if (tab) route.navigate({ type: "session", sessionID: tab.sessionID })
|
||||||
|
},
|
||||||
|
history(direction: 1 | -1) {
|
||||||
|
if (!enabled()) return
|
||||||
|
const next = moveSessionTabHistory(history, store.tabs, current(), direction)
|
||||||
|
history = next.history
|
||||||
|
if (next.sessionID) route.navigate({ type: "session", sessionID: next.sessionID })
|
||||||
|
},
|
||||||
|
selectIndex(index: number) {
|
||||||
|
if (!enabled()) return
|
||||||
|
const tab = store.tabs[index]
|
||||||
|
if (tab) route.navigate({ type: "session", sessionID: tab.sessionID })
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -96,13 +96,13 @@ type State = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type ContextName = "elevated" | "overlay"
|
type ContextName = "elevated" | "overlay"
|
||||||
type ThemeService = {
|
type Themes = {
|
||||||
themeV2: ComponentTheme
|
current: ComponentTheme
|
||||||
contextual(context: ContextName): ThemeService
|
contextual(context: ContextName): ComponentTheme
|
||||||
readonly selected: string
|
readonly selected: string
|
||||||
all: typeof allThemes
|
all: typeof allThemes
|
||||||
has: typeof hasTheme
|
has: typeof hasTheme
|
||||||
syntax: Accessor<SyntaxStyle>
|
currentSyntax: Accessor<SyntaxStyle>
|
||||||
mode: Accessor<"dark" | "light">
|
mode: Accessor<"dark" | "light">
|
||||||
modes: Accessor<readonly ("dark" | "light")[]>
|
modes: Accessor<readonly ("dark" | "light")[]>
|
||||||
supports(mode: "dark" | "light"): boolean
|
supports(mode: "dark" | "light"): boolean
|
||||||
@@ -308,7 +308,7 @@ const themeContext = createSimpleContext({
|
|||||||
const valuesV2 = () => selected().theme
|
const valuesV2 = () => selected().theme
|
||||||
valuesV2()
|
valuesV2()
|
||||||
themePerformance.set("Init", `${(performance.now() - initStarted).toFixed(2)} ms`)
|
themePerformance.set("Init", `${(performance.now() - initStarted).toFixed(2)} ms`)
|
||||||
const themeV2 = createComponentTheme(valuesV2, mode)
|
const current = createComponentTheme(valuesV2, mode)
|
||||||
const contextsV2 = {
|
const contextsV2 = {
|
||||||
elevated: createComponentTheme(() => valuesV2().contexts["@context:elevated"] ?? valuesV2(), mode),
|
elevated: createComponentTheme(() => valuesV2().contexts["@context:elevated"] ?? valuesV2(), mode),
|
||||||
overlay: createComponentTheme(() => valuesV2().contexts["@context:overlay"] ?? valuesV2(), mode),
|
overlay: createComponentTheme(() => valuesV2().contexts["@context:overlay"] ?? valuesV2(), mode),
|
||||||
@@ -316,19 +316,19 @@ const themeContext = createSimpleContext({
|
|||||||
|
|
||||||
createEffect(() => renderer.setBackgroundColor(valuesV2().background.default))
|
createEffect(() => renderer.setBackgroundColor(valuesV2().background.default))
|
||||||
|
|
||||||
const syntax = createSyntaxStyleMemo(() => generateSyntax(valuesV2(), mode()))
|
const currentSyntax = createSyntaxStyleMemo(() => generateSyntax(valuesV2(), mode()))
|
||||||
function contextual(context: ContextName) {
|
function contextual(context: ContextName) {
|
||||||
return contextualServices[context]
|
return contextsV2[context]
|
||||||
}
|
}
|
||||||
const service: ThemeService = {
|
const service: Themes = {
|
||||||
themeV2,
|
current,
|
||||||
|
currentSyntax,
|
||||||
contextual,
|
contextual,
|
||||||
get selected() {
|
get selected() {
|
||||||
return store.active
|
return store.active
|
||||||
},
|
},
|
||||||
all: allThemes,
|
all: allThemes,
|
||||||
has: hasTheme,
|
has: hasTheme,
|
||||||
syntax,
|
|
||||||
mode,
|
mode,
|
||||||
modes,
|
modes,
|
||||||
supports: (requested) => modes().includes(requested),
|
supports: (requested) => modes().includes(requested),
|
||||||
@@ -355,21 +355,28 @@ const themeContext = createSimpleContext({
|
|||||||
return store.ready
|
return store.ready
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
const contextualServices = {
|
return {
|
||||||
elevated: Object.assign(Object.create(service) as ThemeService, { themeV2: contextsV2.elevated }),
|
current,
|
||||||
overlay: Object.assign(Object.create(service) as ThemeService, { themeV2: contextsV2.overlay }),
|
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 const ThemeProvider = themeContext.provider
|
||||||
|
|
||||||
export function ThemeContextProvider(props: ParentProps<{ context: ContextName }>) {
|
export function ThemeContextProvider(props: ParentProps<{ context: ContextName }>) {
|
||||||
const theme = useTheme()
|
const themes = useThemes()
|
||||||
return (
|
return (
|
||||||
<themeContext.context.Provider value={theme.contextual(props.context)}>
|
<themeContext.context.Provider value={{ current: themes.contextual(props.context), themes, ready: themes.ready }}>
|
||||||
{props.children}
|
{props.children}
|
||||||
</themeContext.context.Provider>
|
</themeContext.context.Provider>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,49 +0,0 @@
|
|||||||
import type { TuiPlugin, TuiPluginApi, TuiPluginModule } from "@opencode-ai/plugin/v1/tui"
|
|
||||||
import type { PluginRuntime } from "../plugin/runtime"
|
|
||||||
import PluginManager from "./system/plugins"
|
|
||||||
import WhichKey from "./system/which-key"
|
|
||||||
|
|
||||||
export type BuiltinTuiPlugin = Omit<TuiPluginModule, "id"> & {
|
|
||||||
id: string
|
|
||||||
tui: TuiPlugin
|
|
||||||
enabled?: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createBuiltinPlugins(): BuiltinTuiPlugin[] {
|
|
||||||
return [PluginManager, WhichKey]
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function loadBuiltinPlugins(api: TuiPluginApi, runtime: PluginRuntime) {
|
|
||||||
const slots = runtime.setupSlots(api)
|
|
||||||
const dispose: Array<() => void> = []
|
|
||||||
|
|
||||||
for (const plugin of createBuiltinPlugins()) {
|
|
||||||
if (plugin.enabled === false) continue
|
|
||||||
const scoped = Object.assign(Object.create(api), {
|
|
||||||
slots: {
|
|
||||||
register(input: Parameters<typeof slots.register>[0]) {
|
|
||||||
dispose.push(slots.register({ ...input, id: plugin.id }))
|
|
||||||
return plugin.id
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}) as TuiPluginApi
|
|
||||||
const now = Date.now()
|
|
||||||
await plugin.tui(scoped, undefined, {
|
|
||||||
id: plugin.id,
|
|
||||||
source: "internal",
|
|
||||||
spec: plugin.id,
|
|
||||||
target: plugin.id,
|
|
||||||
first_time: now,
|
|
||||||
last_time: now,
|
|
||||||
time_changed: now,
|
|
||||||
load_count: 1,
|
|
||||||
fingerprint: plugin.id,
|
|
||||||
state: "first",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
for (const fn of dispose.reverse()) fn()
|
|
||||||
slots.dispose()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -11,7 +11,7 @@ function Directory(props: { context: Plugin.Context; maxWidth: number }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Show when={directory()}>
|
<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>
|
</Show>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -24,18 +24,16 @@ function Mcp(props: { context: Plugin.Context }) {
|
|||||||
return (
|
return (
|
||||||
<Show when={list().length}>
|
<Show when={list().length}>
|
||||||
<box gap={1} flexDirection="row" flexShrink={0}>
|
<box gap={1} flexDirection="row" flexShrink={0}>
|
||||||
<text fg={props.context.theme.themeV2.text.default}>
|
<text fg={props.context.theme.text.default}>
|
||||||
<Switch>
|
<Switch>
|
||||||
<Match when={failed()}>
|
<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>
|
||||||
<Match when={true}>
|
<Match when={true}>
|
||||||
<span
|
<span
|
||||||
style={{
|
style={{
|
||||||
fg:
|
fg:
|
||||||
count() > 0
|
count() > 0 ? props.context.theme.text.feedback.success.default : props.context.theme.text.subdued,
|
||||||
? props.context.theme.themeV2.text.feedback.success.default
|
|
||||||
: props.context.theme.themeV2.text.subdued,
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
⊙{" "}
|
⊙{" "}
|
||||||
@@ -44,7 +42,7 @@ function Mcp(props: { context: Plugin.Context }) {
|
|||||||
</Switch>
|
</Switch>
|
||||||
{count()} MCP
|
{count()} MCP
|
||||||
</text>
|
</text>
|
||||||
<text fg={props.context.theme.themeV2.text.subdued}>/status</text>
|
<text fg={props.context.theme.text.subdued}>/status</text>
|
||||||
</box>
|
</box>
|
||||||
</Show>
|
</Show>
|
||||||
)
|
)
|
||||||
@@ -77,7 +75,7 @@ function View(props: { context: Plugin.Context }) {
|
|||||||
<Mcp context={props.context} />
|
<Mcp context={props.context} />
|
||||||
<box flexGrow={1} />
|
<box flexGrow={1} />
|
||||||
<box flexShrink={0}>
|
<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>
|
||||||
</box>
|
</box>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { Plugin } from "@opencode-ai/plugin/tui"
|
import { Plugin } from "@opencode-ai/plugin/tui"
|
||||||
import { createMemo, Show } from "solid-js"
|
import { createMemo, Show } from "solid-js"
|
||||||
import { useTheme } from "../../context/theme"
|
|
||||||
import { contextUsage } from "../../util/session"
|
import { contextUsage } from "../../util/session"
|
||||||
|
|
||||||
const money = new Intl.NumberFormat("en-US", {
|
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 }) {
|
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 msg = createMemo(() => props.context.data.session.message.list(props.sessionID))
|
||||||
const session = createMemo(() => props.context.data.session.get(props.sessionID))
|
const session = createMemo(() => props.context.data.session.get(props.sessionID))
|
||||||
const cost = createMemo(() => props.context.data.session.cost(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 (
|
return (
|
||||||
<box>
|
<box>
|
||||||
<text fg={themeV2.text.default}>
|
<text fg={theme.text.default}>
|
||||||
<b>Context</b>
|
<b>Context</b>
|
||||||
</text>
|
</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) => (
|
{(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}>
|
<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>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Show>
|
</Show>
|
||||||
<text fg={themeV2.text.subdued}>{money.format(cost())} spent</text>
|
<text fg={theme.text.subdued}>{money.format(cost())} spent</text>
|
||||||
</box>
|
</box>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ function View(props: { context: Plugin.Context }) {
|
|||||||
)
|
)
|
||||||
return (
|
return (
|
||||||
<Show when={directory()}>
|
<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>
|
</Show>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
import { Plugin } from "@opencode-ai/plugin/tui"
|
import { Plugin, usePlugin } from "@opencode-ai/plugin/tui"
|
||||||
import { useTheme } from "../../context/theme"
|
|
||||||
|
|
||||||
function View() {
|
function View() {
|
||||||
const { themeV2 } = useTheme()
|
const context = usePlugin()
|
||||||
|
const theme = context.theme
|
||||||
return (
|
return (
|
||||||
<box>
|
<box>
|
||||||
<text fg={themeV2.text.default}>
|
<text fg={theme.text.default}>
|
||||||
<b>LSP</b>
|
<b>LSP</b>
|
||||||
</text>
|
</text>
|
||||||
<text fg={themeV2.text.subdued}>LSP status unavailable</text>
|
<text fg={theme.text.subdued}>LSP status unavailable</text>
|
||||||
</box>
|
</box>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
import { Plugin } from "@opencode-ai/plugin/tui"
|
import { Plugin } from "@opencode-ai/plugin/tui"
|
||||||
import { createMemo, For, Match, Show, Switch, createSignal } from "solid-js"
|
import { createMemo, For, Match, Show, Switch, createSignal } from "solid-js"
|
||||||
import { useTheme } from "../../context/theme"
|
|
||||||
|
|
||||||
function View(props: { context: Plugin.Context; sessionID: string }) {
|
function View(props: { context: Plugin.Context; sessionID: string }) {
|
||||||
const [open, setOpen] = createSignal(true)
|
const [open, setOpen] = createSignal(true)
|
||||||
const { themeV2 } = useTheme()
|
const theme = props.context.theme
|
||||||
const session = createMemo(() => props.context.data.session.get(props.sessionID))
|
const session = createMemo(() => props.context.data.session.get(props.sessionID))
|
||||||
const list = createMemo(() => props.context.data.location.mcp.server.list(session()?.location) ?? [])
|
const list = createMemo(() => props.context.data.location.mcp.server.list(session()?.location) ?? [])
|
||||||
const on = createMemo(() => list().filter((item) => item.status.status === "connected").length)
|
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) => {
|
const dot = (status: string) => {
|
||||||
if (status === "connected") return themeV2.text.feedback.success.default
|
if (status === "connected") return theme.text.feedback.success.default
|
||||||
if (status === "failed") return themeV2.text.feedback.error.default
|
if (status === "failed") return theme.text.feedback.error.default
|
||||||
if (status === "disabled") return themeV2.text.subdued
|
if (status === "disabled") return theme.text.subdued
|
||||||
if (status === "needs_auth") return themeV2.text.feedback.warning.default
|
if (status === "needs_auth") return theme.text.feedback.warning.default
|
||||||
if (status === "needs_client_registration") return themeV2.text.feedback.error.default
|
if (status === "needs_client_registration") return theme.text.feedback.error.default
|
||||||
return themeV2.text.subdued
|
return theme.text.subdued
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -32,12 +31,12 @@ function View(props: { context: Plugin.Context; sessionID: string }) {
|
|||||||
<box>
|
<box>
|
||||||
<box flexDirection="row" gap={1} onMouseDown={() => list().length > 2 && setOpen((x) => !x)}>
|
<box flexDirection="row" gap={1} onMouseDown={() => list().length > 2 && setOpen((x) => !x)}>
|
||||||
<Show when={list().length > 2}>
|
<Show when={list().length > 2}>
|
||||||
<text fg={themeV2.text.default}>{open() ? "▼" : "▶"}</text>
|
<text fg={theme.text.default}>{open() ? "▼" : "▶"}</text>
|
||||||
</Show>
|
</Show>
|
||||||
<text fg={themeV2.text.default}>
|
<text fg={theme.text.default}>
|
||||||
<b>MCP</b>
|
<b>MCP</b>
|
||||||
<Show when={!open()}>
|
<Show when={!open()}>
|
||||||
<span style={{ fg: themeV2.text.subdued }}>
|
<span style={{ fg: theme.text.subdued }}>
|
||||||
{" "}
|
{" "}
|
||||||
({on()} active{bad() > 0 ? `, ${bad()} error${bad() > 1 ? "s" : ""}` : ""})
|
({on()} active{bad() > 0 ? `, ${bad()} error${bad() > 1 ? "s" : ""}` : ""})
|
||||||
</span>
|
</span>
|
||||||
@@ -56,9 +55,9 @@ function View(props: { context: Plugin.Context; sessionID: string }) {
|
|||||||
>
|
>
|
||||||
•
|
•
|
||||||
</text>
|
</text>
|
||||||
<text fg={themeV2.text.default} wrapMode="word">
|
<text fg={theme.text.default} wrapMode="word">
|
||||||
{item.name}{" "}
|
{item.name}{" "}
|
||||||
<span style={{ fg: themeV2.text.subdued }}>
|
<span style={{ fg: theme.text.subdued }}>
|
||||||
<Switch fallback={item.status.status}>
|
<Switch fallback={item.status.status}>
|
||||||
<Match when={item.status.status === "connected"}>Connected</Match>
|
<Match when={item.status.status === "connected"}>Connected</Match>
|
||||||
<Match when={item.status.status === "failed"}>
|
<Match when={item.status.status === "failed"}>
|
||||||
|
|||||||
@@ -1,15 +1,16 @@
|
|||||||
/** @jsxImportSource @opentui/solid */
|
/** @jsxImportSource @opentui/solid */
|
||||||
import type { ScrollBoxRenderable } from "@opentui/core"
|
import type { ScrollBoxRenderable } from "@opentui/core"
|
||||||
|
import type { Plugin } from "@opencode-ai/plugin/tui"
|
||||||
import { Locale } from "../../util/locale"
|
import { Locale } from "../../util/locale"
|
||||||
import { tint } from "../../theme/color"
|
import { tint } from "../../theme/color"
|
||||||
import { createEffect, createMemo, For, Match, Switch } from "solid-js"
|
import { createEffect, createMemo, For, Match, Switch } from "solid-js"
|
||||||
import { buildFileTree, flattenFileTree, type FileTreeItem, type FileTreeRow } from "./diff-viewer-file-tree-utils"
|
import { buildFileTree, flattenFileTree, type FileTreeItem, type FileTreeRow } from "./diff-viewer-file-tree-utils"
|
||||||
import { Panel } from "./diff-viewer-ui"
|
import { Panel } from "./diff-viewer-ui"
|
||||||
import { useTheme } from "../../context/theme"
|
|
||||||
|
|
||||||
const FILE_TREE_STATUS_WIDTH = 2
|
const FILE_TREE_STATUS_WIDTH = 2
|
||||||
|
|
||||||
export type DiffViewerFileTreeProps = {
|
export type DiffViewerFileTreeProps = {
|
||||||
|
readonly context: Plugin.Context
|
||||||
readonly width: number
|
readonly width: number
|
||||||
readonly files: readonly FileTreeItem[]
|
readonly files: readonly FileTreeItem[]
|
||||||
readonly loading: boolean
|
readonly loading: boolean
|
||||||
@@ -23,7 +24,7 @@ export type DiffViewerFileTreeProps = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function DiffViewerFileTree(props: DiffViewerFileTreeProps) {
|
export function DiffViewerFileTree(props: DiffViewerFileTreeProps) {
|
||||||
const { themeV2 } = useTheme()
|
const theme = props.context.theme
|
||||||
const tree = createMemo(() => buildFileTree(props.files))
|
const tree = createMemo(() => buildFileTree(props.files))
|
||||||
const rows = createMemo(() => flattenFileTree(tree(), props.expandedNodes))
|
const rows = createMemo(() => flattenFileTree(tree(), props.expandedNodes))
|
||||||
let scroll: ScrollBoxRenderable | undefined
|
let scroll: ScrollBoxRenderable | undefined
|
||||||
@@ -38,10 +39,10 @@ export function DiffViewerFileTree(props: DiffViewerFileTreeProps) {
|
|||||||
requestAnimationFrame(scrollSelectedIntoView)
|
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 (
|
return (
|
||||||
<Panel border="both" width={props.width}>
|
<Panel border="both" width={props.width} context={props.context}>
|
||||||
<scrollbox
|
<scrollbox
|
||||||
ref={(element: ScrollBoxRenderable) => (scroll = element)}
|
ref={(element: ScrollBoxRenderable) => (scroll = element)}
|
||||||
verticalScrollbarOptions={{ visible: false }}
|
verticalScrollbarOptions={{ visible: false }}
|
||||||
@@ -52,7 +53,7 @@ export function DiffViewerFileTree(props: DiffViewerFileTreeProps) {
|
|||||||
<text />
|
<text />
|
||||||
</Match>
|
</Match>
|
||||||
<Match when={props.files.length === 0}>
|
<Match when={props.files.length === 0}>
|
||||||
<text fg={themeV2.text.default}>No files</text>
|
<text fg={theme.text.default}>No files</text>
|
||||||
</Match>
|
</Match>
|
||||||
<Match when={props.files.length > 0}>
|
<Match when={props.files.length > 0}>
|
||||||
<For each={rows()}>
|
<For each={rows()}>
|
||||||
@@ -71,11 +72,11 @@ export function DiffViewerFileTree(props: DiffViewerFileTreeProps) {
|
|||||||
<box
|
<box
|
||||||
flexDirection="row"
|
flexDirection="row"
|
||||||
width="100%"
|
width="100%"
|
||||||
backgroundColor={highlighted() ? themeV2.background.action.primary.focused : undefined}
|
backgroundColor={highlighted() ? theme.background.action.primary.focused : undefined}
|
||||||
onMouseUp={() => props.onRowClick?.(row)}
|
onMouseUp={() => props.onRowClick?.(row)}
|
||||||
>
|
>
|
||||||
<text
|
<text
|
||||||
fg={highlighted() ? themeV2.text.action.primary.focused : fadedColor()}
|
fg={highlighted() ? theme.text.action.primary.focused : fadedColor()}
|
||||||
wrapMode="none"
|
wrapMode="none"
|
||||||
flexShrink={0}
|
flexShrink={0}
|
||||||
>
|
>
|
||||||
@@ -85,12 +86,12 @@ export function DiffViewerFileTree(props: DiffViewerFileTreeProps) {
|
|||||||
<text
|
<text
|
||||||
fg={
|
fg={
|
||||||
highlighted()
|
highlighted()
|
||||||
? themeV2.text.action.primary.focused
|
? theme.text.action.primary.focused
|
||||||
: selected()
|
: selected()
|
||||||
? themeV2.text.formfield.selected
|
? theme.text.formfield.selected
|
||||||
: reviewed() || row.kind === "directory"
|
: reviewed() || row.kind === "directory"
|
||||||
? themeV2.text.subdued
|
? theme.text.subdued
|
||||||
: themeV2.text.default
|
: theme.text.default
|
||||||
}
|
}
|
||||||
wrapMode="none"
|
wrapMode="none"
|
||||||
>
|
>
|
||||||
@@ -98,7 +99,7 @@ export function DiffViewerFileTree(props: DiffViewerFileTreeProps) {
|
|||||||
</text>
|
</text>
|
||||||
</box>
|
</box>
|
||||||
<text
|
<text
|
||||||
fg={highlighted() ? themeV2.text.action.primary.focused : themeV2.text.subdued}
|
fg={highlighted() ? theme.text.action.primary.focused : theme.text.subdued}
|
||||||
wrapMode="none"
|
wrapMode="none"
|
||||||
flexShrink={0}
|
flexShrink={0}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
import type { BorderSides, ColorInput } from "@opentui/core"
|
import type { BorderSides, ColorInput } from "@opentui/core"
|
||||||
|
import type { Plugin } from "@opencode-ai/plugin/tui"
|
||||||
import type { JSX } from "@opentui/solid"
|
import type { JSX } from "@opentui/solid"
|
||||||
import { useTheme } from "../../context/theme"
|
|
||||||
import { createContext, Show, splitProps, useContext } from "solid-js"
|
import { createContext, Show, splitProps, useContext } from "solid-js"
|
||||||
|
|
||||||
export type Axis = "x" | "y"
|
export type Axis = "x" | "y"
|
||||||
export type SeparatorEdge = "edge" | "edge-in" | "edge-out"
|
export type SeparatorEdge = "edge" | "edge-in" | "edge-out"
|
||||||
export type PanelBorder = "start" | "end" | "both" | "none"
|
export type PanelBorder = "start" | "end" | "both" | "none"
|
||||||
|
|
||||||
const PanelGroupContext = createContext<{ axis: Axis }>()
|
const PanelGroupContext = createContext<{ axis: Axis; context: Plugin.Context }>()
|
||||||
|
|
||||||
function crossAxis(axis: Axis) {
|
function crossAxis(axis: Axis) {
|
||||||
return axis === "x" ? "y" : "x"
|
return axis === "x" ? "y" : "x"
|
||||||
@@ -17,10 +17,10 @@ function usePanelGroup() {
|
|||||||
return useContext(PanelGroupContext)
|
return useContext(PanelGroupContext)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function PanelGroup(props: JSX.IntrinsicElements["box"] & { axis: Axis }) {
|
export function PanelGroup(props: JSX.IntrinsicElements["box"] & { axis: Axis; context: Plugin.Context }) {
|
||||||
const [local, boxProps] = splitProps(props, ["axis", "children"])
|
const [local, boxProps] = splitProps(props, ["axis", "context", "children"])
|
||||||
return (
|
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}>
|
<box minWidth={0} minHeight={0} padding={0} flexDirection={local.axis === "x" ? "row" : "column"} {...boxProps}>
|
||||||
{local.children}
|
{local.children}
|
||||||
</box>
|
</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 group = usePanelGroup()
|
||||||
const { themeV2 } = useTheme()
|
const [local, boxProps] = splitProps(props, ["border", "context"])
|
||||||
const [local, boxProps] = splitProps(props, ["border"])
|
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 border = local.border ?? "start"
|
||||||
const borderProps =
|
const borderProps =
|
||||||
border === "none"
|
border === "none"
|
||||||
? {}
|
? {}
|
||||||
: {
|
: {
|
||||||
border: panelBorderSides(group?.axis ?? "y", border),
|
border: panelBorderSides(group?.axis ?? "y", border),
|
||||||
borderColor: themeV2.border.default,
|
borderColor: theme.border.default,
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<box
|
<box
|
||||||
minWidth={0}
|
minWidth={0}
|
||||||
minHeight={0}
|
minHeight={0}
|
||||||
flexDirection={crossAxis(group?.axis || "y") === "x" ? "row" : "column"}
|
flexDirection={crossAxis(group?.axis ?? "y") === "x" ? "row" : "column"}
|
||||||
{...borderProps}
|
{...borderProps}
|
||||||
{...boxProps}
|
{...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 }) {
|
export function Separator(props: { axis?: Axis; color?: ColorInput; start?: SeparatorEdge; end?: SeparatorEdge }) {
|
||||||
const group = usePanelGroup()
|
const group = usePanelGroup()
|
||||||
const { themeV2 } = useTheme()
|
if (!group) throw new Error("PanelGroup is missing")
|
||||||
const color = () => props.color ?? themeV2.border.default
|
const theme = group.context.theme
|
||||||
const axis = () => props.axis ?? crossAxis(group?.axis ?? "y")
|
const color = () => props.color ?? theme.border.default
|
||||||
|
const axis = () => props.axis ?? crossAxis(group.axis)
|
||||||
if (axis() === "y") {
|
if (axis() === "y") {
|
||||||
return (
|
return (
|
||||||
<Show
|
<Show
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import {
|
|||||||
type ScrollBoxRenderable,
|
type ScrollBoxRenderable,
|
||||||
} from "@opentui/core"
|
} from "@opentui/core"
|
||||||
import { LANGUAGE_EXTENSIONS } from "../../util/filetype"
|
import { LANGUAGE_EXTENSIONS } from "../../util/filetype"
|
||||||
import { useTheme } from "../../context/theme"
|
|
||||||
import { useTerminalDimensions } from "@opentui/solid"
|
import { useTerminalDimensions } from "@opentui/solid"
|
||||||
import path from "path"
|
import path from "path"
|
||||||
import { createEffect, createMemo, createResource, createSignal, For, Match, onCleanup, Show, Switch } from "solid-js"
|
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 dimensions = useTerminalDimensions()
|
||||||
const config = useConfig()
|
const config = useConfig()
|
||||||
const dialog = props.context.ui.dialog
|
const dialog = props.context.ui.dialog
|
||||||
const themeState = useTheme()
|
const theme = props.context.theme
|
||||||
const themeV2 = themeState.themeV2
|
|
||||||
const params = () => {
|
const params = () => {
|
||||||
const route = props.context.ui.router.current()
|
const route = props.context.ui.router.current()
|
||||||
return (route.type === "plugin" ? route.data : undefined) as
|
return (route.type === "plugin" ? route.data : undefined) as
|
||||||
@@ -738,13 +736,13 @@ function DiffViewer(props: { context: Plugin.Context }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<box position="absolute" zIndex={2500} left={0} top={0} width={dimensions().width} height={dimensions().height}>
|
<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}>
|
<Panel border="none" flexShrink={0} padding={0} paddingLeft={1}>
|
||||||
<text fg={themeV2.text.default}>Diff </text>
|
<text fg={theme.text.default}>Diff </text>
|
||||||
<text fg={themeV2.text.subdued}>{diffSourceLabel(mode())}</text>
|
<text fg={theme.text.subdued}>{diffSourceLabel(mode())}</text>
|
||||||
<box flexGrow={1} />
|
<box flexGrow={1} />
|
||||||
<Show when={!diff.loading && !diff.error}>
|
<Show when={!diff.loading && !diff.error}>
|
||||||
<text fg={themeV2.text.subdued}>
|
<text fg={theme.text.subdued}>
|
||||||
{files().length} {files().length === 1 ? "file" : "files"}
|
{files().length} {files().length === 1 ? "file" : "files"}
|
||||||
</text>
|
</text>
|
||||||
</Show>
|
</Show>
|
||||||
@@ -755,13 +753,13 @@ function DiffViewer(props: { context: Plugin.Context }) {
|
|||||||
<Match when={diff.loading}>
|
<Match when={diff.loading}>
|
||||||
<Separator axis="x" />
|
<Separator axis="x" />
|
||||||
<box flexGrow={1} paddingLeft={1}>
|
<box flexGrow={1} paddingLeft={1}>
|
||||||
<text fg={themeV2.text.subdued}>Loading diff…</text>
|
<text fg={theme.text.subdued}>Loading diff…</text>
|
||||||
</box>
|
</box>
|
||||||
</Match>
|
</Match>
|
||||||
<Match when={!diff.loading && diff.error}>
|
<Match when={!diff.loading && diff.error}>
|
||||||
<Separator axis="x" />
|
<Separator axis="x" />
|
||||||
<box flexGrow={1} paddingLeft={1}>
|
<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.
|
Could not load diff. Reopen the diff viewer to try again.
|
||||||
</text>
|
</text>
|
||||||
</box>
|
</box>
|
||||||
@@ -769,13 +767,14 @@ function DiffViewer(props: { context: Plugin.Context }) {
|
|||||||
<Match when={!diff.loading && files().length === 0}>
|
<Match when={!diff.loading && files().length === 0}>
|
||||||
<Separator axis="x" />
|
<Separator axis="x" />
|
||||||
<box flexGrow={1} paddingLeft={1}>
|
<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>
|
</box>
|
||||||
</Match>
|
</Match>
|
||||||
<Match when={!diff.loading}>
|
<Match when={!diff.loading}>
|
||||||
<PanelGroup axis="x">
|
<PanelGroup axis="x" context={props.context}>
|
||||||
<Show when={showFileTree()}>
|
<Show when={showFileTree()}>
|
||||||
<DiffViewerFileTree
|
<DiffViewerFileTree
|
||||||
|
context={props.context}
|
||||||
files={files()}
|
files={files()}
|
||||||
loading={diff.loading}
|
loading={diff.loading}
|
||||||
error={diff.error}
|
error={diff.error}
|
||||||
@@ -812,56 +811,52 @@ function DiffViewer(props: { context: Plugin.Context }) {
|
|||||||
paddingLeft={1}
|
paddingLeft={1}
|
||||||
paddingRight={1}
|
paddingRight={1}
|
||||||
border={patchLeftBorder()}
|
border={patchLeftBorder()}
|
||||||
borderColor={themeV2.border.default}
|
borderColor={theme.border.default}
|
||||||
>
|
>
|
||||||
<text fg={reviewed() ? themeV2.text.subdued : themeV2.text.default}>
|
<text fg={reviewed() ? theme.text.subdued : theme.text.default}>{entry.file.file}</text>
|
||||||
{entry.file.file}
|
|
||||||
</text>
|
|
||||||
<box flexGrow={1} />
|
<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}
|
+{entry.file.additions}
|
||||||
</text>
|
</text>
|
||||||
<text fg={reviewed() ? themeV2.text.subdued : themeV2.diff.text.removed}>
|
<text fg={reviewed() ? theme.text.subdued : theme.diff.text.removed}>
|
||||||
-{entry.file.deletions}
|
-{entry.file.deletions}
|
||||||
</text>
|
</text>
|
||||||
</box>
|
</box>
|
||||||
<Separator axis="x" start={showFileTree() ? "edge" : undefined} />
|
<Separator axis="x" start={showFileTree() ? "edge" : undefined} />
|
||||||
<Show
|
<Show
|
||||||
when={entry.file.patch}
|
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) => (
|
{(patch) => (
|
||||||
<box border={patchLeftBorder()} borderColor={themeV2.border.default}>
|
<box border={patchLeftBorder()} borderColor={theme.border.default}>
|
||||||
<diff
|
<diff
|
||||||
ref={(element: DiffRenderable) => diffNodeByFileIndex.set(entry.fileIndex, element)}
|
ref={(element: DiffRenderable) => diffNodeByFileIndex.set(entry.fileIndex, element)}
|
||||||
diff={patch()}
|
diff={patch()}
|
||||||
view={view()}
|
view={view()}
|
||||||
filetype={reviewed() ? PLAIN_TEXT_FILETYPE : filetype(entry.file.file)}
|
filetype={reviewed() ? PLAIN_TEXT_FILETYPE : filetype(entry.file.file)}
|
||||||
syntaxStyle={themeState.syntax()}
|
syntaxStyle={theme.syntaxStyle()}
|
||||||
showLineNumbers={true}
|
showLineNumbers={true}
|
||||||
width="100%"
|
width="100%"
|
||||||
wrapMode="char"
|
wrapMode="char"
|
||||||
fg={reviewed() ? themeV2.text.subdued : themeV2.text.default}
|
fg={reviewed() ? theme.text.subdued : theme.text.default}
|
||||||
addedBg={
|
addedBg={
|
||||||
reviewed() ? themeV2.background.surface.overlay : themeV2.diff.background.added
|
reviewed() ? theme.background.surface.overlay : theme.diff.background.added
|
||||||
}
|
}
|
||||||
removedBg={
|
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}
|
addedSignColor={reviewed() ? theme.text.subdued : theme.diff.highlight.added}
|
||||||
removedSignColor={
|
removedSignColor={reviewed() ? theme.text.subdued : theme.diff.highlight.removed}
|
||||||
reviewed() ? themeV2.text.subdued : themeV2.diff.highlight.removed
|
lineNumberFg={theme.diff.lineNumber.text}
|
||||||
}
|
|
||||||
lineNumberFg={themeV2.diff.lineNumber.text}
|
|
||||||
addedLineNumberBg={
|
addedLineNumberBg={
|
||||||
reviewed()
|
reviewed()
|
||||||
? themeV2.background.surface.overlay
|
? theme.background.surface.overlay
|
||||||
: themeV2.diff.lineNumber.background.added
|
: theme.diff.lineNumber.background.added
|
||||||
}
|
}
|
||||||
removedLineNumberBg={
|
removedLineNumberBg={
|
||||||
reviewed()
|
reviewed()
|
||||||
? themeV2.background.surface.overlay
|
? theme.background.surface.overlay
|
||||||
: themeV2.diff.lineNumber.background.removed
|
: theme.diff.lineNumber.background.removed
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</box>
|
</box>
|
||||||
@@ -872,11 +867,7 @@ function DiffViewer(props: { context: Plugin.Context }) {
|
|||||||
}}
|
}}
|
||||||
</For>
|
</For>
|
||||||
<Show when={patchFillerHeight() > 0}>
|
<Show when={patchFillerHeight() > 0}>
|
||||||
<box
|
<box height={patchFillerHeight()} border={patchLeftBorder()} borderColor={theme.border.default} />
|
||||||
height={patchFillerHeight()}
|
|
||||||
border={patchLeftBorder()}
|
|
||||||
borderColor={themeV2.border.default}
|
|
||||||
/>
|
|
||||||
</Show>
|
</Show>
|
||||||
</scrollbox>
|
</scrollbox>
|
||||||
<Separator axis="x" start={showFileTree() ? "edge-in" : undefined} />
|
<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">
|
<Panel flexShrink={0} gap={2} paddingLeft={1} border="none">
|
||||||
<Show when={switchFocusShortcut()}>
|
<Show when={switchFocusShortcut()}>
|
||||||
{(shortcut) => (
|
{(shortcut) => (
|
||||||
<text fg={themeV2.text.default}>
|
<text fg={theme.text.default}>
|
||||||
{shortcut()} <span style={{ fg: themeV2.text.subdued }}>focus file tree</span>
|
{shortcut()} <span style={{ fg: theme.text.subdued }}>focus file tree</span>
|
||||||
</text>
|
</text>
|
||||||
)}
|
)}
|
||||||
</Show>
|
</Show>
|
||||||
<Show when={nextFileShortcut()}>
|
<Show when={nextFileShortcut()}>
|
||||||
{(shortcut) => (
|
{(shortcut) => (
|
||||||
<text fg={themeV2.text.default}>
|
<text fg={theme.text.default}>
|
||||||
{shortcut()} <span style={{ fg: themeV2.text.subdued }}>next file</span>
|
{shortcut()} <span style={{ fg: theme.text.subdued }}>next file</span>
|
||||||
</text>
|
</text>
|
||||||
)}
|
)}
|
||||||
</Show>
|
</Show>
|
||||||
<Show when={nextHunkShortcut()}>
|
<Show when={nextHunkShortcut()}>
|
||||||
{(shortcut) => (
|
{(shortcut) => (
|
||||||
<text fg={themeV2.text.default}>
|
<text fg={theme.text.default}>
|
||||||
{shortcut()} <span style={{ fg: themeV2.text.subdued }}>next hunk</span>
|
{shortcut()} <span style={{ fg: theme.text.subdued }}>next hunk</span>
|
||||||
</text>
|
</text>
|
||||||
)}
|
)}
|
||||||
</Show>
|
</Show>
|
||||||
<Show when={previousHunkShortcut()}>
|
<Show when={previousHunkShortcut()}>
|
||||||
{(shortcut) => (
|
{(shortcut) => (
|
||||||
<text fg={themeV2.text.default}>
|
<text fg={theme.text.default}>
|
||||||
{shortcut()} <span style={{ fg: themeV2.text.subdued }}>previous hunk</span>
|
{shortcut()} <span style={{ fg: theme.text.subdued }}>previous hunk</span>
|
||||||
</text>
|
</text>
|
||||||
)}
|
)}
|
||||||
</Show>
|
</Show>
|
||||||
<Show when={previousFileShortcut()}>
|
<Show when={previousFileShortcut()}>
|
||||||
{(shortcut) => (
|
{(shortcut) => (
|
||||||
<text fg={themeV2.text.default}>
|
<text fg={theme.text.default}>
|
||||||
{shortcut()} <span style={{ fg: themeV2.text.subdued }}>previous file</span>
|
{shortcut()} <span style={{ fg: theme.text.subdued }}>previous file</span>
|
||||||
</text>
|
</text>
|
||||||
)}
|
)}
|
||||||
</Show>
|
</Show>
|
||||||
<Show when={switchSourceShortcut()}>
|
<Show when={switchSourceShortcut()}>
|
||||||
{(shortcut) => (
|
{(shortcut) => (
|
||||||
<text fg={themeV2.text.default}>
|
<text fg={theme.text.default}>
|
||||||
{shortcut()} <span style={{ fg: themeV2.text.subdued }}>switch source</span>
|
{shortcut()} <span style={{ fg: theme.text.subdued }}>switch source</span>
|
||||||
</text>
|
</text>
|
||||||
)}
|
)}
|
||||||
</Show>
|
</Show>
|
||||||
<Show when={markReviewedShortcut()}>
|
<Show when={markReviewedShortcut()}>
|
||||||
{(shortcut) => (
|
{(shortcut) => (
|
||||||
<text fg={themeV2.text.default}>
|
<text fg={theme.text.default}>
|
||||||
{shortcut()} <span style={{ fg: themeV2.text.subdued }}>mark reviewed</span>
|
{shortcut()} <span style={{ fg: theme.text.subdued }}>mark reviewed</span>
|
||||||
</text>
|
</text>
|
||||||
)}
|
)}
|
||||||
</Show>
|
</Show>
|
||||||
<Show when={helpShortcut()}>
|
<Show when={helpShortcut()}>
|
||||||
{(shortcut) => (
|
{(shortcut) => (
|
||||||
<text fg={themeV2.text.default}>
|
<text fg={theme.text.default}>
|
||||||
{shortcut()} <span style={{ fg: themeV2.text.subdued }}>all</span>
|
{shortcut()} <span style={{ fg: theme.text.subdued }}>all</span>
|
||||||
</text>
|
</text>
|
||||||
)}
|
)}
|
||||||
</Show>
|
</Show>
|
||||||
@@ -950,7 +941,7 @@ function DiffViewer(props: { context: Plugin.Context }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function DiffViewerHelpDialog(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 shortcut = (id: string) => () => props.context.keymap.shortcuts(id)[0]
|
||||||
const rows = [
|
const rows = [
|
||||||
{
|
{
|
||||||
@@ -1018,30 +1009,30 @@ function DiffViewerHelpDialog(props: { context: Plugin.Context }) {
|
|||||||
return (
|
return (
|
||||||
<box paddingLeft={2} paddingRight={2} paddingBottom={1} gap={1}>
|
<box paddingLeft={2} paddingRight={2} paddingBottom={1} gap={1}>
|
||||||
<box flexDirection="row" justifyContent="space-between">
|
<box flexDirection="row" justifyContent="space-between">
|
||||||
<text attributes={TextAttributes.BOLD} fg={themeV2.text.default}>
|
<text attributes={TextAttributes.BOLD} fg={theme.text.default}>
|
||||||
Diff shortcuts
|
Diff shortcuts
|
||||||
</text>
|
</text>
|
||||||
<text fg={themeV2.text.subdued}>esc</text>
|
<text fg={theme.text.subdued}>esc</text>
|
||||||
</box>
|
</box>
|
||||||
<box flexDirection="row">
|
<box flexDirection="row">
|
||||||
<text fg={themeV2.text.subdued} width={5} wrapMode="none">
|
<text fg={theme.text.subdued} width={5} wrapMode="none">
|
||||||
Key
|
Key
|
||||||
</text>
|
</text>
|
||||||
<text fg={themeV2.text.subdued} width={22} wrapMode="none">
|
<text fg={theme.text.subdued} width={22} wrapMode="none">
|
||||||
Action
|
Action
|
||||||
</text>
|
</text>
|
||||||
<text fg={themeV2.text.subdued}>Description</text>
|
<text fg={theme.text.subdued}>Description</text>
|
||||||
</box>
|
</box>
|
||||||
<For each={rows}>
|
<For each={rows}>
|
||||||
{(row) => (
|
{(row) => (
|
||||||
<box flexDirection="row">
|
<box flexDirection="row">
|
||||||
<text fg={themeV2.text.default} width={5} wrapMode="none">
|
<text fg={theme.text.default} width={5} wrapMode="none">
|
||||||
{row.shortcut() || "-"}
|
{row.shortcut() || "-"}
|
||||||
</text>
|
</text>
|
||||||
<text fg={themeV2.text.default} width={22} wrapMode="none">
|
<text fg={theme.text.default} width={22} wrapMode="none">
|
||||||
{row.action}
|
{row.action}
|
||||||
</text>
|
</text>
|
||||||
<text fg={themeV2.text.subdued}>{row.description}</text>
|
<text fg={theme.text.subdued}>{row.description}</text>
|
||||||
</box>
|
</box>
|
||||||
)}
|
)}
|
||||||
</For>
|
</For>
|
||||||
|
|||||||
@@ -1,280 +1,90 @@
|
|||||||
import type { TuiPlugin, TuiPluginApi, TuiPluginStatus } from "@opencode-ai/plugin/v1/tui"
|
import { Plugin } from "@opencode-ai/plugin/tui"
|
||||||
import type { BuiltinTuiPlugin } from "../builtins"
|
import { createMemo, createSignal } from "solid-js"
|
||||||
import { useTerminalDimensions } from "@opentui/solid"
|
import { usePlugin } from "../../plugin/context"
|
||||||
import { fileURLToPath } from "url"
|
|
||||||
import { DialogSelect, type DialogSelectOption } from "../../ui/dialog-select"
|
import { DialogSelect, type DialogSelectOption } from "../../ui/dialog-select"
|
||||||
import { Show, createEffect, createMemo, createSignal } from "solid-js"
|
|
||||||
import { Keymap } from "../../context/keymap"
|
|
||||||
|
|
||||||
const id = "internal:plugin-manager"
|
const id = "opencode.plugins"
|
||||||
|
|
||||||
function state(api: TuiPluginApi, item: TuiPluginStatus) {
|
function View(props: { context: Plugin.Context; plugins: ReturnType<typeof usePlugin> }) {
|
||||||
if (!item.enabled) {
|
const [locked, setLocked] = createSignal(false)
|
||||||
return <span style={{ fg: api.theme.current.textMuted }}>disabled</span>
|
const options = createMemo(() =>
|
||||||
}
|
props.plugins
|
||||||
|
.registered()
|
||||||
return (
|
.filter((plugin) => plugin.id !== id)
|
||||||
<span style={{ fg: item.active ? api.theme.current.success : api.theme.current.error }}>
|
.sort((a, b) => a.id.localeCompare(b.id))
|
||||||
{item.active ? "active" : "inactive"}
|
.map(
|
||||||
|
(plugin): DialogSelectOption<string> => ({
|
||||||
|
title: plugin.id,
|
||||||
|
value: plugin.id,
|
||||||
|
category: plugin.source === "builtin" ? "Built-in" : "External",
|
||||||
|
footer: (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fg: plugin.active
|
||||||
|
? props.context.theme.text.feedback.success.default
|
||||||
|
: props.context.theme.text.subdued,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{plugin.active ? "active" : "inactive"}
|
||||||
</span>
|
</span>
|
||||||
)
|
),
|
||||||
}
|
}),
|
||||||
|
),
|
||||||
function source(spec: string) {
|
|
||||||
if (!spec.startsWith("file://")) return
|
|
||||||
return fileURLToPath(spec)
|
|
||||||
}
|
|
||||||
|
|
||||||
function meta(item: TuiPluginStatus, width: number) {
|
|
||||||
if (item.source === "internal") {
|
|
||||||
if (width >= 120) return "Built-in plugin"
|
|
||||||
return "Built-in"
|
|
||||||
}
|
|
||||||
const next = source(item.spec)
|
|
||||||
if (next) return next
|
|
||||||
return item.spec
|
|
||||||
}
|
|
||||||
|
|
||||||
function Install(props: { api: TuiPluginApi }) {
|
|
||||||
const [global, setGlobal] = createSignal(false)
|
|
||||||
const [busy, setBusy] = createSignal(false)
|
|
||||||
|
|
||||||
Keymap.createLayer(() => ({
|
|
||||||
mode: "modal",
|
|
||||||
enabled: !busy(),
|
|
||||||
commands: [
|
|
||||||
{
|
|
||||||
bind: "tab",
|
|
||||||
title: "Toggle install scope",
|
|
||||||
group: "Plugins",
|
|
||||||
run: () => {
|
|
||||||
setGlobal((value) => !value)
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}))
|
|
||||||
|
|
||||||
return (
|
|
||||||
<props.api.ui.DialogPrompt
|
|
||||||
title="Install plugin"
|
|
||||||
placeholder="npm package name"
|
|
||||||
busy={busy()}
|
|
||||||
busyText="Installing plugin..."
|
|
||||||
description={() => (
|
|
||||||
<box flexDirection="row" gap={1}>
|
|
||||||
<text fg={props.api.theme.current.textMuted}>scope:</text>
|
|
||||||
<text fg={busy() ? props.api.theme.current.textMuted : props.api.theme.current.text}>
|
|
||||||
{global() ? "global" : "local"}
|
|
||||||
</text>
|
|
||||||
<Show when={!busy()}>
|
|
||||||
<text fg={props.api.theme.current.textMuted}>(tab toggle)</text>
|
|
||||||
</Show>
|
|
||||||
</box>
|
|
||||||
)}
|
|
||||||
onConfirm={(raw) => {
|
|
||||||
if (busy()) return
|
|
||||||
const mod = raw.trim()
|
|
||||||
if (!mod) {
|
|
||||||
props.api.ui.toast({
|
|
||||||
variant: "error",
|
|
||||||
message: "Plugin package name is required",
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
setBusy(true)
|
|
||||||
void props.api.plugins
|
|
||||||
.install(mod, { global: global() })
|
|
||||||
.then((out) => {
|
|
||||||
if (!out.ok) {
|
|
||||||
props.api.ui.toast({
|
|
||||||
variant: "error",
|
|
||||||
message: out.message,
|
|
||||||
})
|
|
||||||
if (out.missing) {
|
|
||||||
props.api.ui.toast({
|
|
||||||
variant: "info",
|
|
||||||
message: "Check npm registry/auth settings and try again.",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
show(props.api)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
props.api.ui.toast({
|
|
||||||
variant: "success",
|
|
||||||
message: `Installed ${mod} (${global() ? "global" : "local"}: ${out.dir})`,
|
|
||||||
})
|
|
||||||
if (!out.tui) {
|
|
||||||
props.api.ui.toast({
|
|
||||||
variant: "info",
|
|
||||||
message: "Package has no TUI target to load in this app.",
|
|
||||||
})
|
|
||||||
show(props.api)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
return props.api.plugins.add(mod).then((ok) => {
|
|
||||||
if (!ok) {
|
|
||||||
props.api.ui.toast({
|
|
||||||
variant: "warning",
|
|
||||||
message: "Installed plugin, but runtime load failed. See console/logs; restart TUI to retry.",
|
|
||||||
})
|
|
||||||
show(props.api)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
props.api.ui.toast({
|
|
||||||
variant: "success",
|
|
||||||
message: `Loaded ${mod} in current session.`,
|
|
||||||
})
|
|
||||||
show(props.api)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
.finally(() => {
|
|
||||||
setBusy(false)
|
|
||||||
})
|
|
||||||
}}
|
|
||||||
onCancel={() => {
|
|
||||||
show(props.api)
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function row(api: TuiPluginApi, item: TuiPluginStatus, width: number): DialogSelectOption<string> {
|
|
||||||
return {
|
|
||||||
title: item.id,
|
|
||||||
value: item.id,
|
|
||||||
category: item.source === "internal" ? "Internal" : "External",
|
|
||||||
description: meta(item, width),
|
|
||||||
footer: state(api, item),
|
|
||||||
disabled: item.id === id,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function showInstall(api: TuiPluginApi) {
|
|
||||||
api.ui.dialog.replace(() => <Install api={api} />)
|
|
||||||
}
|
|
||||||
|
|
||||||
function View(props: { api: TuiPluginApi }) {
|
|
||||||
const size = useTerminalDimensions()
|
|
||||||
const [list, setList] = createSignal(props.api.plugins.list())
|
|
||||||
const [cur, setCur] = createSignal<string | undefined>()
|
|
||||||
const [lock, setLock] = createSignal(false)
|
|
||||||
|
|
||||||
createEffect(() => {
|
|
||||||
const width = size().width
|
|
||||||
if (width >= 128) {
|
|
||||||
props.api.ui.dialog.setSize("xlarge")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (width >= 96) {
|
|
||||||
props.api.ui.dialog.setSize("large")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
props.api.ui.dialog.setSize("medium")
|
|
||||||
})
|
|
||||||
|
|
||||||
const rows = createMemo(() =>
|
|
||||||
[...list()]
|
|
||||||
.sort((a, b) => {
|
|
||||||
const x = a.source === "internal" ? 1 : 0
|
|
||||||
const y = b.source === "internal" ? 1 : 0
|
|
||||||
if (x !== y) return x - y
|
|
||||||
return a.id.localeCompare(b.id)
|
|
||||||
})
|
|
||||||
.map((item) => row(props.api, item, size().width)),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const flip = (x: string) => {
|
const toggle = (plugin: DialogSelectOption<string>) => {
|
||||||
if (lock()) return
|
if (locked()) return
|
||||||
const item = list().find((entry) => entry.id === x)
|
const current = props.plugins.registered().find((item) => item.id === plugin.value)
|
||||||
if (!item) return
|
if (!current) return
|
||||||
setLock(true)
|
setLocked(true)
|
||||||
const task = item.active ? props.api.plugins.deactivate(x) : props.api.plugins.activate(x)
|
void (current.active ? props.plugins.deactivate(current.id) : props.plugins.activate(current.id))
|
||||||
void task
|
|
||||||
.then((ok) => {
|
.then((ok) => {
|
||||||
if (!ok) {
|
if (ok) return
|
||||||
props.api.ui.toast({
|
props.context.ui.toast.show({ variant: "error", message: `Failed to update plugin ${current.id}` })
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
props.context.ui.toast.show({
|
||||||
variant: "error",
|
variant: "error",
|
||||||
message: `Failed to update plugin ${item.id}`,
|
message: error instanceof Error ? error.message : String(error),
|
||||||
})
|
})
|
||||||
}
|
|
||||||
setList(props.api.plugins.list())
|
|
||||||
})
|
|
||||||
.finally(() => {
|
|
||||||
setLock(false)
|
|
||||||
})
|
})
|
||||||
|
.finally(() => setLocked(false))
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DialogSelect
|
<DialogSelect
|
||||||
title="Plugins"
|
title="Plugins"
|
||||||
options={rows()}
|
options={options()}
|
||||||
current={cur()}
|
locked={locked()}
|
||||||
onMove={(item) => setCur(item.value)}
|
preserveSelection={true}
|
||||||
actions={[
|
actions={[{ title: "toggle", command: "plugins.toggle", onTrigger: toggle }]}
|
||||||
{
|
onSelect={toggle}
|
||||||
title: "toggle",
|
|
||||||
command: "plugins.toggle",
|
|
||||||
hidden: lock(),
|
|
||||||
onTrigger: (item) => {
|
|
||||||
setCur(item.value)
|
|
||||||
flip(item.value)
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "install",
|
|
||||||
command: "dialog.plugins.install",
|
|
||||||
selection: "none",
|
|
||||||
hidden: lock(),
|
|
||||||
onTrigger: () => {
|
|
||||||
showInstall(props.api)
|
|
||||||
},
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
onSelect={(item) => {
|
|
||||||
setCur(item.value)
|
|
||||||
flip(item.value)
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function show(api: TuiPluginApi) {
|
function Commands(props: { context: Plugin.Context }) {
|
||||||
api.ui.dialog.replace(() => <View api={api} />)
|
const plugins = usePlugin()
|
||||||
}
|
props.context.keymap.layer(() => ({
|
||||||
|
mode: "global",
|
||||||
const tui: TuiPlugin = async (api) => {
|
|
||||||
api.keymap.registerLayer({
|
|
||||||
commands: [
|
commands: [
|
||||||
{
|
{
|
||||||
name: "plugins.list",
|
id: "plugins.list",
|
||||||
title: "Plugins",
|
title: "Plugins",
|
||||||
category: "System",
|
group: "System",
|
||||||
namespace: "palette",
|
palette: true,
|
||||||
run() {
|
run() {
|
||||||
show(api)
|
props.context.ui.dialog.show(() => <View context={props.context} plugins={plugins} />)
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "plugins.install",
|
|
||||||
title: "Install plugin",
|
|
||||||
category: "System",
|
|
||||||
namespace: "palette",
|
|
||||||
run() {
|
|
||||||
showInstall(api)
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
bindings: ["plugins.list", "plugins.install"].flatMap((command) => api.tuiConfig.keybinds.get(command)),
|
}))
|
||||||
})
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
const plugin: BuiltinTuiPlugin = {
|
export default Plugin.define({
|
||||||
id,
|
id,
|
||||||
tui,
|
setup(context) {
|
||||||
}
|
context.ui.slot("app", () => <Commands context={context} />)
|
||||||
|
},
|
||||||
export default plugin
|
})
|
||||||
|
|||||||
@@ -1,6 +1,26 @@
|
|||||||
import { Plugin } from "@opencode-ai/plugin/tui"
|
import { Plugin } from "@opencode-ai/plugin/tui"
|
||||||
import { useTerminalDimensions } from "@opentui/solid"
|
import { useTerminalDimensions } from "@opentui/solid"
|
||||||
import { useTheme } from "../../context/theme"
|
import { batch, createSignal } from "solid-js"
|
||||||
|
import { SessionTabs, type SessionTabsController } from "../../component/session-tabs"
|
||||||
|
|
||||||
|
type FixtureStatus = ReturnType<SessionTabsController["status"]>
|
||||||
|
|
||||||
|
const FIXTURE_TABS = [
|
||||||
|
{ sessionID: "fixture-1", title: "Implement session tabs" },
|
||||||
|
{ sessionID: "fixture-2", title: "Investigate rendering" },
|
||||||
|
{ sessionID: "fixture-3", title: "A deliberately long session title for truncation" },
|
||||||
|
{ sessionID: "fixture-4", title: "Fix provider state" },
|
||||||
|
{ sessionID: "fixture-5", title: "Review animation" },
|
||||||
|
{ sessionID: "fixture-6", title: "Untitled behavior" },
|
||||||
|
{ sessionID: "fixture-7", title: "Queue follow-up work" },
|
||||||
|
{ sessionID: "fixture-8", title: "Check narrow layout" },
|
||||||
|
{ sessionID: "fixture-9", title: "Profile terminal output" },
|
||||||
|
{ sessionID: "fixture-10", title: "Handle permission" },
|
||||||
|
{ sessionID: "fixture-11", title: "Run focused tests" },
|
||||||
|
{ sessionID: "fixture-12", title: "Prepare review" },
|
||||||
|
]
|
||||||
|
|
||||||
|
const EMPTY_STATUS: FixtureStatus = { unread: undefined, attention: false, busy: false }
|
||||||
|
|
||||||
function Commands(props: { context: Plugin.Context }) {
|
function Commands(props: { context: Plugin.Context }) {
|
||||||
props.context.keymap.layer(() => ({
|
props.context.keymap.layer(() => ({
|
||||||
@@ -23,8 +43,52 @@ function Commands(props: { context: Plugin.Context }) {
|
|||||||
|
|
||||||
function Scrap(props: { context: Plugin.Context }) {
|
function Scrap(props: { context: Plugin.Context }) {
|
||||||
const dimensions = useTerminalDimensions()
|
const dimensions = useTerminalDimensions()
|
||||||
const { themeV2 } = useTheme()
|
const theme = props.context.theme
|
||||||
const { themeV2: elevatedTheme } = useTheme().contextual("elevated")
|
const elevatedTheme = props.context.theme.contextual("elevated")
|
||||||
|
const [tabs, setTabs] = createSignal(FIXTURE_TABS.slice(0, 6))
|
||||||
|
const [active, setActive] = createSignal<string | undefined>("fixture-2")
|
||||||
|
const [animations, setAnimations] = createSignal(true)
|
||||||
|
const [statuses, setStatuses] = createSignal<Record<string, FixtureStatus>>({
|
||||||
|
"fixture-2": { ...EMPTY_STATUS, busy: true },
|
||||||
|
"fixture-3": { ...EMPTY_STATUS, unread: "activity" },
|
||||||
|
"fixture-4": { ...EMPTY_STATUS, unread: "error" },
|
||||||
|
"fixture-5": { ...EMPTY_STATUS, attention: true },
|
||||||
|
"fixture-6": { ...EMPTY_STATUS, busy: true, attention: true },
|
||||||
|
})
|
||||||
|
const controller = {
|
||||||
|
tabs,
|
||||||
|
current: active,
|
||||||
|
status(sessionID) {
|
||||||
|
return statuses()[sessionID] ?? EMPTY_STATUS
|
||||||
|
},
|
||||||
|
select(sessionID) {
|
||||||
|
setActive(sessionID)
|
||||||
|
},
|
||||||
|
close(sessionID?: string) {
|
||||||
|
const target = sessionID ?? active()
|
||||||
|
if (!target) return
|
||||||
|
const items = tabs()
|
||||||
|
const index = items.findIndex((tab) => tab.sessionID === target)
|
||||||
|
if (index === -1) return
|
||||||
|
const next = items.filter((tab) => tab.sessionID !== target)
|
||||||
|
batch(() => {
|
||||||
|
setTabs(next)
|
||||||
|
if (active() === target) setActive(next[index]?.sessionID ?? next[index - 1]?.sessionID)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
} satisfies SessionTabsController
|
||||||
|
|
||||||
|
const cycle = (direction: 1 | -1) => {
|
||||||
|
const items = tabs()
|
||||||
|
if (items.length === 0) return
|
||||||
|
const index = items.findIndex((tab) => tab.sessionID === active())
|
||||||
|
controller.select(items[(index + direction + items.length) % items.length]!.sessionID)
|
||||||
|
}
|
||||||
|
const updateStatus = (update: (status: FixtureStatus) => FixtureStatus) => {
|
||||||
|
const sessionID = active()
|
||||||
|
if (!sessionID) return
|
||||||
|
setStatuses((current) => ({ ...current, [sessionID]: update(current[sessionID] ?? EMPTY_STATUS) }))
|
||||||
|
}
|
||||||
|
|
||||||
props.context.keymap.layer(() => ({
|
props.context.keymap.layer(() => ({
|
||||||
commands: [
|
commands: [
|
||||||
@@ -36,12 +100,60 @@ function Scrap(props: { context: Plugin.Context }) {
|
|||||||
props.context.ui.router.navigate({ type: "home" })
|
props.context.ui.router.navigate({ type: "home" })
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{ bind: "h", title: "Previous tab", group: "Scrap", run: () => cycle(-1) },
|
||||||
|
{ bind: "l", title: "Next tab", group: "Scrap", run: () => cycle(1) },
|
||||||
|
{
|
||||||
|
bind: "t",
|
||||||
|
title: "Add tab",
|
||||||
|
group: "Scrap",
|
||||||
|
run() {
|
||||||
|
const next = FIXTURE_TABS.find((fixture) => !tabs().some((tab) => tab.sessionID === fixture.sessionID))
|
||||||
|
if (next) setTabs((current) => [...current, next])
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ bind: "d", title: "Close tab", group: "Scrap", run: () => controller.close() },
|
||||||
|
{
|
||||||
|
bind: "b",
|
||||||
|
title: "Toggle busy",
|
||||||
|
group: "Scrap",
|
||||||
|
run: () =>
|
||||||
|
updateStatus((status) =>
|
||||||
|
status.busy ? { ...status, busy: false, unread: "activity" } : { ...status, busy: true, unread: undefined },
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
bind: "u",
|
||||||
|
title: "Cycle unread",
|
||||||
|
group: "Scrap",
|
||||||
|
run: () =>
|
||||||
|
updateStatus((status) => ({
|
||||||
|
...status,
|
||||||
|
unread: status.unread === undefined ? "activity" : status.unread === "activity" ? "error" : undefined,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
bind: "a",
|
||||||
|
title: "Toggle attention",
|
||||||
|
group: "Scrap",
|
||||||
|
run: () => updateStatus((status) => ({ ...status, attention: !status.attention })),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
bind: "m",
|
||||||
|
title: "Toggle motion",
|
||||||
|
group: "Scrap",
|
||||||
|
run: () => setAnimations((enabled) => !enabled),
|
||||||
|
},
|
||||||
],
|
],
|
||||||
}))
|
}))
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<box width={dimensions().width} height={dimensions().height} backgroundColor={themeV2.background.default}>
|
<box
|
||||||
<box flexGrow={1} />
|
width={dimensions().width}
|
||||||
|
height={dimensions().height}
|
||||||
|
flexDirection="column"
|
||||||
|
backgroundColor={theme.background.default}
|
||||||
|
>
|
||||||
|
<SessionTabs controller={controller} animations={animations()} />
|
||||||
<box
|
<box
|
||||||
height={1}
|
height={1}
|
||||||
flexShrink={0}
|
flexShrink={0}
|
||||||
@@ -50,10 +162,13 @@ function Scrap(props: { context: Plugin.Context }) {
|
|||||||
paddingRight={1}
|
paddingRight={1}
|
||||||
flexDirection="row"
|
flexDirection="row"
|
||||||
>
|
>
|
||||||
<text fg={elevatedTheme.text.subdued}>~/code/anomalyco/opencode</text>
|
<text fg={elevatedTheme.text.subdued}>tab playground</text>
|
||||||
<box flexGrow={1} />
|
<box flexGrow={1} />
|
||||||
<text fg={elevatedTheme.text.subdued}>esc home</text>
|
<text fg={elevatedTheme.text.subdued}>
|
||||||
|
h/l select | t add | d close | b busy | u unread | a attention | m motion | esc home
|
||||||
|
</text>
|
||||||
</box>
|
</box>
|
||||||
|
<box flexGrow={1} />
|
||||||
</box>
|
</box>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,607 +0,0 @@
|
|||||||
/** @jsxImportSource @opentui/solid */
|
|
||||||
import { RGBA, TextAttributes, type KeyEvent, type Renderable } from "@opentui/core"
|
|
||||||
import { useTerminalDimensions } from "@opentui/solid"
|
|
||||||
import { createEffect, createMemo, createSignal, For, Show } from "solid-js"
|
|
||||||
import { Keymap } from "../../context/keymap"
|
|
||||||
import type { ActiveKey } from "@opentui/keymap"
|
|
||||||
import type { TuiPlugin, TuiPluginApi } from "@opencode-ai/plugin/v1/tui"
|
|
||||||
import type { BuiltinTuiPlugin } from "../builtins"
|
|
||||||
|
|
||||||
const command = {
|
|
||||||
toggle: "which-key.toggle",
|
|
||||||
toggleLayout: "which-key.layout.toggle",
|
|
||||||
togglePending: "which-key.pending.toggle",
|
|
||||||
groupPrevious: "which-key.group.previous",
|
|
||||||
groupNext: "which-key.group.next",
|
|
||||||
scrollUp: "which-key.scroll.up",
|
|
||||||
scrollDown: "which-key.scroll.down",
|
|
||||||
pageUp: "which-key.page.up",
|
|
||||||
pageDown: "which-key.page.down",
|
|
||||||
home: "which-key.home",
|
|
||||||
end: "which-key.end",
|
|
||||||
} as const
|
|
||||||
|
|
||||||
const LAYER_PRIORITY = 900
|
|
||||||
const toggleCommands = [command.toggle, command.toggleLayout, command.togglePending] as const
|
|
||||||
const scrollCommands = [
|
|
||||||
command.scrollUp,
|
|
||||||
command.scrollDown,
|
|
||||||
command.pageUp,
|
|
||||||
command.pageDown,
|
|
||||||
command.home,
|
|
||||||
command.end,
|
|
||||||
] as const
|
|
||||||
const panelCommands = [command.groupPrevious, command.groupNext, ...scrollCommands] as const
|
|
||||||
const COLUMN_GAP = 4
|
|
||||||
const TAB_GAP = 3
|
|
||||||
const MIN_TAB_GAP = 1
|
|
||||||
const TAB_CONTENT_GAP = 1
|
|
||||||
const MIN_COLUMN_WIDTH = 28
|
|
||||||
const MAX_COLUMN_WIDTH = 44
|
|
||||||
const PANEL_HEIGHT_RATIO = 0.3
|
|
||||||
const MIN_PANEL_HEIGHT = 8
|
|
||||||
const MAX_PANEL_HEIGHT = 16
|
|
||||||
const PANEL_TOP_PADDING = 1
|
|
||||||
const FOOTER_HEIGHT = 1
|
|
||||||
const FOOTER_MARGIN = 1
|
|
||||||
const UNKNOWN = "Unknown"
|
|
||||||
|
|
||||||
type Layout = "dock" | "overlay"
|
|
||||||
|
|
||||||
type Color = RGBA | string
|
|
||||||
|
|
||||||
type Skin = {
|
|
||||||
panel: Color
|
|
||||||
text: Color
|
|
||||||
muted: Color
|
|
||||||
subtle: Color
|
|
||||||
key: Color
|
|
||||||
accent: Color
|
|
||||||
tab: Color
|
|
||||||
tabText: Color
|
|
||||||
}
|
|
||||||
|
|
||||||
type Entry = {
|
|
||||||
type: "entry"
|
|
||||||
key: string
|
|
||||||
label: string
|
|
||||||
group: string
|
|
||||||
continues: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
type Group = {
|
|
||||||
label: string
|
|
||||||
entries: Entry[]
|
|
||||||
}
|
|
||||||
|
|
||||||
type HeaderItem = { type: "tab"; group: Group } | { type: "scroll" }
|
|
||||||
|
|
||||||
type GroupHeader = {
|
|
||||||
type: "group"
|
|
||||||
label: string
|
|
||||||
}
|
|
||||||
|
|
||||||
type Item = Entry | GroupHeader
|
|
||||||
|
|
||||||
function text(value: unknown) {
|
|
||||||
if (typeof value !== "string") return undefined
|
|
||||||
const trimmed = value.trim()
|
|
||||||
return trimmed || undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
function ink(api: TuiPluginApi, name: string, fallback: string): Color {
|
|
||||||
const value = Reflect.get(api.theme.current, name)
|
|
||||||
if (typeof value === "string") return value
|
|
||||||
if (value instanceof RGBA) return value
|
|
||||||
return fallback
|
|
||||||
}
|
|
||||||
|
|
||||||
function skin(api: TuiPluginApi): Skin {
|
|
||||||
return {
|
|
||||||
panel: ink(api, "backgroundMenu", "#1c1c1c"),
|
|
||||||
text: ink(api, "text", "#f0f0f0"),
|
|
||||||
muted: ink(api, "textMuted", "#a5a5a5"),
|
|
||||||
subtle: ink(api, "borderSubtle", "#6f6f6f"),
|
|
||||||
key: ink(api, "warning", "#ffd75f"),
|
|
||||||
accent: ink(api, "primary", "#5f87ff"),
|
|
||||||
tab: ink(api, "primary", "#5f87ff"),
|
|
||||||
tabText: ink(api, "selectedListItemText", "#ffffff"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function activeKeyLabel(active: ActiveKey<Renderable, KeyEvent>) {
|
|
||||||
if (active.continues) return text(active.tokenName) ?? text(active.display) ?? UNKNOWN
|
|
||||||
return (
|
|
||||||
text(active.commandAttrs?.title) ?? text(active.bindingAttrs?.desc) ?? text(active.commandAttrs?.desc) ?? UNKNOWN
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function activeKeyGroup(active: ActiveKey<Renderable, KeyEvent>) {
|
|
||||||
if (active.continues) return "System"
|
|
||||||
return text(active.commandAttrs?.category) ?? text(active.bindingAttrs?.group) ?? UNKNOWN
|
|
||||||
}
|
|
||||||
|
|
||||||
function activeKeyEntry(api: TuiPluginApi, active: ActiveKey<Renderable, KeyEvent>): Entry {
|
|
||||||
const key = api.keys.formatSequence([
|
|
||||||
{
|
|
||||||
stroke: active.stroke,
|
|
||||||
display: active.display,
|
|
||||||
tokenName: active.tokenName,
|
|
||||||
},
|
|
||||||
])
|
|
||||||
const label = activeKeyLabel(active)
|
|
||||||
return {
|
|
||||||
type: "entry",
|
|
||||||
key,
|
|
||||||
label: active.continues ? `+${label}` : label,
|
|
||||||
group: activeKeyGroup(active),
|
|
||||||
continues: active.continues,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function grouped(entries: Entry[]): Group[] {
|
|
||||||
const map = new Map<string, Entry[]>()
|
|
||||||
for (const entry of entries) map.set(entry.group, [...(map.get(entry.group) ?? []), entry])
|
|
||||||
return [...map]
|
|
||||||
.map(([label, entries]) => ({
|
|
||||||
label,
|
|
||||||
entries: entries.toSorted(
|
|
||||||
(a, b) =>
|
|
||||||
Number(b.continues) - Number(a.continues) || a.label.localeCompare(b.label) || a.key.localeCompare(b.key),
|
|
||||||
),
|
|
||||||
}))
|
|
||||||
.toSorted((a, b) => a.label.localeCompare(b.label))
|
|
||||||
}
|
|
||||||
|
|
||||||
function commandShortcut(_api: TuiPluginApi, name: string) {
|
|
||||||
const shortcuts = Keymap.useShortcuts()
|
|
||||||
return () => shortcuts.get(name) ?? ""
|
|
||||||
}
|
|
||||||
|
|
||||||
function layout(value: unknown): Layout {
|
|
||||||
if (value === "overlay") return "overlay"
|
|
||||||
return "dock"
|
|
||||||
}
|
|
||||||
|
|
||||||
function HomeHint(props: { api: TuiPluginApi }) {
|
|
||||||
const trigger = commandShortcut(props.api, command.toggle)
|
|
||||||
const look = createMemo(() => skin(props.api))
|
|
||||||
|
|
||||||
return (
|
|
||||||
<box width="100%" maxWidth={75} alignItems="center" paddingTop={1} flexShrink={0}>
|
|
||||||
<text fg={look().muted} wrapMode="none">
|
|
||||||
Show keyboard shortcuts with <span style={{ fg: look().subtle }}>{trigger() || command.toggle}</span>
|
|
||||||
</text>
|
|
||||||
</box>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function WhichKeyPanel(props: {
|
|
||||||
api: TuiPluginApi
|
|
||||||
layout: Layout
|
|
||||||
mode: () => Layout
|
|
||||||
pendingPreview: () => boolean
|
|
||||||
pinned: () => boolean
|
|
||||||
}) {
|
|
||||||
const dimensions = useTerminalDimensions()
|
|
||||||
const [offset, setOffset] = createSignal(0)
|
|
||||||
const [activeGroup, setActiveGroup] = createSignal<string | undefined>()
|
|
||||||
const pending = Keymap.usePendingSequence()
|
|
||||||
const active = Keymap.useActiveKeys()
|
|
||||||
const pendingActive = createMemo(() => pending().length > 0 && active().length > 0)
|
|
||||||
const pendingAutoVisible = createMemo(() => props.mode() === "overlay" && props.pendingPreview() && pendingActive())
|
|
||||||
const visible = createMemo(() => props.pinned() || pendingAutoVisible())
|
|
||||||
const pendingMode = createMemo(() => visible() && pendingActive())
|
|
||||||
const left = 0
|
|
||||||
const width = createMemo(() => Math.max(1, dimensions().width))
|
|
||||||
const panelHeight = createMemo(() =>
|
|
||||||
Math.max(MIN_PANEL_HEIGHT, Math.min(MAX_PANEL_HEIGHT, Math.floor(dimensions().height * PANEL_HEIGHT_RATIO))),
|
|
||||||
)
|
|
||||||
const contentWidth = createMemo(() => Math.max(1, width() - 2))
|
|
||||||
const columns = createMemo(() =>
|
|
||||||
Math.max(1, Math.min(3, Math.floor((contentWidth() + COLUMN_GAP) / (MAX_COLUMN_WIDTH + COLUMN_GAP)) || 1)),
|
|
||||||
)
|
|
||||||
const entries = createMemo(() => active().map((item) => activeKeyEntry(props.api, item)))
|
|
||||||
const groups = createMemo(() => grouped(entries()))
|
|
||||||
const tabsVisible = createMemo(() => !pendingMode() && groups().length > 0)
|
|
||||||
const headerVisible = createMemo(() => tabsVisible() || pendingMode())
|
|
||||||
const footerVisible = createMemo(() => !pendingMode())
|
|
||||||
const rows = createMemo(() =>
|
|
||||||
Math.max(
|
|
||||||
1,
|
|
||||||
panelHeight() -
|
|
||||||
PANEL_TOP_PADDING -
|
|
||||||
(headerVisible() ? 1 : 0) -
|
|
||||||
(tabsVisible() ? TAB_CONTENT_GAP : 0) -
|
|
||||||
(footerVisible() ? FOOTER_MARGIN + FOOTER_HEIGHT : 0),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
const pageSize = createMemo(() => rows() * columns())
|
|
||||||
const currentGroup = createMemo(() => {
|
|
||||||
const group = activeGroup()
|
|
||||||
return groups().find((item) => item.label === group) ?? groups()[0]
|
|
||||||
})
|
|
||||||
const activeEntries = createMemo(() => currentGroup()?.entries ?? [])
|
|
||||||
const items = createMemo<Item[]>(() => {
|
|
||||||
if (!pendingMode()) return activeEntries()
|
|
||||||
return groups().flatMap((group) => [{ type: "group", label: group.label } satisfies GroupHeader, ...group.entries])
|
|
||||||
})
|
|
||||||
const maxOffset = createMemo(() => Math.max(0, items().length - pageSize()))
|
|
||||||
const shown = createMemo(() => {
|
|
||||||
const columnsItems: Item[][] = []
|
|
||||||
let index = offset()
|
|
||||||
for (let column = 0; column < columns() && index < items().length; column++) {
|
|
||||||
const list: Item[] = []
|
|
||||||
while (list.length < rows() && index < items().length) {
|
|
||||||
list.push(items()[index]!)
|
|
||||||
index += 1
|
|
||||||
}
|
|
||||||
columnsItems.push(list)
|
|
||||||
}
|
|
||||||
return columnsItems
|
|
||||||
})
|
|
||||||
const rowIndexes = createMemo(() => Array.from({ length: rows() }, (_, index) => index))
|
|
||||||
const trigger = commandShortcut(props.api, command.toggle)
|
|
||||||
const modeTrigger = commandShortcut(props.api, command.toggleLayout)
|
|
||||||
const upActive = createMemo(() => offset() > 0)
|
|
||||||
const downActive = createMemo(() => offset() < maxOffset())
|
|
||||||
const scrollable = createMemo(() => maxOffset() > 0)
|
|
||||||
const headerItems = createMemo<HeaderItem[]>(() => [
|
|
||||||
...(tabsVisible() ? groups().map((group) => ({ type: "tab" as const, group })) : []),
|
|
||||||
...(scrollable() ? [{ type: "scroll" as const }] : []),
|
|
||||||
])
|
|
||||||
const tabGap = createMemo(() => {
|
|
||||||
const itemCount = headerItems().length
|
|
||||||
if (itemCount <= 1) return 0
|
|
||||||
const itemWidth = headerItems().reduce(
|
|
||||||
(sum, item) => sum + (item.type === "tab" ? item.group.label.length + 2 : 3),
|
|
||||||
0,
|
|
||||||
)
|
|
||||||
return Math.max(MIN_TAB_GAP, Math.min(TAB_GAP, Math.floor((contentWidth() - itemWidth) / (itemCount - 1))))
|
|
||||||
})
|
|
||||||
const nextMode = createMemo(() => (props.mode() === "dock" ? "overlay" : "dock"))
|
|
||||||
const look = createMemo(() => skin(props.api))
|
|
||||||
const columnWidth = createMemo(() =>
|
|
||||||
Math.max(1, Math.min(MAX_COLUMN_WIDTH, Math.floor((contentWidth() - (columns() - 1) * COLUMN_GAP) / columns()))),
|
|
||||||
)
|
|
||||||
const clamp = (value: number) => Math.max(0, Math.min(maxOffset(), value))
|
|
||||||
const scroll = (delta: number) => setOffset((value) => clamp(value + delta))
|
|
||||||
const moveGroup = (delta: number) => {
|
|
||||||
if (pendingMode()) return
|
|
||||||
const list = groups()
|
|
||||||
if (!list.length) return
|
|
||||||
const index = Math.max(
|
|
||||||
0,
|
|
||||||
list.findIndex((item) => item.label === currentGroup()?.label),
|
|
||||||
)
|
|
||||||
setActiveGroup(list[(index + delta + list.length) % list.length]!.label)
|
|
||||||
setOffset(0)
|
|
||||||
}
|
|
||||||
|
|
||||||
Keymap.createLayer(() => ({
|
|
||||||
priority: 1000,
|
|
||||||
enabled: visible(),
|
|
||||||
commands: [
|
|
||||||
{
|
|
||||||
id: command.groupPrevious,
|
|
||||||
bind: false,
|
|
||||||
title: "Previous key binding group",
|
|
||||||
description: "Show the previous which-key group",
|
|
||||||
group: "System",
|
|
||||||
run() {
|
|
||||||
moveGroup(-1)
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: command.groupNext,
|
|
||||||
bind: false,
|
|
||||||
title: "Next key binding group",
|
|
||||||
description: "Show the next which-key group",
|
|
||||||
group: "System",
|
|
||||||
run() {
|
|
||||||
moveGroup(1)
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: command.scrollUp,
|
|
||||||
bind: false,
|
|
||||||
title: "Scroll key bindings up",
|
|
||||||
description: "Scroll the which-key panel up",
|
|
||||||
group: "System",
|
|
||||||
run() {
|
|
||||||
scroll(-columns())
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: command.scrollDown,
|
|
||||||
bind: false,
|
|
||||||
title: "Scroll key bindings down",
|
|
||||||
description: "Scroll the which-key panel down",
|
|
||||||
group: "System",
|
|
||||||
run() {
|
|
||||||
scroll(columns())
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: command.pageUp,
|
|
||||||
bind: false,
|
|
||||||
title: "Page key bindings up",
|
|
||||||
description: "Page the which-key panel up",
|
|
||||||
group: "System",
|
|
||||||
run() {
|
|
||||||
scroll(-pageSize())
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: command.pageDown,
|
|
||||||
bind: false,
|
|
||||||
title: "Page key bindings down",
|
|
||||||
description: "Page the which-key panel down",
|
|
||||||
group: "System",
|
|
||||||
run() {
|
|
||||||
scroll(pageSize())
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: command.home,
|
|
||||||
bind: false,
|
|
||||||
title: "First key binding",
|
|
||||||
description: "Jump to the first which-key binding",
|
|
||||||
group: "System",
|
|
||||||
run() {
|
|
||||||
setOffset(0)
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: command.end,
|
|
||||||
bind: false,
|
|
||||||
title: "Last key binding",
|
|
||||||
description: "Jump to the last which-key binding",
|
|
||||||
group: "System",
|
|
||||||
run() {
|
|
||||||
setOffset(maxOffset())
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
bindings: pendingMode() ? scrollCommands : panelCommands,
|
|
||||||
}))
|
|
||||||
|
|
||||||
createEffect(() => {
|
|
||||||
if (pendingMode()) return
|
|
||||||
const group = currentGroup()
|
|
||||||
if (group?.label === activeGroup()) return
|
|
||||||
setActiveGroup(group?.label)
|
|
||||||
})
|
|
||||||
|
|
||||||
createEffect(() => {
|
|
||||||
if (pendingMode()) return
|
|
||||||
activeGroup()
|
|
||||||
setOffset(0)
|
|
||||||
})
|
|
||||||
|
|
||||||
createEffect(() => {
|
|
||||||
if (!visible()) setOffset(0)
|
|
||||||
})
|
|
||||||
|
|
||||||
createEffect(() => {
|
|
||||||
pending()
|
|
||||||
setOffset(0)
|
|
||||||
})
|
|
||||||
|
|
||||||
createEffect(() => {
|
|
||||||
setOffset((value) => clamp(value))
|
|
||||||
})
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Show when={visible()}>
|
|
||||||
<box
|
|
||||||
position={props.layout === "overlay" ? "absolute" : "relative"}
|
|
||||||
zIndex={3500}
|
|
||||||
left={left}
|
|
||||||
bottom={props.layout === "overlay" ? 0 : undefined}
|
|
||||||
width={dimensions().width}
|
|
||||||
height={panelHeight()}
|
|
||||||
backgroundColor={look().panel}
|
|
||||||
paddingLeft={1}
|
|
||||||
paddingRight={1}
|
|
||||||
paddingTop={1}
|
|
||||||
flexShrink={0}
|
|
||||||
flexDirection="column"
|
|
||||||
>
|
|
||||||
<Show when={headerVisible()}>
|
|
||||||
<box width="100%" flexDirection="row" justifyContent="center" gap={tabGap()} flexShrink={0}>
|
|
||||||
<For each={headerItems()}>
|
|
||||||
{(item) => (
|
|
||||||
<Show
|
|
||||||
when={item.type === "tab" ? item.group : undefined}
|
|
||||||
fallback={
|
|
||||||
<box flexShrink={0}>
|
|
||||||
<text wrapMode="none">
|
|
||||||
<span style={{ fg: upActive() ? look().text : look().muted }}>↑</span>
|
|
||||||
<span style={{ fg: look().muted }}> </span>
|
|
||||||
<span style={{ fg: downActive() ? look().text : look().muted }}>↓</span>
|
|
||||||
</text>
|
|
||||||
</box>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{(group) => {
|
|
||||||
const selected = createMemo(() => currentGroup()?.label === group().label)
|
|
||||||
return (
|
|
||||||
<box
|
|
||||||
paddingLeft={1}
|
|
||||||
paddingRight={1}
|
|
||||||
flexShrink={0}
|
|
||||||
backgroundColor={selected() ? look().tab : undefined}
|
|
||||||
onMouseDown={() => {
|
|
||||||
setActiveGroup(group().label)
|
|
||||||
setOffset(0)
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<text
|
|
||||||
fg={selected() ? look().tabText : look().muted}
|
|
||||||
attributes={selected() ? TextAttributes.BOLD : undefined}
|
|
||||||
wrapMode="none"
|
|
||||||
>
|
|
||||||
{group().label}
|
|
||||||
</text>
|
|
||||||
</box>
|
|
||||||
)
|
|
||||||
}}
|
|
||||||
</Show>
|
|
||||||
)}
|
|
||||||
</For>
|
|
||||||
</box>
|
|
||||||
</Show>
|
|
||||||
<Show when={tabsVisible()}>
|
|
||||||
<box height={TAB_CONTENT_GAP} flexShrink={0} />
|
|
||||||
</Show>
|
|
||||||
<box height={rows()} flexShrink={0} flexDirection="column">
|
|
||||||
<Show when={shown().length > 0} fallback={<text fg={look().muted}>No reachable bindings</text>}>
|
|
||||||
<For each={rowIndexes()}>
|
|
||||||
{(row) => (
|
|
||||||
<box width="100%" flexDirection="row" justifyContent="center" gap={COLUMN_GAP}>
|
|
||||||
<For each={shown()}>
|
|
||||||
{(column) => {
|
|
||||||
const item = createMemo(() => column[row])
|
|
||||||
const entry = createMemo(() => {
|
|
||||||
const value = item()
|
|
||||||
if (value?.type !== "entry") return undefined
|
|
||||||
return value
|
|
||||||
})
|
|
||||||
return (
|
|
||||||
<box width={columnWidth()} flexDirection="row" gap={1} justifyContent="space-between">
|
|
||||||
<Show when={item()}>
|
|
||||||
{(value) => (
|
|
||||||
<Show
|
|
||||||
when={entry()}
|
|
||||||
fallback={
|
|
||||||
<text fg={look().accent} attributes={TextAttributes.BOLD} wrapMode="none" truncate>
|
|
||||||
{value().label}
|
|
||||||
</text>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{(binding) => (
|
|
||||||
<>
|
|
||||||
<box flexGrow={1} minWidth={0}>
|
|
||||||
<text
|
|
||||||
fg={binding().continues ? look().accent : look().muted}
|
|
||||||
wrapMode="none"
|
|
||||||
truncate
|
|
||||||
>
|
|
||||||
{binding().label}
|
|
||||||
</text>
|
|
||||||
</box>
|
|
||||||
<box flexShrink={0}>
|
|
||||||
<text fg={look().text} attributes={TextAttributes.BOLD} wrapMode="none" truncate>
|
|
||||||
{binding().key}
|
|
||||||
</text>
|
|
||||||
</box>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Show>
|
|
||||||
)}
|
|
||||||
</Show>
|
|
||||||
</box>
|
|
||||||
)
|
|
||||||
}}
|
|
||||||
</For>
|
|
||||||
</box>
|
|
||||||
)}
|
|
||||||
</For>
|
|
||||||
</Show>
|
|
||||||
</box>
|
|
||||||
<Show when={footerVisible()}>
|
|
||||||
<box height={FOOTER_MARGIN} flexShrink={0} />
|
|
||||||
<box width="100%" flexDirection="row" justifyContent="space-between" flexShrink={0}>
|
|
||||||
<box>
|
|
||||||
<text fg={look().text} wrapMode="none">
|
|
||||||
toggle <span style={{ fg: look().subtle }}>{trigger() || command.toggle}</span>
|
|
||||||
</text>
|
|
||||||
</box>
|
|
||||||
<box>
|
|
||||||
<text fg={look().text} wrapMode="none">
|
|
||||||
{nextMode()} <span style={{ fg: look().subtle }}>{modeTrigger() || command.toggleLayout}</span>
|
|
||||||
</text>
|
|
||||||
</box>
|
|
||||||
</box>
|
|
||||||
</Show>
|
|
||||||
</box>
|
|
||||||
</Show>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const tui: TuiPlugin = async (api) => {
|
|
||||||
const [pinned, setPinned] = createSignal(false)
|
|
||||||
const [mode, setMode] = createSignal(layout("dock"))
|
|
||||||
const [pendingPreview, setPendingPreview] = createSignal(false)
|
|
||||||
|
|
||||||
api.keymap.registerLayer({
|
|
||||||
priority: LAYER_PRIORITY,
|
|
||||||
commands: [
|
|
||||||
{
|
|
||||||
name: command.toggle,
|
|
||||||
title: "Show key bindings",
|
|
||||||
desc: "Toggle which-key overlay",
|
|
||||||
category: "System",
|
|
||||||
run() {
|
|
||||||
setPinned((value) => !value)
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: command.toggleLayout,
|
|
||||||
title: "Toggle key bindings layout",
|
|
||||||
desc: "Switch which-key between dock and overlay mode",
|
|
||||||
category: "System",
|
|
||||||
run() {
|
|
||||||
setMode((value) => {
|
|
||||||
const next = value === "dock" ? "overlay" : "dock"
|
|
||||||
return next
|
|
||||||
})
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: command.togglePending,
|
|
||||||
title: "Toggle pending key preview",
|
|
||||||
desc: "Automatically show which-key for pending key sequences in overlay mode",
|
|
||||||
category: "System",
|
|
||||||
run() {
|
|
||||||
setPendingPreview((value) => {
|
|
||||||
return !value
|
|
||||||
})
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
bindings: toggleCommands.flatMap((command) => api.tuiConfig.keybinds.get(command)),
|
|
||||||
})
|
|
||||||
|
|
||||||
api.slots.register({
|
|
||||||
order: 200,
|
|
||||||
slots: {
|
|
||||||
home_bottom() {
|
|
||||||
return <HomeHint api={api} />
|
|
||||||
},
|
|
||||||
app() {
|
|
||||||
return (
|
|
||||||
<Show when={mode() === "overlay"}>
|
|
||||||
<WhichKeyPanel api={api} layout="overlay" mode={mode} pendingPreview={pendingPreview} pinned={pinned} />
|
|
||||||
</Show>
|
|
||||||
)
|
|
||||||
},
|
|
||||||
app_bottom() {
|
|
||||||
return (
|
|
||||||
<Show when={mode() === "dock"}>
|
|
||||||
<WhichKeyPanel api={api} layout="dock" mode={mode} pendingPreview={pendingPreview} pinned={pinned} />
|
|
||||||
</Show>
|
|
||||||
)
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const plugin: BuiltinTuiPlugin = {
|
|
||||||
id: "which-key",
|
|
||||||
enabled: false,
|
|
||||||
tui,
|
|
||||||
}
|
|
||||||
|
|
||||||
export default plugin
|
|
||||||
@@ -767,8 +767,12 @@ export function RunSubagentSelectBody(props: {
|
|||||||
onRows?: (rows: number) => void
|
onRows?: (rows: number) => void
|
||||||
mono?: boolean
|
mono?: boolean
|
||||||
}) {
|
}) {
|
||||||
|
const [active, setActive] = createSignal(true)
|
||||||
const entries = createMemo<SubagentEntry[]>(() =>
|
const entries = createMemo<SubagentEntry[]>(() =>
|
||||||
props.tabs().map((item) => {
|
props
|
||||||
|
.tabs()
|
||||||
|
.filter((item) => (active() ? item.status === "running" : item.status !== "running"))
|
||||||
|
.map((item) => {
|
||||||
const title = item.description || item.title || item.label
|
const title = item.description || item.title || item.label
|
||||||
return {
|
return {
|
||||||
category: "",
|
category: "",
|
||||||
@@ -788,6 +792,12 @@ export function RunSubagentSelectBody(props: {
|
|||||||
onSelect: (item) => props.onSelect(item.sessionID),
|
onSelect: (item) => props.onSelect(item.sessionID),
|
||||||
isCurrent: (item) => item.current,
|
isCurrent: (item) => item.current,
|
||||||
closeOnFirstUp: true,
|
closeOnFirstUp: true,
|
||||||
|
onKey(event) {
|
||||||
|
if (event.name.toLowerCase() !== "tab") return false
|
||||||
|
event.preventDefault()
|
||||||
|
setActive((value) => !value)
|
||||||
|
return true
|
||||||
|
},
|
||||||
onRows: props.onRows,
|
onRows: props.onRows,
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -801,6 +811,7 @@ export function RunSubagentSelectBody(props: {
|
|||||||
theme={props.theme}
|
theme={props.theme}
|
||||||
inputRef={controller.inputRef}
|
inputRef={controller.inputRef}
|
||||||
onQuery={controller.setQuery}
|
onQuery={controller.setQuery}
|
||||||
|
hint={`tab show ${active() ? "inactive" : "active"}`}
|
||||||
mono={props.mono}
|
mono={props.mono}
|
||||||
>
|
>
|
||||||
<RunFooterMenu
|
<RunFooterMenu
|
||||||
|
|||||||
@@ -1,40 +0,0 @@
|
|||||||
import type { TuiRouteDefinition } from "@opencode-ai/plugin/v1/tui"
|
|
||||||
import { createSignal } from "solid-js"
|
|
||||||
|
|
||||||
type RouteEntry = {
|
|
||||||
key: symbol
|
|
||||||
render: TuiRouteDefinition["render"]
|
|
||||||
}
|
|
||||||
|
|
||||||
export type RouteMap = Map<string, RouteEntry[]>
|
|
||||||
|
|
||||||
export function createPluginRoutes() {
|
|
||||||
const routes: RouteMap = new Map()
|
|
||||||
const [revision, setRevision] = createSignal(0)
|
|
||||||
|
|
||||||
return {
|
|
||||||
register(list: TuiRouteDefinition[]) {
|
|
||||||
const key = Symbol()
|
|
||||||
list.forEach((item) => routes.set(item.name, [...(routes.get(item.name) ?? []), { key, render: item.render }]))
|
|
||||||
setRevision((value) => value + 1)
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
list.forEach((item) => {
|
|
||||||
const next = routes.get(item.name)?.filter((entry) => entry.key !== key) ?? []
|
|
||||||
if (next.length) {
|
|
||||||
routes.set(item.name, next)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
routes.delete(item.name)
|
|
||||||
})
|
|
||||||
setRevision((value) => value + 1)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
get(name: string) {
|
|
||||||
revision()
|
|
||||||
return routes.get(name)?.at(-1)?.render
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export type PluginRoutes = ReturnType<typeof createPluginRoutes>
|
|
||||||
@@ -5,6 +5,7 @@ import SidebarLsp from "../feature-plugins/sidebar/lsp"
|
|||||||
import SidebarMcp from "../feature-plugins/sidebar/mcp"
|
import SidebarMcp from "../feature-plugins/sidebar/mcp"
|
||||||
import DiffViewer from "../feature-plugins/system/diff-viewer"
|
import DiffViewer from "../feature-plugins/system/diff-viewer"
|
||||||
import Notifications from "../feature-plugins/system/notifications"
|
import Notifications from "../feature-plugins/system/notifications"
|
||||||
|
import Plugins from "../feature-plugins/system/plugins"
|
||||||
import Scrap from "../feature-plugins/system/scrap"
|
import Scrap from "../feature-plugins/system/scrap"
|
||||||
|
|
||||||
export const builtins = [
|
export const builtins = [
|
||||||
@@ -14,6 +15,7 @@ export const builtins = [
|
|||||||
SidebarLsp,
|
SidebarLsp,
|
||||||
SidebarFooter,
|
SidebarFooter,
|
||||||
Notifications,
|
Notifications,
|
||||||
|
Plugins,
|
||||||
Scrap,
|
Scrap,
|
||||||
DiffViewer,
|
DiffViewer,
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,108 +0,0 @@
|
|||||||
// Legacy `api.command` bridge for v1 plugins; remove in v2.
|
|
||||||
import type { TuiCommand, TuiPluginApi } from "@opencode-ai/plugin/v1/tui"
|
|
||||||
import { TuiKeybind } from "../config/keybind"
|
|
||||||
import type { DialogContext } from "../ui/dialog"
|
|
||||||
|
|
||||||
const COMMAND_PALETTE_SHOW = "command.palette.show"
|
|
||||||
const warned = new Set<string>()
|
|
||||||
|
|
||||||
type Warn = (api: string, replacement: string) => void
|
|
||||||
type LegacyDialog = TuiPluginApi["ui"]["dialog"]
|
|
||||||
type CommandShimDialog = DialogContext | LegacyDialog
|
|
||||||
type LegacyKeybinds = TuiPluginApi["tuiConfig"]["keybinds"]
|
|
||||||
|
|
||||||
function warnCommandShim(api: string, replacement: string) {
|
|
||||||
// Warn v1 plugins about deprecated `api.command`; remove this shim path in v2.
|
|
||||||
console.warn("[tui.plugin] deprecated TUI plugin API", { api, replacement })
|
|
||||||
}
|
|
||||||
|
|
||||||
function createCommandShimDialog(dialog: CommandShimDialog): LegacyDialog {
|
|
||||||
if (!("stack" in dialog)) return dialog
|
|
||||||
return {
|
|
||||||
replace(render, onClose) {
|
|
||||||
dialog.replace(render, onClose)
|
|
||||||
},
|
|
||||||
clear() {
|
|
||||||
dialog.clear()
|
|
||||||
},
|
|
||||||
setSize(size) {
|
|
||||||
dialog.setSize(size)
|
|
||||||
},
|
|
||||||
get size() {
|
|
||||||
return dialog.size
|
|
||||||
},
|
|
||||||
get depth() {
|
|
||||||
return dialog.stack.length
|
|
||||||
},
|
|
||||||
get open() {
|
|
||||||
return dialog.stack.length > 0
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function warnOnce(api: string, replacement: string, warn: Warn) {
|
|
||||||
if (warned.has(api)) return
|
|
||||||
warned.add(api)
|
|
||||||
warn(api, replacement)
|
|
||||||
}
|
|
||||||
|
|
||||||
function toCommand(item: TuiCommand, dialog: LegacyDialog) {
|
|
||||||
return {
|
|
||||||
namespace: "palette",
|
|
||||||
name: item.value,
|
|
||||||
title: item.title,
|
|
||||||
desc: item.description,
|
|
||||||
category: item.category,
|
|
||||||
suggested: item.suggested,
|
|
||||||
hidden: item.hidden,
|
|
||||||
enabled: item.enabled,
|
|
||||||
slash: item.slash,
|
|
||||||
run() {
|
|
||||||
return item.onSelect?.(dialog)
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function toBindings(commands: TuiCommand[], keybinds: LegacyKeybinds) {
|
|
||||||
return commands.flatMap((item) =>
|
|
||||||
item.keybind
|
|
||||||
? keybinds.has(TuiKeybind.CommandMap[item.keybind as keyof typeof TuiKeybind.CommandMap] ?? item.keybind)
|
|
||||||
? keybinds
|
|
||||||
.get(TuiKeybind.CommandMap[item.keybind as keyof typeof TuiKeybind.CommandMap] ?? item.keybind)
|
|
||||||
.map((binding) => ({ ...binding, cmd: item.value, desc: binding.desc ?? item.title }))
|
|
||||||
: [
|
|
||||||
{
|
|
||||||
key: item.keybind,
|
|
||||||
cmd: item.value,
|
|
||||||
desc: item.title,
|
|
||||||
},
|
|
||||||
]
|
|
||||||
: [],
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createCommandShim(
|
|
||||||
keymap: TuiPluginApi["keymap"],
|
|
||||||
dialog: CommandShimDialog,
|
|
||||||
keybinds: LegacyKeybinds,
|
|
||||||
): TuiPluginApi["command"] {
|
|
||||||
const shimDialog = createCommandShimDialog(dialog)
|
|
||||||
return {
|
|
||||||
register(cb) {
|
|
||||||
warnOnce("api.command.register", "api.keymap.registerLayer({ commands, bindings })", warnCommandShim)
|
|
||||||
const commands = cb()
|
|
||||||
return keymap.registerLayer({
|
|
||||||
commands: commands.map((item) => toCommand(item, shimDialog)),
|
|
||||||
bindings: toBindings(commands, keybinds),
|
|
||||||
})
|
|
||||||
},
|
|
||||||
trigger(value) {
|
|
||||||
warnOnce("api.command.trigger", "api.keymap.dispatchCommand(name)", warnCommandShim)
|
|
||||||
keymap.dispatchCommand(value)
|
|
||||||
},
|
|
||||||
show() {
|
|
||||||
warnOnce("api.command.show", `api.keymap.dispatchCommand("${COMMAND_PALETTE_SHOW}")`, warnCommandShim)
|
|
||||||
keymap.dispatchCommand(COMMAND_PALETTE_SHOW)
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { Plugin } from "@opencode-ai/plugin/tui"
|
import { PluginContextProvider, type Plugin } from "@opencode-ai/plugin/tui"
|
||||||
import {
|
import {
|
||||||
batch,
|
batch,
|
||||||
createContext,
|
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 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 { createStore, produce, reconcile as reconcileStore } from "solid-js/store"
|
||||||
import { useRenderer } from "@opentui/solid"
|
import { useRenderer } from "@opentui/solid"
|
||||||
|
import { ensureRuntimePluginSupport } from "@opentui/solid/runtime-plugin-support/configure"
|
||||||
import { useConfig } from "../config"
|
import { useConfig } from "../config"
|
||||||
import { useClient } from "../context/client"
|
import { useClient } from "../context/client"
|
||||||
import { useData } from "../context/data"
|
import { useData } from "../context/data"
|
||||||
@@ -23,7 +24,7 @@ import { Keymap } from "../context/keymap"
|
|||||||
import { useRoute } from "../context/route"
|
import { useRoute } from "../context/route"
|
||||||
import { useTuiApp, useTuiLifecycle, useTuiPaths } from "../context/runtime"
|
import { useTuiApp, useTuiLifecycle, useTuiPaths } from "../context/runtime"
|
||||||
import { useLocation } from "../context/location"
|
import { useLocation } from "../context/location"
|
||||||
import { useTheme } from "../context/theme"
|
import { useTheme, useThemes } from "../context/theme"
|
||||||
import { DialogAlert } from "../ui/dialog-alert"
|
import { DialogAlert } from "../ui/dialog-alert"
|
||||||
import { DialogConfirm } from "../ui/dialog-confirm"
|
import { DialogConfirm } from "../ui/dialog-confirm"
|
||||||
import { DialogPrompt } from "../ui/dialog-prompt"
|
import { DialogPrompt } from "../ui/dialog-prompt"
|
||||||
@@ -33,6 +34,9 @@ import { useToast } from "../ui/toast"
|
|||||||
import { useAttention } from "../context/attention"
|
import { useAttention } from "../context/attention"
|
||||||
import { abbreviateHome } from "../util/path-format"
|
import { abbreviateHome } from "../util/path-format"
|
||||||
import { builtins } from "./builtins"
|
import { builtins } from "./builtins"
|
||||||
|
import { discoverTuiPlugins } from "./discovery"
|
||||||
|
|
||||||
|
ensureRuntimePluginSupport()
|
||||||
|
|
||||||
export interface PackageResolver {
|
export interface PackageResolver {
|
||||||
readonly resolve: (spec: string) => Promise<string | undefined>
|
readonly resolve: (spec: string) => Promise<string | undefined>
|
||||||
@@ -44,9 +48,16 @@ type State =
|
|||||||
| { readonly target: string; readonly status: "unsupported" }
|
| { readonly target: string; readonly status: "unsupported" }
|
||||||
| { readonly target: string; readonly status: "failed"; readonly error: string }
|
| { readonly target: string; readonly status: "failed"; readonly error: string }
|
||||||
|
|
||||||
|
type RegisteredPlugin = {
|
||||||
|
readonly id: string
|
||||||
|
readonly source: "builtin" | "external"
|
||||||
|
readonly active: boolean
|
||||||
|
}
|
||||||
|
|
||||||
type Value = {
|
type Value = {
|
||||||
readonly ready: () => boolean
|
readonly ready: () => boolean
|
||||||
readonly list: () => ReadonlyArray<State>
|
readonly list: () => ReadonlyArray<State>
|
||||||
|
readonly registered: () => ReadonlyArray<RegisteredPlugin>
|
||||||
readonly route: (id: string, name: string) => Page["render"] | undefined
|
readonly route: (id: string, name: string) => Page["render"] | undefined
|
||||||
readonly slot: <Name extends SlotName>(name: Name) => ReadonlyArray<Slot<Name>>
|
readonly slot: <Name extends SlotName>(name: Name) => ReadonlyArray<Slot<Name>>
|
||||||
readonly activate: (id: string) => Promise<boolean>
|
readonly activate: (id: string) => Promise<boolean>
|
||||||
@@ -55,8 +66,8 @@ type Value = {
|
|||||||
|
|
||||||
type Dispose = () => Promise<void>
|
type Dispose = () => Promise<void>
|
||||||
type Registration = {
|
type Registration = {
|
||||||
target: string
|
|
||||||
plugin: Plugin.Definition
|
plugin: Plugin.Definition
|
||||||
|
source: RegisteredPlugin["source"]
|
||||||
options?: Readonly<Record<string, any>>
|
options?: Readonly<Record<string, any>>
|
||||||
active: boolean
|
active: boolean
|
||||||
routes: Record<string, Page>
|
routes: Record<string, Page>
|
||||||
@@ -80,6 +91,8 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver }>
|
|||||||
const paths = useTuiPaths()
|
const paths = useTuiPaths()
|
||||||
const location = useLocation()
|
const location = useLocation()
|
||||||
const theme = useTheme()
|
const theme = useTheme()
|
||||||
|
const themes = useThemes()
|
||||||
|
const pluginTheme = createPluginTheme(theme, themes)
|
||||||
const dialog = useDialog()
|
const dialog = useDialog()
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
const attention = useAttention()
|
const attention = useAttention()
|
||||||
@@ -100,25 +113,12 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver }>
|
|||||||
setStore("registrations", id, "cleanups", [])
|
setStore("registrations", id, "cleanups", [])
|
||||||
})
|
})
|
||||||
const owned: Dispose[] = []
|
const owned: Dispose[] = []
|
||||||
let activeDialog: symbol | undefined
|
let context: Context
|
||||||
const dialogApi: Dialog = {
|
const dialogApi: Dialog = {
|
||||||
show(render, onClose) {
|
show(render, onClose) {
|
||||||
const token = Symbol()
|
dialog.replace(() => <PluginContextProvider value={context}>{render()}</PluginContextProvider>, onClose)
|
||||||
let closed = false
|
|
||||||
activeDialog = token
|
|
||||||
dialog.replace(render, () => {
|
|
||||||
if (closed) return
|
|
||||||
closed = true
|
|
||||||
if (activeDialog === token) activeDialog = undefined
|
|
||||||
onClose?.()
|
|
||||||
})
|
|
||||||
return () => {
|
|
||||||
if (closed || activeDialog !== token) return
|
|
||||||
dialog.clear()
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
set(options) {
|
set(options) {
|
||||||
if (!activeDialog) return
|
|
||||||
dialog.setSize(options.size ?? "medium")
|
dialog.setSize(options.size ?? "medium")
|
||||||
dialog.setCentered(options.centered ?? false)
|
dialog.setCentered(options.centered ?? false)
|
||||||
},
|
},
|
||||||
@@ -214,8 +214,7 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver }>
|
|||||||
toast.show({ ...options, variant: options.variant ?? "info" })
|
toast.show({ ...options, variant: options.variant ?? "info" })
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
owned.push(async () => dialogApi.clear())
|
context = {
|
||||||
const context: Context = {
|
|
||||||
options: item.options ?? {},
|
options: item.options ?? {},
|
||||||
get location() {
|
get location() {
|
||||||
return location.current
|
return location.current
|
||||||
@@ -225,7 +224,7 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver }>
|
|||||||
client: client.api,
|
client: client.api,
|
||||||
data,
|
data,
|
||||||
attention,
|
attention,
|
||||||
theme,
|
theme: pluginTheme,
|
||||||
keymap: {
|
keymap: {
|
||||||
layer: Keymap.createLayer,
|
layer: Keymap.createLayer,
|
||||||
dispatch: keymap.dispatch,
|
dispatch: keymap.dispatch,
|
||||||
@@ -245,7 +244,10 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver }>
|
|||||||
register(page) {
|
register(page) {
|
||||||
if (store.registrations[item.plugin.id]?.routes[page.name])
|
if (store.registrations[item.plugin.id]?.routes[page.name])
|
||||||
throw new Error(`Route already registered: ${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
|
let registered = true
|
||||||
const unregister = () => {
|
const unregister = () => {
|
||||||
if (!registered) return
|
if (!registered) return
|
||||||
@@ -275,7 +277,9 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver }>
|
|||||||
},
|
},
|
||||||
slot(name, render) {
|
slot(name, render) {
|
||||||
if (store.registrations[item.plugin.id]?.slots[name]) throw new Error(`Slot already registered: ${name}`)
|
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
|
let registered = true
|
||||||
const unregister = () => {
|
const unregister = () => {
|
||||||
if (!registered) return
|
if (!registered) return
|
||||||
@@ -342,7 +346,7 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver }>
|
|||||||
.filter(([, registration]) => registration.active)
|
.filter(([, registration]) => registration.active)
|
||||||
.map(([id]) => deactivate(id)),
|
.map(([id]) => deactivate(id)),
|
||||||
)
|
)
|
||||||
const entries = config.data.plugins ?? []
|
const entries = [...(await discoverTuiPlugins(paths.cwd)), ...(config.data.plugins ?? [])]
|
||||||
batch(() => {
|
batch(() => {
|
||||||
setStore("registrations", reconcileStore({}))
|
setStore("registrations", reconcileStore({}))
|
||||||
setStore("states", [])
|
setStore("states", [])
|
||||||
@@ -350,8 +354,8 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver }>
|
|||||||
|
|
||||||
for (const plugin of builtins) {
|
for (const plugin of builtins) {
|
||||||
setStore("registrations", plugin.id, {
|
setStore("registrations", plugin.id, {
|
||||||
target: plugin.id,
|
|
||||||
plugin,
|
plugin,
|
||||||
|
source: "builtin",
|
||||||
active: false,
|
active: false,
|
||||||
routes: {},
|
routes: {},
|
||||||
slots: {},
|
slots: {},
|
||||||
@@ -395,23 +399,24 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver }>
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
const item = { target, plugin, options }
|
setStore("registrations", plugin.id, {
|
||||||
setStore("registrations", item.plugin.id, {
|
plugin,
|
||||||
...item,
|
source: "external",
|
||||||
|
options,
|
||||||
active: false,
|
active: false,
|
||||||
routes: {},
|
routes: {},
|
||||||
slots: {},
|
slots: {},
|
||||||
cleanups: [],
|
cleanups: [],
|
||||||
})
|
})
|
||||||
const error = await activate(item.plugin.id).then(
|
const error = await activate(plugin.id).then(
|
||||||
() => undefined,
|
() => undefined,
|
||||||
(error) => (error instanceof Error ? error.message : String(error)),
|
(error) => (error instanceof Error ? error.message : String(error)),
|
||||||
)
|
)
|
||||||
setStore("states", (items) => [
|
setStore("states", (items) => [
|
||||||
...items.filter((state) => state.target !== item.target && (!("id" in state) || state.id !== item.plugin.id)),
|
...items.filter((state) => state.target !== target && (!("id" in state) || state.id !== plugin.id)),
|
||||||
error
|
error
|
||||||
? { target: item.target, status: "failed", error }
|
? { target, status: "failed", error }
|
||||||
: { target: item.target, id: item.plugin.id, status: "active" },
|
: { target, id: plugin.id, status: "active" },
|
||||||
])
|
])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -445,6 +450,8 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver }>
|
|||||||
value={{
|
value={{
|
||||||
ready: () => store.ready,
|
ready: () => store.ready,
|
||||||
list: () => store.states,
|
list: () => store.states,
|
||||||
|
registered: () =>
|
||||||
|
Object.entries(store.registrations).map(([id, plugin]) => ({ id, source: plugin.source, active: plugin.active })),
|
||||||
route: (id, name) => store.registrations[id]?.routes[name]?.render,
|
route: (id, name) => store.registrations[id]?.routes[name]?.render,
|
||||||
slot: (name) =>
|
slot: (name) =>
|
||||||
Object.values(store.registrations).flatMap((registration) =>
|
Object.values(store.registrations).flatMap((registration) =>
|
||||||
@@ -518,6 +525,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() {
|
export function usePlugin() {
|
||||||
const value = useContext(PluginContext)
|
const value = useContext(PluginContext)
|
||||||
if (!value) throw new Error("PluginProvider is missing")
|
if (!value) throw new Error("PluginProvider is missing")
|
||||||
|
|||||||
@@ -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,79 +0,0 @@
|
|||||||
import type {
|
|
||||||
TuiPluginApi,
|
|
||||||
TuiPluginInstallOptions,
|
|
||||||
TuiPluginInstallResult,
|
|
||||||
TuiPluginStatus,
|
|
||||||
} from "@opencode-ai/plugin/v1/tui"
|
|
||||||
import { createContext, createSignal, useContext, type JSX, type ParentProps } from "solid-js"
|
|
||||||
import { createPluginRoutes } from "./api"
|
|
||||||
import { createSlots, type HostSlots } from "./slots"
|
|
||||||
|
|
||||||
export function createPluginRuntime() {
|
|
||||||
const [commands, setCommands] = createSignal<PluginRuntimeCommands>(emptyCommands)
|
|
||||||
const [status, setStatus] = createSignal<ReadonlyArray<TuiPluginStatus>>([])
|
|
||||||
const slots = createSlots()
|
|
||||||
|
|
||||||
return {
|
|
||||||
Slot: slots.Slot,
|
|
||||||
routes: createPluginRoutes(),
|
|
||||||
commands,
|
|
||||||
status,
|
|
||||||
update(input: { commands?: PluginRuntimeCommands; status?: ReadonlyArray<TuiPluginStatus> }) {
|
|
||||||
if (input.commands) setCommands(input.commands)
|
|
||||||
if (input.status) setStatus(input.status)
|
|
||||||
},
|
|
||||||
clear() {
|
|
||||||
setCommands(emptyCommands)
|
|
||||||
setStatus([])
|
|
||||||
slots.clear()
|
|
||||||
},
|
|
||||||
setupSlots(api: TuiPluginApi): HostSlots {
|
|
||||||
return slots.setup(api)
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export type PluginRuntimeCommands = {
|
|
||||||
activate: (id: string) => Promise<boolean>
|
|
||||||
deactivate: (id: string) => Promise<boolean>
|
|
||||||
add: (spec: string) => Promise<boolean>
|
|
||||||
install: (spec: string, options?: TuiPluginInstallOptions) => Promise<TuiPluginInstallResult>
|
|
||||||
}
|
|
||||||
|
|
||||||
const emptyCommands: PluginRuntimeCommands = {
|
|
||||||
async activate() {
|
|
||||||
return false
|
|
||||||
},
|
|
||||||
async deactivate() {
|
|
||||||
return false
|
|
||||||
},
|
|
||||||
async add() {
|
|
||||||
return false
|
|
||||||
},
|
|
||||||
async install() {
|
|
||||||
return { ok: false, message: "Plugin runtime is not available." }
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
export type PluginRuntime = ReturnType<typeof createPluginRuntime>
|
|
||||||
|
|
||||||
export type TuiPluginHost = {
|
|
||||||
start(input: {
|
|
||||||
api: TuiPluginApi
|
|
||||||
runtime: PluginRuntime
|
|
||||||
dispose?: () => void
|
|
||||||
}): Promise<void>
|
|
||||||
dispose(): Promise<void>
|
|
||||||
}
|
|
||||||
|
|
||||||
const Context = createContext<PluginRuntime>()
|
|
||||||
|
|
||||||
export function PluginRuntimeProvider(props: ParentProps<{ value: PluginRuntime }>): JSX.Element {
|
|
||||||
return <Context.Provider value={props.value}>{props.children}</Context.Provider>
|
|
||||||
}
|
|
||||||
|
|
||||||
export function usePluginRuntime() {
|
|
||||||
const runtime = useContext(Context)
|
|
||||||
if (!runtime) throw new Error("usePluginRuntime must be used within PluginRuntimeProvider")
|
|
||||||
return runtime
|
|
||||||
}
|
|
||||||
@@ -1,65 +0,0 @@
|
|||||||
import type { TuiPluginApi, TuiSlotContext, TuiSlotMap, TuiSlotProps } from "@opencode-ai/plugin/v1/tui"
|
|
||||||
import { createSlot, createSolidSlotRegistry, type JSX, type SolidPlugin } from "@opentui/solid"
|
|
||||||
import { createSignal } from "solid-js"
|
|
||||||
import { isRecord } from "../util/record"
|
|
||||||
|
|
||||||
type RuntimeSlotMap = TuiSlotMap<Record<string, object>>
|
|
||||||
type SlotView = <Name extends string>(props: TuiSlotProps<Name>) => JSX.Element | null
|
|
||||||
|
|
||||||
export type HostSlotPlugin<Slots extends Record<string, object> = {}> = SolidPlugin<TuiSlotMap<Slots>, TuiSlotContext>
|
|
||||||
export type HostPluginApi = TuiPluginApi
|
|
||||||
export type HostSlots = {
|
|
||||||
register: {
|
|
||||||
(plugin: HostSlotPlugin): () => void
|
|
||||||
<Slots extends Record<string, object>>(plugin: HostSlotPlugin<Slots>): () => void
|
|
||||||
}
|
|
||||||
dispose: () => void
|
|
||||||
}
|
|
||||||
|
|
||||||
function isHostSlotPlugin(value: unknown): value is HostSlotPlugin<Record<string, object>> {
|
|
||||||
if (!isRecord(value)) return false
|
|
||||||
if (typeof value.id !== "string") return false
|
|
||||||
return isRecord(value.slots)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createSlots() {
|
|
||||||
const empty: SlotView = (props) => props.children ?? null
|
|
||||||
const [view, setView] = createSignal<SlotView>(empty)
|
|
||||||
const Slot: SlotView = (props) => view()(props)
|
|
||||||
|
|
||||||
return {
|
|
||||||
Slot,
|
|
||||||
setup(api: HostPluginApi): HostSlots {
|
|
||||||
const registry = createSolidSlotRegistry<RuntimeSlotMap, TuiSlotContext>(
|
|
||||||
api.renderer,
|
|
||||||
{ theme: api.theme },
|
|
||||||
{
|
|
||||||
onPluginError(event) {
|
|
||||||
console.error("[tui.slot] plugin error", {
|
|
||||||
plugin: event.pluginId,
|
|
||||||
slot: event.slot,
|
|
||||||
phase: event.phase,
|
|
||||||
source: event.source,
|
|
||||||
message: event.error.message,
|
|
||||||
})
|
|
||||||
},
|
|
||||||
},
|
|
||||||
)
|
|
||||||
const slot = createSlot<RuntimeSlotMap, TuiSlotContext>(registry)
|
|
||||||
setView(() => (props: TuiSlotProps<string>) => slot(props))
|
|
||||||
|
|
||||||
return {
|
|
||||||
register(plugin: HostSlotPlugin) {
|
|
||||||
if (!isHostSlotPlugin(plugin)) return () => {}
|
|
||||||
return registry.register(plugin)
|
|
||||||
},
|
|
||||||
dispose() {
|
|
||||||
setView(() => empty)
|
|
||||||
},
|
|
||||||
}
|
|
||||||
},
|
|
||||||
clear() {
|
|
||||||
setView(() => empty)
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -5,7 +5,6 @@ import { useArgs } from "../context/args"
|
|||||||
import { useRouteData } from "../context/route"
|
import { useRouteData } from "../context/route"
|
||||||
import { usePromptRef } from "../context/prompt"
|
import { usePromptRef } from "../context/prompt"
|
||||||
import { useLocal } from "../context/local"
|
import { useLocal } from "../context/local"
|
||||||
import { usePluginRuntime } from "../plugin/runtime"
|
|
||||||
import { useEditorContext } from "../context/editor"
|
import { useEditorContext } from "../context/editor"
|
||||||
import { useData } from "../context/data"
|
import { useData } from "../context/data"
|
||||||
import { useLocation } from "../context/location"
|
import { useLocation } from "../context/location"
|
||||||
@@ -19,7 +18,6 @@ const placeholder = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function Home() {
|
export function Home() {
|
||||||
const pluginRuntime = usePluginRuntime()
|
|
||||||
const route = useRouteData("home")
|
const route = useRouteData("home")
|
||||||
const promptRef = usePromptRef()
|
const promptRef = usePromptRef()
|
||||||
const [ref, setRef] = createSignal<PromptRef | undefined>()
|
const [ref, setRef] = createSignal<PromptRef | undefined>()
|
||||||
@@ -70,20 +68,11 @@ export function Home() {
|
|||||||
<box flexGrow={1} minHeight={0} />
|
<box flexGrow={1} minHeight={0} />
|
||||||
<box height={4} minHeight={0} flexShrink={1} />
|
<box height={4} minHeight={0} flexShrink={1} />
|
||||||
<box flexShrink={0}>
|
<box flexShrink={0}>
|
||||||
<pluginRuntime.Slot name="home_logo" mode="replace">
|
|
||||||
<Logo />
|
<Logo />
|
||||||
</pluginRuntime.Slot>
|
|
||||||
</box>
|
</box>
|
||||||
<box height={1} minHeight={0} flexShrink={1} />
|
<box height={1} minHeight={0} flexShrink={1} />
|
||||||
<box width="100%" maxWidth={75} zIndex={1000} paddingTop={1} flexShrink={0}>
|
<box width="100%" maxWidth={75} zIndex={1000} paddingTop={1} flexShrink={0}>
|
||||||
<pluginRuntime.Slot name="home_prompt" mode="replace" ref={bind}>
|
<Prompt ref={bind} placeholders={placeholder} disabled={forms().length > 0} />
|
||||||
<Prompt
|
|
||||||
ref={bind}
|
|
||||||
right={<pluginRuntime.Slot name="home_prompt_right" />}
|
|
||||||
placeholders={placeholder}
|
|
||||||
disabled={forms().length > 0}
|
|
||||||
/>
|
|
||||||
</pluginRuntime.Slot>
|
|
||||||
</box>
|
</box>
|
||||||
<box flexGrow={1} minHeight={0} />
|
<box flexGrow={1} minHeight={0} />
|
||||||
</box>
|
</box>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { createEffect, createMemo, For, onCleanup, Show, useContext, createContext } from "solid-js"
|
import { createEffect, createMemo, For, onCleanup, Show, useContext, createContext } from "solid-js"
|
||||||
import { createStore } from "solid-js/store"
|
import { createStore } from "solid-js/store"
|
||||||
import { TextAttributes } from "@opentui/core"
|
import { TextAttributes } from "@opentui/core"
|
||||||
import { useTheme } from "../../../context/theme"
|
import { useThemes } from "../../../context/theme"
|
||||||
import { SplitBorder } from "../../../ui/border"
|
import { SplitBorder } from "../../../ui/border"
|
||||||
import { Keymap } from "../../../context/keymap"
|
import { Keymap } from "../../../context/keymap"
|
||||||
import { SubagentsTab } from "./subagents-tab"
|
import { SubagentsTab } from "./subagents-tab"
|
||||||
@@ -39,7 +39,7 @@ export type ComposerProps = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function Composer(props: ComposerProps) {
|
export function Composer(props: ComposerProps) {
|
||||||
const { themeV2 } = useTheme().contextual("elevated")
|
const theme = useThemes().contextual("elevated")
|
||||||
|
|
||||||
const [store, setStore] = createStore({
|
const [store, setStore] = createStore({
|
||||||
tabs: {} as Record<string, Tab>,
|
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: "left", title: "Previous tab", group: "Composer", run: () => switchTab(-1) },
|
||||||
{ bind: "right", title: "Next 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: "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
|
<box
|
||||||
{...SplitBorder}
|
{...SplitBorder}
|
||||||
border={["left"]}
|
border={["left"]}
|
||||||
borderColor={themeV2.border.default}
|
borderColor={theme.border.default}
|
||||||
backgroundColor={themeV2.background.default}
|
backgroundColor={theme.background.default}
|
||||||
paddingLeft={1}
|
paddingLeft={1}
|
||||||
paddingRight={2}
|
paddingRight={2}
|
||||||
paddingTop={1}
|
paddingTop={1}
|
||||||
@@ -125,7 +119,7 @@ export function Composer(props: ComposerProps) {
|
|||||||
<Show
|
<Show
|
||||||
when={tabList().length > 1}
|
when={tabList().length > 1}
|
||||||
fallback={
|
fallback={
|
||||||
<text fg={themeV2.text.default} attributes={TextAttributes.BOLD}>
|
<text fg={theme.text.default} attributes={TextAttributes.BOLD}>
|
||||||
{tabList()[0]?.label ?? ""}
|
{tabList()[0]?.label ?? ""}
|
||||||
</text>
|
</text>
|
||||||
}
|
}
|
||||||
@@ -136,7 +130,7 @@ export function Composer(props: ComposerProps) {
|
|||||||
const isActive = createMemo(() => store.active === t.id)
|
const isActive = createMemo(() => store.active === t.id)
|
||||||
return (
|
return (
|
||||||
<text
|
<text
|
||||||
fg={isActive() ? themeV2.text.default : themeV2.text.subdued}
|
fg={isActive() ? theme.text.default : theme.text.subdued}
|
||||||
attributes={isActive() ? TextAttributes.BOLD : undefined}
|
attributes={isActive() ? TextAttributes.BOLD : undefined}
|
||||||
>
|
>
|
||||||
{t.label}
|
{t.label}
|
||||||
@@ -146,7 +140,7 @@ export function Composer(props: ComposerProps) {
|
|||||||
</For>
|
</For>
|
||||||
</box>
|
</box>
|
||||||
</Show>
|
</Show>
|
||||||
<text fg={themeV2.text.subdued} onMouseUp={close}>
|
<text fg={theme.text.subdued} onMouseUp={close}>
|
||||||
esc
|
esc
|
||||||
</text>
|
</text>
|
||||||
</box>
|
</box>
|
||||||
@@ -156,19 +150,19 @@ export function Composer(props: ComposerProps) {
|
|||||||
<For each={footerHints()}>
|
<For each={footerHints()}>
|
||||||
{(hint) => (
|
{(hint) => (
|
||||||
<text>
|
<text>
|
||||||
<span style={{ fg: themeV2.text.default }}>
|
<span style={{ fg: theme.text.default }}>
|
||||||
<b>{hint.label}</b>{" "}
|
<b>{hint.label}</b>{" "}
|
||||||
</span>
|
</span>
|
||||||
<span style={{ fg: themeV2.text.subdued }}>{hint.shortcut}</span>
|
<span style={{ fg: theme.text.subdued }}>{hint.shortcut}</span>
|
||||||
</text>
|
</text>
|
||||||
)}
|
)}
|
||||||
</For>
|
</For>
|
||||||
<Show when={tabList().length > 1}>
|
<Show when={tabList().length > 1}>
|
||||||
<text>
|
<text>
|
||||||
<span style={{ fg: themeV2.text.default }}>
|
<span style={{ fg: theme.text.default }}>
|
||||||
<b>tabs</b>{" "}
|
<b>tabs</b>{" "}
|
||||||
</span>
|
</span>
|
||||||
<span style={{ fg: themeV2.text.subdued }}>←/→</span>
|
<span style={{ fg: theme.text.subdued }}>←/→</span>
|
||||||
</text>
|
</text>
|
||||||
</Show>
|
</Show>
|
||||||
</box>
|
</box>
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ export function ShellTab(props: { sessionID: string }) {
|
|||||||
const data = useData()
|
const data = useData()
|
||||||
const location = useLocation()
|
const location = useLocation()
|
||||||
const client = useClient()
|
const client = useClient()
|
||||||
const { themeV2 } = useTheme()
|
const theme = useTheme()
|
||||||
const composer = useComposerTab()
|
const composer = useComposerTab()
|
||||||
const shortcuts = Keymap.useShortcuts()
|
const shortcuts = Keymap.useShortcuts()
|
||||||
|
|
||||||
@@ -98,7 +98,7 @@ export function ShellTab(props: { sessionID: string }) {
|
|||||||
return (
|
return (
|
||||||
<Show when={composer.active("shell")}>
|
<Show when={composer.active("shell")}>
|
||||||
<scrollbox scrollbarOptions={{ visible: false }} maxHeight={5} ref={(r: ScrollBoxRenderable) => (scroll = r)}>
|
<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()}>
|
<For each={entries()}>
|
||||||
{(shell, index) => {
|
{(shell, index) => {
|
||||||
const active = createMemo(() => index() === store.selected)
|
const active = createMemo(() => index() === store.selected)
|
||||||
@@ -108,12 +108,12 @@ export function ShellTab(props: { sessionID: string }) {
|
|||||||
paddingLeft={1}
|
paddingLeft={1}
|
||||||
paddingRight={1}
|
paddingRight={1}
|
||||||
backgroundColor={
|
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())}
|
onMouseOver={() => setStore("selected", index())}
|
||||||
>
|
>
|
||||||
<text
|
<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}
|
attributes={active() ? TextAttributes.BOLD : undefined}
|
||||||
wrapMode="none"
|
wrapMode="none"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -21,12 +21,13 @@ export function SubagentsTab(props: { sessionID: string }) {
|
|||||||
const route = useRouteData("session")
|
const route = useRouteData("session")
|
||||||
const data = useData()
|
const data = useData()
|
||||||
const client = useClient()
|
const client = useClient()
|
||||||
const { themeV2 } = useTheme()
|
const theme = useTheme()
|
||||||
const navigate = useRoute().navigate
|
const navigate = useRoute().navigate
|
||||||
const composer = useComposerTab()
|
const composer = useComposerTab()
|
||||||
const shortcuts = Keymap.useShortcuts()
|
const shortcuts = Keymap.useShortcuts()
|
||||||
|
|
||||||
const session = createMemo(() => data.session.get(props.sessionID))
|
const session = createMemo(() => data.session.get(props.sessionID))
|
||||||
|
const [store, setStore] = createStore({ selected: 0, active: true })
|
||||||
|
|
||||||
const entries = createMemo<SubagentEntry[]>(() => {
|
const entries = createMemo<SubagentEntry[]>(() => {
|
||||||
const current = session()
|
const current = session()
|
||||||
@@ -72,10 +73,9 @@ export function SubagentsTab(props: { sessionID: string }) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return result
|
return result.filter((entry) => (store.active ? entry.status === "running" : entry.status !== "running"))
|
||||||
})
|
})
|
||||||
|
|
||||||
const [store, setStore] = createStore({ selected: 0 })
|
|
||||||
let selectedSessionID = ""
|
let selectedSessionID = ""
|
||||||
let wasActive = false
|
let wasActive = false
|
||||||
let scroll: ScrollBoxRenderable | undefined
|
let scroll: ScrollBoxRenderable | undefined
|
||||||
@@ -90,7 +90,7 @@ export function SubagentsTab(props: { sessionID: string }) {
|
|||||||
if (!active) {
|
if (!active) {
|
||||||
if (wasActive) {
|
if (wasActive) {
|
||||||
selectedSessionID = ""
|
selectedSessionID = ""
|
||||||
setStore("selected", 0)
|
setStore({ selected: 0, active: true })
|
||||||
}
|
}
|
||||||
wasActive = false
|
wasActive = false
|
||||||
return
|
return
|
||||||
@@ -140,8 +140,15 @@ export function SubagentsTab(props: { sessionID: string }) {
|
|||||||
label: "Subagents",
|
label: "Subagents",
|
||||||
hints: () => {
|
hints: () => {
|
||||||
const entry = selectedEntry()
|
const entry = selectedEntry()
|
||||||
if (!entry || entry.status !== "running") return []
|
return [
|
||||||
return [{ label: "interrupt", shortcut: shortcuts.get("composer.subagent.interrupt") ?? "" }]
|
...(entry?.status === "running"
|
||||||
|
? [{ label: "interrupt", shortcut: shortcuts.get("composer.subagent.interrupt") ?? "" }]
|
||||||
|
: []),
|
||||||
|
{
|
||||||
|
label: `show ${store.active ? "inactive" : "active"}`,
|
||||||
|
shortcut: shortcuts.get("composer.subagent.toggle-activity") ?? "",
|
||||||
|
},
|
||||||
|
]
|
||||||
},
|
},
|
||||||
onClose: () => {
|
onClose: () => {
|
||||||
const parentID = session()?.parentID
|
const parentID = session()?.parentID
|
||||||
@@ -189,6 +196,16 @@ export function SubagentsTab(props: { sessionID: string }) {
|
|||||||
if (entry) navigate({ type: "session", sessionID: entry.sessionID })
|
if (entry) navigate({ type: "session", sessionID: entry.sessionID })
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: "composer.subagent.toggle-activity",
|
||||||
|
title: "Toggle active subagents",
|
||||||
|
group: "Composer",
|
||||||
|
bind: "ctrl+a",
|
||||||
|
run() {
|
||||||
|
setStore({ selected: 0, active: !store.active })
|
||||||
|
scroll?.scrollTo(0)
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: "composer.subagent.interrupt",
|
id: "composer.subagent.interrupt",
|
||||||
title: "Interrupt subagent",
|
title: "Interrupt subagent",
|
||||||
@@ -206,7 +223,10 @@ export function SubagentsTab(props: { sessionID: string }) {
|
|||||||
return (
|
return (
|
||||||
<Show when={composer.active("subagents")}>
|
<Show when={composer.active("subagents")}>
|
||||||
<scrollbox scrollbarOptions={{ visible: false }} maxHeight={5} ref={(r: ScrollBoxRenderable) => (scroll = r)}>
|
<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 {store.active ? "active" : "inactive"} subagents</text>}
|
||||||
|
>
|
||||||
<For each={entries()}>
|
<For each={entries()}>
|
||||||
{(entry, index) => {
|
{(entry, index) => {
|
||||||
const active = createMemo(() => index() === selected())
|
const active = createMemo(() => index() === selected())
|
||||||
@@ -221,10 +241,10 @@ export function SubagentsTab(props: { sessionID: string }) {
|
|||||||
paddingRight={1}
|
paddingRight={1}
|
||||||
backgroundColor={
|
backgroundColor={
|
||||||
active()
|
active()
|
||||||
? themeV2.background.action.primary.focused
|
? theme.background.action.primary.focused
|
||||||
: entry.current
|
: entry.current
|
||||||
? themeV2.background.action.primary.selected
|
? theme.background.action.primary.selected
|
||||||
: themeV2.background.action.primary.default
|
: theme.background.action.primary.default
|
||||||
}
|
}
|
||||||
onMouseOver={() => setStore("selected", index())}
|
onMouseOver={() => setStore("selected", index())}
|
||||||
onMouseUp={() => {
|
onMouseUp={() => {
|
||||||
@@ -236,10 +256,10 @@ export function SubagentsTab(props: { sessionID: string }) {
|
|||||||
<text
|
<text
|
||||||
fg={
|
fg={
|
||||||
active()
|
active()
|
||||||
? themeV2.text.action.primary.focused
|
? theme.text.action.primary.focused
|
||||||
: entry.current
|
: entry.current
|
||||||
? themeV2.text.action.primary.selected
|
? theme.text.action.primary.selected
|
||||||
: themeV2.text.action.primary.default
|
: theme.text.action.primary.default
|
||||||
}
|
}
|
||||||
attributes={active() ? TextAttributes.BOLD : undefined}
|
attributes={active() ? TextAttributes.BOLD : undefined}
|
||||||
wrapMode="none"
|
wrapMode="none"
|
||||||
@@ -248,7 +268,7 @@ export function SubagentsTab(props: { sessionID: string }) {
|
|||||||
</text>
|
</text>
|
||||||
</box>
|
</box>
|
||||||
<Show when={status()}>
|
<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()}
|
{status()}
|
||||||
</text>
|
</text>
|
||||||
</Show>
|
</Show>
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { useRoute } from "../../context/route"
|
|||||||
import { usePermission } from "../../context/permission"
|
import { usePermission } from "../../context/permission"
|
||||||
|
|
||||||
export function Footer() {
|
export function Footer() {
|
||||||
const { themeV2 } = useTheme()
|
const theme = useTheme()
|
||||||
const data = useData()
|
const data = useData()
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const permission = usePermission()
|
const permission = usePermission()
|
||||||
@@ -54,35 +54,35 @@ export function Footer() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<box flexDirection="row" justifyContent="space-between" gap={1} flexShrink={0}>
|
<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}>
|
<box gap={2} flexDirection="row" flexShrink={0}>
|
||||||
<Switch>
|
<Switch>
|
||||||
<Match when={store.welcome}>
|
<Match when={store.welcome}>
|
||||||
<text fg={themeV2.text.default}>
|
<text fg={theme.text.default}>
|
||||||
Get started <span style={{ fg: themeV2.text.subdued }}>/connect</span>
|
Get started <span style={{ fg: theme.text.subdued }}>/connect</span>
|
||||||
</text>
|
</text>
|
||||||
</Match>
|
</Match>
|
||||||
<Match when={connected()}>
|
<Match when={connected()}>
|
||||||
<Show when={permission.mode !== "auto" && permissions().length > 0}>
|
<Show when={permission.mode !== "auto" && permissions().length > 0}>
|
||||||
<text fg={themeV2.text.feedback.warning.default}>
|
<text fg={theme.text.feedback.warning.default}>
|
||||||
<span style={{ fg: themeV2.text.feedback.warning.default }}>△</span> {permissions().length} Permission
|
<span style={{ fg: theme.text.feedback.warning.default }}>△</span> {permissions().length} Permission
|
||||||
{permissions().length > 1 ? "s" : ""}
|
{permissions().length > 1 ? "s" : ""}
|
||||||
</text>
|
</text>
|
||||||
</Show>
|
</Show>
|
||||||
<Show when={mcp()}>
|
<Show when={mcp()}>
|
||||||
<text fg={themeV2.text.default}>
|
<text fg={theme.text.default}>
|
||||||
<Switch>
|
<Switch>
|
||||||
<Match when={mcpError()}>
|
<Match when={mcpError()}>
|
||||||
<span style={{ fg: themeV2.text.feedback.error.default }}>⊙ </span>
|
<span style={{ fg: theme.text.feedback.error.default }}>⊙ </span>
|
||||||
</Match>
|
</Match>
|
||||||
<Match when={true}>
|
<Match when={true}>
|
||||||
<span style={{ fg: themeV2.text.feedback.success.default }}>⊙ </span>
|
<span style={{ fg: theme.text.feedback.success.default }}>⊙ </span>
|
||||||
</Match>
|
</Match>
|
||||||
</Switch>
|
</Switch>
|
||||||
{mcp()} MCP
|
{mcp()} MCP
|
||||||
</text>
|
</text>
|
||||||
</Show>
|
</Show>
|
||||||
<text fg={themeV2.text.subdued}>/status</text>
|
<text fg={theme.text.subdued}>/status</text>
|
||||||
</Match>
|
</Match>
|
||||||
</Switch>
|
</Switch>
|
||||||
</box>
|
</box>
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { createMemo, createSignal, For, onCleanup, onMount, Show } from "solid-j
|
|||||||
import { useRenderer, useTerminalDimensions } from "@opentui/solid"
|
import { useRenderer, useTerminalDimensions } from "@opentui/solid"
|
||||||
import type { ScrollBoxRenderable, TextareaRenderable } from "@opentui/core"
|
import type { ScrollBoxRenderable, TextareaRenderable } from "@opentui/core"
|
||||||
import open from "open"
|
import open from "open"
|
||||||
import { useTheme } from "../../context/theme"
|
import { useThemes } from "../../context/theme"
|
||||||
import type { FormField, FormValue } from "@opencode-ai/client"
|
import type { FormField, FormValue } from "@opencode-ai/client"
|
||||||
import type { FormWithLocation } from "../../context/data"
|
import type { FormWithLocation } from "../../context/data"
|
||||||
import { useClient } from "../../context/client"
|
import { useClient } from "../../context/client"
|
||||||
@@ -44,7 +44,9 @@ function requestOptions(form: FormWithLocation) {
|
|||||||
|
|
||||||
export function FormPrompt(props: { form: FormWithLocation }) {
|
export function FormPrompt(props: { form: FormWithLocation }) {
|
||||||
const client = useClient()
|
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 renderer = useRenderer()
|
||||||
const dimensions = useTerminalDimensions()
|
const dimensions = useTerminalDimensions()
|
||||||
const keymap = Keymap.use()
|
const keymap = Keymap.use()
|
||||||
@@ -624,27 +626,27 @@ export function FormPrompt(props: { form: FormWithLocation }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<box
|
<box
|
||||||
backgroundColor={themeV2.background.default}
|
backgroundColor={theme.background.default}
|
||||||
border={["left"]}
|
border={["left"]}
|
||||||
borderColor={themeV2.hue.interactive[themeMode() === "light" ? 800 : 200]}
|
borderColor={theme.hue.interactive[themeMode() === "light" ? 800 : 200]}
|
||||||
customBorderChars={SplitBorder.customBorderChars}
|
customBorderChars={SplitBorder.customBorderChars}
|
||||||
>
|
>
|
||||||
<box gap={1} paddingLeft={1} paddingRight={3} paddingTop={1} paddingBottom={1}>
|
<box gap={1} paddingLeft={1} paddingRight={3} paddingTop={1} paddingBottom={1}>
|
||||||
<box paddingLeft={1}>
|
<box paddingLeft={1}>
|
||||||
<text fg={themeV2.text.subdued}>{props.form.title}</text>
|
<text fg={theme.text.subdued}>{props.form.title}</text>
|
||||||
</box>
|
</box>
|
||||||
<Show when={message()}>
|
<Show when={message()}>
|
||||||
<box paddingLeft={1}>
|
<box paddingLeft={1}>
|
||||||
<text fg={themeV2.text.default}>{message()}</text>
|
<text fg={theme.text.default}>{message()}</text>
|
||||||
</box>
|
</box>
|
||||||
</Show>
|
</Show>
|
||||||
<Show when={!single() && !tabbed()}>
|
<Show when={!single() && !tabbed()}>
|
||||||
<box flexDirection="row" gap={1} paddingLeft={1}>
|
<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}`}
|
{confirm() ? "Review" : `Field ${Math.min(store.tab, fields().length - 1) + 1} of ${fields().length}`}
|
||||||
</text>
|
</text>
|
||||||
<Show when={fields().length > 0}>
|
<Show when={fields().length > 0}>
|
||||||
<text fg={themeV2.text.subdued}>
|
<text fg={theme.text.subdued}>
|
||||||
· {answered()}/{fields().length} completed
|
· {answered()}/{fields().length} completed
|
||||||
</text>
|
</text>
|
||||||
</Show>
|
</Show>
|
||||||
@@ -661,10 +663,10 @@ export function FormPrompt(props: { form: FormWithLocation }) {
|
|||||||
paddingRight={2}
|
paddingRight={2}
|
||||||
backgroundColor={
|
backgroundColor={
|
||||||
isTab()
|
isTab()
|
||||||
? themeV2.background.formfield.selected
|
? theme.background.formfield.selected
|
||||||
: tabHover() === index()
|
: tabHover() === index()
|
||||||
? themeV2.background.formfield.focused
|
? theme.background.formfield.focused
|
||||||
: themeV2.background.default
|
: theme.background.default
|
||||||
}
|
}
|
||||||
onMouseOver={() => setTabHover(index())}
|
onMouseOver={() => setTabHover(index())}
|
||||||
onMouseOut={() => setTabHover(null)}
|
onMouseOut={() => setTabHover(null)}
|
||||||
@@ -676,12 +678,12 @@ export function FormPrompt(props: { form: FormWithLocation }) {
|
|||||||
<text
|
<text
|
||||||
fg={
|
fg={
|
||||||
isTab()
|
isTab()
|
||||||
? themeV2.text.formfield.selected
|
? theme.text.formfield.selected
|
||||||
: tabHover() === index()
|
: tabHover() === index()
|
||||||
? themeV2.text.formfield.focused
|
? theme.text.formfield.focused
|
||||||
: isAnswered()
|
: isAnswered()
|
||||||
? themeV2.text.default
|
? theme.text.default
|
||||||
: themeV2.text.subdued
|
: theme.text.subdued
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{truncate(formLabel(item), 24)}
|
{truncate(formLabel(item), 24)}
|
||||||
@@ -693,10 +695,10 @@ export function FormPrompt(props: { form: FormWithLocation }) {
|
|||||||
<box
|
<box
|
||||||
backgroundColor={
|
backgroundColor={
|
||||||
confirm()
|
confirm()
|
||||||
? themeV2.background.formfield.selected
|
? theme.background.formfield.selected
|
||||||
: tabHover() === "confirm"
|
: tabHover() === "confirm"
|
||||||
? themeV2.background.formfield.focused
|
? theme.background.formfield.focused
|
||||||
: themeV2.background.default
|
: theme.background.default
|
||||||
}
|
}
|
||||||
onMouseOver={() => setTabHover("confirm")}
|
onMouseOver={() => setTabHover("confirm")}
|
||||||
onMouseOut={() => setTabHover(null)}
|
onMouseOut={() => setTabHover(null)}
|
||||||
@@ -705,7 +707,7 @@ export function FormPrompt(props: { form: FormWithLocation }) {
|
|||||||
selectTabFromMouse()
|
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>
|
||||||
</box>
|
</box>
|
||||||
</Show>
|
</Show>
|
||||||
@@ -714,13 +716,13 @@ export function FormPrompt(props: { form: FormWithLocation }) {
|
|||||||
{(external) => (
|
{(external) => (
|
||||||
<box paddingLeft={1} gap={1}>
|
<box paddingLeft={1} gap={1}>
|
||||||
<Show when={external().title}>
|
<Show when={external().title}>
|
||||||
<text fg={themeV2.text.default}>{external().title}</text>
|
<text fg={theme.text.default}>{external().title}</text>
|
||||||
</Show>
|
</Show>
|
||||||
<Show when={external().description}>
|
<Show when={external().description}>
|
||||||
<text fg={themeV2.text.subdued}>{external().description}</text>
|
<text fg={theme.text.subdued}>{external().description}</text>
|
||||||
</Show>
|
</Show>
|
||||||
<text
|
<text
|
||||||
fg={themeV2.background.action.primary.default}
|
fg={theme.background.action.primary.default}
|
||||||
onMouseUp={() => {
|
onMouseUp={() => {
|
||||||
if (renderer.getSelection()?.getSelectedText()) return
|
if (renderer.getSelection()?.getSelectedText()) return
|
||||||
openExternal()
|
openExternal()
|
||||||
@@ -729,9 +731,7 @@ export function FormPrompt(props: { form: FormWithLocation }) {
|
|||||||
{external().url}
|
{external().url}
|
||||||
</text>
|
</text>
|
||||||
<text
|
<text
|
||||||
fg={
|
fg={store.answers[external().key] === true ? theme.text.feedback.success.default : theme.text.subdued}
|
||||||
store.answers[external().key] === true ? themeV2.text.feedback.success.default : themeV2.text.subdued
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
{store.answers[external().key] === true
|
{store.answers[external().key] === true
|
||||||
? "✓ Acknowledged"
|
? "✓ Acknowledged"
|
||||||
@@ -746,7 +746,7 @@ export function FormPrompt(props: { form: FormWithLocation }) {
|
|||||||
<Show when={!confirm() && answerField()}>
|
<Show when={!confirm() && answerField()}>
|
||||||
<box paddingLeft={1} gap={1}>
|
<box paddingLeft={1} gap={1}>
|
||||||
<box>
|
<box>
|
||||||
<text fg={themeV2.text.default}>{answerField()!.description ?? formLabel(answerField()!)}</text>
|
<text fg={theme.text.default}>{answerField()!.description ?? formLabel(answerField()!)}</text>
|
||||||
</box>
|
</box>
|
||||||
<Show when={textual() ? answerField()!.key : undefined} keyed>
|
<Show when={textual() ? answerField()!.key : undefined} keyed>
|
||||||
<box paddingLeft={1}>
|
<box paddingLeft={1}>
|
||||||
@@ -763,12 +763,12 @@ export function FormPrompt(props: { form: FormWithLocation }) {
|
|||||||
input() || formDisplayValue(answerField()!, store.answers[answerField()!.key], "(none)")
|
input() || formDisplayValue(answerField()!, store.answers[answerField()!.key], "(none)")
|
||||||
}
|
}
|
||||||
placeholder={placeholder()}
|
placeholder={placeholder()}
|
||||||
placeholderColor={themeV2.text.subdued}
|
placeholderColor={theme.text.subdued}
|
||||||
minHeight={1}
|
minHeight={1}
|
||||||
maxHeight={6}
|
maxHeight={6}
|
||||||
textColor={themeV2.text.default}
|
textColor={theme.text.default}
|
||||||
focusedTextColor={themeV2.text.default}
|
focusedTextColor={theme.text.default}
|
||||||
cursorColor={themeV2.text.default}
|
cursorColor={theme.text.default}
|
||||||
/>
|
/>
|
||||||
</box>
|
</box>
|
||||||
</Show>
|
</Show>
|
||||||
@@ -793,39 +793,35 @@ export function FormPrompt(props: { form: FormWithLocation }) {
|
|||||||
>
|
>
|
||||||
<box flexDirection="row">
|
<box flexDirection="row">
|
||||||
<box
|
<box
|
||||||
backgroundColor={
|
backgroundColor={active() ? theme.background.formfield.focused : theme.background.default}
|
||||||
active() ? themeV2.background.formfield.focused : themeV2.background.default
|
|
||||||
}
|
|
||||||
paddingRight={1}
|
paddingRight={1}
|
||||||
>
|
>
|
||||||
<text
|
<text
|
||||||
fg={active() ? themeV2.text.formfield.focused : themeV2.text.formfield.default}
|
fg={active() ? theme.text.formfield.focused : theme.text.formfield.default}
|
||||||
>{`${i() + 1}.`}</text>
|
>{`${i() + 1}.`}</text>
|
||||||
</box>
|
</box>
|
||||||
<box
|
<box
|
||||||
backgroundColor={
|
backgroundColor={active() ? theme.background.formfield.focused : theme.background.default}
|
||||||
active() ? themeV2.background.formfield.focused : themeV2.background.default
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
<text
|
<text
|
||||||
fg={
|
fg={
|
||||||
active()
|
active()
|
||||||
? themeV2.text.formfield.focused
|
? theme.text.formfield.focused
|
||||||
: picked()
|
: picked()
|
||||||
? themeV2.text.formfield.selected
|
? theme.text.formfield.selected
|
||||||
: themeV2.text.formfield.default
|
: theme.text.formfield.default
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{multi() ? `[${picked() ? "✓" : " "}] ${row.label}` : row.label}
|
{multi() ? `[${picked() ? "✓" : " "}] ${row.label}` : row.label}
|
||||||
</text>
|
</text>
|
||||||
</box>
|
</box>
|
||||||
<Show when={!multi()}>
|
<Show when={!multi()}>
|
||||||
<text fg={themeV2.text.formfield.selected}>{picked() ? " ✓" : ""}</text>
|
<text fg={theme.text.formfield.selected}>{picked() ? " ✓" : ""}</text>
|
||||||
</Show>
|
</Show>
|
||||||
</box>
|
</box>
|
||||||
<Show when={row.description}>
|
<Show when={row.description}>
|
||||||
<box paddingLeft={3}>
|
<box paddingLeft={3}>
|
||||||
<text fg={themeV2.text.subdued}>{row.description}</text>
|
<text fg={theme.text.subdued}>{row.description}</text>
|
||||||
</box>
|
</box>
|
||||||
</Show>
|
</Show>
|
||||||
</box>
|
</box>
|
||||||
@@ -843,30 +839,28 @@ export function FormPrompt(props: { form: FormWithLocation }) {
|
|||||||
>
|
>
|
||||||
<box flexDirection="row">
|
<box flexDirection="row">
|
||||||
<box
|
<box
|
||||||
backgroundColor={other() ? themeV2.background.formfield.focused : themeV2.background.default}
|
backgroundColor={other() ? theme.background.formfield.focused : theme.background.default}
|
||||||
paddingRight={1}
|
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}.`}
|
{`${rows().length + 1}.`}
|
||||||
</text>
|
</text>
|
||||||
</box>
|
</box>
|
||||||
<box
|
<box backgroundColor={other() ? theme.background.formfield.focused : theme.background.default}>
|
||||||
backgroundColor={other() ? themeV2.background.formfield.focused : themeV2.background.default}
|
|
||||||
>
|
|
||||||
<text
|
<text
|
||||||
fg={
|
fg={
|
||||||
other()
|
other()
|
||||||
? themeV2.text.formfield.focused
|
? theme.text.formfield.focused
|
||||||
: customPicked()
|
: customPicked()
|
||||||
? themeV2.text.feedback.success.default
|
? theme.text.feedback.success.default
|
||||||
: themeV2.text.default
|
: theme.text.default
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{multi() ? `[${customPicked() ? "✓" : " "}] Type your own answer` : "Type your own answer"}
|
{multi() ? `[${customPicked() ? "✓" : " "}] Type your own answer` : "Type your own answer"}
|
||||||
</text>
|
</text>
|
||||||
</box>
|
</box>
|
||||||
<Show when={!multi()}>
|
<Show when={!multi()}>
|
||||||
<text fg={themeV2.text.feedback.success.default}>{customPicked() ? " ✓" : ""}</text>
|
<text fg={theme.text.feedback.success.default}>{customPicked() ? " ✓" : ""}</text>
|
||||||
</Show>
|
</Show>
|
||||||
</box>
|
</box>
|
||||||
<Show when={store.editing}>
|
<Show when={store.editing}>
|
||||||
@@ -882,18 +876,18 @@ export function FormPrompt(props: { form: FormWithLocation }) {
|
|||||||
}}
|
}}
|
||||||
initialValue={input()}
|
initialValue={input()}
|
||||||
placeholder="Type your own answer"
|
placeholder="Type your own answer"
|
||||||
placeholderColor={themeV2.text.subdued}
|
placeholderColor={theme.text.subdued}
|
||||||
minHeight={1}
|
minHeight={1}
|
||||||
maxHeight={6}
|
maxHeight={6}
|
||||||
textColor={themeV2.text.default}
|
textColor={theme.text.default}
|
||||||
focusedTextColor={themeV2.text.default}
|
focusedTextColor={theme.text.default}
|
||||||
cursorColor={themeV2.text.default}
|
cursorColor={theme.text.default}
|
||||||
/>
|
/>
|
||||||
</box>
|
</box>
|
||||||
</Show>
|
</Show>
|
||||||
<Show when={!store.editing && input()}>
|
<Show when={!store.editing && input()}>
|
||||||
<box paddingLeft={3}>
|
<box paddingLeft={3}>
|
||||||
<text fg={themeV2.text.subdued}>{input()}</text>
|
<text fg={theme.text.subdued}>{input()}</text>
|
||||||
</box>
|
</box>
|
||||||
</Show>
|
</Show>
|
||||||
</box>
|
</box>
|
||||||
@@ -906,7 +900,7 @@ export function FormPrompt(props: { form: FormWithLocation }) {
|
|||||||
<Show when={confirm()}>
|
<Show when={confirm()}>
|
||||||
<Show when={tabbed()}>
|
<Show when={tabbed()}>
|
||||||
<box paddingLeft={1}>
|
<box paddingLeft={1}>
|
||||||
<text fg={themeV2.text.default}>Review</text>
|
<text fg={theme.text.default}>Review</text>
|
||||||
</box>
|
</box>
|
||||||
</Show>
|
</Show>
|
||||||
<scrollbox
|
<scrollbox
|
||||||
@@ -921,12 +915,12 @@ export function FormPrompt(props: { form: FormWithLocation }) {
|
|||||||
return (
|
return (
|
||||||
<box paddingLeft={1}>
|
<box paddingLeft={1}>
|
||||||
<text>
|
<text>
|
||||||
<span style={{ fg: themeV2.text.subdued }}>{truncate(formLabel(item), 40)}:</span>{" "}
|
<span style={{ fg: theme.text.subdued }}>{truncate(formLabel(item), 40)}:</span>{" "}
|
||||||
<span
|
<span
|
||||||
style={{
|
style={{
|
||||||
fg: acknowledged()
|
fg: acknowledged()
|
||||||
? themeV2.text.feedback.success.default
|
? theme.text.feedback.success.default
|
||||||
: themeV2.text.feedback.error.default,
|
: theme.text.feedback.error.default,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{acknowledged() ? "Acknowledged" : "(acknowledgement required)"}
|
{acknowledged() ? "Acknowledged" : "(acknowledgement required)"}
|
||||||
@@ -942,15 +936,15 @@ export function FormPrompt(props: { form: FormWithLocation }) {
|
|||||||
return (
|
return (
|
||||||
<box paddingLeft={1}>
|
<box paddingLeft={1}>
|
||||||
<text>
|
<text>
|
||||||
<span style={{ fg: themeV2.text.subdued }}>{truncate(formLabel(item), 40)}:</span>{" "}
|
<span style={{ fg: theme.text.subdued }}>{truncate(formLabel(item), 40)}:</span>{" "}
|
||||||
<span
|
<span
|
||||||
style={{
|
style={{
|
||||||
fg:
|
fg:
|
||||||
invalid() || missing()
|
invalid() || missing()
|
||||||
? themeV2.text.feedback.error.default
|
? theme.text.feedback.error.default
|
||||||
: answered()
|
: answered()
|
||||||
? themeV2.text.default
|
? theme.text.default
|
||||||
: themeV2.text.subdued,
|
: theme.text.subdued,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{invalid() ?? (answered() ? value() : missing() ? "(required)" : "(not answered)")}
|
{invalid() ?? (answered() ? value() : missing() ? "(required)" : "(not answered)")}
|
||||||
@@ -974,41 +968,41 @@ export function FormPrompt(props: { form: FormWithLocation }) {
|
|||||||
>
|
>
|
||||||
<box flexDirection="row" gap={2}>
|
<box flexDirection="row" gap={2}>
|
||||||
<Show when={!single()}>
|
<Show when={!single()}>
|
||||||
<text fg={themeV2.text.default}>
|
<text fg={theme.text.default}>
|
||||||
{"⇆"} <span style={{ fg: themeV2.text.subdued }}>tab</span>
|
{"⇆"} <span style={{ fg: theme.text.subdued }}>tab</span>
|
||||||
</text>
|
</text>
|
||||||
</Show>
|
</Show>
|
||||||
<Show when={!confirm() && !textual() && !externalField()}>
|
<Show when={!confirm() && !textual() && !externalField()}>
|
||||||
<text fg={themeV2.text.default}>
|
<text fg={theme.text.default}>
|
||||||
{"↑↓"} <span style={{ fg: themeV2.text.subdued }}>select</span>
|
{"↑↓"} <span style={{ fg: theme.text.subdued }}>select</span>
|
||||||
</text>
|
</text>
|
||||||
</Show>
|
</Show>
|
||||||
<Show when={confirm() && fields().length > 0}>
|
<Show when={confirm() && fields().length > 0}>
|
||||||
<text fg={themeV2.text.default}>
|
<text fg={theme.text.default}>
|
||||||
{"↑↓"} <span style={{ fg: themeV2.text.subdued }}>scroll</span>
|
{"↑↓"} <span style={{ fg: theme.text.subdued }}>scroll</span>
|
||||||
</text>
|
</text>
|
||||||
</Show>
|
</Show>
|
||||||
<text
|
<text
|
||||||
fg={themeV2.text.default}
|
fg={theme.text.default}
|
||||||
onMouseUp={() => {
|
onMouseUp={() => {
|
||||||
if (renderer.getSelection()?.getSelectedText()) return
|
if (renderer.getSelection()?.getSelectedText()) return
|
||||||
if (confirm()) submit()
|
if (confirm()) submit()
|
||||||
if (externalField()) acknowledgeExternal()
|
if (externalField()) acknowledgeExternal()
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
enter <span style={{ fg: themeV2.text.subdued }}>{actionLabel()}</span>
|
enter <span style={{ fg: theme.text.subdued }}>{actionLabel()}</span>
|
||||||
</text>
|
</text>
|
||||||
<Show when={externalField() && clipboard.write}>
|
<Show when={externalField() && clipboard.write}>
|
||||||
<text fg={themeV2.text.default} onMouseUp={copyExternal}>
|
<text fg={theme.text.default} onMouseUp={copyExternal}>
|
||||||
c <span style={{ fg: themeV2.text.subdued }}>copy</span>
|
c <span style={{ fg: theme.text.subdued }}>copy</span>
|
||||||
</text>
|
</text>
|
||||||
</Show>
|
</Show>
|
||||||
<text fg={themeV2.text.default} onMouseUp={cancel}>
|
<text fg={theme.text.default} onMouseUp={cancel}>
|
||||||
esc <span style={{ fg: themeV2.text.subdued }}>dismiss</span>
|
esc <span style={{ fg: theme.text.subdued }}>dismiss</span>
|
||||||
</text>
|
</text>
|
||||||
</box>
|
</box>
|
||||||
<Show when={store.error}>
|
<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>
|
</Show>
|
||||||
</box>
|
</box>
|
||||||
</box>
|
</box>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -2,7 +2,7 @@ import { createStore } from "solid-js/store"
|
|||||||
import { createMemo, For, Match, Show, Switch } from "solid-js"
|
import { createMemo, For, Match, Show, Switch } from "solid-js"
|
||||||
import { Portal, useRenderer, useTerminalDimensions, type JSX } from "@opentui/solid"
|
import { Portal, useRenderer, useTerminalDimensions, type JSX } from "@opentui/solid"
|
||||||
import type { TextareaRenderable } from "@opentui/core"
|
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 type { PermissionRequest } from "@opencode-ai/client"
|
||||||
import { useClient } from "../../context/client"
|
import { useClient } from "../../context/client"
|
||||||
import { SplitBorder } from "../../ui/border"
|
import { SplitBorder } from "../../ui/border"
|
||||||
@@ -18,9 +18,9 @@ import { SimulationSemantics } from "../../simulation/semantics"
|
|||||||
type PermissionStage = "permission" | "always" | "reject"
|
type PermissionStage = "permission" | "always" | "reject"
|
||||||
|
|
||||||
function EditBody(props: { file?: string; diff?: string; patch?: string }) {
|
function EditBody(props: { file?: string; diff?: string; patch?: string }) {
|
||||||
const themeState = useTheme()
|
const theme = useTheme()
|
||||||
const themeV2 = themeState.themeV2
|
const themes = useThemes()
|
||||||
const syntax = themeState.syntax
|
const syntax = themes.currentSyntax
|
||||||
const config = useConfig().data
|
const config = useConfig().data
|
||||||
const dimensions = useTerminalDimensions()
|
const dimensions = useTerminalDimensions()
|
||||||
|
|
||||||
@@ -45,8 +45,8 @@ function EditBody(props: { file?: string; diff?: string; patch?: string }) {
|
|||||||
scrollAcceleration={scrollAcceleration()}
|
scrollAcceleration={scrollAcceleration()}
|
||||||
verticalScrollbarOptions={{
|
verticalScrollbarOptions={{
|
||||||
trackOptions: {
|
trackOptions: {
|
||||||
backgroundColor: themeV2.background.default,
|
backgroundColor: theme.background.default,
|
||||||
foregroundColor: themeV2.scrollbar.default,
|
foregroundColor: theme.scrollbar.default,
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -58,16 +58,16 @@ function EditBody(props: { file?: string; diff?: string; patch?: string }) {
|
|||||||
showLineNumbers={true}
|
showLineNumbers={true}
|
||||||
width="100%"
|
width="100%"
|
||||||
wrapMode="word"
|
wrapMode="word"
|
||||||
fg={themeV2.text.default}
|
fg={theme.text.default}
|
||||||
addedBg={themeV2.diff.background.added}
|
addedBg={theme.diff.background.added}
|
||||||
removedBg={themeV2.diff.background.removed}
|
removedBg={theme.diff.background.removed}
|
||||||
contextBg={themeV2.diff.background.context}
|
contextBg={theme.diff.background.context}
|
||||||
addedSignColor={themeV2.diff.highlight.added}
|
addedSignColor={theme.diff.highlight.added}
|
||||||
removedSignColor={themeV2.diff.highlight.removed}
|
removedSignColor={theme.diff.highlight.removed}
|
||||||
lineNumberFg={themeV2.diff.lineNumber.text}
|
lineNumberFg={theme.diff.lineNumber.text}
|
||||||
lineNumberBg={themeV2.diff.background.context}
|
lineNumberBg={theme.diff.background.context}
|
||||||
addedLineNumberBg={themeV2.diff.lineNumber.background.added}
|
addedLineNumberBg={theme.diff.lineNumber.background.added}
|
||||||
removedLineNumberBg={themeV2.diff.lineNumber.background.removed}
|
removedLineNumberBg={theme.diff.lineNumber.background.removed}
|
||||||
/>
|
/>
|
||||||
</scrollbox>
|
</scrollbox>
|
||||||
</Show>
|
</Show>
|
||||||
@@ -76,7 +76,7 @@ function EditBody(props: { file?: string; diff?: string; patch?: string }) {
|
|||||||
when={props.patch}
|
when={props.patch}
|
||||||
fallback={
|
fallback={
|
||||||
<box paddingLeft={1}>
|
<box paddingLeft={1}>
|
||||||
<text fg={themeV2.text.subdued}>No diff provided</text>
|
<text fg={theme.text.subdued}>No diff provided</text>
|
||||||
</box>
|
</box>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
@@ -86,8 +86,8 @@ function EditBody(props: { file?: string; diff?: string; patch?: string }) {
|
|||||||
scrollAcceleration={scrollAcceleration()}
|
scrollAcceleration={scrollAcceleration()}
|
||||||
verticalScrollbarOptions={{
|
verticalScrollbarOptions={{
|
||||||
trackOptions: {
|
trackOptions: {
|
||||||
backgroundColor: themeV2.background.default,
|
backgroundColor: theme.background.default,
|
||||||
foregroundColor: themeV2.scrollbar.default,
|
foregroundColor: theme.scrollbar.default,
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -97,7 +97,7 @@ function EditBody(props: { file?: string; diff?: string; patch?: string }) {
|
|||||||
streaming={true}
|
streaming={true}
|
||||||
syntaxStyle={syntax()}
|
syntaxStyle={syntax()}
|
||||||
content={patch()}
|
content={patch()}
|
||||||
fg={themeV2.text.subdued}
|
fg={theme.text.subdued}
|
||||||
/>
|
/>
|
||||||
</scrollbox>
|
</scrollbox>
|
||||||
)}
|
)}
|
||||||
@@ -128,7 +128,7 @@ export function PermissionPrompt(props: { request: PermissionRequest; directory?
|
|||||||
return { input: undefined, metadata: undefined }
|
return { input: undefined, metadata: undefined }
|
||||||
})
|
})
|
||||||
|
|
||||||
const { themeV2 } = useTheme()
|
const theme = useTheme()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Switch>
|
<Switch>
|
||||||
@@ -140,7 +140,7 @@ export function PermissionPrompt(props: { request: PermissionRequest; directory?
|
|||||||
body={
|
body={
|
||||||
<box paddingLeft={1} gap={1}>
|
<box paddingLeft={1} gap={1}>
|
||||||
<For each={permissionAlwaysLines(props.request)}>
|
<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>
|
</For>
|
||||||
</box>
|
</box>
|
||||||
}
|
}
|
||||||
@@ -192,9 +192,9 @@ export function PermissionPrompt(props: { request: PermissionRequest; directory?
|
|||||||
) : props.request.action === "external_directory" ? (
|
) : props.request.action === "external_directory" ? (
|
||||||
<Show when={current.lines.length > 0}>
|
<Show when={current.lines.length > 0}>
|
||||||
<box paddingLeft={1} gap={1}>
|
<box paddingLeft={1} gap={1}>
|
||||||
<text fg={themeV2.text.subdued}>Patterns</text>
|
<text fg={theme.text.subdued}>Patterns</text>
|
||||||
<box>
|
<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>
|
||||||
</box>
|
</box>
|
||||||
</Show>
|
</Show>
|
||||||
@@ -207,8 +207,8 @@ export function PermissionPrompt(props: { request: PermissionRequest; directory?
|
|||||||
props.request.action === "shell" ||
|
props.request.action === "shell" ||
|
||||||
props.request.action === "subagent" ||
|
props.request.action === "subagent" ||
|
||||||
props.request.action === "task"
|
props.request.action === "task"
|
||||||
? themeV2.text.default
|
? theme.text.default
|
||||||
: themeV2.text.subdued
|
: theme.text.subdued
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{line}
|
{line}
|
||||||
@@ -221,15 +221,15 @@ export function PermissionPrompt(props: { request: PermissionRequest; directory?
|
|||||||
const header = () => (
|
const header = () => (
|
||||||
<box flexDirection="column" gap={0}>
|
<box flexDirection="column" gap={0}>
|
||||||
<box flexDirection="row" gap={1} flexShrink={0}>
|
<box flexDirection="row" gap={1} flexShrink={0}>
|
||||||
<text fg={themeV2.text.feedback.warning.default}>{"△"}</text>
|
<text fg={theme.text.feedback.warning.default}>{"△"}</text>
|
||||||
<text fg={themeV2.text.default}>Permission required</text>
|
<text fg={theme.text.default}>Permission required</text>
|
||||||
</box>
|
</box>
|
||||||
<Show when={props.request.action !== "shell" && current.title}>
|
<Show when={props.request.action !== "shell" && current.title}>
|
||||||
<box flexDirection="row" gap={1} paddingLeft={2} flexShrink={0}>
|
<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}
|
{current.icon}
|
||||||
</text>
|
</text>
|
||||||
<text fg={themeV2.text.default}>{current.title}</text>
|
<text fg={theme.text.default}>{current.title}</text>
|
||||||
</box>
|
</box>
|
||||||
</Show>
|
</Show>
|
||||||
</box>
|
</box>
|
||||||
@@ -297,7 +297,7 @@ function RejectPrompt(props: {
|
|||||||
onCancel: () => void
|
onCancel: () => void
|
||||||
}) {
|
}) {
|
||||||
let input: TextareaRenderable
|
let input: TextareaRenderable
|
||||||
const { themeV2 } = useTheme().contextual("elevated")
|
const theme = useThemes().contextual("elevated")
|
||||||
const dimensions = useTerminalDimensions()
|
const dimensions = useTerminalDimensions()
|
||||||
const narrow = createMemo(() => dimensions().width < 80)
|
const narrow = createMemo(() => dimensions().width < 80)
|
||||||
Keymap.createLayer(() => ({
|
Keymap.createLayer(() => ({
|
||||||
@@ -329,18 +329,18 @@ function RejectPrompt(props: {
|
|||||||
role: "dialog",
|
role: "dialog",
|
||||||
label: `Reject permission: ${props.action}`,
|
label: `Reject permission: ${props.action}`,
|
||||||
}))}
|
}))}
|
||||||
backgroundColor={themeV2.background.default}
|
backgroundColor={theme.background.default}
|
||||||
border={["left"]}
|
border={["left"]}
|
||||||
borderColor={themeV2.text.feedback.error.default}
|
borderColor={theme.text.feedback.error.default}
|
||||||
customBorderChars={SplitBorder.customBorderChars}
|
customBorderChars={SplitBorder.customBorderChars}
|
||||||
>
|
>
|
||||||
<box gap={1} paddingLeft={1} paddingRight={3} paddingTop={1} paddingBottom={1}>
|
<box gap={1} paddingLeft={1} paddingRight={3} paddingTop={1} paddingBottom={1}>
|
||||||
<box flexDirection="row" gap={1} paddingLeft={1}>
|
<box flexDirection="row" gap={1} paddingLeft={1}>
|
||||||
<text fg={themeV2.text.feedback.error.default}>{"△"}</text>
|
<text fg={theme.text.feedback.error.default}>{"△"}</text>
|
||||||
<text fg={themeV2.text.default}>Reject permission</text>
|
<text fg={theme.text.default}>Reject permission</text>
|
||||||
</box>
|
</box>
|
||||||
<box paddingLeft={1}>
|
<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>
|
</box>
|
||||||
<box
|
<box
|
||||||
@@ -350,7 +350,7 @@ function RejectPrompt(props: {
|
|||||||
paddingLeft={2}
|
paddingLeft={2}
|
||||||
paddingRight={3}
|
paddingRight={3}
|
||||||
paddingBottom={1}
|
paddingBottom={1}
|
||||||
backgroundColor={themeV2.raise(themeV2.background.default)}
|
backgroundColor={theme.raise(theme.background.default)}
|
||||||
justifyContent={narrow() ? "flex-start" : "space-between"}
|
justifyContent={narrow() ? "flex-start" : "space-between"}
|
||||||
alignItems={narrow() ? "flex-start" : "center"}
|
alignItems={narrow() ? "flex-start" : "center"}
|
||||||
gap={1}
|
gap={1}
|
||||||
@@ -369,9 +369,9 @@ function RejectPrompt(props: {
|
|||||||
val.traits = { status: "REJECT" }
|
val.traits = { status: "REJECT" }
|
||||||
}}
|
}}
|
||||||
focused
|
focused
|
||||||
textColor={themeV2.text.default}
|
textColor={theme.text.default}
|
||||||
focusedTextColor={themeV2.text.default}
|
focusedTextColor={theme.text.default}
|
||||||
cursorColor={themeV2.text.default}
|
cursorColor={theme.text.default}
|
||||||
/>
|
/>
|
||||||
<box
|
<box
|
||||||
id="session.permission.reject.actions"
|
id="session.permission.reject.actions"
|
||||||
@@ -394,8 +394,8 @@ function RejectPrompt(props: {
|
|||||||
}))}
|
}))}
|
||||||
onMouseUp={() => props.onConfirm(input.plainText)}
|
onMouseUp={() => props.onConfirm(input.plainText)}
|
||||||
>
|
>
|
||||||
<text fg={themeV2.text.default}>
|
<text fg={theme.text.default}>
|
||||||
enter <span style={{ fg: themeV2.text.subdued }}>confirm</span>
|
enter <span style={{ fg: theme.text.subdued }}>confirm</span>
|
||||||
</text>
|
</text>
|
||||||
</box>
|
</box>
|
||||||
<box
|
<box
|
||||||
@@ -408,8 +408,8 @@ function RejectPrompt(props: {
|
|||||||
}))}
|
}))}
|
||||||
onMouseUp={props.onCancel}
|
onMouseUp={props.onCancel}
|
||||||
>
|
>
|
||||||
<text fg={themeV2.text.default}>
|
<text fg={theme.text.default}>
|
||||||
esc <span style={{ fg: themeV2.text.subdued }}>cancel</span>
|
esc <span style={{ fg: theme.text.subdued }}>cancel</span>
|
||||||
</text>
|
</text>
|
||||||
</box>
|
</box>
|
||||||
</box>
|
</box>
|
||||||
@@ -429,7 +429,7 @@ function Prompt<const T extends Record<string, string>>(props: {
|
|||||||
fullscreen?: boolean
|
fullscreen?: boolean
|
||||||
onSelect: (option: keyof T) => void
|
onSelect: (option: keyof T) => void
|
||||||
}) {
|
}) {
|
||||||
const { themeV2 } = useTheme().contextual("elevated")
|
const theme = useThemes().contextual("elevated")
|
||||||
const dimensions = useTerminalDimensions()
|
const dimensions = useTerminalDimensions()
|
||||||
const keys = Object.keys(props.options) as (keyof T)[]
|
const keys = Object.keys(props.options) as (keyof T)[]
|
||||||
const [store, setStore] = createStore({
|
const [store, setStore] = createStore({
|
||||||
@@ -534,9 +534,9 @@ function Prompt<const T extends Record<string, string>>(props: {
|
|||||||
label: props.semanticLabel ?? props.title,
|
label: props.semanticLabel ?? props.title,
|
||||||
expanded: store.expanded,
|
expanded: store.expanded,
|
||||||
}))}
|
}))}
|
||||||
backgroundColor={themeV2.background.default}
|
backgroundColor={theme.background.default}
|
||||||
border={["left"]}
|
border={["left"]}
|
||||||
borderColor={themeV2.background.action.primary.focused}
|
borderColor={theme.background.action.primary.focused}
|
||||||
customBorderChars={SplitBorder.customBorderChars}
|
customBorderChars={SplitBorder.customBorderChars}
|
||||||
{...(store.expanded
|
{...(store.expanded
|
||||||
? { top: dimensions().height * -1 + 1, bottom: 1, left: 2, right: 2, position: "absolute" }
|
? { 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}
|
when={props.header}
|
||||||
fallback={
|
fallback={
|
||||||
<box flexDirection="row" gap={1} paddingLeft={1} flexShrink={0}>
|
<box flexDirection="row" gap={1} paddingLeft={1} flexShrink={0}>
|
||||||
<text fg={themeV2.text.feedback.warning.default}>{"△"}</text>
|
<text fg={theme.text.feedback.warning.default}>{"△"}</text>
|
||||||
<text fg={themeV2.text.default}>{props.title}</text>
|
<text fg={theme.text.default}>{props.title}</text>
|
||||||
</box>
|
</box>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
@@ -573,7 +573,7 @@ function Prompt<const T extends Record<string, string>>(props: {
|
|||||||
paddingLeft={2}
|
paddingLeft={2}
|
||||||
paddingRight={3}
|
paddingRight={3}
|
||||||
paddingBottom={1}
|
paddingBottom={1}
|
||||||
backgroundColor={themeV2.raise(themeV2.background.default)}
|
backgroundColor={theme.raise(theme.background.default)}
|
||||||
justifyContent={narrow() ? "flex-start" : "space-between"}
|
justifyContent={narrow() ? "flex-start" : "space-between"}
|
||||||
alignItems={narrow() ? "flex-start" : "center"}
|
alignItems={narrow() ? "flex-start" : "center"}
|
||||||
>
|
>
|
||||||
@@ -604,8 +604,8 @@ function Prompt<const T extends Record<string, string>>(props: {
|
|||||||
paddingRight={1}
|
paddingRight={1}
|
||||||
backgroundColor={
|
backgroundColor={
|
||||||
option === store.selected
|
option === store.selected
|
||||||
? themeV2.background.action.primary.focused
|
? theme.background.action.primary.focused
|
||||||
: themeV2.background.action.primary.default
|
: theme.background.action.primary.default
|
||||||
}
|
}
|
||||||
onMouseOver={() => setStore("selected", option)}
|
onMouseOver={() => setStore("selected", option)}
|
||||||
onMouseUp={() => {
|
onMouseUp={() => {
|
||||||
@@ -614,11 +614,7 @@ function Prompt<const T extends Record<string, string>>(props: {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<text
|
<text
|
||||||
fg={
|
fg={option === store.selected ? theme.text.action.primary.focused : theme.text.action.primary.default}
|
||||||
option === store.selected
|
|
||||||
? themeV2.text.action.primary.focused
|
|
||||||
: themeV2.text.action.primary.default
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
{props.options[option]}
|
{props.options[option]}
|
||||||
</text>
|
</text>
|
||||||
@@ -628,15 +624,15 @@ function Prompt<const T extends Record<string, string>>(props: {
|
|||||||
</box>
|
</box>
|
||||||
<box flexDirection="row" gap={2} flexShrink={0}>
|
<box flexDirection="row" gap={2} flexShrink={0}>
|
||||||
<Show when={props.fullscreen}>
|
<Show when={props.fullscreen}>
|
||||||
<text fg={themeV2.text.default}>
|
<text fg={theme.text.default}>
|
||||||
{shortcuts.get("permission.prompt.fullscreen")} <span style={{ fg: themeV2.text.subdued }}>{hint()}</span>
|
{shortcuts.get("permission.prompt.fullscreen")} <span style={{ fg: theme.text.subdued }}>{hint()}</span>
|
||||||
</text>
|
</text>
|
||||||
</Show>
|
</Show>
|
||||||
<text fg={themeV2.text.default}>
|
<text fg={theme.text.default}>
|
||||||
{"⇆"} <span style={{ fg: themeV2.text.subdued }}>select</span>
|
{"⇆"} <span style={{ fg: theme.text.subdued }}>select</span>
|
||||||
</text>
|
</text>
|
||||||
<text fg={themeV2.text.default}>
|
<text fg={theme.text.default}>
|
||||||
enter <span style={{ fg: themeV2.text.subdued }}>confirm</span>
|
enter <span style={{ fg: theme.text.subdued }}>confirm</span>
|
||||||
</text>
|
</text>
|
||||||
</box>
|
</box>
|
||||||
</box>
|
</box>
|
||||||
|
|||||||
@@ -1,16 +1,14 @@
|
|||||||
import { useData } from "../../context/data"
|
import { useData } from "../../context/data"
|
||||||
import { createMemo, Show } from "solid-js"
|
import { createMemo, Show } from "solid-js"
|
||||||
import { useTheme } from "../../context/theme"
|
import { useThemes } from "../../context/theme"
|
||||||
import { useConfig } from "../../config"
|
import { useConfig } from "../../config"
|
||||||
import { usePluginRuntime } from "../../plugin/runtime"
|
|
||||||
import { PluginSlot } from "../../plugin/context"
|
import { PluginSlot } from "../../plugin/context"
|
||||||
|
|
||||||
import { getScrollAcceleration } from "../../util/scroll"
|
import { getScrollAcceleration } from "../../util/scroll"
|
||||||
|
|
||||||
export function Sidebar(props: { sessionID: string; overlay?: boolean }) {
|
export function Sidebar(props: { sessionID: string; overlay?: boolean }) {
|
||||||
const pluginRuntime = usePluginRuntime()
|
|
||||||
const data = useData()
|
const data = useData()
|
||||||
const { themeV2 } = useTheme().contextual("elevated")
|
const theme = useThemes().contextual("elevated")
|
||||||
const config = useConfig().data
|
const config = useConfig().data
|
||||||
const session = createMemo(() => data.session.get(props.sessionID))
|
const session = createMemo(() => data.session.get(props.sessionID))
|
||||||
const scrollAcceleration = createMemo(() => getScrollAcceleration(config))
|
const scrollAcceleration = createMemo(() => getScrollAcceleration(config))
|
||||||
@@ -18,7 +16,7 @@ export function Sidebar(props: { sessionID: string; overlay?: boolean }) {
|
|||||||
return (
|
return (
|
||||||
<Show when={session()}>
|
<Show when={session()}>
|
||||||
<box
|
<box
|
||||||
backgroundColor={themeV2.background.default}
|
backgroundColor={theme.background.default}
|
||||||
width={42}
|
width={42}
|
||||||
height="100%"
|
height="100%"
|
||||||
paddingTop={1}
|
paddingTop={1}
|
||||||
@@ -32,27 +30,20 @@ export function Sidebar(props: { sessionID: string; overlay?: boolean }) {
|
|||||||
scrollAcceleration={scrollAcceleration()}
|
scrollAcceleration={scrollAcceleration()}
|
||||||
verticalScrollbarOptions={{
|
verticalScrollbarOptions={{
|
||||||
trackOptions: {
|
trackOptions: {
|
||||||
backgroundColor: themeV2.background.default,
|
backgroundColor: theme.background.default,
|
||||||
foregroundColor: themeV2.scrollbar.default,
|
foregroundColor: theme.scrollbar.default,
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<box flexShrink={0} gap={1} paddingRight={1}>
|
<box flexShrink={0} gap={1} paddingRight={1}>
|
||||||
<pluginRuntime.Slot
|
|
||||||
name="sidebar_title"
|
|
||||||
mode="single_winner"
|
|
||||||
session_id={props.sessionID}
|
|
||||||
title={session()!.title}
|
|
||||||
>
|
|
||||||
<box paddingRight={1}>
|
<box paddingRight={1}>
|
||||||
<text fg={themeV2.text.default}>
|
<text fg={theme.text.default}>
|
||||||
<b>{session()!.title}</b>
|
<b>{session()!.title}</b>
|
||||||
</text>
|
</text>
|
||||||
<Show when={session()!.location.workspaceID}>
|
<Show when={session()!.location.workspaceID}>
|
||||||
<text fg={themeV2.text.subdued}>{session()!.location.workspaceID}</text>
|
<text fg={theme.text.subdued}>{session()!.location.workspaceID}</text>
|
||||||
</Show>
|
</Show>
|
||||||
</box>
|
</box>
|
||||||
</pluginRuntime.Slot>
|
|
||||||
<PluginSlot name="sidebar.content" input={{ sessionID: props.sessionID }} mode="all" />
|
<PluginSlot name="sidebar.content" input={{ sessionID: props.sessionID }} mode="all" />
|
||||||
</box>
|
</box>
|
||||||
</scrollbox>
|
</scrollbox>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { createMemo, createSignal, Show } from "solid-js"
|
import { createMemo, createSignal, Show } from "solid-js"
|
||||||
import { useRouteData } from "../../context/route"
|
import { useRouteData } from "../../context/route"
|
||||||
import { useData } from "../../context/data"
|
import { useData } from "../../context/data"
|
||||||
import { useTheme } from "../../context/theme"
|
import { useThemes } from "../../context/theme"
|
||||||
import { SplitBorder } from "../../ui/border"
|
import { SplitBorder } from "../../ui/border"
|
||||||
import { Locale } from "../../util/locale"
|
import { Locale } from "../../util/locale"
|
||||||
import { useTerminalDimensions } from "@opentui/solid"
|
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 keymap = Keymap.use()
|
||||||
const shortcuts = Keymap.useShortcuts()
|
const shortcuts = Keymap.useShortcuts()
|
||||||
const [hover, setHover] = createSignal<"parent" | "prev" | "next" | null>(null)
|
const [hover, setHover] = createSignal<"parent" | "prev" | "next" | null>(null)
|
||||||
@@ -57,18 +57,18 @@ export function SubagentFooter() {
|
|||||||
paddingRight={1}
|
paddingRight={1}
|
||||||
{...SplitBorder}
|
{...SplitBorder}
|
||||||
border={["left"]}
|
border={["left"]}
|
||||||
borderColor={themeV2.border.default}
|
borderColor={theme.border.default}
|
||||||
flexShrink={0}
|
flexShrink={0}
|
||||||
backgroundColor={themeV2.background.default}
|
backgroundColor={theme.background.default}
|
||||||
>
|
>
|
||||||
<box flexDirection="row" justifyContent="space-between" gap={1}>
|
<box flexDirection="row" justifyContent="space-between" gap={1}>
|
||||||
<box flexDirection="row" gap={1}>
|
<box flexDirection="row" gap={1}>
|
||||||
<text fg={themeV2.text.default}>
|
<text fg={theme.text.default}>
|
||||||
<b>{subagentInfo()}</b>
|
<b>{subagentInfo()}</b>
|
||||||
</text>
|
</text>
|
||||||
<Show when={usage()}>
|
<Show when={usage()}>
|
||||||
{(item) => (
|
{(item) => (
|
||||||
<text fg={themeV2.text.subdued} wrapMode="none">
|
<text fg={theme.text.subdued} wrapMode="none">
|
||||||
{[item().context, item().cost].filter(Boolean).join(" · ")}
|
{[item().context, item().cost].filter(Boolean).join(" · ")}
|
||||||
</text>
|
</text>
|
||||||
)}
|
)}
|
||||||
@@ -80,35 +80,31 @@ export function SubagentFooter() {
|
|||||||
onMouseOut={() => setHover(null)}
|
onMouseOut={() => setHover(null)}
|
||||||
onMouseUp={() => keymap.dispatch("session.parent")}
|
onMouseUp={() => keymap.dispatch("session.parent")}
|
||||||
backgroundColor={
|
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}>
|
<text fg={theme.text.default}>
|
||||||
Parent <span style={{ fg: themeV2.text.subdued }}>{shortcuts.get("session.parent")}</span>
|
Parent <span style={{ fg: theme.text.subdued }}>{shortcuts.get("session.parent")}</span>
|
||||||
</text>
|
</text>
|
||||||
</box>
|
</box>
|
||||||
<box
|
<box
|
||||||
onMouseOver={() => setHover("prev")}
|
onMouseOver={() => setHover("prev")}
|
||||||
onMouseOut={() => setHover(null)}
|
onMouseOut={() => setHover(null)}
|
||||||
onMouseUp={() => keymap.dispatch("session.child.previous")}
|
onMouseUp={() => keymap.dispatch("session.child.previous")}
|
||||||
backgroundColor={
|
backgroundColor={hover() === "prev" ? theme.background.action.primary.hovered : theme.background.default}
|
||||||
hover() === "prev" ? themeV2.background.action.primary.hovered : themeV2.background.default
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
<text fg={themeV2.text.default}>
|
<text fg={theme.text.default}>
|
||||||
Prev <span style={{ fg: themeV2.text.subdued }}>{shortcuts.get("session.child.previous")}</span>
|
Prev <span style={{ fg: theme.text.subdued }}>{shortcuts.get("session.child.previous")}</span>
|
||||||
</text>
|
</text>
|
||||||
</box>
|
</box>
|
||||||
<box
|
<box
|
||||||
onMouseOver={() => setHover("next")}
|
onMouseOver={() => setHover("next")}
|
||||||
onMouseOut={() => setHover(null)}
|
onMouseOut={() => setHover(null)}
|
||||||
onMouseUp={() => keymap.dispatch("session.child.next")}
|
onMouseUp={() => keymap.dispatch("session.child.next")}
|
||||||
backgroundColor={
|
backgroundColor={hover() === "next" ? theme.background.action.primary.hovered : theme.background.default}
|
||||||
hover() === "next" ? themeV2.background.action.primary.hovered : themeV2.background.default
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
<text fg={themeV2.text.default}>
|
<text fg={theme.text.default}>
|
||||||
Next <span style={{ fg: themeV2.text.subdued }}>{shortcuts.get("session.child.next")}</span>
|
Next <span style={{ fg: theme.text.subdued }}>{shortcuts.get("session.child.next")}</span>
|
||||||
</text>
|
</text>
|
||||||
</box>
|
</box>
|
||||||
</box>
|
</box>
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { TextAttributes } from "@opentui/core"
|
import { TextAttributes } from "@opentui/core"
|
||||||
import { Keymap } from "../context/keymap"
|
import { Keymap } from "../context/keymap"
|
||||||
import { useTheme } from "../context/theme"
|
import { useThemes } from "../context/theme"
|
||||||
import { useDialog, type DialogContext } from "./dialog"
|
import { useDialog, type DialogContext } from "./dialog"
|
||||||
|
|
||||||
export type DialogAlertProps = {
|
export type DialogAlertProps = {
|
||||||
@@ -11,7 +11,7 @@ export type DialogAlertProps = {
|
|||||||
|
|
||||||
export function DialogAlert(props: DialogAlertProps) {
|
export function DialogAlert(props: DialogAlertProps) {
|
||||||
const dialog = useDialog()
|
const dialog = useDialog()
|
||||||
const { themeV2 } = useTheme().contextual("elevated")
|
const theme = useThemes().contextual("elevated")
|
||||||
|
|
||||||
Keymap.createLayer(() => ({
|
Keymap.createLayer(() => ({
|
||||||
mode: "modal",
|
mode: "modal",
|
||||||
@@ -30,27 +30,27 @@ export function DialogAlert(props: DialogAlertProps) {
|
|||||||
return (
|
return (
|
||||||
<box paddingLeft={2} paddingRight={2} gap={1}>
|
<box paddingLeft={2} paddingRight={2} gap={1}>
|
||||||
<box flexDirection="row" justifyContent="space-between">
|
<box flexDirection="row" justifyContent="space-between">
|
||||||
<text attributes={TextAttributes.BOLD} fg={themeV2.text.default}>
|
<text attributes={TextAttributes.BOLD} fg={theme.text.default}>
|
||||||
{props.title}
|
{props.title}
|
||||||
</text>
|
</text>
|
||||||
<text fg={themeV2.text.subdued} onMouseUp={() => dialog.clear()}>
|
<text fg={theme.text.subdued} onMouseUp={() => dialog.clear()}>
|
||||||
esc
|
esc
|
||||||
</text>
|
</text>
|
||||||
</box>
|
</box>
|
||||||
<box paddingBottom={1}>
|
<box paddingBottom={1}>
|
||||||
<text fg={themeV2.text.subdued}>{props.message}</text>
|
<text fg={theme.text.subdued}>{props.message}</text>
|
||||||
</box>
|
</box>
|
||||||
<box flexDirection="row" justifyContent="flex-end" paddingBottom={1}>
|
<box flexDirection="row" justifyContent="flex-end" paddingBottom={1}>
|
||||||
<box
|
<box
|
||||||
paddingLeft={3}
|
paddingLeft={3}
|
||||||
paddingRight={3}
|
paddingRight={3}
|
||||||
backgroundColor={themeV2.background.action.primary.focused}
|
backgroundColor={theme.background.action.primary.focused}
|
||||||
onMouseUp={() => {
|
onMouseUp={() => {
|
||||||
props.onConfirm?.()
|
props.onConfirm?.()
|
||||||
dialog.clear()
|
dialog.clear()
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<text fg={themeV2.text.action.primary.focused}>ok</text>
|
<text fg={theme.text.action.primary.focused}>ok</text>
|
||||||
</box>
|
</box>
|
||||||
</box>
|
</box>
|
||||||
</box>
|
</box>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { TextAttributes } from "@opentui/core"
|
import { TextAttributes } from "@opentui/core"
|
||||||
import { Keymap } from "../context/keymap"
|
import { Keymap } from "../context/keymap"
|
||||||
import { useTheme } from "../context/theme"
|
import { useThemes } from "../context/theme"
|
||||||
import { useDialog, type DialogContext } from "./dialog"
|
import { useDialog, type DialogContext } from "./dialog"
|
||||||
import { createStore } from "solid-js/store"
|
import { createStore } from "solid-js/store"
|
||||||
import { For } from "solid-js"
|
import { For } from "solid-js"
|
||||||
@@ -21,7 +21,7 @@ export type DialogConfirmResult = boolean | undefined
|
|||||||
|
|
||||||
export function DialogConfirm(props: DialogConfirmProps) {
|
export function DialogConfirm(props: DialogConfirmProps) {
|
||||||
const dialog = useDialog()
|
const dialog = useDialog()
|
||||||
const { themeV2 } = useTheme().contextual("elevated")
|
const theme = useThemes().contextual("elevated")
|
||||||
const [store, setStore] = createStore({
|
const [store, setStore] = createStore({
|
||||||
active: "confirm" as "confirm" | "cancel",
|
active: "confirm" as "confirm" | "cancel",
|
||||||
})
|
})
|
||||||
@@ -60,15 +60,15 @@ export function DialogConfirm(props: DialogConfirmProps) {
|
|||||||
return (
|
return (
|
||||||
<box paddingLeft={2} paddingRight={2} gap={1}>
|
<box paddingLeft={2} paddingRight={2} gap={1}>
|
||||||
<box flexDirection="row" justifyContent="space-between">
|
<box flexDirection="row" justifyContent="space-between">
|
||||||
<text attributes={TextAttributes.BOLD} fg={themeV2.text.default}>
|
<text attributes={TextAttributes.BOLD} fg={theme.text.default}>
|
||||||
{props.title}
|
{props.title}
|
||||||
</text>
|
</text>
|
||||||
<text fg={themeV2.text.subdued} onMouseUp={() => dialog.clear()}>
|
<text fg={theme.text.subdued} onMouseUp={() => dialog.clear()}>
|
||||||
esc
|
esc
|
||||||
</text>
|
</text>
|
||||||
</box>
|
</box>
|
||||||
<box paddingBottom={1}>
|
<box paddingBottom={1}>
|
||||||
<text fg={themeV2.text.subdued}>{props.message}</text>
|
<text fg={theme.text.subdued}>{props.message}</text>
|
||||||
</box>
|
</box>
|
||||||
<box flexDirection="row" justifyContent="flex-end" paddingBottom={1}>
|
<box flexDirection="row" justifyContent="flex-end" paddingBottom={1}>
|
||||||
<For each={["cancel", "confirm"] as const}>
|
<For each={["cancel", "confirm"] as const}>
|
||||||
@@ -76,14 +76,14 @@ export function DialogConfirm(props: DialogConfirmProps) {
|
|||||||
<box
|
<box
|
||||||
paddingLeft={1}
|
paddingLeft={1}
|
||||||
paddingRight={1}
|
paddingRight={1}
|
||||||
backgroundColor={key === store.active ? themeV2.background.action.primary.focused : undefined}
|
backgroundColor={key === store.active ? theme.background.action.primary.focused : undefined}
|
||||||
onMouseUp={() => {
|
onMouseUp={() => {
|
||||||
if (key === "confirm") props.onConfirm?.()
|
if (key === "confirm") props.onConfirm?.()
|
||||||
if (key === "cancel") props.onCancel?.()
|
if (key === "cancel") props.onCancel?.()
|
||||||
dialog.clear()
|
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)}
|
{Locale.titlecase(props.label?.[key] ?? key)}
|
||||||
</text>
|
</text>
|
||||||
</box>
|
</box>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { TextAttributes } from "@opentui/core"
|
import { TextAttributes } from "@opentui/core"
|
||||||
import { Keymap } from "../context/keymap"
|
import { Keymap } from "../context/keymap"
|
||||||
import { useTheme } from "../context/theme"
|
import { useThemes } from "../context/theme"
|
||||||
import { useDialog, type DialogContext } from "./dialog"
|
import { useDialog, type DialogContext } from "./dialog"
|
||||||
import { createStore } from "solid-js/store"
|
import { createStore } from "solid-js/store"
|
||||||
import { For, Show } from "solid-js"
|
import { For, Show } from "solid-js"
|
||||||
@@ -17,8 +17,8 @@ type Active = ExportFormat | "thinking" | "copy" | "export"
|
|||||||
|
|
||||||
export function DialogExportOptions(props: DialogExportOptionsProps) {
|
export function DialogExportOptions(props: DialogExportOptionsProps) {
|
||||||
const dialog = useDialog()
|
const dialog = useDialog()
|
||||||
const { themeV2 } = useTheme().contextual("elevated")
|
const theme = useThemes().contextual("elevated")
|
||||||
const { themeV2: overlayTheme } = useTheme().contextual("overlay")
|
const overlayTheme = useThemes().contextual("overlay")
|
||||||
const [store, setStore] = createStore({
|
const [store, setStore] = createStore({
|
||||||
format: "markdown" as ExportFormat,
|
format: "markdown" as ExportFormat,
|
||||||
thinking: props.defaultThinking,
|
thinking: props.defaultThinking,
|
||||||
@@ -73,15 +73,15 @@ export function DialogExportOptions(props: DialogExportOptionsProps) {
|
|||||||
return (
|
return (
|
||||||
<box paddingLeft={2} paddingRight={2} gap={1}>
|
<box paddingLeft={2} paddingRight={2} gap={1}>
|
||||||
<box flexDirection="row" justifyContent="space-between">
|
<box flexDirection="row" justifyContent="space-between">
|
||||||
<text attributes={TextAttributes.BOLD} fg={themeV2.text.default}>
|
<text attributes={TextAttributes.BOLD} fg={theme.text.default}>
|
||||||
Export session
|
Export session
|
||||||
</text>
|
</text>
|
||||||
<text fg={themeV2.text.subdued} onMouseUp={() => dialog.clear()}>
|
<text fg={theme.text.subdued} onMouseUp={() => dialog.clear()}>
|
||||||
esc
|
esc
|
||||||
</text>
|
</text>
|
||||||
</box>
|
</box>
|
||||||
<box flexDirection="row" gap={1}>
|
<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}>
|
<box flexDirection="row" gap={1}>
|
||||||
<For each={["markdown", "json"] as const}>
|
<For each={["markdown", "json"] as const}>
|
||||||
{(format) => (
|
{(format) => (
|
||||||
@@ -90,20 +90,20 @@ export function DialogExportOptions(props: DialogExportOptionsProps) {
|
|||||||
paddingRight={1}
|
paddingRight={1}
|
||||||
backgroundColor={
|
backgroundColor={
|
||||||
store.active === format
|
store.active === format
|
||||||
? themeV2.background.formfield.focused
|
? theme.background.formfield.focused
|
||||||
: store.format === format
|
: store.format === format
|
||||||
? themeV2.background.formfield.selected
|
? theme.background.formfield.selected
|
||||||
: themeV2.background.formfield.default
|
: theme.background.formfield.default
|
||||||
}
|
}
|
||||||
onMouseUp={() => selectFormat(format)}
|
onMouseUp={() => selectFormat(format)}
|
||||||
>
|
>
|
||||||
<text
|
<text
|
||||||
fg={
|
fg={
|
||||||
store.active === format
|
store.active === format
|
||||||
? themeV2.text.formfield.focused
|
? theme.text.formfield.focused
|
||||||
: store.format === format
|
: store.format === format
|
||||||
? themeV2.text.formfield.selected
|
? theme.text.formfield.selected
|
||||||
: themeV2.text.formfield.default
|
: theme.text.formfield.default
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{store.format === format ? "◉" : "○"} {format === "markdown" ? "Markdown" : "JSON"}
|
{store.format === format ? "◉" : "○"} {format === "markdown" ? "Markdown" : "JSON"}
|
||||||
@@ -119,10 +119,10 @@ export function DialogExportOptions(props: DialogExportOptionsProps) {
|
|||||||
gap={1}
|
gap={1}
|
||||||
backgroundColor={
|
backgroundColor={
|
||||||
store.active === "thinking"
|
store.active === "thinking"
|
||||||
? themeV2.background.formfield.focused
|
? theme.background.formfield.focused
|
||||||
: store.thinking
|
: store.thinking
|
||||||
? themeV2.background.formfield.selected
|
? theme.background.formfield.selected
|
||||||
: themeV2.background.formfield.default
|
: theme.background.formfield.default
|
||||||
}
|
}
|
||||||
onMouseUp={() => {
|
onMouseUp={() => {
|
||||||
setStore("active", "thinking")
|
setStore("active", "thinking")
|
||||||
@@ -132,10 +132,10 @@ export function DialogExportOptions(props: DialogExportOptionsProps) {
|
|||||||
<text
|
<text
|
||||||
fg={
|
fg={
|
||||||
store.active === "thinking"
|
store.active === "thinking"
|
||||||
? themeV2.text.formfield.focused
|
? theme.text.formfield.focused
|
||||||
: store.thinking
|
: store.thinking
|
||||||
? themeV2.text.formfield.selected
|
? theme.text.formfield.selected
|
||||||
: themeV2.text.formfield.default
|
: theme.text.formfield.default
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{store.thinking ? "[x]" : "[ ]"}
|
{store.thinking ? "[x]" : "[ ]"}
|
||||||
@@ -143,10 +143,10 @@ export function DialogExportOptions(props: DialogExportOptionsProps) {
|
|||||||
<text
|
<text
|
||||||
fg={
|
fg={
|
||||||
store.active === "thinking"
|
store.active === "thinking"
|
||||||
? themeV2.text.formfield.focused
|
? theme.text.formfield.focused
|
||||||
: store.thinking
|
: store.thinking
|
||||||
? themeV2.text.formfield.selected
|
? theme.text.formfield.selected
|
||||||
: themeV2.text.formfield.default
|
: theme.text.formfield.default
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
Include thinking
|
Include thinking
|
||||||
@@ -167,14 +167,12 @@ export function DialogExportOptions(props: DialogExportOptionsProps) {
|
|||||||
paddingRight={4}
|
paddingRight={4}
|
||||||
backgroundColor={
|
backgroundColor={
|
||||||
store.active === "export"
|
store.active === "export"
|
||||||
? themeV2.background.action.primary.focused
|
? theme.background.action.primary.focused
|
||||||
: themeV2.background.action.primary.default
|
: theme.background.action.primary.default
|
||||||
}
|
}
|
||||||
onMouseUp={() => confirm("export")}
|
onMouseUp={() => confirm("export")}
|
||||||
>
|
>
|
||||||
<text
|
<text fg={store.active === "export" ? theme.text.action.primary.focused : theme.text.action.primary.default}>
|
||||||
fg={store.active === "export" ? themeV2.text.action.primary.focused : themeV2.text.action.primary.default}
|
|
||||||
>
|
|
||||||
Export
|
Export
|
||||||
</text>
|
</text>
|
||||||
</box>
|
</box>
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user