mirror of
https://github.com/anomalyco/opencode.git
synced 2026-07-29 14:41:48 -04:00
Compare commits
23 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 99cae9766b | |||
| b47cfbee7c | |||
| 5504245f7b | |||
| f64b50d71b | |||
| a7b2ea94e5 | |||
| 7b775c2582 | |||
| 12a931a220 | |||
| 90100c1365 | |||
| 139c9febe4 | |||
| 06290907a9 | |||
| 1c8175a61a | |||
| fe91698ed6 | |||
| 068c32df39 | |||
| a2885d1662 | |||
| 38a3dbb4c4 | |||
| 43383d4fba | |||
| 40c4c3918a | |||
| 754ea99d86 | |||
| 37a1b80d5a | |||
| f95d04fea0 | |||
| 08b80da931 | |||
| f6fb1a7cdd | |||
| 5bcc0016a6 |
@@ -0,0 +1,16 @@
|
||||
import type { Context } from "../../../packages/plugin/src/tui/context"
|
||||
|
||||
export default {
|
||||
id: "test.tui-discovery-smoke",
|
||||
setup(context: Context) {
|
||||
const timer = setTimeout(() => {
|
||||
context.ui.toast.show({
|
||||
title: "TUI plugin discovery works",
|
||||
message: "Loaded .opencode/plugins/tui/discovery-smoke.ts",
|
||||
variant: "success",
|
||||
duration: 30_000,
|
||||
})
|
||||
}, 1_000)
|
||||
return () => clearTimeout(timer)
|
||||
},
|
||||
}
|
||||
@@ -147,6 +147,11 @@
|
||||
"ws": "8.21.0",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@ff-labs/fff-bin-darwin-arm64": "0.10.1",
|
||||
"@ff-labs/fff-bin-linux-arm64-gnu": "0.10.1",
|
||||
"@ff-labs/fff-bin-linux-x64-gnu": "0.10.1",
|
||||
"@ff-labs/fff-bin-win32-arm64": "0.10.1",
|
||||
"@ff-labs/fff-bin-win32-x64": "0.10.1",
|
||||
"@lydell/node-pty-darwin-arm64": "1.2.0-beta.12",
|
||||
"@lydell/node-pty-darwin-x64": "1.2.0-beta.12",
|
||||
"@lydell/node-pty-linux-arm64": "1.2.0-beta.12",
|
||||
@@ -164,6 +169,11 @@
|
||||
"@types/bun": "catalog:",
|
||||
"@types/semver": "catalog:",
|
||||
"@typescript/native-preview": "catalog:",
|
||||
"@yuuang/ffi-rs-darwin-arm64": "1.3.2",
|
||||
"@yuuang/ffi-rs-linux-arm64-gnu": "1.3.2",
|
||||
"@yuuang/ffi-rs-linux-x64-gnu": "1.3.2",
|
||||
"@yuuang/ffi-rs-win32-arm64-msvc": "1.3.2",
|
||||
"@yuuang/ffi-rs-win32-x64-msvc": "1.3.2",
|
||||
"vite": "catalog:",
|
||||
"vite-plugin-solid": "catalog:",
|
||||
},
|
||||
@@ -364,6 +374,7 @@
|
||||
"@effect/platform-node": "catalog:",
|
||||
"@effect/sql-sqlite-bun": "catalog:",
|
||||
"@ff-labs/fff-bun": "0.10.1",
|
||||
"@ff-labs/fff-node": "0.10.1",
|
||||
"@lydell/node-pty": "catalog:",
|
||||
"@modelcontextprotocol/sdk": "1.29.0",
|
||||
"@opencode-ai/ai": "workspace:*",
|
||||
@@ -596,17 +607,20 @@
|
||||
"@tsconfig/node22": "catalog:",
|
||||
"@types/node": "catalog:",
|
||||
"@typescript/native-preview": "catalog:",
|
||||
"solid-js": "catalog:",
|
||||
"typescript": "catalog:",
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@opentui/core": ">=0.4.5",
|
||||
"@opentui/keymap": ">=0.4.5",
|
||||
"@opentui/solid": ">=0.4.5",
|
||||
"solid-js": ">=1.9.0",
|
||||
},
|
||||
"optionalPeers": [
|
||||
"@opentui/core",
|
||||
"@opentui/keymap",
|
||||
"@opentui/solid",
|
||||
"solid-js",
|
||||
],
|
||||
},
|
||||
"packages/protocol": {
|
||||
@@ -1693,6 +1707,8 @@
|
||||
|
||||
"@ff-labs/fff-bun": ["@ff-labs/fff-bun@0.10.1", "", { "optionalDependencies": { "@ff-labs/fff-bin-android-arm64": "0.10.1", "@ff-labs/fff-bin-darwin-arm64": "0.10.1", "@ff-labs/fff-bin-darwin-x64": "0.10.1", "@ff-labs/fff-bin-linux-arm64-gnu": "0.10.1", "@ff-labs/fff-bin-linux-arm64-musl": "0.10.1", "@ff-labs/fff-bin-linux-x64-gnu": "0.10.1", "@ff-labs/fff-bin-linux-x64-musl": "0.10.1", "@ff-labs/fff-bin-win32-arm64": "0.10.1", "@ff-labs/fff-bin-win32-x64": "0.10.1" }, "os": [ "!aix", "!sunos", "!freebsd", "!openbsd", ], "cpu": [ "x64", "arm64", ] }, "sha512-9oUCxypGbf2q3vNfKZ31wdzt5KqjhA9S6TwQaFol/j1lkSHVGvtIa3RvdGHPs1UUuvR/MO7b6pHj054BR9bXPQ=="],
|
||||
|
||||
"@ff-labs/fff-node": ["@ff-labs/fff-node@0.10.1", "", { "dependencies": { "ffi-rs": "^1.0.0" }, "optionalDependencies": { "@ff-labs/fff-bin-android-arm64": "0.10.1", "@ff-labs/fff-bin-darwin-arm64": "0.10.1", "@ff-labs/fff-bin-darwin-x64": "0.10.1", "@ff-labs/fff-bin-linux-arm64-gnu": "0.10.1", "@ff-labs/fff-bin-linux-arm64-musl": "0.10.1", "@ff-labs/fff-bin-linux-x64-gnu": "0.10.1", "@ff-labs/fff-bin-linux-x64-musl": "0.10.1", "@ff-labs/fff-bin-win32-arm64": "0.10.1", "@ff-labs/fff-bin-win32-x64": "0.10.1" }, "os": [ "!aix", "!sunos", "!freebsd", "!openbsd", ], "cpu": [ "x64", "arm64", ] }, "sha512-I2TIWHkey4wpLCncUQymBz/yWrjp/TE91yjrPvSLaPMUMRf3wXwEmelGNnZ5ynxy3cUauYg5b5hsUwhOK4skfA=="],
|
||||
|
||||
"@floating-ui/core": ["@floating-ui/core@1.8.0", "", { "dependencies": { "@floating-ui/utils": "^0.2.12" } }, "sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ=="],
|
||||
|
||||
"@floating-ui/dom": ["@floating-ui/dom@1.8.0", "", { "dependencies": { "@floating-ui/core": "^1.8.0", "@floating-ui/utils": "^0.2.12" } }, "sha512-yXSrzeHZBTZadLOlfyhCkJHNeLJnHRnRInwdZ40L7ZiaAtrBwoYlsDrX3v5zB1Utk7CLfzcOVnVVWoXEky7Ceg=="],
|
||||
@@ -3269,6 +3285,28 @@
|
||||
|
||||
"@xmldom/xmldom": ["@xmldom/xmldom@0.8.13", "", {}, "sha512-KRYzxepc14G/CEpEGc3Yn+JKaAeT63smlDr+vjB8jRfgTBBI9wRj/nkQEO+ucV8p8I9bfKLWp37uHgFrbntPvw=="],
|
||||
|
||||
"@yuuang/ffi-rs-android-arm64": ["@yuuang/ffi-rs-android-arm64@1.3.2", "", { "os": "android", "cpu": "arm64" }, "sha512-eDYLT0kVBkp7e2BwdRDmt6N1rkeDPUHDefk3ZX0/nok+GLsqfy1WBoSL3Yg7HVXN1EyW8OBVc2uK8Zq8HbmaSA=="],
|
||||
|
||||
"@yuuang/ffi-rs-darwin-arm64": ["@yuuang/ffi-rs-darwin-arm64@1.3.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-kRdgPaOM6TfuC5wHUwstlatk4HNie2lwSLJWQL2LiAUIJ7+96CoiWUNVhwBcFrhdfxhnWenYS6F668CV0vit8Q=="],
|
||||
|
||||
"@yuuang/ffi-rs-darwin-x64": ["@yuuang/ffi-rs-darwin-x64@1.3.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-O3AlVgre8FQcZRJe44Xs7A6iDLumoPXqbw40+eJCa2gyXaXyLPdHoWrS1W9rBCa1QZRRnG7zRulPVFw8C5uo8g=="],
|
||||
|
||||
"@yuuang/ffi-rs-linux-arm-gnueabihf": ["@yuuang/ffi-rs-linux-arm-gnueabihf@1.3.2", "", { "os": "linux", "cpu": "arm" }, "sha512-IXiNdTbIcTCPny5eeElijFWYeKSJjQWSjt9ZyJNdLHYiB1Np+XD6K7wNZS6EOMgMelhW1kQE62T654skGkVDIA=="],
|
||||
|
||||
"@yuuang/ffi-rs-linux-arm64-gnu": ["@yuuang/ffi-rs-linux-arm64-gnu@1.3.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-gWFO6xufUK9lPYUqDvKa6IR243dPqdetgl9Q7HrZWaDu7wLo06QQrosw8QTzndafQnOcBKm6LoLujmGCfTgJOA=="],
|
||||
|
||||
"@yuuang/ffi-rs-linux-arm64-musl": ["@yuuang/ffi-rs-linux-arm64-musl@1.3.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-lejvOSqypPziQH5rzfkDlJ6e92qhWbDutE9ttOO6z5I2k83zoh9iZhZWhaXSU5VqgQpcshRkrbtXb9gy1ft5dA=="],
|
||||
|
||||
"@yuuang/ffi-rs-linux-x64-gnu": ["@yuuang/ffi-rs-linux-x64-gnu@1.3.2", "", { "os": "linux", "cpu": "x64" }, "sha512-s8VCFazaJKmgY2hgMTpWk4TtBY/zy5ovbaGgwyY0FvBD0YvyhcET4IrMsDJpHhFVTPCYfKZ1dN45clD/YiFp6g=="],
|
||||
|
||||
"@yuuang/ffi-rs-linux-x64-musl": ["@yuuang/ffi-rs-linux-x64-musl@1.3.2", "", { "os": "linux", "cpu": "x64" }, "sha512-Ahr5chfKZKWUik20bEZRug+be57LZ2yYrtolyjSRoo7A4ZniBUHBZUNWm6TD6i0CJayqyxWeVk/XiaABD8bY0w=="],
|
||||
|
||||
"@yuuang/ffi-rs-win32-arm64-msvc": ["@yuuang/ffi-rs-win32-arm64-msvc@1.3.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-yhpLcj0qel5VNlpzxPZfNmi7+rEX8444QHjUP6WWLxdRfqPllROu/Cp3OpkBpw3BLdxfcDhWkjWMD5QsJN0Pvg=="],
|
||||
|
||||
"@yuuang/ffi-rs-win32-ia32-msvc": ["@yuuang/ffi-rs-win32-ia32-msvc@1.3.2", "", { "os": "win32", "cpu": [ "x64", "ia32", ] }, "sha512-BFVSbdtg/7mJBw5kQFOPKFiA+SF7z3240HpzHN81Umm4Bp4dWkyx0msYn8+Q7/BBJiLQ4F6bi3Nftk58YA9r9w=="],
|
||||
|
||||
"@yuuang/ffi-rs-win32-x64-msvc": ["@yuuang/ffi-rs-win32-x64-msvc@1.3.2", "", { "os": "win32", "cpu": "x64" }, "sha512-ZL5MJ76n2rjwGo26kCWW7wK6QT/cee00Rx8pfW79pz6vM6jqfhoE7zTnwFiw4aOQUes9+HUc5DeeJ3z+Vb9oLg=="],
|
||||
|
||||
"@zip.js/zip.js": ["@zip.js/zip.js@2.7.62", "", {}, "sha512-OaLvZ8j4gCkLn048ypkZu29KX30r8/OfFF2w4Jo5WXFr+J04J+lzJ5TKZBVgFXhlvSkqNFQdfnY1Q8TMTCyBVA=="],
|
||||
|
||||
"abbrev": ["abbrev@4.0.0", "", {}, "sha512-a1wflyaL0tHtJSmLSOVybYhy22vRih4eduhhrkcjgrWGnRfrZtovJ2FRjxuTtkkj47O/baf0R86QU5OuYpz8fA=="],
|
||||
@@ -4033,6 +4071,8 @@
|
||||
|
||||
"file-uri-to-path": ["file-uri-to-path@1.0.0", "", {}, "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw=="],
|
||||
|
||||
"ffi-rs": ["ffi-rs@1.3.2", "", { "optionalDependencies": { "@yuuang/ffi-rs-android-arm64": "1.3.2", "@yuuang/ffi-rs-darwin-arm64": "1.3.2", "@yuuang/ffi-rs-darwin-x64": "1.3.2", "@yuuang/ffi-rs-linux-arm-gnueabihf": "1.3.2", "@yuuang/ffi-rs-linux-arm64-gnu": "1.3.2", "@yuuang/ffi-rs-linux-arm64-musl": "1.3.2", "@yuuang/ffi-rs-linux-x64-gnu": "1.3.2", "@yuuang/ffi-rs-linux-x64-musl": "1.3.2", "@yuuang/ffi-rs-win32-arm64-msvc": "1.3.2", "@yuuang/ffi-rs-win32-ia32-msvc": "1.3.2", "@yuuang/ffi-rs-win32-x64-msvc": "1.3.2" } }, "sha512-4s8dX9VbBw/jd5NOuE3EJRqXaIVdjMyiumeeDzrOhtjQRwp6Bz2za7iksWXTnvTQKV/tTdm1s1w7mObe92zPjQ=="],
|
||||
|
||||
"filelist": ["filelist@1.0.6", "", { "dependencies": { "minimatch": "^5.0.1" } }, "sha512-5giy2PkLYY1cP39p17Ech+2xlpTRL9HLspOfEgm0L6CwBXBTgsK5ou0JtzYuepxkaQ/tvhCFIJ5uXo0OrM2DxA=="],
|
||||
|
||||
"fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="],
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
export function timelineChildTitle(input: {
|
||||
parentID?: string
|
||||
taskDescription?: string
|
||||
title?: string
|
||||
fallback: string
|
||||
}) {
|
||||
if (!input.parentID) return input.title ?? ""
|
||||
if (input.taskDescription) return input.taskDescription
|
||||
return input.title?.replace(/\s+\(@[^)]+ subagent\)$/, "") || input.fallback
|
||||
}
|
||||
|
||||
export function timelineRemovedSessionIDs(sessions: readonly { id: string; parentID?: string }[], sessionID: string) {
|
||||
const removed = new Set([sessionID])
|
||||
const byParent = Map.groupBy(
|
||||
sessions.filter((session) => session.parentID),
|
||||
(session) => session.parentID!,
|
||||
)
|
||||
const visit = (id: string) =>
|
||||
byParent.get(id)?.forEach((child) => {
|
||||
if (removed.has(child.id)) return
|
||||
removed.add(child.id)
|
||||
visit(child.id)
|
||||
})
|
||||
visit(sessionID)
|
||||
return removed
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { timelineChildTitle, timelineRemovedSessionIDs } from "./controller-projection"
|
||||
|
||||
describe("timeline controller", () => {
|
||||
test("projects child titles from task descriptions and session fallbacks", () => {
|
||||
expect(timelineChildTitle({ title: "Root", fallback: "New session" })).toBe("Root")
|
||||
expect(
|
||||
timelineChildTitle({ parentID: "parent", taskDescription: "Investigate timeline", fallback: "New session" }),
|
||||
).toBe("Investigate timeline")
|
||||
expect(
|
||||
timelineChildTitle({ parentID: "parent", title: "Fallback (@build subagent)", fallback: "New session" }),
|
||||
).toBe("Fallback")
|
||||
expect(timelineChildTitle({ parentID: "parent", fallback: "New session" })).toBe("New session")
|
||||
})
|
||||
|
||||
test("collects the removed session and all descendants", () => {
|
||||
const removed = timelineRemovedSessionIDs(
|
||||
[
|
||||
{ id: "root" },
|
||||
{ id: "child", parentID: "root" },
|
||||
{ id: "grandchild", parentID: "child" },
|
||||
{ id: "sibling", parentID: "root" },
|
||||
{ id: "unrelated" },
|
||||
],
|
||||
"root",
|
||||
)
|
||||
|
||||
expect([...removed]).toEqual(["root", "child", "grandchild", "sibling"])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,330 @@
|
||||
import type { Message, Part, UserMessage } from "@opencode-ai/sdk/v2"
|
||||
import { Button } from "@opencode-ai/ui/button"
|
||||
import { Dialog } from "@opencode-ai/ui/dialog"
|
||||
import { DialogFooter, DialogHeader, DialogTitleGroup, DialogV2 } from "@opencode-ai/ui/v2/dialog-v2"
|
||||
import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
|
||||
import { useNavigate } from "@solidjs/router"
|
||||
import { createEffect, createMemo, on, type Accessor } from "solid-js"
|
||||
import { createStore, produce } from "solid-js/store"
|
||||
import { notifySessionTabsRemoved } from "@/components/titlebar-session-events"
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { usePlatform } from "@/context/platform"
|
||||
import { useServerSDK } from "@/context/server-sdk"
|
||||
import { useSettings } from "@/context/settings"
|
||||
import { useSDK } from "@/context/sdk"
|
||||
import { useSync } from "@/context/sync"
|
||||
import { useTabs } from "@/context/tabs"
|
||||
import { useSessionKey } from "@/pages/session/session-layout"
|
||||
import { legacySessionHref, requireServerKey, sessionHref } from "@/utils/session-route"
|
||||
import { sessionTitle } from "@/utils/session-title"
|
||||
import { showToast } from "@/utils/toast"
|
||||
import { timelineChildTitle, timelineRemovedSessionIDs } from "./controller-projection"
|
||||
import { createTimelineProjection } from "./projection"
|
||||
|
||||
const emptyMessages: Message[] = []
|
||||
const emptyParts: Part[] = []
|
||||
const idle = { type: "idle" as const }
|
||||
|
||||
const taskDescription = (part: Part, sessionID: string): string | undefined => {
|
||||
if (part.type !== "tool" || part.tool !== "task") return undefined
|
||||
const metadata = "metadata" in part.state ? part.state.metadata : undefined
|
||||
if (metadata?.sessionId !== sessionID) return undefined
|
||||
const value = part.state.input?.description
|
||||
if (typeof value === "string" && value) return value
|
||||
return undefined
|
||||
}
|
||||
|
||||
export function createTimelineController(input: { userMessages: Accessor<UserMessage[]> }) {
|
||||
const navigate = useNavigate()
|
||||
const serverSDK = useServerSDK()
|
||||
const sdk = useSDK()
|
||||
const sync = useSync()
|
||||
const settings = useSettings()
|
||||
const tabs = useTabs()
|
||||
const dialog = useDialog()
|
||||
const language = useLanguage()
|
||||
const platform = usePlatform()
|
||||
const { params, sessionKey } = useSessionKey()
|
||||
const sessionID = createMemo(() => params.id)
|
||||
const status = createMemo(() => {
|
||||
const id = sessionID()
|
||||
if (!id) return idle
|
||||
return sync().data.session_status[id] ?? idle
|
||||
})
|
||||
const messages = createMemo(() => (sessionID() ? (sync().data.message[sessionID()!] ?? []) : []))
|
||||
const projectedMessages = createMemo(() => {
|
||||
const id = sessionID()
|
||||
if (!id) return []
|
||||
const visible = new Set(input.userMessages().map((message) => message.id))
|
||||
const boundary = messages().find((message) => message.role === "user" && !visible.has(message.id))?.id
|
||||
const projected = sync().data.session_message[id] ?? []
|
||||
return boundary ? projected.filter((message) => message.id < boundary) : projected
|
||||
})
|
||||
const info = createMemo(() => {
|
||||
const id = sessionID()
|
||||
return id ? sync().session.get(id) : undefined
|
||||
})
|
||||
const titleValue = createMemo(() => info()?.title)
|
||||
const titleLabel = createMemo(() => sessionTitle(titleValue()))
|
||||
const shareUrl = createMemo(() => info()?.share?.url)
|
||||
const shareEnabled = createMemo(() => sync().data.config.share !== "disabled")
|
||||
const parentID = createMemo(() => info()?.parentID)
|
||||
const parent = createMemo(() => {
|
||||
const id = parentID()
|
||||
return id ? sync().session.get(id) : undefined
|
||||
})
|
||||
const parentMessages = createMemo(() => {
|
||||
const id = parentID()
|
||||
return id ? (sync().data.message[id] ?? emptyMessages) : emptyMessages
|
||||
})
|
||||
const parentTitle = createMemo(() => sessionTitle(parent()?.title) ?? language.t("command.session.new"))
|
||||
const parts = (messageID: string) => sync().data.part[messageID] ?? emptyParts
|
||||
const part = (messageID: string, partID: string) => parts(messageID).find((item) => item.id === partID)
|
||||
const childTaskDescription = createMemo(() => {
|
||||
const id = sessionID()
|
||||
if (!id) return undefined
|
||||
return parentMessages()
|
||||
.flatMap((message) => parts(message.id))
|
||||
.map((item) => taskDescription(item, id))
|
||||
.findLast((value): value is string => !!value)
|
||||
})
|
||||
const childTitle = createMemo(() => {
|
||||
return timelineChildTitle({
|
||||
parentID: parentID(),
|
||||
taskDescription: childTaskDescription(),
|
||||
title: titleLabel(),
|
||||
fallback: language.t("command.session.new"),
|
||||
})
|
||||
})
|
||||
const showHeader = createMemo(() => !!(titleValue() || parentID()))
|
||||
const projection = createTimelineProjection({
|
||||
messages,
|
||||
userMessages: input.userMessages,
|
||||
sessionMessages: projectedMessages,
|
||||
parts,
|
||||
status,
|
||||
showReasoningSummaries: settings.general.showReasoningSummaries,
|
||||
inlineComments: settings.general.newLayoutDesigns,
|
||||
})
|
||||
const [pending, setPending] = createStore({ rename: false, share: false, unshare: false })
|
||||
|
||||
const errorMessage = (error: unknown) => {
|
||||
if (error && typeof error === "object" && "data" in error) {
|
||||
const data = error.data
|
||||
if (data && typeof data === "object" && "message" in data && typeof data.message === "string") return data.message
|
||||
}
|
||||
if (error instanceof Error) return error.message
|
||||
return language.t("common.requestFailed")
|
||||
}
|
||||
const rename = async (title: string) => {
|
||||
const id = sessionID()
|
||||
if (!id || pending.rename) return false
|
||||
const next = title.trim()
|
||||
if (!next || next === (titleLabel() ?? "")) return true
|
||||
setPending("rename", true)
|
||||
const success = await sdk()
|
||||
.api.session.rename({ sessionID: id, title: next })
|
||||
.then(() => true)
|
||||
.catch((error) => {
|
||||
showToast({ title: language.t("common.requestFailed"), description: errorMessage(error) })
|
||||
return false
|
||||
})
|
||||
setPending("rename", false)
|
||||
if (!success) return false
|
||||
sync().set(
|
||||
produce((draft) => {
|
||||
const index = draft.session.findIndex((session) => session.id === id)
|
||||
if (index !== -1) draft.session[index].title = next
|
||||
}),
|
||||
)
|
||||
return true
|
||||
}
|
||||
const share = async () => {
|
||||
const id = sessionID()
|
||||
if (!id || pending.share || !shareEnabled()) return
|
||||
setPending("share", true)
|
||||
await serverSDK()
|
||||
.client.session.share({ sessionID: id })
|
||||
.catch((error) => console.error("Failed to share session", error))
|
||||
setPending("share", false)
|
||||
}
|
||||
const unshare = async () => {
|
||||
const id = sessionID()
|
||||
if (!id || pending.unshare || !shareEnabled()) return
|
||||
setPending("unshare", true)
|
||||
await serverSDK()
|
||||
.client.session.unshare({ sessionID: id })
|
||||
.catch((error) => console.error("Failed to unshare session", error))
|
||||
setPending("unshare", false)
|
||||
}
|
||||
const href = (id: string) =>
|
||||
params.serverKey ? sessionHref(requireServerKey(params.serverKey), id) : legacySessionHref(sdk().directory, id)
|
||||
const navigateAfterRemoval = (id: string, parent?: string, next?: string) => {
|
||||
if (params.id !== id) return
|
||||
if (parent) return navigate(href(parent))
|
||||
if (next) return navigate(href(next))
|
||||
if (params.serverKey)
|
||||
return tabs.newDraft({ server: requireServerKey(params.serverKey), directory: sdk().directory })
|
||||
navigate(`/${params.dir}/session`)
|
||||
}
|
||||
const archive = async (id: string) => {
|
||||
const session = sync().session.get(id)
|
||||
if (!session || (await sdk().protocol) !== "v1") return
|
||||
const index = sync().data.session.findIndex((item) => item.id === id)
|
||||
const next = index === -1 ? undefined : (sync().data.session[index + 1] ?? sync().data.session[index - 1])
|
||||
await sdk()
|
||||
.client.session.update({ sessionID: id, directory: sdk().directory, time: { archived: Date.now() } })
|
||||
.then(() => {
|
||||
sync().set(
|
||||
produce((draft) => {
|
||||
const index = draft.session.findIndex((item) => item.id === id)
|
||||
if (index !== -1) draft.session.splice(index, 1)
|
||||
}),
|
||||
)
|
||||
sync().session.evict(id)
|
||||
void navigateAfterRemoval(id, session.parentID, next?.id)
|
||||
notifySessionTabsRemoved({ directory: sdk().directory, sessionIDs: [id] })
|
||||
})
|
||||
.catch((error) => showToast({ title: language.t("common.requestFailed"), description: errorMessage(error) }))
|
||||
}
|
||||
const remove = async (id: string) => {
|
||||
const session = sync().session.get(id)
|
||||
if (!session) return false
|
||||
const sessions = sync().data.session.filter((item) => !item.parentID && !item.time?.archived)
|
||||
const index = sessions.findIndex((item) => item.id === id)
|
||||
const next = index === -1 ? undefined : (sessions[index + 1] ?? sessions[index - 1])
|
||||
const success = await sdk()
|
||||
.api.session.remove({ sessionID: id })
|
||||
.then(() => true)
|
||||
.catch((error) => {
|
||||
showToast({ title: language.t("session.delete.failed.title"), description: errorMessage(error) })
|
||||
return false
|
||||
})
|
||||
if (!success) return false
|
||||
const removed = timelineRemovedSessionIDs(sync().data.session, id)
|
||||
void navigateAfterRemoval(id, session.parentID, next?.id)
|
||||
sync().set(produce((draft) => void (draft.session = draft.session.filter((item) => !removed.has(item.id)))))
|
||||
removed.forEach((sessionID) => sync().session.evict(sessionID))
|
||||
notifySessionTabsRemoved({ directory: sdk().directory, sessionIDs: [...removed] })
|
||||
return true
|
||||
}
|
||||
|
||||
function DeleteDialog(props: { sessionID: string }) {
|
||||
const name = createMemo(
|
||||
() => sessionTitle(sync().session.get(props.sessionID)?.title) ?? language.t("command.session.new"),
|
||||
)
|
||||
const confirm = async () => {
|
||||
await remove(props.sessionID)
|
||||
dialog.close()
|
||||
}
|
||||
if (settings.general.newLayoutDesigns())
|
||||
return (
|
||||
<DialogV2 fit>
|
||||
<DialogHeader hideClose>
|
||||
<DialogTitleGroup
|
||||
title={language.t("session.delete.title")}
|
||||
description={language.t("session.delete.confirm", { name: name() })}
|
||||
/>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<ButtonV2 variant="ghost" onClick={() => dialog.close()}>
|
||||
{language.t("common.cancel")}
|
||||
</ButtonV2>
|
||||
<ButtonV2 variant="danger" onClick={confirm}>
|
||||
{language.t("session.delete.button")}
|
||||
</ButtonV2>
|
||||
</DialogFooter>
|
||||
</DialogV2>
|
||||
)
|
||||
return (
|
||||
<Dialog title={language.t("session.delete.title")} fit>
|
||||
<div class="flex flex-col gap-4 pl-6 pr-2.5 pb-3">
|
||||
<div class="flex flex-col gap-1">
|
||||
<span class="text-14-regular text-text-strong">
|
||||
{language.t("session.delete.confirm", { name: name() })}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex justify-end gap-2">
|
||||
<Button variant="ghost" size="large" onClick={() => dialog.close()}>
|
||||
{language.t("common.cancel")}
|
||||
</Button>
|
||||
<Button variant="primary" size="large" onClick={confirm}>
|
||||
{language.t("session.delete.button")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
createEffect(
|
||||
on(
|
||||
() => [parentID(), childTaskDescription()] as const,
|
||||
([id, description]) => {
|
||||
if (!id || description || sync().data.message[id] !== undefined) return
|
||||
void sync().session.sync(id)
|
||||
},
|
||||
{ defer: true },
|
||||
),
|
||||
)
|
||||
|
||||
return {
|
||||
data: {
|
||||
sessionKey,
|
||||
sessionID,
|
||||
status,
|
||||
titleValue,
|
||||
titleLabel,
|
||||
shareUrl,
|
||||
shareEnabled,
|
||||
parentID,
|
||||
parentTitle,
|
||||
childTitle,
|
||||
showHeader,
|
||||
parts,
|
||||
part,
|
||||
projection,
|
||||
newLayoutDesigns: settings.general.newLayoutDesigns,
|
||||
showReasoningSummaries: settings.general.showReasoningSummaries,
|
||||
shellToolPartsExpanded: settings.general.shellToolPartsExpanded,
|
||||
editToolPartsExpanded: settings.general.editToolPartsExpanded,
|
||||
},
|
||||
pending: {
|
||||
rename: () => pending.rename,
|
||||
share: () => pending.share,
|
||||
unshare: () => pending.unshare,
|
||||
},
|
||||
action: {
|
||||
rename,
|
||||
share,
|
||||
unshare,
|
||||
archive,
|
||||
showDelete: (id: string) => dialog.show(() => <DeleteDialog sessionID={id} />),
|
||||
navigateParent: () => {
|
||||
const id = parentID()
|
||||
if (id) navigate(href(id))
|
||||
},
|
||||
viewShare: () => {
|
||||
const url = shareUrl()
|
||||
if (url) platform.openLink(url)
|
||||
},
|
||||
copyShareUrl: async () => {
|
||||
const url = shareUrl()
|
||||
if (!url) return
|
||||
await navigator.clipboard.writeText(url).then(
|
||||
() =>
|
||||
showToast({
|
||||
variant: "success",
|
||||
icon: "circle-check",
|
||||
title: language.t("session.share.copy.copied"),
|
||||
description: url,
|
||||
}),
|
||||
(error) => showToast({ title: language.t("common.requestFailed"), description: errorMessage(error) }),
|
||||
)
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export type TimelineController = ReturnType<typeof createTimelineController>
|
||||
@@ -11,10 +11,8 @@ import {
|
||||
type Accessor,
|
||||
type JSX,
|
||||
} from "solid-js"
|
||||
import { createStore, produce } from "solid-js/store"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { Dynamic } from "solid-js/web"
|
||||
import { useNavigate } from "@solidjs/router"
|
||||
import { useMutation } from "@tanstack/solid-query"
|
||||
import { createVirtualizer, defaultRangeExtractor, elementScroll, type VirtualItem } from "@tanstack/solid-virtual"
|
||||
import { Accordion } from "@opencode-ai/ui/accordion"
|
||||
import { Button } from "@opencode-ai/ui/button"
|
||||
@@ -35,8 +33,6 @@ import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
|
||||
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
|
||||
import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
|
||||
import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2"
|
||||
import { Dialog } from "@opencode-ai/ui/dialog"
|
||||
import { DialogFooter, DialogHeader, DialogTitleGroup, DialogV2 } from "@opencode-ai/ui/v2/dialog-v2"
|
||||
import { InlineInput } from "@opencode-ai/ui/inline-input"
|
||||
import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
|
||||
import { SessionRetry } from "@opencode-ai/session-ui/session-retry"
|
||||
@@ -45,43 +41,22 @@ import { StickyAccordionHeader } from "@opencode-ai/ui/sticky-accordion-header"
|
||||
import { TextField } from "@opencode-ai/ui/text-field"
|
||||
import { TextReveal } from "@opencode-ai/ui/text-reveal"
|
||||
import { TextShimmer } from "@opencode-ai/ui/text-shimmer"
|
||||
import type {
|
||||
AssistantMessage,
|
||||
Message as MessageType,
|
||||
Part as PartType,
|
||||
ToolPart,
|
||||
UserMessage,
|
||||
} from "@opencode-ai/sdk/v2"
|
||||
import { showToast } from "@/utils/toast"
|
||||
import type { AssistantMessage, ToolPart, UserMessage } from "@opencode-ai/sdk/v2"
|
||||
import { getDirectory, getFilename } from "@opencode-ai/core/util/path"
|
||||
import { Popover as KobaltePopover } from "@kobalte/core/popover"
|
||||
import { normalize } from "@opencode-ai/session-ui/session-diff"
|
||||
import { useFileComponent } from "@opencode-ai/ui/context/file"
|
||||
import { shouldMarkBoundaryGesture, normalizeWheelDelta } from "@/pages/session/message-gesture"
|
||||
import { SessionContextUsage } from "@/components/session-context-usage"
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { useSessionKey } from "@/pages/session/session-layout"
|
||||
import { useServerSDK } from "@/context/server-sdk"
|
||||
import { usePlatform } from "@/context/platform"
|
||||
import { useSettings } from "@/context/settings"
|
||||
import { useTabs } from "@/context/tabs"
|
||||
import { legacySessionHref, requireServerKey, sessionHref } from "@/utils/session-route"
|
||||
import { useSDK } from "@/context/sdk"
|
||||
import { useSync } from "@/context/sync"
|
||||
import { notifySessionTabsRemoved } from "@/components/titlebar-session-events"
|
||||
import { sessionTitle } from "@/utils/session-title"
|
||||
import { scheduleConnectedMeasure } from "./measure"
|
||||
import { observeElementOffsetReconnectAware } from "./observe-element-offset"
|
||||
import { createTimelineProjection } from "./projection"
|
||||
import { MessageComment, SummaryDiff, TimelineRow, TimelineRowMap } from "./rows"
|
||||
import { filterVirtualIndexes } from "./virtual-items"
|
||||
import { createTimelineController, type TimelineController } from "./controller"
|
||||
|
||||
const emptyMessages: MessageType[] = []
|
||||
const emptyParts: PartType[] = []
|
||||
const emptyTools: ToolPart[] = []
|
||||
const emptyAssistantMessages: AssistantMessage[] = []
|
||||
const idle = { type: "idle" as const }
|
||||
|
||||
type FramedTimelineRow = Exclude<TimelineRow.TimelineRow, { _tag: "TurnGap" }>
|
||||
type TimelineRowByTag<T extends TimelineRow.TimelineRow["_tag"]> = Extract<TimelineRow.TimelineRow, { _tag: T }>
|
||||
@@ -89,14 +64,6 @@ type TimelineRowByTag<T extends TimelineRow.TimelineRow["_tag"]> = Extract<Timel
|
||||
const timelineFallbackItemSize = 60
|
||||
const timelineCache = new Map<string, { measurements: VirtualItem[]; toolOpen: Record<string, boolean | undefined> }>()
|
||||
|
||||
const taskDescription = (part: PartType, sessionID: string) => {
|
||||
if (part.type !== "tool" || part.tool !== "task") return
|
||||
const metadata = "metadata" in part.state ? part.state.metadata : undefined
|
||||
if (metadata?.sessionId !== sessionID) return
|
||||
const value = part.state.input?.description
|
||||
if (typeof value === "string" && value) return value
|
||||
}
|
||||
|
||||
const boundaryTarget = (root: HTMLElement, target: EventTarget | null) => {
|
||||
const current = target instanceof Element ? target : undefined
|
||||
const nested = current?.closest("[data-scrollable]")
|
||||
@@ -237,7 +204,7 @@ function TimelineDiffView(props: { diff: SummaryDiff }) {
|
||||
)
|
||||
}
|
||||
|
||||
export function MessageTimeline(props: {
|
||||
type MessageTimelineProps = {
|
||||
actions?: UserActions
|
||||
scroll: { overflow: boolean; bottom: boolean; jump: boolean }
|
||||
onResumeScroll: () => void
|
||||
@@ -257,88 +224,42 @@ export function MessageTimeline(props: {
|
||||
setRevealMessage?: (fn: (id: string) => void) => void
|
||||
setScrollToEnd?: (fn: () => void) => void
|
||||
setHistoryAnchor?: (handlers: { capture: () => void; restore: (done: boolean) => void }) => void
|
||||
}) {
|
||||
let touchGesture: number | undefined
|
||||
}
|
||||
|
||||
const navigate = useNavigate()
|
||||
const serverSDK = useServerSDK()
|
||||
const sdk = useSDK()
|
||||
const sync = useSync()
|
||||
const settings = useSettings()
|
||||
const tabs = useTabs()
|
||||
const dialog = useDialog()
|
||||
export function MessageTimeline(props: MessageTimelineProps) {
|
||||
const controller = createTimelineController({ userMessages: () => props.userMessages })
|
||||
return (
|
||||
<MessageTimelineView {...props} data={controller.data} action={controller.action} pending={controller.pending} />
|
||||
)
|
||||
}
|
||||
|
||||
function MessageTimelineView(
|
||||
props: MessageTimelineProps & {
|
||||
data: TimelineController["data"]
|
||||
action: TimelineController["action"]
|
||||
pending: TimelineController["pending"]
|
||||
},
|
||||
) {
|
||||
let touchGesture: number | undefined
|
||||
const language = useLanguage()
|
||||
const { params, sessionKey } = useSessionKey()
|
||||
const ownerSessionKey = sessionKey()
|
||||
const ownerSessionKey = props.data.sessionKey()
|
||||
const cached = timelineCache.get(ownerSessionKey)
|
||||
const initialMeasurements = cached?.measurements
|
||||
const coldBottomMount = !initialMeasurements?.length && props.shouldAnchorBottom()
|
||||
const platform = usePlatform()
|
||||
|
||||
const [listRoot, setListRoot] = createSignal<HTMLDivElement>()
|
||||
const sessionID = createMemo(() => params.id)
|
||||
const sessionStatus = createMemo(() => {
|
||||
const id = sessionID()
|
||||
if (!id) return idle
|
||||
return sync().data.session_status[id] ?? idle
|
||||
})
|
||||
const sessionMessages = createMemo(() => (sessionID() ? (sync().data.message[sessionID()!] ?? []) : []))
|
||||
const projectedMessages = createMemo(() => {
|
||||
const id = sessionID()
|
||||
if (!id) return []
|
||||
const visible = new Set(props.userMessages.map((message) => message.id))
|
||||
const boundary = sessionMessages().find((message) => message.role === "user" && !visible.has(message.id))?.id
|
||||
const messages = sync().data.session_message[id] ?? []
|
||||
return boundary ? messages.filter((message) => message.id < boundary) : messages
|
||||
})
|
||||
const info = createMemo(() => {
|
||||
const id = sessionID()
|
||||
if (!id) return
|
||||
return sync().session.get(id)
|
||||
})
|
||||
const titleValue = createMemo(() => info()?.title)
|
||||
const titleLabel = createMemo(() => sessionTitle(titleValue()))
|
||||
const shareUrl = createMemo(() => info()?.share?.url)
|
||||
const shareEnabled = createMemo(() => sync().data.config.share !== "disabled")
|
||||
const parentID = createMemo(() => info()?.parentID)
|
||||
const parent = createMemo(() => {
|
||||
const id = parentID()
|
||||
if (!id) return
|
||||
return sync().session.get(id)
|
||||
})
|
||||
const parentMessages = createMemo(() => {
|
||||
const id = parentID()
|
||||
if (!id) return emptyMessages
|
||||
return sync().data.message[id] ?? emptyMessages
|
||||
})
|
||||
const parentTitle = createMemo(() => sessionTitle(parent()?.title) ?? language.t("command.session.new"))
|
||||
const getMsgParts = (msgId: string) => sync().data.part[msgId] ?? emptyParts
|
||||
const getMsgPart = (messageID: string, partID: string) => getMsgParts(messageID).find((part) => part.id === partID)
|
||||
const childTaskDescription = createMemo(() => {
|
||||
const id = sessionID()
|
||||
if (!id) return
|
||||
return parentMessages()
|
||||
.flatMap((message) => getMsgParts(message.id))
|
||||
.map((part) => taskDescription(part, id))
|
||||
.findLast((value): value is string => !!value)
|
||||
})
|
||||
const childTitle = createMemo(() => {
|
||||
if (!parentID()) return titleLabel() ?? ""
|
||||
if (childTaskDescription()) return childTaskDescription()
|
||||
const value = titleLabel()?.replace(/\s+\(@[^)]+ subagent\)$/, "")
|
||||
if (value) return value
|
||||
return language.t("command.session.new")
|
||||
})
|
||||
const showHeader = createMemo(() => !!(titleValue() || parentID()))
|
||||
const projection = createTimelineProjection({
|
||||
messages: sessionMessages,
|
||||
userMessages: () => props.userMessages,
|
||||
sessionMessages: projectedMessages,
|
||||
parts: getMsgParts,
|
||||
status: sessionStatus,
|
||||
showReasoningSummaries: settings.general.showReasoningSummaries,
|
||||
inlineComments: settings.general.newLayoutDesigns,
|
||||
})
|
||||
const sessionID = props.data.sessionID
|
||||
const sessionStatus = props.data.status
|
||||
const titleLabel = props.data.titleLabel
|
||||
const shareUrl = props.data.shareUrl
|
||||
const shareEnabled = props.data.shareEnabled
|
||||
const parentID = props.data.parentID
|
||||
const parentTitle = props.data.parentTitle
|
||||
const childTitle = props.data.childTitle
|
||||
const showHeader = props.data.showHeader
|
||||
const getMsgParts = props.data.parts
|
||||
const getMsgPart = props.data.part
|
||||
const projection = props.data.projection
|
||||
const activeMessageID = projection.activeMessageID
|
||||
const assistantMessagesByParent = projection.assistantMessagesByParent
|
||||
const lastAssistantGroupKey = projection.lastAssistantGroupKey
|
||||
@@ -528,9 +449,9 @@ export function MessageTimeline(props: {
|
||||
virtualizer.scrollToEnd()
|
||||
}
|
||||
|
||||
let measuredSessionKey = sessionKey()
|
||||
let measuredSessionKey = props.data.sessionKey()
|
||||
createEffect(() => {
|
||||
const key = sessionKey()
|
||||
const key = props.data.sessionKey()
|
||||
timelineRows().length
|
||||
if (measuredSessionKey !== key) {
|
||||
measuredSessionKey = key
|
||||
@@ -643,88 +564,6 @@ export function MessageTimeline(props: {
|
||||
props.setScrollRef(undefined)
|
||||
})
|
||||
|
||||
const viewShare = () => {
|
||||
const url = shareUrl()
|
||||
if (!url) return
|
||||
platform.openLink(url)
|
||||
}
|
||||
|
||||
const errorMessage = (err: unknown) => {
|
||||
if (err && typeof err === "object" && "data" in err) {
|
||||
const data = (err as { data?: { message?: string } }).data
|
||||
if (data?.message) return data.message
|
||||
}
|
||||
if (err instanceof Error) return err.message
|
||||
return language.t("common.requestFailed")
|
||||
}
|
||||
|
||||
const shareMutation = useMutation(() => ({
|
||||
mutationFn: (id: string) => serverSDK().client.session.share({ sessionID: id }),
|
||||
onError: (err) => {
|
||||
console.error("Failed to share session", err)
|
||||
},
|
||||
}))
|
||||
|
||||
const unshareMutation = useMutation(() => ({
|
||||
mutationFn: (id: string) => serverSDK().client.session.unshare({ sessionID: id }),
|
||||
onError: (err) => {
|
||||
console.error("Failed to unshare session", err)
|
||||
},
|
||||
}))
|
||||
|
||||
const titleMutation = useMutation(() => ({
|
||||
mutationFn: (input: { id: string; title: string }) =>
|
||||
sdk().api.session.rename({ sessionID: input.id, title: input.title }),
|
||||
onSuccess: (_, input) => {
|
||||
sync().set(
|
||||
produce((draft) => {
|
||||
const index = draft.session.findIndex((s) => s.id === input.id)
|
||||
if (index !== -1) draft.session[index].title = input.title
|
||||
}),
|
||||
)
|
||||
setTitle("editing", false)
|
||||
},
|
||||
onError: (err) => {
|
||||
showToast({
|
||||
title: language.t("common.requestFailed"),
|
||||
description: errorMessage(err),
|
||||
})
|
||||
},
|
||||
}))
|
||||
|
||||
const shareSession = () => {
|
||||
const id = sessionID()
|
||||
if (!id || shareMutation.isPending) return
|
||||
if (!shareEnabled()) return
|
||||
shareMutation.mutate(id)
|
||||
}
|
||||
|
||||
const unshareSession = () => {
|
||||
const id = sessionID()
|
||||
if (!id || unshareMutation.isPending) return
|
||||
if (!shareEnabled()) return
|
||||
unshareMutation.mutate(id)
|
||||
}
|
||||
const copyShareUrl = () => {
|
||||
const url = shareUrl()
|
||||
if (!url) return
|
||||
void navigator.clipboard
|
||||
.writeText(url)
|
||||
.then(() =>
|
||||
showToast({
|
||||
variant: "success",
|
||||
icon: "circle-check",
|
||||
title: language.t("session.share.copy.copied"),
|
||||
description: url,
|
||||
}),
|
||||
)
|
||||
.catch((err: unknown) =>
|
||||
showToast({
|
||||
title: language.t("common.requestFailed"),
|
||||
description: errorMessage(err),
|
||||
}),
|
||||
)
|
||||
}
|
||||
const selectShareUrlText: JSX.EventHandler<HTMLDivElement, MouseEvent> = (event) => {
|
||||
const selection = window.getSelection()
|
||||
if (!selection) return
|
||||
@@ -736,7 +575,7 @@ export function MessageTimeline(props: {
|
||||
|
||||
createEffect(
|
||||
on(
|
||||
sessionKey,
|
||||
props.data.sessionKey,
|
||||
() =>
|
||||
setTitle({
|
||||
draft: "",
|
||||
@@ -749,18 +588,6 @@ export function MessageTimeline(props: {
|
||||
),
|
||||
)
|
||||
|
||||
createEffect(
|
||||
on(
|
||||
() => [parentID(), childTaskDescription()] as const,
|
||||
([id, description]) => {
|
||||
if (!id || description) return
|
||||
if (sync().data.message[id] !== undefined) return
|
||||
void sync().session.sync(id)
|
||||
},
|
||||
{ defer: true },
|
||||
),
|
||||
)
|
||||
|
||||
const openTitleEditor = () => {
|
||||
if (!sessionID() || parentID()) return
|
||||
setTitle({ editing: true, draft: titleLabel() ?? "" })
|
||||
@@ -772,193 +599,12 @@ export function MessageTimeline(props: {
|
||||
}
|
||||
|
||||
const closeTitleEditor = () => {
|
||||
if (titleMutation.isPending) return
|
||||
if (props.pending.rename()) return
|
||||
setTitle("editing", false)
|
||||
}
|
||||
|
||||
const saveTitleEditor = () => {
|
||||
const id = sessionID()
|
||||
if (!id) return
|
||||
if (titleMutation.isPending) return
|
||||
|
||||
const next = title.draft.trim()
|
||||
if (!next || next === (titleLabel() ?? "")) {
|
||||
setTitle("editing", false)
|
||||
return
|
||||
}
|
||||
|
||||
titleMutation.mutate({ id, title: next })
|
||||
}
|
||||
|
||||
const navigateAfterSessionRemoval = (sessionID: string, parentID?: string, nextSessionID?: string) => {
|
||||
if (params.id !== sessionID) return
|
||||
const href = (id: string) =>
|
||||
params.serverKey ? sessionHref(requireServerKey(params.serverKey), id) : legacySessionHref(sdk().directory, id)
|
||||
if (parentID) {
|
||||
navigate(href(parentID))
|
||||
return
|
||||
}
|
||||
if (nextSessionID) {
|
||||
navigate(href(nextSessionID))
|
||||
return
|
||||
}
|
||||
if (params.serverKey) {
|
||||
tabs.newDraft({ server: requireServerKey(params.serverKey), directory: sdk().directory })
|
||||
return
|
||||
}
|
||||
navigate(`/${params.dir}/session`)
|
||||
}
|
||||
|
||||
const archiveSession = async (sessionID: string) => {
|
||||
const session = sync().session.get(sessionID)
|
||||
if (!session) return
|
||||
if ((await sdk().protocol) !== "v1") return
|
||||
|
||||
const sessions = sync().data.session ?? []
|
||||
const index = sessions.findIndex((s) => s.id === sessionID)
|
||||
const nextSession = index === -1 ? undefined : (sessions[index + 1] ?? sessions[index - 1])
|
||||
|
||||
await sdk()
|
||||
.client.session.update({ sessionID, directory: sdk().directory, time: { archived: Date.now() } })
|
||||
.then(() => {
|
||||
sync().set(
|
||||
produce((draft) => {
|
||||
const index = draft.session.findIndex((s) => s.id === sessionID)
|
||||
if (index !== -1) draft.session.splice(index, 1)
|
||||
}),
|
||||
)
|
||||
sync().session.evict(sessionID)
|
||||
navigateAfterSessionRemoval(sessionID, session.parentID, nextSession?.id)
|
||||
notifySessionTabsRemoved({ directory: sdk().directory, sessionIDs: [sessionID] })
|
||||
})
|
||||
.catch((err) => {
|
||||
showToast({
|
||||
title: language.t("common.requestFailed"),
|
||||
description: errorMessage(err),
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
const deleteSession = async (sessionID: string) => {
|
||||
const session = sync().session.get(sessionID)
|
||||
if (!session) return false
|
||||
|
||||
const sessions = (sync().data.session ?? []).filter((s) => !s.parentID && !s.time?.archived)
|
||||
const index = sessions.findIndex((s) => s.id === sessionID)
|
||||
const nextSession = index === -1 ? undefined : (sessions[index + 1] ?? sessions[index - 1])
|
||||
|
||||
const result = await sdk()
|
||||
.api.session.remove({ sessionID })
|
||||
.then(() => true)
|
||||
.catch((err) => {
|
||||
showToast({
|
||||
title: language.t("session.delete.failed.title"),
|
||||
description: errorMessage(err),
|
||||
})
|
||||
return false
|
||||
})
|
||||
|
||||
if (!result) return false
|
||||
|
||||
const removed = new Set<string>([sessionID])
|
||||
const byParent = new Map<string, string[]>()
|
||||
for (const item of sync().data.session) {
|
||||
const parentID = item.parentID
|
||||
if (!parentID) continue
|
||||
const existing = byParent.get(parentID)
|
||||
if (existing) {
|
||||
existing.push(item.id)
|
||||
continue
|
||||
}
|
||||
byParent.set(parentID, [item.id])
|
||||
}
|
||||
|
||||
const stack = [sessionID]
|
||||
while (stack.length) {
|
||||
const parentID = stack.pop()
|
||||
if (!parentID) continue
|
||||
|
||||
const children = byParent.get(parentID)
|
||||
if (!children) continue
|
||||
|
||||
for (const child of children) {
|
||||
if (removed.has(child)) continue
|
||||
removed.add(child)
|
||||
stack.push(child)
|
||||
}
|
||||
}
|
||||
|
||||
navigateAfterSessionRemoval(sessionID, session.parentID, nextSession?.id)
|
||||
|
||||
sync().set(
|
||||
produce((draft) => {
|
||||
draft.session = draft.session.filter((s) => !removed.has(s.id))
|
||||
}),
|
||||
)
|
||||
|
||||
for (const id of removed) {
|
||||
sync().session.evict(id)
|
||||
}
|
||||
notifySessionTabsRemoved({ directory: sdk().directory, sessionIDs: [...removed] })
|
||||
return true
|
||||
}
|
||||
|
||||
const navigateParent = () => {
|
||||
const id = parentID()
|
||||
if (!id) return
|
||||
navigate(
|
||||
params.serverKey ? sessionHref(requireServerKey(params.serverKey), id) : legacySessionHref(sdk().directory, id),
|
||||
)
|
||||
}
|
||||
|
||||
function DialogDeleteSession(props: { sessionID: string }) {
|
||||
const name = createMemo(
|
||||
() => sessionTitle(sync().session.get(props.sessionID)?.title) ?? language.t("command.session.new"),
|
||||
)
|
||||
const handleDelete = async () => {
|
||||
await deleteSession(props.sessionID)
|
||||
dialog.close()
|
||||
}
|
||||
|
||||
if (settings.general.newLayoutDesigns())
|
||||
return (
|
||||
<DialogV2 fit>
|
||||
<DialogHeader hideClose>
|
||||
<DialogTitleGroup
|
||||
title={language.t("session.delete.title")}
|
||||
description={language.t("session.delete.confirm", { name: name() })}
|
||||
/>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<ButtonV2 variant="ghost" onClick={() => dialog.close()}>
|
||||
{language.t("common.cancel")}
|
||||
</ButtonV2>
|
||||
<ButtonV2 variant="danger" onClick={handleDelete}>
|
||||
{language.t("session.delete.button")}
|
||||
</ButtonV2>
|
||||
</DialogFooter>
|
||||
</DialogV2>
|
||||
)
|
||||
|
||||
return (
|
||||
<Dialog title={language.t("session.delete.title")} fit>
|
||||
<div class="flex flex-col gap-4 pl-6 pr-2.5 pb-3">
|
||||
<div class="flex flex-col gap-1">
|
||||
<span class="text-14-regular text-text-strong">
|
||||
{language.t("session.delete.confirm", { name: name() })}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex justify-end gap-2">
|
||||
<Button variant="ghost" size="large" onClick={() => dialog.close()}>
|
||||
{language.t("common.cancel")}
|
||||
</Button>
|
||||
<Button variant="primary" size="large" onClick={handleDelete}>
|
||||
{language.t("session.delete.button")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
)
|
||||
const saveTitleEditor = async () => {
|
||||
if (await props.action.rename(title.draft)) setTitle("editing", false)
|
||||
}
|
||||
|
||||
const workingTurn = (userMessageID: string) => sessionStatus().type !== "idle" && activeMessageID() === userMessageID
|
||||
@@ -1037,7 +683,7 @@ export function MessageTimeline(props: {
|
||||
const defaultOpen = createMemo(() => {
|
||||
const item = part()
|
||||
if (!item) return
|
||||
return partDefaultOpen(item, settings.general.shellToolPartsExpanded(), settings.general.editToolPartsExpanded())
|
||||
return partDefaultOpen(item, props.data.shellToolPartsExpanded(), props.data.editToolPartsExpanded())
|
||||
})
|
||||
|
||||
return (
|
||||
@@ -1050,7 +696,7 @@ export function MessageTimeline(props: {
|
||||
message={message()}
|
||||
showAssistantCopyPartID={assistantCopyPartID(row().userMessageID)}
|
||||
turnDurationMs={turnDurationMs(row().userMessageID)}
|
||||
useV2Actions={settings.general.newLayoutDesigns()}
|
||||
useV2Actions={props.data.newLayoutDesigns()}
|
||||
defaultOpen={defaultOpen()}
|
||||
toolOpen={toolOpen[part().id] ?? defaultOpen()}
|
||||
onToolOpenChange={(open) => setToolOpen(part().id, open)}
|
||||
@@ -1113,8 +759,8 @@ export function MessageTimeline(props: {
|
||||
<div
|
||||
classList={{
|
||||
"shrink-0 max-w-[260px] rounded-[6px] border-border-weak-base bg-background-stronger px-2.5 py-2": true,
|
||||
"border-[0.5px]": settings.general.newLayoutDesigns(),
|
||||
border: !settings.general.newLayoutDesigns(),
|
||||
"border-[0.5px]": props.data.newLayoutDesigns(),
|
||||
border: !props.data.newLayoutDesigns(),
|
||||
}}
|
||||
>
|
||||
<div class="flex items-center gap-1.5 min-w-0 text-11-medium text-text-strong">
|
||||
@@ -1149,7 +795,7 @@ export function MessageTimeline(props: {
|
||||
if (m?.role === "user") return m
|
||||
})
|
||||
const messageComments = createMemo(() => {
|
||||
if (!settings.general.newLayoutDesigns()) return []
|
||||
if (!props.data.newLayoutDesigns()) return []
|
||||
return getMsgParts(userMessageRow().userMessageID).flatMap((part) => MessageComment.fromPart(part) ?? [])
|
||||
})
|
||||
return (
|
||||
@@ -1162,7 +808,7 @@ export function MessageTimeline(props: {
|
||||
message={message()}
|
||||
parts={getMsgParts(userMessageRow().userMessageID)}
|
||||
actions={props.actions}
|
||||
useV2Actions={settings.general.newLayoutDesigns()}
|
||||
useV2Actions={props.data.newLayoutDesigns()}
|
||||
comments={messageComments()}
|
||||
/>
|
||||
</div>
|
||||
@@ -1210,7 +856,7 @@ export function MessageTimeline(props: {
|
||||
<div data-slot="session-turn-message-container" class="w-full px-4 md:px-5">
|
||||
<TimelineThinkingRow
|
||||
reasoningHeading={thinkingRow().reasoningHeading}
|
||||
showReasoningSummaries={settings.general.showReasoningSummaries()}
|
||||
showReasoningSummaries={props.data.showReasoningSummaries()}
|
||||
/>
|
||||
</div>
|
||||
</TimelineRowFrame>
|
||||
@@ -1326,16 +972,16 @@ export function MessageTimeline(props: {
|
||||
<div
|
||||
class="absolute left-1/2 -translate-x-1/2 z-[60] pointer-events-none transition-all duration-200 ease-out"
|
||||
classList={{
|
||||
"bottom-8": settings.general.newLayoutDesigns(),
|
||||
"bottom-6": !settings.general.newLayoutDesigns(),
|
||||
"bottom-8": props.data.newLayoutDesigns(),
|
||||
"bottom-6": !props.data.newLayoutDesigns(),
|
||||
"opacity-100 translate-y-0 scale-100": props.scroll.overflow && props.scroll.jump,
|
||||
"opacity-0 translate-y-2 pointer-events-none": !props.scroll.overflow || !props.scroll.jump,
|
||||
"scale-[0.8]": (!props.scroll.overflow || !props.scroll.jump) && settings.general.newLayoutDesigns(),
|
||||
"scale-95": (!props.scroll.overflow || !props.scroll.jump) && !settings.general.newLayoutDesigns(),
|
||||
"scale-[0.8]": (!props.scroll.overflow || !props.scroll.jump) && props.data.newLayoutDesigns(),
|
||||
"scale-95": (!props.scroll.overflow || !props.scroll.jump) && !props.data.newLayoutDesigns(),
|
||||
}}
|
||||
>
|
||||
<Show
|
||||
when={settings.general.newLayoutDesigns()}
|
||||
when={props.data.newLayoutDesigns()}
|
||||
fallback={
|
||||
<button
|
||||
type="button"
|
||||
@@ -1398,22 +1044,22 @@ export function MessageTimeline(props: {
|
||||
classList={{
|
||||
"sticky top-0 z-30": true,
|
||||
"bg-[linear-gradient(to_bottom,var(--v2-background-bg-base)_48px,transparent)]":
|
||||
settings.general.newLayoutDesigns(),
|
||||
props.data.newLayoutDesigns(),
|
||||
"bg-[linear-gradient(to_bottom,var(--background-stronger)_48px,transparent)]":
|
||||
!settings.general.newLayoutDesigns(),
|
||||
!props.data.newLayoutDesigns(),
|
||||
"w-full": true,
|
||||
"pb-4": true,
|
||||
"pr-3": true,
|
||||
"pl-2.5": settings.general.newLayoutDesigns(),
|
||||
"pl-2 md:pl-4": !settings.general.newLayoutDesigns(),
|
||||
"md:max-w-200 md:mx-auto 2xl:max-w-[1000px]": props.centered && !settings.general.newLayoutDesigns(),
|
||||
"pl-2.5": props.data.newLayoutDesigns(),
|
||||
"pl-2 md:pl-4": !props.data.newLayoutDesigns(),
|
||||
"md:max-w-200 md:mx-auto 2xl:max-w-[1000px]": props.centered && !props.data.newLayoutDesigns(),
|
||||
}}
|
||||
>
|
||||
<div class="h-12 w-full flex items-center justify-between gap-2">
|
||||
<div
|
||||
classList={{
|
||||
"flex items-center gap-1 min-w-0 flex-1": true,
|
||||
"pr-3": !settings.general.newLayoutDesigns(),
|
||||
"pr-3": !props.data.newLayoutDesigns(),
|
||||
}}
|
||||
>
|
||||
<div class="flex items-center min-w-0 flex-1 w-full">
|
||||
@@ -1422,7 +1068,7 @@ export function MessageTimeline(props: {
|
||||
type="button"
|
||||
data-slot="session-title-parent"
|
||||
class="min-w-0 max-w-[40%] truncate pl-2 text-[13px] font-[530] leading-4 tracking-[-0.04px] text-v2-text-text-faint transition-colors hover:text-v2-text-text-muted"
|
||||
onClick={navigateParent}
|
||||
onClick={props.action.navigateParent}
|
||||
>
|
||||
{parentTitle()}
|
||||
</button>
|
||||
@@ -1443,8 +1089,8 @@ export function MessageTimeline(props: {
|
||||
classList={{
|
||||
"truncate text-[13px] font-[530] leading-4 tracking-[-0.04px] text-v2-text-text-base": true,
|
||||
"w-fit rounded-[6px] px-2 py-1 hover:bg-v2-overlay-simple-overlay-hover":
|
||||
settings.general.newLayoutDesigns(),
|
||||
"grow-1 min-w-0": !settings.general.newLayoutDesigns(),
|
||||
props.data.newLayoutDesigns(),
|
||||
"grow-1 min-w-0": !props.data.newLayoutDesigns(),
|
||||
}}
|
||||
onClick={openTitleEditor}
|
||||
>
|
||||
@@ -1458,15 +1104,14 @@ export function MessageTimeline(props: {
|
||||
}}
|
||||
data-slot="session-title-child"
|
||||
value={title.draft}
|
||||
disabled={titleMutation.isPending}
|
||||
disabled={props.pending.rename()}
|
||||
classList={{
|
||||
"block text-[13px] font-[530] leading-4 tracking-[-0.04px] text-v2-text-text-base": true,
|
||||
"w-full flex-1 grow-1 min-w-0 pl-1 -ml-1 rounded-[6px]": !settings.general.newLayoutDesigns(),
|
||||
"field-sizing-content self-start rounded-[6px] px-2 py-1 ":
|
||||
settings.general.newLayoutDesigns(),
|
||||
"w-full flex-1 grow-1 min-w-0 pl-1 -ml-1 rounded-[6px]": !props.data.newLayoutDesigns(),
|
||||
"field-sizing-content self-start rounded-[6px] px-2 py-1 ": props.data.newLayoutDesigns(),
|
||||
}}
|
||||
style={{
|
||||
"--inline-input-shadow": settings.general.newLayoutDesigns()
|
||||
"--inline-input-shadow": props.data.newLayoutDesigns()
|
||||
? "none"
|
||||
: "var(--shadow-xs-border-select)",
|
||||
}}
|
||||
@@ -1494,17 +1139,17 @@ export function MessageTimeline(props: {
|
||||
<div
|
||||
classList={{
|
||||
"shrink-0 flex items-center": true,
|
||||
"gap-2": settings.general.newLayoutDesigns(),
|
||||
"gap-3": !settings.general.newLayoutDesigns(),
|
||||
"gap-2": props.data.newLayoutDesigns(),
|
||||
"gap-3": !props.data.newLayoutDesigns(),
|
||||
}}
|
||||
>
|
||||
<SessionContextUsage
|
||||
placement="bottom"
|
||||
buttonAppearance={settings.general.newLayoutDesigns() ? "v2" : "default"}
|
||||
buttonAppearance={props.data.newLayoutDesigns() ? "v2" : "default"}
|
||||
/>
|
||||
<Show when={!parentID()}>
|
||||
<Show
|
||||
when={settings.general.newLayoutDesigns()}
|
||||
when={props.data.newLayoutDesigns()}
|
||||
fallback={
|
||||
<DropdownMenu
|
||||
gutter={4}
|
||||
@@ -1567,13 +1212,11 @@ export function MessageTimeline(props: {
|
||||
</DropdownMenu.ItemLabel>
|
||||
</DropdownMenu.Item>
|
||||
</Show>
|
||||
<DropdownMenu.Item onSelect={() => void archiveSession(id)}>
|
||||
<DropdownMenu.Item onSelect={() => void props.action.archive(id)}>
|
||||
<DropdownMenu.ItemLabel>{language.t("common.archive")}</DropdownMenu.ItemLabel>
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Separator />
|
||||
<DropdownMenu.Item
|
||||
onSelect={() => dialog.show(() => <DialogDeleteSession sessionID={id} />)}
|
||||
>
|
||||
<DropdownMenu.Item onSelect={() => props.action.showDelete(id)}>
|
||||
<DropdownMenu.ItemLabel>{language.t("common.delete")}</DropdownMenu.ItemLabel>
|
||||
</DropdownMenu.Item>
|
||||
</DropdownMenu.Content>
|
||||
@@ -1638,11 +1281,11 @@ export function MessageTimeline(props: {
|
||||
{language.t("session.share.action.share")}...
|
||||
</MenuV2.Item>
|
||||
</Show>
|
||||
<MenuV2.Item onSelect={() => void archiveSession(id)}>
|
||||
<MenuV2.Item onSelect={() => void props.action.archive(id)}>
|
||||
{language.t("common.archive")}
|
||||
</MenuV2.Item>
|
||||
<MenuV2.Separator />
|
||||
<MenuV2.Item onSelect={() => dialog.show(() => <DialogDeleteSession sessionID={id} />)}>
|
||||
<MenuV2.Item onSelect={() => props.action.showDelete(id)}>
|
||||
{language.t("common.delete")}...
|
||||
</MenuV2.Item>
|
||||
</MenuV2.Content>
|
||||
@@ -1654,7 +1297,7 @@ export function MessageTimeline(props: {
|
||||
open={share.open}
|
||||
anchorRef={() => more}
|
||||
placement="bottom-end"
|
||||
gutter={settings.general.newLayoutDesigns() ? 6 : 4}
|
||||
gutter={props.data.newLayoutDesigns() ? 6 : 4}
|
||||
modal={false}
|
||||
onOpenChange={(open) => {
|
||||
if (open) setShare("dismiss", null)
|
||||
@@ -1666,7 +1309,7 @@ export function MessageTimeline(props: {
|
||||
data-component="popover-content"
|
||||
classList={{
|
||||
"flex w-80 max-w-none flex-col items-start gap-3 rounded-[10px] border-0 bg-v2-background-bg-layer-01 p-3 shadow-[var(--v2-elevation-floating)]":
|
||||
settings.general.newLayoutDesigns(),
|
||||
props.data.newLayoutDesigns(),
|
||||
}}
|
||||
style={{ "min-width": "320px" }}
|
||||
onEscapeKeyDown={(event) => {
|
||||
@@ -1686,7 +1329,7 @@ export function MessageTimeline(props: {
|
||||
}}
|
||||
>
|
||||
<Show
|
||||
when={settings.general.newLayoutDesigns()}
|
||||
when={props.data.newLayoutDesigns()}
|
||||
fallback={
|
||||
<div class="flex flex-col p-3">
|
||||
<div class="flex flex-col gap-1">
|
||||
@@ -1707,10 +1350,10 @@ export function MessageTimeline(props: {
|
||||
size="large"
|
||||
variant="primary"
|
||||
class="w-full"
|
||||
onClick={shareSession}
|
||||
disabled={shareMutation.isPending}
|
||||
onClick={() => void props.action.share()}
|
||||
disabled={props.pending.share()}
|
||||
>
|
||||
{shareMutation.isPending
|
||||
{props.pending.share()
|
||||
? language.t("session.share.action.publishing")
|
||||
: language.t("session.share.action.publish")}
|
||||
</Button>
|
||||
@@ -1730,10 +1373,10 @@ export function MessageTimeline(props: {
|
||||
size="large"
|
||||
variant="secondary"
|
||||
class="w-full shadow-none border border-border-weak-base"
|
||||
onClick={unshareSession}
|
||||
disabled={unshareMutation.isPending}
|
||||
onClick={() => void props.action.unshare()}
|
||||
disabled={props.pending.unshare()}
|
||||
>
|
||||
{unshareMutation.isPending
|
||||
{props.pending.unshare()
|
||||
? language.t("session.share.action.unpublishing")
|
||||
: language.t("session.share.action.unpublish")}
|
||||
</Button>
|
||||
@@ -1741,8 +1384,8 @@ export function MessageTimeline(props: {
|
||||
size="large"
|
||||
variant="primary"
|
||||
class="w-full"
|
||||
onClick={viewShare}
|
||||
disabled={unshareMutation.isPending}
|
||||
onClick={props.action.viewShare}
|
||||
disabled={props.pending.unshare()}
|
||||
>
|
||||
{language.t("session.share.action.view")}
|
||||
</Button>
|
||||
@@ -1770,10 +1413,10 @@ export function MessageTimeline(props: {
|
||||
<ButtonV2
|
||||
variant="contrast"
|
||||
class="w-full"
|
||||
onClick={shareSession}
|
||||
disabled={shareMutation.isPending}
|
||||
onClick={() => void props.action.share()}
|
||||
disabled={props.pending.share()}
|
||||
>
|
||||
{shareMutation.isPending
|
||||
{props.pending.share()
|
||||
? language.t("session.share.action.publishing")
|
||||
: language.t("session.share.action.publish")}
|
||||
</ButtonV2>
|
||||
@@ -1799,7 +1442,7 @@ export function MessageTimeline(props: {
|
||||
variant="ghost-muted"
|
||||
icon={<IconV2 name="outline-copy" />}
|
||||
aria-label={language.t("session.share.copy.copyLink")}
|
||||
onClick={copyShareUrl}
|
||||
onClick={() => void props.action.copyShareUrl()}
|
||||
/>
|
||||
<IconButtonV2
|
||||
type="button"
|
||||
@@ -1807,18 +1450,18 @@ export function MessageTimeline(props: {
|
||||
variant="ghost-muted"
|
||||
icon={<IconV2 name="outline-square-arrow" />}
|
||||
aria-label={language.t("session.share.action.view")}
|
||||
onClick={viewShare}
|
||||
disabled={unshareMutation.isPending}
|
||||
onClick={props.action.viewShare}
|
||||
disabled={props.pending.unshare()}
|
||||
/>
|
||||
</div>
|
||||
<div class="flex w-full">
|
||||
<ButtonV2
|
||||
variant="outline"
|
||||
class="w-full"
|
||||
onClick={unshareSession}
|
||||
disabled={unshareMutation.isPending}
|
||||
onClick={() => void props.action.unshare()}
|
||||
disabled={props.pending.unshare()}
|
||||
>
|
||||
{unshareMutation.isPending
|
||||
{props.pending.unshare()
|
||||
? language.t("session.share.action.unpublishing")
|
||||
: language.t("session.share.action.unpublish")}
|
||||
</ButtonV2>
|
||||
|
||||
@@ -56,6 +56,16 @@
|
||||
"@lydell/node-pty-linux-x64": "1.2.0-beta.12",
|
||||
"@lydell/node-pty-win32-arm64": "1.2.0-beta.12",
|
||||
"@lydell/node-pty-win32-x64": "1.2.0-beta.12",
|
||||
"@ff-labs/fff-bin-darwin-arm64": "0.10.1",
|
||||
"@ff-labs/fff-bin-linux-arm64-gnu": "0.10.1",
|
||||
"@ff-labs/fff-bin-linux-x64-gnu": "0.10.1",
|
||||
"@ff-labs/fff-bin-win32-arm64": "0.10.1",
|
||||
"@ff-labs/fff-bin-win32-x64": "0.10.1",
|
||||
"@yuuang/ffi-rs-darwin-arm64": "1.3.2",
|
||||
"@yuuang/ffi-rs-linux-arm64-gnu": "1.3.2",
|
||||
"@yuuang/ffi-rs-linux-x64-gnu": "1.3.2",
|
||||
"@yuuang/ffi-rs-win32-arm64-msvc": "1.3.2",
|
||||
"@yuuang/ffi-rs-win32-x64-msvc": "1.3.2",
|
||||
"@parcel/watcher-darwin-arm64": "2.5.1",
|
||||
"@parcel/watcher-linux-arm64-glibc": "2.5.1",
|
||||
"@parcel/watcher-linux-x64-glibc": "2.5.1",
|
||||
|
||||
@@ -5,6 +5,7 @@ import { rm } from "fs/promises"
|
||||
import path from "path"
|
||||
import { Script } from "@opencode-ai/script"
|
||||
import { createSolidTransformPlugin } from "@opentui/solid/bun-plugin"
|
||||
import type { BunPlugin } from "bun"
|
||||
import pkg from "../package.json"
|
||||
import { modelsData } from "./generate"
|
||||
|
||||
@@ -22,7 +23,7 @@ await rm(outdir, { recursive: true, force: true })
|
||||
const singleFlag = process.argv.includes("--single")
|
||||
const baselineFlag = process.argv.includes("--baseline")
|
||||
const skipInstall = process.argv.includes("--skip-install")
|
||||
const plugin = createSolidTransformPlugin()
|
||||
const solidPlugin = createSolidTransformPlugin()
|
||||
|
||||
const allTargets: {
|
||||
os: string
|
||||
@@ -55,6 +56,16 @@ const targets = singleFlag
|
||||
if (!skipInstall) await $`bun install --os="*" --cpu="*" @opentui/core@${pkg.dependencies["@opentui/core"]}`
|
||||
|
||||
for (const item of targets) {
|
||||
const parcelWatcherPackage = `@parcel/watcher-${item.os}-${item.arch}${item.os === "linux" ? `-${item.abi ?? "glibc"}` : ""}`
|
||||
const parcelWatcherPlugin: BunPlugin = {
|
||||
name: "parcel-watcher-binding",
|
||||
setup(build) {
|
||||
build.onLoad({ filter: /filesystem\/watcher-binding\.ts$/ }, () => ({
|
||||
contents: `import binding from ${JSON.stringify(parcelWatcherPackage)}; export default () => binding`,
|
||||
loader: "js",
|
||||
}))
|
||||
},
|
||||
}
|
||||
const target = [
|
||||
binary,
|
||||
item.os === "win32" ? "windows" : item.os,
|
||||
@@ -69,7 +80,7 @@ for (const item of targets) {
|
||||
const result = await Bun.build({
|
||||
entrypoints: ["./src/index.ts"],
|
||||
tsconfig: "./tsconfig.json",
|
||||
plugins: [plugin],
|
||||
plugins: [solidPlugin, parcelWatcherPlugin],
|
||||
external: ["node-gyp"],
|
||||
format: "esm",
|
||||
minify: true,
|
||||
|
||||
@@ -37,6 +37,8 @@ export async function collectNodeAssets(target: NodeTarget) {
|
||||
...(target.platform === "linux" ? { libc: "glibc" as const } : {}),
|
||||
}),
|
||||
{ key: target.parcelWatcherAsset, source: fileURLToPath(import.meta.resolve(target.parcelWatcherPackage)) },
|
||||
{ key: target.fffAsset, source: fileURLToPath(import.meta.resolve(target.fffPackage)) },
|
||||
{ key: target.fffFfiAsset, source: fileURLToPath(import.meta.resolve(target.fffFfiPackage)) },
|
||||
{
|
||||
key: photonWasmAsset,
|
||||
source: fileURLToPath(import.meta.resolve(photonWasmAsset)),
|
||||
|
||||
@@ -13,7 +13,7 @@ const directory = path.join(import.meta.dir, "..", "dist", ...(nodeBuild ? ["nod
|
||||
const binary = path.join(directory, `opencode2${nodeBuild ? "-node" : ""}${process.platform === "win32" ? ".exe" : ""}`)
|
||||
if (!(await Bun.file(binary).exists())) throw new Error(`Missing compiled CLI in ${directory}`)
|
||||
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-service-smoke-"))
|
||||
const root = await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), "opencode-service-smoke-")))
|
||||
const env = {
|
||||
...process.env,
|
||||
HOME: root,
|
||||
@@ -29,6 +29,7 @@ const processes: Array<ReturnType<typeof Bun.spawn>> = []
|
||||
const errors: Array<Promise<string>> = []
|
||||
let failure: unknown
|
||||
try {
|
||||
await fs.mkdir(path.join(root, ".opencode"))
|
||||
spawnService()
|
||||
spawnService()
|
||||
const registration = await waitForRegistration()
|
||||
@@ -49,6 +50,11 @@ try {
|
||||
{ signal: AbortSignal.timeout(5_000) },
|
||||
)
|
||||
if (tokenOpenApi.status !== 200) throw new Error("Compiled application rejected query authentication")
|
||||
if ((await pluginIDs(info.url, headers)).includes("smoke")) throw new Error("Smoke plugin existed before creation")
|
||||
const plugin = path.join(root, ".opencode", "plugins", "smoke.ts")
|
||||
await fs.mkdir(path.dirname(plugin), { recursive: true })
|
||||
await fs.writeFile(plugin, pluginSource())
|
||||
await waitForPlugin(info.url, headers)
|
||||
|
||||
const unauthorizedHealth = await fetch(new URL("/api/health", info.url), {
|
||||
signal: AbortSignal.timeout(5_000),
|
||||
@@ -88,6 +94,7 @@ try {
|
||||
} finally {
|
||||
processes.forEach((process) => process.kill())
|
||||
await Promise.all(processes.map((process) => process.exited))
|
||||
if (failure) errors.push(fs.readFile(path.join(root, "data", "opencode", "log", "opencode.log"), "utf8").catch(() => ""))
|
||||
}
|
||||
|
||||
const output = await Promise.all(errors)
|
||||
@@ -133,3 +140,31 @@ async function waitForReady(url: string, headers: HeadersInit) {
|
||||
function exitsWithin(process: Bun.Subprocess, milliseconds: number) {
|
||||
return Promise.race([process.exited.then(() => true), Bun.sleep(milliseconds).then(() => false)])
|
||||
}
|
||||
|
||||
function pluginSource() {
|
||||
return 'export default { id: "smoke", setup: async () => {} }\n'
|
||||
}
|
||||
|
||||
async function pluginIDs(url: string, headers: HeadersInit) {
|
||||
const endpoint = new URL("/api/plugin", url)
|
||||
endpoint.searchParams.set("location[directory]", root)
|
||||
const response = await fetch(endpoint, { headers, signal: AbortSignal.timeout(5_000) })
|
||||
const body: unknown = await response.json()
|
||||
if (typeof body !== "object" || body === null || !("data" in body) || !Array.isArray(body.data)) {
|
||||
throw new Error("Compiled service returned an invalid plugin list")
|
||||
}
|
||||
return body.data.flatMap((plugin) =>
|
||||
typeof plugin === "object" && plugin !== null && "id" in plugin && typeof plugin.id === "string"
|
||||
? [plugin.id]
|
||||
: [],
|
||||
)
|
||||
}
|
||||
|
||||
async function waitForPlugin(url: string, headers: HeadersInit) {
|
||||
const deadline = Date.now() + 10_000
|
||||
while (Date.now() < deadline) {
|
||||
if ((await pluginIDs(url, headers)).includes("smoke")) return
|
||||
await Bun.sleep(25)
|
||||
}
|
||||
throw new Error("Compiled service did not discover the created plugin")
|
||||
}
|
||||
|
||||
@@ -11,6 +11,8 @@ export function nodeTarget(platform: string, arch: string) {
|
||||
const targetArch = arch as "arm64" | "x64"
|
||||
const nodePtyPackage = `@lydell/node-pty-${targetPlatform}-${targetArch}`
|
||||
const parcelWatcherPackage = `@parcel/watcher-${targetPlatform}-${targetArch}${targetPlatform === "linux" ? "-glibc" : ""}`
|
||||
const fffPackage = `@ff-labs/fff-bin-${targetPlatform}-${targetArch}${targetPlatform === "linux" ? "-gnu" : ""}`
|
||||
const fffFfiPackage = `@yuuang/ffi-rs-${targetPlatform}-${targetArch}${targetPlatform === "linux" ? "-gnu" : targetPlatform === "win32" ? "-msvc" : ""}`
|
||||
|
||||
return {
|
||||
platform: targetPlatform,
|
||||
@@ -19,6 +21,10 @@ export function nodeTarget(platform: string, arch: string) {
|
||||
nodePtyEntryAsset: `${nodePtyPackage}/lib/index.js`,
|
||||
parcelWatcherPackage,
|
||||
parcelWatcherAsset: `${parcelWatcherPackage}/watcher.node`,
|
||||
fffPackage,
|
||||
fffAsset: `${fffPackage}/${targetPlatform === "darwin" ? "libfff_c.dylib" : targetPlatform === "win32" ? "fff_c.dll" : "libfff_c.so"}`,
|
||||
fffFfiPackage,
|
||||
fffFfiAsset: `${fffFfiPackage}/ffi-rs.${targetPlatform}-${targetArch}${targetPlatform === "linux" ? "-gnu" : targetPlatform === "win32" ? "-msvc" : ""}.node`,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -30,6 +30,44 @@ function runtimeRequirePlugin(): Plugin {
|
||||
}
|
||||
}
|
||||
|
||||
function fffNodePlugin(): Plugin {
|
||||
return {
|
||||
name: "opencode:fff-node",
|
||||
enforce: "pre",
|
||||
transform(code, id) {
|
||||
const normalized = id.replaceAll("\\", "/")
|
||||
if (normalized.endsWith("/ffi-rs/index.js")) {
|
||||
const start = code.indexOf("if (!nativeBinding) {")
|
||||
if (start === -1) this.error("Failed to rewrite ffi-rs native binding loader")
|
||||
return `const unavailable = () => { throw new Error("ffi-rs native binding unavailable") }
|
||||
const nativeBinding = globalThis.__OPENCODE_FFF_FFI ?? {
|
||||
DataType: new Proxy({}, { get: (target, key) => target[key] ?? key }),
|
||||
PointerType: {},
|
||||
FFITypeTag: {},
|
||||
open: unavailable,
|
||||
close: unavailable,
|
||||
load: unavailable,
|
||||
isNullPointer: unavailable,
|
||||
createPointer: unavailable,
|
||||
restorePointer: unavailable,
|
||||
unwrapPointer: unavailable,
|
||||
wrapPointer: unavailable,
|
||||
freePointer: unavailable,
|
||||
}
|
||||
const loadError = undefined
|
||||
${code.slice(start)}`
|
||||
}
|
||||
if (!normalized.endsWith("/fff-node/dist/src/binary.js")) return
|
||||
const transformed = code.replace(
|
||||
"export function findBinary() {",
|
||||
"export function findBinary() { if (process.env.FFF_BINARY_PATH) return process.env.FFF_BINARY_PATH;",
|
||||
)
|
||||
if (transformed === code) this.error("Failed to rewrite FFF binary loader")
|
||||
return transformed
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const resolve = {
|
||||
alias: [
|
||||
{ find: /^solid-js\/store$/, replacement: "solid-js/store/dist/store.js" },
|
||||
@@ -156,6 +194,11 @@ process.env.OTUI_ASSET_ROOT = __ocAssetRoot
|
||||
process.env.OPENCODE_NODE_PTY_PATH = __ocPath.join(__ocAssetRoot, ${JSON.stringify(input.target.nodePtyEntryAsset)})
|
||||
process.env.OPENCODE_PARCEL_WATCHER_PATH = __ocPath.join(__ocAssetRoot, ${JSON.stringify(input.target.parcelWatcherAsset)})
|
||||
process.env.OPENCODE_PHOTON_WASM_PATH = __ocPath.join(__ocAssetRoot, ${JSON.stringify(photonWasmAsset)})
|
||||
process.env.FFF_BINARY_PATH = __ocPath.join(__ocAssetRoot, ${JSON.stringify(input.target.fffAsset)})
|
||||
process.env.OPENCODE_FFF_FFI_PATH = __ocPath.join(__ocAssetRoot, ${JSON.stringify(input.target.fffFfiAsset)})
|
||||
try {
|
||||
globalThis.__OPENCODE_FFF_FFI = require(process.env.OPENCODE_FFF_FFI_PATH)
|
||||
} catch {}
|
||||
globalThis.__OPENCODE_PHOTON_WASM_PATH = process.env.OPENCODE_PHOTON_WASM_PATH
|
||||
if (process.platform === "linux") process.env.OPENTUI_LIBC = "glibc"`
|
||||
}
|
||||
@@ -174,6 +217,7 @@ export function mainConfig(input: NodeBuildInput): UserConfig {
|
||||
plugins: [
|
||||
rawTextPlugin(),
|
||||
runtimeRequirePlugin(),
|
||||
fffNodePlugin(),
|
||||
solid({
|
||||
solid: {
|
||||
generate: "universal",
|
||||
|
||||
@@ -92,6 +92,7 @@
|
||||
"@lydell/node-pty": "catalog:",
|
||||
"@modelcontextprotocol/sdk": "1.29.0",
|
||||
"@ff-labs/fff-bun": "0.10.1",
|
||||
"@ff-labs/fff-node": "0.10.1",
|
||||
"@opencode-ai/codemode": "workspace:*",
|
||||
"@opencode-ai/effect-drizzle-sqlite": "workspace:*",
|
||||
"@opencode-ai/effect-sqlite-node": "workspace:*",
|
||||
|
||||
@@ -6,9 +6,7 @@ import { Instructions } from "../instructions/index"
|
||||
import { CodeModeCatalog } from "./catalog"
|
||||
|
||||
// prettier-ignore
|
||||
const prompt = (hasMoreTools: boolean) => `The Code Mode tool catalog below is ${hasMoreTools ? "partial" : "complete"}.
|
||||
|
||||
The Code Mode catalog and \`search\` results are the complete set of tools available within Code Mode. Tools presented elsewhere are not available in this runtime.${hasMoreTools ? `
|
||||
const prompt = (hasMoreTools: boolean) => `The Code Mode tool catalog below is ${hasMoreTools ? "partial" : "complete"}.${hasMoreTools ? `
|
||||
|
||||
## Search
|
||||
|
||||
|
||||
@@ -33,7 +33,7 @@ type CollectedFiles = {
|
||||
|
||||
// Invariant model-facing guidance; the changing tool catalog is delivered through Instructions.
|
||||
const description = [
|
||||
"Run JavaScript in a confined Code Mode runtime to orchestrate tool calls and compose their results.",
|
||||
"Run JavaScript to orchestrate tool calls and compose their results through `{ code }` in a confined Code Mode runtime.",
|
||||
"Imports, direct filesystem access, and timers are unavailable. Do not use `fetch`; all external access goes through `tools`.",
|
||||
"Within `{ code }`, the only callable tools are those explicitly listed in the Code Mode catalog instructions or returned by `search`. Inside `{ code }`, ignore tools shown outside the Code Mode catalog. They are not available in the Code Mode runtime.",
|
||||
'Call tools through `tools` using only exact paths and signatures from the catalog. Do not infer or normalize tool names; preserve bracket notation such as `tools.<namespace>["tool-name"](input)`.',
|
||||
|
||||
@@ -1,71 +1,52 @@
|
||||
import type {
|
||||
DirItem,
|
||||
DirSearchResult,
|
||||
FileItem,
|
||||
GrepCursor,
|
||||
GrepMatch,
|
||||
GrepResult,
|
||||
InitOptions,
|
||||
MixedItem,
|
||||
MixedSearchResult,
|
||||
SearchResult,
|
||||
} from "@ff-labs/fff-node"
|
||||
|
||||
const { FileFinder } = await import("@ff-labs/fff-node").catch(() => ({ FileFinder: undefined }))
|
||||
|
||||
export type Result<T> = { ok: true; value: T } | { ok: false; error: string }
|
||||
|
||||
export interface Init {
|
||||
basePath: string
|
||||
frecencyDbPath?: string
|
||||
historyDbPath?: string
|
||||
useUnsafeNoLock?: boolean
|
||||
disableMmapCache?: boolean
|
||||
disableContentIndexing?: boolean
|
||||
disableWatch?: boolean
|
||||
aiMode?: boolean
|
||||
logFilePath?: string
|
||||
logLevel?: "trace" | "debug" | "info" | "warn" | "error"
|
||||
enableFsRootScanning?: boolean
|
||||
enableHomeDirScanning?: boolean
|
||||
}
|
||||
|
||||
export interface File {
|
||||
relativePath: string
|
||||
fileName: string
|
||||
modified: number
|
||||
}
|
||||
|
||||
export interface Directory {
|
||||
relativePath: string
|
||||
dirName: string
|
||||
maxAccessFrecency: number
|
||||
}
|
||||
|
||||
export type Mixed = { type: "file"; item: File } | { type: "directory"; item: Directory }
|
||||
export type Init = InitOptions
|
||||
|
||||
export interface Search {
|
||||
items: File[]
|
||||
scores: Array<{ total: number }>
|
||||
items: FileItem[]
|
||||
scores: SearchResult["scores"]
|
||||
totalMatched: number
|
||||
totalFiles: number
|
||||
}
|
||||
|
||||
export interface DirSearch {
|
||||
items: Directory[]
|
||||
scores: Array<{ total: number }>
|
||||
items: DirItem[]
|
||||
scores: DirSearchResult["scores"]
|
||||
totalMatched: number
|
||||
totalDirs: number
|
||||
}
|
||||
|
||||
export interface MixedSearch {
|
||||
items: Mixed[]
|
||||
scores: Array<{ total: number }>
|
||||
items: MixedItem[]
|
||||
scores: MixedSearchResult["scores"]
|
||||
totalMatched: number
|
||||
totalFiles: number
|
||||
totalDirs: number
|
||||
}
|
||||
|
||||
export type Cursor = null
|
||||
|
||||
export interface Hit {
|
||||
relativePath: string
|
||||
fileName: string
|
||||
lineNumber: number
|
||||
byteOffset: number
|
||||
lineContent: string
|
||||
matchRanges: [number, number][]
|
||||
contextBefore?: string[]
|
||||
contextAfter?: string[]
|
||||
}
|
||||
export type File = FileItem
|
||||
export type Directory = DirItem
|
||||
export type Mixed = MixedItem
|
||||
export type Cursor = GrepCursor | null
|
||||
export type Hit = GrepMatch
|
||||
|
||||
export interface Grep {
|
||||
items: Hit[]
|
||||
items: GrepResult["items"]
|
||||
totalMatched: number
|
||||
totalFilesSearched: number
|
||||
totalFiles: number
|
||||
@@ -128,11 +109,30 @@ export interface Picker {
|
||||
}
|
||||
|
||||
export function available() {
|
||||
return false
|
||||
return FileFinder?.isAvailable() ?? false
|
||||
}
|
||||
|
||||
export function create(_opts: Init): Result<Picker> {
|
||||
return { ok: false, error: "fff unavailable on node runtime" }
|
||||
export function create(opts: Init): Result<Picker> {
|
||||
if (!FileFinder) return { ok: false, error: "fff unavailable on node runtime" }
|
||||
const made = FileFinder.create(opts)
|
||||
if (!made.ok) return made
|
||||
const pick = made.value
|
||||
return {
|
||||
ok: true,
|
||||
value: {
|
||||
destroy: () => pick.destroy(),
|
||||
isScanning: () => pick.isScanning(),
|
||||
waitForScan: (timeoutMs) => pick.waitForScan(timeoutMs),
|
||||
refreshGitStatus: () => pick.refreshGitStatus(),
|
||||
fileSearch: (query, next) => pick.fileSearch(query, next),
|
||||
glob: (pattern, next) => pick.glob(pattern, next),
|
||||
directorySearch: (query, next) => pick.directorySearch(query, next),
|
||||
mixedSearch: (query, next) => pick.mixedSearch(query, next),
|
||||
grep: (query, next) => pick.grep(query, next),
|
||||
trackQuery: (query, file) => pick.trackQuery(query, file),
|
||||
getHistoricalQuery: (offset) => pick.getHistoricalQuery(offset),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export * as Fff from "./fff.node"
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import { createRequire } from "node:module"
|
||||
|
||||
declare const OPENCODE_LIBC: string | undefined
|
||||
|
||||
const require = createRequire(import.meta.url)
|
||||
|
||||
export default function load() {
|
||||
const libc = typeof OPENCODE_LIBC === "undefined" ? undefined : OPENCODE_LIBC
|
||||
return require(
|
||||
process.env.OPENCODE_PARCEL_WATCHER_PATH ??
|
||||
`@parcel/watcher-${process.platform}-${process.arch}${process.platform === "linux" ? `-${libc || "glibc"}` : ""}`,
|
||||
)
|
||||
}
|
||||
@@ -9,23 +9,14 @@ import { Cause, Context, Effect, Layer, PubSub, RcMap, Schema, Stream } from "ef
|
||||
import { lazy } from "../util/lazy"
|
||||
import { watch as watchFileSystem } from "node:fs"
|
||||
import path from "path"
|
||||
import { createRequire } from "node:module"
|
||||
|
||||
declare const OPENCODE_LIBC: string | undefined
|
||||
import loadBinding from "./watcher-binding"
|
||||
|
||||
const SUBSCRIBE_TIMEOUT_MS = 10_000
|
||||
const require = createRequire(import.meta.url)
|
||||
|
||||
export const Event = { Updated: FileSystem.Event.Changed }
|
||||
|
||||
const watcher = lazy((): typeof import("@parcel/watcher") | undefined => {
|
||||
try {
|
||||
const libc = typeof OPENCODE_LIBC === "undefined" ? undefined : OPENCODE_LIBC
|
||||
const binding = require(
|
||||
process.env.OPENCODE_PARCEL_WATCHER_PATH ??
|
||||
`@parcel/watcher-${process.platform}-${process.arch}${process.platform === "linux" ? `-${libc || "glibc"}` : ""}`,
|
||||
)
|
||||
return createWrapper(binding) as typeof import("@parcel/watcher")
|
||||
return createWrapper(loadBinding()) as typeof import("@parcel/watcher")
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -44,6 +44,7 @@ type Active = {
|
||||
* here; callers (e.g. `ShellTool`) own that association and store the shell ID.
|
||||
*/
|
||||
export interface Interface {
|
||||
readonly name: () => Effect.Effect<string>
|
||||
readonly create: (input: Shell.CreateInput) => Effect.Effect<Shell.Info>
|
||||
// Currently running commands only; exited shells are retained for get/output but excluded here.
|
||||
readonly list: () => Effect.Effect<Shell.Info[]>
|
||||
@@ -134,6 +135,13 @@ export const layer = (options?: ShellSelect.Options) => Layer.effect(
|
||||
return session.info
|
||||
})
|
||||
|
||||
const resolve = () =>
|
||||
config
|
||||
.entries()
|
||||
.pipe(Effect.map((entries) => ShellSelect.preferred(Config.latest(entries, "shell"), options)))
|
||||
|
||||
const name = () => resolve().pipe(Effect.map(ShellSelect.name))
|
||||
|
||||
const output = Effect.fn("Shell.output")(function* (id: Shell.ID, input?: Shell.OutputInput) {
|
||||
const session = yield* require(id)
|
||||
const cursor = input?.cursor ?? 0
|
||||
@@ -167,8 +175,7 @@ export const layer = (options?: ShellSelect.Options) => Layer.effect(
|
||||
const create = Effect.fn("Shell.create")(function* (input: Shell.CreateInput) {
|
||||
const id = Shell.ID.ascending()
|
||||
const cwd = input.cwd ?? location.directory
|
||||
const configShell = Config.latest(yield* config.entries(), "shell")
|
||||
const shell = ShellSelect.preferred(configShell, options)
|
||||
const shell = yield* resolve()
|
||||
const args = ShellSelect.args(shell, input.command)
|
||||
const file = path.join(outputDir, `${id}.out`)
|
||||
const env = {
|
||||
@@ -312,7 +319,7 @@ export const layer = (options?: ShellSelect.Options) => Layer.effect(
|
||||
return session.info
|
||||
})
|
||||
|
||||
return Service.of({ create, list, get, wait, timeout, output, remove })
|
||||
return Service.of({ name, create, list, get, wait, timeout, output, remove })
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -15,22 +15,40 @@ import { Shell } from "../../shell"
|
||||
|
||||
export const name = "shell"
|
||||
export const DEFAULT_TIMEOUT_MS = 2 * 60 * 1_000
|
||||
export const MAX_TIMEOUT_MS = 10 * 60 * 1_000
|
||||
export const MAX_CAPTURE_BYTES = 1024 * 1024
|
||||
|
||||
const BACKGROUND_STARTED = "The command was moved to the background."
|
||||
const BACKGROUND_INSTRUCTION =
|
||||
"You will be notified automatically when the command finishes. DO NOT sleep, poll, or proactively check on its progress."
|
||||
const OS =
|
||||
process.platform === "darwin"
|
||||
? "macOS"
|
||||
: process.platform === "win32"
|
||||
? "Windows"
|
||||
: process.platform === "linux"
|
||||
? "Linux"
|
||||
: process.platform
|
||||
const description = (shell?: string) =>
|
||||
[
|
||||
"Execute a shell command and return its output.",
|
||||
...(shell ? [`Commands run on ${OS} using ${shell}.`] : []),
|
||||
"Quote file paths containing spaces or special characters.",
|
||||
"Prefer dedicated tools over shell commands when possible.",
|
||||
"When output is large, the full result is saved to a file and a truncated preview is returned.",
|
||||
"Rely on automatic truncation unless filtering the output is more useful.",
|
||||
"Commands accept an optional timeout, background commands have no timeout by default.",
|
||||
"Background commands return immediately, and you will be notified when they complete.",
|
||||
].join(" ")
|
||||
|
||||
export const Input = Schema.Struct({
|
||||
command: Schema.String.annotate({ description: "Shell command string to execute" }),
|
||||
workdir: Schema.optionalKey(Schema.String).annotate({
|
||||
description: "Working directory. Defaults to the active Location; relative paths resolve from that Location.",
|
||||
description:
|
||||
"Working directory to execute the command in. Defaults to the current working directory. When possible, avoid changing directories in the command and set the working directory here instead.",
|
||||
}),
|
||||
timeout: Schema.optionalKey(NonNegativeInt).annotate({
|
||||
description: `Timeout in milliseconds. Set to 0 to disable the timeout. Defaults to ${DEFAULT_TIMEOUT_MS} for foreground commands. Background commands have no timeout by default.`,
|
||||
}),
|
||||
timeout: Schema.optionalKey(NonNegativeInt.check(Schema.isLessThanOrEqualTo(MAX_TIMEOUT_MS)))
|
||||
.annotate({
|
||||
description: `Optional timeout in milliseconds. Zero means unlimited. Foreground commands default to ${DEFAULT_TIMEOUT_MS}; background commands default to unlimited. May not exceed ${MAX_TIMEOUT_MS}.`,
|
||||
}),
|
||||
background: Schema.optionalKey(Schema.Boolean).annotate({
|
||||
description:
|
||||
"Run the command in the background and return immediately. You will be notified when it completes. DO NOT poll its progress.",
|
||||
@@ -69,13 +87,11 @@ const modelOutput = (output: Output): string | undefined => {
|
||||
// TODO: Port tree-sitter bash / PowerShell parser-based approval reduction.
|
||||
// TODO: Port BashArity reusable command-prefix approvals.
|
||||
// TODO: Replace token-based command-argument external-directory advisories with parser-based detection.
|
||||
// TODO: Restore PowerShell and cmd-specific invocation/path handling on Windows.
|
||||
// TODO: Add plugin shell.env environment augmentation once plugin hooks exist.
|
||||
// TODO: Persist job status and define restart recovery before exposing remote observation.
|
||||
// TODO: Add HTTP job observation only after durable status, restart recovery, and authorization are defined.
|
||||
// TODO: Revisit process-group cleanup and platform coverage with shell-specific tests if current AppProcess semantics do not fully cover it.
|
||||
// TODO: Revisit binary output handling if stdout/stderr decoding is text-only.
|
||||
// TODO: Stream full shell output into managed storage while retaining only a bounded in-memory preview.
|
||||
|
||||
const shellTokens = (command: string) => command.match(/(?:[^\s"']+|"[^"]*"|'[^']*')+/g) ?? []
|
||||
const unquote = (value: string) => value.replace(/^(['"])(.*)\1$/, "$2")
|
||||
@@ -144,7 +160,7 @@ export const Plugin = {
|
||||
({
|
||||
name,
|
||||
options: { codemode: false },
|
||||
description: `Execute one shell command string with the host user's filesystem, process, and network authority. The active Location is the default working directory. Relative workdir values resolve from that Location. External workdir values require external_directory approval; best-effort command-argument path warnings are advisory only. An optional timeout may be provided in milliseconds (zero: unlimited; foreground default: ${DEFAULT_TIMEOUT_MS}; maximum: ${MAX_TIMEOUT_MS}). Background commands default to unlimited. Uses the configured shell when set; otherwise uses /bin/sh on POSIX and COMSPEC or cmd.exe on Windows. Background mode (background=true) launches the command asynchronously and returns immediately; you are notified when it finishes.`,
|
||||
description: description(),
|
||||
input: Input,
|
||||
output: Output,
|
||||
execute: (input, context) =>
|
||||
@@ -189,8 +205,12 @@ export const Plugin = {
|
||||
yield* context.progress({ shellID: info.id })
|
||||
|
||||
const captureShell = Effect.fn("ShellTool.captureShell")(function* () {
|
||||
const page = yield* shell.output(info.id, { limit: MAX_CAPTURE_BYTES })
|
||||
const truncated = page.size > page.cursor
|
||||
const latest = yield* shell.output(info.id, { cursor: Number.MAX_SAFE_INTEGER })
|
||||
const truncated = latest.size > MAX_CAPTURE_BYTES
|
||||
const page = yield* shell.output(info.id, {
|
||||
cursor: Math.max(0, latest.size - MAX_CAPTURE_BYTES),
|
||||
limit: MAX_CAPTURE_BYTES,
|
||||
})
|
||||
const notice = truncated ? `\n\n[output truncated; full output saved to: ${info.file}]` : ""
|
||||
return {
|
||||
output: `${page.output || "(no output)"}${notice}`,
|
||||
@@ -291,5 +311,13 @@ export const Plugin = {
|
||||
),
|
||||
)
|
||||
.pipe(Effect.orDie)
|
||||
|
||||
yield* ctx.session.hook("context", (event) =>
|
||||
Effect.gen(function* () {
|
||||
const tool = event.tools[name]
|
||||
if (!tool) return
|
||||
tool.description = description(yield* shell.name())
|
||||
}),
|
||||
)
|
||||
}),
|
||||
}
|
||||
|
||||
@@ -44,9 +44,6 @@ describe("CodeModeInstructions", () => {
|
||||
it.effect("renders the initial catalog, semantic deltas, and removal", () =>
|
||||
Effect.gen(function* () {
|
||||
const initialized = yield* readInitial(CodeModeInstructions.make([echo]))
|
||||
expect(initialized.text).toContain(
|
||||
"The Code Mode catalog and `search` results are the complete set of tools available within Code Mode. Tools presented elsewhere are not available in this runtime.",
|
||||
)
|
||||
expect(initialized.text).toContain("## Available tools")
|
||||
expect(initialized.text).not.toContain("## Search")
|
||||
expect(initialized.text).toContain(` - ${echo.signature} // Echo text`)
|
||||
|
||||
@@ -22,7 +22,7 @@ const createCodeMode = (tools: ReadonlyMap<string, Info>) =>
|
||||
test("execute describes invariant Code Mode behavior", () => {
|
||||
expect(createCodeMode(new Map()).description).toBe(
|
||||
[
|
||||
"Run JavaScript in a confined Code Mode runtime to orchestrate tool calls and compose their results.",
|
||||
"Run JavaScript to orchestrate tool calls and compose their results through `{ code }` in a confined Code Mode runtime.",
|
||||
"Imports, direct filesystem access, and timers are unavailable. Do not use `fetch`; all external access goes through `tools`.",
|
||||
"Within `{ code }`, the only callable tools are those explicitly listed in the Code Mode catalog instructions or returned by `search`. Inside `{ code }`, ignore tools shown outside the Code Mode catalog. They are not available in the Code Mode runtime.",
|
||||
'Call tools through `tools` using only exact paths and signatures from the catalog. Do not infer or normalize tool names; preserve bracket notation such as `tools.<namespace>["tool-name"](input)`.',
|
||||
|
||||
@@ -165,8 +165,8 @@ const bodyExitCommand = isWindows
|
||||
: "printf body && exit 7"
|
||||
const overflowCommand = (bytes: number) =>
|
||||
isWindows
|
||||
? `[Console]::Out.Write(('x' * ${bytes})); Start-Sleep -Milliseconds 100`
|
||||
: `head -c ${bytes} /dev/zero | tr '\\0' 'x'`
|
||||
? `[Console]::Out.Write('output-start' + ('x' * ${bytes}) + 'output-end'); Start-Sleep -Milliseconds 100`
|
||||
: `printf output-start; head -c ${bytes} /dev/zero | tr '\\0' 'x'; printf output-end`
|
||||
const progressOverflowCommand = (bytes: number, release: string) =>
|
||||
isWindows
|
||||
? `[Console]::Out.Write(('x' * ${bytes})); while (!(Test-Path -LiteralPath '${release}')) { Start-Sleep -Milliseconds 50 }`
|
||||
@@ -200,10 +200,11 @@ describe("ShellTool", () => {
|
||||
return withSession(tmp.path, (registry) =>
|
||||
Effect.gen(function* () {
|
||||
const definitions = yield* toolDefinitions(registry)
|
||||
const shell = definitions.find((tool) => tool.name === "shell")
|
||||
expect(shell).toBeDefined()
|
||||
const definition = definitions.find((tool) => tool.name === "shell")
|
||||
expect(definition?.description).toStartWith("Execute a shell command and return its output.")
|
||||
expect(definition?.inputSchema).not.toHaveProperty("properties.timeout.maximum")
|
||||
// Code Mode receives the declared output schema, including the command output text.
|
||||
expect(shell?.outputSchema).toHaveProperty("properties.output")
|
||||
expect(definition?.outputSchema).toHaveProperty("properties.output")
|
||||
expect(
|
||||
(yield* toolDefinitions(registry, [{ action: "shell", resource: "*", effect: "deny" }])).map(
|
||||
(tool) => tool.name,
|
||||
@@ -409,7 +410,11 @@ describe("ShellTool", () => {
|
||||
Effect.andThen((settled) =>
|
||||
Effect.sync(() => {
|
||||
expect(settled.metadata).toMatchObject({ exit: 0, truncated: true })
|
||||
expect(settled.content?.[0]).toMatchObject({
|
||||
const content = settled.content?.[0]
|
||||
if (!content || content.type !== "text") throw new Error("Expected text content")
|
||||
expect(content.text.includes("output-start")).toBe(false)
|
||||
expect(content.text.includes("output-end")).toBe(true)
|
||||
expect(content).toMatchObject({
|
||||
type: "text",
|
||||
text: expect.stringContaining("output truncated; full output saved to:"),
|
||||
})
|
||||
|
||||
@@ -32,7 +32,8 @@
|
||||
"peerDependencies": {
|
||||
"@opentui/core": ">=0.4.5",
|
||||
"@opentui/keymap": ">=0.4.5",
|
||||
"@opentui/solid": ">=0.4.5"
|
||||
"@opentui/solid": ">=0.4.5",
|
||||
"solid-js": ">=1.9.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@opentui/core": {
|
||||
@@ -43,6 +44,9 @@
|
||||
},
|
||||
"@opentui/solid": {
|
||||
"optional": true
|
||||
},
|
||||
"solid-js": {
|
||||
"optional": true
|
||||
}
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -52,6 +56,7 @@
|
||||
"@tsconfig/bun": "catalog:",
|
||||
"@tsconfig/node22": "catalog:",
|
||||
"@types/node": "catalog:",
|
||||
"solid-js": "catalog:",
|
||||
"typescript": "catalog:",
|
||||
"@typescript/native-preview": "catalog:"
|
||||
}
|
||||
|
||||
@@ -230,11 +230,11 @@ export interface DialogSelectOptions<Value> {
|
||||
}
|
||||
|
||||
export interface Dialog {
|
||||
/** Shows a dialog and returns a function that closes it. */
|
||||
show(render: () => JSX.Element, onClose?: () => void): () => void
|
||||
/** Sets the presentation options for this plugin's active dialog. */
|
||||
/** Shows a dialog. */
|
||||
show(render: () => JSX.Element, onClose?: () => void): void
|
||||
/** Sets the active dialog's presentation options. */
|
||||
set(options: DialogOptions): void
|
||||
/** Closes this plugin's active dialog. */
|
||||
/** Closes the active dialog. */
|
||||
clear(): void
|
||||
alert(options: DialogAlertOptions): Promise<void>
|
||||
confirm(options: DialogConfirmOptions): Promise<boolean | undefined>
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
export * as Plugin from "./plugin.js"
|
||||
export { PluginContextProvider, usePlugin } from "./solid.js"
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { createComponent, createContext, useContext, type JSX } from "solid-js"
|
||||
import type { Context } from "./context.js"
|
||||
|
||||
const PluginContext = createContext<Context>()
|
||||
|
||||
export function PluginContextProvider(props: { readonly value: Context; readonly children: JSX.Element }) {
|
||||
return createComponent(PluginContext.Provider, {
|
||||
value: props.value,
|
||||
get children() {
|
||||
return props.children
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function usePlugin() {
|
||||
const context = useContext(PluginContext)
|
||||
if (!context) throw new Error("PluginContextProvider is missing")
|
||||
return context
|
||||
}
|
||||
@@ -11,7 +11,6 @@
|
||||
},
|
||||
"exports": {
|
||||
".": "./src/index.tsx",
|
||||
"./builtins": "./src/feature-plugins/builtins.ts",
|
||||
"./config": "./src/config/index.tsx",
|
||||
"./config/keybind": "./src/config/keybind.ts",
|
||||
"./config/v1": "./src/config/v1/index.tsx",
|
||||
@@ -37,9 +36,6 @@
|
||||
"./context/keymap": "./src/context/keymap.tsx",
|
||||
"./prompt/content": "./src/prompt/content.ts",
|
||||
"./prompt/display": "./src/prompt/display.ts",
|
||||
"./plugin/runtime": "./src/plugin/runtime.tsx",
|
||||
"./plugin/slots": "./src/plugin/slots.tsx",
|
||||
"./plugin/command-shim": "./src/plugin/command-shim.ts",
|
||||
"./parsers-config": "./src/parsers-config.ts",
|
||||
"./util/error": "./src/util/error.ts",
|
||||
"./util/filetype": "./src/util/filetype.ts",
|
||||
|
||||
+199
-108
@@ -51,6 +51,7 @@ import { StartupLoading } from "./component/startup-loading"
|
||||
import { DevToolsBar } from "./component/devtools-bar"
|
||||
import { Reconnecting } from "./component/reconnecting"
|
||||
import { DataProvider, useData } from "./context/data"
|
||||
import { SessionTabsProvider, useSessionTabs } from "./context/session-tabs"
|
||||
import { LocationProvider, useLocation } from "./context/location"
|
||||
import { LocalProvider, useLocal } from "./context/local"
|
||||
import { PermissionProvider } from "./context/permission"
|
||||
@@ -65,8 +66,9 @@ import { DialogThemeList } from "./component/dialog-theme-list"
|
||||
import { DialogHelp } from "./ui/dialog-help"
|
||||
import { DialogAgent } from "./component/dialog-agent"
|
||||
import { DialogSessionList } from "./component/dialog-session-list"
|
||||
import { SessionTabs } from "./component/session-tabs"
|
||||
import { ThemeErrorToast } from "./component/theme-error-toast"
|
||||
import { ThemeProvider, useTheme } from "./context/theme"
|
||||
import { ThemeProvider, useTheme, useThemes } from "./context/theme"
|
||||
import { Home } from "./routes/home"
|
||||
import { Session } from "./routes/session"
|
||||
import { PromptHistoryProvider } from "./component/prompt/history"
|
||||
@@ -79,7 +81,6 @@ import { ArgsProvider, useArgs, type Args } from "./context/args"
|
||||
import open from "open"
|
||||
import { PromptRefProvider, usePromptRef } from "./context/prompt"
|
||||
import { Config, ConfigProvider, useConfig } from "./config"
|
||||
import { createPluginRuntime, PluginRuntimeProvider, usePluginRuntime } from "./plugin/runtime"
|
||||
import { PluginProvider, PluginRoute, PluginSlot, usePlugin, type PackageResolver } from "./plugin/context"
|
||||
import { CommandPaletteDialog } from "./component/command-palette"
|
||||
import { COMMAND_PALETTE_COMMAND, Keymap, type KeymapCommand } from "./context/keymap"
|
||||
@@ -92,9 +93,28 @@ import { AttentionProvider } from "./context/attention"
|
||||
|
||||
registerOpencodeSpinner()
|
||||
|
||||
const appGlobalBindingCommands = [
|
||||
"session.list",
|
||||
"session.new",
|
||||
const appGlobalBindingCommands = ["session.list", "session.new"] as const
|
||||
|
||||
const sessionTabBindingCommands = [
|
||||
"session.tab.next",
|
||||
"session.tab.previous",
|
||||
"session.tab.history.back",
|
||||
"session.tab.history.forward",
|
||||
"session.tab.next_unread",
|
||||
"session.tab.previous_unread",
|
||||
"session.tab.close",
|
||||
"session.tab.select.1",
|
||||
"session.tab.select.2",
|
||||
"session.tab.select.3",
|
||||
"session.tab.select.4",
|
||||
"session.tab.select.5",
|
||||
"session.tab.select.6",
|
||||
"session.tab.select.7",
|
||||
"session.tab.select.8",
|
||||
"session.tab.select.9",
|
||||
] as const
|
||||
|
||||
const pinnedSessionBindingCommands = [
|
||||
"session.quick_switch.1",
|
||||
"session.quick_switch.2",
|
||||
"session.quick_switch.3",
|
||||
@@ -254,8 +274,6 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
|
||||
() => Effect.sync(() => process.off("SIGHUP", onSighup)),
|
||||
)
|
||||
renderer.once("destroy", () => Deferred.doneUnsafe(shutdown, Effect.void))
|
||||
const pluginRuntime = createPluginRuntime()
|
||||
|
||||
yield* Effect.tryPromise(async () => {
|
||||
// Prewarm palette before ThemeProvider mounts so `system` theme avoids a first-paint fallback flash.
|
||||
void renderer.getPalette({ size: 16 }).catch(() => undefined)
|
||||
@@ -277,113 +295,113 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
|
||||
<ErrorBoundary
|
||||
fallback={(error, reset) => <ErrorComponent error={error} reset={reset} mode={mode} />}
|
||||
>
|
||||
<TuiPathsProvider
|
||||
value={{
|
||||
cwd: process.cwd(),
|
||||
home: global.home,
|
||||
state: global.state,
|
||||
worktree: global.data + "/worktree",
|
||||
}}
|
||||
>
|
||||
<TuiLifecycleProvider
|
||||
<TuiPathsProvider
|
||||
value={{
|
||||
add(finalizer) {
|
||||
finalizers.add(finalizer)
|
||||
return () => finalizers.delete(finalizer)
|
||||
},
|
||||
cwd: process.cwd(),
|
||||
home: global.home,
|
||||
state: global.state,
|
||||
worktree: global.data + "/worktree",
|
||||
}}
|
||||
>
|
||||
<TuiTerminalEnvironmentProvider
|
||||
<TuiLifecycleProvider
|
||||
value={{
|
||||
platform: process.platform,
|
||||
multiplexer: process.env.TMUX ? "tmux" : process.env.STY ? "screen" : undefined,
|
||||
displayServer: process.env.WAYLAND_DISPLAY
|
||||
? "wayland"
|
||||
: process.env.DISPLAY
|
||||
? "x11"
|
||||
: undefined,
|
||||
add(finalizer) {
|
||||
finalizers.add(finalizer)
|
||||
return () => finalizers.delete(finalizer)
|
||||
},
|
||||
}}
|
||||
>
|
||||
<TuiStartupProvider
|
||||
<TuiTerminalEnvironmentProvider
|
||||
value={{
|
||||
initialRoute: process.env.OPENCODE_SCRAP
|
||||
? { type: "plugin", id: "scrap", name: "scrap" }
|
||||
: process.env.OPENCODE_ROUTE
|
||||
? JSON.parse(process.env.OPENCODE_ROUTE)
|
||||
platform: process.platform,
|
||||
multiplexer: process.env.TMUX ? "tmux" : process.env.STY ? "screen" : undefined,
|
||||
displayServer: process.env.WAYLAND_DISPLAY
|
||||
? "wayland"
|
||||
: process.env.DISPLAY
|
||||
? "x11"
|
||||
: undefined,
|
||||
skipInitialLoading: Boolean(process.env.OPENCODE_FAST_BOOT),
|
||||
}}
|
||||
>
|
||||
<ClipboardProvider>
|
||||
<ArgsProvider {...input.args}>
|
||||
<ConfigProvider
|
||||
config={config}
|
||||
service={input.config}
|
||||
options={{ terminalSuspend: process.platform !== "win32" }}
|
||||
>
|
||||
<Keymap.Provider>
|
||||
<ToastProvider>
|
||||
<RouteProvider
|
||||
initialRoute={
|
||||
input.args.continue
|
||||
? {
|
||||
type: "session",
|
||||
sessionID: "dummy",
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<PluginRuntimeProvider value={pluginRuntime}>
|
||||
<TuiStartupProvider
|
||||
value={{
|
||||
initialRoute: process.env.OPENCODE_SCRAP
|
||||
? { type: "plugin", id: "scrap", name: "scrap" }
|
||||
: process.env.OPENCODE_ROUTE
|
||||
? JSON.parse(process.env.OPENCODE_ROUTE)
|
||||
: undefined,
|
||||
skipInitialLoading: Boolean(process.env.OPENCODE_FAST_BOOT),
|
||||
}}
|
||||
>
|
||||
<ClipboardProvider>
|
||||
<ArgsProvider {...input.args}>
|
||||
<ConfigProvider
|
||||
config={config}
|
||||
service={input.config}
|
||||
options={{ terminalSuspend: process.platform !== "win32" }}
|
||||
>
|
||||
<Keymap.Provider>
|
||||
<ToastProvider>
|
||||
<RouteProvider
|
||||
initialRoute={
|
||||
input.args.continue
|
||||
? {
|
||||
type: "session",
|
||||
sessionID: "dummy",
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<ClientProvider api={api} service={service}>
|
||||
<PermissionProvider>
|
||||
<DataProvider>
|
||||
<LocationProvider>
|
||||
<ThemeProvider mode={mode}>
|
||||
<ThemeErrorToast />
|
||||
<LocalProvider>
|
||||
<PromptStashProvider>
|
||||
<DialogProvider>
|
||||
<FrecencyProvider>
|
||||
<PromptHistoryProvider>
|
||||
<PromptRefProvider>
|
||||
<EditorContextProvider>
|
||||
<AttentionProvider>
|
||||
<PluginProvider packages={input.packages}>
|
||||
<App
|
||||
pair={
|
||||
input.server.endpoint.auth
|
||||
? input.server.endpoint.auth
|
||||
: {
|
||||
username: "opencode",
|
||||
password: "",
|
||||
}
|
||||
}
|
||||
/>
|
||||
</PluginProvider>
|
||||
</AttentionProvider>
|
||||
</EditorContextProvider>
|
||||
</PromptRefProvider>
|
||||
</PromptHistoryProvider>
|
||||
</FrecencyProvider>
|
||||
</DialogProvider>
|
||||
</PromptStashProvider>
|
||||
</LocalProvider>
|
||||
</ThemeProvider>
|
||||
<SessionTabsProvider>
|
||||
<ThemeProvider mode={mode}>
|
||||
<ThemeErrorToast />
|
||||
<LocalProvider>
|
||||
<PromptStashProvider>
|
||||
<DialogProvider>
|
||||
<FrecencyProvider>
|
||||
<PromptHistoryProvider>
|
||||
<PromptRefProvider>
|
||||
<EditorContextProvider>
|
||||
<AttentionProvider>
|
||||
<PluginProvider packages={input.packages}>
|
||||
<App
|
||||
pair={
|
||||
input.server.endpoint.auth
|
||||
? input.server.endpoint.auth
|
||||
: {
|
||||
username: "opencode",
|
||||
password: "",
|
||||
}
|
||||
}
|
||||
/>
|
||||
</PluginProvider>
|
||||
</AttentionProvider>
|
||||
</EditorContextProvider>
|
||||
</PromptRefProvider>
|
||||
</PromptHistoryProvider>
|
||||
</FrecencyProvider>
|
||||
</DialogProvider>
|
||||
</PromptStashProvider>
|
||||
</LocalProvider>
|
||||
</ThemeProvider>
|
||||
</SessionTabsProvider>
|
||||
</LocationProvider>
|
||||
</DataProvider>
|
||||
</PermissionProvider>
|
||||
</ClientProvider>
|
||||
</PluginRuntimeProvider>
|
||||
</RouteProvider>
|
||||
</ToastProvider>
|
||||
</Keymap.Provider>
|
||||
</ConfigProvider>
|
||||
</ArgsProvider>
|
||||
</ClipboardProvider>
|
||||
</TuiStartupProvider>
|
||||
</TuiTerminalEnvironmentProvider>
|
||||
</TuiLifecycleProvider>
|
||||
</TuiPathsProvider>
|
||||
</RouteProvider>
|
||||
</ToastProvider>
|
||||
</Keymap.Provider>
|
||||
</ConfigProvider>
|
||||
</ArgsProvider>
|
||||
</ClipboardProvider>
|
||||
</TuiStartupProvider>
|
||||
</TuiTerminalEnvironmentProvider>
|
||||
</TuiLifecycleProvider>
|
||||
</TuiPathsProvider>
|
||||
</ErrorBoundary>
|
||||
</TuiAppProvider>
|
||||
</EpilogueProvider>
|
||||
@@ -419,17 +437,17 @@ function App(props: { pair?: DialogPairCredentials }) {
|
||||
const renderer = useRenderer()
|
||||
const dialog = useDialog()
|
||||
const local = useLocal()
|
||||
const sessionTabs = useSessionTabs()
|
||||
const keymap = Keymap.use()
|
||||
const event = useEvent()
|
||||
const client = useClient()
|
||||
const toast = useToast()
|
||||
const themeState = useTheme()
|
||||
const { themeV2, mode, supports, setMode, locked, lock, unlock } = themeState
|
||||
const theme = useTheme()
|
||||
const { mode, supports, setMode, locked, lock, unlock } = useThemes()
|
||||
const data = useData()
|
||||
const location = useLocation()
|
||||
const exit = useExit()
|
||||
const promptRef = usePromptRef()
|
||||
const pluginRuntime = usePluginRuntime()
|
||||
const plugins = usePlugin()
|
||||
const clipboard = useClipboard()
|
||||
|
||||
@@ -623,9 +641,71 @@ function App(props: { pair?: DialogPairCredentials }) {
|
||||
title: `Switch to session in quick slot ${i + 1}`,
|
||||
category: "Session",
|
||||
palette: undefined,
|
||||
run: () => {
|
||||
local.session.quickSwitch(i + 1)
|
||||
},
|
||||
enabled: () => !sessionTabs.enabled(),
|
||||
run: () => local.session.quickSwitch(i + 1),
|
||||
})),
|
||||
{
|
||||
name: "session.tab.next",
|
||||
title: "Next open session tab",
|
||||
category: "Session",
|
||||
palette: undefined,
|
||||
enabled: sessionTabs.enabled,
|
||||
run: () => sessionTabs.cycle(1),
|
||||
},
|
||||
{
|
||||
name: "session.tab.previous",
|
||||
title: "Previous open session tab",
|
||||
category: "Session",
|
||||
palette: undefined,
|
||||
enabled: sessionTabs.enabled,
|
||||
run: () => sessionTabs.cycle(-1),
|
||||
},
|
||||
{
|
||||
name: "session.tab.history.back",
|
||||
title: "Back in session tab history",
|
||||
category: "Session",
|
||||
palette: undefined,
|
||||
enabled: sessionTabs.enabled,
|
||||
run: () => sessionTabs.history(-1),
|
||||
},
|
||||
{
|
||||
name: "session.tab.history.forward",
|
||||
title: "Forward in session tab history",
|
||||
category: "Session",
|
||||
palette: undefined,
|
||||
enabled: sessionTabs.enabled,
|
||||
run: () => sessionTabs.history(1),
|
||||
},
|
||||
{
|
||||
name: "session.tab.next_unread",
|
||||
title: "Next unread session tab",
|
||||
category: "Session",
|
||||
palette: undefined,
|
||||
enabled: sessionTabs.enabled,
|
||||
run: () => sessionTabs.cycleUnread(1),
|
||||
},
|
||||
{
|
||||
name: "session.tab.previous_unread",
|
||||
title: "Previous unread session tab",
|
||||
category: "Session",
|
||||
palette: undefined,
|
||||
enabled: sessionTabs.enabled,
|
||||
run: () => sessionTabs.cycleUnread(-1),
|
||||
},
|
||||
{
|
||||
name: "session.tab.close",
|
||||
title: "Close current session tab",
|
||||
category: "Session",
|
||||
enabled: sessionTabs.enabled,
|
||||
run: () => sessionTabs.close(),
|
||||
},
|
||||
...Array.from({ length: 9 }, (_, i) => ({
|
||||
name: `session.tab.select.${i + 1}`,
|
||||
title: `Switch to session tab ${i + 1}`,
|
||||
category: "Session",
|
||||
palette: undefined,
|
||||
enabled: sessionTabs.enabled,
|
||||
run: () => sessionTabs.selectIndex(i),
|
||||
})),
|
||||
{
|
||||
name: "model.list",
|
||||
@@ -1004,6 +1084,18 @@ function App(props: { pair?: DialogPairCredentials }) {
|
||||
bindings: appGlobalBindingCommands,
|
||||
}))
|
||||
|
||||
Keymap.createLayer(() => ({
|
||||
mode: "global",
|
||||
enabled: sessionTabs.enabled,
|
||||
bindings: sessionTabBindingCommands,
|
||||
}))
|
||||
|
||||
Keymap.createLayer(() => ({
|
||||
mode: "global",
|
||||
enabled: () => !sessionTabs.enabled(),
|
||||
bindings: pinnedSessionBindingCommands,
|
||||
}))
|
||||
|
||||
Keymap.createLayer(() => ({
|
||||
enabled: () => {
|
||||
const current = promptRef.current
|
||||
@@ -1090,7 +1182,7 @@ function App(props: { pair?: DialogPairCredentials }) {
|
||||
width={dimensions().width}
|
||||
height={dimensions().height}
|
||||
flexDirection="column"
|
||||
backgroundColor={themeV2.background.default}
|
||||
backgroundColor={theme.background.default}
|
||||
onMouseDown={(evt) => {
|
||||
if (copyOnSelectEnabled()) return
|
||||
if (evt.button !== MouseButton.RIGHT) return
|
||||
@@ -1099,16 +1191,15 @@ function App(props: { pair?: DialogPairCredentials }) {
|
||||
evt.preventDefault()
|
||||
evt.stopPropagation()
|
||||
}}
|
||||
onMouseUp={
|
||||
copyOnSelectEnabled()
|
||||
? () => Selection.copy(renderer, toast, clipboard)
|
||||
: undefined
|
||||
}
|
||||
onMouseUp={copyOnSelectEnabled() ? () => Selection.copy(renderer, toast, clipboard) : undefined}
|
||||
>
|
||||
<box flexGrow={1} minHeight={0} flexDirection="row">
|
||||
<box flexGrow={1} minWidth={0} flexDirection="column">
|
||||
<Show when={plugins.ready()}>
|
||||
<box flexGrow={1} minHeight={0} flexDirection="column">
|
||||
<Show when={sessionTabs.enabled() && sessionTabs.tabs().length > 0 && route.data.type !== "plugin"}>
|
||||
<SessionTabs />
|
||||
</Show>
|
||||
<Switch>
|
||||
<Match when={route.data.type === "home"}>
|
||||
<Home />
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
} from "@opentui/core"
|
||||
import { extend, useRenderer } from "@opentui/solid"
|
||||
import { onCleanup, onMount } from "solid-js"
|
||||
import { useTheme } from "../context/theme"
|
||||
import { useThemes } from "../context/theme"
|
||||
import { tint } from "../theme/color"
|
||||
import { GoUpsellArtPainter } from "./bg-pulse-render"
|
||||
|
||||
@@ -70,7 +70,9 @@ declare module "@opentui/solid" {
|
||||
extend({ go_upsell_art: GoUpsellArtRenderable })
|
||||
|
||||
export function BgPulse() {
|
||||
const { themeV2, mode } = useTheme().contextual("elevated")
|
||||
const themes = useThemes()
|
||||
const theme = themes.contextual("elevated")
|
||||
const mode = themes.mode
|
||||
const renderer = useRenderer()
|
||||
let targetFps = renderer.targetFps
|
||||
let maxFps = renderer.maxFps
|
||||
@@ -91,9 +93,9 @@ export function BgPulse() {
|
||||
<go_upsell_art
|
||||
width="100%"
|
||||
height="100%"
|
||||
backgroundPanel={themeV2.background.default}
|
||||
primary={themeV2.hue.interactive[mode() === "light" ? 800 : 200]}
|
||||
logoBase={tint(themeV2.background.default, themeV2.text.default, 0.62)}
|
||||
backgroundPanel={theme.background.default}
|
||||
primary={theme.hue.interactive[mode() === "light" ? 800 : 200]}
|
||||
logoBase={tint(theme.background.default, theme.text.default, 0.62)}
|
||||
live
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -11,7 +11,7 @@ import { useData } from "../context/data"
|
||||
import { useLocation } from "../context/location"
|
||||
import { useRoute } from "../context/route"
|
||||
import { Keymap } from "../context/keymap"
|
||||
import { useTheme } from "../context/theme"
|
||||
import { useTheme, useThemes } from "../context/theme"
|
||||
import { DevTools } from "../devtools"
|
||||
import { usePlugin } from "../plugin/context"
|
||||
import { errorMessage } from "../util/error"
|
||||
@@ -31,12 +31,12 @@ export function DevToolsBar() {
|
||||
const location = useLocation()
|
||||
const route = useRoute()
|
||||
const plugins = usePlugin()
|
||||
const theme = useTheme()
|
||||
const themes = useThemes()
|
||||
const keymap = Keymap.use()
|
||||
const renderer = useRenderer()
|
||||
const dimensions = useTerminalDimensions()
|
||||
const { themeV2, mode, supports, setMode } = theme
|
||||
const elevatedTheme = theme.contextual("elevated").themeV2
|
||||
const { current: theme, mode, supports, setMode } = themes
|
||||
const elevatedTheme = themes.contextual("elevated")
|
||||
const [panel, setPanel] = createSignal<Panel>()
|
||||
const [dumping, setDumping] = createSignal(false)
|
||||
const [dumpPath, setDumpPath] = createSignal<string>()
|
||||
@@ -196,8 +196,8 @@ export function DevToolsBar() {
|
||||
})),
|
||||
},
|
||||
theme: {
|
||||
name: theme.selected,
|
||||
mode: theme.mode(),
|
||||
name: themes.selected,
|
||||
mode: themes.mode(),
|
||||
},
|
||||
},
|
||||
null,
|
||||
@@ -213,7 +213,7 @@ export function DevToolsBar() {
|
||||
}
|
||||
|
||||
return (
|
||||
<box height={1} flexShrink={0} flexDirection="row" backgroundColor={themeV2.raise(themeV2.background.default)}>
|
||||
<box height={1} flexShrink={0} flexDirection="row" backgroundColor={theme.raise(theme.background.default)}>
|
||||
<Show when={panel()}>
|
||||
<box
|
||||
position="absolute"
|
||||
@@ -230,12 +230,12 @@ export function DevToolsBar() {
|
||||
<text
|
||||
fg={
|
||||
panel() === "server"
|
||||
? themeV2.text.action.primary.focused
|
||||
? theme.text.action.primary.focused
|
||||
: serverIndicator().state === "connected"
|
||||
? themeV2.text.feedback.success.default
|
||||
? theme.text.feedback.success.default
|
||||
: serverIndicator().state === "disconnected"
|
||||
? themeV2.text.feedback.error.default
|
||||
: themeV2.text.default
|
||||
? theme.text.feedback.error.default
|
||||
: theme.text.default
|
||||
}
|
||||
>
|
||||
{serverIndicator().icon}
|
||||
@@ -243,10 +243,10 @@ export function DevToolsBar() {
|
||||
<text
|
||||
fg={
|
||||
panel() === "server"
|
||||
? themeV2.text.action.primary.focused
|
||||
? theme.text.action.primary.focused
|
||||
: serverIndicator().state === "disconnected"
|
||||
? themeV2.text.feedback.error.default
|
||||
: themeV2.text.subdued
|
||||
? theme.text.feedback.error.default
|
||||
: theme.text.subdued
|
||||
}
|
||||
>
|
||||
{" "}
|
||||
@@ -279,10 +279,10 @@ export function DevToolsBar() {
|
||||
<text
|
||||
fg={
|
||||
panel() === "ui"
|
||||
? themeV2.text.action.primary.focused
|
||||
? theme.text.action.primary.focused
|
||||
: runtime() === "high"
|
||||
? themeV2.text.feedback.error.default
|
||||
: themeV2.text.subdued
|
||||
? theme.text.feedback.error.default
|
||||
: theme.text.subdued
|
||||
}
|
||||
>
|
||||
{statusIcon(runtime())}
|
||||
@@ -290,10 +290,10 @@ export function DevToolsBar() {
|
||||
<text
|
||||
fg={
|
||||
panel() === "ui"
|
||||
? themeV2.text.action.primary.focused
|
||||
? theme.text.action.primary.focused
|
||||
: runtime() === "high"
|
||||
? themeV2.text.feedback.error.default
|
||||
: themeV2.text.subdued
|
||||
? theme.text.feedback.error.default
|
||||
: theme.text.subdued
|
||||
}
|
||||
>
|
||||
{" "}
|
||||
@@ -320,11 +320,11 @@ export function DevToolsBar() {
|
||||
</Show>
|
||||
</BarItem>
|
||||
<BarItem active={panel() === "theme"} onClick={() => toggle("theme")}>
|
||||
<text fg={panel() === "theme" ? themeV2.text.action.primary.focused : themeV2.text.subdued}>Theme</text>
|
||||
<text fg={panel() === "theme" ? theme.text.action.primary.focused : theme.text.subdued}>Theme</text>
|
||||
<Show when={panel() === "theme"}>
|
||||
<PanelBox>
|
||||
<PanelTitle>Theme</PanelTitle>
|
||||
<Row label="Name" value={theme.selected} />
|
||||
<Row label="Name" value={themes.selected} />
|
||||
<Row label="Mode" value={mode()} />
|
||||
<For each={themePerformance()}>{(entry) => <Row label={entry.key} value={String(entry.value)} />}</For>
|
||||
<Show when={canSwitchMode()}>
|
||||
@@ -336,7 +336,7 @@ export function DevToolsBar() {
|
||||
</Show>
|
||||
</BarItem>
|
||||
<BarItem active={panel() === "tools"} onClick={() => toggle("tools")}>
|
||||
<text fg={panel() === "tools" ? themeV2.text.action.primary.focused : themeV2.text.subdued}>Tools</text>
|
||||
<text fg={panel() === "tools" ? theme.text.action.primary.focused : theme.text.subdued}>Tools</text>
|
||||
<Show when={panel() === "tools"}>
|
||||
<PanelBox>
|
||||
<PanelTitle>Tools</PanelTitle>
|
||||
@@ -406,14 +406,14 @@ export function DevToolsBar() {
|
||||
</Show>
|
||||
</BarItem>
|
||||
<box flexGrow={1} minWidth={0}>
|
||||
<TimeToFirstDraw visible={timing()} width="100%" fg={themeV2.text.subdued} label="Time to first draw" />
|
||||
<TimeToFirstDraw visible={timing()} width="100%" fg={theme.text.subdued} label="Time to first draw" />
|
||||
</box>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
|
||||
function BarItem(props: ParentProps<{ active: boolean; onClick: () => void }>) {
|
||||
const { themeV2 } = useTheme()
|
||||
const theme = useTheme()
|
||||
const renderer = useRenderer()
|
||||
return (
|
||||
<box
|
||||
@@ -423,7 +423,7 @@ function BarItem(props: ParentProps<{ active: boolean; onClick: () => void }>) {
|
||||
flexDirection="row"
|
||||
paddingLeft={1}
|
||||
paddingRight={1}
|
||||
backgroundColor={props.active ? themeV2.background.action.primary.focused : undefined}
|
||||
backgroundColor={props.active ? theme.background.action.primary.focused : undefined}
|
||||
onMouseUp={() => {
|
||||
if (renderer.getSelection()?.getSelectedText()) return
|
||||
props.onClick()
|
||||
@@ -435,7 +435,7 @@ function BarItem(props: ParentProps<{ active: boolean; onClick: () => void }>) {
|
||||
}
|
||||
|
||||
function PanelBox(props: ParentProps) {
|
||||
const { themeV2 } = useTheme().contextual("elevated")
|
||||
const theme = useThemes().contextual("elevated")
|
||||
const renderer = useRenderer()
|
||||
return (
|
||||
<box
|
||||
@@ -448,7 +448,7 @@ function PanelBox(props: ParentProps) {
|
||||
paddingRight={2}
|
||||
paddingTop={1}
|
||||
paddingBottom={1}
|
||||
backgroundColor={themeV2.background.default}
|
||||
backgroundColor={theme.background.default}
|
||||
flexDirection="column"
|
||||
onMouseUp={(event) => {
|
||||
if (renderer.getSelection()?.getSelectedText()) return
|
||||
@@ -461,32 +461,32 @@ function PanelBox(props: ParentProps) {
|
||||
}
|
||||
|
||||
function PanelTitle(props: ParentProps) {
|
||||
const { themeV2 } = useTheme().contextual("elevated")
|
||||
const theme = useThemes().contextual("elevated")
|
||||
return (
|
||||
<text fg={themeV2.text.default} attributes={TextAttributes.BOLD} marginBottom={1}>
|
||||
<text fg={theme.text.default} attributes={TextAttributes.BOLD} marginBottom={1}>
|
||||
{props.children}
|
||||
</text>
|
||||
)
|
||||
}
|
||||
|
||||
function Row(props: { label: string; value: string }) {
|
||||
const { themeV2 } = useTheme().contextual("elevated")
|
||||
const theme = useThemes().contextual("elevated")
|
||||
return (
|
||||
<box flexDirection="row">
|
||||
<text fg={themeV2.text.subdued}>{props.label}</text>
|
||||
<text fg={theme.text.subdued}>{props.label}</text>
|
||||
<box flexGrow={1} />
|
||||
<text fg={themeV2.text.default}>{props.value}</text>
|
||||
<text fg={theme.text.default}>{props.value}</text>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
|
||||
function Action(props: ParentProps<{ onClick: () => void; disabled?: boolean; hoverBackground?: boolean }>) {
|
||||
const { themeV2 } = useTheme().contextual("elevated")
|
||||
const theme = useThemes().contextual("elevated")
|
||||
const [hovered, setHovered] = createSignal(false)
|
||||
return (
|
||||
<box
|
||||
backgroundColor={
|
||||
props.hoverBackground && hovered() && !props.disabled ? themeV2.background.action.primary.hovered : undefined
|
||||
props.hoverBackground && hovered() && !props.disabled ? theme.background.action.primary.hovered : undefined
|
||||
}
|
||||
onMouseOver={() => setHovered(true)}
|
||||
onMouseOut={() => setHovered(false)}
|
||||
@@ -495,7 +495,7 @@ function Action(props: ParentProps<{ onClick: () => void; disabled?: boolean; ho
|
||||
if (!props.disabled) props.onClick()
|
||||
}}
|
||||
>
|
||||
<text fg={props.disabled ? themeV2.text.subdued : themeV2.text.action.primary.default}>{props.children}</text>
|
||||
<text fg={props.disabled ? theme.text.subdued : theme.text.action.primary.default}>{props.children}</text>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
@@ -506,7 +506,7 @@ function cpuPercent(microseconds: number, milliseconds: number) {
|
||||
}
|
||||
|
||||
function ProcessStat(props: { label: string; values: readonly number[]; unit: string; decimals?: number }) {
|
||||
const { themeV2 } = useTheme().contextual("elevated")
|
||||
const theme = useThemes().contextual("elevated")
|
||||
const value = () => {
|
||||
const value = props.values.at(-1)
|
||||
if (value === undefined) return "--"
|
||||
@@ -515,13 +515,13 @@ function ProcessStat(props: { label: string; values: readonly number[]; unit: st
|
||||
return (
|
||||
<box flexDirection="row">
|
||||
<box width={7}>
|
||||
<text fg={themeV2.text.subdued}>{props.label}</text>
|
||||
<text fg={theme.text.subdued}>{props.label}</text>
|
||||
</box>
|
||||
<box flexGrow={1}>
|
||||
<text fg={props.values.length ? themeV2.text.default : themeV2.text.subdued}>{brailleGraph(props.values)}</text>
|
||||
<text fg={props.values.length ? theme.text.default : theme.text.subdued}>{brailleGraph(props.values)}</text>
|
||||
</box>
|
||||
<box width={8} alignItems="flex-end">
|
||||
<text fg={props.values.length ? themeV2.text.default : themeV2.text.subdued}>{value()}</text>
|
||||
<text fg={props.values.length ? theme.text.default : theme.text.subdued}>{value()}</text>
|
||||
</box>
|
||||
</box>
|
||||
)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { createMemo, createSignal } from "solid-js"
|
||||
import { useConfig } from "../config"
|
||||
import { useTheme } from "../context/theme"
|
||||
import { useThemes } from "../context/theme"
|
||||
import { DialogSelect } from "../ui/dialog-select"
|
||||
import { useToast } from "../ui/toast"
|
||||
|
||||
@@ -93,6 +93,14 @@ export const settings: Setting[] = [
|
||||
values: ["none", "auto"],
|
||||
keywords: ["transcript", "messages"],
|
||||
},
|
||||
{
|
||||
title: "Enabled",
|
||||
category: "Tabs",
|
||||
path: ["tabs", "enabled"],
|
||||
default: false,
|
||||
values: [false, true],
|
||||
labels: ["off", "on"],
|
||||
},
|
||||
{
|
||||
title: "Layout",
|
||||
category: "Diffs",
|
||||
@@ -267,7 +275,7 @@ export function settingID(setting: Setting) {
|
||||
export function DialogConfig(props: { current?: string }) {
|
||||
const config = useConfig()
|
||||
const toast = useToast()
|
||||
const themeState = useTheme()
|
||||
const themes = useThemes()
|
||||
const current = Math.max(
|
||||
0,
|
||||
settings.findIndex((setting) => settingID(setting) === props.current),
|
||||
@@ -280,12 +288,12 @@ export function DialogConfig(props: { current?: string }) {
|
||||
if (!result || typeof result !== "object") return undefined
|
||||
return (result as Record<string, unknown>)[key]
|
||||
}, config.data)
|
||||
if (setting.path.join(".") === "theme.name") return current ?? themeState.selected
|
||||
if (setting.path.join(".") === "theme.name") return current ?? themes.selected
|
||||
return current ?? setting.default
|
||||
}
|
||||
const values = (setting: Setting) =>
|
||||
setting.path.join(".") === "theme.name"
|
||||
? Object.keys(themeState.all()).sort((a, b) => a.localeCompare(b, undefined, { sensitivity: "base" }))
|
||||
? Object.keys(themes.all()).sort((a, b) => a.localeCompare(b, undefined, { sensitivity: "base" }))
|
||||
: setting.values
|
||||
const display = (setting: Setting) => {
|
||||
const current = value(setting)
|
||||
|
||||
@@ -11,7 +11,7 @@ import { describeOS, describeTerminal } from "../util/system"
|
||||
import { useTuiApp } from "../context/runtime"
|
||||
|
||||
export function DialogDebug() {
|
||||
const { themeV2 } = useTheme()
|
||||
const theme = useTheme()
|
||||
const dialog = useDialog()
|
||||
const route = useRoute()
|
||||
const local = useLocal()
|
||||
@@ -55,10 +55,10 @@ export function DialogDebug() {
|
||||
return (
|
||||
<box paddingLeft={2} paddingRight={2} gap={1} paddingBottom={1}>
|
||||
<box flexDirection="row" justifyContent="space-between">
|
||||
<text fg={themeV2.text.default} attributes={TextAttributes.BOLD}>
|
||||
<text fg={theme.text.default} attributes={TextAttributes.BOLD}>
|
||||
Debug
|
||||
</text>
|
||||
<text fg={themeV2.text.subdued} onMouseUp={() => dialog.clear()}>
|
||||
<text fg={theme.text.subdued} onMouseUp={() => dialog.clear()}>
|
||||
esc
|
||||
</text>
|
||||
</box>
|
||||
@@ -68,10 +68,10 @@ export function DialogDebug() {
|
||||
<For each={entries()}>
|
||||
{(entry) => (
|
||||
<box flexDirection="row" gap={1}>
|
||||
<text flexShrink={0} fg={themeV2.text.subdued}>
|
||||
<text flexShrink={0} fg={theme.text.subdued}>
|
||||
{entry.label.padEnd(10)}
|
||||
</text>
|
||||
<text fg={themeV2.text.default} wrapMode="word">
|
||||
<text fg={theme.text.default} wrapMode="word">
|
||||
{entry.value}
|
||||
</text>
|
||||
</box>
|
||||
@@ -79,12 +79,12 @@ export function DialogDebug() {
|
||||
</For>
|
||||
</box>
|
||||
<box flexDirection="row" justifyContent="space-between">
|
||||
<text fg={themeV2.text.subdued}>Share this when reporting an issue.</text>
|
||||
<text fg={theme.text.subdued}>Share this when reporting an issue.</text>
|
||||
<text onMouseUp={copy}>
|
||||
<span style={{ fg: copied() ? themeV2.text.feedback.success.default : themeV2.text.default }}>
|
||||
<span style={{ fg: copied() ? theme.text.feedback.success.default : theme.text.default }}>
|
||||
<b>{copied() ? "✓ copied" : "copy"}</b>{" "}
|
||||
</span>
|
||||
<span style={{ fg: themeV2.text.subdued }}>enter</span>
|
||||
<span style={{ fg: theme.text.subdued }}>enter</span>
|
||||
</text>
|
||||
</box>
|
||||
</box>
|
||||
|
||||
@@ -11,7 +11,7 @@ import { useClipboard } from "../context/clipboard"
|
||||
import { useData } from "../context/data"
|
||||
import { useClient } from "../context/client"
|
||||
import { Keymap } from "../context/keymap"
|
||||
import { useTheme } from "../context/theme"
|
||||
import { useThemes } from "../context/theme"
|
||||
import { useDialog } from "../ui/dialog"
|
||||
import { DialogPrompt } from "../ui/dialog-prompt"
|
||||
import { DialogSelect } from "../ui/dialog-select"
|
||||
@@ -64,7 +64,7 @@ export function DialogIntegration(
|
||||
) {
|
||||
const data = useData()
|
||||
const dialog = useDialog()
|
||||
const { themeV2 } = useTheme().contextual("elevated")
|
||||
const theme = useThemes().contextual("elevated")
|
||||
const options = createMemo(() => {
|
||||
const providers = data.location.websearch.list() ?? []
|
||||
const providersByID = new Map(providers.map((provider) => [provider.id, provider]))
|
||||
@@ -87,7 +87,7 @@ export function DialogIntegration(
|
||||
disabled: methods.length === 0 && credentials.length === 0,
|
||||
gutter:
|
||||
integration.connections.length > 0
|
||||
? () => <text fg={themeV2.text.feedback.success.default}>✓</text>
|
||||
? () => <text fg={theme.text.feedback.success.default}>✓</text>
|
||||
: undefined,
|
||||
onSelect: () => {
|
||||
if (credentials.length) return manageConnections(integration, methods, dialog, props.onConnected)
|
||||
@@ -103,12 +103,12 @@ export function DialogIntegration(
|
||||
options={options()}
|
||||
emptyView={
|
||||
<box paddingLeft={4} paddingRight={4} paddingTop={1}>
|
||||
<text fg={themeV2.text.subdued}>No integrations available</text>
|
||||
<text fg={theme.text.subdued}>No integrations available</text>
|
||||
</box>
|
||||
}
|
||||
noMatchView={
|
||||
<box paddingLeft={4} paddingRight={4} paddingTop={1}>
|
||||
<text fg={themeV2.text.subdued}>No integrations found</text>
|
||||
<text fg={theme.text.subdued}>No integrations found</text>
|
||||
</box>
|
||||
}
|
||||
/>
|
||||
@@ -303,16 +303,16 @@ function CommandPending(props: {
|
||||
|
||||
function CommandView(props: { title: string; output: string; message: string }) {
|
||||
const dialog = useDialog()
|
||||
const { themeV2 } = useTheme().contextual("elevated")
|
||||
const { themeV2: overlayTheme } = useTheme().contextual("overlay")
|
||||
const theme = useThemes().contextual("elevated")
|
||||
const overlayTheme = useThemes().contextual("overlay")
|
||||
onMount(() => dialog.setSize("large"))
|
||||
return (
|
||||
<box gap={1} paddingBottom={1}>
|
||||
<box flexDirection="row" justifyContent="space-between" paddingLeft={2} paddingRight={2}>
|
||||
<text attributes={TextAttributes.BOLD} fg={themeV2.text.default}>
|
||||
<text attributes={TextAttributes.BOLD} fg={theme.text.default}>
|
||||
{props.title}
|
||||
</text>
|
||||
<text fg={themeV2.text.subdued} onMouseUp={() => dialog.clear()}>
|
||||
<text fg={theme.text.subdued} onMouseUp={() => dialog.clear()}>
|
||||
esc close
|
||||
</text>
|
||||
</box>
|
||||
@@ -326,7 +326,7 @@ function CommandView(props: { title: string; output: string; message: string })
|
||||
<text fg={overlayTheme.text.default}>{props.output.trim()}</text>
|
||||
</box>
|
||||
<box paddingLeft={2} paddingRight={2}>
|
||||
<text fg={themeV2.text.subdued}>{props.message}</text>
|
||||
<text fg={theme.text.subdued}>{props.message}</text>
|
||||
</box>
|
||||
</box>
|
||||
)
|
||||
@@ -341,7 +341,7 @@ function KeyMethod(props: {
|
||||
const dialog = useDialog()
|
||||
const client = useClient()
|
||||
const toast = useToast()
|
||||
const { themeV2 } = useTheme().contextual("elevated")
|
||||
const theme = useThemes().contextual("elevated")
|
||||
const [error, setError] = createSignal<string>()
|
||||
|
||||
return (
|
||||
@@ -360,7 +360,7 @@ function KeyMethod(props: {
|
||||
.catch((cause) => setError(message(cause)))
|
||||
}}
|
||||
description={() => (
|
||||
<Show when={error()}>{(value) => <text fg={themeV2.text.feedback.error.default}>{value()}</text>}</Show>
|
||||
<Show when={error()}>{(value) => <text fg={theme.text.feedback.error.default}>{value()}</text>}</Show>
|
||||
)}
|
||||
/>
|
||||
)
|
||||
@@ -516,7 +516,7 @@ function OAuthCode(props: {
|
||||
const dialog = useDialog()
|
||||
const client = useClient()
|
||||
const toast = useToast()
|
||||
const { themeV2 } = useTheme().contextual("elevated")
|
||||
const theme = useThemes().contextual("elevated")
|
||||
const [error, setError] = createSignal<string>()
|
||||
let settled = false
|
||||
|
||||
@@ -550,9 +550,9 @@ function OAuthCode(props: {
|
||||
}}
|
||||
description={() => (
|
||||
<box gap={1}>
|
||||
<text fg={themeV2.text.subdued}>{props.attempt.instructions}</text>
|
||||
<Link href={props.attempt.url} fg={themeV2.markdown.link} />
|
||||
<Show when={error()}>{(value) => <text fg={themeV2.text.feedback.error.default}>{value()}</text>}</Show>
|
||||
<text fg={theme.text.subdued}>{props.attempt.instructions}</text>
|
||||
<Link href={props.attempt.url} fg={theme.markdown.link} />
|
||||
<Show when={error()}>{(value) => <text fg={theme.text.feedback.error.default}>{value()}</text>}</Show>
|
||||
</box>
|
||||
)}
|
||||
/>
|
||||
@@ -561,31 +561,31 @@ function OAuthCode(props: {
|
||||
|
||||
function OAuthView(props: { title: string; url?: string; instructions?: string; message: string; copy?: boolean }) {
|
||||
const dialog = useDialog()
|
||||
const { themeV2 } = useTheme().contextual("elevated")
|
||||
const theme = useThemes().contextual("elevated")
|
||||
return (
|
||||
<box paddingLeft={2} paddingRight={2} gap={1} paddingBottom={1}>
|
||||
<box flexDirection="row" justifyContent="space-between">
|
||||
<text attributes={TextAttributes.BOLD} fg={themeV2.text.default}>
|
||||
<text attributes={TextAttributes.BOLD} fg={theme.text.default}>
|
||||
{props.title}
|
||||
</text>
|
||||
<text fg={themeV2.text.subdued} onMouseUp={() => dialog.clear()}>
|
||||
<text fg={theme.text.subdued} onMouseUp={() => dialog.clear()}>
|
||||
esc
|
||||
</text>
|
||||
</box>
|
||||
<Show when={props.url}>
|
||||
{(url) => (
|
||||
<box gap={1}>
|
||||
<Link href={url()} fg={themeV2.markdown.link} />
|
||||
<Link href={url()} fg={theme.markdown.link} />
|
||||
<Show when={props.instructions}>
|
||||
{(instructions) => <text fg={themeV2.text.subdued}>{instructions()}</text>}
|
||||
{(instructions) => <text fg={theme.text.subdued}>{instructions()}</text>}
|
||||
</Show>
|
||||
</box>
|
||||
)}
|
||||
</Show>
|
||||
<text fg={themeV2.text.subdued}>{props.message}</text>
|
||||
<text fg={theme.text.subdued}>{props.message}</text>
|
||||
<Show when={props.copy}>
|
||||
<text fg={themeV2.text.default}>
|
||||
c <span style={{ fg: themeV2.text.subdued }}>copy</span>
|
||||
<text fg={theme.text.default}>
|
||||
c <span style={{ fg: theme.text.subdued }}>copy</span>
|
||||
</text>
|
||||
</Show>
|
||||
</box>
|
||||
|
||||
@@ -5,7 +5,7 @@ import { Keymap } from "../context/keymap"
|
||||
import { pipe, sortBy } from "remeda"
|
||||
import { DialogSelect } from "../ui/dialog-select"
|
||||
import { useDialog } from "../ui/dialog"
|
||||
import { useTheme } from "../context/theme"
|
||||
import { useThemes } from "../context/theme"
|
||||
import { TextAttributes, type ScrollBoxRenderable } from "@opentui/core"
|
||||
import type { McpServer } from "@opencode-ai/client"
|
||||
import { useClipboard } from "../context/clipboard"
|
||||
@@ -20,12 +20,12 @@ function statusError(status: McpServer["status"]) {
|
||||
}
|
||||
|
||||
function Status(props: { enabled: boolean; loading: boolean }) {
|
||||
const { themeV2 } = useTheme().contextual("elevated")
|
||||
if (props.loading) return <span style={{ fg: themeV2.text.subdued }}>⋯ Loading</span>
|
||||
const theme = useThemes().contextual("elevated")
|
||||
if (props.loading) return <span style={{ fg: theme.text.subdued }}>⋯ Loading</span>
|
||||
if (props.enabled) {
|
||||
return <span style={{ fg: themeV2.text.feedback.success.default, attributes: TextAttributes.BOLD }}>✓ Enabled</span>
|
||||
return <span style={{ fg: theme.text.feedback.success.default, attributes: TextAttributes.BOLD }}>✓ Enabled</span>
|
||||
}
|
||||
return <span style={{ fg: themeV2.text.subdued }}>○ Disabled</span>
|
||||
return <span style={{ fg: theme.text.subdued }}>○ Disabled</span>
|
||||
}
|
||||
|
||||
export function DialogMcp() {
|
||||
@@ -33,7 +33,7 @@ export function DialogMcp() {
|
||||
const dialog = useDialog()
|
||||
const client = useClient()
|
||||
const toast = useToast()
|
||||
const { themeV2 } = useTheme().contextual("elevated")
|
||||
const theme = useThemes().contextual("elevated")
|
||||
const [focused, setFocused] = createSignal<string>()
|
||||
const [detail, setDetail] = createSignal<McpServer>()
|
||||
const [loading, setLoading] = createSignal<string | null>(null)
|
||||
@@ -110,7 +110,7 @@ export function DialogMcp() {
|
||||
]}
|
||||
footer={
|
||||
<Show when={focusedError()}>
|
||||
<text fg={themeV2.text.subdued}>enter to view error</text>
|
||||
<text fg={theme.text.subdued}>enter to view error</text>
|
||||
</Show>
|
||||
}
|
||||
/>
|
||||
@@ -134,8 +134,8 @@ function DialogMcpError(props: { server: McpServer; onBack: () => void }) {
|
||||
const dialog = useDialog()
|
||||
const clipboard = useClipboard()
|
||||
const toast = useToast()
|
||||
const { themeV2 } = useTheme().contextual("elevated")
|
||||
const { themeV2: overlayTheme } = useTheme().contextual("overlay")
|
||||
const theme = useThemes().contextual("elevated")
|
||||
const overlayTheme = useThemes().contextual("overlay")
|
||||
const dimensions = useTerminalDimensions()
|
||||
const config = useConfig().data
|
||||
const [copied, setCopied] = createSignal(false)
|
||||
@@ -171,14 +171,14 @@ function DialogMcpError(props: { server: McpServer; onBack: () => void }) {
|
||||
return (
|
||||
<box paddingLeft={4} paddingRight={4} paddingBottom={1} gap={1}>
|
||||
<box flexDirection="row" justifyContent="space-between">
|
||||
<text attributes={TextAttributes.BOLD} fg={themeV2.text.default}>
|
||||
<text attributes={TextAttributes.BOLD} fg={theme.text.default}>
|
||||
MCP server: {props.server.name}
|
||||
</text>
|
||||
<text fg={themeV2.text.subdued} onMouseUp={props.onBack}>
|
||||
<text fg={theme.text.subdued} onMouseUp={props.onBack}>
|
||||
esc back
|
||||
</text>
|
||||
</box>
|
||||
<text fg={themeV2.text.feedback.error.default}>✗ Failed</text>
|
||||
<text fg={theme.text.feedback.error.default}>✗ Failed</text>
|
||||
<box
|
||||
backgroundColor={overlayTheme.background.default}
|
||||
paddingLeft={2}
|
||||
@@ -198,8 +198,8 @@ function DialogMcpError(props: { server: McpServer; onBack: () => void }) {
|
||||
</scrollbox>
|
||||
</box>
|
||||
<box flexDirection="row" justifyContent="space-between">
|
||||
<text fg={themeV2.text.subdued}>↑↓ scroll</text>
|
||||
<text fg={themeV2.text.subdued} onMouseUp={copy}>
|
||||
<text fg={theme.text.subdued}>↑↓ scroll</text>
|
||||
<text fg={theme.text.subdued} onMouseUp={copy}>
|
||||
{copied() ? "✓ copied" : "c copy details"}
|
||||
</text>
|
||||
</box>
|
||||
|
||||
@@ -6,7 +6,7 @@ import { DialogSelect, type DialogSelectOption } from "../ui/dialog-select"
|
||||
import { useDialog } from "../ui/dialog"
|
||||
import { useClient } from "../context/client"
|
||||
import { Keymap } from "../context/keymap"
|
||||
import { useTheme } from "../context/theme"
|
||||
import { useThemes } from "../context/theme"
|
||||
import { useData } from "../context/data"
|
||||
import { abbreviateHome } from "../runtime"
|
||||
import { useTuiPaths } from "../context/runtime"
|
||||
@@ -38,7 +38,7 @@ export function DialogMoveSession(props: DialogMoveSessionProps) {
|
||||
const dialog = useDialog()
|
||||
const client = useClient()
|
||||
const dimensions = useTerminalDimensions()
|
||||
const { themeV2 } = useTheme().contextual("elevated")
|
||||
const theme = useThemes().contextual("elevated")
|
||||
const sessionData = useData()
|
||||
const route = useRoute()
|
||||
const toast = useToast()
|
||||
@@ -172,18 +172,18 @@ export function DialogMoveSession(props: DialogMoveSessionProps) {
|
||||
return {
|
||||
title,
|
||||
titleView: isRemoving ? (
|
||||
<span style={{ fg: themeV2.text.feedback.error.default }}>Deleting {item.location}</span>
|
||||
<span style={{ fg: theme.text.feedback.error.default }}>Deleting {item.location}</span>
|
||||
) : deleting ? (
|
||||
<span style={{ fg: themeV2.text.action.destructive.default }}>
|
||||
<span style={{ fg: theme.text.action.destructive.default }}>
|
||||
Press {shortcuts.get("dialog.move_session.delete")} again to confirm
|
||||
</span>
|
||||
) : suffix ? (
|
||||
<>
|
||||
{visible.slice(0, split)}
|
||||
<span style={{ fg: themeV2.text.subdued }}>{visible.slice(split)}</span>
|
||||
<span style={{ fg: theme.text.subdued }}>{visible.slice(split)}</span>
|
||||
</>
|
||||
) : undefined,
|
||||
bg: deleting ? themeV2.background.action.destructive.default : undefined,
|
||||
bg: deleting ? theme.background.action.destructive.default : undefined,
|
||||
value: {
|
||||
type: "directory",
|
||||
directory: item.location,
|
||||
@@ -316,7 +316,7 @@ export function DialogMoveSession(props: DialogMoveSessionProps) {
|
||||
title="Move session"
|
||||
titleView={
|
||||
<box flexDirection="row" gap={1}>
|
||||
<text fg={themeV2.text.default} attributes={TextAttributes.BOLD}>
|
||||
<text fg={theme.text.default} attributes={TextAttributes.BOLD}>
|
||||
Move session
|
||||
</text>
|
||||
<Show when={working() || directories.loading || loadedProject.loading}>
|
||||
@@ -329,25 +329,25 @@ export function DialogMoveSession(props: DialogMoveSessionProps) {
|
||||
emptyView={
|
||||
showError() ? (
|
||||
<box paddingLeft={4} paddingRight={4} paddingTop={1}>
|
||||
<text fg={themeV2.text.feedback.error.default} attributes={TextAttributes.BOLD}>
|
||||
<text fg={theme.text.feedback.error.default} attributes={TextAttributes.BOLD}>
|
||||
Could not load project directories
|
||||
</text>
|
||||
<text fg={themeV2.text.subdued}>{errorMessage(loadError())}</text>
|
||||
<text fg={themeV2.text.subdued}>Close and reopen Move session to try again.</text>
|
||||
<text fg={theme.text.subdued}>{errorMessage(loadError())}</text>
|
||||
<text fg={theme.text.subdued}>Close and reopen Move session to try again.</text>
|
||||
</box>
|
||||
) : directories.loading || loadedProject.loading ? (
|
||||
<box paddingLeft={4} paddingRight={4} paddingTop={1}>
|
||||
<text fg={themeV2.text.subdued}>Loading project directories…</text>
|
||||
<text fg={theme.text.subdued}>Loading project directories…</text>
|
||||
</box>
|
||||
) : (
|
||||
<box paddingLeft={4} paddingRight={4} paddingTop={1}>
|
||||
<text fg={themeV2.text.subdued}>No project directories available</text>
|
||||
<text fg={theme.text.subdued}>No project directories available</text>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
noMatchView={
|
||||
<box paddingLeft={4} paddingRight={4} paddingTop={1}>
|
||||
<text fg={themeV2.text.subdued}>No project directories found</text>
|
||||
<text fg={theme.text.subdued}>No project directories found</text>
|
||||
</box>
|
||||
}
|
||||
locked={showError() || directories.loading || loadedProject.loading || Boolean(removing())}
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useTerminalDimensions } from "@opentui/solid"
|
||||
import { createMemo, createResource, createSignal, For, Show } from "solid-js"
|
||||
import { renderUnicodeCompact } from "uqr"
|
||||
import { useClient } from "../context/client"
|
||||
import { useTheme } from "../context/theme"
|
||||
import { useThemes } from "../context/theme"
|
||||
import { useDialog } from "../ui/dialog"
|
||||
import { errorMessage } from "../util/error"
|
||||
|
||||
@@ -16,7 +16,7 @@ export function DialogPair(props: { credentials?: DialogPairCredentials }) {
|
||||
const client = useClient()
|
||||
const dialog = useDialog()
|
||||
const dimensions = useTerminalDimensions()
|
||||
const { themeV2 } = useTheme().contextual("elevated")
|
||||
const theme = useThemes().contextual("elevated")
|
||||
const [loadError, setLoadError] = createSignal<unknown>()
|
||||
const [showPassword, setShowPassword] = createSignal(false)
|
||||
const [passwordHover, setPasswordHover] = createSignal(false)
|
||||
@@ -47,17 +47,17 @@ export function DialogPair(props: { credentials?: DialogPairCredentials }) {
|
||||
<box flexDirection={horizontal() ? "row" : "column"} alignItems={horizontal() ? "flex-start" : "center"} gap={2}>
|
||||
<box width={horizontal() ? 29 : "100%"} flexShrink={0} gap={1}>
|
||||
<box>
|
||||
<text fg={themeV2.text.subdued}>URLs</text>
|
||||
<For each={value.urls}>{(url) => <text fg={themeV2.text.default}>{url}</text>}</For>
|
||||
<text fg={theme.text.subdued}>URLs</text>
|
||||
<For each={value.urls}>{(url) => <text fg={theme.text.default}>{url}</text>}</For>
|
||||
</box>
|
||||
<box>
|
||||
<text fg={themeV2.text.subdued}>Username</text>
|
||||
<text fg={themeV2.text.default}>{value.username}</text>
|
||||
<text fg={theme.text.subdued}>Username</text>
|
||||
<text fg={theme.text.default}>{value.username}</text>
|
||||
</box>
|
||||
<box>
|
||||
<text fg={themeV2.text.subdued}>Password</text>
|
||||
<text fg={theme.text.subdued}>Password</text>
|
||||
<text
|
||||
fg={passwordHover() ? themeV2.text.default : themeV2.text.subdued}
|
||||
fg={passwordHover() ? theme.text.default : theme.text.subdued}
|
||||
wrapMode="word"
|
||||
onMouseOver={() => setPasswordHover(true)}
|
||||
onMouseOut={() => setPasswordHover(false)}
|
||||
@@ -67,7 +67,7 @@ export function DialogPair(props: { credentials?: DialogPairCredentials }) {
|
||||
</text>
|
||||
</box>
|
||||
<Show when={value.urls.some((url) => ["localhost", "127.0.0.1", "[::1]"].includes(new URL(url).hostname))}>
|
||||
<text fg={themeV2.text.subdued} wrapMode="word">
|
||||
<text fg={theme.text.subdued} wrapMode="word">
|
||||
Run `opencode service set hostname 0.0.0.0` to access the service remotely.
|
||||
</text>
|
||||
</Show>
|
||||
@@ -78,7 +78,7 @@ export function DialogPair(props: { credentials?: DialogPairCredentials }) {
|
||||
flexShrink={0}
|
||||
alignItems={horizontal() ? "flex-end" : "center"}
|
||||
>
|
||||
<text fg={themeV2.text.default}>{renderUnicodeCompact(JSON.stringify(value), { border: 1 })}</text>
|
||||
<text fg={theme.text.default}>{renderUnicodeCompact(JSON.stringify(value), { border: 1 })}</text>
|
||||
</box>
|
||||
</box>
|
||||
)
|
||||
@@ -87,17 +87,17 @@ export function DialogPair(props: { credentials?: DialogPairCredentials }) {
|
||||
return (
|
||||
<box paddingLeft={2} paddingRight={2} paddingBottom={1} gap={1}>
|
||||
<box flexDirection="row" justifyContent="space-between">
|
||||
<text fg={themeV2.text.default} attributes={TextAttributes.BOLD}>
|
||||
<text fg={theme.text.default} attributes={TextAttributes.BOLD}>
|
||||
Pair
|
||||
</text>
|
||||
<text fg={themeV2.text.subdued} onMouseUp={() => dialog.clear()}>
|
||||
<text fg={theme.text.subdued} onMouseUp={() => dialog.clear()}>
|
||||
esc
|
||||
</text>
|
||||
</box>
|
||||
<Show
|
||||
when={loadError()}
|
||||
fallback={
|
||||
<Show when={info()} fallback={<text fg={themeV2.text.subdued}>Loading server information…</text>}>
|
||||
<Show when={info()} fallback={<text fg={theme.text.subdued}>Loading server information…</text>}>
|
||||
<Show
|
||||
when={dimensions().height >= 36}
|
||||
fallback={
|
||||
@@ -116,11 +116,11 @@ export function DialogPair(props: { credentials?: DialogPairCredentials }) {
|
||||
>
|
||||
{(error) => (
|
||||
<box>
|
||||
<text fg={themeV2.text.feedback.error.default} attributes={TextAttributes.BOLD}>
|
||||
<text fg={theme.text.feedback.error.default} attributes={TextAttributes.BOLD}>
|
||||
Could not load server information
|
||||
</text>
|
||||
<text fg={themeV2.text.subdued}>{errorMessage(error())}</text>
|
||||
<text fg={themeV2.text.subdued}>Close and reopen Pair to try again.</text>
|
||||
<text fg={theme.text.subdued}>{errorMessage(error())}</text>
|
||||
<text fg={theme.text.subdued}>Close and reopen Pair to try again.</text>
|
||||
</box>
|
||||
)}
|
||||
</Show>
|
||||
|
||||
@@ -2,12 +2,12 @@ import { InputRenderable, TextAttributes } from "@opentui/core"
|
||||
import { Slug } from "@opencode-ai/core/util/slug"
|
||||
import { createSignal, onMount } from "solid-js"
|
||||
import { Keymap } from "../context/keymap"
|
||||
import { useTheme } from "../context/theme"
|
||||
import { useThemes } from "../context/theme"
|
||||
import { useDialog, type DialogContext } from "../ui/dialog"
|
||||
|
||||
export function DialogProjectCopyName(props: { onConfirm: (name: string) => void }) {
|
||||
const dialog = useDialog()
|
||||
const { themeV2 } = useTheme().contextual("elevated")
|
||||
const theme = useThemes().contextual("elevated")
|
||||
const shortcuts = Keymap.useShortcuts()
|
||||
const [inputTarget, setInputTarget] = createSignal<InputRenderable>()
|
||||
let input: InputRenderable
|
||||
@@ -47,10 +47,10 @@ export function DialogProjectCopyName(props: { onConfirm: (name: string) => void
|
||||
return (
|
||||
<box paddingLeft={2} paddingRight={2} gap={1}>
|
||||
<box flexDirection="row" justifyContent="space-between">
|
||||
<text attributes={TextAttributes.BOLD} fg={themeV2.text.default}>
|
||||
<text attributes={TextAttributes.BOLD} fg={theme.text.default}>
|
||||
Name project copy
|
||||
</text>
|
||||
<text fg={themeV2.text.subdued} onMouseUp={() => dialog.clear()}>
|
||||
<text fg={theme.text.subdued} onMouseUp={() => dialog.clear()}>
|
||||
esc
|
||||
</text>
|
||||
</box>
|
||||
@@ -61,17 +61,17 @@ export function DialogProjectCopyName(props: { onConfirm: (name: string) => void
|
||||
}}
|
||||
onSubmit={confirm}
|
||||
placeholder="Project copy name"
|
||||
placeholderColor={themeV2.text.subdued}
|
||||
textColor={themeV2.text.formfield.default}
|
||||
focusedTextColor={themeV2.text.formfield.default}
|
||||
cursorColor={themeV2.text.formfield.default}
|
||||
placeholderColor={theme.text.subdued}
|
||||
textColor={theme.text.formfield.default}
|
||||
focusedTextColor={theme.text.formfield.default}
|
||||
cursorColor={theme.text.formfield.default}
|
||||
/>
|
||||
<box paddingBottom={1} flexDirection="row" gap={2}>
|
||||
<text fg={themeV2.text.default}>
|
||||
enter <span style={{ fg: themeV2.text.subdued }}>submit</span>
|
||||
<text fg={theme.text.default}>
|
||||
enter <span style={{ fg: theme.text.subdued }}>submit</span>
|
||||
</text>
|
||||
<text fg={themeV2.text.default}>
|
||||
{shortcuts.get("dialog.project_copy.generate")} <span style={{ fg: themeV2.text.subdued }}>generate one</span>
|
||||
<text fg={theme.text.default}>
|
||||
{shortcuts.get("dialog.project_copy.generate")} <span style={{ fg: theme.text.subdued }}>generate one</span>
|
||||
</text>
|
||||
</box>
|
||||
</box>
|
||||
|
||||
@@ -2,7 +2,7 @@ import { RGBA, TextAttributes } from "@opentui/core"
|
||||
import open from "open"
|
||||
import { createSignal } from "solid-js"
|
||||
import { Keymap } from "../context/keymap"
|
||||
import { useTheme } from "../context/theme"
|
||||
import { useThemes } from "../context/theme"
|
||||
import { useDialog, type DialogContext } from "../ui/dialog"
|
||||
import { Link } from "../ui/link"
|
||||
import { BgPulse } from "./bg-pulse"
|
||||
@@ -38,9 +38,9 @@ function panelOverlay(color: RGBA) {
|
||||
|
||||
export function DialogRetryAction(props: DialogRetryActionProps) {
|
||||
const dialog = useDialog()
|
||||
const { themeV2 } = useTheme().contextual("elevated")
|
||||
const theme = useThemes().contextual("elevated")
|
||||
const showGoTreatment = () => props.link === GO_URL
|
||||
const textBg = () => (showGoTreatment() ? panelOverlay(themeV2.background.default) : undefined)
|
||||
const textBg = () => (showGoTreatment() ? panelOverlay(theme.background.default) : undefined)
|
||||
const [selected, setSelected] = createSignal<"dismiss" | "action">("action")
|
||||
|
||||
Keymap.createLayer(() => ({
|
||||
@@ -85,26 +85,26 @@ export function DialogRetryAction(props: DialogRetryActionProps) {
|
||||
) : null}
|
||||
<box zIndex={1} paddingLeft={PAD_X} paddingRight={PAD_X} paddingBottom={1} gap={1}>
|
||||
<box flexDirection="row" justifyContent="space-between">
|
||||
<text attributes={TextAttributes.BOLD} fg={themeV2.text.default} bg={textBg()}>
|
||||
<text attributes={TextAttributes.BOLD} fg={theme.text.default} bg={textBg()}>
|
||||
{props.title}
|
||||
</text>
|
||||
<text fg={themeV2.text.subdued} bg={textBg()} onMouseUp={() => dialog.clear()}>
|
||||
<text fg={theme.text.subdued} bg={textBg()} onMouseUp={() => dialog.clear()}>
|
||||
esc
|
||||
</text>
|
||||
</box>
|
||||
<box gap={0}>
|
||||
<text fg={themeV2.text.subdued} bg={textBg()}>
|
||||
<text fg={theme.text.subdued} bg={textBg()}>
|
||||
{props.message}
|
||||
</text>
|
||||
</box>
|
||||
{props.link ? (
|
||||
showGoTreatment() ? (
|
||||
<box alignItems="center" justifyContent="flex-end" height={7} paddingBottom={1}>
|
||||
<Link href={props.link} fg={themeV2.markdown.link} bg={textBg()} wrapMode="none" />
|
||||
<Link href={props.link} fg={theme.markdown.link} bg={textBg()} wrapMode="none" />
|
||||
</box>
|
||||
) : (
|
||||
<box width="100%" flexDirection="row" justifyContent="center" paddingBottom={1}>
|
||||
<Link href={props.link} fg={themeV2.markdown.link} wrapMode="none" />
|
||||
<Link href={props.link} fg={theme.markdown.link} wrapMode="none" />
|
||||
</box>
|
||||
)
|
||||
) : (
|
||||
@@ -115,13 +115,13 @@ export function DialogRetryAction(props: DialogRetryActionProps) {
|
||||
paddingLeft={2}
|
||||
paddingRight={2}
|
||||
backgroundColor={
|
||||
selected() === "dismiss" ? themeV2.background.action.primary.focused : RGBA.fromInts(0, 0, 0, 0)
|
||||
selected() === "dismiss" ? theme.background.action.primary.focused : RGBA.fromInts(0, 0, 0, 0)
|
||||
}
|
||||
onMouseOver={() => setSelected("dismiss")}
|
||||
onMouseUp={() => dismiss(props, dialog)}
|
||||
>
|
||||
<text
|
||||
fg={selected() === "dismiss" ? themeV2.text.action.primary.focused : themeV2.text.subdued}
|
||||
fg={selected() === "dismiss" ? theme.text.action.primary.focused : theme.text.subdued}
|
||||
bg={selected() === "dismiss" ? undefined : textBg()}
|
||||
attributes={selected() === "dismiss" ? TextAttributes.BOLD : undefined}
|
||||
>
|
||||
@@ -132,13 +132,13 @@ export function DialogRetryAction(props: DialogRetryActionProps) {
|
||||
paddingLeft={2}
|
||||
paddingRight={2}
|
||||
backgroundColor={
|
||||
selected() === "action" ? themeV2.background.action.primary.focused : RGBA.fromInts(0, 0, 0, 0)
|
||||
selected() === "action" ? theme.background.action.primary.focused : RGBA.fromInts(0, 0, 0, 0)
|
||||
}
|
||||
onMouseOver={() => setSelected("action")}
|
||||
onMouseUp={() => runAction(props, dialog)}
|
||||
>
|
||||
<text
|
||||
fg={selected() === "action" ? themeV2.text.action.primary.focused : themeV2.text.default}
|
||||
fg={selected() === "action" ? theme.text.action.primary.focused : theme.text.default}
|
||||
bg={selected() === "action" ? undefined : textBg()}
|
||||
attributes={selected() === "action" ? TextAttributes.BOLD : undefined}
|
||||
>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { TextAttributes } from "@opentui/core"
|
||||
import { Keymap } from "../context/keymap"
|
||||
import { useTheme } from "../context/theme"
|
||||
import { useThemes } from "../context/theme"
|
||||
import { useDialog } from "../ui/dialog"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { For } from "solid-js"
|
||||
@@ -13,7 +13,7 @@ export function DialogSessionDeleteFailed(props: {
|
||||
onDone?: () => void
|
||||
}) {
|
||||
const dialog = useDialog()
|
||||
const { themeV2 } = useTheme().contextual("elevated")
|
||||
const theme = useThemes().contextual("elevated")
|
||||
const [store, setStore] = createStore({
|
||||
active: "delete" as "delete" | "restore",
|
||||
})
|
||||
@@ -64,17 +64,17 @@ export function DialogSessionDeleteFailed(props: {
|
||||
return (
|
||||
<box paddingLeft={2} paddingRight={2} gap={1}>
|
||||
<box flexDirection="row" justifyContent="space-between">
|
||||
<text attributes={TextAttributes.BOLD} fg={themeV2.text.default}>
|
||||
<text attributes={TextAttributes.BOLD} fg={theme.text.default}>
|
||||
Failed to Delete Session
|
||||
</text>
|
||||
<text fg={themeV2.text.subdued} onMouseUp={() => dialog.clear()}>
|
||||
<text fg={theme.text.subdued} onMouseUp={() => dialog.clear()}>
|
||||
esc
|
||||
</text>
|
||||
</box>
|
||||
<text fg={themeV2.text.subdued} wrapMode="word">
|
||||
<text fg={theme.text.subdued} wrapMode="word">
|
||||
{`The session "${props.session}" could not be deleted because the workspace "${props.workspace}" is not available.`}
|
||||
</text>
|
||||
<text fg={themeV2.text.subdued} wrapMode="word">
|
||||
<text fg={theme.text.subdued} wrapMode="word">
|
||||
Choose how you want to recover this broken workspace session.
|
||||
</text>
|
||||
<box flexDirection="column" paddingBottom={1} gap={1}>
|
||||
@@ -86,7 +86,7 @@ export function DialogSessionDeleteFailed(props: {
|
||||
paddingRight={1}
|
||||
paddingTop={1}
|
||||
paddingBottom={1}
|
||||
backgroundColor={item.id === store.active ? themeV2.background.action.primary.focused : undefined}
|
||||
backgroundColor={item.id === store.active ? theme.background.action.primary.focused : undefined}
|
||||
onMouseUp={() => {
|
||||
setStore("active", item.id)
|
||||
void confirm()
|
||||
@@ -94,12 +94,12 @@ export function DialogSessionDeleteFailed(props: {
|
||||
>
|
||||
<text
|
||||
attributes={TextAttributes.BOLD}
|
||||
fg={item.id === store.active ? themeV2.text.action.primary.focused : themeV2.text.default}
|
||||
fg={item.id === store.active ? theme.text.action.primary.focused : theme.text.default}
|
||||
>
|
||||
{item.title}
|
||||
</text>
|
||||
<text
|
||||
fg={item.id === store.active ? themeV2.text.action.primary.focused : themeV2.text.subdued}
|
||||
fg={item.id === store.active ? theme.text.action.primary.focused : theme.text.subdued}
|
||||
wrapMode="word"
|
||||
>
|
||||
{item.description}
|
||||
|
||||
@@ -7,7 +7,7 @@ import { useRoute } from "../context/route"
|
||||
import { useData } from "../context/data"
|
||||
import { Keymap } from "../context/keymap"
|
||||
import { Locale } from "../util/locale"
|
||||
import { useTheme } from "../context/theme"
|
||||
import { useThemes } from "../context/theme"
|
||||
import { useClient } from "../context/client"
|
||||
import { useLocal } from "../context/local"
|
||||
import { createDebouncedSignal } from "../util/signal"
|
||||
@@ -15,14 +15,18 @@ import { useToast } from "../ui/toast"
|
||||
import { DialogSessionRename } from "./dialog-session-rename"
|
||||
import { Spinner } from "./spinner"
|
||||
import { errorMessage } from "../util/error"
|
||||
import { useSessionTabs } from "../context/session-tabs"
|
||||
|
||||
export function DialogSessionList() {
|
||||
const dialog = useDialog()
|
||||
const route = useRoute()
|
||||
const data = useData()
|
||||
const { themeV2, mode } = useTheme().contextual("elevated")
|
||||
const themes = useThemes()
|
||||
const theme = themes.contextual("elevated")
|
||||
const mode = themes.mode
|
||||
const client = useClient()
|
||||
const local = useLocal()
|
||||
const sessionTabs = useSessionTabs()
|
||||
const toast = useToast()
|
||||
const [filter, setFilter] = createSignal("")
|
||||
const shortcuts = Keymap.useShortcuts()
|
||||
@@ -77,6 +81,7 @@ export function DialogSessionList() {
|
||||
})
|
||||
|
||||
const quickSwitchHint = createMemo(() => {
|
||||
if (sessionTabs.enabled()) return
|
||||
const first = shortcuts.get("session.quick_switch.1")
|
||||
const last = shortcuts.get("session.quick_switch.9")
|
||||
if (!first || !last) return
|
||||
@@ -94,7 +99,7 @@ export function DialogSessionList() {
|
||||
.filter((session) => !session.parentID)
|
||||
.map((session) => [session.id, session]),
|
||||
)
|
||||
const pinned = local.session.pinned().filter((sessionID) => sessionMap.has(sessionID))
|
||||
const pinned = sessionTabs.enabled() ? [] : local.session.pinned().filter((sessionID) => sessionMap.has(sessionID))
|
||||
const pinnedSet = new Set(pinned)
|
||||
const slotByID = new Map(local.session.slots().map((sessionID, index) => [sessionID, index + 1]))
|
||||
|
||||
@@ -102,20 +107,20 @@ export function DialogSessionList() {
|
||||
const directory = session.location.directory
|
||||
const footer =
|
||||
directory !== data.location.info()?.project.directory ? Locale.truncate(path.basename(directory), 20) : ""
|
||||
const slot = slotByID.get(session.id)
|
||||
const slot = sessionTabs.enabled() ? undefined : slotByID.get(session.id)
|
||||
const deleting = toDelete() === session.id
|
||||
return {
|
||||
title: deleting ? `Press ${shortcuts.get("session.delete")} again to confirm` : session.title,
|
||||
value: session.id,
|
||||
category,
|
||||
footer,
|
||||
bg: deleting ? themeV2.background.action.destructive.focused : undefined,
|
||||
fg: deleting ? themeV2.text.action.destructive.focused : undefined,
|
||||
bg: deleting ? theme.background.action.destructive.focused : undefined,
|
||||
fg: deleting ? theme.text.action.destructive.focused : undefined,
|
||||
gutter: data.session.family(session.id).some((id) => data.session.status(id) === "running")
|
||||
? () => <Spinner />
|
||||
: slot === undefined
|
||||
? undefined
|
||||
: () => <text fg={themeV2.hue.accent[mode() === "light" ? 800 : 200]}>{slot}</text>,
|
||||
: () => <text fg={theme.hue.accent[mode() === "light" ? 800 : 200]}>{slot}</text>,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -143,12 +148,12 @@ export function DialogSessionList() {
|
||||
}}
|
||||
emptyView={
|
||||
<box paddingLeft={4} paddingRight={4} paddingTop={1}>
|
||||
<text fg={themeV2.text.subdued}>No sessions available</text>
|
||||
<text fg={theme.text.subdued}>No sessions available</text>
|
||||
</box>
|
||||
}
|
||||
noMatchView={
|
||||
<box paddingLeft={4} paddingRight={4} paddingTop={1}>
|
||||
<text fg={searchState().error ? themeV2.text.feedback.error.default : themeV2.text.subdued}>
|
||||
<text fg={searchState().error ? theme.text.feedback.error.default : theme.text.subdued}>
|
||||
{searchState().message}
|
||||
</text>
|
||||
</box>
|
||||
@@ -162,7 +167,8 @@ export function DialogSessionList() {
|
||||
{
|
||||
command: "session.pin.toggle",
|
||||
title: "pin/unpin",
|
||||
onTrigger: (option: { value: string }) => local.session.togglePin(option.value),
|
||||
hidden: sessionTabs.enabled(),
|
||||
onTrigger: (option) => local.session.togglePin(option.value),
|
||||
},
|
||||
{
|
||||
command: "session.delete",
|
||||
|
||||
@@ -15,7 +15,7 @@ export type DialogSkillProps = {
|
||||
export function DialogSkill(props: DialogSkillProps) {
|
||||
const dialog = useDialog()
|
||||
const data = useData()
|
||||
const { themeV2 } = useTheme()
|
||||
const theme = useTheme()
|
||||
dialog.setSize("large")
|
||||
|
||||
const [loadError, setLoadError] = createSignal<unknown>()
|
||||
@@ -63,29 +63,29 @@ export function DialogSkill(props: DialogSkillProps) {
|
||||
<Switch
|
||||
fallback={
|
||||
<box paddingLeft={4} paddingRight={4} paddingTop={1}>
|
||||
<text fg={themeV2.text.subdued}>No skills available</text>
|
||||
<text fg={theme.text.subdued}>No skills available</text>
|
||||
</box>
|
||||
}
|
||||
>
|
||||
<Match when={showError()}>
|
||||
<box paddingLeft={4} paddingRight={4} paddingTop={1}>
|
||||
<text fg={themeV2.text.feedback.error.default} attributes={TextAttributes.BOLD}>
|
||||
<text fg={theme.text.feedback.error.default} attributes={TextAttributes.BOLD}>
|
||||
Could not load skills
|
||||
</text>
|
||||
<text fg={themeV2.text.subdued}>{errorMessage(loadError())}</text>
|
||||
<text fg={themeV2.text.subdued}>Close and reopen Skills to try again.</text>
|
||||
<text fg={theme.text.subdued}>{errorMessage(loadError())}</text>
|
||||
<text fg={theme.text.subdued}>Close and reopen Skills to try again.</text>
|
||||
</box>
|
||||
</Match>
|
||||
<Match when={skills.loading}>
|
||||
<box paddingLeft={4} paddingRight={4} paddingTop={1}>
|
||||
<text fg={themeV2.text.subdued}>Loading skills…</text>
|
||||
<text fg={theme.text.subdued}>Loading skills…</text>
|
||||
</box>
|
||||
</Match>
|
||||
</Switch>
|
||||
}
|
||||
noMatchView={
|
||||
<box paddingLeft={4} paddingRight={4} paddingTop={1}>
|
||||
<text fg={themeV2.text.subdued}>No skills found</text>
|
||||
<text fg={theme.text.subdued}>No skills found</text>
|
||||
</box>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -3,7 +3,7 @@ import { DialogSelect } from "../ui/dialog-select"
|
||||
import { createMemo, createSignal } from "solid-js"
|
||||
import { Locale } from "../util/locale"
|
||||
import { Keymap } from "../context/keymap"
|
||||
import { useTheme } from "../context/theme"
|
||||
import { useThemes } from "../context/theme"
|
||||
import { usePromptStash, type StashEntry } from "./prompt/stash"
|
||||
|
||||
function getRelativeTime(timestamp: number): string {
|
||||
@@ -29,7 +29,7 @@ function getStashPreview(input: string, maxLength: number = 50): string {
|
||||
export function DialogStash(props: { onSelect: (entry: StashEntry) => void }) {
|
||||
const dialog = useDialog()
|
||||
const stash = usePromptStash()
|
||||
const { themeV2 } = useTheme().contextual("elevated")
|
||||
const theme = useThemes().contextual("elevated")
|
||||
const shortcuts = Keymap.useShortcuts()
|
||||
|
||||
const [toDelete, setToDelete] = createSignal<number>()
|
||||
@@ -45,8 +45,8 @@ export function DialogStash(props: { onSelect: (entry: StashEntry) => void }) {
|
||||
title: isDeleting
|
||||
? `Press ${shortcuts.get("stash.delete")} again to confirm`
|
||||
: getStashPreview(entry.prompt.text),
|
||||
bg: isDeleting ? themeV2.background.action.destructive.focused : undefined,
|
||||
fg: isDeleting ? themeV2.text.action.destructive.focused : undefined,
|
||||
bg: isDeleting ? theme.background.action.destructive.focused : undefined,
|
||||
fg: isDeleting ? theme.text.action.destructive.focused : undefined,
|
||||
value: index,
|
||||
description: getRelativeTime(entry.timestamp),
|
||||
footer: lineCount > 1 ? `~${lineCount} lines` : undefined,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { TextAttributes } from "@opentui/core"
|
||||
import { useTheme } from "../context/theme"
|
||||
import { useThemes } from "../context/theme"
|
||||
import { useDialog } from "../ui/dialog"
|
||||
import { useData } from "../context/data"
|
||||
import { For, Match, Switch, Show, createMemo } from "solid-js"
|
||||
@@ -8,30 +8,30 @@ export type DialogStatusProps = {}
|
||||
|
||||
export function DialogStatus() {
|
||||
const data = useData()
|
||||
const { themeV2 } = useTheme().contextual("elevated")
|
||||
const theme = useThemes().contextual("elevated")
|
||||
const dialog = useDialog()
|
||||
|
||||
const mcp = createMemo(() => data.location.mcp.server.list() ?? [])
|
||||
const color = (status: string) => {
|
||||
if (status === "connected") return themeV2.text.feedback.success.default
|
||||
if (status === "failed") return themeV2.text.feedback.error.default
|
||||
if (status === "needs_auth") return themeV2.text.feedback.warning.default
|
||||
if (status === "needs_client_registration") return themeV2.text.feedback.error.default
|
||||
return themeV2.text.subdued
|
||||
if (status === "connected") return theme.text.feedback.success.default
|
||||
if (status === "failed") return theme.text.feedback.error.default
|
||||
if (status === "needs_auth") return theme.text.feedback.warning.default
|
||||
if (status === "needs_client_registration") return theme.text.feedback.error.default
|
||||
return theme.text.subdued
|
||||
}
|
||||
return (
|
||||
<box paddingLeft={2} paddingRight={2} gap={1} paddingBottom={1}>
|
||||
<box flexDirection="row" justifyContent="space-between">
|
||||
<text fg={themeV2.text.default} attributes={TextAttributes.BOLD}>
|
||||
<text fg={theme.text.default} attributes={TextAttributes.BOLD}>
|
||||
Status
|
||||
</text>
|
||||
<text fg={themeV2.text.subdued} onMouseUp={() => dialog.clear()}>
|
||||
<text fg={theme.text.subdued} onMouseUp={() => dialog.clear()}>
|
||||
esc
|
||||
</text>
|
||||
</box>
|
||||
<Show when={mcp().length > 0} fallback={<text fg={themeV2.text.default}>No MCP servers</text>}>
|
||||
<Show when={mcp().length > 0} fallback={<text fg={theme.text.default}>No MCP servers</text>}>
|
||||
<box>
|
||||
<text fg={themeV2.text.default}>
|
||||
<text fg={theme.text.default}>
|
||||
{mcp().length} MCP server{mcp().length === 1 ? "" : "s"}
|
||||
</text>
|
||||
<For each={mcp()}>
|
||||
@@ -40,9 +40,9 @@ export function DialogStatus() {
|
||||
<text flexShrink={0} style={{ fg: color(item.status.status) }}>
|
||||
•
|
||||
</text>
|
||||
<text fg={themeV2.text.default} wrapMode="word">
|
||||
<text fg={theme.text.default} wrapMode="word">
|
||||
<b>{item.name}</b>{" "}
|
||||
<span style={{ fg: themeV2.text.subdued }}>
|
||||
<span style={{ fg: theme.text.subdued }}>
|
||||
<Switch fallback={item.status.status}>
|
||||
<Match when={item.status.status === "connected"}>Connected</Match>
|
||||
<Match when={item.status.status === "failed" && item.status}>{(val) => val().error}</Match>
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { DialogSelect, type DialogSelectRef } from "../ui/dialog-select"
|
||||
import { useTheme } from "../context/theme"
|
||||
import { useThemes } from "../context/theme"
|
||||
import { useDialog } from "../ui/dialog"
|
||||
import { onCleanup } from "solid-js"
|
||||
|
||||
export function DialogThemeList() {
|
||||
const theme = useTheme()
|
||||
const options = Object.keys(theme.all())
|
||||
const themes = useThemes()
|
||||
const options = Object.keys(themes.all())
|
||||
.sort((a, b) => a.localeCompare(b, undefined, { sensitivity: "base" }))
|
||||
.map((value) => ({
|
||||
title: value,
|
||||
@@ -14,10 +14,10 @@ export function DialogThemeList() {
|
||||
const dialog = useDialog()
|
||||
let confirmed = false
|
||||
let ref: DialogSelectRef<string>
|
||||
const initial = theme.selected
|
||||
const initial = themes.selected
|
||||
|
||||
onCleanup(() => {
|
||||
if (!confirmed) theme.set(initial)
|
||||
if (!confirmed) themes.set(initial)
|
||||
})
|
||||
|
||||
return (
|
||||
@@ -26,10 +26,10 @@ export function DialogThemeList() {
|
||||
options={options}
|
||||
current={initial}
|
||||
onMove={(opt) => {
|
||||
theme.set(opt.value)
|
||||
themes.set(opt.value)
|
||||
}}
|
||||
onSelect={(opt) => {
|
||||
theme.set(opt.value)
|
||||
themes.set(opt.value)
|
||||
confirmed = true
|
||||
dialog.clear()
|
||||
}}
|
||||
@@ -38,12 +38,12 @@ export function DialogThemeList() {
|
||||
}}
|
||||
onFilter={(query) => {
|
||||
if (query.length === 0) {
|
||||
theme.set(initial)
|
||||
themes.set(initial)
|
||||
return
|
||||
}
|
||||
|
||||
const first = ref.filtered[0]
|
||||
if (first) theme.set(first.value)
|
||||
if (first) themes.set(first.value)
|
||||
}}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -4,7 +4,7 @@ import type { VcsFileStatus } from "@opencode-ai/client"
|
||||
import { createMemo, For } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { FilePath } from "../ui/file-path"
|
||||
import { useTheme } from "../context/theme"
|
||||
import { useThemes } from "../context/theme"
|
||||
import { useConfig } from "../config"
|
||||
import { useDialog, type DialogContext } from "../ui/dialog"
|
||||
import { getScrollAcceleration } from "../util/scroll"
|
||||
@@ -31,8 +31,8 @@ export function DialogWorkspaceFileChanges(props: {
|
||||
message?: string
|
||||
}) {
|
||||
const dialog = useDialog()
|
||||
const { themeV2 } = useTheme().contextual("elevated")
|
||||
const { themeV2: overlayTheme } = useTheme().contextual("overlay")
|
||||
const theme = useThemes().contextual("elevated")
|
||||
const overlayTheme = useThemes().contextual("overlay")
|
||||
const config = useConfig().data
|
||||
const dimensions = useTerminalDimensions()
|
||||
const scrollAcceleration = createMemo(() => getScrollAcceleration(config))
|
||||
@@ -72,15 +72,15 @@ export function DialogWorkspaceFileChanges(props: {
|
||||
return (
|
||||
<box gap={1}>
|
||||
<box flexDirection="row" justifyContent="space-between" paddingLeft={2} paddingRight={2}>
|
||||
<text attributes={TextAttributes.BOLD} fg={themeV2.text.default}>
|
||||
<text attributes={TextAttributes.BOLD} fg={theme.text.default}>
|
||||
{props.title ?? "File Changes Found"}
|
||||
</text>
|
||||
<text fg={themeV2.text.subdued} onMouseUp={() => dialog.clear()}>
|
||||
<text fg={theme.text.subdued} onMouseUp={() => dialog.clear()}>
|
||||
esc
|
||||
</text>
|
||||
</box>
|
||||
<box paddingLeft={2} paddingRight={2}>
|
||||
<text fg={themeV2.text.subdued} wrapMode="word">
|
||||
<text fg={theme.text.subdued} wrapMode="word">
|
||||
{props.message ?? "Do you want to move these changes with the session?"}
|
||||
</text>
|
||||
</box>
|
||||
@@ -118,16 +118,14 @@ export function DialogWorkspaceFileChanges(props: {
|
||||
<box
|
||||
paddingLeft={2}
|
||||
paddingRight={2}
|
||||
backgroundColor={item === store.active ? themeV2.background.action.primary.focused : undefined}
|
||||
backgroundColor={item === store.active ? theme.background.action.primary.focused : undefined}
|
||||
onMouseUp={() => {
|
||||
setStore("active", item)
|
||||
props.onSelect(item)
|
||||
dialog.clear()
|
||||
}}
|
||||
>
|
||||
<text fg={item === store.active ? themeV2.text.action.primary.focused : themeV2.text.subdued}>
|
||||
{item}
|
||||
</text>
|
||||
<text fg={item === store.active ? theme.text.action.primary.focused : theme.text.subdued}>{item}</text>
|
||||
</box>
|
||||
)}
|
||||
</For>
|
||||
|
||||
@@ -5,10 +5,10 @@ import { tint } from "../theme/color"
|
||||
import { logo } from "../logo"
|
||||
|
||||
export function Logo() {
|
||||
const { themeV2 } = useTheme()
|
||||
const theme = useTheme()
|
||||
|
||||
const renderLine = (line: string, fg: RGBA, bold: boolean): JSX.Element[] => {
|
||||
const shadow = tint(themeV2.background.default, fg, 0.25)
|
||||
const shadow = tint(theme.background.default, fg, 0.25)
|
||||
const attrs = bold ? TextAttributes.BOLD : undefined
|
||||
return Array.from(line).map((char) => {
|
||||
if (char === "_") {
|
||||
@@ -52,8 +52,8 @@ export function Logo() {
|
||||
<For each={logo.left}>
|
||||
{(line, index) => (
|
||||
<box flexDirection="row" gap={1}>
|
||||
<box flexDirection="row">{renderLine(line, themeV2.text.subdued, false)}</box>
|
||||
<box flexDirection="row">{renderLine(logo.right[index()], themeV2.text.default, true)}</box>
|
||||
<box flexDirection="row">{renderLine(line, theme.text.subdued, false)}</box>
|
||||
<box flexDirection="row">{renderLine(logo.right[index()], theme.text.default, true)}</box>
|
||||
</box>
|
||||
)}
|
||||
</For>
|
||||
|
||||
@@ -1,20 +1,20 @@
|
||||
import { useTheme } from "../context/theme"
|
||||
|
||||
export function PluginRouteMissing(props: { id: string; name: string; onHome: () => void }) {
|
||||
const { themeV2 } = useTheme()
|
||||
const theme = useTheme()
|
||||
|
||||
return (
|
||||
<box width="100%" height="100%" alignItems="center" justifyContent="center" flexDirection="column" gap={1}>
|
||||
<text fg={themeV2.text.feedback.warning.default}>
|
||||
<text fg={theme.text.feedback.warning.default}>
|
||||
Unknown plugin route: {props.id}/{props.name}
|
||||
</text>
|
||||
<box
|
||||
onMouseUp={props.onHome}
|
||||
backgroundColor={themeV2.background.action.primary.hovered}
|
||||
backgroundColor={theme.background.action.primary.hovered}
|
||||
paddingLeft={1}
|
||||
paddingRight={1}
|
||||
>
|
||||
<text fg={themeV2.text.action.primary.hovered}>go home</text>
|
||||
<text fg={theme.text.action.primary.hovered}>go home</text>
|
||||
</box>
|
||||
</box>
|
||||
)
|
||||
|
||||
@@ -12,7 +12,7 @@ import { getScrollAcceleration } from "../../util/scroll"
|
||||
import { useTuiPaths } from "../../context/runtime"
|
||||
import { useConfig } from "../../config"
|
||||
import { useLocation } from "../../context/location"
|
||||
import { useTheme } from "../../context/theme"
|
||||
import { useThemes } from "../../context/theme"
|
||||
import { SplitBorder } from "../../ui/border"
|
||||
import { useTerminalDimensions } from "@opentui/solid"
|
||||
import { Locale } from "../../util/locale"
|
||||
@@ -57,7 +57,7 @@ export function Autocomplete(props: {
|
||||
const data = useData()
|
||||
const keymap = Keymap.use()
|
||||
const keymapCommands = Keymap.useCommands()
|
||||
const { themeV2 } = useTheme().contextual("overlay")
|
||||
const theme = useThemes().contextual("overlay")
|
||||
const dimensions = useTerminalDimensions()
|
||||
const frecency = useFrecency()
|
||||
const config = useConfig().data
|
||||
@@ -698,11 +698,11 @@ export function Autocomplete(props: {
|
||||
width={position().width}
|
||||
zIndex={100}
|
||||
{...SplitBorder}
|
||||
borderColor={themeV2.border.default}
|
||||
borderColor={theme.border.default}
|
||||
>
|
||||
<scrollbox
|
||||
ref={(r: ScrollBoxRenderable) => (scroll = r)}
|
||||
backgroundColor={themeV2.background.default}
|
||||
backgroundColor={theme.background.default}
|
||||
height={height()}
|
||||
scrollbarOptions={{ visible: false }}
|
||||
scrollAcceleration={scrollAcceleration()}
|
||||
@@ -711,9 +711,7 @@ export function Autocomplete(props: {
|
||||
each={options()}
|
||||
fallback={
|
||||
<box paddingLeft={1} paddingRight={1}>
|
||||
<text fg={emptyError() ? themeV2.text.feedback.error.default : themeV2.text.subdued}>
|
||||
{emptyMessage()}
|
||||
</text>
|
||||
<text fg={emptyError() ? theme.text.feedback.error.default : theme.text.subdued}>{emptyMessage()}</text>
|
||||
</box>
|
||||
}
|
||||
>
|
||||
@@ -721,7 +719,7 @@ export function Autocomplete(props: {
|
||||
<box
|
||||
paddingLeft={1}
|
||||
paddingRight={1}
|
||||
backgroundColor={index === store.selected ? themeV2.background.action.primary.focused : undefined}
|
||||
backgroundColor={index === store.selected ? theme.background.action.primary.focused : undefined}
|
||||
flexDirection="row"
|
||||
onMouseMove={() => {
|
||||
setStore("input", "mouse")
|
||||
@@ -737,14 +735,14 @@ export function Autocomplete(props: {
|
||||
onMouseUp={() => select()}
|
||||
>
|
||||
<text
|
||||
fg={index === store.selected ? themeV2.text.action.primary.focused : themeV2.text.default}
|
||||
fg={index === store.selected ? theme.text.action.primary.focused : theme.text.default}
|
||||
flexShrink={0}
|
||||
>
|
||||
{option().display}
|
||||
</text>
|
||||
<Show when={option().description}>
|
||||
<text
|
||||
fg={index === store.selected ? themeV2.text.action.primary.focused : themeV2.text.subdued}
|
||||
fg={index === store.selected ? theme.text.action.primary.focused : theme.text.subdued}
|
||||
wrapMode="none"
|
||||
>
|
||||
{" " + option().description?.trimStart()}
|
||||
|
||||
@@ -12,7 +12,7 @@ import { registerOpencodeSpinner } from "../register-spinner"
|
||||
import path from "path"
|
||||
import { fileURLToPath } from "url"
|
||||
import { useLocal } from "../../context/local"
|
||||
import { useTheme } from "../../context/theme"
|
||||
import { useTheme, useThemes } from "../../context/theme"
|
||||
import { tint } from "../../theme/color"
|
||||
import { EmptyBorder, SplitBorder } from "../../ui/border"
|
||||
import { useTuiPaths, useTuiTerminalEnvironment } from "../../context/runtime"
|
||||
@@ -189,7 +189,8 @@ export function Prompt(props: PromptProps) {
|
||||
const renderer = useRenderer()
|
||||
const exit = useExit()
|
||||
const dimensions = useTerminalDimensions()
|
||||
const { themeV2, syntax } = useTheme()
|
||||
const theme = useTheme()
|
||||
const { currentSyntax: syntax } = useThemes()
|
||||
const animationsEnabled = createMemo(() => config.animations ?? true)
|
||||
const list = createMemo(() => props.placeholders?.normal ?? [])
|
||||
const shell = createMemo(() => props.placeholders?.shell ?? [])
|
||||
@@ -296,8 +297,8 @@ export function Prompt(props: PromptProps) {
|
||||
|
||||
createEffect(() => {
|
||||
if (!input || input.isDestroyed) return
|
||||
if (props.disabled) input.cursorColor = themeV2.background.surface.offset
|
||||
if (!props.disabled) input.cursorColor = themeV2.text.default
|
||||
if (props.disabled) input.cursorColor = theme.background.surface.offset
|
||||
if (!props.disabled) input.cursorColor = theme.text.default
|
||||
})
|
||||
|
||||
const usage = createMemo(() => {
|
||||
@@ -1299,10 +1300,10 @@ export function Prompt(props: PromptProps) {
|
||||
}
|
||||
|
||||
const highlight = createMemo(() => {
|
||||
if (leader()) return themeV2.border.default
|
||||
if (store.mode === "shell") return themeV2.text.action.primary.selected
|
||||
if (leader()) return theme.border.default
|
||||
if (store.mode === "shell") return theme.text.action.primary.selected
|
||||
const agent = local.agent.current()
|
||||
if (!agent) return themeV2.border.default
|
||||
if (!agent) return theme.border.default
|
||||
return local.agent.color(agent.id)
|
||||
})
|
||||
const agentLabel = createMemo(() => {
|
||||
@@ -1324,7 +1325,7 @@ export function Prompt(props: PromptProps) {
|
||||
() => !!local.agent.current() && store.mode === "normal" && showVariant(),
|
||||
animationsEnabled,
|
||||
)
|
||||
const borderHighlight = createMemo(() => tint(themeV2.border.default, highlight(), agentMetaAlpha()))
|
||||
const borderHighlight = createMemo(() => tint(theme.border.default, highlight(), agentMetaAlpha()))
|
||||
|
||||
const placeholderText = createMemo(() => {
|
||||
if (props.showPlaceholder === false) return undefined
|
||||
@@ -1344,7 +1345,7 @@ export function Prompt(props: PromptProps) {
|
||||
|
||||
const spinnerDef = createMemo(() => {
|
||||
const agent = status() === "running" ? local.agent.current() : local.agent.current()
|
||||
const color = agent ? local.agent.color(agent.id) : themeV2.border.default
|
||||
const color = agent ? local.agent.color(agent.id) : theme.border.default
|
||||
return {
|
||||
frames: createFrames({
|
||||
color,
|
||||
@@ -1364,7 +1365,7 @@ export function Prompt(props: PromptProps) {
|
||||
})
|
||||
const maxHeight = createMemo(() => Math.max(6, Math.floor(dimensions().height / 3)))
|
||||
|
||||
const promptBg = createMemo(() => themeV2.raise(themeV2.background.surface.offset))
|
||||
const promptBg = createMemo(() => theme.raise(theme.background.surface.offset))
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -1390,9 +1391,9 @@ export function Prompt(props: PromptProps) {
|
||||
<textarea
|
||||
width="100%"
|
||||
placeholder={placeholderText()}
|
||||
placeholderColor={themeV2.text.subdued}
|
||||
textColor={leader() ? themeV2.text.subdued : themeV2.text.default}
|
||||
focusedTextColor={leader() ? themeV2.text.subdued : themeV2.text.default}
|
||||
placeholderColor={theme.text.subdued}
|
||||
textColor={leader() ? theme.text.subdued : theme.text.default}
|
||||
focusedTextColor={leader() ? theme.text.subdued : theme.text.default}
|
||||
minHeight={1}
|
||||
maxHeight={maxHeight()}
|
||||
onContentChange={() => {
|
||||
@@ -1452,7 +1453,7 @@ export function Prompt(props: PromptProps) {
|
||||
setTimeout(() => {
|
||||
// setTimeout is a workaround and needs to be addressed properly
|
||||
if (!input || input.isDestroyed) return
|
||||
input.cursorColor = themeV2.text.default
|
||||
input.cursorColor = theme.text.default
|
||||
}, 0)
|
||||
}}
|
||||
onMouseDown={(r: MouseEvent) => {
|
||||
@@ -1460,7 +1461,7 @@ export function Prompt(props: PromptProps) {
|
||||
r.target?.focus()
|
||||
}}
|
||||
focusedBackgroundColor="transparent"
|
||||
cursorColor={props.disabled ? themeV2.background.surface.offset : themeV2.text.default}
|
||||
cursorColor={props.disabled ? theme.background.surface.offset : theme.text.default}
|
||||
syntaxStyle={syntax()}
|
||||
/>
|
||||
<box flexDirection="row" flexShrink={0} paddingTop={1} gap={1} justifyContent="space-between">
|
||||
@@ -1470,24 +1471,24 @@ export function Prompt(props: PromptProps) {
|
||||
<>
|
||||
<text fg={fadeColor(highlight(), agentMetaAlpha())}>{label()}</text>
|
||||
<Show when={store.mode === "normal" && local.permission.mode === "auto"}>
|
||||
<text fg={fadeColor(themeV2.text.subdued, agentMetaAlpha())}>auto</text>
|
||||
<text fg={fadeColor(theme.text.subdued, agentMetaAlpha())}>auto</text>
|
||||
</Show>
|
||||
<Show when={store.mode === "normal"}>
|
||||
<box flexDirection="row" gap={1}>
|
||||
<text fg={fadeColor(themeV2.text.subdued, modelMetaAlpha())}>·</text>
|
||||
<text fg={fadeColor(theme.text.subdued, modelMetaAlpha())}>·</text>
|
||||
<text
|
||||
flexShrink={0}
|
||||
fg={fadeColor(leader() ? themeV2.text.subdued : themeV2.text.default, modelMetaAlpha())}
|
||||
fg={fadeColor(leader() ? theme.text.subdued : theme.text.default, modelMetaAlpha())}
|
||||
>
|
||||
{local.model.parsed().model}
|
||||
</text>
|
||||
<text fg={fadeColor(themeV2.text.subdued, modelMetaAlpha())}>{currentProviderLabel()}</text>
|
||||
<text fg={fadeColor(theme.text.subdued, modelMetaAlpha())}>{currentProviderLabel()}</text>
|
||||
<Show when={showVariant()}>
|
||||
<text fg={fadeColor(themeV2.text.subdued, variantMetaAlpha())}>·</text>
|
||||
<text fg={fadeColor(theme.text.subdued, variantMetaAlpha())}>·</text>
|
||||
<text>
|
||||
<span
|
||||
style={{
|
||||
fg: fadeColor(themeV2.text.feedback.warning.default, variantMetaAlpha()),
|
||||
fg: fadeColor(theme.text.feedback.warning.default, variantMetaAlpha()),
|
||||
bold: true,
|
||||
}}
|
||||
>
|
||||
@@ -1541,12 +1542,12 @@ export function Prompt(props: PromptProps) {
|
||||
<Match when={status() === "running"}>
|
||||
<box flexDirection="row" gap={1} flexGrow={1} justifyContent="flex-start">
|
||||
<box marginLeft={1}>
|
||||
<Show when={config.animations ?? true} fallback={<text fg={themeV2.text.subdued}>[⋯]</text>}>
|
||||
<Show when={config.animations ?? true} fallback={<text fg={theme.text.subdued}>[⋯]</text>}>
|
||||
<spinner color={spinnerDef().color} frames={spinnerDef().frames} interval={40} />
|
||||
</Show>
|
||||
</box>
|
||||
<text
|
||||
fg={store.interrupt > 0 ? themeV2.background.action.primary.default : themeV2.text.default}
|
||||
fg={store.interrupt > 0 ? theme.background.action.primary.default : theme.text.default}
|
||||
wrapMode="none"
|
||||
truncate
|
||||
flexShrink={1}
|
||||
@@ -1554,7 +1555,7 @@ export function Prompt(props: PromptProps) {
|
||||
esc{" "}
|
||||
<span
|
||||
style={{
|
||||
fg: store.interrupt > 0 ? themeV2.background.action.primary.default : themeV2.text.subdued,
|
||||
fg: store.interrupt > 0 ? theme.background.action.primary.default : theme.text.subdued,
|
||||
}}
|
||||
>
|
||||
{store.interrupt > 0 ? "again to interrupt" : "interrupt"}
|
||||
@@ -1565,16 +1566,16 @@ export function Prompt(props: PromptProps) {
|
||||
<Match when={move.progress()}>
|
||||
{(progress) => (
|
||||
<box paddingLeft={3} height={1} minHeight={0} flexShrink={1}>
|
||||
<Spinner color={themeV2.hue.accent[500]}>
|
||||
<Spinner color={theme.hue.accent[500]}>
|
||||
{progress()}
|
||||
<span style={{ fg: themeV2.text.subdued }}>{".".repeat(move.creatingDots())}</span>
|
||||
<span style={{ fg: theme.text.subdued }}>{".".repeat(move.creatingDots())}</span>
|
||||
</Spinner>
|
||||
</box>
|
||||
)}
|
||||
</Match>
|
||||
<Match when={move.pendingNew()}>
|
||||
<box paddingLeft={3} height={1} minHeight={0} flexShrink={1}>
|
||||
<text fg={themeV2.hue.accent[500]} wrapMode="none" truncate>
|
||||
<text fg={theme.hue.accent[500]} wrapMode="none" truncate>
|
||||
(new working copy)
|
||||
</text>
|
||||
</box>
|
||||
@@ -1582,7 +1583,7 @@ export function Prompt(props: PromptProps) {
|
||||
<Match when={true}>
|
||||
<Show when={!props.hint && locationLabel()} fallback={props.hint ?? <text />}>
|
||||
{(location) => (
|
||||
<text fg={themeV2.text.subdued} wrapMode="none" truncate flexGrow={1} flexShrink={1}>
|
||||
<text fg={theme.text.subdued} wrapMode="none" truncate flexGrow={1} flexShrink={1}>
|
||||
{location()}
|
||||
</text>
|
||||
)}
|
||||
@@ -1596,7 +1597,7 @@ export function Prompt(props: PromptProps) {
|
||||
wrapMode="none"
|
||||
truncate
|
||||
flexShrink={1}
|
||||
fg={editorContextLabelState() === "pending" ? themeV2.hue.accent[500] : themeV2.text.subdued}
|
||||
fg={editorContextLabelState() === "pending" ? theme.hue.accent[500] : theme.text.subdued}
|
||||
>
|
||||
{file()}
|
||||
</text>
|
||||
@@ -1606,40 +1607,40 @@ export function Prompt(props: PromptProps) {
|
||||
<Match when={store.mode === "normal"}>
|
||||
<Switch>
|
||||
<Match when={liveWorkStatusVisible() || statusItems().length > 0}>
|
||||
<text fg={themeV2.text.subdued} wrapMode="none" truncate flexShrink={1}>
|
||||
<text fg={theme.text.subdued} wrapMode="none" truncate flexShrink={1}>
|
||||
<Show when={liveWorkStatusVisible() && liveWorkShortcut()}>
|
||||
{(shortcut) => <span style={{ fg: themeV2.text.default }}>{shortcut()} </span>}
|
||||
{(shortcut) => <span style={{ fg: theme.text.default }}>{shortcut()} </span>}
|
||||
</Show>
|
||||
<Show when={subagentStatusLabel()}>
|
||||
{(label) => <span style={{ fg: themeV2.text.subdued }}>{label()}</span>}
|
||||
{(label) => <span style={{ fg: theme.text.subdued }}>{label()}</span>}
|
||||
</Show>
|
||||
<Show when={subagentStatusLabel() && shellStatusLabel()}>
|
||||
<span style={{ fg: themeV2.text.subdued }}> · </span>
|
||||
<span style={{ fg: theme.text.subdued }}> · </span>
|
||||
</Show>
|
||||
<Show when={shellStatusLabel()}>
|
||||
{(label) => <span style={{ fg: themeV2.text.subdued }}>{label()}</span>}
|
||||
{(label) => <span style={{ fg: theme.text.subdued }}>{label()}</span>}
|
||||
</Show>
|
||||
<Show when={liveWorkStatusVisible() && statusItems().length > 0}>
|
||||
<span style={{ fg: themeV2.text.subdued }}> · </span>
|
||||
<span style={{ fg: theme.text.subdued }}> · </span>
|
||||
</Show>
|
||||
<Show when={statusItems().length > 0}>
|
||||
<span style={{ fg: themeV2.text.subdued }}>{statusItems().join(" · ")}</span>
|
||||
<span style={{ fg: theme.text.subdued }}>{statusItems().join(" · ")}</span>
|
||||
</Show>
|
||||
</text>
|
||||
</Match>
|
||||
<Match when={true}>
|
||||
<text fg={themeV2.text.default} flexShrink={0}>
|
||||
{agentShortcut()} <span style={{ fg: themeV2.text.subdued }}>agents</span>
|
||||
<text fg={theme.text.default} flexShrink={0}>
|
||||
{agentShortcut()} <span style={{ fg: theme.text.subdued }}>agents</span>
|
||||
</text>
|
||||
</Match>
|
||||
</Switch>
|
||||
<text fg={themeV2.text.default} flexShrink={0}>
|
||||
{paletteShortcut()} <span style={{ fg: themeV2.text.subdued }}>commands</span>
|
||||
<text fg={theme.text.default} flexShrink={0}>
|
||||
{paletteShortcut()} <span style={{ fg: theme.text.subdued }}>commands</span>
|
||||
</text>
|
||||
</Match>
|
||||
<Match when={store.mode === "shell"}>
|
||||
<text fg={themeV2.text.default} flexShrink={0}>
|
||||
esc <span style={{ fg: themeV2.text.subdued }}>exit shell mode</span>
|
||||
<text fg={theme.text.default} flexShrink={0}>
|
||||
esc <span style={{ fg: theme.text.subdued }}>exit shell mode</span>
|
||||
</text>
|
||||
</Match>
|
||||
</Switch>
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { RGBA } from "@opentui/core"
|
||||
import { useTheme } from "../context/theme"
|
||||
import { useThemes } from "../context/theme"
|
||||
import { Spinner } from "./spinner"
|
||||
|
||||
export function Reconnecting() {
|
||||
const { themeV2 } = useTheme().contextual("elevated")
|
||||
const theme = useThemes().contextual("elevated")
|
||||
|
||||
return (
|
||||
<box
|
||||
@@ -21,15 +21,15 @@ export function Reconnecting() {
|
||||
width={48}
|
||||
maxWidth="90%"
|
||||
flexDirection="column"
|
||||
backgroundColor={themeV2.background.default}
|
||||
backgroundColor={theme.background.default}
|
||||
paddingTop={1}
|
||||
paddingBottom={1}
|
||||
paddingLeft={2}
|
||||
paddingRight={2}
|
||||
gap={1}
|
||||
>
|
||||
<Spinner color={themeV2.text.default}>Restarting service...</Spinner>
|
||||
<text fg={themeV2.text.subdued}>Your session will resume automatically.</text>
|
||||
<Spinner color={theme.text.default}>Restarting service...</Spinner>
|
||||
<text fg={theme.text.subdued}>Your session will resume automatically.</text>
|
||||
</box>
|
||||
</box>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,250 @@
|
||||
import { RGBA, TextAttributes } from "@opentui/core"
|
||||
import { For, Show, createEffect, createMemo, createSignal } from "solid-js"
|
||||
import { useTerminalDimensions } from "@opentui/solid"
|
||||
import { useConfig } from "../config"
|
||||
import { useSessionTabs } from "../context/session-tabs"
|
||||
import { useTheme, useThemes } from "../context/theme"
|
||||
import {
|
||||
adaptiveSessionTabLayout,
|
||||
sessionTabComplete,
|
||||
SESSION_TAB_OVERFLOW_WIDTH,
|
||||
type SessionTabUnread,
|
||||
} from "../context/session-tabs-model"
|
||||
import { createAnimatable, spring } from "../ui/animation"
|
||||
import { Locale } from "../util/locale"
|
||||
import { stringWidth } from "../util/string-width"
|
||||
import { TabPulse } from "./tab-pulse"
|
||||
import { tint } from "../theme/color"
|
||||
|
||||
type ContextController = ReturnType<typeof useSessionTabs>
|
||||
export type SessionTabsStatus = Omit<ReturnType<ContextController["status"]>, "unread"> & {
|
||||
unread: SessionTabUnread | undefined
|
||||
}
|
||||
export type SessionTabsController = Pick<ContextController, "tabs" | "current" | "select" | "close"> & {
|
||||
status(sessionID: string): SessionTabsStatus
|
||||
}
|
||||
|
||||
export function SessionTabs(props: { controller?: SessionTabsController; animations?: boolean } = {}) {
|
||||
const tabs = props.controller ?? useSessionTabs()
|
||||
const dimensions = useTerminalDimensions()
|
||||
const theme = useTheme()
|
||||
const { mode } = useThemes()
|
||||
const config = useConfig().data
|
||||
const animations = () => props.animations ?? config.animations ?? true
|
||||
const [hovered, setHovered] = createSignal<string>()
|
||||
const hueStep = () => (mode() === "light" ? 800 : 200)
|
||||
const accent = () => theme.hue.accent[hueStep()]
|
||||
const activeNumber = () => tint(theme.hue.interactive[hueStep()], theme.background.default, 0.25)
|
||||
const idleNumber = () => tint(theme.text.subdued, theme.background.default, 0.35)
|
||||
const activeID = createMemo(tabs.current)
|
||||
const items = tabs.tabs
|
||||
const layout = createMemo((previous: ReturnType<typeof adaptiveSessionTabLayout> | undefined) =>
|
||||
adaptiveSessionTabLayout(items(), activeID(), dimensions().width, previous?.start),
|
||||
)
|
||||
const statuses = createMemo(
|
||||
() =>
|
||||
new Map(
|
||||
layout().tabs.map((tab) => {
|
||||
const status = tabs.status(tab.sessionID)
|
||||
return [
|
||||
tab.sessionID,
|
||||
{
|
||||
...status,
|
||||
complete: sessionTabComplete(status.unread, status.busy),
|
||||
},
|
||||
] as const
|
||||
}),
|
||||
),
|
||||
)
|
||||
const targets = createMemo(() => ({
|
||||
widths: layout().widths,
|
||||
selections: layout().tabs.map((tab) => Number(tab.sessionID === activeID())),
|
||||
activities: layout().tabs.map((tab) => Number(statuses().get(tab.sessionID)!.complete)),
|
||||
}))
|
||||
const motion = createAnimatable(targets(), {
|
||||
enabled: animations,
|
||||
transition: spring({ visualDuration: 0.1 }),
|
||||
})
|
||||
const identity = createMemo(() =>
|
||||
layout()
|
||||
.tabs.map((tab) => tab.sessionID)
|
||||
.join(":"),
|
||||
)
|
||||
let signature = ""
|
||||
let total = 0
|
||||
|
||||
createEffect(() => {
|
||||
const next = targets()
|
||||
const nextSignature = identity()
|
||||
const reset = (signature && signature !== nextSignature) || (total && total !== layout().total)
|
||||
signature = nextSignature
|
||||
total = layout().total
|
||||
if (reset) return motion.jump(next)
|
||||
motion.animate(next)
|
||||
})
|
||||
|
||||
const visuals = createMemo(() => {
|
||||
const current = signature === identity() && total === layout().total ? motion.value() : targets()
|
||||
const widths = current.widths.map((width) => Math.max(1, Math.round(width)))
|
||||
const active = layout().tabs.findIndex((tab) => tab.sessionID === activeID())
|
||||
if (active !== -1) widths[active]! += layout().total - widths.reduce((sum, width) => sum + width, 0)
|
||||
return new Map(
|
||||
layout().tabs.map((tab, index) => [
|
||||
tab.sessionID,
|
||||
{
|
||||
width: widths[index]!,
|
||||
selection: current.selections[index] ?? Number(tab.sessionID === activeID()),
|
||||
activity: current.activities[index] ?? Number(statuses().get(tab.sessionID)!.complete),
|
||||
},
|
||||
]),
|
||||
)
|
||||
})
|
||||
|
||||
return (
|
||||
<box
|
||||
height={1}
|
||||
flexShrink={0}
|
||||
position="relative"
|
||||
flexDirection="row"
|
||||
zIndex={1}
|
||||
renderAfter={function (buffer) {
|
||||
const x = Math.max(0, this.screenX)
|
||||
const y = this.screenY + this.height
|
||||
const width = Math.min(this.width, buffer.width - x)
|
||||
if (y < 0 || y >= buffer.height || width <= 0) return
|
||||
buffer.fillRect(
|
||||
x,
|
||||
y,
|
||||
width,
|
||||
1,
|
||||
RGBA.fromValues(
|
||||
theme.background.default.r,
|
||||
theme.background.default.g,
|
||||
theme.background.default.b,
|
||||
mode() === "light" ? 0.14 : 0.28,
|
||||
),
|
||||
)
|
||||
}}
|
||||
>
|
||||
<Show when={layout().before > 0}>
|
||||
<text width={SESSION_TAB_OVERFLOW_WIDTH} fg={theme.text.subdued}>
|
||||
‹{layout().before}
|
||||
</text>
|
||||
</Show>
|
||||
<For each={layout().tabs}>
|
||||
{(tab) => {
|
||||
const selected = () => activeID() === tab.sessionID
|
||||
const status = () => statuses().get(tab.sessionID)!
|
||||
const width = () => visuals().get(tab.sessionID)?.width ?? 1
|
||||
const selection = () => visuals().get(tab.sessionID)?.selection ?? Number(selected())
|
||||
const activity = () => visuals().get(tab.sessionID)?.activity ?? Number(status().complete)
|
||||
const background = () => {
|
||||
const base =
|
||||
hovered() === tab.sessionID && !selected()
|
||||
? theme.background.action.primary.hovered
|
||||
: theme.background.default
|
||||
return tint(base, theme.raise(theme.background.surface.offset), selection())
|
||||
}
|
||||
const pulseBackground = () => background()
|
||||
const pulseColor = () => tint(pulseBackground(), theme.text.default, 0.45)
|
||||
const title = () => tab.title ?? "Untitled session"
|
||||
const availableTitleWidth = () => Math.max(1, width() - 3)
|
||||
const visibleTitle = createMemo(() => Locale.takeWidth(title(), availableTitleWidth()))
|
||||
const visibleTitleParts = createMemo(() => Locale.graphemes(visibleTitle()))
|
||||
const fadeWidth = () => (hovered() === tab.sessionID ? 6 : 4)
|
||||
const fadedTitleParts = createMemo(() => visibleTitleParts().slice(-fadeWidth()))
|
||||
const titleFades = createMemo(
|
||||
() => stringWidth(title()) >= availableTitleWidth() && availableTitleWidth() > fadeWidth(),
|
||||
)
|
||||
const foreground = () => {
|
||||
if (hovered() === tab.sessionID) return theme.text.default
|
||||
return tint(theme.text.subdued, theme.text.default, selection())
|
||||
}
|
||||
const numberColor = () => {
|
||||
if (status().attention) return theme.text.feedback.warning.default
|
||||
if (status().unread === "error") return theme.text.feedback.error.default
|
||||
const base =
|
||||
hovered() === tab.sessionID && !selected()
|
||||
? foreground()
|
||||
: tint(idleNumber(), activeNumber(), selection())
|
||||
return tint(base, accent(), activity())
|
||||
}
|
||||
const closeColor = () => tint(theme.text.subdued, theme.text.default, 0.6)
|
||||
return (
|
||||
<box
|
||||
width={width()}
|
||||
position="relative"
|
||||
flexDirection="row"
|
||||
backgroundColor={background()}
|
||||
onMouseOver={() => setHovered(tab.sessionID)}
|
||||
onMouseOut={() => setHovered(undefined)}
|
||||
onMouseUp={() => tabs.select(tab.sessionID)}
|
||||
>
|
||||
<TabPulse
|
||||
enabled={animations()}
|
||||
active={status().busy}
|
||||
complete={status().complete}
|
||||
glow={status().unread === "activity" && !status().busy && !selected() && !status().attention}
|
||||
color={pulseColor()}
|
||||
glowColor={accent()}
|
||||
completionColor={accent()}
|
||||
backgroundColor={pulseBackground()}
|
||||
/>
|
||||
<box zIndex={1} width="100%" flexDirection="row">
|
||||
<text width={1}> </text>
|
||||
<text width={2} fg={numberColor()} attributes={selected() ? TextAttributes.BOLD : undefined}>
|
||||
{items().findIndex((item) => item.sessionID === tab.sessionID) + 1}
|
||||
</text>
|
||||
<Show
|
||||
when={titleFades()}
|
||||
fallback={
|
||||
<text width={availableTitleWidth()} fg={foreground()} wrapMode="none">
|
||||
{visibleTitle()}
|
||||
</text>
|
||||
}
|
||||
>
|
||||
<text width={availableTitleWidth()} fg={foreground()} wrapMode="none">
|
||||
{visibleTitleParts().slice(0, -fadeWidth()).join("")}
|
||||
<For each={fadedTitleParts()}>
|
||||
{(character, index) => (
|
||||
<span
|
||||
style={{
|
||||
fg: tint(
|
||||
foreground(),
|
||||
pulseBackground(),
|
||||
0.2 + 0.72 * (index() / Math.max(1, fadedTitleParts().length - 1)),
|
||||
),
|
||||
}}
|
||||
>
|
||||
{character}
|
||||
</span>
|
||||
)}
|
||||
</For>
|
||||
</text>
|
||||
</Show>
|
||||
<text
|
||||
position="absolute"
|
||||
right={1}
|
||||
zIndex={2}
|
||||
width={1}
|
||||
fg={closeColor()}
|
||||
onMouseUp={(event) => {
|
||||
event.stopPropagation()
|
||||
tabs.close(tab.sessionID)
|
||||
}}
|
||||
>
|
||||
{hovered() === tab.sessionID ? "×" : ""}
|
||||
</text>
|
||||
</box>
|
||||
</box>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
<Show when={layout().after > 0}>
|
||||
<text width={SESSION_TAB_OVERFLOW_WIDTH} fg={theme.text.subdued}>
|
||||
{layout().after}›
|
||||
</text>
|
||||
</Show>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
@@ -11,9 +11,9 @@ export { SPINNER_FRAMES } from "./spinner-frames"
|
||||
registerOpencodeSpinner()
|
||||
|
||||
export function Spinner(props: { children?: JSX.Element; color?: RGBA }) {
|
||||
const { themeV2 } = useTheme()
|
||||
const theme = useTheme()
|
||||
const config = useConfig().data
|
||||
const color = () => props.color ?? themeV2.text.subdued
|
||||
const color = () => props.color ?? theme.text.subdued
|
||||
return (
|
||||
<Show
|
||||
when={config.animations ?? true}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { createEffect, createMemo, createSignal, onCleanup, Show } from "solid-js"
|
||||
import { useTheme } from "../context/theme"
|
||||
import { useThemes } from "../context/theme"
|
||||
import { Spinner } from "./spinner"
|
||||
|
||||
export function StartupLoading(props: { ready: () => boolean }) {
|
||||
const { themeV2 } = useTheme().contextual("elevated")
|
||||
const theme = useThemes().contextual("elevated")
|
||||
const [show, setShow] = createSignal(false)
|
||||
const text = createMemo(() => (props.ready() ? "Finishing startup..." : "Loading plugins..."))
|
||||
let wait: NodeJS.Timeout | undefined
|
||||
@@ -54,8 +54,8 @@ export function StartupLoading(props: { ready: () => boolean }) {
|
||||
return (
|
||||
<Show when={show()}>
|
||||
<box position="absolute" zIndex={5000} left={0} right={0} bottom={1} justifyContent="center" alignItems="center">
|
||||
<box backgroundColor={themeV2.background.default} paddingLeft={1} paddingRight={1}>
|
||||
<Spinner color={themeV2.text.subdued}>{text()}</Spinner>
|
||||
<box backgroundColor={theme.background.default} paddingLeft={1} paddingRight={1}>
|
||||
<Spinner color={theme.text.subdued}>{text()}</Spinner>
|
||||
</box>
|
||||
</box>
|
||||
</Show>
|
||||
|
||||
@@ -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 { useTheme } from "../context/theme"
|
||||
import { useThemes } from "../context/theme"
|
||||
import { useToast } from "../ui/toast"
|
||||
|
||||
export function ThemeErrorToast() {
|
||||
const theme = useTheme()
|
||||
const themes = useThemes()
|
||||
const toast = useToast()
|
||||
|
||||
onCleanup(
|
||||
theme.onError(({ name, error }) =>
|
||||
themes.onError(({ name, error }) =>
|
||||
toast.show({
|
||||
variant: "error",
|
||||
title: `Failed to load theme: ${name}`,
|
||||
|
||||
@@ -122,6 +122,13 @@ export const Info = Schema.Struct({
|
||||
}),
|
||||
}),
|
||||
).annotate({ description: "Session transcript presentation settings" }),
|
||||
tabs: Schema.optional(
|
||||
Schema.Struct({
|
||||
enabled: Schema.optional(Schema.Boolean).annotate({
|
||||
description: "Use a persistent session tab strip instead of pinned quick-switch sessions",
|
||||
}),
|
||||
}),
|
||||
).annotate({ description: "Session tab settings" }),
|
||||
mini: Schema.optional(
|
||||
Schema.Struct({
|
||||
thinking: Schema.optional(Schema.Literals(["show", "hide"])).annotate({
|
||||
|
||||
@@ -87,6 +87,13 @@ export const Definitions = {
|
||||
session_move: keybind("none", "Move session"),
|
||||
session_new: keybind("<leader>n", "Create a new session"),
|
||||
session_list: keybind("<leader>l", "List all sessions"),
|
||||
session_tab_next: keybind("ctrl+tab,<leader>right", "Switch to next open session tab"),
|
||||
session_tab_previous: keybind("ctrl+shift+tab,<leader>left", "Switch to previous open session tab"),
|
||||
session_tab_history_back: keybind("ctrl+o", "Go back in session tab history"),
|
||||
session_tab_history_forward: keybind("ctrl+i", "Go forward in session tab history"),
|
||||
session_tab_next_unread: keybind("<leader>down", "Switch to next unread session tab"),
|
||||
session_tab_previous_unread: keybind("<leader>up", "Switch to previous unread session tab"),
|
||||
session_tab_close: keybind("<leader>w", "Close current session tab"),
|
||||
session_timeline: keybind("<leader>g", "Show session timeline"),
|
||||
session_fork: keybind("none", "Fork session from message"),
|
||||
session_rename: keybind("ctrl+r", "Rename session"),
|
||||
@@ -97,7 +104,7 @@ export const Definitions = {
|
||||
session_background: keybind("ctrl+b", "Background blocking session tools"),
|
||||
session_compact: keybind("<leader>c", "Compact the session"),
|
||||
session_queued_prompts: keybind("<leader>q", "View pending work"),
|
||||
session_child_first: keybind("down,<leader>down", "Toggle subagent picker"),
|
||||
session_child_first: keybind("down", "Toggle subagent picker"),
|
||||
session_child_cycle: keybind("right", "Go to next child session"),
|
||||
session_child_cycle_reverse: keybind("left", "Go to previous child session"),
|
||||
session_parent: keybind("up", "Go to parent session"),
|
||||
@@ -111,6 +118,15 @@ export const Definitions = {
|
||||
session_quick_switch_7: keybind("<leader>7", "Switch to session in quick slot 7"),
|
||||
session_quick_switch_8: keybind("<leader>8", "Switch to session in quick slot 8"),
|
||||
session_quick_switch_9: keybind("<leader>9", "Switch to session in quick slot 9"),
|
||||
session_tab_select_1: keybind("<leader>1,ctrl+1", "Switch to session tab 1"),
|
||||
session_tab_select_2: keybind("<leader>2,ctrl+2", "Switch to session tab 2"),
|
||||
session_tab_select_3: keybind("<leader>3,ctrl+3", "Switch to session tab 3"),
|
||||
session_tab_select_4: keybind("<leader>4,ctrl+4", "Switch to session tab 4"),
|
||||
session_tab_select_5: keybind("<leader>5,ctrl+5", "Switch to session tab 5"),
|
||||
session_tab_select_6: keybind("<leader>6,ctrl+6", "Switch to session tab 6"),
|
||||
session_tab_select_7: keybind("<leader>7,ctrl+7", "Switch to session tab 7"),
|
||||
session_tab_select_8: keybind("<leader>8,ctrl+8", "Switch to session tab 8"),
|
||||
session_tab_select_9: keybind("<leader>9,ctrl+9", "Switch to session tab 9"),
|
||||
|
||||
stash_delete: keybind("ctrl+d", "Delete stash entry"),
|
||||
model_provider_list: keybind("ctrl+a", "Open provider list from model dialog"),
|
||||
@@ -214,24 +230,9 @@ export const Definitions = {
|
||||
"permission.prompt.fullscreen": keybind("ctrl+f", "Toggle permission prompt fullscreen"),
|
||||
"plugins.toggle": keybind("space", "Toggle plugin"),
|
||||
"dialog.mcp.toggle": keybind("space", "Toggle MCP server"),
|
||||
"dialog.plugins.install": keybind("shift+i", "Install plugin from plugin dialog"),
|
||||
|
||||
terminal_suspend: keybind("ctrl+z", "Suspend terminal"),
|
||||
terminal_title_toggle: keybind("none", "Toggle terminal title"),
|
||||
plugin_manager: keybind("none", "Open plugin manager dialog"),
|
||||
plugin_install: keybind("none", "Install plugin"),
|
||||
|
||||
which_key_toggle: keybind("ctrl+alt+k", "Toggle which-key panel"),
|
||||
which_key_layout_toggle: keybind("ctrl+alt+shift+k", "Switch which-key layout"),
|
||||
which_key_pending_toggle: keybind("ctrl+alt+shift+p", "Toggle which-key pending preview"),
|
||||
which_key_group_previous: keybind("ctrl+alt+left,ctrl+alt+[", "Previous which-key group"),
|
||||
which_key_group_next: keybind("ctrl+alt+right,ctrl+alt+]", "Next which-key group"),
|
||||
which_key_scroll_up: keybind("ctrl+alt+up,ctrl+alt+p", "Scroll which-key up"),
|
||||
which_key_scroll_down: keybind("ctrl+alt+down,ctrl+alt+n", "Scroll which-key down"),
|
||||
which_key_page_up: keybind("ctrl+alt+pageup", "Page which-key up"),
|
||||
which_key_page_down: keybind("ctrl+alt+pagedown", "Page which-key down"),
|
||||
which_key_home: keybind("ctrl+alt+home", "Jump to first which-key binding"),
|
||||
which_key_end: keybind("ctrl+alt+end", "Jump to last which-key binding"),
|
||||
} satisfies Record<string, Definition>
|
||||
|
||||
type KeybindName = keyof typeof Definitions
|
||||
@@ -289,6 +290,13 @@ export const CommandMap = {
|
||||
session_move: "session.move",
|
||||
session_new: "session.new",
|
||||
session_list: "session.list",
|
||||
session_tab_next: "session.tab.next",
|
||||
session_tab_previous: "session.tab.previous",
|
||||
session_tab_history_back: "session.tab.history.back",
|
||||
session_tab_history_forward: "session.tab.history.forward",
|
||||
session_tab_next_unread: "session.tab.next_unread",
|
||||
session_tab_previous_unread: "session.tab.previous_unread",
|
||||
session_tab_close: "session.tab.close",
|
||||
session_timeline: "session.timeline",
|
||||
session_fork: "session.fork",
|
||||
session_rename: "session.rename",
|
||||
@@ -313,6 +321,15 @@ export const CommandMap = {
|
||||
session_quick_switch_7: "session.quick_switch.7",
|
||||
session_quick_switch_8: "session.quick_switch.8",
|
||||
session_quick_switch_9: "session.quick_switch.9",
|
||||
session_tab_select_1: "session.tab.select.1",
|
||||
session_tab_select_2: "session.tab.select.2",
|
||||
session_tab_select_3: "session.tab.select.3",
|
||||
session_tab_select_4: "session.tab.select.4",
|
||||
session_tab_select_5: "session.tab.select.5",
|
||||
session_tab_select_6: "session.tab.select.6",
|
||||
session_tab_select_7: "session.tab.select.7",
|
||||
session_tab_select_8: "session.tab.select.8",
|
||||
session_tab_select_9: "session.tab.select.9",
|
||||
stash_delete: "stash.delete",
|
||||
model_provider_list: "model.dialog.provider",
|
||||
model_favorite_toggle: "model.dialog.favorite",
|
||||
@@ -393,19 +410,6 @@ export const CommandMap = {
|
||||
history_next: "prompt.history.next",
|
||||
terminal_suspend: "terminal.suspend",
|
||||
terminal_title_toggle: "terminal.title.toggle",
|
||||
plugin_manager: "plugins.list",
|
||||
plugin_install: "plugins.install",
|
||||
which_key_toggle: "which-key.toggle",
|
||||
which_key_layout_toggle: "which-key.layout.toggle",
|
||||
which_key_pending_toggle: "which-key.pending.toggle",
|
||||
which_key_group_previous: "which-key.group.previous",
|
||||
which_key_group_next: "which-key.group.next",
|
||||
which_key_scroll_up: "which-key.scroll.up",
|
||||
which_key_scroll_down: "which-key.scroll.down",
|
||||
which_key_page_up: "which-key.page.up",
|
||||
which_key_page_down: "which-key.page.down",
|
||||
which_key_home: "which-key.home",
|
||||
which_key_end: "which-key.end",
|
||||
} satisfies BindingCommandMap
|
||||
const CommandDescriptions = Object.fromEntries(
|
||||
Object.entries(Definitions).map(([name, item]) => [
|
||||
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
type ModelPreference,
|
||||
type ModelPreferenceModel,
|
||||
} from "../model-preference"
|
||||
import { useTheme } from "./theme"
|
||||
import { useTheme, useThemes } from "./theme"
|
||||
import { useToast } from "../ui/toast"
|
||||
import { useRoute } from "./route"
|
||||
import { useData } from "./data"
|
||||
@@ -50,7 +50,8 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
||||
const data = useData()
|
||||
const client = useClient()
|
||||
const toast = useToast()
|
||||
const { themeV2, mode } = useTheme()
|
||||
const theme = useTheme()
|
||||
const { mode } = useThemes()
|
||||
const route = useRoute()
|
||||
const paths = useTuiPaths()
|
||||
const args = useArgs()
|
||||
@@ -82,7 +83,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
||||
const colors = createMemo(() => {
|
||||
const step = mode() === "light" ? 800 : 200
|
||||
return dedupeWith(
|
||||
themeV2.categorical.map((scale) => scale[step]),
|
||||
theme.categorical.map((scale) => scale[step]),
|
||||
(first, second) => first.equals(second),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
export type SessionTab = {
|
||||
sessionID: string
|
||||
title?: string
|
||||
}
|
||||
|
||||
export type SessionTabUnread = "activity" | "error"
|
||||
|
||||
export type SessionTabHistory = {
|
||||
entries: readonly string[]
|
||||
index: number
|
||||
}
|
||||
|
||||
export function sessionTabComplete(unread: SessionTabUnread | undefined, busy: boolean) {
|
||||
return unread === "activity" && !busy
|
||||
}
|
||||
|
||||
export const SESSION_TAB_WIDTH = 22
|
||||
export const SESSION_TAB_MAX_WIDTH = 32
|
||||
export const SESSION_TAB_MIN_WIDTH = 8
|
||||
export const SESSION_TAB_OVERFLOW_WIDTH = 3
|
||||
|
||||
export function openSessionTab(tabs: SessionTab[], tab: SessionTab): SessionTab[] {
|
||||
const index = tabs.findIndex((item) => item.sessionID === tab.sessionID)
|
||||
if (index === -1) return [...tabs, tab]
|
||||
if (!tab.title || tabs[index]?.title === tab.title) return tabs
|
||||
return tabs.map((item, position) => (position === index ? { ...item, title: tab.title } : item))
|
||||
}
|
||||
|
||||
export function closeSessionTab(tabs: readonly SessionTab[], sessionID: string) {
|
||||
const index = tabs.findIndex((tab) => tab.sessionID === sessionID)
|
||||
if (index === -1) return { tabs: [...tabs], next: undefined }
|
||||
return {
|
||||
tabs: tabs.filter((tab) => tab.sessionID !== sessionID),
|
||||
next: tabs[index + 1]?.sessionID ?? tabs[index - 1]?.sessionID,
|
||||
}
|
||||
}
|
||||
|
||||
export function cycleSessionTab(tabs: readonly SessionTab[], active: string | undefined, direction: 1 | -1) {
|
||||
if (tabs.length === 0) return
|
||||
const index = tabs.findIndex((tab) => tab.sessionID === active)
|
||||
const start = index === -1 ? (direction === 1 ? -1 : 0) : index
|
||||
return tabs[(start + direction + tabs.length) % tabs.length]
|
||||
}
|
||||
|
||||
export function recordSessionTabHistory(history: SessionTabHistory, sessionID: string): SessionTabHistory {
|
||||
if (history.entries[history.index] === sessionID) return history
|
||||
const entries = [...history.entries.slice(0, history.index + 1), sessionID]
|
||||
return { entries, index: entries.length - 1 }
|
||||
}
|
||||
|
||||
export function moveSessionTabHistory(
|
||||
history: SessionTabHistory,
|
||||
tabs: readonly SessionTab[],
|
||||
active: string | undefined,
|
||||
direction: 1 | -1,
|
||||
) {
|
||||
if (!active) {
|
||||
const sessionID = history.entries[history.index]
|
||||
return tabs.some((tab) => tab.sessionID === sessionID) ? { history, sessionID } : { history, sessionID: undefined }
|
||||
}
|
||||
const entries = history.entries.map((sessionID, index) => ({ sessionID, index }))
|
||||
const candidates = direction === -1 ? entries.slice(0, history.index).reverse() : entries.slice(history.index + 1)
|
||||
const target = candidates.find(
|
||||
(entry) => entry.sessionID !== active && tabs.some((tab) => tab.sessionID === entry.sessionID),
|
||||
)
|
||||
if (!target) return { history, sessionID: undefined }
|
||||
return { history: { ...history, index: target.index }, sessionID: target.sessionID }
|
||||
}
|
||||
|
||||
export function adaptiveSessionTabLayout(
|
||||
tabs: readonly SessionTab[],
|
||||
active: string | undefined,
|
||||
available: number,
|
||||
previousStart = 0,
|
||||
) {
|
||||
if (tabs.length === 0) return { tabs: [], widths: [], before: 0, after: 0, start: 0, total: 0 }
|
||||
|
||||
const activeIndex = tabs.findIndex((tab) => tab.sessionID === active)
|
||||
const fit = (width: number) =>
|
||||
Math.min(
|
||||
tabs.length,
|
||||
Math.max(
|
||||
1,
|
||||
activeIndex === -1
|
||||
? Math.floor(Math.max(0, width) / SESSION_TAB_MIN_WIDTH)
|
||||
: 1 + Math.floor((Math.max(0, width) - SESSION_TAB_WIDTH) / SESSION_TAB_MIN_WIDTH),
|
||||
),
|
||||
)
|
||||
const solve = (count: number, start: number, attempts: number): { count: number; start: number } => {
|
||||
const boundedStart = Math.min(Math.max(0, start), tabs.length - count)
|
||||
const nextStart = Math.min(
|
||||
Math.max(
|
||||
0,
|
||||
activeIndex === -1
|
||||
? boundedStart
|
||||
: activeIndex < boundedStart
|
||||
? activeIndex
|
||||
: activeIndex >= boundedStart + count
|
||||
? activeIndex - count + 1
|
||||
: boundedStart,
|
||||
),
|
||||
tabs.length - count,
|
||||
)
|
||||
const markers =
|
||||
(nextStart > 0 ? SESSION_TAB_OVERFLOW_WIDTH : 0) +
|
||||
(nextStart + count < tabs.length ? SESSION_TAB_OVERFLOW_WIDTH : 0)
|
||||
const nextCount = fit(available - markers)
|
||||
if (nextCount === count || attempts === 0) return { count, start: nextStart }
|
||||
return solve(nextCount, nextStart, attempts - 1)
|
||||
}
|
||||
const solved = solve(fit(available), previousStart, 3)
|
||||
const visible = tabs.slice(solved.start, solved.start + solved.count)
|
||||
const before = solved.start
|
||||
const after = tabs.length - solved.start - solved.count
|
||||
const contentWidth = Math.max(
|
||||
1,
|
||||
available - (before > 0 ? SESSION_TAB_OVERFLOW_WIDTH : 0) - (after > 0 ? SESSION_TAB_OVERFLOW_WIDTH : 0),
|
||||
)
|
||||
const roomy = contentWidth >= SESSION_TAB_WIDTH * visible.length
|
||||
const total = roomy ? Math.min(contentWidth, SESSION_TAB_MAX_WIDTH * visible.length) : contentWidth
|
||||
if (roomy || activeIndex === -1) {
|
||||
const width = Math.floor(total / visible.length)
|
||||
const remainder = total - width * visible.length
|
||||
return {
|
||||
tabs: visible,
|
||||
widths: visible.map((_, index) => width + Number(index < remainder)),
|
||||
before,
|
||||
after,
|
||||
start: solved.start,
|
||||
total,
|
||||
}
|
||||
}
|
||||
const inactiveWidth =
|
||||
visible.length === 1
|
||||
? 0
|
||||
: Math.min(
|
||||
SESSION_TAB_WIDTH,
|
||||
Math.max(
|
||||
SESSION_TAB_MIN_WIDTH,
|
||||
Math.floor((total - Math.min(SESSION_TAB_WIDTH, total)) / (visible.length - 1)),
|
||||
),
|
||||
)
|
||||
const activeWidth = visible.length === 1 ? total : total - inactiveWidth * (visible.length - 1)
|
||||
|
||||
return {
|
||||
tabs: visible,
|
||||
widths: visible.map((tab) => (tab.sessionID === active ? activeWidth : inactiveWidth)),
|
||||
before,
|
||||
after,
|
||||
start: solved.start,
|
||||
total,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,267 @@
|
||||
import { batch, createEffect, onCleanup, untrack } from "solid-js"
|
||||
import { createStore, produce, reconcile } from "solid-js/store"
|
||||
import path from "path"
|
||||
import { isDeepEqual } from "remeda"
|
||||
import { createSimpleContext } from "./helper"
|
||||
import { useData } from "./data"
|
||||
import { useEvent } from "./event"
|
||||
import { useRoute } from "./route"
|
||||
import { useTuiPaths } from "./runtime"
|
||||
import { useConfig } from "../config"
|
||||
import { readJson, writeJsonAtomic } from "../util/persistence"
|
||||
import { isRecord } from "../util/record"
|
||||
import {
|
||||
closeSessionTab,
|
||||
cycleSessionTab,
|
||||
moveSessionTabHistory,
|
||||
openSessionTab,
|
||||
recordSessionTabHistory,
|
||||
type SessionTab,
|
||||
type SessionTabHistory,
|
||||
type SessionTabUnread,
|
||||
} from "./session-tabs-model"
|
||||
|
||||
type PersistedState = {
|
||||
tabs: SessionTab[]
|
||||
unread: Record<string, SessionTabUnread>
|
||||
}
|
||||
|
||||
export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimpleContext({
|
||||
name: "SessionTabs",
|
||||
init: () => {
|
||||
const route = useRoute()
|
||||
const data = useData()
|
||||
const event = useEvent()
|
||||
const config = useConfig().data
|
||||
const filePath = path.join(useTuiPaths().state, "session-tabs.json")
|
||||
const enabled = () => config.tabs?.enabled ?? false
|
||||
const state: {
|
||||
pending: boolean
|
||||
saving: boolean
|
||||
snapshot: string
|
||||
value?: PersistedState
|
||||
} = { pending: false, saving: false, snapshot: "" }
|
||||
const [store, setStore] = createStore<PersistedState & { ready: boolean }>({
|
||||
ready: false,
|
||||
tabs: [],
|
||||
unread: {},
|
||||
})
|
||||
let history: SessionTabHistory = { entries: [], index: -1 }
|
||||
|
||||
const root = (sessionID: string) => data.session.root(sessionID)
|
||||
const current = () => (route.data.type === "session" ? root(route.data.sessionID) : undefined)
|
||||
const status = (sessionID: string) => {
|
||||
const session = root(sessionID)
|
||||
const members = data.session.family(session)
|
||||
const family = members.length > 0 ? members : [session]
|
||||
return {
|
||||
unread: store.unread[session],
|
||||
attention: family.some(
|
||||
(id) => (data.session.permission.list(id)?.length ?? 0) > 0 || (data.session.form.list(id)?.length ?? 0) > 0,
|
||||
),
|
||||
busy: family.some((id) => data.session.status(id) === "running" || data.session.pending.list(id).length > 0),
|
||||
}
|
||||
}
|
||||
|
||||
function save() {
|
||||
if (!store.ready) {
|
||||
state.pending = true
|
||||
return
|
||||
}
|
||||
const value = { tabs: [...store.tabs], unread: { ...store.unread } }
|
||||
const snapshot = JSON.stringify(value)
|
||||
if (snapshot === state.snapshot && !state.saving) return
|
||||
state.value = value
|
||||
state.pending = true
|
||||
flush()
|
||||
}
|
||||
|
||||
function flush() {
|
||||
if (state.saving || !state.pending || !state.value) return
|
||||
const value = state.value
|
||||
const snapshot = JSON.stringify(value)
|
||||
state.pending = false
|
||||
if (snapshot === state.snapshot) return
|
||||
state.saving = true
|
||||
void writeJsonAtomic(filePath, value)
|
||||
.then(() => {
|
||||
state.snapshot = snapshot
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(() => {
|
||||
state.saving = false
|
||||
flush()
|
||||
})
|
||||
}
|
||||
|
||||
function open(sessionID: string) {
|
||||
const session = root(sessionID)
|
||||
const next = openSessionTab(store.tabs, { sessionID: session, title: data.session.get(session)?.title })
|
||||
if (next === store.tabs) return { sessionID: session, changed: false }
|
||||
setStore("tabs", reconcile(next))
|
||||
return { sessionID: session, changed: true }
|
||||
}
|
||||
|
||||
function clearUnread(sessionID: string) {
|
||||
const session = root(sessionID)
|
||||
if (!store.unread[session]) return false
|
||||
setStore(
|
||||
"unread",
|
||||
produce((draft) => {
|
||||
delete draft[session]
|
||||
}),
|
||||
)
|
||||
return true
|
||||
}
|
||||
|
||||
function markUnread(sessionID: string, unread: SessionTabUnread) {
|
||||
if (!enabled()) return
|
||||
const session = root(sessionID)
|
||||
if (current() === session || !store.tabs.some((tab) => tab.sessionID === session)) return
|
||||
if (store.unread[session] === unread) return
|
||||
setStore("unread", session, unread)
|
||||
save()
|
||||
}
|
||||
|
||||
readJson<unknown>(filePath)
|
||||
.then((value) => {
|
||||
if (!isRecord(value)) return
|
||||
const persisted = value
|
||||
if (Array.isArray(persisted.tabs))
|
||||
setStore(
|
||||
"tabs",
|
||||
persisted.tabs.flatMap((tab) => {
|
||||
if (!isRecord(tab) || typeof tab.sessionID !== "string") return []
|
||||
if ("title" in tab && tab.title !== undefined && typeof tab.title !== "string") return []
|
||||
return [{ sessionID: tab.sessionID, title: typeof tab.title === "string" ? tab.title : undefined }]
|
||||
}),
|
||||
)
|
||||
if (persisted.unread && typeof persisted.unread === "object")
|
||||
setStore(
|
||||
"unread",
|
||||
Object.fromEntries(
|
||||
Object.entries(persisted.unread).filter(
|
||||
(entry): entry is [string, SessionTabUnread] => entry[1] === "activity" || entry[1] === "error",
|
||||
),
|
||||
),
|
||||
)
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(() => {
|
||||
setStore("ready", true)
|
||||
if (state.pending) save()
|
||||
else state.snapshot = JSON.stringify({ tabs: store.tabs, unread: store.unread })
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
if (!enabled()) return
|
||||
if (!store.ready || route.data.type !== "session" || route.data.sessionID === "dummy") return
|
||||
const routeSessionID = route.data.sessionID
|
||||
batch(() => {
|
||||
const opened = open(routeSessionID)
|
||||
history = recordSessionTabHistory(history, opened.sessionID)
|
||||
const changed = clearUnread(opened.sessionID)
|
||||
if (opened.changed || changed) untrack(save)
|
||||
})
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
if (!enabled() || !store.ready) return
|
||||
const next = store.tabs.reduce<SessionTab[]>((tabs, tab) => {
|
||||
const sessionID = root(tab.sessionID)
|
||||
return openSessionTab(tabs, { sessionID, title: data.session.get(sessionID)?.title ?? tab.title })
|
||||
}, [])
|
||||
const unread = Object.entries(store.unread).reduce<Record<string, SessionTabUnread>>((result, entry) => {
|
||||
const sessionID = root(entry[0])
|
||||
result[sessionID] = result[sessionID] === "error" ? "error" : entry[1]
|
||||
return result
|
||||
}, {})
|
||||
if (isDeepEqual(next, store.tabs) && isDeepEqual(unread, store.unread)) return
|
||||
batch(() => {
|
||||
setStore("tabs", reconcile(next))
|
||||
setStore("unread", reconcile(unread))
|
||||
})
|
||||
save()
|
||||
})
|
||||
|
||||
onCleanup(event.on("session.execution.succeeded", (evt) => markUnread(evt.data.sessionID, "activity")))
|
||||
onCleanup(event.on("session.execution.interrupted", (evt) => markUnread(evt.data.sessionID, "activity")))
|
||||
onCleanup(event.on("session.execution.failed", (evt) => markUnread(evt.data.sessionID, "error")))
|
||||
onCleanup(
|
||||
event.on("session.error", (evt) => {
|
||||
if (evt.data.sessionID) markUnread(evt.data.sessionID, "error")
|
||||
}),
|
||||
)
|
||||
onCleanup(
|
||||
event.on("session.deleted", (evt) => {
|
||||
remove(evt.data.sessionID, enabled())
|
||||
}),
|
||||
)
|
||||
|
||||
function remove(sessionID: string, navigate: boolean) {
|
||||
const target = root(sessionID)
|
||||
const closed = closeSessionTab(store.tabs, target)
|
||||
if (closed.tabs.length === store.tabs.length) return
|
||||
const selected = navigate && current() === target
|
||||
const previous = selected
|
||||
? moveSessionTabHistory(recordSessionTabHistory(history, target), closed.tabs, target, -1)
|
||||
: { history, sessionID: undefined }
|
||||
const next = previous.sessionID ?? closed.next
|
||||
history = previous.history
|
||||
batch(() => {
|
||||
setStore("tabs", reconcile(closed.tabs))
|
||||
clearUnread(target)
|
||||
if (selected) route.navigate(next ? { type: "session", sessionID: next } : { type: "home" })
|
||||
})
|
||||
save()
|
||||
}
|
||||
|
||||
return {
|
||||
enabled,
|
||||
tabs() {
|
||||
return store.tabs
|
||||
},
|
||||
current,
|
||||
status,
|
||||
select(sessionID: string) {
|
||||
if (!enabled()) return
|
||||
route.navigate({ type: "session", sessionID: root(sessionID) })
|
||||
},
|
||||
close(sessionID?: string) {
|
||||
if (!enabled()) return
|
||||
const target = sessionID ? root(sessionID) : current()
|
||||
if (!target) {
|
||||
const previous = store.tabs.at(-1)
|
||||
if (route.data.type === "home" && previous) route.navigate({ type: "session", sessionID: previous.sessionID })
|
||||
return
|
||||
}
|
||||
remove(target, true)
|
||||
},
|
||||
cycle(direction: 1 | -1) {
|
||||
if (!enabled()) return
|
||||
const tab = cycleSessionTab(store.tabs, current(), direction)
|
||||
if (tab) route.navigate({ type: "session", sessionID: tab.sessionID })
|
||||
},
|
||||
cycleUnread(direction: 1 | -1) {
|
||||
if (!enabled()) return
|
||||
const tab = cycleSessionTab(
|
||||
store.tabs.filter((tab) => store.unread[tab.sessionID] || status(tab.sessionID).attention),
|
||||
current(),
|
||||
direction,
|
||||
)
|
||||
if (tab) route.navigate({ type: "session", sessionID: tab.sessionID })
|
||||
},
|
||||
history(direction: 1 | -1) {
|
||||
if (!enabled()) return
|
||||
const next = moveSessionTabHistory(history, store.tabs, current(), direction)
|
||||
history = next.history
|
||||
if (next.sessionID) route.navigate({ type: "session", sessionID: next.sessionID })
|
||||
},
|
||||
selectIndex(index: number) {
|
||||
if (!enabled()) return
|
||||
const tab = store.tabs[index]
|
||||
if (tab) route.navigate({ type: "session", sessionID: tab.sessionID })
|
||||
},
|
||||
}
|
||||
},
|
||||
})
|
||||
@@ -96,13 +96,13 @@ type State = {
|
||||
}
|
||||
|
||||
type ContextName = "elevated" | "overlay"
|
||||
type ThemeService = {
|
||||
themeV2: ComponentTheme
|
||||
contextual(context: ContextName): ThemeService
|
||||
type Themes = {
|
||||
current: ComponentTheme
|
||||
contextual(context: ContextName): ComponentTheme
|
||||
readonly selected: string
|
||||
all: typeof allThemes
|
||||
has: typeof hasTheme
|
||||
syntax: Accessor<SyntaxStyle>
|
||||
currentSyntax: Accessor<SyntaxStyle>
|
||||
mode: Accessor<"dark" | "light">
|
||||
modes: Accessor<readonly ("dark" | "light")[]>
|
||||
supports(mode: "dark" | "light"): boolean
|
||||
@@ -308,7 +308,7 @@ const themeContext = createSimpleContext({
|
||||
const valuesV2 = () => selected().theme
|
||||
valuesV2()
|
||||
themePerformance.set("Init", `${(performance.now() - initStarted).toFixed(2)} ms`)
|
||||
const themeV2 = createComponentTheme(valuesV2, mode)
|
||||
const current = createComponentTheme(valuesV2, mode)
|
||||
const contextsV2 = {
|
||||
elevated: createComponentTheme(() => valuesV2().contexts["@context:elevated"] ?? valuesV2(), mode),
|
||||
overlay: createComponentTheme(() => valuesV2().contexts["@context:overlay"] ?? valuesV2(), mode),
|
||||
@@ -316,19 +316,19 @@ const themeContext = createSimpleContext({
|
||||
|
||||
createEffect(() => renderer.setBackgroundColor(valuesV2().background.default))
|
||||
|
||||
const syntax = createSyntaxStyleMemo(() => generateSyntax(valuesV2(), mode()))
|
||||
const currentSyntax = createSyntaxStyleMemo(() => generateSyntax(valuesV2(), mode()))
|
||||
function contextual(context: ContextName) {
|
||||
return contextualServices[context]
|
||||
return contextsV2[context]
|
||||
}
|
||||
const service: ThemeService = {
|
||||
themeV2,
|
||||
const service: Themes = {
|
||||
current,
|
||||
currentSyntax,
|
||||
contextual,
|
||||
get selected() {
|
||||
return store.active
|
||||
},
|
||||
all: allThemes,
|
||||
has: hasTheme,
|
||||
syntax,
|
||||
mode,
|
||||
modes,
|
||||
supports: (requested) => modes().includes(requested),
|
||||
@@ -355,21 +355,28 @@ const themeContext = createSimpleContext({
|
||||
return store.ready
|
||||
},
|
||||
}
|
||||
const contextualServices = {
|
||||
elevated: Object.assign(Object.create(service) as ThemeService, { themeV2: contextsV2.elevated }),
|
||||
overlay: Object.assign(Object.create(service) as ThemeService, { themeV2: contextsV2.overlay }),
|
||||
return {
|
||||
current,
|
||||
themes: service,
|
||||
get ready() {
|
||||
return service.ready
|
||||
},
|
||||
}
|
||||
return service
|
||||
},
|
||||
})
|
||||
|
||||
export const useTheme = themeContext.use
|
||||
export function useThemes() {
|
||||
return themeContext.use().themes
|
||||
}
|
||||
export function useTheme() {
|
||||
return themeContext.use().current
|
||||
}
|
||||
export const ThemeProvider = themeContext.provider
|
||||
|
||||
export function ThemeContextProvider(props: ParentProps<{ context: ContextName }>) {
|
||||
const theme = useTheme()
|
||||
const themes = useThemes()
|
||||
return (
|
||||
<themeContext.context.Provider value={theme.contextual(props.context)}>
|
||||
<themeContext.context.Provider value={{ current: themes.contextual(props.context), themes, ready: themes.ready }}>
|
||||
{props.children}
|
||||
</themeContext.context.Provider>
|
||||
)
|
||||
|
||||
@@ -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 (
|
||||
<Show when={directory()}>
|
||||
{(value) => <FilePath value={value()} maxWidth={props.maxWidth} fg={props.context.theme.themeV2.text.subdued} />}
|
||||
{(value) => <FilePath value={value()} maxWidth={props.maxWidth} fg={props.context.theme.text.subdued} />}
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
@@ -24,18 +24,16 @@ function Mcp(props: { context: Plugin.Context }) {
|
||||
return (
|
||||
<Show when={list().length}>
|
||||
<box gap={1} flexDirection="row" flexShrink={0}>
|
||||
<text fg={props.context.theme.themeV2.text.default}>
|
||||
<text fg={props.context.theme.text.default}>
|
||||
<Switch>
|
||||
<Match when={failed()}>
|
||||
<span style={{ fg: props.context.theme.themeV2.text.feedback.error.default }}>⊙ </span>
|
||||
<span style={{ fg: props.context.theme.text.feedback.error.default }}>⊙ </span>
|
||||
</Match>
|
||||
<Match when={true}>
|
||||
<span
|
||||
style={{
|
||||
fg:
|
||||
count() > 0
|
||||
? props.context.theme.themeV2.text.feedback.success.default
|
||||
: props.context.theme.themeV2.text.subdued,
|
||||
count() > 0 ? props.context.theme.text.feedback.success.default : props.context.theme.text.subdued,
|
||||
}}
|
||||
>
|
||||
⊙{" "}
|
||||
@@ -44,7 +42,7 @@ function Mcp(props: { context: Plugin.Context }) {
|
||||
</Switch>
|
||||
{count()} MCP
|
||||
</text>
|
||||
<text fg={props.context.theme.themeV2.text.subdued}>/status</text>
|
||||
<text fg={props.context.theme.text.subdued}>/status</text>
|
||||
</box>
|
||||
</Show>
|
||||
)
|
||||
@@ -77,7 +75,7 @@ function View(props: { context: Plugin.Context }) {
|
||||
<Mcp context={props.context} />
|
||||
<box flexGrow={1} />
|
||||
<box flexShrink={0}>
|
||||
<text fg={props.context.theme.themeV2.text.subdued}>{props.context.app.version}</text>
|
||||
<text fg={props.context.theme.text.subdued}>{props.context.app.version}</text>
|
||||
</box>
|
||||
</box>
|
||||
)
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { Plugin } from "@opencode-ai/plugin/tui"
|
||||
import { createMemo, Show } from "solid-js"
|
||||
import { useTheme } from "../../context/theme"
|
||||
import { contextUsage } from "../../util/session"
|
||||
|
||||
const money = new Intl.NumberFormat("en-US", {
|
||||
@@ -9,7 +8,7 @@ const money = new Intl.NumberFormat("en-US", {
|
||||
})
|
||||
|
||||
function View(props: { context: Plugin.Context; sessionID: string }) {
|
||||
const { themeV2 } = useTheme()
|
||||
const theme = props.context.theme
|
||||
const msg = createMemo(() => props.context.data.session.message.list(props.sessionID))
|
||||
const session = createMemo(() => props.context.data.session.get(props.sessionID))
|
||||
const cost = createMemo(() => props.context.data.session.cost(props.sessionID))
|
||||
@@ -20,20 +19,20 @@ function View(props: { context: Plugin.Context; sessionID: string }) {
|
||||
|
||||
return (
|
||||
<box>
|
||||
<text fg={themeV2.text.default}>
|
||||
<text fg={theme.text.default}>
|
||||
<b>Context</b>
|
||||
</text>
|
||||
<Show when={state()} fallback={<text fg={themeV2.text.subdued}>Not measured</text>}>
|
||||
<Show when={state()} fallback={<text fg={theme.text.subdued}>Not measured</text>}>
|
||||
{(value) => (
|
||||
<>
|
||||
<text fg={themeV2.text.subdued}>{value().tokens.toLocaleString()} tokens</text>
|
||||
<text fg={theme.text.subdued}>{value().tokens.toLocaleString()} tokens</text>
|
||||
<Show when={value().percent !== undefined}>
|
||||
<text fg={themeV2.text.subdued}>{value().percent}% used</text>
|
||||
<text fg={theme.text.subdued}>{value().percent}% used</text>
|
||||
</Show>
|
||||
</>
|
||||
)}
|
||||
</Show>
|
||||
<text fg={themeV2.text.subdued}>{money.format(cost())} spent</text>
|
||||
<text fg={theme.text.subdued}>{money.format(cost())} spent</text>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ function View(props: { context: Plugin.Context }) {
|
||||
)
|
||||
return (
|
||||
<Show when={directory()}>
|
||||
{(value) => <FilePath value={value()} maxWidth={38} fg={props.context.theme.themeV2.text.subdued} />}
|
||||
{(value) => <FilePath value={value()} maxWidth={38} fg={props.context.theme.text.subdued} />}
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import { Plugin } from "@opencode-ai/plugin/tui"
|
||||
import { useTheme } from "../../context/theme"
|
||||
import { Plugin, usePlugin } from "@opencode-ai/plugin/tui"
|
||||
|
||||
function View() {
|
||||
const { themeV2 } = useTheme()
|
||||
const context = usePlugin()
|
||||
const theme = context.theme
|
||||
return (
|
||||
<box>
|
||||
<text fg={themeV2.text.default}>
|
||||
<text fg={theme.text.default}>
|
||||
<b>LSP</b>
|
||||
</text>
|
||||
<text fg={themeV2.text.subdued}>LSP status unavailable</text>
|
||||
<text fg={theme.text.subdued}>LSP status unavailable</text>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import { Plugin } from "@opencode-ai/plugin/tui"
|
||||
import { createMemo, For, Match, Show, Switch, createSignal } from "solid-js"
|
||||
import { useTheme } from "../../context/theme"
|
||||
|
||||
function View(props: { context: Plugin.Context; sessionID: string }) {
|
||||
const [open, setOpen] = createSignal(true)
|
||||
const { themeV2 } = useTheme()
|
||||
const theme = props.context.theme
|
||||
const session = createMemo(() => props.context.data.session.get(props.sessionID))
|
||||
const list = createMemo(() => props.context.data.location.mcp.server.list(session()?.location) ?? [])
|
||||
const on = createMemo(() => list().filter((item) => item.status.status === "connected").length)
|
||||
@@ -19,12 +18,12 @@ function View(props: { context: Plugin.Context; sessionID: string }) {
|
||||
)
|
||||
|
||||
const dot = (status: string) => {
|
||||
if (status === "connected") return themeV2.text.feedback.success.default
|
||||
if (status === "failed") return themeV2.text.feedback.error.default
|
||||
if (status === "disabled") return themeV2.text.subdued
|
||||
if (status === "needs_auth") return themeV2.text.feedback.warning.default
|
||||
if (status === "needs_client_registration") return themeV2.text.feedback.error.default
|
||||
return themeV2.text.subdued
|
||||
if (status === "connected") return theme.text.feedback.success.default
|
||||
if (status === "failed") return theme.text.feedback.error.default
|
||||
if (status === "disabled") return theme.text.subdued
|
||||
if (status === "needs_auth") return theme.text.feedback.warning.default
|
||||
if (status === "needs_client_registration") return theme.text.feedback.error.default
|
||||
return theme.text.subdued
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -32,12 +31,12 @@ function View(props: { context: Plugin.Context; sessionID: string }) {
|
||||
<box>
|
||||
<box flexDirection="row" gap={1} onMouseDown={() => list().length > 2 && setOpen((x) => !x)}>
|
||||
<Show when={list().length > 2}>
|
||||
<text fg={themeV2.text.default}>{open() ? "▼" : "▶"}</text>
|
||||
<text fg={theme.text.default}>{open() ? "▼" : "▶"}</text>
|
||||
</Show>
|
||||
<text fg={themeV2.text.default}>
|
||||
<text fg={theme.text.default}>
|
||||
<b>MCP</b>
|
||||
<Show when={!open()}>
|
||||
<span style={{ fg: themeV2.text.subdued }}>
|
||||
<span style={{ fg: theme.text.subdued }}>
|
||||
{" "}
|
||||
({on()} active{bad() > 0 ? `, ${bad()} error${bad() > 1 ? "s" : ""}` : ""})
|
||||
</span>
|
||||
@@ -56,9 +55,9 @@ function View(props: { context: Plugin.Context; sessionID: string }) {
|
||||
>
|
||||
•
|
||||
</text>
|
||||
<text fg={themeV2.text.default} wrapMode="word">
|
||||
<text fg={theme.text.default} wrapMode="word">
|
||||
{item.name}{" "}
|
||||
<span style={{ fg: themeV2.text.subdued }}>
|
||||
<span style={{ fg: theme.text.subdued }}>
|
||||
<Switch fallback={item.status.status}>
|
||||
<Match when={item.status.status === "connected"}>Connected</Match>
|
||||
<Match when={item.status.status === "failed"}>
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
/** @jsxImportSource @opentui/solid */
|
||||
import type { ScrollBoxRenderable } from "@opentui/core"
|
||||
import type { Plugin } from "@opencode-ai/plugin/tui"
|
||||
import { Locale } from "../../util/locale"
|
||||
import { tint } from "../../theme/color"
|
||||
import { createEffect, createMemo, For, Match, Switch } from "solid-js"
|
||||
import { buildFileTree, flattenFileTree, type FileTreeItem, type FileTreeRow } from "./diff-viewer-file-tree-utils"
|
||||
import { Panel } from "./diff-viewer-ui"
|
||||
import { useTheme } from "../../context/theme"
|
||||
|
||||
const FILE_TREE_STATUS_WIDTH = 2
|
||||
|
||||
export type DiffViewerFileTreeProps = {
|
||||
readonly context: Plugin.Context
|
||||
readonly width: number
|
||||
readonly files: readonly FileTreeItem[]
|
||||
readonly loading: boolean
|
||||
@@ -23,7 +24,7 @@ export type DiffViewerFileTreeProps = {
|
||||
}
|
||||
|
||||
export function DiffViewerFileTree(props: DiffViewerFileTreeProps) {
|
||||
const { themeV2 } = useTheme()
|
||||
const theme = props.context.theme
|
||||
const tree = createMemo(() => buildFileTree(props.files))
|
||||
const rows = createMemo(() => flattenFileTree(tree(), props.expandedNodes))
|
||||
let scroll: ScrollBoxRenderable | undefined
|
||||
@@ -38,10 +39,10 @@ export function DiffViewerFileTree(props: DiffViewerFileTreeProps) {
|
||||
requestAnimationFrame(scrollSelectedIntoView)
|
||||
})
|
||||
|
||||
const fadedColor = () => tint(themeV2.text.default, themeV2.background.default, 0.75)
|
||||
const fadedColor = () => tint(theme.text.default, theme.background.default, 0.75)
|
||||
|
||||
return (
|
||||
<Panel border="both" width={props.width}>
|
||||
<Panel border="both" width={props.width} context={props.context}>
|
||||
<scrollbox
|
||||
ref={(element: ScrollBoxRenderable) => (scroll = element)}
|
||||
verticalScrollbarOptions={{ visible: false }}
|
||||
@@ -52,7 +53,7 @@ export function DiffViewerFileTree(props: DiffViewerFileTreeProps) {
|
||||
<text />
|
||||
</Match>
|
||||
<Match when={props.files.length === 0}>
|
||||
<text fg={themeV2.text.default}>No files</text>
|
||||
<text fg={theme.text.default}>No files</text>
|
||||
</Match>
|
||||
<Match when={props.files.length > 0}>
|
||||
<For each={rows()}>
|
||||
@@ -71,11 +72,11 @@ export function DiffViewerFileTree(props: DiffViewerFileTreeProps) {
|
||||
<box
|
||||
flexDirection="row"
|
||||
width="100%"
|
||||
backgroundColor={highlighted() ? themeV2.background.action.primary.focused : undefined}
|
||||
backgroundColor={highlighted() ? theme.background.action.primary.focused : undefined}
|
||||
onMouseUp={() => props.onRowClick?.(row)}
|
||||
>
|
||||
<text
|
||||
fg={highlighted() ? themeV2.text.action.primary.focused : fadedColor()}
|
||||
fg={highlighted() ? theme.text.action.primary.focused : fadedColor()}
|
||||
wrapMode="none"
|
||||
flexShrink={0}
|
||||
>
|
||||
@@ -85,12 +86,12 @@ export function DiffViewerFileTree(props: DiffViewerFileTreeProps) {
|
||||
<text
|
||||
fg={
|
||||
highlighted()
|
||||
? themeV2.text.action.primary.focused
|
||||
? theme.text.action.primary.focused
|
||||
: selected()
|
||||
? themeV2.text.formfield.selected
|
||||
? theme.text.formfield.selected
|
||||
: reviewed() || row.kind === "directory"
|
||||
? themeV2.text.subdued
|
||||
: themeV2.text.default
|
||||
? theme.text.subdued
|
||||
: theme.text.default
|
||||
}
|
||||
wrapMode="none"
|
||||
>
|
||||
@@ -98,7 +99,7 @@ export function DiffViewerFileTree(props: DiffViewerFileTreeProps) {
|
||||
</text>
|
||||
</box>
|
||||
<text
|
||||
fg={highlighted() ? themeV2.text.action.primary.focused : themeV2.text.subdued}
|
||||
fg={highlighted() ? theme.text.action.primary.focused : theme.text.subdued}
|
||||
wrapMode="none"
|
||||
flexShrink={0}
|
||||
>
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import type { BorderSides, ColorInput } from "@opentui/core"
|
||||
import type { Plugin } from "@opencode-ai/plugin/tui"
|
||||
import type { JSX } from "@opentui/solid"
|
||||
import { useTheme } from "../../context/theme"
|
||||
import { createContext, Show, splitProps, useContext } from "solid-js"
|
||||
|
||||
export type Axis = "x" | "y"
|
||||
export type SeparatorEdge = "edge" | "edge-in" | "edge-out"
|
||||
export type PanelBorder = "start" | "end" | "both" | "none"
|
||||
|
||||
const PanelGroupContext = createContext<{ axis: Axis }>()
|
||||
const PanelGroupContext = createContext<{ axis: Axis; context: Plugin.Context }>()
|
||||
|
||||
function crossAxis(axis: Axis) {
|
||||
return axis === "x" ? "y" : "x"
|
||||
@@ -17,10 +17,10 @@ function usePanelGroup() {
|
||||
return useContext(PanelGroupContext)
|
||||
}
|
||||
|
||||
export function PanelGroup(props: JSX.IntrinsicElements["box"] & { axis: Axis }) {
|
||||
const [local, boxProps] = splitProps(props, ["axis", "children"])
|
||||
export function PanelGroup(props: JSX.IntrinsicElements["box"] & { axis: Axis; context: Plugin.Context }) {
|
||||
const [local, boxProps] = splitProps(props, ["axis", "context", "children"])
|
||||
return (
|
||||
<PanelGroupContext.Provider value={{ axis: local.axis }}>
|
||||
<PanelGroupContext.Provider value={{ axis: local.axis, context: local.context }}>
|
||||
<box minWidth={0} minHeight={0} padding={0} flexDirection={local.axis === "x" ? "row" : "column"} {...boxProps}>
|
||||
{local.children}
|
||||
</box>
|
||||
@@ -28,24 +28,28 @@ export function PanelGroup(props: JSX.IntrinsicElements["box"] & { axis: Axis })
|
||||
)
|
||||
}
|
||||
|
||||
export function Panel(props: Omit<JSX.IntrinsicElements["box"], "border"> & { border?: PanelBorder }) {
|
||||
export function Panel(
|
||||
props: Omit<JSX.IntrinsicElements["box"], "border"> & { border?: PanelBorder; context?: Plugin.Context },
|
||||
) {
|
||||
const group = usePanelGroup()
|
||||
const { themeV2 } = useTheme()
|
||||
const [local, boxProps] = splitProps(props, ["border"])
|
||||
const [local, boxProps] = splitProps(props, ["border", "context"])
|
||||
const context = local.context ?? group?.context
|
||||
if (!context) throw new Error("Panel context is missing")
|
||||
const theme = context.theme
|
||||
const border = local.border ?? "start"
|
||||
const borderProps =
|
||||
border === "none"
|
||||
? {}
|
||||
: {
|
||||
border: panelBorderSides(group?.axis ?? "y", border),
|
||||
borderColor: themeV2.border.default,
|
||||
borderColor: theme.border.default,
|
||||
}
|
||||
|
||||
return (
|
||||
<box
|
||||
minWidth={0}
|
||||
minHeight={0}
|
||||
flexDirection={crossAxis(group?.axis || "y") === "x" ? "row" : "column"}
|
||||
flexDirection={crossAxis(group?.axis ?? "y") === "x" ? "row" : "column"}
|
||||
{...borderProps}
|
||||
{...boxProps}
|
||||
/>
|
||||
@@ -59,9 +63,10 @@ function panelBorderSides(axis: Axis, border: Exclude<PanelBorder, "none">): Bor
|
||||
|
||||
export function Separator(props: { axis?: Axis; color?: ColorInput; start?: SeparatorEdge; end?: SeparatorEdge }) {
|
||||
const group = usePanelGroup()
|
||||
const { themeV2 } = useTheme()
|
||||
const color = () => props.color ?? themeV2.border.default
|
||||
const axis = () => props.axis ?? crossAxis(group?.axis ?? "y")
|
||||
if (!group) throw new Error("PanelGroup is missing")
|
||||
const theme = group.context.theme
|
||||
const color = () => props.color ?? theme.border.default
|
||||
const axis = () => props.axis ?? crossAxis(group.axis)
|
||||
if (axis() === "y") {
|
||||
return (
|
||||
<Show
|
||||
|
||||
@@ -10,7 +10,6 @@ import {
|
||||
type ScrollBoxRenderable,
|
||||
} from "@opentui/core"
|
||||
import { LANGUAGE_EXTENSIONS } from "../../util/filetype"
|
||||
import { useTheme } from "../../context/theme"
|
||||
import { useTerminalDimensions } from "@opentui/solid"
|
||||
import path from "path"
|
||||
import { createEffect, createMemo, createResource, createSignal, For, Match, onCleanup, Show, Switch } from "solid-js"
|
||||
@@ -83,8 +82,7 @@ function DiffViewer(props: { context: Plugin.Context }) {
|
||||
const dimensions = useTerminalDimensions()
|
||||
const config = useConfig()
|
||||
const dialog = props.context.ui.dialog
|
||||
const themeState = useTheme()
|
||||
const themeV2 = themeState.themeV2
|
||||
const theme = props.context.theme
|
||||
const params = () => {
|
||||
const route = props.context.ui.router.current()
|
||||
return (route.type === "plugin" ? route.data : undefined) as
|
||||
@@ -738,13 +736,13 @@ function DiffViewer(props: { context: Plugin.Context }) {
|
||||
|
||||
return (
|
||||
<box position="absolute" zIndex={2500} left={0} top={0} width={dimensions().width} height={dimensions().height}>
|
||||
<PanelGroup axis="y" width="100%" height="100%">
|
||||
<PanelGroup axis="y" context={props.context} width="100%" height="100%">
|
||||
<Panel border="none" flexShrink={0} padding={0} paddingLeft={1}>
|
||||
<text fg={themeV2.text.default}>Diff </text>
|
||||
<text fg={themeV2.text.subdued}>{diffSourceLabel(mode())}</text>
|
||||
<text fg={theme.text.default}>Diff </text>
|
||||
<text fg={theme.text.subdued}>{diffSourceLabel(mode())}</text>
|
||||
<box flexGrow={1} />
|
||||
<Show when={!diff.loading && !diff.error}>
|
||||
<text fg={themeV2.text.subdued}>
|
||||
<text fg={theme.text.subdued}>
|
||||
{files().length} {files().length === 1 ? "file" : "files"}
|
||||
</text>
|
||||
</Show>
|
||||
@@ -755,13 +753,13 @@ function DiffViewer(props: { context: Plugin.Context }) {
|
||||
<Match when={diff.loading}>
|
||||
<Separator axis="x" />
|
||||
<box flexGrow={1} paddingLeft={1}>
|
||||
<text fg={themeV2.text.subdued}>Loading diff…</text>
|
||||
<text fg={theme.text.subdued}>Loading diff…</text>
|
||||
</box>
|
||||
</Match>
|
||||
<Match when={!diff.loading && diff.error}>
|
||||
<Separator axis="x" />
|
||||
<box flexGrow={1} paddingLeft={1}>
|
||||
<text fg={themeV2.text.feedback.error.default}>
|
||||
<text fg={theme.text.feedback.error.default}>
|
||||
Could not load diff. Reopen the diff viewer to try again.
|
||||
</text>
|
||||
</box>
|
||||
@@ -769,13 +767,14 @@ function DiffViewer(props: { context: Plugin.Context }) {
|
||||
<Match when={!diff.loading && files().length === 0}>
|
||||
<Separator axis="x" />
|
||||
<box flexGrow={1} paddingLeft={1}>
|
||||
<text fg={themeV2.text.subdued}>No changes to show</text>
|
||||
<text fg={theme.text.subdued}>No changes to show</text>
|
||||
</box>
|
||||
</Match>
|
||||
<Match when={!diff.loading}>
|
||||
<PanelGroup axis="x">
|
||||
<PanelGroup axis="x" context={props.context}>
|
||||
<Show when={showFileTree()}>
|
||||
<DiffViewerFileTree
|
||||
context={props.context}
|
||||
files={files()}
|
||||
loading={diff.loading}
|
||||
error={diff.error}
|
||||
@@ -812,56 +811,52 @@ function DiffViewer(props: { context: Plugin.Context }) {
|
||||
paddingLeft={1}
|
||||
paddingRight={1}
|
||||
border={patchLeftBorder()}
|
||||
borderColor={themeV2.border.default}
|
||||
borderColor={theme.border.default}
|
||||
>
|
||||
<text fg={reviewed() ? themeV2.text.subdued : themeV2.text.default}>
|
||||
{entry.file.file}
|
||||
</text>
|
||||
<text fg={reviewed() ? theme.text.subdued : theme.text.default}>{entry.file.file}</text>
|
||||
<box flexGrow={1} />
|
||||
<text fg={reviewed() ? themeV2.text.subdued : themeV2.diff.text.added}>
|
||||
<text fg={reviewed() ? theme.text.subdued : theme.diff.text.added}>
|
||||
+{entry.file.additions}
|
||||
</text>
|
||||
<text fg={reviewed() ? themeV2.text.subdued : themeV2.diff.text.removed}>
|
||||
<text fg={reviewed() ? theme.text.subdued : theme.diff.text.removed}>
|
||||
-{entry.file.deletions}
|
||||
</text>
|
||||
</box>
|
||||
<Separator axis="x" start={showFileTree() ? "edge" : undefined} />
|
||||
<Show
|
||||
when={entry.file.patch}
|
||||
fallback={<text fg={themeV2.text.subdued}>No patch available for this file.</text>}
|
||||
fallback={<text fg={theme.text.subdued}>No patch available for this file.</text>}
|
||||
>
|
||||
{(patch) => (
|
||||
<box border={patchLeftBorder()} borderColor={themeV2.border.default}>
|
||||
<box border={patchLeftBorder()} borderColor={theme.border.default}>
|
||||
<diff
|
||||
ref={(element: DiffRenderable) => diffNodeByFileIndex.set(entry.fileIndex, element)}
|
||||
diff={patch()}
|
||||
view={view()}
|
||||
filetype={reviewed() ? PLAIN_TEXT_FILETYPE : filetype(entry.file.file)}
|
||||
syntaxStyle={themeState.syntax()}
|
||||
syntaxStyle={theme.syntaxStyle()}
|
||||
showLineNumbers={true}
|
||||
width="100%"
|
||||
wrapMode="char"
|
||||
fg={reviewed() ? themeV2.text.subdued : themeV2.text.default}
|
||||
fg={reviewed() ? theme.text.subdued : theme.text.default}
|
||||
addedBg={
|
||||
reviewed() ? themeV2.background.surface.overlay : themeV2.diff.background.added
|
||||
reviewed() ? theme.background.surface.overlay : theme.diff.background.added
|
||||
}
|
||||
removedBg={
|
||||
reviewed() ? themeV2.background.surface.overlay : themeV2.diff.background.removed
|
||||
reviewed() ? theme.background.surface.overlay : theme.diff.background.removed
|
||||
}
|
||||
addedSignColor={reviewed() ? themeV2.text.subdued : themeV2.diff.highlight.added}
|
||||
removedSignColor={
|
||||
reviewed() ? themeV2.text.subdued : themeV2.diff.highlight.removed
|
||||
}
|
||||
lineNumberFg={themeV2.diff.lineNumber.text}
|
||||
addedSignColor={reviewed() ? theme.text.subdued : theme.diff.highlight.added}
|
||||
removedSignColor={reviewed() ? theme.text.subdued : theme.diff.highlight.removed}
|
||||
lineNumberFg={theme.diff.lineNumber.text}
|
||||
addedLineNumberBg={
|
||||
reviewed()
|
||||
? themeV2.background.surface.overlay
|
||||
: themeV2.diff.lineNumber.background.added
|
||||
? theme.background.surface.overlay
|
||||
: theme.diff.lineNumber.background.added
|
||||
}
|
||||
removedLineNumberBg={
|
||||
reviewed()
|
||||
? themeV2.background.surface.overlay
|
||||
: themeV2.diff.lineNumber.background.removed
|
||||
? theme.background.surface.overlay
|
||||
: theme.diff.lineNumber.background.removed
|
||||
}
|
||||
/>
|
||||
</box>
|
||||
@@ -872,11 +867,7 @@ function DiffViewer(props: { context: Plugin.Context }) {
|
||||
}}
|
||||
</For>
|
||||
<Show when={patchFillerHeight() > 0}>
|
||||
<box
|
||||
height={patchFillerHeight()}
|
||||
border={patchLeftBorder()}
|
||||
borderColor={themeV2.border.default}
|
||||
/>
|
||||
<box height={patchFillerHeight()} border={patchLeftBorder()} borderColor={theme.border.default} />
|
||||
</Show>
|
||||
</scrollbox>
|
||||
<Separator axis="x" start={showFileTree() ? "edge-in" : undefined} />
|
||||
@@ -889,57 +880,57 @@ function DiffViewer(props: { context: Plugin.Context }) {
|
||||
<Panel flexShrink={0} gap={2} paddingLeft={1} border="none">
|
||||
<Show when={switchFocusShortcut()}>
|
||||
{(shortcut) => (
|
||||
<text fg={themeV2.text.default}>
|
||||
{shortcut()} <span style={{ fg: themeV2.text.subdued }}>focus file tree</span>
|
||||
<text fg={theme.text.default}>
|
||||
{shortcut()} <span style={{ fg: theme.text.subdued }}>focus file tree</span>
|
||||
</text>
|
||||
)}
|
||||
</Show>
|
||||
<Show when={nextFileShortcut()}>
|
||||
{(shortcut) => (
|
||||
<text fg={themeV2.text.default}>
|
||||
{shortcut()} <span style={{ fg: themeV2.text.subdued }}>next file</span>
|
||||
<text fg={theme.text.default}>
|
||||
{shortcut()} <span style={{ fg: theme.text.subdued }}>next file</span>
|
||||
</text>
|
||||
)}
|
||||
</Show>
|
||||
<Show when={nextHunkShortcut()}>
|
||||
{(shortcut) => (
|
||||
<text fg={themeV2.text.default}>
|
||||
{shortcut()} <span style={{ fg: themeV2.text.subdued }}>next hunk</span>
|
||||
<text fg={theme.text.default}>
|
||||
{shortcut()} <span style={{ fg: theme.text.subdued }}>next hunk</span>
|
||||
</text>
|
||||
)}
|
||||
</Show>
|
||||
<Show when={previousHunkShortcut()}>
|
||||
{(shortcut) => (
|
||||
<text fg={themeV2.text.default}>
|
||||
{shortcut()} <span style={{ fg: themeV2.text.subdued }}>previous hunk</span>
|
||||
<text fg={theme.text.default}>
|
||||
{shortcut()} <span style={{ fg: theme.text.subdued }}>previous hunk</span>
|
||||
</text>
|
||||
)}
|
||||
</Show>
|
||||
<Show when={previousFileShortcut()}>
|
||||
{(shortcut) => (
|
||||
<text fg={themeV2.text.default}>
|
||||
{shortcut()} <span style={{ fg: themeV2.text.subdued }}>previous file</span>
|
||||
<text fg={theme.text.default}>
|
||||
{shortcut()} <span style={{ fg: theme.text.subdued }}>previous file</span>
|
||||
</text>
|
||||
)}
|
||||
</Show>
|
||||
<Show when={switchSourceShortcut()}>
|
||||
{(shortcut) => (
|
||||
<text fg={themeV2.text.default}>
|
||||
{shortcut()} <span style={{ fg: themeV2.text.subdued }}>switch source</span>
|
||||
<text fg={theme.text.default}>
|
||||
{shortcut()} <span style={{ fg: theme.text.subdued }}>switch source</span>
|
||||
</text>
|
||||
)}
|
||||
</Show>
|
||||
<Show when={markReviewedShortcut()}>
|
||||
{(shortcut) => (
|
||||
<text fg={themeV2.text.default}>
|
||||
{shortcut()} <span style={{ fg: themeV2.text.subdued }}>mark reviewed</span>
|
||||
<text fg={theme.text.default}>
|
||||
{shortcut()} <span style={{ fg: theme.text.subdued }}>mark reviewed</span>
|
||||
</text>
|
||||
)}
|
||||
</Show>
|
||||
<Show when={helpShortcut()}>
|
||||
{(shortcut) => (
|
||||
<text fg={themeV2.text.default}>
|
||||
{shortcut()} <span style={{ fg: themeV2.text.subdued }}>all</span>
|
||||
<text fg={theme.text.default}>
|
||||
{shortcut()} <span style={{ fg: theme.text.subdued }}>all</span>
|
||||
</text>
|
||||
)}
|
||||
</Show>
|
||||
@@ -950,7 +941,7 @@ function DiffViewer(props: { context: Plugin.Context }) {
|
||||
}
|
||||
|
||||
function DiffViewerHelpDialog(props: { context: Plugin.Context }) {
|
||||
const { themeV2 } = useTheme().contextual("elevated")
|
||||
const theme = props.context.theme.contextual("elevated")
|
||||
const shortcut = (id: string) => () => props.context.keymap.shortcuts(id)[0]
|
||||
const rows = [
|
||||
{
|
||||
@@ -1018,30 +1009,30 @@ function DiffViewerHelpDialog(props: { context: Plugin.Context }) {
|
||||
return (
|
||||
<box paddingLeft={2} paddingRight={2} paddingBottom={1} gap={1}>
|
||||
<box flexDirection="row" justifyContent="space-between">
|
||||
<text attributes={TextAttributes.BOLD} fg={themeV2.text.default}>
|
||||
<text attributes={TextAttributes.BOLD} fg={theme.text.default}>
|
||||
Diff shortcuts
|
||||
</text>
|
||||
<text fg={themeV2.text.subdued}>esc</text>
|
||||
<text fg={theme.text.subdued}>esc</text>
|
||||
</box>
|
||||
<box flexDirection="row">
|
||||
<text fg={themeV2.text.subdued} width={5} wrapMode="none">
|
||||
<text fg={theme.text.subdued} width={5} wrapMode="none">
|
||||
Key
|
||||
</text>
|
||||
<text fg={themeV2.text.subdued} width={22} wrapMode="none">
|
||||
<text fg={theme.text.subdued} width={22} wrapMode="none">
|
||||
Action
|
||||
</text>
|
||||
<text fg={themeV2.text.subdued}>Description</text>
|
||||
<text fg={theme.text.subdued}>Description</text>
|
||||
</box>
|
||||
<For each={rows}>
|
||||
{(row) => (
|
||||
<box flexDirection="row">
|
||||
<text fg={themeV2.text.default} width={5} wrapMode="none">
|
||||
<text fg={theme.text.default} width={5} wrapMode="none">
|
||||
{row.shortcut() || "-"}
|
||||
</text>
|
||||
<text fg={themeV2.text.default} width={22} wrapMode="none">
|
||||
<text fg={theme.text.default} width={22} wrapMode="none">
|
||||
{row.action}
|
||||
</text>
|
||||
<text fg={themeV2.text.subdued}>{row.description}</text>
|
||||
<text fg={theme.text.subdued}>{row.description}</text>
|
||||
</box>
|
||||
)}
|
||||
</For>
|
||||
|
||||
@@ -1,280 +1,90 @@
|
||||
import type { TuiPlugin, TuiPluginApi, TuiPluginStatus } from "@opencode-ai/plugin/v1/tui"
|
||||
import type { BuiltinTuiPlugin } from "../builtins"
|
||||
import { useTerminalDimensions } from "@opentui/solid"
|
||||
import { fileURLToPath } from "url"
|
||||
import { Plugin } from "@opencode-ai/plugin/tui"
|
||||
import { createMemo, createSignal } from "solid-js"
|
||||
import { usePlugin } from "../../plugin/context"
|
||||
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) {
|
||||
if (!item.enabled) {
|
||||
return <span style={{ fg: api.theme.current.textMuted }}>disabled</span>
|
||||
}
|
||||
|
||||
return (
|
||||
<span style={{ fg: item.active ? api.theme.current.success : api.theme.current.error }}>
|
||||
{item.active ? "active" : "inactive"}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function source(spec: string) {
|
||||
if (!spec.startsWith("file://")) return
|
||||
return fileURLToPath(spec)
|
||||
}
|
||||
|
||||
function meta(item: TuiPluginStatus, width: number) {
|
||||
if (item.source === "internal") {
|
||||
if (width >= 120) return "Built-in plugin"
|
||||
return "Built-in"
|
||||
}
|
||||
const next = source(item.spec)
|
||||
if (next) return next
|
||||
return item.spec
|
||||
}
|
||||
|
||||
function Install(props: { api: TuiPluginApi }) {
|
||||
const [global, setGlobal] = createSignal(false)
|
||||
const [busy, setBusy] = createSignal(false)
|
||||
|
||||
Keymap.createLayer(() => ({
|
||||
mode: "modal",
|
||||
enabled: !busy(),
|
||||
commands: [
|
||||
{
|
||||
bind: "tab",
|
||||
title: "Toggle install scope",
|
||||
group: "Plugins",
|
||||
run: () => {
|
||||
setGlobal((value) => !value)
|
||||
},
|
||||
},
|
||||
],
|
||||
}))
|
||||
|
||||
return (
|
||||
<props.api.ui.DialogPrompt
|
||||
title="Install plugin"
|
||||
placeholder="npm package name"
|
||||
busy={busy()}
|
||||
busyText="Installing plugin..."
|
||||
description={() => (
|
||||
<box flexDirection="row" gap={1}>
|
||||
<text fg={props.api.theme.current.textMuted}>scope:</text>
|
||||
<text fg={busy() ? props.api.theme.current.textMuted : props.api.theme.current.text}>
|
||||
{global() ? "global" : "local"}
|
||||
</text>
|
||||
<Show when={!busy()}>
|
||||
<text fg={props.api.theme.current.textMuted}>(tab toggle)</text>
|
||||
</Show>
|
||||
</box>
|
||||
)}
|
||||
onConfirm={(raw) => {
|
||||
if (busy()) return
|
||||
const mod = raw.trim()
|
||||
if (!mod) {
|
||||
props.api.ui.toast({
|
||||
variant: "error",
|
||||
message: "Plugin package name is required",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
setBusy(true)
|
||||
void props.api.plugins
|
||||
.install(mod, { global: global() })
|
||||
.then((out) => {
|
||||
if (!out.ok) {
|
||||
props.api.ui.toast({
|
||||
variant: "error",
|
||||
message: out.message,
|
||||
})
|
||||
if (out.missing) {
|
||||
props.api.ui.toast({
|
||||
variant: "info",
|
||||
message: "Check npm registry/auth settings and try again.",
|
||||
})
|
||||
}
|
||||
show(props.api)
|
||||
return
|
||||
}
|
||||
|
||||
props.api.ui.toast({
|
||||
variant: "success",
|
||||
message: `Installed ${mod} (${global() ? "global" : "local"}: ${out.dir})`,
|
||||
})
|
||||
if (!out.tui) {
|
||||
props.api.ui.toast({
|
||||
variant: "info",
|
||||
message: "Package has no TUI target to load in this app.",
|
||||
})
|
||||
show(props.api)
|
||||
return
|
||||
}
|
||||
|
||||
return props.api.plugins.add(mod).then((ok) => {
|
||||
if (!ok) {
|
||||
props.api.ui.toast({
|
||||
variant: "warning",
|
||||
message: "Installed plugin, but runtime load failed. See console/logs; restart TUI to retry.",
|
||||
})
|
||||
show(props.api)
|
||||
return
|
||||
}
|
||||
|
||||
props.api.ui.toast({
|
||||
variant: "success",
|
||||
message: `Loaded ${mod} in current session.`,
|
||||
})
|
||||
show(props.api)
|
||||
})
|
||||
})
|
||||
.finally(() => {
|
||||
setBusy(false)
|
||||
})
|
||||
}}
|
||||
onCancel={() => {
|
||||
show(props.api)
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function row(api: TuiPluginApi, item: TuiPluginStatus, width: number): DialogSelectOption<string> {
|
||||
return {
|
||||
title: item.id,
|
||||
value: item.id,
|
||||
category: item.source === "internal" ? "Internal" : "External",
|
||||
description: meta(item, width),
|
||||
footer: state(api, item),
|
||||
disabled: item.id === id,
|
||||
}
|
||||
}
|
||||
|
||||
function showInstall(api: TuiPluginApi) {
|
||||
api.ui.dialog.replace(() => <Install api={api} />)
|
||||
}
|
||||
|
||||
function View(props: { api: TuiPluginApi }) {
|
||||
const size = useTerminalDimensions()
|
||||
const [list, setList] = createSignal(props.api.plugins.list())
|
||||
const [cur, setCur] = createSignal<string | undefined>()
|
||||
const [lock, setLock] = createSignal(false)
|
||||
|
||||
createEffect(() => {
|
||||
const width = size().width
|
||||
if (width >= 128) {
|
||||
props.api.ui.dialog.setSize("xlarge")
|
||||
return
|
||||
}
|
||||
if (width >= 96) {
|
||||
props.api.ui.dialog.setSize("large")
|
||||
return
|
||||
}
|
||||
props.api.ui.dialog.setSize("medium")
|
||||
})
|
||||
|
||||
const rows = createMemo(() =>
|
||||
[...list()]
|
||||
.sort((a, b) => {
|
||||
const x = a.source === "internal" ? 1 : 0
|
||||
const y = b.source === "internal" ? 1 : 0
|
||||
if (x !== y) return x - y
|
||||
return a.id.localeCompare(b.id)
|
||||
})
|
||||
.map((item) => row(props.api, item, size().width)),
|
||||
function View(props: { context: Plugin.Context; plugins: ReturnType<typeof usePlugin> }) {
|
||||
const [locked, setLocked] = createSignal(false)
|
||||
const options = createMemo(() =>
|
||||
props.plugins
|
||||
.registered()
|
||||
.filter((plugin) => plugin.id !== id)
|
||||
.sort((a, b) => a.id.localeCompare(b.id))
|
||||
.map(
|
||||
(plugin): DialogSelectOption<string> => ({
|
||||
title: plugin.id,
|
||||
value: plugin.id,
|
||||
category: plugin.source === "builtin" ? "Built-in" : "External",
|
||||
footer: (
|
||||
<span
|
||||
style={{
|
||||
fg: plugin.active
|
||||
? props.context.theme.text.feedback.success.default
|
||||
: props.context.theme.text.subdued,
|
||||
}}
|
||||
>
|
||||
{plugin.active ? "active" : "inactive"}
|
||||
</span>
|
||||
),
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
const flip = (x: string) => {
|
||||
if (lock()) return
|
||||
const item = list().find((entry) => entry.id === x)
|
||||
if (!item) return
|
||||
setLock(true)
|
||||
const task = item.active ? props.api.plugins.deactivate(x) : props.api.plugins.activate(x)
|
||||
void task
|
||||
const toggle = (plugin: DialogSelectOption<string>) => {
|
||||
if (locked()) return
|
||||
const current = props.plugins.registered().find((item) => item.id === plugin.value)
|
||||
if (!current) return
|
||||
setLocked(true)
|
||||
void (current.active ? props.plugins.deactivate(current.id) : props.plugins.activate(current.id))
|
||||
.then((ok) => {
|
||||
if (!ok) {
|
||||
props.api.ui.toast({
|
||||
variant: "error",
|
||||
message: `Failed to update plugin ${item.id}`,
|
||||
})
|
||||
}
|
||||
setList(props.api.plugins.list())
|
||||
if (ok) return
|
||||
props.context.ui.toast.show({ variant: "error", message: `Failed to update plugin ${current.id}` })
|
||||
})
|
||||
.finally(() => {
|
||||
setLock(false)
|
||||
.catch((error) => {
|
||||
props.context.ui.toast.show({
|
||||
variant: "error",
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
})
|
||||
})
|
||||
.finally(() => setLocked(false))
|
||||
}
|
||||
|
||||
return (
|
||||
<DialogSelect
|
||||
title="Plugins"
|
||||
options={rows()}
|
||||
current={cur()}
|
||||
onMove={(item) => setCur(item.value)}
|
||||
actions={[
|
||||
{
|
||||
title: "toggle",
|
||||
command: "plugins.toggle",
|
||||
hidden: lock(),
|
||||
onTrigger: (item) => {
|
||||
setCur(item.value)
|
||||
flip(item.value)
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "install",
|
||||
command: "dialog.plugins.install",
|
||||
selection: "none",
|
||||
hidden: lock(),
|
||||
onTrigger: () => {
|
||||
showInstall(props.api)
|
||||
},
|
||||
},
|
||||
]}
|
||||
onSelect={(item) => {
|
||||
setCur(item.value)
|
||||
flip(item.value)
|
||||
}}
|
||||
options={options()}
|
||||
locked={locked()}
|
||||
preserveSelection={true}
|
||||
actions={[{ title: "toggle", command: "plugins.toggle", onTrigger: toggle }]}
|
||||
onSelect={toggle}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function show(api: TuiPluginApi) {
|
||||
api.ui.dialog.replace(() => <View api={api} />)
|
||||
}
|
||||
|
||||
const tui: TuiPlugin = async (api) => {
|
||||
api.keymap.registerLayer({
|
||||
function Commands(props: { context: Plugin.Context }) {
|
||||
const plugins = usePlugin()
|
||||
props.context.keymap.layer(() => ({
|
||||
mode: "global",
|
||||
commands: [
|
||||
{
|
||||
name: "plugins.list",
|
||||
id: "plugins.list",
|
||||
title: "Plugins",
|
||||
category: "System",
|
||||
namespace: "palette",
|
||||
group: "System",
|
||||
palette: true,
|
||||
run() {
|
||||
show(api)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "plugins.install",
|
||||
title: "Install plugin",
|
||||
category: "System",
|
||||
namespace: "palette",
|
||||
run() {
|
||||
showInstall(api)
|
||||
props.context.ui.dialog.show(() => <View context={props.context} plugins={plugins} />)
|
||||
},
|
||||
},
|
||||
],
|
||||
bindings: ["plugins.list", "plugins.install"].flatMap((command) => api.tuiConfig.keybinds.get(command)),
|
||||
})
|
||||
}))
|
||||
return null
|
||||
}
|
||||
|
||||
const plugin: BuiltinTuiPlugin = {
|
||||
export default Plugin.define({
|
||||
id,
|
||||
tui,
|
||||
}
|
||||
|
||||
export default plugin
|
||||
setup(context) {
|
||||
context.ui.slot("app", () => <Commands context={context} />)
|
||||
},
|
||||
})
|
||||
|
||||
@@ -1,6 +1,26 @@
|
||||
import { Plugin } from "@opencode-ai/plugin/tui"
|
||||
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 }) {
|
||||
props.context.keymap.layer(() => ({
|
||||
@@ -23,8 +43,52 @@ function Commands(props: { context: Plugin.Context }) {
|
||||
|
||||
function Scrap(props: { context: Plugin.Context }) {
|
||||
const dimensions = useTerminalDimensions()
|
||||
const { themeV2 } = useTheme()
|
||||
const { themeV2: elevatedTheme } = useTheme().contextual("elevated")
|
||||
const theme = props.context.theme
|
||||
const elevatedTheme = props.context.theme.contextual("elevated")
|
||||
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(() => ({
|
||||
commands: [
|
||||
@@ -36,12 +100,60 @@ function Scrap(props: { context: Plugin.Context }) {
|
||||
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 (
|
||||
<box width={dimensions().width} height={dimensions().height} backgroundColor={themeV2.background.default}>
|
||||
<box flexGrow={1} />
|
||||
<box
|
||||
width={dimensions().width}
|
||||
height={dimensions().height}
|
||||
flexDirection="column"
|
||||
backgroundColor={theme.background.default}
|
||||
>
|
||||
<SessionTabs controller={controller} animations={animations()} />
|
||||
<box
|
||||
height={1}
|
||||
flexShrink={0}
|
||||
@@ -50,10 +162,13 @@ function Scrap(props: { context: Plugin.Context }) {
|
||||
paddingRight={1}
|
||||
flexDirection="row"
|
||||
>
|
||||
<text fg={elevatedTheme.text.subdued}>~/code/anomalyco/opencode</text>
|
||||
<text fg={elevatedTheme.text.subdued}>tab playground</text>
|
||||
<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 flexGrow={1} />
|
||||
</box>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,607 +0,0 @@
|
||||
/** @jsxImportSource @opentui/solid */
|
||||
import { RGBA, TextAttributes, type KeyEvent, type Renderable } from "@opentui/core"
|
||||
import { useTerminalDimensions } from "@opentui/solid"
|
||||
import { createEffect, createMemo, createSignal, For, Show } from "solid-js"
|
||||
import { Keymap } from "../../context/keymap"
|
||||
import type { ActiveKey } from "@opentui/keymap"
|
||||
import type { TuiPlugin, TuiPluginApi } from "@opencode-ai/plugin/v1/tui"
|
||||
import type { BuiltinTuiPlugin } from "../builtins"
|
||||
|
||||
const command = {
|
||||
toggle: "which-key.toggle",
|
||||
toggleLayout: "which-key.layout.toggle",
|
||||
togglePending: "which-key.pending.toggle",
|
||||
groupPrevious: "which-key.group.previous",
|
||||
groupNext: "which-key.group.next",
|
||||
scrollUp: "which-key.scroll.up",
|
||||
scrollDown: "which-key.scroll.down",
|
||||
pageUp: "which-key.page.up",
|
||||
pageDown: "which-key.page.down",
|
||||
home: "which-key.home",
|
||||
end: "which-key.end",
|
||||
} as const
|
||||
|
||||
const LAYER_PRIORITY = 900
|
||||
const toggleCommands = [command.toggle, command.toggleLayout, command.togglePending] as const
|
||||
const scrollCommands = [
|
||||
command.scrollUp,
|
||||
command.scrollDown,
|
||||
command.pageUp,
|
||||
command.pageDown,
|
||||
command.home,
|
||||
command.end,
|
||||
] as const
|
||||
const panelCommands = [command.groupPrevious, command.groupNext, ...scrollCommands] as const
|
||||
const COLUMN_GAP = 4
|
||||
const TAB_GAP = 3
|
||||
const MIN_TAB_GAP = 1
|
||||
const TAB_CONTENT_GAP = 1
|
||||
const MIN_COLUMN_WIDTH = 28
|
||||
const MAX_COLUMN_WIDTH = 44
|
||||
const PANEL_HEIGHT_RATIO = 0.3
|
||||
const MIN_PANEL_HEIGHT = 8
|
||||
const MAX_PANEL_HEIGHT = 16
|
||||
const PANEL_TOP_PADDING = 1
|
||||
const FOOTER_HEIGHT = 1
|
||||
const FOOTER_MARGIN = 1
|
||||
const UNKNOWN = "Unknown"
|
||||
|
||||
type Layout = "dock" | "overlay"
|
||||
|
||||
type Color = RGBA | string
|
||||
|
||||
type Skin = {
|
||||
panel: Color
|
||||
text: Color
|
||||
muted: Color
|
||||
subtle: Color
|
||||
key: Color
|
||||
accent: Color
|
||||
tab: Color
|
||||
tabText: Color
|
||||
}
|
||||
|
||||
type Entry = {
|
||||
type: "entry"
|
||||
key: string
|
||||
label: string
|
||||
group: string
|
||||
continues: boolean
|
||||
}
|
||||
|
||||
type Group = {
|
||||
label: string
|
||||
entries: Entry[]
|
||||
}
|
||||
|
||||
type HeaderItem = { type: "tab"; group: Group } | { type: "scroll" }
|
||||
|
||||
type GroupHeader = {
|
||||
type: "group"
|
||||
label: string
|
||||
}
|
||||
|
||||
type Item = Entry | GroupHeader
|
||||
|
||||
function text(value: unknown) {
|
||||
if (typeof value !== "string") return undefined
|
||||
const trimmed = value.trim()
|
||||
return trimmed || undefined
|
||||
}
|
||||
|
||||
function ink(api: TuiPluginApi, name: string, fallback: string): Color {
|
||||
const value = Reflect.get(api.theme.current, name)
|
||||
if (typeof value === "string") return value
|
||||
if (value instanceof RGBA) return value
|
||||
return fallback
|
||||
}
|
||||
|
||||
function skin(api: TuiPluginApi): Skin {
|
||||
return {
|
||||
panel: ink(api, "backgroundMenu", "#1c1c1c"),
|
||||
text: ink(api, "text", "#f0f0f0"),
|
||||
muted: ink(api, "textMuted", "#a5a5a5"),
|
||||
subtle: ink(api, "borderSubtle", "#6f6f6f"),
|
||||
key: ink(api, "warning", "#ffd75f"),
|
||||
accent: ink(api, "primary", "#5f87ff"),
|
||||
tab: ink(api, "primary", "#5f87ff"),
|
||||
tabText: ink(api, "selectedListItemText", "#ffffff"),
|
||||
}
|
||||
}
|
||||
|
||||
function activeKeyLabel(active: ActiveKey<Renderable, KeyEvent>) {
|
||||
if (active.continues) return text(active.tokenName) ?? text(active.display) ?? UNKNOWN
|
||||
return (
|
||||
text(active.commandAttrs?.title) ?? text(active.bindingAttrs?.desc) ?? text(active.commandAttrs?.desc) ?? UNKNOWN
|
||||
)
|
||||
}
|
||||
|
||||
function activeKeyGroup(active: ActiveKey<Renderable, KeyEvent>) {
|
||||
if (active.continues) return "System"
|
||||
return text(active.commandAttrs?.category) ?? text(active.bindingAttrs?.group) ?? UNKNOWN
|
||||
}
|
||||
|
||||
function activeKeyEntry(api: TuiPluginApi, active: ActiveKey<Renderable, KeyEvent>): Entry {
|
||||
const key = api.keys.formatSequence([
|
||||
{
|
||||
stroke: active.stroke,
|
||||
display: active.display,
|
||||
tokenName: active.tokenName,
|
||||
},
|
||||
])
|
||||
const label = activeKeyLabel(active)
|
||||
return {
|
||||
type: "entry",
|
||||
key,
|
||||
label: active.continues ? `+${label}` : label,
|
||||
group: activeKeyGroup(active),
|
||||
continues: active.continues,
|
||||
}
|
||||
}
|
||||
|
||||
function grouped(entries: Entry[]): Group[] {
|
||||
const map = new Map<string, Entry[]>()
|
||||
for (const entry of entries) map.set(entry.group, [...(map.get(entry.group) ?? []), entry])
|
||||
return [...map]
|
||||
.map(([label, entries]) => ({
|
||||
label,
|
||||
entries: entries.toSorted(
|
||||
(a, b) =>
|
||||
Number(b.continues) - Number(a.continues) || a.label.localeCompare(b.label) || a.key.localeCompare(b.key),
|
||||
),
|
||||
}))
|
||||
.toSorted((a, b) => a.label.localeCompare(b.label))
|
||||
}
|
||||
|
||||
function commandShortcut(_api: TuiPluginApi, name: string) {
|
||||
const shortcuts = Keymap.useShortcuts()
|
||||
return () => shortcuts.get(name) ?? ""
|
||||
}
|
||||
|
||||
function layout(value: unknown): Layout {
|
||||
if (value === "overlay") return "overlay"
|
||||
return "dock"
|
||||
}
|
||||
|
||||
function HomeHint(props: { api: TuiPluginApi }) {
|
||||
const trigger = commandShortcut(props.api, command.toggle)
|
||||
const look = createMemo(() => skin(props.api))
|
||||
|
||||
return (
|
||||
<box width="100%" maxWidth={75} alignItems="center" paddingTop={1} flexShrink={0}>
|
||||
<text fg={look().muted} wrapMode="none">
|
||||
Show keyboard shortcuts with <span style={{ fg: look().subtle }}>{trigger() || command.toggle}</span>
|
||||
</text>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
|
||||
function WhichKeyPanel(props: {
|
||||
api: TuiPluginApi
|
||||
layout: Layout
|
||||
mode: () => Layout
|
||||
pendingPreview: () => boolean
|
||||
pinned: () => boolean
|
||||
}) {
|
||||
const dimensions = useTerminalDimensions()
|
||||
const [offset, setOffset] = createSignal(0)
|
||||
const [activeGroup, setActiveGroup] = createSignal<string | undefined>()
|
||||
const pending = Keymap.usePendingSequence()
|
||||
const active = Keymap.useActiveKeys()
|
||||
const pendingActive = createMemo(() => pending().length > 0 && active().length > 0)
|
||||
const pendingAutoVisible = createMemo(() => props.mode() === "overlay" && props.pendingPreview() && pendingActive())
|
||||
const visible = createMemo(() => props.pinned() || pendingAutoVisible())
|
||||
const pendingMode = createMemo(() => visible() && pendingActive())
|
||||
const left = 0
|
||||
const width = createMemo(() => Math.max(1, dimensions().width))
|
||||
const panelHeight = createMemo(() =>
|
||||
Math.max(MIN_PANEL_HEIGHT, Math.min(MAX_PANEL_HEIGHT, Math.floor(dimensions().height * PANEL_HEIGHT_RATIO))),
|
||||
)
|
||||
const contentWidth = createMemo(() => Math.max(1, width() - 2))
|
||||
const columns = createMemo(() =>
|
||||
Math.max(1, Math.min(3, Math.floor((contentWidth() + COLUMN_GAP) / (MAX_COLUMN_WIDTH + COLUMN_GAP)) || 1)),
|
||||
)
|
||||
const entries = createMemo(() => active().map((item) => activeKeyEntry(props.api, item)))
|
||||
const groups = createMemo(() => grouped(entries()))
|
||||
const tabsVisible = createMemo(() => !pendingMode() && groups().length > 0)
|
||||
const headerVisible = createMemo(() => tabsVisible() || pendingMode())
|
||||
const footerVisible = createMemo(() => !pendingMode())
|
||||
const rows = createMemo(() =>
|
||||
Math.max(
|
||||
1,
|
||||
panelHeight() -
|
||||
PANEL_TOP_PADDING -
|
||||
(headerVisible() ? 1 : 0) -
|
||||
(tabsVisible() ? TAB_CONTENT_GAP : 0) -
|
||||
(footerVisible() ? FOOTER_MARGIN + FOOTER_HEIGHT : 0),
|
||||
),
|
||||
)
|
||||
const pageSize = createMemo(() => rows() * columns())
|
||||
const currentGroup = createMemo(() => {
|
||||
const group = activeGroup()
|
||||
return groups().find((item) => item.label === group) ?? groups()[0]
|
||||
})
|
||||
const activeEntries = createMemo(() => currentGroup()?.entries ?? [])
|
||||
const items = createMemo<Item[]>(() => {
|
||||
if (!pendingMode()) return activeEntries()
|
||||
return groups().flatMap((group) => [{ type: "group", label: group.label } satisfies GroupHeader, ...group.entries])
|
||||
})
|
||||
const maxOffset = createMemo(() => Math.max(0, items().length - pageSize()))
|
||||
const shown = createMemo(() => {
|
||||
const columnsItems: Item[][] = []
|
||||
let index = offset()
|
||||
for (let column = 0; column < columns() && index < items().length; column++) {
|
||||
const list: Item[] = []
|
||||
while (list.length < rows() && index < items().length) {
|
||||
list.push(items()[index]!)
|
||||
index += 1
|
||||
}
|
||||
columnsItems.push(list)
|
||||
}
|
||||
return columnsItems
|
||||
})
|
||||
const rowIndexes = createMemo(() => Array.from({ length: rows() }, (_, index) => index))
|
||||
const trigger = commandShortcut(props.api, command.toggle)
|
||||
const modeTrigger = commandShortcut(props.api, command.toggleLayout)
|
||||
const upActive = createMemo(() => offset() > 0)
|
||||
const downActive = createMemo(() => offset() < maxOffset())
|
||||
const scrollable = createMemo(() => maxOffset() > 0)
|
||||
const headerItems = createMemo<HeaderItem[]>(() => [
|
||||
...(tabsVisible() ? groups().map((group) => ({ type: "tab" as const, group })) : []),
|
||||
...(scrollable() ? [{ type: "scroll" as const }] : []),
|
||||
])
|
||||
const tabGap = createMemo(() => {
|
||||
const itemCount = headerItems().length
|
||||
if (itemCount <= 1) return 0
|
||||
const itemWidth = headerItems().reduce(
|
||||
(sum, item) => sum + (item.type === "tab" ? item.group.label.length + 2 : 3),
|
||||
0,
|
||||
)
|
||||
return Math.max(MIN_TAB_GAP, Math.min(TAB_GAP, Math.floor((contentWidth() - itemWidth) / (itemCount - 1))))
|
||||
})
|
||||
const nextMode = createMemo(() => (props.mode() === "dock" ? "overlay" : "dock"))
|
||||
const look = createMemo(() => skin(props.api))
|
||||
const columnWidth = createMemo(() =>
|
||||
Math.max(1, Math.min(MAX_COLUMN_WIDTH, Math.floor((contentWidth() - (columns() - 1) * COLUMN_GAP) / columns()))),
|
||||
)
|
||||
const clamp = (value: number) => Math.max(0, Math.min(maxOffset(), value))
|
||||
const scroll = (delta: number) => setOffset((value) => clamp(value + delta))
|
||||
const moveGroup = (delta: number) => {
|
||||
if (pendingMode()) return
|
||||
const list = groups()
|
||||
if (!list.length) return
|
||||
const index = Math.max(
|
||||
0,
|
||||
list.findIndex((item) => item.label === currentGroup()?.label),
|
||||
)
|
||||
setActiveGroup(list[(index + delta + list.length) % list.length]!.label)
|
||||
setOffset(0)
|
||||
}
|
||||
|
||||
Keymap.createLayer(() => ({
|
||||
priority: 1000,
|
||||
enabled: visible(),
|
||||
commands: [
|
||||
{
|
||||
id: command.groupPrevious,
|
||||
bind: false,
|
||||
title: "Previous key binding group",
|
||||
description: "Show the previous which-key group",
|
||||
group: "System",
|
||||
run() {
|
||||
moveGroup(-1)
|
||||
},
|
||||
},
|
||||
{
|
||||
id: command.groupNext,
|
||||
bind: false,
|
||||
title: "Next key binding group",
|
||||
description: "Show the next which-key group",
|
||||
group: "System",
|
||||
run() {
|
||||
moveGroup(1)
|
||||
},
|
||||
},
|
||||
{
|
||||
id: command.scrollUp,
|
||||
bind: false,
|
||||
title: "Scroll key bindings up",
|
||||
description: "Scroll the which-key panel up",
|
||||
group: "System",
|
||||
run() {
|
||||
scroll(-columns())
|
||||
},
|
||||
},
|
||||
{
|
||||
id: command.scrollDown,
|
||||
bind: false,
|
||||
title: "Scroll key bindings down",
|
||||
description: "Scroll the which-key panel down",
|
||||
group: "System",
|
||||
run() {
|
||||
scroll(columns())
|
||||
},
|
||||
},
|
||||
{
|
||||
id: command.pageUp,
|
||||
bind: false,
|
||||
title: "Page key bindings up",
|
||||
description: "Page the which-key panel up",
|
||||
group: "System",
|
||||
run() {
|
||||
scroll(-pageSize())
|
||||
},
|
||||
},
|
||||
{
|
||||
id: command.pageDown,
|
||||
bind: false,
|
||||
title: "Page key bindings down",
|
||||
description: "Page the which-key panel down",
|
||||
group: "System",
|
||||
run() {
|
||||
scroll(pageSize())
|
||||
},
|
||||
},
|
||||
{
|
||||
id: command.home,
|
||||
bind: false,
|
||||
title: "First key binding",
|
||||
description: "Jump to the first which-key binding",
|
||||
group: "System",
|
||||
run() {
|
||||
setOffset(0)
|
||||
},
|
||||
},
|
||||
{
|
||||
id: command.end,
|
||||
bind: false,
|
||||
title: "Last key binding",
|
||||
description: "Jump to the last which-key binding",
|
||||
group: "System",
|
||||
run() {
|
||||
setOffset(maxOffset())
|
||||
},
|
||||
},
|
||||
],
|
||||
bindings: pendingMode() ? scrollCommands : panelCommands,
|
||||
}))
|
||||
|
||||
createEffect(() => {
|
||||
if (pendingMode()) return
|
||||
const group = currentGroup()
|
||||
if (group?.label === activeGroup()) return
|
||||
setActiveGroup(group?.label)
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
if (pendingMode()) return
|
||||
activeGroup()
|
||||
setOffset(0)
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
if (!visible()) setOffset(0)
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
pending()
|
||||
setOffset(0)
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
setOffset((value) => clamp(value))
|
||||
})
|
||||
|
||||
return (
|
||||
<Show when={visible()}>
|
||||
<box
|
||||
position={props.layout === "overlay" ? "absolute" : "relative"}
|
||||
zIndex={3500}
|
||||
left={left}
|
||||
bottom={props.layout === "overlay" ? 0 : undefined}
|
||||
width={dimensions().width}
|
||||
height={panelHeight()}
|
||||
backgroundColor={look().panel}
|
||||
paddingLeft={1}
|
||||
paddingRight={1}
|
||||
paddingTop={1}
|
||||
flexShrink={0}
|
||||
flexDirection="column"
|
||||
>
|
||||
<Show when={headerVisible()}>
|
||||
<box width="100%" flexDirection="row" justifyContent="center" gap={tabGap()} flexShrink={0}>
|
||||
<For each={headerItems()}>
|
||||
{(item) => (
|
||||
<Show
|
||||
when={item.type === "tab" ? item.group : undefined}
|
||||
fallback={
|
||||
<box flexShrink={0}>
|
||||
<text wrapMode="none">
|
||||
<span style={{ fg: upActive() ? look().text : look().muted }}>↑</span>
|
||||
<span style={{ fg: look().muted }}> </span>
|
||||
<span style={{ fg: downActive() ? look().text : look().muted }}>↓</span>
|
||||
</text>
|
||||
</box>
|
||||
}
|
||||
>
|
||||
{(group) => {
|
||||
const selected = createMemo(() => currentGroup()?.label === group().label)
|
||||
return (
|
||||
<box
|
||||
paddingLeft={1}
|
||||
paddingRight={1}
|
||||
flexShrink={0}
|
||||
backgroundColor={selected() ? look().tab : undefined}
|
||||
onMouseDown={() => {
|
||||
setActiveGroup(group().label)
|
||||
setOffset(0)
|
||||
}}
|
||||
>
|
||||
<text
|
||||
fg={selected() ? look().tabText : look().muted}
|
||||
attributes={selected() ? TextAttributes.BOLD : undefined}
|
||||
wrapMode="none"
|
||||
>
|
||||
{group().label}
|
||||
</text>
|
||||
</box>
|
||||
)
|
||||
}}
|
||||
</Show>
|
||||
)}
|
||||
</For>
|
||||
</box>
|
||||
</Show>
|
||||
<Show when={tabsVisible()}>
|
||||
<box height={TAB_CONTENT_GAP} flexShrink={0} />
|
||||
</Show>
|
||||
<box height={rows()} flexShrink={0} flexDirection="column">
|
||||
<Show when={shown().length > 0} fallback={<text fg={look().muted}>No reachable bindings</text>}>
|
||||
<For each={rowIndexes()}>
|
||||
{(row) => (
|
||||
<box width="100%" flexDirection="row" justifyContent="center" gap={COLUMN_GAP}>
|
||||
<For each={shown()}>
|
||||
{(column) => {
|
||||
const item = createMemo(() => column[row])
|
||||
const entry = createMemo(() => {
|
||||
const value = item()
|
||||
if (value?.type !== "entry") return undefined
|
||||
return value
|
||||
})
|
||||
return (
|
||||
<box width={columnWidth()} flexDirection="row" gap={1} justifyContent="space-between">
|
||||
<Show when={item()}>
|
||||
{(value) => (
|
||||
<Show
|
||||
when={entry()}
|
||||
fallback={
|
||||
<text fg={look().accent} attributes={TextAttributes.BOLD} wrapMode="none" truncate>
|
||||
{value().label}
|
||||
</text>
|
||||
}
|
||||
>
|
||||
{(binding) => (
|
||||
<>
|
||||
<box flexGrow={1} minWidth={0}>
|
||||
<text
|
||||
fg={binding().continues ? look().accent : look().muted}
|
||||
wrapMode="none"
|
||||
truncate
|
||||
>
|
||||
{binding().label}
|
||||
</text>
|
||||
</box>
|
||||
<box flexShrink={0}>
|
||||
<text fg={look().text} attributes={TextAttributes.BOLD} wrapMode="none" truncate>
|
||||
{binding().key}
|
||||
</text>
|
||||
</box>
|
||||
</>
|
||||
)}
|
||||
</Show>
|
||||
)}
|
||||
</Show>
|
||||
</box>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
</box>
|
||||
)}
|
||||
</For>
|
||||
</Show>
|
||||
</box>
|
||||
<Show when={footerVisible()}>
|
||||
<box height={FOOTER_MARGIN} flexShrink={0} />
|
||||
<box width="100%" flexDirection="row" justifyContent="space-between" flexShrink={0}>
|
||||
<box>
|
||||
<text fg={look().text} wrapMode="none">
|
||||
toggle <span style={{ fg: look().subtle }}>{trigger() || command.toggle}</span>
|
||||
</text>
|
||||
</box>
|
||||
<box>
|
||||
<text fg={look().text} wrapMode="none">
|
||||
{nextMode()} <span style={{ fg: look().subtle }}>{modeTrigger() || command.toggleLayout}</span>
|
||||
</text>
|
||||
</box>
|
||||
</box>
|
||||
</Show>
|
||||
</box>
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
|
||||
const tui: TuiPlugin = async (api) => {
|
||||
const [pinned, setPinned] = createSignal(false)
|
||||
const [mode, setMode] = createSignal(layout("dock"))
|
||||
const [pendingPreview, setPendingPreview] = createSignal(false)
|
||||
|
||||
api.keymap.registerLayer({
|
||||
priority: LAYER_PRIORITY,
|
||||
commands: [
|
||||
{
|
||||
name: command.toggle,
|
||||
title: "Show key bindings",
|
||||
desc: "Toggle which-key overlay",
|
||||
category: "System",
|
||||
run() {
|
||||
setPinned((value) => !value)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: command.toggleLayout,
|
||||
title: "Toggle key bindings layout",
|
||||
desc: "Switch which-key between dock and overlay mode",
|
||||
category: "System",
|
||||
run() {
|
||||
setMode((value) => {
|
||||
const next = value === "dock" ? "overlay" : "dock"
|
||||
return next
|
||||
})
|
||||
},
|
||||
},
|
||||
{
|
||||
name: command.togglePending,
|
||||
title: "Toggle pending key preview",
|
||||
desc: "Automatically show which-key for pending key sequences in overlay mode",
|
||||
category: "System",
|
||||
run() {
|
||||
setPendingPreview((value) => {
|
||||
return !value
|
||||
})
|
||||
},
|
||||
},
|
||||
],
|
||||
bindings: toggleCommands.flatMap((command) => api.tuiConfig.keybinds.get(command)),
|
||||
})
|
||||
|
||||
api.slots.register({
|
||||
order: 200,
|
||||
slots: {
|
||||
home_bottom() {
|
||||
return <HomeHint api={api} />
|
||||
},
|
||||
app() {
|
||||
return (
|
||||
<Show when={mode() === "overlay"}>
|
||||
<WhichKeyPanel api={api} layout="overlay" mode={mode} pendingPreview={pendingPreview} pinned={pinned} />
|
||||
</Show>
|
||||
)
|
||||
},
|
||||
app_bottom() {
|
||||
return (
|
||||
<Show when={mode() === "dock"}>
|
||||
<WhichKeyPanel api={api} layout="dock" mode={mode} pendingPreview={pendingPreview} pinned={pinned} />
|
||||
</Show>
|
||||
)
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const plugin: BuiltinTuiPlugin = {
|
||||
id: "which-key",
|
||||
enabled: false,
|
||||
tui,
|
||||
}
|
||||
|
||||
export default plugin
|
||||
@@ -767,19 +767,23 @@ export function RunSubagentSelectBody(props: {
|
||||
onRows?: (rows: number) => void
|
||||
mono?: boolean
|
||||
}) {
|
||||
const [active, setActive] = createSignal(true)
|
||||
const entries = createMemo<SubagentEntry[]>(() =>
|
||||
props.tabs().map((item) => {
|
||||
const title = item.description || item.title || item.label
|
||||
return {
|
||||
category: "",
|
||||
display: title,
|
||||
description: title === item.label ? undefined : item.label,
|
||||
footer: subagentStatusLabel(item.status),
|
||||
keywords: `${item.label} ${item.description} ${item.title ?? ""} ${item.status}`,
|
||||
sessionID: item.sessionID,
|
||||
current: props.current() === item.sessionID,
|
||||
}
|
||||
}),
|
||||
props
|
||||
.tabs()
|
||||
.filter((item) => (active() ? item.status === "running" : item.status !== "running"))
|
||||
.map((item) => {
|
||||
const title = item.description || item.title || item.label
|
||||
return {
|
||||
category: "",
|
||||
display: title,
|
||||
description: title === item.label ? undefined : item.label,
|
||||
footer: subagentStatusLabel(item.status),
|
||||
keywords: `${item.label} ${item.description} ${item.title ?? ""} ${item.status}`,
|
||||
sessionID: item.sessionID,
|
||||
current: props.current() === item.sessionID,
|
||||
}
|
||||
}),
|
||||
)
|
||||
const controller = createSearchablePanelController({
|
||||
entries,
|
||||
@@ -788,6 +792,12 @@ export function RunSubagentSelectBody(props: {
|
||||
onSelect: (item) => props.onSelect(item.sessionID),
|
||||
isCurrent: (item) => item.current,
|
||||
closeOnFirstUp: true,
|
||||
onKey(event) {
|
||||
if (event.name.toLowerCase() !== "tab") return false
|
||||
event.preventDefault()
|
||||
setActive((value) => !value)
|
||||
return true
|
||||
},
|
||||
onRows: props.onRows,
|
||||
})
|
||||
|
||||
@@ -801,6 +811,7 @@ export function RunSubagentSelectBody(props: {
|
||||
theme={props.theme}
|
||||
inputRef={controller.inputRef}
|
||||
onQuery={controller.setQuery}
|
||||
hint={`tab show ${active() ? "inactive" : "active"}`}
|
||||
mono={props.mono}
|
||||
>
|
||||
<RunFooterMenu
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
import type { TuiRouteDefinition } from "@opencode-ai/plugin/v1/tui"
|
||||
import { createSignal } from "solid-js"
|
||||
|
||||
type RouteEntry = {
|
||||
key: symbol
|
||||
render: TuiRouteDefinition["render"]
|
||||
}
|
||||
|
||||
export type RouteMap = Map<string, RouteEntry[]>
|
||||
|
||||
export function createPluginRoutes() {
|
||||
const routes: RouteMap = new Map()
|
||||
const [revision, setRevision] = createSignal(0)
|
||||
|
||||
return {
|
||||
register(list: TuiRouteDefinition[]) {
|
||||
const key = Symbol()
|
||||
list.forEach((item) => routes.set(item.name, [...(routes.get(item.name) ?? []), { key, render: item.render }]))
|
||||
setRevision((value) => value + 1)
|
||||
|
||||
return () => {
|
||||
list.forEach((item) => {
|
||||
const next = routes.get(item.name)?.filter((entry) => entry.key !== key) ?? []
|
||||
if (next.length) {
|
||||
routes.set(item.name, next)
|
||||
return
|
||||
}
|
||||
routes.delete(item.name)
|
||||
})
|
||||
setRevision((value) => value + 1)
|
||||
}
|
||||
},
|
||||
get(name: string) {
|
||||
revision()
|
||||
return routes.get(name)?.at(-1)?.render
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export type PluginRoutes = ReturnType<typeof createPluginRoutes>
|
||||
@@ -5,6 +5,7 @@ import SidebarLsp from "../feature-plugins/sidebar/lsp"
|
||||
import SidebarMcp from "../feature-plugins/sidebar/mcp"
|
||||
import DiffViewer from "../feature-plugins/system/diff-viewer"
|
||||
import Notifications from "../feature-plugins/system/notifications"
|
||||
import Plugins from "../feature-plugins/system/plugins"
|
||||
import Scrap from "../feature-plugins/system/scrap"
|
||||
|
||||
export const builtins = [
|
||||
@@ -14,6 +15,7 @@ export const builtins = [
|
||||
SidebarLsp,
|
||||
SidebarFooter,
|
||||
Notifications,
|
||||
Plugins,
|
||||
Scrap,
|
||||
DiffViewer,
|
||||
]
|
||||
|
||||
@@ -1,108 +0,0 @@
|
||||
// Legacy `api.command` bridge for v1 plugins; remove in v2.
|
||||
import type { TuiCommand, TuiPluginApi } from "@opencode-ai/plugin/v1/tui"
|
||||
import { TuiKeybind } from "../config/keybind"
|
||||
import type { DialogContext } from "../ui/dialog"
|
||||
|
||||
const COMMAND_PALETTE_SHOW = "command.palette.show"
|
||||
const warned = new Set<string>()
|
||||
|
||||
type Warn = (api: string, replacement: string) => void
|
||||
type LegacyDialog = TuiPluginApi["ui"]["dialog"]
|
||||
type CommandShimDialog = DialogContext | LegacyDialog
|
||||
type LegacyKeybinds = TuiPluginApi["tuiConfig"]["keybinds"]
|
||||
|
||||
function warnCommandShim(api: string, replacement: string) {
|
||||
// Warn v1 plugins about deprecated `api.command`; remove this shim path in v2.
|
||||
console.warn("[tui.plugin] deprecated TUI plugin API", { api, replacement })
|
||||
}
|
||||
|
||||
function createCommandShimDialog(dialog: CommandShimDialog): LegacyDialog {
|
||||
if (!("stack" in dialog)) return dialog
|
||||
return {
|
||||
replace(render, onClose) {
|
||||
dialog.replace(render, onClose)
|
||||
},
|
||||
clear() {
|
||||
dialog.clear()
|
||||
},
|
||||
setSize(size) {
|
||||
dialog.setSize(size)
|
||||
},
|
||||
get size() {
|
||||
return dialog.size
|
||||
},
|
||||
get depth() {
|
||||
return dialog.stack.length
|
||||
},
|
||||
get open() {
|
||||
return dialog.stack.length > 0
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function warnOnce(api: string, replacement: string, warn: Warn) {
|
||||
if (warned.has(api)) return
|
||||
warned.add(api)
|
||||
warn(api, replacement)
|
||||
}
|
||||
|
||||
function toCommand(item: TuiCommand, dialog: LegacyDialog) {
|
||||
return {
|
||||
namespace: "palette",
|
||||
name: item.value,
|
||||
title: item.title,
|
||||
desc: item.description,
|
||||
category: item.category,
|
||||
suggested: item.suggested,
|
||||
hidden: item.hidden,
|
||||
enabled: item.enabled,
|
||||
slash: item.slash,
|
||||
run() {
|
||||
return item.onSelect?.(dialog)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function toBindings(commands: TuiCommand[], keybinds: LegacyKeybinds) {
|
||||
return commands.flatMap((item) =>
|
||||
item.keybind
|
||||
? keybinds.has(TuiKeybind.CommandMap[item.keybind as keyof typeof TuiKeybind.CommandMap] ?? item.keybind)
|
||||
? keybinds
|
||||
.get(TuiKeybind.CommandMap[item.keybind as keyof typeof TuiKeybind.CommandMap] ?? item.keybind)
|
||||
.map((binding) => ({ ...binding, cmd: item.value, desc: binding.desc ?? item.title }))
|
||||
: [
|
||||
{
|
||||
key: item.keybind,
|
||||
cmd: item.value,
|
||||
desc: item.title,
|
||||
},
|
||||
]
|
||||
: [],
|
||||
)
|
||||
}
|
||||
|
||||
export function createCommandShim(
|
||||
keymap: TuiPluginApi["keymap"],
|
||||
dialog: CommandShimDialog,
|
||||
keybinds: LegacyKeybinds,
|
||||
): TuiPluginApi["command"] {
|
||||
const shimDialog = createCommandShimDialog(dialog)
|
||||
return {
|
||||
register(cb) {
|
||||
warnOnce("api.command.register", "api.keymap.registerLayer({ commands, bindings })", warnCommandShim)
|
||||
const commands = cb()
|
||||
return keymap.registerLayer({
|
||||
commands: commands.map((item) => toCommand(item, shimDialog)),
|
||||
bindings: toBindings(commands, keybinds),
|
||||
})
|
||||
},
|
||||
trigger(value) {
|
||||
warnOnce("api.command.trigger", "api.keymap.dispatchCommand(name)", warnCommandShim)
|
||||
keymap.dispatchCommand(value)
|
||||
},
|
||||
show() {
|
||||
warnOnce("api.command.show", `api.keymap.dispatchCommand("${COMMAND_PALETTE_SHOW}")`, warnCommandShim)
|
||||
keymap.dispatchCommand(COMMAND_PALETTE_SHOW)
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Plugin } from "@opencode-ai/plugin/tui"
|
||||
import { PluginContextProvider, type Plugin } from "@opencode-ai/plugin/tui"
|
||||
import {
|
||||
batch,
|
||||
createContext,
|
||||
@@ -16,6 +16,7 @@ import { fileURLToPath, pathToFileURL } from "url"
|
||||
import type { Context, Dialog, Page, Slot, SlotMap, SlotName, Toast } from "@opencode-ai/plugin/tui/context"
|
||||
import { createStore, produce, reconcile as reconcileStore } from "solid-js/store"
|
||||
import { useRenderer } from "@opentui/solid"
|
||||
import { ensureRuntimePluginSupport } from "@opentui/solid/runtime-plugin-support/configure"
|
||||
import { useConfig } from "../config"
|
||||
import { useClient } from "../context/client"
|
||||
import { useData } from "../context/data"
|
||||
@@ -23,7 +24,7 @@ import { Keymap } from "../context/keymap"
|
||||
import { useRoute } from "../context/route"
|
||||
import { useTuiApp, useTuiLifecycle, useTuiPaths } from "../context/runtime"
|
||||
import { useLocation } from "../context/location"
|
||||
import { useTheme } from "../context/theme"
|
||||
import { useTheme, useThemes } from "../context/theme"
|
||||
import { DialogAlert } from "../ui/dialog-alert"
|
||||
import { DialogConfirm } from "../ui/dialog-confirm"
|
||||
import { DialogPrompt } from "../ui/dialog-prompt"
|
||||
@@ -33,6 +34,9 @@ import { useToast } from "../ui/toast"
|
||||
import { useAttention } from "../context/attention"
|
||||
import { abbreviateHome } from "../util/path-format"
|
||||
import { builtins } from "./builtins"
|
||||
import { discoverTuiPlugins } from "./discovery"
|
||||
|
||||
ensureRuntimePluginSupport()
|
||||
|
||||
export interface PackageResolver {
|
||||
readonly resolve: (spec: string) => Promise<string | undefined>
|
||||
@@ -44,9 +48,16 @@ type State =
|
||||
| { readonly target: string; readonly status: "unsupported" }
|
||||
| { readonly target: string; readonly status: "failed"; readonly error: string }
|
||||
|
||||
type RegisteredPlugin = {
|
||||
readonly id: string
|
||||
readonly source: "builtin" | "external"
|
||||
readonly active: boolean
|
||||
}
|
||||
|
||||
type Value = {
|
||||
readonly ready: () => boolean
|
||||
readonly list: () => ReadonlyArray<State>
|
||||
readonly registered: () => ReadonlyArray<RegisteredPlugin>
|
||||
readonly route: (id: string, name: string) => Page["render"] | undefined
|
||||
readonly slot: <Name extends SlotName>(name: Name) => ReadonlyArray<Slot<Name>>
|
||||
readonly activate: (id: string) => Promise<boolean>
|
||||
@@ -55,8 +66,8 @@ type Value = {
|
||||
|
||||
type Dispose = () => Promise<void>
|
||||
type Registration = {
|
||||
target: string
|
||||
plugin: Plugin.Definition
|
||||
source: RegisteredPlugin["source"]
|
||||
options?: Readonly<Record<string, any>>
|
||||
active: boolean
|
||||
routes: Record<string, Page>
|
||||
@@ -80,6 +91,8 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver }>
|
||||
const paths = useTuiPaths()
|
||||
const location = useLocation()
|
||||
const theme = useTheme()
|
||||
const themes = useThemes()
|
||||
const pluginTheme = createPluginTheme(theme, themes)
|
||||
const dialog = useDialog()
|
||||
const toast = useToast()
|
||||
const attention = useAttention()
|
||||
@@ -100,25 +113,12 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver }>
|
||||
setStore("registrations", id, "cleanups", [])
|
||||
})
|
||||
const owned: Dispose[] = []
|
||||
let activeDialog: symbol | undefined
|
||||
let context: Context
|
||||
const dialogApi: Dialog = {
|
||||
show(render, onClose) {
|
||||
const token = Symbol()
|
||||
let closed = false
|
||||
activeDialog = token
|
||||
dialog.replace(render, () => {
|
||||
if (closed) return
|
||||
closed = true
|
||||
if (activeDialog === token) activeDialog = undefined
|
||||
onClose?.()
|
||||
})
|
||||
return () => {
|
||||
if (closed || activeDialog !== token) return
|
||||
dialog.clear()
|
||||
}
|
||||
dialog.replace(() => <PluginContextProvider value={context}>{render()}</PluginContextProvider>, onClose)
|
||||
},
|
||||
set(options) {
|
||||
if (!activeDialog) return
|
||||
dialog.setSize(options.size ?? "medium")
|
||||
dialog.setCentered(options.centered ?? false)
|
||||
},
|
||||
@@ -214,8 +214,7 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver }>
|
||||
toast.show({ ...options, variant: options.variant ?? "info" })
|
||||
},
|
||||
}
|
||||
owned.push(async () => dialogApi.clear())
|
||||
const context: Context = {
|
||||
context = {
|
||||
options: item.options ?? {},
|
||||
get location() {
|
||||
return location.current
|
||||
@@ -225,7 +224,7 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver }>
|
||||
client: client.api,
|
||||
data,
|
||||
attention,
|
||||
theme,
|
||||
theme: pluginTheme,
|
||||
keymap: {
|
||||
layer: Keymap.createLayer,
|
||||
dispatch: keymap.dispatch,
|
||||
@@ -245,7 +244,10 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver }>
|
||||
register(page) {
|
||||
if (store.registrations[item.plugin.id]?.routes[page.name])
|
||||
throw new Error(`Route already registered: ${page.name}`)
|
||||
setStore("registrations", item.plugin.id, "routes", page.name, page)
|
||||
setStore("registrations", item.plugin.id, "routes", page.name, {
|
||||
...page,
|
||||
render: (input) => <PluginContextProvider value={context}>{page.render(input)}</PluginContextProvider>,
|
||||
})
|
||||
let registered = true
|
||||
const unregister = () => {
|
||||
if (!registered) return
|
||||
@@ -275,7 +277,9 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver }>
|
||||
},
|
||||
slot(name, render) {
|
||||
if (store.registrations[item.plugin.id]?.slots[name]) throw new Error(`Slot already registered: ${name}`)
|
||||
setStore("registrations", item.plugin.id, "slots", name, () => render)
|
||||
setStore("registrations", item.plugin.id, "slots", name, () => (input: SlotMap[typeof name]) => (
|
||||
<PluginContextProvider value={context}>{render(input)}</PluginContextProvider>
|
||||
))
|
||||
let registered = true
|
||||
const unregister = () => {
|
||||
if (!registered) return
|
||||
@@ -342,7 +346,7 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver }>
|
||||
.filter(([, registration]) => registration.active)
|
||||
.map(([id]) => deactivate(id)),
|
||||
)
|
||||
const entries = config.data.plugins ?? []
|
||||
const entries = [...(await discoverTuiPlugins(paths.cwd)), ...(config.data.plugins ?? [])]
|
||||
batch(() => {
|
||||
setStore("registrations", reconcileStore({}))
|
||||
setStore("states", [])
|
||||
@@ -350,8 +354,8 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver }>
|
||||
|
||||
for (const plugin of builtins) {
|
||||
setStore("registrations", plugin.id, {
|
||||
target: plugin.id,
|
||||
plugin,
|
||||
source: "builtin",
|
||||
active: false,
|
||||
routes: {},
|
||||
slots: {},
|
||||
@@ -395,23 +399,24 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver }>
|
||||
continue
|
||||
}
|
||||
|
||||
const item = { target, plugin, options }
|
||||
setStore("registrations", item.plugin.id, {
|
||||
...item,
|
||||
setStore("registrations", plugin.id, {
|
||||
plugin,
|
||||
source: "external",
|
||||
options,
|
||||
active: false,
|
||||
routes: {},
|
||||
slots: {},
|
||||
cleanups: [],
|
||||
})
|
||||
const error = await activate(item.plugin.id).then(
|
||||
const error = await activate(plugin.id).then(
|
||||
() => undefined,
|
||||
(error) => (error instanceof Error ? error.message : String(error)),
|
||||
)
|
||||
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
|
||||
? { target: item.target, status: "failed", error }
|
||||
: { target: item.target, id: item.plugin.id, status: "active" },
|
||||
? { target, status: "failed", error }
|
||||
: { target, id: plugin.id, status: "active" },
|
||||
])
|
||||
}
|
||||
}
|
||||
@@ -445,6 +450,8 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver }>
|
||||
value={{
|
||||
ready: () => store.ready,
|
||||
list: () => store.states,
|
||||
registered: () =>
|
||||
Object.entries(store.registrations).map(([id, plugin]) => ({ id, source: plugin.source, active: plugin.active })),
|
||||
route: (id, name) => store.registrations[id]?.routes[name]?.render,
|
||||
slot: (name) =>
|
||||
Object.values(store.registrations).flatMap((registration) =>
|
||||
@@ -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() {
|
||||
const value = useContext(PluginContext)
|
||||
if (!value) throw new Error("PluginProvider is missing")
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import { readdir } from "node:fs/promises"
|
||||
import path from "node:path"
|
||||
|
||||
const extensions = new Set([".cjs", ".cts", ".js", ".jsx", ".mjs", ".mts", ".ts", ".tsx"])
|
||||
|
||||
export async function discoverTuiPlugins(cwd: string) {
|
||||
const directory = path.join(cwd, ".opencode", "plugins", "tui")
|
||||
const entries = await readdir(directory, { withFileTypes: true }).catch((error: unknown) => {
|
||||
if (error && typeof error === "object" && Reflect.get(error, "code") === "ENOENT") return []
|
||||
return Promise.reject(error)
|
||||
})
|
||||
return entries
|
||||
.filter((entry) => (entry.isFile() || entry.isSymbolicLink()) && extensions.has(path.extname(entry.name)))
|
||||
.map((entry) => path.join(directory, entry.name))
|
||||
.sort()
|
||||
}
|
||||
@@ -1,79 +0,0 @@
|
||||
import type {
|
||||
TuiPluginApi,
|
||||
TuiPluginInstallOptions,
|
||||
TuiPluginInstallResult,
|
||||
TuiPluginStatus,
|
||||
} from "@opencode-ai/plugin/v1/tui"
|
||||
import { createContext, createSignal, useContext, type JSX, type ParentProps } from "solid-js"
|
||||
import { createPluginRoutes } from "./api"
|
||||
import { createSlots, type HostSlots } from "./slots"
|
||||
|
||||
export function createPluginRuntime() {
|
||||
const [commands, setCommands] = createSignal<PluginRuntimeCommands>(emptyCommands)
|
||||
const [status, setStatus] = createSignal<ReadonlyArray<TuiPluginStatus>>([])
|
||||
const slots = createSlots()
|
||||
|
||||
return {
|
||||
Slot: slots.Slot,
|
||||
routes: createPluginRoutes(),
|
||||
commands,
|
||||
status,
|
||||
update(input: { commands?: PluginRuntimeCommands; status?: ReadonlyArray<TuiPluginStatus> }) {
|
||||
if (input.commands) setCommands(input.commands)
|
||||
if (input.status) setStatus(input.status)
|
||||
},
|
||||
clear() {
|
||||
setCommands(emptyCommands)
|
||||
setStatus([])
|
||||
slots.clear()
|
||||
},
|
||||
setupSlots(api: TuiPluginApi): HostSlots {
|
||||
return slots.setup(api)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export type PluginRuntimeCommands = {
|
||||
activate: (id: string) => Promise<boolean>
|
||||
deactivate: (id: string) => Promise<boolean>
|
||||
add: (spec: string) => Promise<boolean>
|
||||
install: (spec: string, options?: TuiPluginInstallOptions) => Promise<TuiPluginInstallResult>
|
||||
}
|
||||
|
||||
const emptyCommands: PluginRuntimeCommands = {
|
||||
async activate() {
|
||||
return false
|
||||
},
|
||||
async deactivate() {
|
||||
return false
|
||||
},
|
||||
async add() {
|
||||
return false
|
||||
},
|
||||
async install() {
|
||||
return { ok: false, message: "Plugin runtime is not available." }
|
||||
},
|
||||
}
|
||||
|
||||
export type PluginRuntime = ReturnType<typeof createPluginRuntime>
|
||||
|
||||
export type TuiPluginHost = {
|
||||
start(input: {
|
||||
api: TuiPluginApi
|
||||
runtime: PluginRuntime
|
||||
dispose?: () => void
|
||||
}): Promise<void>
|
||||
dispose(): Promise<void>
|
||||
}
|
||||
|
||||
const Context = createContext<PluginRuntime>()
|
||||
|
||||
export function PluginRuntimeProvider(props: ParentProps<{ value: PluginRuntime }>): JSX.Element {
|
||||
return <Context.Provider value={props.value}>{props.children}</Context.Provider>
|
||||
}
|
||||
|
||||
export function usePluginRuntime() {
|
||||
const runtime = useContext(Context)
|
||||
if (!runtime) throw new Error("usePluginRuntime must be used within PluginRuntimeProvider")
|
||||
return runtime
|
||||
}
|
||||
@@ -1,65 +0,0 @@
|
||||
import type { TuiPluginApi, TuiSlotContext, TuiSlotMap, TuiSlotProps } from "@opencode-ai/plugin/v1/tui"
|
||||
import { createSlot, createSolidSlotRegistry, type JSX, type SolidPlugin } from "@opentui/solid"
|
||||
import { createSignal } from "solid-js"
|
||||
import { isRecord } from "../util/record"
|
||||
|
||||
type RuntimeSlotMap = TuiSlotMap<Record<string, object>>
|
||||
type SlotView = <Name extends string>(props: TuiSlotProps<Name>) => JSX.Element | null
|
||||
|
||||
export type HostSlotPlugin<Slots extends Record<string, object> = {}> = SolidPlugin<TuiSlotMap<Slots>, TuiSlotContext>
|
||||
export type HostPluginApi = TuiPluginApi
|
||||
export type HostSlots = {
|
||||
register: {
|
||||
(plugin: HostSlotPlugin): () => void
|
||||
<Slots extends Record<string, object>>(plugin: HostSlotPlugin<Slots>): () => void
|
||||
}
|
||||
dispose: () => void
|
||||
}
|
||||
|
||||
function isHostSlotPlugin(value: unknown): value is HostSlotPlugin<Record<string, object>> {
|
||||
if (!isRecord(value)) return false
|
||||
if (typeof value.id !== "string") return false
|
||||
return isRecord(value.slots)
|
||||
}
|
||||
|
||||
export function createSlots() {
|
||||
const empty: SlotView = (props) => props.children ?? null
|
||||
const [view, setView] = createSignal<SlotView>(empty)
|
||||
const Slot: SlotView = (props) => view()(props)
|
||||
|
||||
return {
|
||||
Slot,
|
||||
setup(api: HostPluginApi): HostSlots {
|
||||
const registry = createSolidSlotRegistry<RuntimeSlotMap, TuiSlotContext>(
|
||||
api.renderer,
|
||||
{ theme: api.theme },
|
||||
{
|
||||
onPluginError(event) {
|
||||
console.error("[tui.slot] plugin error", {
|
||||
plugin: event.pluginId,
|
||||
slot: event.slot,
|
||||
phase: event.phase,
|
||||
source: event.source,
|
||||
message: event.error.message,
|
||||
})
|
||||
},
|
||||
},
|
||||
)
|
||||
const slot = createSlot<RuntimeSlotMap, TuiSlotContext>(registry)
|
||||
setView(() => (props: TuiSlotProps<string>) => slot(props))
|
||||
|
||||
return {
|
||||
register(plugin: HostSlotPlugin) {
|
||||
if (!isHostSlotPlugin(plugin)) return () => {}
|
||||
return registry.register(plugin)
|
||||
},
|
||||
dispose() {
|
||||
setView(() => empty)
|
||||
},
|
||||
}
|
||||
},
|
||||
clear() {
|
||||
setView(() => empty)
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,6 @@ import { useArgs } from "../context/args"
|
||||
import { useRouteData } from "../context/route"
|
||||
import { usePromptRef } from "../context/prompt"
|
||||
import { useLocal } from "../context/local"
|
||||
import { usePluginRuntime } from "../plugin/runtime"
|
||||
import { useEditorContext } from "../context/editor"
|
||||
import { useData } from "../context/data"
|
||||
import { useLocation } from "../context/location"
|
||||
@@ -19,7 +18,6 @@ const placeholder = {
|
||||
}
|
||||
|
||||
export function Home() {
|
||||
const pluginRuntime = usePluginRuntime()
|
||||
const route = useRouteData("home")
|
||||
const promptRef = usePromptRef()
|
||||
const [ref, setRef] = createSignal<PromptRef | undefined>()
|
||||
@@ -70,20 +68,11 @@ export function Home() {
|
||||
<box flexGrow={1} minHeight={0} />
|
||||
<box height={4} minHeight={0} flexShrink={1} />
|
||||
<box flexShrink={0}>
|
||||
<pluginRuntime.Slot name="home_logo" mode="replace">
|
||||
<Logo />
|
||||
</pluginRuntime.Slot>
|
||||
<Logo />
|
||||
</box>
|
||||
<box height={1} minHeight={0} flexShrink={1} />
|
||||
<box width="100%" maxWidth={75} zIndex={1000} paddingTop={1} flexShrink={0}>
|
||||
<pluginRuntime.Slot name="home_prompt" mode="replace" ref={bind}>
|
||||
<Prompt
|
||||
ref={bind}
|
||||
right={<pluginRuntime.Slot name="home_prompt_right" />}
|
||||
placeholders={placeholder}
|
||||
disabled={forms().length > 0}
|
||||
/>
|
||||
</pluginRuntime.Slot>
|
||||
<Prompt ref={bind} placeholders={placeholder} disabled={forms().length > 0} />
|
||||
</box>
|
||||
<box flexGrow={1} minHeight={0} />
|
||||
</box>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { createEffect, createMemo, For, onCleanup, Show, useContext, createContext } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { TextAttributes } from "@opentui/core"
|
||||
import { useTheme } from "../../../context/theme"
|
||||
import { useThemes } from "../../../context/theme"
|
||||
import { SplitBorder } from "../../../ui/border"
|
||||
import { Keymap } from "../../../context/keymap"
|
||||
import { SubagentsTab } from "./subagents-tab"
|
||||
@@ -39,7 +39,7 @@ export type ComposerProps = {
|
||||
}
|
||||
|
||||
export function Composer(props: ComposerProps) {
|
||||
const { themeV2 } = useTheme().contextual("elevated")
|
||||
const theme = useThemes().contextual("elevated")
|
||||
|
||||
const [store, setStore] = createStore({
|
||||
tabs: {} as Record<string, Tab>,
|
||||
@@ -98,12 +98,6 @@ export function Composer(props: ComposerProps) {
|
||||
{ bind: "left", title: "Previous tab", group: "Composer", run: () => switchTab(-1) },
|
||||
{ bind: "right", title: "Next tab", group: "Composer", run: () => switchTab(1) },
|
||||
{ bind: "escape", title: "Close composer", group: "Composer", run: close },
|
||||
{
|
||||
bind: "<leader>down",
|
||||
title: "Toggle composer",
|
||||
group: "Composer",
|
||||
run: close,
|
||||
},
|
||||
],
|
||||
}))
|
||||
|
||||
@@ -113,8 +107,8 @@ export function Composer(props: ComposerProps) {
|
||||
<box
|
||||
{...SplitBorder}
|
||||
border={["left"]}
|
||||
borderColor={themeV2.border.default}
|
||||
backgroundColor={themeV2.background.default}
|
||||
borderColor={theme.border.default}
|
||||
backgroundColor={theme.background.default}
|
||||
paddingLeft={1}
|
||||
paddingRight={2}
|
||||
paddingTop={1}
|
||||
@@ -125,7 +119,7 @@ export function Composer(props: ComposerProps) {
|
||||
<Show
|
||||
when={tabList().length > 1}
|
||||
fallback={
|
||||
<text fg={themeV2.text.default} attributes={TextAttributes.BOLD}>
|
||||
<text fg={theme.text.default} attributes={TextAttributes.BOLD}>
|
||||
{tabList()[0]?.label ?? ""}
|
||||
</text>
|
||||
}
|
||||
@@ -136,7 +130,7 @@ export function Composer(props: ComposerProps) {
|
||||
const isActive = createMemo(() => store.active === t.id)
|
||||
return (
|
||||
<text
|
||||
fg={isActive() ? themeV2.text.default : themeV2.text.subdued}
|
||||
fg={isActive() ? theme.text.default : theme.text.subdued}
|
||||
attributes={isActive() ? TextAttributes.BOLD : undefined}
|
||||
>
|
||||
{t.label}
|
||||
@@ -146,7 +140,7 @@ export function Composer(props: ComposerProps) {
|
||||
</For>
|
||||
</box>
|
||||
</Show>
|
||||
<text fg={themeV2.text.subdued} onMouseUp={close}>
|
||||
<text fg={theme.text.subdued} onMouseUp={close}>
|
||||
esc
|
||||
</text>
|
||||
</box>
|
||||
@@ -156,19 +150,19 @@ export function Composer(props: ComposerProps) {
|
||||
<For each={footerHints()}>
|
||||
{(hint) => (
|
||||
<text>
|
||||
<span style={{ fg: themeV2.text.default }}>
|
||||
<span style={{ fg: theme.text.default }}>
|
||||
<b>{hint.label}</b>{" "}
|
||||
</span>
|
||||
<span style={{ fg: themeV2.text.subdued }}>{hint.shortcut}</span>
|
||||
<span style={{ fg: theme.text.subdued }}>{hint.shortcut}</span>
|
||||
</text>
|
||||
)}
|
||||
</For>
|
||||
<Show when={tabList().length > 1}>
|
||||
<text>
|
||||
<span style={{ fg: themeV2.text.default }}>
|
||||
<span style={{ fg: theme.text.default }}>
|
||||
<b>tabs</b>{" "}
|
||||
</span>
|
||||
<span style={{ fg: themeV2.text.subdued }}>←/→</span>
|
||||
<span style={{ fg: theme.text.subdued }}>←/→</span>
|
||||
</text>
|
||||
</Show>
|
||||
</box>
|
||||
|
||||
@@ -12,7 +12,7 @@ export function ShellTab(props: { sessionID: string }) {
|
||||
const data = useData()
|
||||
const location = useLocation()
|
||||
const client = useClient()
|
||||
const { themeV2 } = useTheme()
|
||||
const theme = useTheme()
|
||||
const composer = useComposerTab()
|
||||
const shortcuts = Keymap.useShortcuts()
|
||||
|
||||
@@ -98,7 +98,7 @@ export function ShellTab(props: { sessionID: string }) {
|
||||
return (
|
||||
<Show when={composer.active("shell")}>
|
||||
<scrollbox scrollbarOptions={{ visible: false }} maxHeight={5} ref={(r: ScrollBoxRenderable) => (scroll = r)}>
|
||||
<Show when={entries().length > 0} fallback={<text fg={themeV2.text.subdued}> No shell commands</text>}>
|
||||
<Show when={entries().length > 0} fallback={<text fg={theme.text.subdued}> No shell commands</text>}>
|
||||
<For each={entries()}>
|
||||
{(shell, index) => {
|
||||
const active = createMemo(() => index() === store.selected)
|
||||
@@ -108,12 +108,12 @@ export function ShellTab(props: { sessionID: string }) {
|
||||
paddingLeft={1}
|
||||
paddingRight={1}
|
||||
backgroundColor={
|
||||
active() ? themeV2.background.action.primary.focused : themeV2.background.action.primary.default
|
||||
active() ? theme.background.action.primary.focused : theme.background.action.primary.default
|
||||
}
|
||||
onMouseOver={() => setStore("selected", index())}
|
||||
>
|
||||
<text
|
||||
fg={active() ? themeV2.text.action.primary.focused : themeV2.text.action.primary.default}
|
||||
fg={active() ? theme.text.action.primary.focused : theme.text.action.primary.default}
|
||||
attributes={active() ? TextAttributes.BOLD : undefined}
|
||||
wrapMode="none"
|
||||
>
|
||||
|
||||
@@ -21,12 +21,13 @@ export function SubagentsTab(props: { sessionID: string }) {
|
||||
const route = useRouteData("session")
|
||||
const data = useData()
|
||||
const client = useClient()
|
||||
const { themeV2 } = useTheme()
|
||||
const theme = useTheme()
|
||||
const navigate = useRoute().navigate
|
||||
const composer = useComposerTab()
|
||||
const shortcuts = Keymap.useShortcuts()
|
||||
|
||||
const session = createMemo(() => data.session.get(props.sessionID))
|
||||
const [store, setStore] = createStore({ selected: 0, active: true })
|
||||
|
||||
const entries = createMemo<SubagentEntry[]>(() => {
|
||||
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 wasActive = false
|
||||
let scroll: ScrollBoxRenderable | undefined
|
||||
@@ -90,7 +90,7 @@ export function SubagentsTab(props: { sessionID: string }) {
|
||||
if (!active) {
|
||||
if (wasActive) {
|
||||
selectedSessionID = ""
|
||||
setStore("selected", 0)
|
||||
setStore({ selected: 0, active: true })
|
||||
}
|
||||
wasActive = false
|
||||
return
|
||||
@@ -140,8 +140,15 @@ export function SubagentsTab(props: { sessionID: string }) {
|
||||
label: "Subagents",
|
||||
hints: () => {
|
||||
const entry = selectedEntry()
|
||||
if (!entry || entry.status !== "running") return []
|
||||
return [{ label: "interrupt", shortcut: shortcuts.get("composer.subagent.interrupt") ?? "" }]
|
||||
return [
|
||||
...(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: () => {
|
||||
const parentID = session()?.parentID
|
||||
@@ -189,6 +196,16 @@ export function SubagentsTab(props: { sessionID: string }) {
|
||||
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",
|
||||
title: "Interrupt subagent",
|
||||
@@ -206,7 +223,10 @@ export function SubagentsTab(props: { sessionID: string }) {
|
||||
return (
|
||||
<Show when={composer.active("subagents")}>
|
||||
<scrollbox scrollbarOptions={{ visible: false }} maxHeight={5} ref={(r: ScrollBoxRenderable) => (scroll = r)}>
|
||||
<Show when={entries().length > 0} fallback={<text fg={themeV2.text.subdued}> No subagents</text>}>
|
||||
<Show
|
||||
when={entries().length > 0}
|
||||
fallback={<text fg={theme.text.subdued}> No {store.active ? "active" : "inactive"} subagents</text>}
|
||||
>
|
||||
<For each={entries()}>
|
||||
{(entry, index) => {
|
||||
const active = createMemo(() => index() === selected())
|
||||
@@ -221,10 +241,10 @@ export function SubagentsTab(props: { sessionID: string }) {
|
||||
paddingRight={1}
|
||||
backgroundColor={
|
||||
active()
|
||||
? themeV2.background.action.primary.focused
|
||||
? theme.background.action.primary.focused
|
||||
: entry.current
|
||||
? themeV2.background.action.primary.selected
|
||||
: themeV2.background.action.primary.default
|
||||
? theme.background.action.primary.selected
|
||||
: theme.background.action.primary.default
|
||||
}
|
||||
onMouseOver={() => setStore("selected", index())}
|
||||
onMouseUp={() => {
|
||||
@@ -236,10 +256,10 @@ export function SubagentsTab(props: { sessionID: string }) {
|
||||
<text
|
||||
fg={
|
||||
active()
|
||||
? themeV2.text.action.primary.focused
|
||||
? theme.text.action.primary.focused
|
||||
: entry.current
|
||||
? themeV2.text.action.primary.selected
|
||||
: themeV2.text.action.primary.default
|
||||
? theme.text.action.primary.selected
|
||||
: theme.text.action.primary.default
|
||||
}
|
||||
attributes={active() ? TextAttributes.BOLD : undefined}
|
||||
wrapMode="none"
|
||||
@@ -248,7 +268,7 @@ export function SubagentsTab(props: { sessionID: string }) {
|
||||
</text>
|
||||
</box>
|
||||
<Show when={status()}>
|
||||
<text fg={active() ? themeV2.text.action.primary.focused : themeV2.text.subdued} wrapMode="none">
|
||||
<text fg={active() ? theme.text.action.primary.focused : theme.text.subdued} wrapMode="none">
|
||||
{status()}
|
||||
</text>
|
||||
</Show>
|
||||
|
||||
@@ -8,7 +8,7 @@ import { useRoute } from "../../context/route"
|
||||
import { usePermission } from "../../context/permission"
|
||||
|
||||
export function Footer() {
|
||||
const { themeV2 } = useTheme()
|
||||
const theme = useTheme()
|
||||
const data = useData()
|
||||
const route = useRoute()
|
||||
const permission = usePermission()
|
||||
@@ -54,35 +54,35 @@ export function Footer() {
|
||||
|
||||
return (
|
||||
<box flexDirection="row" justifyContent="space-between" gap={1} flexShrink={0}>
|
||||
<text fg={themeV2.text.subdued}>{directory()}</text>
|
||||
<text fg={theme.text.subdued}>{directory()}</text>
|
||||
<box gap={2} flexDirection="row" flexShrink={0}>
|
||||
<Switch>
|
||||
<Match when={store.welcome}>
|
||||
<text fg={themeV2.text.default}>
|
||||
Get started <span style={{ fg: themeV2.text.subdued }}>/connect</span>
|
||||
<text fg={theme.text.default}>
|
||||
Get started <span style={{ fg: theme.text.subdued }}>/connect</span>
|
||||
</text>
|
||||
</Match>
|
||||
<Match when={connected()}>
|
||||
<Show when={permission.mode !== "auto" && permissions().length > 0}>
|
||||
<text fg={themeV2.text.feedback.warning.default}>
|
||||
<span style={{ fg: themeV2.text.feedback.warning.default }}>△</span> {permissions().length} Permission
|
||||
<text fg={theme.text.feedback.warning.default}>
|
||||
<span style={{ fg: theme.text.feedback.warning.default }}>△</span> {permissions().length} Permission
|
||||
{permissions().length > 1 ? "s" : ""}
|
||||
</text>
|
||||
</Show>
|
||||
<Show when={mcp()}>
|
||||
<text fg={themeV2.text.default}>
|
||||
<text fg={theme.text.default}>
|
||||
<Switch>
|
||||
<Match when={mcpError()}>
|
||||
<span style={{ fg: themeV2.text.feedback.error.default }}>⊙ </span>
|
||||
<span style={{ fg: theme.text.feedback.error.default }}>⊙ </span>
|
||||
</Match>
|
||||
<Match when={true}>
|
||||
<span style={{ fg: themeV2.text.feedback.success.default }}>⊙ </span>
|
||||
<span style={{ fg: theme.text.feedback.success.default }}>⊙ </span>
|
||||
</Match>
|
||||
</Switch>
|
||||
{mcp()} MCP
|
||||
</text>
|
||||
</Show>
|
||||
<text fg={themeV2.text.subdued}>/status</text>
|
||||
<text fg={theme.text.subdued}>/status</text>
|
||||
</Match>
|
||||
</Switch>
|
||||
</box>
|
||||
|
||||
@@ -3,7 +3,7 @@ import { createMemo, createSignal, For, onCleanup, onMount, Show } from "solid-j
|
||||
import { useRenderer, useTerminalDimensions } from "@opentui/solid"
|
||||
import type { ScrollBoxRenderable, TextareaRenderable } from "@opentui/core"
|
||||
import open from "open"
|
||||
import { useTheme } from "../../context/theme"
|
||||
import { useThemes } from "../../context/theme"
|
||||
import type { FormField, FormValue } from "@opencode-ai/client"
|
||||
import type { FormWithLocation } from "../../context/data"
|
||||
import { useClient } from "../../context/client"
|
||||
@@ -44,7 +44,9 @@ function requestOptions(form: FormWithLocation) {
|
||||
|
||||
export function FormPrompt(props: { form: FormWithLocation }) {
|
||||
const client = useClient()
|
||||
const { themeV2, mode: themeMode } = useTheme().contextual("elevated")
|
||||
const themes = useThemes()
|
||||
const theme = themes.contextual("elevated")
|
||||
const themeMode = themes.mode
|
||||
const renderer = useRenderer()
|
||||
const dimensions = useTerminalDimensions()
|
||||
const keymap = Keymap.use()
|
||||
@@ -624,27 +626,27 @@ export function FormPrompt(props: { form: FormWithLocation }) {
|
||||
|
||||
return (
|
||||
<box
|
||||
backgroundColor={themeV2.background.default}
|
||||
backgroundColor={theme.background.default}
|
||||
border={["left"]}
|
||||
borderColor={themeV2.hue.interactive[themeMode() === "light" ? 800 : 200]}
|
||||
borderColor={theme.hue.interactive[themeMode() === "light" ? 800 : 200]}
|
||||
customBorderChars={SplitBorder.customBorderChars}
|
||||
>
|
||||
<box gap={1} paddingLeft={1} paddingRight={3} paddingTop={1} paddingBottom={1}>
|
||||
<box paddingLeft={1}>
|
||||
<text fg={themeV2.text.subdued}>{props.form.title}</text>
|
||||
<text fg={theme.text.subdued}>{props.form.title}</text>
|
||||
</box>
|
||||
<Show when={message()}>
|
||||
<box paddingLeft={1}>
|
||||
<text fg={themeV2.text.default}>{message()}</text>
|
||||
<text fg={theme.text.default}>{message()}</text>
|
||||
</box>
|
||||
</Show>
|
||||
<Show when={!single() && !tabbed()}>
|
||||
<box flexDirection="row" gap={1} paddingLeft={1}>
|
||||
<text fg={themeV2.text.subdued}>
|
||||
<text fg={theme.text.subdued}>
|
||||
{confirm() ? "Review" : `Field ${Math.min(store.tab, fields().length - 1) + 1} of ${fields().length}`}
|
||||
</text>
|
||||
<Show when={fields().length > 0}>
|
||||
<text fg={themeV2.text.subdued}>
|
||||
<text fg={theme.text.subdued}>
|
||||
· {answered()}/{fields().length} completed
|
||||
</text>
|
||||
</Show>
|
||||
@@ -661,10 +663,10 @@ export function FormPrompt(props: { form: FormWithLocation }) {
|
||||
paddingRight={2}
|
||||
backgroundColor={
|
||||
isTab()
|
||||
? themeV2.background.formfield.selected
|
||||
? theme.background.formfield.selected
|
||||
: tabHover() === index()
|
||||
? themeV2.background.formfield.focused
|
||||
: themeV2.background.default
|
||||
? theme.background.formfield.focused
|
||||
: theme.background.default
|
||||
}
|
||||
onMouseOver={() => setTabHover(index())}
|
||||
onMouseOut={() => setTabHover(null)}
|
||||
@@ -676,12 +678,12 @@ export function FormPrompt(props: { form: FormWithLocation }) {
|
||||
<text
|
||||
fg={
|
||||
isTab()
|
||||
? themeV2.text.formfield.selected
|
||||
? theme.text.formfield.selected
|
||||
: tabHover() === index()
|
||||
? themeV2.text.formfield.focused
|
||||
? theme.text.formfield.focused
|
||||
: isAnswered()
|
||||
? themeV2.text.default
|
||||
: themeV2.text.subdued
|
||||
? theme.text.default
|
||||
: theme.text.subdued
|
||||
}
|
||||
>
|
||||
{truncate(formLabel(item), 24)}
|
||||
@@ -693,10 +695,10 @@ export function FormPrompt(props: { form: FormWithLocation }) {
|
||||
<box
|
||||
backgroundColor={
|
||||
confirm()
|
||||
? themeV2.background.formfield.selected
|
||||
? theme.background.formfield.selected
|
||||
: tabHover() === "confirm"
|
||||
? themeV2.background.formfield.focused
|
||||
: themeV2.background.default
|
||||
? theme.background.formfield.focused
|
||||
: theme.background.default
|
||||
}
|
||||
onMouseOver={() => setTabHover("confirm")}
|
||||
onMouseOut={() => setTabHover(null)}
|
||||
@@ -705,7 +707,7 @@ export function FormPrompt(props: { form: FormWithLocation }) {
|
||||
selectTabFromMouse()
|
||||
}}
|
||||
>
|
||||
<text fg={confirm() ? themeV2.text.formfield.selected : themeV2.text.formfield.default}>Confirm</text>
|
||||
<text fg={confirm() ? theme.text.formfield.selected : theme.text.formfield.default}>Confirm</text>
|
||||
</box>
|
||||
</box>
|
||||
</Show>
|
||||
@@ -714,13 +716,13 @@ export function FormPrompt(props: { form: FormWithLocation }) {
|
||||
{(external) => (
|
||||
<box paddingLeft={1} gap={1}>
|
||||
<Show when={external().title}>
|
||||
<text fg={themeV2.text.default}>{external().title}</text>
|
||||
<text fg={theme.text.default}>{external().title}</text>
|
||||
</Show>
|
||||
<Show when={external().description}>
|
||||
<text fg={themeV2.text.subdued}>{external().description}</text>
|
||||
<text fg={theme.text.subdued}>{external().description}</text>
|
||||
</Show>
|
||||
<text
|
||||
fg={themeV2.background.action.primary.default}
|
||||
fg={theme.background.action.primary.default}
|
||||
onMouseUp={() => {
|
||||
if (renderer.getSelection()?.getSelectedText()) return
|
||||
openExternal()
|
||||
@@ -729,9 +731,7 @@ export function FormPrompt(props: { form: FormWithLocation }) {
|
||||
{external().url}
|
||||
</text>
|
||||
<text
|
||||
fg={
|
||||
store.answers[external().key] === true ? themeV2.text.feedback.success.default : themeV2.text.subdued
|
||||
}
|
||||
fg={store.answers[external().key] === true ? theme.text.feedback.success.default : theme.text.subdued}
|
||||
>
|
||||
{store.answers[external().key] === true
|
||||
? "✓ Acknowledged"
|
||||
@@ -746,7 +746,7 @@ export function FormPrompt(props: { form: FormWithLocation }) {
|
||||
<Show when={!confirm() && answerField()}>
|
||||
<box paddingLeft={1} gap={1}>
|
||||
<box>
|
||||
<text fg={themeV2.text.default}>{answerField()!.description ?? formLabel(answerField()!)}</text>
|
||||
<text fg={theme.text.default}>{answerField()!.description ?? formLabel(answerField()!)}</text>
|
||||
</box>
|
||||
<Show when={textual() ? answerField()!.key : undefined} keyed>
|
||||
<box paddingLeft={1}>
|
||||
@@ -763,12 +763,12 @@ export function FormPrompt(props: { form: FormWithLocation }) {
|
||||
input() || formDisplayValue(answerField()!, store.answers[answerField()!.key], "(none)")
|
||||
}
|
||||
placeholder={placeholder()}
|
||||
placeholderColor={themeV2.text.subdued}
|
||||
placeholderColor={theme.text.subdued}
|
||||
minHeight={1}
|
||||
maxHeight={6}
|
||||
textColor={themeV2.text.default}
|
||||
focusedTextColor={themeV2.text.default}
|
||||
cursorColor={themeV2.text.default}
|
||||
textColor={theme.text.default}
|
||||
focusedTextColor={theme.text.default}
|
||||
cursorColor={theme.text.default}
|
||||
/>
|
||||
</box>
|
||||
</Show>
|
||||
@@ -793,39 +793,35 @@ export function FormPrompt(props: { form: FormWithLocation }) {
|
||||
>
|
||||
<box flexDirection="row">
|
||||
<box
|
||||
backgroundColor={
|
||||
active() ? themeV2.background.formfield.focused : themeV2.background.default
|
||||
}
|
||||
backgroundColor={active() ? theme.background.formfield.focused : theme.background.default}
|
||||
paddingRight={1}
|
||||
>
|
||||
<text
|
||||
fg={active() ? themeV2.text.formfield.focused : themeV2.text.formfield.default}
|
||||
fg={active() ? theme.text.formfield.focused : theme.text.formfield.default}
|
||||
>{`${i() + 1}.`}</text>
|
||||
</box>
|
||||
<box
|
||||
backgroundColor={
|
||||
active() ? themeV2.background.formfield.focused : themeV2.background.default
|
||||
}
|
||||
backgroundColor={active() ? theme.background.formfield.focused : theme.background.default}
|
||||
>
|
||||
<text
|
||||
fg={
|
||||
active()
|
||||
? themeV2.text.formfield.focused
|
||||
? theme.text.formfield.focused
|
||||
: picked()
|
||||
? themeV2.text.formfield.selected
|
||||
: themeV2.text.formfield.default
|
||||
? theme.text.formfield.selected
|
||||
: theme.text.formfield.default
|
||||
}
|
||||
>
|
||||
{multi() ? `[${picked() ? "✓" : " "}] ${row.label}` : row.label}
|
||||
</text>
|
||||
</box>
|
||||
<Show when={!multi()}>
|
||||
<text fg={themeV2.text.formfield.selected}>{picked() ? " ✓" : ""}</text>
|
||||
<text fg={theme.text.formfield.selected}>{picked() ? " ✓" : ""}</text>
|
||||
</Show>
|
||||
</box>
|
||||
<Show when={row.description}>
|
||||
<box paddingLeft={3}>
|
||||
<text fg={themeV2.text.subdued}>{row.description}</text>
|
||||
<text fg={theme.text.subdued}>{row.description}</text>
|
||||
</box>
|
||||
</Show>
|
||||
</box>
|
||||
@@ -843,30 +839,28 @@ export function FormPrompt(props: { form: FormWithLocation }) {
|
||||
>
|
||||
<box flexDirection="row">
|
||||
<box
|
||||
backgroundColor={other() ? themeV2.background.formfield.focused : themeV2.background.default}
|
||||
backgroundColor={other() ? theme.background.formfield.focused : theme.background.default}
|
||||
paddingRight={1}
|
||||
>
|
||||
<text fg={other() ? themeV2.text.formfield.focused : themeV2.text.formfield.default}>
|
||||
<text fg={other() ? theme.text.formfield.focused : theme.text.formfield.default}>
|
||||
{`${rows().length + 1}.`}
|
||||
</text>
|
||||
</box>
|
||||
<box
|
||||
backgroundColor={other() ? themeV2.background.formfield.focused : themeV2.background.default}
|
||||
>
|
||||
<box backgroundColor={other() ? theme.background.formfield.focused : theme.background.default}>
|
||||
<text
|
||||
fg={
|
||||
other()
|
||||
? themeV2.text.formfield.focused
|
||||
? theme.text.formfield.focused
|
||||
: customPicked()
|
||||
? themeV2.text.feedback.success.default
|
||||
: themeV2.text.default
|
||||
? theme.text.feedback.success.default
|
||||
: theme.text.default
|
||||
}
|
||||
>
|
||||
{multi() ? `[${customPicked() ? "✓" : " "}] Type your own answer` : "Type your own answer"}
|
||||
</text>
|
||||
</box>
|
||||
<Show when={!multi()}>
|
||||
<text fg={themeV2.text.feedback.success.default}>{customPicked() ? " ✓" : ""}</text>
|
||||
<text fg={theme.text.feedback.success.default}>{customPicked() ? " ✓" : ""}</text>
|
||||
</Show>
|
||||
</box>
|
||||
<Show when={store.editing}>
|
||||
@@ -882,18 +876,18 @@ export function FormPrompt(props: { form: FormWithLocation }) {
|
||||
}}
|
||||
initialValue={input()}
|
||||
placeholder="Type your own answer"
|
||||
placeholderColor={themeV2.text.subdued}
|
||||
placeholderColor={theme.text.subdued}
|
||||
minHeight={1}
|
||||
maxHeight={6}
|
||||
textColor={themeV2.text.default}
|
||||
focusedTextColor={themeV2.text.default}
|
||||
cursorColor={themeV2.text.default}
|
||||
textColor={theme.text.default}
|
||||
focusedTextColor={theme.text.default}
|
||||
cursorColor={theme.text.default}
|
||||
/>
|
||||
</box>
|
||||
</Show>
|
||||
<Show when={!store.editing && input()}>
|
||||
<box paddingLeft={3}>
|
||||
<text fg={themeV2.text.subdued}>{input()}</text>
|
||||
<text fg={theme.text.subdued}>{input()}</text>
|
||||
</box>
|
||||
</Show>
|
||||
</box>
|
||||
@@ -906,7 +900,7 @@ export function FormPrompt(props: { form: FormWithLocation }) {
|
||||
<Show when={confirm()}>
|
||||
<Show when={tabbed()}>
|
||||
<box paddingLeft={1}>
|
||||
<text fg={themeV2.text.default}>Review</text>
|
||||
<text fg={theme.text.default}>Review</text>
|
||||
</box>
|
||||
</Show>
|
||||
<scrollbox
|
||||
@@ -921,12 +915,12 @@ export function FormPrompt(props: { form: FormWithLocation }) {
|
||||
return (
|
||||
<box paddingLeft={1}>
|
||||
<text>
|
||||
<span style={{ fg: themeV2.text.subdued }}>{truncate(formLabel(item), 40)}:</span>{" "}
|
||||
<span style={{ fg: theme.text.subdued }}>{truncate(formLabel(item), 40)}:</span>{" "}
|
||||
<span
|
||||
style={{
|
||||
fg: acknowledged()
|
||||
? themeV2.text.feedback.success.default
|
||||
: themeV2.text.feedback.error.default,
|
||||
? theme.text.feedback.success.default
|
||||
: theme.text.feedback.error.default,
|
||||
}}
|
||||
>
|
||||
{acknowledged() ? "Acknowledged" : "(acknowledgement required)"}
|
||||
@@ -942,15 +936,15 @@ export function FormPrompt(props: { form: FormWithLocation }) {
|
||||
return (
|
||||
<box paddingLeft={1}>
|
||||
<text>
|
||||
<span style={{ fg: themeV2.text.subdued }}>{truncate(formLabel(item), 40)}:</span>{" "}
|
||||
<span style={{ fg: theme.text.subdued }}>{truncate(formLabel(item), 40)}:</span>{" "}
|
||||
<span
|
||||
style={{
|
||||
fg:
|
||||
invalid() || missing()
|
||||
? themeV2.text.feedback.error.default
|
||||
? theme.text.feedback.error.default
|
||||
: answered()
|
||||
? themeV2.text.default
|
||||
: themeV2.text.subdued,
|
||||
? theme.text.default
|
||||
: theme.text.subdued,
|
||||
}}
|
||||
>
|
||||
{invalid() ?? (answered() ? value() : missing() ? "(required)" : "(not answered)")}
|
||||
@@ -974,41 +968,41 @@ export function FormPrompt(props: { form: FormWithLocation }) {
|
||||
>
|
||||
<box flexDirection="row" gap={2}>
|
||||
<Show when={!single()}>
|
||||
<text fg={themeV2.text.default}>
|
||||
{"⇆"} <span style={{ fg: themeV2.text.subdued }}>tab</span>
|
||||
<text fg={theme.text.default}>
|
||||
{"⇆"} <span style={{ fg: theme.text.subdued }}>tab</span>
|
||||
</text>
|
||||
</Show>
|
||||
<Show when={!confirm() && !textual() && !externalField()}>
|
||||
<text fg={themeV2.text.default}>
|
||||
{"↑↓"} <span style={{ fg: themeV2.text.subdued }}>select</span>
|
||||
<text fg={theme.text.default}>
|
||||
{"↑↓"} <span style={{ fg: theme.text.subdued }}>select</span>
|
||||
</text>
|
||||
</Show>
|
||||
<Show when={confirm() && fields().length > 0}>
|
||||
<text fg={themeV2.text.default}>
|
||||
{"↑↓"} <span style={{ fg: themeV2.text.subdued }}>scroll</span>
|
||||
<text fg={theme.text.default}>
|
||||
{"↑↓"} <span style={{ fg: theme.text.subdued }}>scroll</span>
|
||||
</text>
|
||||
</Show>
|
||||
<text
|
||||
fg={themeV2.text.default}
|
||||
fg={theme.text.default}
|
||||
onMouseUp={() => {
|
||||
if (renderer.getSelection()?.getSelectedText()) return
|
||||
if (confirm()) submit()
|
||||
if (externalField()) acknowledgeExternal()
|
||||
}}
|
||||
>
|
||||
enter <span style={{ fg: themeV2.text.subdued }}>{actionLabel()}</span>
|
||||
enter <span style={{ fg: theme.text.subdued }}>{actionLabel()}</span>
|
||||
</text>
|
||||
<Show when={externalField() && clipboard.write}>
|
||||
<text fg={themeV2.text.default} onMouseUp={copyExternal}>
|
||||
c <span style={{ fg: themeV2.text.subdued }}>copy</span>
|
||||
<text fg={theme.text.default} onMouseUp={copyExternal}>
|
||||
c <span style={{ fg: theme.text.subdued }}>copy</span>
|
||||
</text>
|
||||
</Show>
|
||||
<text fg={themeV2.text.default} onMouseUp={cancel}>
|
||||
esc <span style={{ fg: themeV2.text.subdued }}>dismiss</span>
|
||||
<text fg={theme.text.default} onMouseUp={cancel}>
|
||||
esc <span style={{ fg: theme.text.subdued }}>dismiss</span>
|
||||
</text>
|
||||
</box>
|
||||
<Show when={store.error}>
|
||||
<text fg={themeV2.text.feedback.error.default}>{store.error}</text>
|
||||
<text fg={theme.text.feedback.error.default}>{store.error}</text>
|
||||
</Show>
|
||||
</box>
|
||||
</box>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -2,7 +2,7 @@ import { createStore } from "solid-js/store"
|
||||
import { createMemo, For, Match, Show, Switch } from "solid-js"
|
||||
import { Portal, useRenderer, useTerminalDimensions, type JSX } from "@opentui/solid"
|
||||
import type { TextareaRenderable } from "@opentui/core"
|
||||
import { useTheme } from "../../context/theme"
|
||||
import { useTheme, useThemes } from "../../context/theme"
|
||||
import type { PermissionRequest } from "@opencode-ai/client"
|
||||
import { useClient } from "../../context/client"
|
||||
import { SplitBorder } from "../../ui/border"
|
||||
@@ -18,9 +18,9 @@ import { SimulationSemantics } from "../../simulation/semantics"
|
||||
type PermissionStage = "permission" | "always" | "reject"
|
||||
|
||||
function EditBody(props: { file?: string; diff?: string; patch?: string }) {
|
||||
const themeState = useTheme()
|
||||
const themeV2 = themeState.themeV2
|
||||
const syntax = themeState.syntax
|
||||
const theme = useTheme()
|
||||
const themes = useThemes()
|
||||
const syntax = themes.currentSyntax
|
||||
const config = useConfig().data
|
||||
const dimensions = useTerminalDimensions()
|
||||
|
||||
@@ -45,8 +45,8 @@ function EditBody(props: { file?: string; diff?: string; patch?: string }) {
|
||||
scrollAcceleration={scrollAcceleration()}
|
||||
verticalScrollbarOptions={{
|
||||
trackOptions: {
|
||||
backgroundColor: themeV2.background.default,
|
||||
foregroundColor: themeV2.scrollbar.default,
|
||||
backgroundColor: theme.background.default,
|
||||
foregroundColor: theme.scrollbar.default,
|
||||
},
|
||||
}}
|
||||
>
|
||||
@@ -58,16 +58,16 @@ function EditBody(props: { file?: string; diff?: string; patch?: string }) {
|
||||
showLineNumbers={true}
|
||||
width="100%"
|
||||
wrapMode="word"
|
||||
fg={themeV2.text.default}
|
||||
addedBg={themeV2.diff.background.added}
|
||||
removedBg={themeV2.diff.background.removed}
|
||||
contextBg={themeV2.diff.background.context}
|
||||
addedSignColor={themeV2.diff.highlight.added}
|
||||
removedSignColor={themeV2.diff.highlight.removed}
|
||||
lineNumberFg={themeV2.diff.lineNumber.text}
|
||||
lineNumberBg={themeV2.diff.background.context}
|
||||
addedLineNumberBg={themeV2.diff.lineNumber.background.added}
|
||||
removedLineNumberBg={themeV2.diff.lineNumber.background.removed}
|
||||
fg={theme.text.default}
|
||||
addedBg={theme.diff.background.added}
|
||||
removedBg={theme.diff.background.removed}
|
||||
contextBg={theme.diff.background.context}
|
||||
addedSignColor={theme.diff.highlight.added}
|
||||
removedSignColor={theme.diff.highlight.removed}
|
||||
lineNumberFg={theme.diff.lineNumber.text}
|
||||
lineNumberBg={theme.diff.background.context}
|
||||
addedLineNumberBg={theme.diff.lineNumber.background.added}
|
||||
removedLineNumberBg={theme.diff.lineNumber.background.removed}
|
||||
/>
|
||||
</scrollbox>
|
||||
</Show>
|
||||
@@ -76,7 +76,7 @@ function EditBody(props: { file?: string; diff?: string; patch?: string }) {
|
||||
when={props.patch}
|
||||
fallback={
|
||||
<box paddingLeft={1}>
|
||||
<text fg={themeV2.text.subdued}>No diff provided</text>
|
||||
<text fg={theme.text.subdued}>No diff provided</text>
|
||||
</box>
|
||||
}
|
||||
>
|
||||
@@ -86,8 +86,8 @@ function EditBody(props: { file?: string; diff?: string; patch?: string }) {
|
||||
scrollAcceleration={scrollAcceleration()}
|
||||
verticalScrollbarOptions={{
|
||||
trackOptions: {
|
||||
backgroundColor: themeV2.background.default,
|
||||
foregroundColor: themeV2.scrollbar.default,
|
||||
backgroundColor: theme.background.default,
|
||||
foregroundColor: theme.scrollbar.default,
|
||||
},
|
||||
}}
|
||||
>
|
||||
@@ -97,7 +97,7 @@ function EditBody(props: { file?: string; diff?: string; patch?: string }) {
|
||||
streaming={true}
|
||||
syntaxStyle={syntax()}
|
||||
content={patch()}
|
||||
fg={themeV2.text.subdued}
|
||||
fg={theme.text.subdued}
|
||||
/>
|
||||
</scrollbox>
|
||||
)}
|
||||
@@ -128,7 +128,7 @@ export function PermissionPrompt(props: { request: PermissionRequest; directory?
|
||||
return { input: undefined, metadata: undefined }
|
||||
})
|
||||
|
||||
const { themeV2 } = useTheme()
|
||||
const theme = useTheme()
|
||||
|
||||
return (
|
||||
<Switch>
|
||||
@@ -140,7 +140,7 @@ export function PermissionPrompt(props: { request: PermissionRequest; directory?
|
||||
body={
|
||||
<box paddingLeft={1} gap={1}>
|
||||
<For each={permissionAlwaysLines(props.request)}>
|
||||
{(line, index) => <text fg={index() === 0 ? themeV2.text.subdued : themeV2.text.default}>{line}</text>}
|
||||
{(line, index) => <text fg={index() === 0 ? theme.text.subdued : theme.text.default}>{line}</text>}
|
||||
</For>
|
||||
</box>
|
||||
}
|
||||
@@ -192,9 +192,9 @@ export function PermissionPrompt(props: { request: PermissionRequest; directory?
|
||||
) : props.request.action === "external_directory" ? (
|
||||
<Show when={current.lines.length > 0}>
|
||||
<box paddingLeft={1} gap={1}>
|
||||
<text fg={themeV2.text.subdued}>Patterns</text>
|
||||
<text fg={theme.text.subdued}>Patterns</text>
|
||||
<box>
|
||||
<For each={current.lines}>{(line) => <text fg={themeV2.text.default}>{line}</text>}</For>
|
||||
<For each={current.lines}>{(line) => <text fg={theme.text.default}>{line}</text>}</For>
|
||||
</box>
|
||||
</box>
|
||||
</Show>
|
||||
@@ -207,8 +207,8 @@ export function PermissionPrompt(props: { request: PermissionRequest; directory?
|
||||
props.request.action === "shell" ||
|
||||
props.request.action === "subagent" ||
|
||||
props.request.action === "task"
|
||||
? themeV2.text.default
|
||||
: themeV2.text.subdued
|
||||
? theme.text.default
|
||||
: theme.text.subdued
|
||||
}
|
||||
>
|
||||
{line}
|
||||
@@ -221,15 +221,15 @@ export function PermissionPrompt(props: { request: PermissionRequest; directory?
|
||||
const header = () => (
|
||||
<box flexDirection="column" gap={0}>
|
||||
<box flexDirection="row" gap={1} flexShrink={0}>
|
||||
<text fg={themeV2.text.feedback.warning.default}>{"△"}</text>
|
||||
<text fg={themeV2.text.default}>Permission required</text>
|
||||
<text fg={theme.text.feedback.warning.default}>{"△"}</text>
|
||||
<text fg={theme.text.default}>Permission required</text>
|
||||
</box>
|
||||
<Show when={props.request.action !== "shell" && current.title}>
|
||||
<box flexDirection="row" gap={1} paddingLeft={2} flexShrink={0}>
|
||||
<text fg={themeV2.text.subdued} flexShrink={0}>
|
||||
<text fg={theme.text.subdued} flexShrink={0}>
|
||||
{current.icon}
|
||||
</text>
|
||||
<text fg={themeV2.text.default}>{current.title}</text>
|
||||
<text fg={theme.text.default}>{current.title}</text>
|
||||
</box>
|
||||
</Show>
|
||||
</box>
|
||||
@@ -297,7 +297,7 @@ function RejectPrompt(props: {
|
||||
onCancel: () => void
|
||||
}) {
|
||||
let input: TextareaRenderable
|
||||
const { themeV2 } = useTheme().contextual("elevated")
|
||||
const theme = useThemes().contextual("elevated")
|
||||
const dimensions = useTerminalDimensions()
|
||||
const narrow = createMemo(() => dimensions().width < 80)
|
||||
Keymap.createLayer(() => ({
|
||||
@@ -329,18 +329,18 @@ function RejectPrompt(props: {
|
||||
role: "dialog",
|
||||
label: `Reject permission: ${props.action}`,
|
||||
}))}
|
||||
backgroundColor={themeV2.background.default}
|
||||
backgroundColor={theme.background.default}
|
||||
border={["left"]}
|
||||
borderColor={themeV2.text.feedback.error.default}
|
||||
borderColor={theme.text.feedback.error.default}
|
||||
customBorderChars={SplitBorder.customBorderChars}
|
||||
>
|
||||
<box gap={1} paddingLeft={1} paddingRight={3} paddingTop={1} paddingBottom={1}>
|
||||
<box flexDirection="row" gap={1} paddingLeft={1}>
|
||||
<text fg={themeV2.text.feedback.error.default}>{"△"}</text>
|
||||
<text fg={themeV2.text.default}>Reject permission</text>
|
||||
<text fg={theme.text.feedback.error.default}>{"△"}</text>
|
||||
<text fg={theme.text.default}>Reject permission</text>
|
||||
</box>
|
||||
<box paddingLeft={1}>
|
||||
<text fg={themeV2.text.subdued}>Tell OpenCode what to do differently</text>
|
||||
<text fg={theme.text.subdued}>Tell OpenCode what to do differently</text>
|
||||
</box>
|
||||
</box>
|
||||
<box
|
||||
@@ -350,7 +350,7 @@ function RejectPrompt(props: {
|
||||
paddingLeft={2}
|
||||
paddingRight={3}
|
||||
paddingBottom={1}
|
||||
backgroundColor={themeV2.raise(themeV2.background.default)}
|
||||
backgroundColor={theme.raise(theme.background.default)}
|
||||
justifyContent={narrow() ? "flex-start" : "space-between"}
|
||||
alignItems={narrow() ? "flex-start" : "center"}
|
||||
gap={1}
|
||||
@@ -369,9 +369,9 @@ function RejectPrompt(props: {
|
||||
val.traits = { status: "REJECT" }
|
||||
}}
|
||||
focused
|
||||
textColor={themeV2.text.default}
|
||||
focusedTextColor={themeV2.text.default}
|
||||
cursorColor={themeV2.text.default}
|
||||
textColor={theme.text.default}
|
||||
focusedTextColor={theme.text.default}
|
||||
cursorColor={theme.text.default}
|
||||
/>
|
||||
<box
|
||||
id="session.permission.reject.actions"
|
||||
@@ -394,8 +394,8 @@ function RejectPrompt(props: {
|
||||
}))}
|
||||
onMouseUp={() => props.onConfirm(input.plainText)}
|
||||
>
|
||||
<text fg={themeV2.text.default}>
|
||||
enter <span style={{ fg: themeV2.text.subdued }}>confirm</span>
|
||||
<text fg={theme.text.default}>
|
||||
enter <span style={{ fg: theme.text.subdued }}>confirm</span>
|
||||
</text>
|
||||
</box>
|
||||
<box
|
||||
@@ -408,8 +408,8 @@ function RejectPrompt(props: {
|
||||
}))}
|
||||
onMouseUp={props.onCancel}
|
||||
>
|
||||
<text fg={themeV2.text.default}>
|
||||
esc <span style={{ fg: themeV2.text.subdued }}>cancel</span>
|
||||
<text fg={theme.text.default}>
|
||||
esc <span style={{ fg: theme.text.subdued }}>cancel</span>
|
||||
</text>
|
||||
</box>
|
||||
</box>
|
||||
@@ -429,7 +429,7 @@ function Prompt<const T extends Record<string, string>>(props: {
|
||||
fullscreen?: boolean
|
||||
onSelect: (option: keyof T) => void
|
||||
}) {
|
||||
const { themeV2 } = useTheme().contextual("elevated")
|
||||
const theme = useThemes().contextual("elevated")
|
||||
const dimensions = useTerminalDimensions()
|
||||
const keys = Object.keys(props.options) as (keyof T)[]
|
||||
const [store, setStore] = createStore({
|
||||
@@ -534,9 +534,9 @@ function Prompt<const T extends Record<string, string>>(props: {
|
||||
label: props.semanticLabel ?? props.title,
|
||||
expanded: store.expanded,
|
||||
}))}
|
||||
backgroundColor={themeV2.background.default}
|
||||
backgroundColor={theme.background.default}
|
||||
border={["left"]}
|
||||
borderColor={themeV2.background.action.primary.focused}
|
||||
borderColor={theme.background.action.primary.focused}
|
||||
customBorderChars={SplitBorder.customBorderChars}
|
||||
{...(store.expanded
|
||||
? { top: dimensions().height * -1 + 1, bottom: 1, left: 2, right: 2, position: "absolute" }
|
||||
@@ -554,8 +554,8 @@ function Prompt<const T extends Record<string, string>>(props: {
|
||||
when={props.header}
|
||||
fallback={
|
||||
<box flexDirection="row" gap={1} paddingLeft={1} flexShrink={0}>
|
||||
<text fg={themeV2.text.feedback.warning.default}>{"△"}</text>
|
||||
<text fg={themeV2.text.default}>{props.title}</text>
|
||||
<text fg={theme.text.feedback.warning.default}>{"△"}</text>
|
||||
<text fg={theme.text.default}>{props.title}</text>
|
||||
</box>
|
||||
}
|
||||
>
|
||||
@@ -573,7 +573,7 @@ function Prompt<const T extends Record<string, string>>(props: {
|
||||
paddingLeft={2}
|
||||
paddingRight={3}
|
||||
paddingBottom={1}
|
||||
backgroundColor={themeV2.raise(themeV2.background.default)}
|
||||
backgroundColor={theme.raise(theme.background.default)}
|
||||
justifyContent={narrow() ? "flex-start" : "space-between"}
|
||||
alignItems={narrow() ? "flex-start" : "center"}
|
||||
>
|
||||
@@ -604,8 +604,8 @@ function Prompt<const T extends Record<string, string>>(props: {
|
||||
paddingRight={1}
|
||||
backgroundColor={
|
||||
option === store.selected
|
||||
? themeV2.background.action.primary.focused
|
||||
: themeV2.background.action.primary.default
|
||||
? theme.background.action.primary.focused
|
||||
: theme.background.action.primary.default
|
||||
}
|
||||
onMouseOver={() => setStore("selected", option)}
|
||||
onMouseUp={() => {
|
||||
@@ -614,11 +614,7 @@ function Prompt<const T extends Record<string, string>>(props: {
|
||||
}}
|
||||
>
|
||||
<text
|
||||
fg={
|
||||
option === store.selected
|
||||
? themeV2.text.action.primary.focused
|
||||
: themeV2.text.action.primary.default
|
||||
}
|
||||
fg={option === store.selected ? theme.text.action.primary.focused : theme.text.action.primary.default}
|
||||
>
|
||||
{props.options[option]}
|
||||
</text>
|
||||
@@ -628,15 +624,15 @@ function Prompt<const T extends Record<string, string>>(props: {
|
||||
</box>
|
||||
<box flexDirection="row" gap={2} flexShrink={0}>
|
||||
<Show when={props.fullscreen}>
|
||||
<text fg={themeV2.text.default}>
|
||||
{shortcuts.get("permission.prompt.fullscreen")} <span style={{ fg: themeV2.text.subdued }}>{hint()}</span>
|
||||
<text fg={theme.text.default}>
|
||||
{shortcuts.get("permission.prompt.fullscreen")} <span style={{ fg: theme.text.subdued }}>{hint()}</span>
|
||||
</text>
|
||||
</Show>
|
||||
<text fg={themeV2.text.default}>
|
||||
{"⇆"} <span style={{ fg: themeV2.text.subdued }}>select</span>
|
||||
<text fg={theme.text.default}>
|
||||
{"⇆"} <span style={{ fg: theme.text.subdued }}>select</span>
|
||||
</text>
|
||||
<text fg={themeV2.text.default}>
|
||||
enter <span style={{ fg: themeV2.text.subdued }}>confirm</span>
|
||||
<text fg={theme.text.default}>
|
||||
enter <span style={{ fg: theme.text.subdued }}>confirm</span>
|
||||
</text>
|
||||
</box>
|
||||
</box>
|
||||
|
||||
@@ -1,16 +1,14 @@
|
||||
import { useData } from "../../context/data"
|
||||
import { createMemo, Show } from "solid-js"
|
||||
import { useTheme } from "../../context/theme"
|
||||
import { useThemes } from "../../context/theme"
|
||||
import { useConfig } from "../../config"
|
||||
import { usePluginRuntime } from "../../plugin/runtime"
|
||||
import { PluginSlot } from "../../plugin/context"
|
||||
|
||||
import { getScrollAcceleration } from "../../util/scroll"
|
||||
|
||||
export function Sidebar(props: { sessionID: string; overlay?: boolean }) {
|
||||
const pluginRuntime = usePluginRuntime()
|
||||
const data = useData()
|
||||
const { themeV2 } = useTheme().contextual("elevated")
|
||||
const theme = useThemes().contextual("elevated")
|
||||
const config = useConfig().data
|
||||
const session = createMemo(() => data.session.get(props.sessionID))
|
||||
const scrollAcceleration = createMemo(() => getScrollAcceleration(config))
|
||||
@@ -18,7 +16,7 @@ export function Sidebar(props: { sessionID: string; overlay?: boolean }) {
|
||||
return (
|
||||
<Show when={session()}>
|
||||
<box
|
||||
backgroundColor={themeV2.background.default}
|
||||
backgroundColor={theme.background.default}
|
||||
width={42}
|
||||
height="100%"
|
||||
paddingTop={1}
|
||||
@@ -32,27 +30,20 @@ export function Sidebar(props: { sessionID: string; overlay?: boolean }) {
|
||||
scrollAcceleration={scrollAcceleration()}
|
||||
verticalScrollbarOptions={{
|
||||
trackOptions: {
|
||||
backgroundColor: themeV2.background.default,
|
||||
foregroundColor: themeV2.scrollbar.default,
|
||||
backgroundColor: theme.background.default,
|
||||
foregroundColor: theme.scrollbar.default,
|
||||
},
|
||||
}}
|
||||
>
|
||||
<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}>
|
||||
<text fg={themeV2.text.default}>
|
||||
<b>{session()!.title}</b>
|
||||
</text>
|
||||
<Show when={session()!.location.workspaceID}>
|
||||
<text fg={themeV2.text.subdued}>{session()!.location.workspaceID}</text>
|
||||
</Show>
|
||||
</box>
|
||||
</pluginRuntime.Slot>
|
||||
<box paddingRight={1}>
|
||||
<text fg={theme.text.default}>
|
||||
<b>{session()!.title}</b>
|
||||
</text>
|
||||
<Show when={session()!.location.workspaceID}>
|
||||
<text fg={theme.text.subdued}>{session()!.location.workspaceID}</text>
|
||||
</Show>
|
||||
</box>
|
||||
<PluginSlot name="sidebar.content" input={{ sessionID: props.sessionID }} mode="all" />
|
||||
</box>
|
||||
</scrollbox>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { createMemo, createSignal, Show } from "solid-js"
|
||||
import { useRouteData } from "../../context/route"
|
||||
import { useData } from "../../context/data"
|
||||
import { useTheme } from "../../context/theme"
|
||||
import { useThemes } from "../../context/theme"
|
||||
import { SplitBorder } from "../../ui/border"
|
||||
import { Locale } from "../../util/locale"
|
||||
import { useTerminalDimensions } from "@opentui/solid"
|
||||
@@ -42,7 +42,7 @@ export function SubagentFooter() {
|
||||
}
|
||||
})
|
||||
|
||||
const { themeV2 } = useTheme().contextual("elevated")
|
||||
const theme = useThemes().contextual("elevated")
|
||||
const keymap = Keymap.use()
|
||||
const shortcuts = Keymap.useShortcuts()
|
||||
const [hover, setHover] = createSignal<"parent" | "prev" | "next" | null>(null)
|
||||
@@ -57,18 +57,18 @@ export function SubagentFooter() {
|
||||
paddingRight={1}
|
||||
{...SplitBorder}
|
||||
border={["left"]}
|
||||
borderColor={themeV2.border.default}
|
||||
borderColor={theme.border.default}
|
||||
flexShrink={0}
|
||||
backgroundColor={themeV2.background.default}
|
||||
backgroundColor={theme.background.default}
|
||||
>
|
||||
<box flexDirection="row" justifyContent="space-between" gap={1}>
|
||||
<box flexDirection="row" gap={1}>
|
||||
<text fg={themeV2.text.default}>
|
||||
<text fg={theme.text.default}>
|
||||
<b>{subagentInfo()}</b>
|
||||
</text>
|
||||
<Show when={usage()}>
|
||||
{(item) => (
|
||||
<text fg={themeV2.text.subdued} wrapMode="none">
|
||||
<text fg={theme.text.subdued} wrapMode="none">
|
||||
{[item().context, item().cost].filter(Boolean).join(" · ")}
|
||||
</text>
|
||||
)}
|
||||
@@ -80,35 +80,31 @@ export function SubagentFooter() {
|
||||
onMouseOut={() => setHover(null)}
|
||||
onMouseUp={() => keymap.dispatch("session.parent")}
|
||||
backgroundColor={
|
||||
hover() === "parent" ? themeV2.background.action.primary.hovered : themeV2.background.default
|
||||
hover() === "parent" ? theme.background.action.primary.hovered : theme.background.default
|
||||
}
|
||||
>
|
||||
<text fg={themeV2.text.default}>
|
||||
Parent <span style={{ fg: themeV2.text.subdued }}>{shortcuts.get("session.parent")}</span>
|
||||
<text fg={theme.text.default}>
|
||||
Parent <span style={{ fg: theme.text.subdued }}>{shortcuts.get("session.parent")}</span>
|
||||
</text>
|
||||
</box>
|
||||
<box
|
||||
onMouseOver={() => setHover("prev")}
|
||||
onMouseOut={() => setHover(null)}
|
||||
onMouseUp={() => keymap.dispatch("session.child.previous")}
|
||||
backgroundColor={
|
||||
hover() === "prev" ? themeV2.background.action.primary.hovered : themeV2.background.default
|
||||
}
|
||||
backgroundColor={hover() === "prev" ? theme.background.action.primary.hovered : theme.background.default}
|
||||
>
|
||||
<text fg={themeV2.text.default}>
|
||||
Prev <span style={{ fg: themeV2.text.subdued }}>{shortcuts.get("session.child.previous")}</span>
|
||||
<text fg={theme.text.default}>
|
||||
Prev <span style={{ fg: theme.text.subdued }}>{shortcuts.get("session.child.previous")}</span>
|
||||
</text>
|
||||
</box>
|
||||
<box
|
||||
onMouseOver={() => setHover("next")}
|
||||
onMouseOut={() => setHover(null)}
|
||||
onMouseUp={() => keymap.dispatch("session.child.next")}
|
||||
backgroundColor={
|
||||
hover() === "next" ? themeV2.background.action.primary.hovered : themeV2.background.default
|
||||
}
|
||||
backgroundColor={hover() === "next" ? theme.background.action.primary.hovered : theme.background.default}
|
||||
>
|
||||
<text fg={themeV2.text.default}>
|
||||
Next <span style={{ fg: themeV2.text.subdued }}>{shortcuts.get("session.child.next")}</span>
|
||||
<text fg={theme.text.default}>
|
||||
Next <span style={{ fg: theme.text.subdued }}>{shortcuts.get("session.child.next")}</span>
|
||||
</text>
|
||||
</box>
|
||||
</box>
|
||||
|
||||
@@ -0,0 +1,235 @@
|
||||
import { createEffect, createSignal, onCleanup, type Accessor } from "solid-js"
|
||||
|
||||
type AnimatableValue = number | readonly number[]
|
||||
type AnimatableTarget = Record<string, AnimatableValue>
|
||||
type Ease = (progress: number) => number
|
||||
|
||||
type SpringTransition = {
|
||||
type: "spring"
|
||||
visualDuration: number
|
||||
restDelta: number
|
||||
restSpeed: number
|
||||
}
|
||||
|
||||
type TweenTransition = {
|
||||
type: "tween"
|
||||
duration: number
|
||||
ease: Ease
|
||||
}
|
||||
|
||||
type Transition = SpringTransition | TweenTransition
|
||||
|
||||
type ValueState = {
|
||||
scalar: boolean
|
||||
value: number[]
|
||||
target: number[]
|
||||
velocity: number[]
|
||||
from: number[]
|
||||
}
|
||||
|
||||
type AnimationTask = (now: number) => boolean
|
||||
|
||||
const tasks = new Set<AnimationTask>()
|
||||
let timer: ReturnType<typeof setInterval> | undefined
|
||||
|
||||
const smoothstep = (progress: number) => progress * progress * (3 - 2 * progress)
|
||||
|
||||
export function spring(options: { visualDuration: number; restDelta?: number; restSpeed?: number }): Transition {
|
||||
return {
|
||||
type: "spring",
|
||||
visualDuration: options.visualDuration,
|
||||
restDelta: options.restDelta ?? 0.002,
|
||||
restSpeed: options.restSpeed ?? 0.002,
|
||||
}
|
||||
}
|
||||
|
||||
export function tween(options: { duration: number; ease?: Ease }): Transition {
|
||||
return {
|
||||
type: "tween",
|
||||
duration: options.duration,
|
||||
ease: options.ease ?? smoothstep,
|
||||
}
|
||||
}
|
||||
|
||||
export function createAnimatable<T extends AnimatableTarget>(
|
||||
initial: T,
|
||||
options: {
|
||||
transition: Transition
|
||||
enabled?: Accessor<boolean>
|
||||
},
|
||||
) {
|
||||
const enabled = options.enabled ?? (() => true)
|
||||
const state = new Map<string, ValueState>()
|
||||
const [value, setValue] = createSignal(clone(initial), { equals: false })
|
||||
let target = clone(initial)
|
||||
let started = performance.now()
|
||||
let previous = started
|
||||
|
||||
rebuild(initial)
|
||||
|
||||
const step: AnimationTask = (now) => {
|
||||
if (!enabled()) {
|
||||
jump(target)
|
||||
return false
|
||||
}
|
||||
|
||||
const delta = Math.min(0.05, (now - previous) / 1_000)
|
||||
previous = now
|
||||
const moving = options.transition.type === "spring" ? advanceSpring(delta) : advanceTween(now)
|
||||
if (!moving) {
|
||||
jump(target)
|
||||
return false
|
||||
}
|
||||
setValue(() => read())
|
||||
return true
|
||||
}
|
||||
|
||||
function animate(next: T) {
|
||||
if (sameTarget(next)) return
|
||||
target = clone(next)
|
||||
if (!enabled() || !sameShape(next)) return jump(next)
|
||||
|
||||
started = performance.now()
|
||||
previous = started
|
||||
for (const [key, current] of state) {
|
||||
current.from = [...current.value]
|
||||
current.target = values(next[key]!)
|
||||
}
|
||||
if (settled()) return jump(next)
|
||||
schedule(step)
|
||||
}
|
||||
|
||||
function jump(next: T) {
|
||||
target = clone(next)
|
||||
unschedule(step)
|
||||
rebuild(next)
|
||||
setValue(() => clone(next))
|
||||
}
|
||||
|
||||
function stop() {
|
||||
unschedule(step)
|
||||
}
|
||||
|
||||
function rebuild(next: T) {
|
||||
state.clear()
|
||||
for (const [key, nextValue] of Object.entries(next)) {
|
||||
const value = values(nextValue)
|
||||
state.set(key, {
|
||||
scalar: typeof nextValue === "number",
|
||||
value,
|
||||
target: [...value],
|
||||
velocity: value.map(() => 0),
|
||||
from: [...value],
|
||||
})
|
||||
}
|
||||
started = performance.now()
|
||||
previous = started
|
||||
}
|
||||
|
||||
function sameShape(next: T) {
|
||||
const entries = Object.entries(next)
|
||||
if (entries.length !== state.size) return false
|
||||
return entries.every(([key, nextValue]) => {
|
||||
const current = state.get(key)
|
||||
if (!current || current.scalar !== (typeof nextValue === "number")) return false
|
||||
return current.value.length === values(nextValue).length
|
||||
})
|
||||
}
|
||||
|
||||
function sameTarget(next: T) {
|
||||
if (!sameShape(next)) return false
|
||||
return Object.entries(next).every(([key, value]) =>
|
||||
values(value).every((part, index) => part === state.get(key)!.target[index]),
|
||||
)
|
||||
}
|
||||
|
||||
function settled() {
|
||||
return [...state.values()].every(
|
||||
(current) =>
|
||||
current.value.every((value, index) => value === current.target[index]) &&
|
||||
current.velocity.every((velocity) => velocity === 0),
|
||||
)
|
||||
}
|
||||
|
||||
function advanceSpring(delta: number) {
|
||||
const transition = options.transition
|
||||
if (transition.type !== "spring") return false
|
||||
const frequency = (2 * Math.PI) / (Math.max(0.001, transition.visualDuration) * 1.2)
|
||||
let moving = false
|
||||
|
||||
for (const current of state.values()) {
|
||||
current.value.forEach((value, index) => {
|
||||
const target = current.target[index]!
|
||||
const velocity = current.velocity[index]!
|
||||
const offset = value - target
|
||||
const decay = Math.exp(-frequency * delta)
|
||||
const nextVelocity = velocity + frequency * offset
|
||||
current.value[index] = target + (offset + nextVelocity * delta) * decay
|
||||
current.velocity[index] = (velocity - frequency * nextVelocity * delta) * decay
|
||||
moving ||=
|
||||
Math.abs(current.value[index]! - target) > transition.restDelta ||
|
||||
Math.abs(current.velocity[index]!) > transition.restSpeed
|
||||
})
|
||||
}
|
||||
return moving
|
||||
}
|
||||
|
||||
function advanceTween(now: number) {
|
||||
const transition = options.transition
|
||||
if (transition.type !== "tween") return false
|
||||
const progress = Math.min(1, (now - started) / 1_000 / Math.max(0.001, transition.duration))
|
||||
const eased = transition.ease(progress)
|
||||
for (const current of state.values())
|
||||
current.value.forEach((_, index) => {
|
||||
const from = current.from[index]!
|
||||
current.value[index] = from + (current.target[index]! - from) * eased
|
||||
current.velocity[index] = 0
|
||||
})
|
||||
return progress < 1
|
||||
}
|
||||
|
||||
function read() {
|
||||
return Object.fromEntries(
|
||||
[...state].map(([key, current]) => [key, current.scalar ? current.value[0]! : [...current.value]]),
|
||||
) as T
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
if (enabled()) return
|
||||
jump(target)
|
||||
})
|
||||
onCleanup(stop)
|
||||
|
||||
return { value, animate, jump }
|
||||
}
|
||||
|
||||
function values(value: AnimatableValue) {
|
||||
return typeof value === "number" ? [value] : [...value]
|
||||
}
|
||||
|
||||
function clone<T extends AnimatableTarget>(target: T) {
|
||||
return Object.fromEntries(
|
||||
Object.entries(target).map(([key, value]) => [key, typeof value === "number" ? value : [...value]]),
|
||||
) as T
|
||||
}
|
||||
|
||||
function schedule(task: AnimationTask) {
|
||||
tasks.add(task)
|
||||
if (timer) return
|
||||
timer = setInterval(tick, 16)
|
||||
}
|
||||
|
||||
function unschedule(task: AnimationTask) {
|
||||
tasks.delete(task)
|
||||
if (tasks.size > 0 || !timer) return
|
||||
clearInterval(timer)
|
||||
timer = undefined
|
||||
}
|
||||
|
||||
function tick() {
|
||||
const now = performance.now()
|
||||
for (const task of tasks) if (!task(now)) tasks.delete(task)
|
||||
if (tasks.size > 0 || !timer) return
|
||||
clearInterval(timer)
|
||||
timer = undefined
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { TextAttributes } from "@opentui/core"
|
||||
import { Keymap } from "../context/keymap"
|
||||
import { useTheme } from "../context/theme"
|
||||
import { useThemes } from "../context/theme"
|
||||
import { useDialog, type DialogContext } from "./dialog"
|
||||
|
||||
export type DialogAlertProps = {
|
||||
@@ -11,7 +11,7 @@ export type DialogAlertProps = {
|
||||
|
||||
export function DialogAlert(props: DialogAlertProps) {
|
||||
const dialog = useDialog()
|
||||
const { themeV2 } = useTheme().contextual("elevated")
|
||||
const theme = useThemes().contextual("elevated")
|
||||
|
||||
Keymap.createLayer(() => ({
|
||||
mode: "modal",
|
||||
@@ -30,27 +30,27 @@ export function DialogAlert(props: DialogAlertProps) {
|
||||
return (
|
||||
<box paddingLeft={2} paddingRight={2} gap={1}>
|
||||
<box flexDirection="row" justifyContent="space-between">
|
||||
<text attributes={TextAttributes.BOLD} fg={themeV2.text.default}>
|
||||
<text attributes={TextAttributes.BOLD} fg={theme.text.default}>
|
||||
{props.title}
|
||||
</text>
|
||||
<text fg={themeV2.text.subdued} onMouseUp={() => dialog.clear()}>
|
||||
<text fg={theme.text.subdued} onMouseUp={() => dialog.clear()}>
|
||||
esc
|
||||
</text>
|
||||
</box>
|
||||
<box paddingBottom={1}>
|
||||
<text fg={themeV2.text.subdued}>{props.message}</text>
|
||||
<text fg={theme.text.subdued}>{props.message}</text>
|
||||
</box>
|
||||
<box flexDirection="row" justifyContent="flex-end" paddingBottom={1}>
|
||||
<box
|
||||
paddingLeft={3}
|
||||
paddingRight={3}
|
||||
backgroundColor={themeV2.background.action.primary.focused}
|
||||
backgroundColor={theme.background.action.primary.focused}
|
||||
onMouseUp={() => {
|
||||
props.onConfirm?.()
|
||||
dialog.clear()
|
||||
}}
|
||||
>
|
||||
<text fg={themeV2.text.action.primary.focused}>ok</text>
|
||||
<text fg={theme.text.action.primary.focused}>ok</text>
|
||||
</box>
|
||||
</box>
|
||||
</box>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { TextAttributes } from "@opentui/core"
|
||||
import { Keymap } from "../context/keymap"
|
||||
import { useTheme } from "../context/theme"
|
||||
import { useThemes } from "../context/theme"
|
||||
import { useDialog, type DialogContext } from "./dialog"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { For } from "solid-js"
|
||||
@@ -21,7 +21,7 @@ export type DialogConfirmResult = boolean | undefined
|
||||
|
||||
export function DialogConfirm(props: DialogConfirmProps) {
|
||||
const dialog = useDialog()
|
||||
const { themeV2 } = useTheme().contextual("elevated")
|
||||
const theme = useThemes().contextual("elevated")
|
||||
const [store, setStore] = createStore({
|
||||
active: "confirm" as "confirm" | "cancel",
|
||||
})
|
||||
@@ -60,15 +60,15 @@ export function DialogConfirm(props: DialogConfirmProps) {
|
||||
return (
|
||||
<box paddingLeft={2} paddingRight={2} gap={1}>
|
||||
<box flexDirection="row" justifyContent="space-between">
|
||||
<text attributes={TextAttributes.BOLD} fg={themeV2.text.default}>
|
||||
<text attributes={TextAttributes.BOLD} fg={theme.text.default}>
|
||||
{props.title}
|
||||
</text>
|
||||
<text fg={themeV2.text.subdued} onMouseUp={() => dialog.clear()}>
|
||||
<text fg={theme.text.subdued} onMouseUp={() => dialog.clear()}>
|
||||
esc
|
||||
</text>
|
||||
</box>
|
||||
<box paddingBottom={1}>
|
||||
<text fg={themeV2.text.subdued}>{props.message}</text>
|
||||
<text fg={theme.text.subdued}>{props.message}</text>
|
||||
</box>
|
||||
<box flexDirection="row" justifyContent="flex-end" paddingBottom={1}>
|
||||
<For each={["cancel", "confirm"] as const}>
|
||||
@@ -76,14 +76,14 @@ export function DialogConfirm(props: DialogConfirmProps) {
|
||||
<box
|
||||
paddingLeft={1}
|
||||
paddingRight={1}
|
||||
backgroundColor={key === store.active ? themeV2.background.action.primary.focused : undefined}
|
||||
backgroundColor={key === store.active ? theme.background.action.primary.focused : undefined}
|
||||
onMouseUp={() => {
|
||||
if (key === "confirm") props.onConfirm?.()
|
||||
if (key === "cancel") props.onCancel?.()
|
||||
dialog.clear()
|
||||
}}
|
||||
>
|
||||
<text fg={key === store.active ? themeV2.text.action.primary.focused : themeV2.text.subdued}>
|
||||
<text fg={key === store.active ? theme.text.action.primary.focused : theme.text.subdued}>
|
||||
{Locale.titlecase(props.label?.[key] ?? key)}
|
||||
</text>
|
||||
</box>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { TextAttributes } from "@opentui/core"
|
||||
import { Keymap } from "../context/keymap"
|
||||
import { useTheme } from "../context/theme"
|
||||
import { useThemes } from "../context/theme"
|
||||
import { useDialog, type DialogContext } from "./dialog"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { For, Show } from "solid-js"
|
||||
@@ -17,8 +17,8 @@ type Active = ExportFormat | "thinking" | "copy" | "export"
|
||||
|
||||
export function DialogExportOptions(props: DialogExportOptionsProps) {
|
||||
const dialog = useDialog()
|
||||
const { themeV2 } = useTheme().contextual("elevated")
|
||||
const { themeV2: overlayTheme } = useTheme().contextual("overlay")
|
||||
const theme = useThemes().contextual("elevated")
|
||||
const overlayTheme = useThemes().contextual("overlay")
|
||||
const [store, setStore] = createStore({
|
||||
format: "markdown" as ExportFormat,
|
||||
thinking: props.defaultThinking,
|
||||
@@ -73,15 +73,15 @@ export function DialogExportOptions(props: DialogExportOptionsProps) {
|
||||
return (
|
||||
<box paddingLeft={2} paddingRight={2} gap={1}>
|
||||
<box flexDirection="row" justifyContent="space-between">
|
||||
<text attributes={TextAttributes.BOLD} fg={themeV2.text.default}>
|
||||
<text attributes={TextAttributes.BOLD} fg={theme.text.default}>
|
||||
Export session
|
||||
</text>
|
||||
<text fg={themeV2.text.subdued} onMouseUp={() => dialog.clear()}>
|
||||
<text fg={theme.text.subdued} onMouseUp={() => dialog.clear()}>
|
||||
esc
|
||||
</text>
|
||||
</box>
|
||||
<box flexDirection="row" gap={1}>
|
||||
<text fg={themeV2.text.default}>Export as:</text>
|
||||
<text fg={theme.text.default}>Export as:</text>
|
||||
<box flexDirection="row" gap={1}>
|
||||
<For each={["markdown", "json"] as const}>
|
||||
{(format) => (
|
||||
@@ -90,20 +90,20 @@ export function DialogExportOptions(props: DialogExportOptionsProps) {
|
||||
paddingRight={1}
|
||||
backgroundColor={
|
||||
store.active === format
|
||||
? themeV2.background.formfield.focused
|
||||
? theme.background.formfield.focused
|
||||
: store.format === format
|
||||
? themeV2.background.formfield.selected
|
||||
: themeV2.background.formfield.default
|
||||
? theme.background.formfield.selected
|
||||
: theme.background.formfield.default
|
||||
}
|
||||
onMouseUp={() => selectFormat(format)}
|
||||
>
|
||||
<text
|
||||
fg={
|
||||
store.active === format
|
||||
? themeV2.text.formfield.focused
|
||||
? theme.text.formfield.focused
|
||||
: store.format === format
|
||||
? themeV2.text.formfield.selected
|
||||
: themeV2.text.formfield.default
|
||||
? theme.text.formfield.selected
|
||||
: theme.text.formfield.default
|
||||
}
|
||||
>
|
||||
{store.format === format ? "◉" : "○"} {format === "markdown" ? "Markdown" : "JSON"}
|
||||
@@ -119,10 +119,10 @@ export function DialogExportOptions(props: DialogExportOptionsProps) {
|
||||
gap={1}
|
||||
backgroundColor={
|
||||
store.active === "thinking"
|
||||
? themeV2.background.formfield.focused
|
||||
? theme.background.formfield.focused
|
||||
: store.thinking
|
||||
? themeV2.background.formfield.selected
|
||||
: themeV2.background.formfield.default
|
||||
? theme.background.formfield.selected
|
||||
: theme.background.formfield.default
|
||||
}
|
||||
onMouseUp={() => {
|
||||
setStore("active", "thinking")
|
||||
@@ -132,10 +132,10 @@ export function DialogExportOptions(props: DialogExportOptionsProps) {
|
||||
<text
|
||||
fg={
|
||||
store.active === "thinking"
|
||||
? themeV2.text.formfield.focused
|
||||
? theme.text.formfield.focused
|
||||
: store.thinking
|
||||
? themeV2.text.formfield.selected
|
||||
: themeV2.text.formfield.default
|
||||
? theme.text.formfield.selected
|
||||
: theme.text.formfield.default
|
||||
}
|
||||
>
|
||||
{store.thinking ? "[x]" : "[ ]"}
|
||||
@@ -143,10 +143,10 @@ export function DialogExportOptions(props: DialogExportOptionsProps) {
|
||||
<text
|
||||
fg={
|
||||
store.active === "thinking"
|
||||
? themeV2.text.formfield.focused
|
||||
? theme.text.formfield.focused
|
||||
: store.thinking
|
||||
? themeV2.text.formfield.selected
|
||||
: themeV2.text.formfield.default
|
||||
? theme.text.formfield.selected
|
||||
: theme.text.formfield.default
|
||||
}
|
||||
>
|
||||
Include thinking
|
||||
@@ -167,14 +167,12 @@ export function DialogExportOptions(props: DialogExportOptionsProps) {
|
||||
paddingRight={4}
|
||||
backgroundColor={
|
||||
store.active === "export"
|
||||
? themeV2.background.action.primary.focused
|
||||
: themeV2.background.action.primary.default
|
||||
? theme.background.action.primary.focused
|
||||
: theme.background.action.primary.default
|
||||
}
|
||||
onMouseUp={() => confirm("export")}
|
||||
>
|
||||
<text
|
||||
fg={store.active === "export" ? themeV2.text.action.primary.focused : themeV2.text.action.primary.default}
|
||||
>
|
||||
<text fg={store.active === "export" ? theme.text.action.primary.focused : theme.text.action.primary.default}>
|
||||
Export
|
||||
</text>
|
||||
</box>
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { TextAttributes } from "@opentui/core"
|
||||
import { Keymap } from "../context/keymap"
|
||||
import { useTheme } from "../context/theme"
|
||||
import { useThemes } from "../context/theme"
|
||||
import { useDialog, type DialogContext } from "./dialog"
|
||||
|
||||
export function DialogExportResult(props: { path: string; onClose?: () => void }) {
|
||||
const dialog = useDialog()
|
||||
const { themeV2 } = useTheme().contextual("elevated")
|
||||
const theme = useThemes().contextual("elevated")
|
||||
|
||||
const close = () => {
|
||||
props.onClose?.()
|
||||
@@ -27,24 +27,24 @@ export function DialogExportResult(props: { path: string; onClose?: () => void }
|
||||
return (
|
||||
<box paddingLeft={2} paddingRight={2} gap={1}>
|
||||
<box flexDirection="row" justifyContent="space-between">
|
||||
<text attributes={TextAttributes.BOLD} fg={themeV2.text.default}>
|
||||
<text attributes={TextAttributes.BOLD} fg={theme.text.default}>
|
||||
Session exported
|
||||
</text>
|
||||
<text fg={themeV2.text.subdued} onMouseUp={close}>
|
||||
<text fg={theme.text.subdued} onMouseUp={close}>
|
||||
esc
|
||||
</text>
|
||||
</box>
|
||||
<box>
|
||||
<text fg={themeV2.text.default}>{props.path}</text>
|
||||
<text fg={theme.text.default}>{props.path}</text>
|
||||
</box>
|
||||
<box flexDirection="row" justifyContent="flex-end" gap={1} paddingBottom={1}>
|
||||
<box
|
||||
paddingLeft={3}
|
||||
paddingRight={3}
|
||||
backgroundColor={themeV2.background.action.primary.focused}
|
||||
backgroundColor={theme.background.action.primary.focused}
|
||||
onMouseUp={close}
|
||||
>
|
||||
<text fg={themeV2.text.action.primary.focused}>Close</text>
|
||||
<text fg={theme.text.action.primary.focused}>Close</text>
|
||||
</box>
|
||||
</box>
|
||||
</box>
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { TextAttributes } from "@opentui/core"
|
||||
import { Keymap } from "../context/keymap"
|
||||
import { useTheme } from "../context/theme"
|
||||
import { useThemes } from "../context/theme"
|
||||
import { useDialog } from "./dialog"
|
||||
|
||||
export function DialogHelp() {
|
||||
const dialog = useDialog()
|
||||
const { themeV2 } = useTheme().contextual("elevated")
|
||||
const theme = useThemes().contextual("elevated")
|
||||
const shortcuts = Keymap.useShortcuts()
|
||||
|
||||
Keymap.createLayer(() => ({
|
||||
@@ -19,15 +19,15 @@ export function DialogHelp() {
|
||||
return (
|
||||
<box paddingLeft={2} paddingRight={2} gap={1}>
|
||||
<box flexDirection="row" justifyContent="space-between">
|
||||
<text attributes={TextAttributes.BOLD} fg={themeV2.text.default}>
|
||||
<text attributes={TextAttributes.BOLD} fg={theme.text.default}>
|
||||
Help
|
||||
</text>
|
||||
<text fg={themeV2.text.subdued} onMouseUp={() => dialog.clear()}>
|
||||
<text fg={theme.text.subdued} onMouseUp={() => dialog.clear()}>
|
||||
esc/enter
|
||||
</text>
|
||||
</box>
|
||||
<box paddingBottom={1}>
|
||||
<text fg={themeV2.text.subdued}>
|
||||
<text fg={theme.text.subdued}>
|
||||
Press {shortcuts.get("command.palette.show")} to see all available actions and commands in any context.
|
||||
</text>
|
||||
</box>
|
||||
@@ -35,10 +35,10 @@ export function DialogHelp() {
|
||||
<box
|
||||
paddingLeft={3}
|
||||
paddingRight={3}
|
||||
backgroundColor={themeV2.background.action.primary.focused}
|
||||
backgroundColor={theme.background.action.primary.focused}
|
||||
onMouseUp={() => dialog.clear()}
|
||||
>
|
||||
<text fg={themeV2.text.action.primary.focused}>ok</text>
|
||||
<text fg={theme.text.action.primary.focused}>ok</text>
|
||||
</box>
|
||||
</box>
|
||||
</box>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { TextareaRenderable, TextAttributes } from "@opentui/core"
|
||||
import { Keymap } from "../context/keymap"
|
||||
import { useTheme } from "../context/theme"
|
||||
import { useThemes } from "../context/theme"
|
||||
import { useDialog, type DialogContext } from "./dialog"
|
||||
import { Show, createEffect, createSignal, onMount, type JSX } from "solid-js"
|
||||
import { Spinner } from "../component/spinner"
|
||||
@@ -18,7 +18,7 @@ export type DialogPromptProps = {
|
||||
|
||||
export function DialogPrompt(props: DialogPromptProps) {
|
||||
const dialog = useDialog()
|
||||
const { themeV2 } = useTheme().contextual("elevated")
|
||||
const theme = useThemes().contextual("elevated")
|
||||
const shortcuts = Keymap.useShortcuts()
|
||||
const [textareaTarget, setTextareaTarget] = createSignal<TextareaRenderable>()
|
||||
let textarea: TextareaRenderable
|
||||
@@ -74,10 +74,10 @@ export function DialogPrompt(props: DialogPromptProps) {
|
||||
return (
|
||||
<box paddingLeft={2} paddingRight={2} gap={1}>
|
||||
<box flexDirection="row" justifyContent="space-between">
|
||||
<text attributes={TextAttributes.BOLD} fg={themeV2.text.default}>
|
||||
<text attributes={TextAttributes.BOLD} fg={theme.text.default}>
|
||||
{props.title}
|
||||
</text>
|
||||
<text fg={themeV2.text.subdued} onMouseUp={() => dialog.clear()}>
|
||||
<text fg={theme.text.subdued} onMouseUp={() => dialog.clear()}>
|
||||
esc
|
||||
</text>
|
||||
</box>
|
||||
@@ -91,20 +91,20 @@ export function DialogPrompt(props: DialogPromptProps) {
|
||||
}}
|
||||
initialValue={props.value}
|
||||
placeholder={props.placeholder ?? "Enter text"}
|
||||
placeholderColor={themeV2.text.subdued}
|
||||
textColor={props.busy ? themeV2.text.formfield.disabled : themeV2.text.formfield.default}
|
||||
focusedTextColor={props.busy ? themeV2.text.formfield.disabled : themeV2.text.formfield.default}
|
||||
cursorColor={props.busy ? themeV2.background.formfield.disabled : themeV2.text.default}
|
||||
placeholderColor={theme.text.subdued}
|
||||
textColor={props.busy ? theme.text.formfield.disabled : theme.text.formfield.default}
|
||||
focusedTextColor={props.busy ? theme.text.formfield.disabled : theme.text.formfield.default}
|
||||
cursorColor={props.busy ? theme.background.formfield.disabled : theme.text.default}
|
||||
/>
|
||||
<Show when={props.busy}>
|
||||
<Spinner color={themeV2.text.subdued}>{props.busyText ?? "Working..."}</Spinner>
|
||||
<Spinner color={theme.text.subdued}>{props.busyText ?? "Working..."}</Spinner>
|
||||
</Show>
|
||||
</box>
|
||||
<box paddingBottom={1} gap={1} flexDirection="row">
|
||||
<Show when={!props.busy} fallback={<text fg={themeV2.text.subdued}>processing...</text>}>
|
||||
<Show when={!props.busy} fallback={<text fg={theme.text.subdued}>processing...</text>}>
|
||||
<Show when={shortcuts.get("dialog.prompt.submit")}>
|
||||
<text fg={themeV2.text.default}>
|
||||
{shortcuts.get("dialog.prompt.submit")} <span style={{ fg: themeV2.text.subdued }}>submit</span>
|
||||
<text fg={theme.text.default}>
|
||||
{shortcuts.get("dialog.prompt.submit")} <span style={{ fg: theme.text.subdued }}>submit</span>
|
||||
</text>
|
||||
</Show>
|
||||
</Show>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { InputRenderable, RGBA, ScrollBoxRenderable, TextAttributes } from "@opentui/core"
|
||||
import { Keymap, type KeymapCommand } from "../context/keymap"
|
||||
import { useTheme } from "../context/theme"
|
||||
import { useThemes } from "../context/theme"
|
||||
import { entries, filter, flatMap, groupBy, pipe } from "remeda"
|
||||
import { batch, createEffect, createMemo, createSignal, For, Show, type JSX, on, onCleanup } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
@@ -95,7 +95,9 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
|
||||
type VisibleAction = (Action & { label: string }) | FooterHint
|
||||
|
||||
const dialog = useDialog()
|
||||
const { themeV2, mode } = useTheme().contextual("elevated")
|
||||
const themes = useThemes()
|
||||
const theme = themes.contextual("elevated")
|
||||
const mode = themes.mode
|
||||
const config = useConfig().data
|
||||
const scrollAcceleration = createMemo(() => getScrollAcceleration(config))
|
||||
|
||||
@@ -522,10 +524,10 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
|
||||
if (!isActionItem(action.item))
|
||||
return (
|
||||
<text>
|
||||
<span style={{ fg: themeV2.text.default }}>
|
||||
<span style={{ fg: theme.text.default }}>
|
||||
<b>{action.item.title}</b>{" "}
|
||||
</span>
|
||||
<span style={{ fg: themeV2.text.subdued }}>{action.item.label}</span>
|
||||
<span style={{ fg: theme.text.subdued }}>{action.item.label}</span>
|
||||
</text>
|
||||
)
|
||||
const item = action.item
|
||||
@@ -534,16 +536,16 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
|
||||
return (
|
||||
<box
|
||||
flexDirection="row"
|
||||
backgroundColor={active() ? themeV2.background.action.primary.focused : RGBA.fromInts(0, 0, 0, 0)}
|
||||
backgroundColor={active() ? theme.background.action.primary.focused : RGBA.fromInts(0, 0, 0, 0)}
|
||||
onMouseUp={() => trigger(item)}
|
||||
>
|
||||
<text
|
||||
fg={
|
||||
disabled()
|
||||
? themeV2.text.action.primary.disabled
|
||||
? theme.text.action.primary.disabled
|
||||
: active()
|
||||
? themeV2.text.action.primary.focused
|
||||
: themeV2.text.default
|
||||
? theme.text.action.primary.focused
|
||||
: theme.text.default
|
||||
}
|
||||
attributes={active() ? TextAttributes.BOLD : undefined}
|
||||
>
|
||||
@@ -552,10 +554,10 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
|
||||
<text
|
||||
fg={
|
||||
disabled()
|
||||
? themeV2.text.action.primary.disabled
|
||||
? theme.text.action.primary.disabled
|
||||
: active()
|
||||
? themeV2.text.action.primary.focused
|
||||
: themeV2.text.subdued
|
||||
? theme.text.action.primary.focused
|
||||
: theme.text.subdued
|
||||
}
|
||||
>
|
||||
{" " + item.label}
|
||||
@@ -569,11 +571,11 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
|
||||
<box paddingLeft={4} paddingRight={4}>
|
||||
<box flexDirection="row" justifyContent="space-between">
|
||||
{props.titleView ?? (
|
||||
<text fg={themeV2.text.default} attributes={TextAttributes.BOLD}>
|
||||
<text fg={theme.text.default} attributes={TextAttributes.BOLD}>
|
||||
{props.title}
|
||||
</text>
|
||||
)}
|
||||
<text fg={themeV2.text.subdued} onMouseUp={() => dialog.clear()}>
|
||||
<text fg={theme.text.subdued} onMouseUp={() => dialog.clear()}>
|
||||
esc
|
||||
</text>
|
||||
</box>
|
||||
@@ -587,9 +589,9 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
|
||||
props.onFilter?.(e)
|
||||
})
|
||||
}}
|
||||
focusedBackgroundColor={themeV2.background.formfield.focused}
|
||||
cursorColor={themeV2.text.formfield.focused}
|
||||
focusedTextColor={themeV2.text.formfield.focused}
|
||||
focusedBackgroundColor={theme.background.formfield.focused}
|
||||
cursorColor={theme.text.formfield.focused}
|
||||
focusedTextColor={theme.text.formfield.focused}
|
||||
ref={(r) => {
|
||||
input = r
|
||||
input.traits = { status: "FILTER" }
|
||||
@@ -600,7 +602,7 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
|
||||
}, 1)
|
||||
}}
|
||||
placeholder={props.placeholder ?? "Search"}
|
||||
placeholderColor={themeV2.text.subdued}
|
||||
placeholderColor={theme.text.subdued}
|
||||
/>
|
||||
</box>
|
||||
</Show>
|
||||
@@ -614,14 +616,14 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
|
||||
fallback={
|
||||
props.emptyView ?? (
|
||||
<box paddingLeft={4} paddingRight={4} paddingTop={1}>
|
||||
<text fg={themeV2.text.subdued}>No items available</text>
|
||||
<text fg={theme.text.subdued}>No items available</text>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
>
|
||||
{props.noMatchView ?? (
|
||||
<box paddingLeft={4} paddingRight={4} paddingTop={1}>
|
||||
<text fg={themeV2.text.subdued}>No results found</text>
|
||||
<text fg={theme.text.subdued}>No results found</text>
|
||||
</box>
|
||||
)}
|
||||
</Show>
|
||||
@@ -643,10 +645,7 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
|
||||
<Show
|
||||
when={options[0]?.categoryView}
|
||||
fallback={
|
||||
<text
|
||||
fg={themeV2.hue.accent[mode() === "light" ? 800 : 200]}
|
||||
attributes={TextAttributes.BOLD}
|
||||
>
|
||||
<text fg={theme.hue.accent[mode() === "light" ? 800 : 200]} attributes={TextAttributes.BOLD}>
|
||||
{category}
|
||||
</text>
|
||||
}
|
||||
@@ -695,8 +694,8 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
|
||||
backgroundColor={
|
||||
active()
|
||||
? actionFocused()
|
||||
? themeV2.background.surface.overlay
|
||||
: (option.bg ?? themeV2.background.action.primary.focused)
|
||||
? theme.background.surface.overlay
|
||||
: (option.bg ?? theme.background.action.primary.focused)
|
||||
: RGBA.fromInts(0, 0, 0, 0)
|
||||
}
|
||||
>
|
||||
@@ -725,7 +724,7 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
|
||||
{(detail) => (
|
||||
<box paddingLeft={3} paddingRight={3}>
|
||||
<text
|
||||
fg={option.detailsColor ?? themeV2.text.subdued}
|
||||
fg={option.detailsColor ?? theme.text.subdued}
|
||||
wrapMode={option.detailsWrap ? "word" : "none"}
|
||||
>
|
||||
{option.detailsWrap
|
||||
@@ -774,12 +773,12 @@ function Option(props: {
|
||||
activeColor?: RGBA
|
||||
onMouseOver?: () => void
|
||||
}) {
|
||||
const { themeV2 } = useTheme().contextual("elevated")
|
||||
const theme = useThemes().contextual("elevated")
|
||||
const text = createMemo(() => {
|
||||
if (props.active && !props.muted) return props.activeColor ?? themeV2.text.action.primary.focused
|
||||
if (props.muted && (props.active || props.current)) return themeV2.text.subdued
|
||||
if (props.current) return themeV2.text.formfield.selected
|
||||
return themeV2.text.default
|
||||
if (props.active && !props.muted) return props.activeColor ?? theme.text.action.primary.focused
|
||||
if (props.muted && (props.active || props.current)) return theme.text.subdued
|
||||
if (props.current) return theme.text.formfield.selected
|
||||
return theme.text.default
|
||||
})
|
||||
|
||||
return (
|
||||
@@ -809,14 +808,14 @@ function Option(props: {
|
||||
? Locale.truncateLeft(props.title, props.titleWidth ?? 61)
|
||||
: Locale.truncate(props.title, props.titleWidth ?? 61))}
|
||||
<Show when={props.description}>
|
||||
<span style={{ fg: props.active && !props.muted ? text() : themeV2.text.subdued }}>
|
||||
<span style={{ fg: props.active && !props.muted ? text() : theme.text.subdued }}>
|
||||
{" " + props.description}
|
||||
</span>
|
||||
</Show>
|
||||
</text>
|
||||
<Show when={props.footer}>
|
||||
<box flexShrink={0}>
|
||||
<text fg={props.active && !props.muted ? text() : themeV2.text.subdued}>{props.footer}</text>
|
||||
<text fg={props.active && !props.muted ? text() : theme.text.subdued}>{props.footer}</text>
|
||||
</box>
|
||||
</Show>
|
||||
</>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user