Compare commits

...

23 Commits

Author SHA1 Message Date
Brendan Allan 3989bedd21 refactor(app): extract session side panel controller 2026-07-29 14:25:20 +08:00
Kit Langton b47cfbee7c fix(tui): reduce tab pulse allocations (#39433) 2026-07-28 22:43:46 -04:00
Kit Langton 5504245f7b feat(tui): add session tab playground (#39432) 2026-07-28 22:36:53 -04:00
Kit Langton f64b50d71b feat(tui): add unread tab glow (#39428) 2026-07-28 22:34:33 -04:00
Kit Langton a7b2ea94e5 fix(tui): always show session tab (#39429) 2026-07-28 22:33:34 -04:00
Kit Langton 7b775c2582 fix(cli): embed native watcher binding 2026-07-28 22:32:08 -04:00
Dax 12a931a220 feat(tui): filter subagents by activity 2026-07-28 22:13:46 -04:00
Dax Raad 90100c1365 docs: clarify side-by-side V1 and V2 installs 2026-07-28 22:02:19 -04:00
Dax Raad 139c9febe4 refactor(tui): group tab settings 2026-07-28 21:23:53 -04:00
Dax Raad 06290907a9 feat(tui): restore plugin manager dialog 2026-07-28 21:19:17 -04:00
Kit Langton 1c8175a61a fix(tui): preserve tab context on home and close (#39421) 2026-07-29 01:03:17 +00:00
Dax Raad fe91698ed6 fix(tui): initialize external plugin runtime 2026-07-28 21:00:09 -04:00
Dax Raad 068c32df39 feat(tui): discover project plugins 2026-07-28 20:21:38 -04:00
Kit Langton a2885d1662 feat(tui): add session tab history (#39411) 2026-07-28 19:38:46 -04:00
Kit Langton 38a3dbb4c4 fix(tui): fade full-width tab titles (#39409) 2026-07-28 19:38:28 -04:00
Kit Langton 43383d4fba fix(tui): hide single session tab (#39408) 2026-07-28 18:25:46 -04:00
opencode-agent[bot] 40c4c3918a feat(core): enable fff in node runtimes (#38776)
Co-authored-by: Aiden Cline <rekram1-node@users.noreply.github.com>
2026-07-28 17:14:11 -05:00
Aiden Cline 754ea99d86 fix(core): preserve shell output tail (#39403) 2026-07-28 16:32:11 -05:00
Kit Langton 37a1b80d5a feat(tui): add adaptive session tabs (#39396) 2026-07-28 17:12:07 -04:00
Aiden Cline f95d04fea0 feat(core): improve shell tool guidance (#39401) 2026-07-28 15:53:19 -05:00
James Long 08b80da931 refactor(tui): split theme hooks (#39395) 2026-07-28 16:25:32 -04:00
Aiden Cline f6fb1a7cdd fix(ai): retry transient client statuses (#39391) 2026-07-28 14:25:33 -05:00
Dax Raad 5bcc0016a6 feat(tui): add plugin context hook 2026-07-28 14:57:59 -04:00
117 changed files with 3749 additions and 2715 deletions
+16
View File
@@ -0,0 +1,16 @@
import type { Context } from "../../../packages/plugin/src/tui/context"
export default {
id: "test.tui-discovery-smoke",
setup(context: Context) {
const timer = setTimeout(() => {
context.ui.toast.show({
title: "TUI plugin discovery works",
message: "Loaded .opencode/plugins/tui/discovery-smoke.ts",
variant: "success",
duration: 30_000,
})
}, 1_000)
return () => clearTimeout(timer)
},
}
+40
View File
@@ -147,6 +147,11 @@
"ws": "8.21.0", "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=="],
+1 -2
View File
@@ -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
) )
+6
View File
@@ -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(
[ [
@@ -0,0 +1,133 @@
import { describe, expect, test } from "bun:test"
import { createRoot } from "solid-js"
import { createStore } from "solid-js/store"
import { SESSION_OPEN_FILE_TAB } from "./helpers"
import { createSessionSidePanelController, sessionSidePanelHandoffFiles } from "./session-side-panel-controller"
function createController(options?: { active?: string; all?: string[]; mode?: "changes" | "all" }) {
const calls: string[] = []
const [state, setState] = createStore({
active: options?.active,
all: options?.all ?? ["file://src/a.ts"],
preview: undefined as string | undefined,
mode: options?.mode ?? ("changes" as "changes" | "all"),
})
return createRoot((dispose) => ({
dispose,
calls,
state,
controller: createSessionSidePanelController({
currentTab: () => state.active,
allTabs: () => state.all,
openTab: (tab) => calls.push(`open:${tab}`),
preview: (tab) => calls.push(`preview:${tab}`),
setActive: (tab) => calls.push(`active:${tab}`),
normalizeFileTab: (tab) => `file://${tab.slice("file://".length).toLowerCase()}`,
pathFromTab: (tab) => (tab.startsWith("file://") ? tab.slice("file://".length) : undefined),
loadFile: (path) => calls.push(`load:${path}`),
reviewEnabled: () => true,
canReview: () => true,
fileBrowserEnabled: () => true,
reviewPanelOpened: () => false,
openReviewPanel: () => calls.push("panel"),
treeMode: () => state.mode,
setTreeMode: (mode) => setState("mode", mode),
fileReady: () => false,
sessionKey: () => "session",
selectedLines: () => null,
persistHandoff: () => undefined,
showDialog: () => undefined,
}),
}))
}
describe("session side panel controller", () => {
test("normalizes and centralizes file tab selection mutations", async () => {
const owned = createController()
owned.controller.tabs.activate("file://SRC/A.ts")
expect(owned.calls).toEqual(["load:src/a.ts", "panel", "active:file://src/a.ts"])
owned.calls.length = 0
owned.controller.tabs.preview("file://SRC/B.ts")
expect(owned.calls).toEqual(["preview:file://src/b.ts", "load:src/b.ts", "panel"])
await Promise.resolve()
expect(owned.calls).toEqual(["preview:file://src/b.ts", "load:src/b.ts", "panel", "active:file://src/b.ts"])
owned.calls.length = 0
owned.controller.tabs.open("file://SRC/C.ts")
expect(owned.calls).toEqual(["open:file://src/c.ts", "load:src/c.ts", "panel", "active:file://src/c.ts"])
owned.dispose()
})
test("derives browser selection and controls the tree mode", () => {
const owned = createController({ active: "file://src/a.ts", all: ["file://src/a.ts"] })
expect(owned.controller.browser.tab()).toBe("file://src/a.ts")
expect(owned.controller.browser.mounted()).toBe(true)
expect(owned.controller.browser.visible()).toBe(true)
owned.controller.tree.setMode("invalid")
expect(owned.state.mode).toBe("changes")
owned.controller.tree.showAll()
expect(owned.state.mode).toBe("all")
owned.controller.tree.showAll()
expect(owned.state.mode).toBe("all")
owned.calls.length = 0
owned.controller.browser.open()
expect(owned.calls[0]).toBe(`preview:${SESSION_OPEN_FILE_TAB}`)
owned.dispose()
})
test("opens the file dialog with the tree handoff callback", async () => {
let render: (() => unknown) | undefined
let dialogProps: { mode?: "files"; onOpenFile?: (path: string) => void } | undefined
const owned = createController()
const controller = createSessionSidePanelController({
currentTab: () => undefined,
allTabs: () => [],
openTab: () => undefined,
preview: () => undefined,
setActive: () => undefined,
normalizeFileTab: (tab) => tab,
pathFromTab: () => undefined,
loadFile: () => undefined,
reviewEnabled: () => true,
canReview: () => true,
fileBrowserEnabled: () => true,
reviewPanelOpened: () => true,
openReviewPanel: () => undefined,
treeMode: owned.controller.tree.mode,
setTreeMode: owned.controller.tree.setMode,
fileReady: () => false,
sessionKey: () => "session",
selectedLines: () => null,
persistHandoff: () => undefined,
showDialog: (value) => (render = value),
loadSelectFileDialog: async () => ({
DialogSelectFile: (props) => {
dialogProps = props
return null
},
}),
})
await controller.dialog.openFile()
render?.()
expect(dialogProps?.mode).toBe("files")
dialogProps?.onOpenFile?.("src/a.ts")
expect(owned.state.mode).toBe("all")
owned.dispose()
})
})
test("projects only file tabs into handoff persistence", () => {
expect(
sessionSidePanelHandoffFiles(
["review", "file://src/a.ts", "file://src/b.ts"],
(tab) => (tab.startsWith("file://") ? tab.slice("file://".length) : undefined),
(path) => (path.endsWith("a.ts") ? { start: 2, end: 4 } : { startLine: 2, endLine: 4 }),
),
).toEqual({ "src/a.ts": { start: 2, end: 4 }, "src/b.ts": null })
})
@@ -0,0 +1,150 @@
import { createComponent, createEffect, createMemo, type Accessor, type Component, type JSX } from "solid-js"
import type { SelectedLineRange } from "@/context/file"
import { SESSION_OPEN_FILE_TAB, createOpenSessionFileTab, createSessionTabs } from "@/pages/session/helpers"
type TreeMode = "changes" | "all"
type Input = {
currentTab: Accessor<string | undefined>
allTabs: Accessor<string[]>
openTab: (tab: string) => void
preview: (tab: string) => void
setActive: (tab: string) => void
normalizeFileTab: (tab: string) => string
pathFromTab: (tab: string) => string | undefined
loadFile: (path: string) => void
reviewEnabled: Accessor<boolean>
canReview: Accessor<boolean>
fileBrowserEnabled: Accessor<boolean>
reviewPanelOpened: Accessor<boolean>
openReviewPanel: () => void
treeMode: Accessor<TreeMode>
setTreeMode: (mode: TreeMode) => void
fileReady: Accessor<boolean>
sessionKey: Accessor<string>
selectedLines: (path: string) => unknown
persistHandoff: (key: string, files: Record<string, SelectedLineRange | null>) => void
showDialog: (render: () => JSX.Element) => void
loadSelectFileDialog?: () => Promise<{
DialogSelectFile: Component<{ mode?: "files"; onOpenFile?: (path: string) => void }>
}>
}
export function createSessionSidePanelController(input: Input) {
const normalizeTab = (tab: string) => (tab.startsWith("file://") ? input.normalizeFileTab(tab) : tab)
const openReviewPanel = () => {
if (!input.reviewPanelOpened()) input.openReviewPanel()
}
const tabs = createSessionTabs({
tabs: () => ({ active: input.currentTab, all: input.allTabs }),
pathFromTab: input.pathFromTab,
normalizeTab,
review: input.reviewEnabled,
hasReview: input.canReview,
fileBrowser: input.fileBrowserEnabled,
})
const prepareTab = (tab: string) => {
const path = input.pathFromTab(tab)
if (path) input.loadFile(path)
openReviewPanel()
return tab
}
const open = createOpenSessionFileTab({
normalizeTab,
openTab: input.openTab,
pathFromTab: input.pathFromTab,
loadFile: input.loadFile,
openReviewPanel,
setActive: input.setActive,
})
const preview = (value: string) => {
const next = normalizeTab(value)
input.preview(next)
const selected = prepareTab(next)
queueMicrotask(() => input.setActive(selected))
}
const activate = (value: string) => input.setActive(prepareTab(normalizeTab(value)))
const openFileBrowser = () => preview(SESSION_OPEN_FILE_TAB)
const browserTab = createMemo(() => {
if (!input.fileBrowserEnabled()) return undefined
const active = tabs.activeTab()
if (active === SESSION_OPEN_FILE_TAB) return SESSION_OPEN_FILE_TAB
if (active && input.pathFromTab(active)) return active
return tabs.activeFileTab()
})
// Keep the shell mounted while any file tab exists. Kobalte briefly selects
// Review while replacing a preview trigger, which must not reset sidebar scroll.
const fileBrowserMounted = createMemo(
() =>
input.fileBrowserEnabled() && (tabs.openedTabs().length > 0 || tabs.openFileOpen() || browserTab() !== undefined),
)
const fileBrowserVisible = createMemo(() => {
const active = tabs.activeTab()
return active !== "review" && active !== "context" && active !== "empty"
})
const setTreeMode = (value: string) => {
if (value !== "changes" && value !== "all") return
input.setTreeMode(value)
}
const showAllFiles = () => {
if (input.treeMode() !== "changes") return
input.setTreeMode("all")
}
const openFileDialog = async () => {
const load = input.loadSelectFileDialog ?? (() => import("@/components/dialog-select-file"))
const { DialogSelectFile } = await load()
input.showDialog(() => createComponent(DialogSelectFile, { mode: "files", onOpenFile: showAllFiles }))
}
createEffect(() => {
if (!input.fileReady()) return
input.persistHandoff(
input.sessionKey(),
sessionSidePanelHandoffFiles(input.allTabs(), input.pathFromTab, input.selectedLines),
)
})
return {
tabs: {
...tabs,
normalize: normalizeTab,
open,
preview,
activate,
},
browser: {
tab: browserTab,
mounted: fileBrowserMounted,
visible: fileBrowserVisible,
open: openFileBrowser,
},
tree: {
mode: input.treeMode,
setMode: setTreeMode,
showAll: showAllFiles,
},
dialog: {
openFile: openFileDialog,
},
}
}
export function sessionSidePanelHandoffFiles(
tabs: readonly string[],
pathFromTab: (tab: string) => string | undefined,
selectedLines: (path: string) => unknown,
) {
return tabs.reduce<Record<string, SelectedLineRange | null>>((files, tab) => {
const path = pathFromTab(tab)
if (!path) return files
const selected = selectedLines(path)
files[path] = isSelectedLineRange(selected) ? selected : null
return files
}, {})
}
function isSelectedLineRange(value: unknown): value is SelectedLineRange {
return !!value && typeof value === "object" && "start" in value && "end" in value
}
export type SessionSidePanelController = ReturnType<typeof createSessionSidePanelController>
@@ -1,4 +1,4 @@
import { For, Match, Show, Switch, createEffect, createMemo, onCleanup, type JSX } from "solid-js" import { For, Match, Show, Switch, createMemo, onCleanup, type JSX } from "solid-js"
import { createStore } from "solid-js/store" import { createStore } from "solid-js/store"
import { createMediaQuery } from "@solid-primitives/media" import { createMediaQuery } from "@solid-primitives/media"
import { DragDropProvider as DndKitProvider, PointerSensor } from "@dnd-kit/solid" import { DragDropProvider as DndKitProvider, PointerSensor } from "@dnd-kit/solid"
@@ -38,23 +38,17 @@ const fileBrowserTabPanelID = "session-side-panel-file-browser-tabpanel"
import { SessionContextTab, SortableTab, SortableTabV2, FileVisual } from "@/components/session" import { SessionContextTab, SortableTab, SortableTabV2, FileVisual } from "@/components/session"
import { OpenInAppV2 } from "@/components/session/open-in-app-v2" import { OpenInAppV2 } from "@/components/session/open-in-app-v2"
import { useCommand } from "@/context/command" import { useCommand } from "@/context/command"
import { useFile, type SelectedLineRange } from "@/context/file" import { useFile } from "@/context/file"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { useLayout } from "@/context/layout" import { useLayout } from "@/context/layout"
import { useSDK } from "@/context/sdk" import { useSDK } from "@/context/sdk"
import { useSettings } from "@/context/settings" import { useSettings } from "@/context/settings"
import { createFileTabListSync } from "@/pages/session/file-tab-scroll" import { createFileTabListSync } from "@/pages/session/file-tab-scroll"
import { FileTabContent } from "@/pages/session/file-tabs" import { FileTabContent } from "@/pages/session/file-tabs"
import { import { SESSION_OPEN_FILE_TAB, getTabReorderIndex, shouldShowFileTree, type Sizing } from "@/pages/session/helpers"
SESSION_OPEN_FILE_TAB,
createOpenSessionFileTab,
createSessionTabs,
getTabReorderIndex,
shouldShowFileTree,
type Sizing,
} from "@/pages/session/helpers"
import { setSessionHandoff } from "@/pages/session/handoff" import { setSessionHandoff } from "@/pages/session/handoff"
import { useSessionLayout } from "@/pages/session/session-layout" import { useSessionLayout } from "@/pages/session/session-layout"
import { createSessionSidePanelController } from "@/pages/session/session-side-panel-controller"
import { SessionFileBrowserTab, type SessionFileBrowserState } from "@/pages/session/v2/session-file-browser-tab" import { SessionFileBrowserTab, type SessionFileBrowserState } from "@/pages/session/v2/session-file-browser-tab"
type ReviewDiff = FileDiffInfo | SnapshotFileDiff | VcsFileDiff type ReviewDiff = FileDiffInfo | SnapshotFileDiff | VcsFileDiff
@@ -153,91 +147,49 @@ export function SessionSidePanel(props: {
return file.tree.children("").length === 0 return file.tree.children("").length === 0
}) })
const normalizeTab = (tab: string) => { const controller = createSessionSidePanelController({
if (!tab.startsWith("file://")) return tab currentTab: () => tabs().active(),
return file.tab(tab) allTabs: () => tabs().all(),
} openTab: (tab) => tabs().open(tab),
preview: (tab) => tabs().previewTab(tab),
const openReviewPanel = () => { setActive: (tab) => tabs().setActive(tab),
if (!view().reviewPanel.opened()) view().reviewPanel.open() normalizeFileTab: file.tab,
}
const openTab = createOpenSessionFileTab({
normalizeTab,
openTab: tabs().open,
pathFromTab: file.pathFromTab, pathFromTab: file.pathFromTab,
loadFile: file.load, loadFile: file.load,
openReviewPanel, reviewEnabled: reviewTab,
setActive: tabs().setActive, canReview: props.canReview,
fileBrowserEnabled: () => !!props.fileBrowserState,
reviewPanelOpened: () => view().reviewPanel.opened(),
openReviewPanel: () => view().reviewPanel.open(),
treeMode: () => layout.fileTree.tab(),
setTreeMode: (mode) => layout.fileTree.setTab(mode),
fileReady: file.ready,
sessionKey,
selectedLines: file.selectedLines,
persistHandoff: (key, files) => setSessionHandoff(key, { files }),
showDialog: (render) => void dialog.show(render),
}) })
const contextOpen = controller.tabs.contextOpen
const tabState = createSessionTabs({ const panelTabs = controller.tabs.panelTabs
tabs, const openedTabs = controller.tabs.openedTabs
pathFromTab: file.pathFromTab, const activeTab = controller.tabs.activeTab
normalizeTab, const activeFileTab = controller.tabs.activeFileTab
review: reviewTab, const openTab = controller.tabs.open
hasReview: props.canReview, const previewTab = controller.tabs.preview
fileBrowser: () => !!props.fileBrowserState, const activateTab = controller.tabs.activate
}) const browserTab = controller.browser.tab
const contextOpen = tabState.contextOpen const fileBrowserMounted = controller.browser.mounted
const openFileOpen = tabState.openFileOpen const fileBrowserVisible = controller.browser.visible
const panelTabs = tabState.panelTabs const fileTreeTab = controller.tree.mode
const openedTabs = tabState.openedTabs const setFileTreeTabValue = controller.tree.setMode
const activeTab = tabState.activeTab
const activeFileTab = tabState.activeFileTab
const fileTreeTab = () => layout.fileTree.tab()
const setFileTreeTabValue = (value: string) => {
if (value !== "changes" && value !== "all") return
layout.fileTree.setTab(value)
}
const showAllFiles = () => {
if (fileTreeTab() !== "changes") return
layout.fileTree.setTab("all")
}
let fileFilter: HTMLInputElement | undefined let fileFilter: HTMLInputElement | undefined
let tabList: HTMLDivElement | undefined let tabList: HTMLDivElement | undefined
const temporaryTab = tabs().preview const temporaryTab = tabs().preview
const previewTab = (value: string) => {
const next = normalizeTab(value)
tabs().previewTab(next)
const path = file.pathFromTab(next)
if (path) void file.load(path)
openReviewPanel()
queueMicrotask(() => tabs().setActive(next))
}
const openFileBrowser = () => { const openFileBrowser = () => {
previewTab(SESSION_OPEN_FILE_TAB) controller.browser.open()
queueMicrotask(() => fileFilter?.focus()) queueMicrotask(() => fileFilter?.focus())
} }
const activateTab = (value: string) => {
const next = normalizeTab(value)
const path = file.pathFromTab(next)
if (path) void file.load(path)
openReviewPanel()
tabs().setActive(next)
}
const browserTab = createMemo(() => {
if (!props.fileBrowserState) return undefined
const active = activeTab()
if (active === SESSION_OPEN_FILE_TAB) return SESSION_OPEN_FILE_TAB
if (active && file.pathFromTab(active)) return active
return activeFileTab()
})
// Keep the file-browser shell mounted while any file tab exists. Kobalte briefly
// selects Review while the tab For replaces a preview trigger, which would
// otherwise dispose the sidebar and reset scroll.
const fileBrowserMounted = createMemo(() => {
if (!props.fileBrowserState) return false
return openedTabs().length > 0 || openFileOpen() || !!browserTab()
})
const fileBrowserVisible = createMemo(() => {
const active = activeTab()
return active !== "review" && active !== "context" && active !== "empty"
})
const openFileKeybind = createMemo(() => command.keybindParts("file.open")) const openFileKeybind = createMemo(() => command.keybindParts("file.open"))
const closeTabKeybind = createMemo(() => command.keybindParts("tab.close")) const closeTabKeybind = createMemo(() => command.keybindParts("tab.close"))
const [store, setStore] = createStore({ const [store, setStore] = createStore({
@@ -264,27 +216,6 @@ export function SessionSidePanel(props: {
setStore("activeDraggable", undefined) setStore("activeDraggable", undefined)
} }
createEffect(() => {
if (!file.ready()) return
setSessionHandoff(sessionKey(), {
files: tabs()
.all()
.reduce<Record<string, SelectedLineRange | null>>((acc, tab) => {
const path = file.pathFromTab(tab)
if (!path) return acc
const selected = file.selectedLines(path)
acc[path] =
selected && typeof selected === "object" && "start" in selected && "end" in selected
? (selected as SelectedLineRange)
: null
return acc
}, {}),
})
})
return ( return (
<Show when={isDesktop() && !(settings.general.newLayoutDesigns() && !params.id)}> <Show when={isDesktop() && !(settings.general.newLayoutDesigns() && !params.id)}>
<aside <aside
@@ -451,9 +382,7 @@ export function SessionSidePanel(props: {
iconSize="large" iconSize="large"
class="!rounded-md" class="!rounded-md"
onClick={() => { onClick={() => {
void import("@/components/dialog-select-file").then((x) => { void controller.dialog.openFile()
dialog.show(() => <x.DialogSelectFile mode="files" onOpenFile={showAllFiles} />)
})
}} }}
aria-label={language.t("command.file.open")} aria-label={language.t("command.file.open")}
/> />
+10
View File
@@ -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",
+13 -2
View File
@@ -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,
+2
View File
@@ -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)),
+36 -1
View File
@@ -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")
}
+6
View File
@@ -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`,
} }
} }
+44
View File
@@ -30,6 +30,44 @@ function runtimeRequirePlugin(): Plugin {
} }
} }
function fffNodePlugin(): Plugin {
return {
name: "opencode:fff-node",
enforce: "pre",
transform(code, id) {
const normalized = id.replaceAll("\\", "/")
if (normalized.endsWith("/ffi-rs/index.js")) {
const start = code.indexOf("if (!nativeBinding) {")
if (start === -1) this.error("Failed to rewrite ffi-rs native binding loader")
return `const unavailable = () => { throw new Error("ffi-rs native binding unavailable") }
const nativeBinding = globalThis.__OPENCODE_FFF_FFI ?? {
DataType: new Proxy({}, { get: (target, key) => target[key] ?? key }),
PointerType: {},
FFITypeTag: {},
open: unavailable,
close: unavailable,
load: unavailable,
isNullPointer: unavailable,
createPointer: unavailable,
restorePointer: unavailable,
unwrapPointer: unavailable,
wrapPointer: unavailable,
freePointer: unavailable,
}
const loadError = undefined
${code.slice(start)}`
}
if (!normalized.endsWith("/fff-node/dist/src/binary.js")) return
const transformed = code.replace(
"export function findBinary() {",
"export function findBinary() { if (process.env.FFF_BINARY_PATH) return process.env.FFF_BINARY_PATH;",
)
if (transformed === code) this.error("Failed to rewrite FFF binary loader")
return transformed
},
}
}
const resolve = { 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",
+1
View File
@@ -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:*",
+50 -50
View File
@@ -1,71 +1,52 @@
import type {
DirItem,
DirSearchResult,
FileItem,
GrepCursor,
GrepMatch,
GrepResult,
InitOptions,
MixedItem,
MixedSearchResult,
SearchResult,
} from "@ff-labs/fff-node"
const { FileFinder } = await import("@ff-labs/fff-node").catch(() => ({ FileFinder: undefined }))
export type Result<T> = { ok: true; value: T } | { ok: false; error: string } export 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"}` : ""}`,
)
}
+2 -11
View File
@@ -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
} }
+10 -3
View File
@@ -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 })
}), }),
) )
+38 -10
View File
@@ -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())
}),
)
}), }),
} }
+11 -6
View File
@@ -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:"),
}) })
+6 -1
View File
@@ -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:"
} }
+4 -4
View File
@@ -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
View File
@@ -1 +1,2 @@
export * as Plugin from "./plugin.js" export * as Plugin from "./plugin.js"
export { PluginContextProvider, usePlugin } from "./solid.js"
+19
View File
@@ -0,0 +1,19 @@
import { createComponent, createContext, useContext, type JSX } from "solid-js"
import type { Context } from "./context.js"
const PluginContext = createContext<Context>()
export function PluginContextProvider(props: { readonly value: Context; readonly children: JSX.Element }) {
return createComponent(PluginContext.Provider, {
value: props.value,
get children() {
return props.children
},
})
}
export function usePlugin() {
const context = useContext(PluginContext)
if (!context) throw new Error("PluginContextProvider is missing")
return context
}
-4
View File
@@ -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
View File
@@ -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 -5
View File
@@ -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
/> />
) )
+40 -40
View File
@@ -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>
) )
+12 -4
View File
@@ -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)
+8 -8
View File
@@ -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>
+14 -14
View File
@@ -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())}
+16 -16
View File
@@ -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",
+7 -7
View File
@@ -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>
} }
/> />
+4 -4
View File
@@ -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,
+13 -13
View File
@@ -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>
+4 -4
View File
@@ -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()}
+43 -42
View File
@@ -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>
+5 -5
View File
@@ -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>
) )
+250
View File
@@ -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>
)
}
+2 -2
View File
@@ -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>
+266
View File
@@ -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}`,
+7
View File
@@ -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({
+33 -29
View File
@@ -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]) => [
+4 -3
View File
@@ -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,
}
}
+267
View File
@@ -0,0 +1,267 @@
import { batch, createEffect, onCleanup, untrack } from "solid-js"
import { createStore, produce, reconcile } from "solid-js/store"
import path from "path"
import { isDeepEqual } from "remeda"
import { createSimpleContext } from "./helper"
import { useData } from "./data"
import { useEvent } from "./event"
import { useRoute } from "./route"
import { useTuiPaths } from "./runtime"
import { useConfig } from "../config"
import { readJson, writeJsonAtomic } from "../util/persistence"
import { isRecord } from "../util/record"
import {
closeSessionTab,
cycleSessionTab,
moveSessionTabHistory,
openSessionTab,
recordSessionTabHistory,
type SessionTab,
type SessionTabHistory,
type SessionTabUnread,
} from "./session-tabs-model"
type PersistedState = {
tabs: SessionTab[]
unread: Record<string, SessionTabUnread>
}
export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimpleContext({
name: "SessionTabs",
init: () => {
const route = useRoute()
const data = useData()
const event = useEvent()
const config = useConfig().data
const filePath = path.join(useTuiPaths().state, "session-tabs.json")
const enabled = () => config.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 })
},
}
},
})
+24 -17
View File
@@ -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
+12 -1
View File
@@ -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
-40
View File
@@ -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>
+2
View File
@@ -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,
] ]
-108
View File
@@ -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)
},
}
}
+57 -32
View File
@@ -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")
+16
View File
@@ -0,0 +1,16 @@
import { readdir } from "node:fs/promises"
import path from "node:path"
const extensions = new Set([".cjs", ".cts", ".js", ".jsx", ".mjs", ".mts", ".ts", ".tsx"])
export async function discoverTuiPlugins(cwd: string) {
const directory = path.join(cwd, ".opencode", "plugins", "tui")
const entries = await readdir(directory, { withFileTypes: true }).catch((error: unknown) => {
if (error && typeof error === "object" && Reflect.get(error, "code") === "ENOENT") return []
return Promise.reject(error)
})
return entries
.filter((entry) => (entry.isFile() || entry.isSymbolicLink()) && extensions.has(path.extname(entry.name)))
.map((entry) => path.join(directory, entry.name))
.sort()
}
-79
View File
@@ -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
}
-65
View File
@@ -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)
},
}
}
+1 -12
View File
@@ -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>
+10 -10
View File
@@ -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>
+71 -77
View File
@@ -3,7 +3,7 @@ import { createMemo, createSignal, For, onCleanup, onMount, Show } from "solid-j
import { useRenderer, useTerminalDimensions } from "@opentui/solid" import { 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
+59 -63
View File
@@ -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>
+7 -16
View File
@@ -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>
+235
View File
@@ -0,0 +1,235 @@
import { createEffect, createSignal, onCleanup, type Accessor } from "solid-js"
type AnimatableValue = number | readonly number[]
type AnimatableTarget = Record<string, AnimatableValue>
type Ease = (progress: number) => number
type SpringTransition = {
type: "spring"
visualDuration: number
restDelta: number
restSpeed: number
}
type TweenTransition = {
type: "tween"
duration: number
ease: Ease
}
type Transition = SpringTransition | TweenTransition
type ValueState = {
scalar: boolean
value: number[]
target: number[]
velocity: number[]
from: number[]
}
type AnimationTask = (now: number) => boolean
const tasks = new Set<AnimationTask>()
let timer: ReturnType<typeof setInterval> | undefined
const smoothstep = (progress: number) => progress * progress * (3 - 2 * progress)
export function spring(options: { visualDuration: number; restDelta?: number; restSpeed?: number }): Transition {
return {
type: "spring",
visualDuration: options.visualDuration,
restDelta: options.restDelta ?? 0.002,
restSpeed: options.restSpeed ?? 0.002,
}
}
export function tween(options: { duration: number; ease?: Ease }): Transition {
return {
type: "tween",
duration: options.duration,
ease: options.ease ?? smoothstep,
}
}
export function createAnimatable<T extends AnimatableTarget>(
initial: T,
options: {
transition: Transition
enabled?: Accessor<boolean>
},
) {
const enabled = options.enabled ?? (() => true)
const state = new Map<string, ValueState>()
const [value, setValue] = createSignal(clone(initial), { equals: false })
let target = clone(initial)
let started = performance.now()
let previous = started
rebuild(initial)
const step: AnimationTask = (now) => {
if (!enabled()) {
jump(target)
return false
}
const delta = Math.min(0.05, (now - previous) / 1_000)
previous = now
const moving = options.transition.type === "spring" ? advanceSpring(delta) : advanceTween(now)
if (!moving) {
jump(target)
return false
}
setValue(() => read())
return true
}
function animate(next: T) {
if (sameTarget(next)) return
target = clone(next)
if (!enabled() || !sameShape(next)) return jump(next)
started = performance.now()
previous = started
for (const [key, current] of state) {
current.from = [...current.value]
current.target = values(next[key]!)
}
if (settled()) return jump(next)
schedule(step)
}
function jump(next: T) {
target = clone(next)
unschedule(step)
rebuild(next)
setValue(() => clone(next))
}
function stop() {
unschedule(step)
}
function rebuild(next: T) {
state.clear()
for (const [key, nextValue] of Object.entries(next)) {
const value = values(nextValue)
state.set(key, {
scalar: typeof nextValue === "number",
value,
target: [...value],
velocity: value.map(() => 0),
from: [...value],
})
}
started = performance.now()
previous = started
}
function sameShape(next: T) {
const entries = Object.entries(next)
if (entries.length !== state.size) return false
return entries.every(([key, nextValue]) => {
const current = state.get(key)
if (!current || current.scalar !== (typeof nextValue === "number")) return false
return current.value.length === values(nextValue).length
})
}
function sameTarget(next: T) {
if (!sameShape(next)) return false
return Object.entries(next).every(([key, value]) =>
values(value).every((part, index) => part === state.get(key)!.target[index]),
)
}
function settled() {
return [...state.values()].every(
(current) =>
current.value.every((value, index) => value === current.target[index]) &&
current.velocity.every((velocity) => velocity === 0),
)
}
function advanceSpring(delta: number) {
const transition = options.transition
if (transition.type !== "spring") return false
const frequency = (2 * Math.PI) / (Math.max(0.001, transition.visualDuration) * 1.2)
let moving = false
for (const current of state.values()) {
current.value.forEach((value, index) => {
const target = current.target[index]!
const velocity = current.velocity[index]!
const offset = value - target
const decay = Math.exp(-frequency * delta)
const nextVelocity = velocity + frequency * offset
current.value[index] = target + (offset + nextVelocity * delta) * decay
current.velocity[index] = (velocity - frequency * nextVelocity * delta) * decay
moving ||=
Math.abs(current.value[index]! - target) > transition.restDelta ||
Math.abs(current.velocity[index]!) > transition.restSpeed
})
}
return moving
}
function advanceTween(now: number) {
const transition = options.transition
if (transition.type !== "tween") return false
const progress = Math.min(1, (now - started) / 1_000 / Math.max(0.001, transition.duration))
const eased = transition.ease(progress)
for (const current of state.values())
current.value.forEach((_, index) => {
const from = current.from[index]!
current.value[index] = from + (current.target[index]! - from) * eased
current.velocity[index] = 0
})
return progress < 1
}
function read() {
return Object.fromEntries(
[...state].map(([key, current]) => [key, current.scalar ? current.value[0]! : [...current.value]]),
) as T
}
createEffect(() => {
if (enabled()) return
jump(target)
})
onCleanup(stop)
return { value, animate, jump }
}
function values(value: AnimatableValue) {
return typeof value === "number" ? [value] : [...value]
}
function clone<T extends AnimatableTarget>(target: T) {
return Object.fromEntries(
Object.entries(target).map(([key, value]) => [key, typeof value === "number" ? value : [...value]]),
) as T
}
function schedule(task: AnimationTask) {
tasks.add(task)
if (timer) return
timer = setInterval(tick, 16)
}
function unschedule(task: AnimationTask) {
tasks.delete(task)
if (tasks.size > 0 || !timer) return
clearInterval(timer)
timer = undefined
}
function tick() {
const now = performance.now()
for (const task of tasks) if (!task(now)) tasks.delete(task)
if (tasks.size > 0 || !timer) return
clearInterval(timer)
timer = undefined
}
+7 -7
View File
@@ -1,6 +1,6 @@
import { TextAttributes } from "@opentui/core" import { 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>
+7 -7
View File
@@ -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>
+24 -26
View File
@@ -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>
+7 -7
View File
@@ -1,11 +1,11 @@
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 function DialogExportResult(props: { path: string; onClose?: () => void }) { export function DialogExportResult(props: { path: string; onClose?: () => void }) {
const dialog = useDialog() const dialog = useDialog()
const { themeV2 } = useTheme().contextual("elevated") const theme = useThemes().contextual("elevated")
const close = () => { const close = () => {
props.onClose?.() props.onClose?.()
@@ -27,24 +27,24 @@ export function DialogExportResult(props: { path: string; onClose?: () => 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}>
Session exported Session exported
</text> </text>
<text fg={themeV2.text.subdued} onMouseUp={close}> <text fg={theme.text.subdued} onMouseUp={close}>
esc esc
</text> </text>
</box> </box>
<box> <box>
<text fg={themeV2.text.default}>{props.path}</text> <text fg={theme.text.default}>{props.path}</text>
</box> </box>
<box flexDirection="row" justifyContent="flex-end" gap={1} paddingBottom={1}> <box flexDirection="row" justifyContent="flex-end" gap={1} 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={close} onMouseUp={close}
> >
<text fg={themeV2.text.action.primary.focused}>Close</text> <text fg={theme.text.action.primary.focused}>Close</text>
</box> </box>
</box> </box>
</box> </box>
+7 -7
View File
@@ -1,11 +1,11 @@
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 "./dialog" import { useDialog } from "./dialog"
export function DialogHelp() { export function DialogHelp() {
const dialog = useDialog() const dialog = useDialog()
const { themeV2 } = useTheme().contextual("elevated") const theme = useThemes().contextual("elevated")
const shortcuts = Keymap.useShortcuts() const shortcuts = Keymap.useShortcuts()
Keymap.createLayer(() => ({ Keymap.createLayer(() => ({
@@ -19,15 +19,15 @@ export function DialogHelp() {
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}>
Help Help
</text> </text>
<text fg={themeV2.text.subdued} onMouseUp={() => dialog.clear()}> <text fg={theme.text.subdued} onMouseUp={() => dialog.clear()}>
esc/enter esc/enter
</text> </text>
</box> </box>
<box paddingBottom={1}> <box paddingBottom={1}>
<text fg={themeV2.text.subdued}> <text fg={theme.text.subdued}>
Press {shortcuts.get("command.palette.show")} to see all available actions and commands in any context. Press {shortcuts.get("command.palette.show")} to see all available actions and commands in any context.
</text> </text>
</box> </box>
@@ -35,10 +35,10 @@ export function DialogHelp() {
<box <box
paddingLeft={3} paddingLeft={3}
paddingRight={3} paddingRight={3}
backgroundColor={themeV2.background.action.primary.focused} backgroundColor={theme.background.action.primary.focused}
onMouseUp={() => dialog.clear()} onMouseUp={() => 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>
+12 -12
View File
@@ -1,6 +1,6 @@
import { TextareaRenderable, TextAttributes } from "@opentui/core" import { TextareaRenderable, 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 { Show, createEffect, createSignal, onMount, type JSX } from "solid-js" import { Show, createEffect, createSignal, onMount, type JSX } from "solid-js"
import { Spinner } from "../component/spinner" import { Spinner } from "../component/spinner"
@@ -18,7 +18,7 @@ export type DialogPromptProps = {
export function DialogPrompt(props: DialogPromptProps) { export function DialogPrompt(props: DialogPromptProps) {
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 [textareaTarget, setTextareaTarget] = createSignal<TextareaRenderable>() const [textareaTarget, setTextareaTarget] = createSignal<TextareaRenderable>()
let textarea: TextareaRenderable let textarea: TextareaRenderable
@@ -74,10 +74,10 @@ export function DialogPrompt(props: DialogPromptProps) {
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>
@@ -91,20 +91,20 @@ export function DialogPrompt(props: DialogPromptProps) {
}} }}
initialValue={props.value} initialValue={props.value}
placeholder={props.placeholder ?? "Enter text"} placeholder={props.placeholder ?? "Enter text"}
placeholderColor={themeV2.text.subdued} placeholderColor={theme.text.subdued}
textColor={props.busy ? themeV2.text.formfield.disabled : themeV2.text.formfield.default} textColor={props.busy ? theme.text.formfield.disabled : theme.text.formfield.default}
focusedTextColor={props.busy ? themeV2.text.formfield.disabled : themeV2.text.formfield.default} focusedTextColor={props.busy ? theme.text.formfield.disabled : theme.text.formfield.default}
cursorColor={props.busy ? themeV2.background.formfield.disabled : themeV2.text.default} cursorColor={props.busy ? theme.background.formfield.disabled : theme.text.default}
/> />
<Show when={props.busy}> <Show when={props.busy}>
<Spinner color={themeV2.text.subdued}>{props.busyText ?? "Working..."}</Spinner> <Spinner color={theme.text.subdued}>{props.busyText ?? "Working..."}</Spinner>
</Show> </Show>
</box> </box>
<box paddingBottom={1} gap={1} flexDirection="row"> <box paddingBottom={1} gap={1} flexDirection="row">
<Show when={!props.busy} fallback={<text fg={themeV2.text.subdued}>processing...</text>}> <Show when={!props.busy} fallback={<text fg={theme.text.subdued}>processing...</text>}>
<Show when={shortcuts.get("dialog.prompt.submit")}> <Show when={shortcuts.get("dialog.prompt.submit")}>
<text fg={themeV2.text.default}> <text fg={theme.text.default}>
{shortcuts.get("dialog.prompt.submit")} <span style={{ fg: themeV2.text.subdued }}>submit</span> {shortcuts.get("dialog.prompt.submit")} <span style={{ fg: theme.text.subdued }}>submit</span>
</text> </text>
</Show> </Show>
</Show> </Show>
+32 -33
View File
@@ -1,6 +1,6 @@
import { InputRenderable, RGBA, ScrollBoxRenderable, TextAttributes } from "@opentui/core" import { InputRenderable, RGBA, ScrollBoxRenderable, TextAttributes } from "@opentui/core"
import { Keymap, type KeymapCommand } from "../context/keymap" import { Keymap, type KeymapCommand } from "../context/keymap"
import { useTheme } from "../context/theme" import { useThemes } from "../context/theme"
import { entries, filter, flatMap, groupBy, pipe } from "remeda" import { entries, filter, flatMap, groupBy, pipe } from "remeda"
import { batch, createEffect, createMemo, createSignal, For, Show, type JSX, on, onCleanup } from "solid-js" import { batch, createEffect, createMemo, createSignal, For, Show, type JSX, on, onCleanup } from "solid-js"
import { createStore } from "solid-js/store" import { createStore } from "solid-js/store"
@@ -95,7 +95,9 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
type VisibleAction = (Action & { label: string }) | FooterHint type VisibleAction = (Action & { label: string }) | FooterHint
const dialog = useDialog() const dialog = useDialog()
const { themeV2, mode } = useTheme().contextual("elevated") const themes = useThemes()
const theme = themes.contextual("elevated")
const mode = themes.mode
const config = useConfig().data const config = useConfig().data
const scrollAcceleration = createMemo(() => getScrollAcceleration(config)) const scrollAcceleration = createMemo(() => getScrollAcceleration(config))
@@ -522,10 +524,10 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
if (!isActionItem(action.item)) if (!isActionItem(action.item))
return ( return (
<text> <text>
<span style={{ fg: themeV2.text.default }}> <span style={{ fg: theme.text.default }}>
<b>{action.item.title}</b>{" "} <b>{action.item.title}</b>{" "}
</span> </span>
<span style={{ fg: themeV2.text.subdued }}>{action.item.label}</span> <span style={{ fg: theme.text.subdued }}>{action.item.label}</span>
</text> </text>
) )
const item = action.item const item = action.item
@@ -534,16 +536,16 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
return ( return (
<box <box
flexDirection="row" flexDirection="row"
backgroundColor={active() ? themeV2.background.action.primary.focused : RGBA.fromInts(0, 0, 0, 0)} backgroundColor={active() ? theme.background.action.primary.focused : RGBA.fromInts(0, 0, 0, 0)}
onMouseUp={() => trigger(item)} onMouseUp={() => trigger(item)}
> >
<text <text
fg={ fg={
disabled() disabled()
? themeV2.text.action.primary.disabled ? theme.text.action.primary.disabled
: active() : active()
? themeV2.text.action.primary.focused ? theme.text.action.primary.focused
: themeV2.text.default : theme.text.default
} }
attributes={active() ? TextAttributes.BOLD : undefined} attributes={active() ? TextAttributes.BOLD : undefined}
> >
@@ -552,10 +554,10 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
<text <text
fg={ fg={
disabled() disabled()
? themeV2.text.action.primary.disabled ? theme.text.action.primary.disabled
: active() : active()
? themeV2.text.action.primary.focused ? theme.text.action.primary.focused
: themeV2.text.subdued : theme.text.subdued
} }
> >
{" " + item.label} {" " + item.label}
@@ -569,11 +571,11 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
<box paddingLeft={4} paddingRight={4}> <box paddingLeft={4} paddingRight={4}>
<box flexDirection="row" justifyContent="space-between"> <box flexDirection="row" justifyContent="space-between">
{props.titleView ?? ( {props.titleView ?? (
<text fg={themeV2.text.default} attributes={TextAttributes.BOLD}> <text fg={theme.text.default} attributes={TextAttributes.BOLD}>
{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>
@@ -587,9 +589,9 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
props.onFilter?.(e) props.onFilter?.(e)
}) })
}} }}
focusedBackgroundColor={themeV2.background.formfield.focused} focusedBackgroundColor={theme.background.formfield.focused}
cursorColor={themeV2.text.formfield.focused} cursorColor={theme.text.formfield.focused}
focusedTextColor={themeV2.text.formfield.focused} focusedTextColor={theme.text.formfield.focused}
ref={(r) => { ref={(r) => {
input = r input = r
input.traits = { status: "FILTER" } input.traits = { status: "FILTER" }
@@ -600,7 +602,7 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
}, 1) }, 1)
}} }}
placeholder={props.placeholder ?? "Search"} placeholder={props.placeholder ?? "Search"}
placeholderColor={themeV2.text.subdued} placeholderColor={theme.text.subdued}
/> />
</box> </box>
</Show> </Show>
@@ -614,14 +616,14 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
fallback={ fallback={
props.emptyView ?? ( props.emptyView ?? (
<box paddingLeft={4} paddingRight={4} paddingTop={1}> <box paddingLeft={4} paddingRight={4} paddingTop={1}>
<text fg={themeV2.text.subdued}>No items available</text> <text fg={theme.text.subdued}>No items available</text>
</box> </box>
) )
} }
> >
{props.noMatchView ?? ( {props.noMatchView ?? (
<box paddingLeft={4} paddingRight={4} paddingTop={1}> <box paddingLeft={4} paddingRight={4} paddingTop={1}>
<text fg={themeV2.text.subdued}>No results found</text> <text fg={theme.text.subdued}>No results found</text>
</box> </box>
)} )}
</Show> </Show>
@@ -643,10 +645,7 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
<Show <Show
when={options[0]?.categoryView} when={options[0]?.categoryView}
fallback={ fallback={
<text <text fg={theme.hue.accent[mode() === "light" ? 800 : 200]} attributes={TextAttributes.BOLD}>
fg={themeV2.hue.accent[mode() === "light" ? 800 : 200]}
attributes={TextAttributes.BOLD}
>
{category} {category}
</text> </text>
} }
@@ -695,8 +694,8 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
backgroundColor={ backgroundColor={
active() active()
? actionFocused() ? actionFocused()
? themeV2.background.surface.overlay ? theme.background.surface.overlay
: (option.bg ?? themeV2.background.action.primary.focused) : (option.bg ?? theme.background.action.primary.focused)
: RGBA.fromInts(0, 0, 0, 0) : RGBA.fromInts(0, 0, 0, 0)
} }
> >
@@ -725,7 +724,7 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
{(detail) => ( {(detail) => (
<box paddingLeft={3} paddingRight={3}> <box paddingLeft={3} paddingRight={3}>
<text <text
fg={option.detailsColor ?? themeV2.text.subdued} fg={option.detailsColor ?? theme.text.subdued}
wrapMode={option.detailsWrap ? "word" : "none"} wrapMode={option.detailsWrap ? "word" : "none"}
> >
{option.detailsWrap {option.detailsWrap
@@ -774,12 +773,12 @@ function Option(props: {
activeColor?: RGBA activeColor?: RGBA
onMouseOver?: () => void onMouseOver?: () => void
}) { }) {
const { themeV2 } = useTheme().contextual("elevated") const theme = useThemes().contextual("elevated")
const text = createMemo(() => { const text = createMemo(() => {
if (props.active && !props.muted) return props.activeColor ?? themeV2.text.action.primary.focused if (props.active && !props.muted) return props.activeColor ?? theme.text.action.primary.focused
if (props.muted && (props.active || props.current)) return themeV2.text.subdued if (props.muted && (props.active || props.current)) return theme.text.subdued
if (props.current) return themeV2.text.formfield.selected if (props.current) return theme.text.formfield.selected
return themeV2.text.default return theme.text.default
}) })
return ( return (
@@ -809,14 +808,14 @@ function Option(props: {
? Locale.truncateLeft(props.title, props.titleWidth ?? 61) ? Locale.truncateLeft(props.title, props.titleWidth ?? 61)
: Locale.truncate(props.title, props.titleWidth ?? 61))} : Locale.truncate(props.title, props.titleWidth ?? 61))}
<Show when={props.description}> <Show when={props.description}>
<span style={{ fg: props.active && !props.muted ? text() : themeV2.text.subdued }}> <span style={{ fg: props.active && !props.muted ? text() : theme.text.subdued }}>
{" " + props.description} {" " + props.description}
</span> </span>
</Show> </Show>
</text> </text>
<Show when={props.footer}> <Show when={props.footer}>
<box flexShrink={0}> <box flexShrink={0}>
<text fg={props.active && !props.muted ? text() : themeV2.text.subdued}>{props.footer}</text> <text fg={props.active && !props.muted ? text() : theme.text.subdued}>{props.footer}</text>
</box> </box>
</Show> </Show>
</> </>
+3 -3
View File
@@ -1,7 +1,7 @@
import { useRenderer, useTerminalDimensions } from "@opentui/solid" import { useRenderer, useTerminalDimensions } from "@opentui/solid"
import { batch, createContext, createEffect, onCleanup, Show, useContext, type JSX, type ParentProps } from "solid-js" import { batch, createContext, createEffect, onCleanup, Show, useContext, type JSX, type ParentProps } from "solid-js"
import { Keymap } from "../context/keymap" import { Keymap } from "../context/keymap"
import { useTheme } from "../context/theme" import { useThemes } from "../context/theme"
import { MouseButton, Renderable, RGBA } from "@opentui/core" import { MouseButton, Renderable, RGBA } from "@opentui/core"
import { createStore } from "solid-js/store" import { createStore } from "solid-js/store"
import { useToast } from "./toast" import { useToast } from "./toast"
@@ -16,7 +16,7 @@ export function Dialog(
}>, }>,
) { ) {
const dimensions = useTerminalDimensions() const dimensions = useTerminalDimensions()
const { themeV2 } = useTheme().contextual("elevated") const theme = useThemes().contextual("elevated")
const renderer = useRenderer() const renderer = useRenderer()
let dismiss = false let dismiss = false
@@ -59,7 +59,7 @@ export function Dialog(
}} }}
width={width()} width={width()}
maxWidth={dimensions().width - 2} maxWidth={dimensions().width - 2}
backgroundColor={themeV2.background.default} backgroundColor={theme.background.default}
paddingTop={1} paddingTop={1}
> >
{props.children} {props.children}
+6 -6
View File
@@ -1,6 +1,6 @@
import { createContext, useContext, type ParentProps, Show } from "solid-js" import { createContext, useContext, type ParentProps, Show } from "solid-js"
import { createStore } from "solid-js/store" import { createStore } from "solid-js/store"
import { useTheme } from "../context/theme" import { useThemes } from "../context/theme"
import { useTerminalDimensions } from "@opentui/solid" import { useTerminalDimensions } from "@opentui/solid"
import { SplitBorder } from "./border" import { SplitBorder } from "./border"
import { TextAttributes } from "@opentui/core" import { TextAttributes } from "@opentui/core"
@@ -14,7 +14,7 @@ type ToastInput = Omit<ToastOptions, "duration"> & { duration?: number }
export function Toast() { export function Toast() {
const toast = useToast() const toast = useToast()
const { themeV2 } = useTheme().contextual("overlay") const theme = useThemes().contextual("overlay")
const dimensions = useTerminalDimensions() const dimensions = useTerminalDimensions()
return ( return (
@@ -31,17 +31,17 @@ export function Toast() {
paddingRight={2} paddingRight={2}
paddingTop={1} paddingTop={1}
paddingBottom={1} paddingBottom={1}
backgroundColor={themeV2.background.default} backgroundColor={theme.background.default}
borderColor={themeV2.text.feedback[current().variant].default} borderColor={theme.text.feedback[current().variant].default}
border={["left", "right"]} border={["left", "right"]}
customBorderChars={SplitBorder.customBorderChars} customBorderChars={SplitBorder.customBorderChars}
> >
<Show when={current().title}> <Show when={current().title}>
<text attributes={TextAttributes.BOLD} marginBottom={1} fg={themeV2.text.default}> <text attributes={TextAttributes.BOLD} marginBottom={1} fg={theme.text.default}>
{current().title} {current().title}
</text> </text>
</Show> </Show>
<text fg={themeV2.text.default} wrapMode="word" width="100%"> <text fg={theme.text.default} wrapMode="word" width="100%">
{current().message} {current().message}
</text> </text>
</box> </box>
+20 -10
View File
@@ -67,20 +67,30 @@ export function truncate(str: string, len: number): string {
const graphemeSegmenter = new Intl.Segmenter(undefined, { granularity: "grapheme" }) const graphemeSegmenter = new Intl.Segmenter(undefined, { granularity: "grapheme" })
export function graphemes(str: string) {
return Array.from(graphemeSegmenter.segment(str), (item) => item.segment)
}
export function takeWidth(str: string, width: number) {
if (width <= 0) return ""
if (stringWidth(str) <= width) return str
const result: string[] = []
let used = 0
for (const segment of graphemes(str)) {
const next = stringWidth(segment)
if (used + next > width) break
result.push(segment)
used += next
}
return result.join("")
}
export function truncateWidth(str: string, width: number): string { export function truncateWidth(str: string, width: number): string {
if (width <= 0) return "" if (width <= 0) return ""
if (stringWidth(str) <= width) return str if (stringWidth(str) <= width) return str
if (width === 1) return "…" if (width === 1) return "…"
return takeWidth(str, width - 1) + "…"
const result: string[] = []
let used = 0
for (const item of graphemeSegmenter.segment(str)) {
const next = stringWidth(item.segment)
if (used + next > width - 1) break
result.push(item.segment)
used += next
}
return result.join("") + "…"
} }
export function truncateLeft(str: string, len: number): string { export function truncateLeft(str: string, len: number): string {

Some files were not shown because too many files have changed in this diff Show More