Compare commits

...

31 Commits

Author SHA1 Message Date
Hona d6c1b96bbf fix(app): hide built-in plugins 2026-08-19 01:39:43 +00:00
Luke Parker 3b8d3ca639 refactor(ui): promote current design system (#43200) 2026-08-19 11:36:11 +10:00
Kyle Altendorf 044d04df06 fix(cli): preserve clean build manifest (#43287) 2026-08-18 16:32:10 -05:00
Shoubhit Dash 4b9d89e943 refactor(core): move image config into state (#43090) 2026-08-18 21:07:27 +00:00
Kit Langton 5ff6bb87cf fix(core): coalesce queued compactions (#43292) 2026-08-18 21:04:02 +00:00
opencode-agent[bot] 511b4556a2 fix(tui): navigate nested subagents (#43290)
Co-authored-by: neriousy <34747899+neriousy@users.noreply.github.com>
2026-08-18 23:02:45 +02:00
Shoubhit Dash cb39ea1136 fix(core): prefer websocket with request hooks (#43234) 2026-08-19 02:20:57 +05:30
Shoubhit Dash ff9452bf03 refactor(core): move formatter config into state (#43087) 2026-08-19 02:20:24 +05:30
Aiden Cline 97265f8ac5 fix(ai): ignore orphaned Anthropic tool deltas (#43289) 2026-08-18 15:42:31 -05:00
Dax Raad 56e66656b8 fix(ci): limit cli publish build time 2026-08-18 15:43:47 -04:00
opencode-agent[bot] 02f3f3cb3e test(core): remove flaky webfetch checks (#43278)
Co-authored-by: nexxeln <nexxeln@users.noreply.github.com>
2026-08-18 20:26:26 +02:00
Dax b0c3a16ead feat(plugin): expose server plugin status 2026-08-18 14:22:32 -04:00
Filip 594c395576 fix(tui): restore MCP sign-in flow (#43274) 2026-08-18 20:02:10 +02:00
Dax b7402c264d fix(core): reduce noisy tracing spans
Reduce low-value tracing emitted during parallel tool execution and suppress per-statement SQL spans.
2026-08-18 13:42:50 -04:00
Dax 3f79699bce feat(core): persist websearch provider selection (#43268) 2026-08-18 17:32:08 +00:00
Dax 67fe76057e feat(cli): configure service environment (#43269) 2026-08-18 13:27:44 -04:00
opencode-agent[bot] bac474aaa0 fix(tui): scope session picker to active location (#43264)
Co-authored-by: neriousy <34747899+neriousy@users.noreply.github.com>
2026-08-18 17:01:15 +00:00
Aiden Cline 98c717cb5b fix(core): migrate standalone small model (#43260) 2026-08-18 11:14:36 -05:00
Major Hayden 5c8d46ab4b fix(core): make Google Vertex models work with ADC credentials (#43077)
Signed-off-by: Major Hayden <major@mhtx.net>
2026-08-18 10:47:13 -05:00
Dax Raad d5e83fefda fix(core): reuse prompt cache for forks 2026-08-18 11:38:11 -04:00
Dax Raad 46378dda50 refactor(core): standardize builtin plugin ids 2026-08-18 11:07:11 -04:00
Dax b38d9d812f refactor(client): move service shutdown client-side (#43252) 2026-08-18 15:02:37 +00:00
Dax Raad 8df039d261 fix(cli): limit source maps to development channels 2026-08-18 10:53:42 -04:00
Dax Raad c92fb2d41b fix(cli): improve process failure logging 2026-08-18 10:49:52 -04:00
Shoubhit Dash 958308c913 fix(core): ignore malformed model costs (#43251) 2026-08-18 19:59:47 +05:30
Dax Raad 16390ca47d fix(cli): log background service startup 2026-08-18 10:15:36 -04:00
Dax Raad 643eed300d fix(cli): log managed service lease loss 2026-08-18 10:06:59 -04:00
Dax Raad c3a6721de2 refactor(tui): standardize builtin plugin ids 2026-08-18 10:06:59 -04:00
Shoubhit Dash 47af7462ea feat(cli): improve session export flow (#43229) 2026-08-18 18:00:12 +05:30
Simon Klee a76ab93673 tui: update OpenTUI to 0.5.4 (#43220) 2026-08-18 13:56:34 +02:00
Shoubhit Dash dfb60e37a3 feat(cli): add interactive auth commands (#43127) 2026-08-18 16:37:56 +05:30
441 changed files with 7398 additions and 11571 deletions
+6
View File
@@ -0,0 +1,6 @@
---
"@opencode-ai/plugin": patch
"@opencode-ai/core": patch
---
Add transport-neutral Session model request hooks and provider-scoped hook registration so eligible OpenAI Responses requests can prefer WebSocket without bypassing HTTP-only middleware.
+1
View File
@@ -82,6 +82,7 @@ jobs:
build-cli:
needs: version
runs-on: blacksmith-4vcpu-ubuntu-2404
timeout-minutes: 30
if: github.repository == 'anomalyco/opencode'
steps:
- uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0
+18 -17
View File
@@ -119,6 +119,7 @@
},
"dependencies": {
"@agentclientprotocol/sdk": "1.2.1",
"@clack/prompts": "1.0.0-alpha.1",
"@effect/platform-node": "catalog:",
"@opencode-ai/client": "workspace:*",
"@opencode-ai/plugin": "workspace:*",
@@ -589,9 +590,9 @@
},
"peerDependencies": {
"@opencode-ai/theme": "workspace:*",
"@opentui/core": ">=0.5.3",
"@opentui/keymap": ">=0.5.3",
"@opentui/solid": ">=0.5.3",
"@opentui/core": ">=0.5.4",
"@opentui/keymap": ">=0.5.4",
"@opentui/solid": ">=0.5.4",
"solid-js": ">=1.9.0",
},
"optionalPeers": [
@@ -1087,9 +1088,9 @@
"@npmcli/arborist": "9.4.0",
"@octokit/rest": "22.0.0",
"@openauthjs/openauth": "0.0.0-20250322224806",
"@opentui/core": "0.5.3",
"@opentui/keymap": "0.5.3",
"@opentui/solid": "0.5.3",
"@opentui/core": "0.5.4",
"@opentui/keymap": "0.5.4",
"@opentui/solid": "0.5.4",
"@pierre/diffs": "1.2.10",
"@playwright/test": "1.59.1",
"@sentry/solid": "10.36.0",
@@ -2111,27 +2112,27 @@
"@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.43.0", "", {}, "sha512-eSYWTm620tTk45EKSedaUL8MFYI8hW164hIXsgIHyxu3VobUB3fFCu5t0hQby6OoWRPsG1KkKUG2M5UadiLiVg=="],
"@opentui/core": ["@opentui/core@0.5.3", "", { "dependencies": { "bun-ffi-structs": "0.3.1", "diff": "9.0.0", "marked": "17.0.1", "string-width": "7.2.0", "strip-ansi": "7.1.2" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "0.5.3", "@opentui/core-darwin-x64": "0.5.3", "@opentui/core-linux-arm64": "0.5.3", "@opentui/core-linux-arm64-musl": "0.5.3", "@opentui/core-linux-x64": "0.5.3", "@opentui/core-linux-x64-musl": "0.5.3", "@opentui/core-win32-arm64": "0.5.3", "@opentui/core-win32-x64": "0.5.3" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-K8EQu44cx0rhnn3v3baCQW18Bpci3GltZayOwVpGGsbiAGL1WUYqwQjuaWsmS0c4dCa9rQ5xCEoHB1C4936nDg=="],
"@opentui/core": ["@opentui/core@0.5.4", "", { "dependencies": { "bun-ffi-structs": "0.3.1", "diff": "9.0.0", "marked": "17.0.1", "string-width": "7.2.0", "strip-ansi": "7.1.2" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "0.5.4", "@opentui/core-darwin-x64": "0.5.4", "@opentui/core-linux-arm64": "0.5.4", "@opentui/core-linux-arm64-musl": "0.5.4", "@opentui/core-linux-x64": "0.5.4", "@opentui/core-linux-x64-musl": "0.5.4", "@opentui/core-win32-arm64": "0.5.4", "@opentui/core-win32-x64": "0.5.4" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-czcJKQ72QhTWvu1eWfKg4EPN1GLLND6cP9MOhDyqYzCdIZgxQSJVWYzz6c4/CQGOu/qQLRyce2y1efVu4lgQ0w=="],
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.5.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-R39YeUqaMb/rH1h6G4MkB4MLVKIrRaUaXLfVqorZM4xgU5BxnfPetRk1vWR9vuLCvDwskg+kQ589kULw0o6AWA=="],
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.5.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-oETbn6tg/0g+mOvGXy3iot+1Zv7CGr65U1lRaPJ7kpsKGnOxftCpDD1qA0I6eQwfPJa9k7h9D/9yGB3IbweGcg=="],
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.5.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-1pmUas/chTVFGeiN19kaOx+5Xbte/DLhcgKyACwWO0M3+xE3z1v/6QGSyX6CoP5HBpmDroiX+JHv1ic/JlGd/g=="],
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.5.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-TIeqCNfAV8xvNAv6oVBYsoGBz/p8CxcYm668OQIeBPUO+irqbQ72vJJa/SwFZrUEAHFmsDELaB6y80oHU5Jm6g=="],
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.5.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-0nMo9Q9VIaQVdw2SNKlwIEWMmf3z+cI4jRdCkh36e2RU1FO7LrIBAEmV1ZuRp1CIFVGPkqCXIizCeckZHTr4yQ=="],
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.5.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-UrOsX3D5BOO9TI30WvRwEK/lyPPhY75+LwSqeRRe8SV2ON+Ez5QqY4oryTyYC6+yNahTJufz34n0YgwnQaxTNA=="],
"@opentui/core-linux-arm64-musl": ["@opentui/core-linux-arm64-musl@0.5.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-QOYAxbbWrhYo27Cd6m0ATzpEx9YCAKAq82LgfUn0xu+VKXLNu+Q3hMNSVbG0SepUQQZil5rz228R9o9Cs8995w=="],
"@opentui/core-linux-arm64-musl": ["@opentui/core-linux-arm64-musl@0.5.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-8vFWd1dsPZj9fHQKlsGHue3ukp7MRjTSpmIrdneIruOcoK2etccHfd6bqIo4FcNr+HA0eI2FP4ZIL9xhE3r9/w=="],
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.5.3", "", { "os": "linux", "cpu": "x64" }, "sha512-hdAYLriLpTj3lvpMyL25GPBzvM2w/n2KCSbIwTmgS2F/dPZYCKJxHEETj2lCvtStSp7KuY8tkg3Xl5RAq1v7gA=="],
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.5.4", "", { "os": "linux", "cpu": "x64" }, "sha512-1RXzSl6d347O7mUXviXFWlFFyILr7qsVt4jwD3k0UiKtENeEDNi+glM1bKHbXwCdQjfA835QgFA2mbILybK+aQ=="],
"@opentui/core-linux-x64-musl": ["@opentui/core-linux-x64-musl@0.5.3", "", { "os": "linux", "cpu": "x64" }, "sha512-BkVIiPQ1TOf5/FfmIpf7DQU5rT/FO6ASW5R/o/wonI5Pdul7XiDCu86gzGyk1x5k9Sbh6GLeq1fe8/tPmI7IaA=="],
"@opentui/core-linux-x64-musl": ["@opentui/core-linux-x64-musl@0.5.4", "", { "os": "linux", "cpu": "x64" }, "sha512-YYREqUB3v5K0qWij3A5YWzJkkVXp/EqIiuHZKsAjrUAY/0+Bp8Hn0baLvvvkuklpG9whW+GfwIeUsmp4fxqmCA=="],
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.5.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-AjObTyZPU0xsK3Yk8GmhkboK6OcMoHBbydqYAybeHD4+v6axScSuZ3OEI9J05JJ9T7H2nZNky75tsdnjsvZJmg=="],
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.5.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-F9sB6suPJmwkLF2MwKEC+OtwP6cn5QspIm4MCh2hmu3mVqSz9GDpYID1X3sAh9/V79EVocqiOSqI3KeCTRouKQ=="],
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.5.3", "", { "os": "win32", "cpu": "x64" }, "sha512-e3nRlF2nSkLKCUPBF32OL9EDgtQDIh2pBo7tjhumpTyJ3qoNOa3us7DsM290Vw4xnakM6jpk9r9NzRf72CuMVg=="],
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.5.4", "", { "os": "win32", "cpu": "x64" }, "sha512-2/6dPPPJ9xL/bWz9jh+lZeV2g/tbxZ9N4FBIqwhnqSfIYy5jOnscsPZpcN4X6PstfJAo2yf0Ebk/tLTOzOS6TQ=="],
"@opentui/keymap": ["@opentui/keymap@0.5.3", "", { "dependencies": { "@opentui/core": "0.5.3" }, "peerDependencies": { "@opentui/react": "0.5.3", "@opentui/solid": "0.5.3", "react": ">=19.2.0", "solid-js": "1.9.12" }, "optionalPeers": ["@opentui/react", "@opentui/solid", "react", "solid-js"] }, "sha512-VdvUabUDWnmgolzp1qiO55wz1zv0mMu5Z6tv+UQk9JqZRfKt5Q8XTDi0uQzhBb6qeLsHvXtRFOsnn6EFAcLixA=="],
"@opentui/keymap": ["@opentui/keymap@0.5.4", "", { "dependencies": { "@opentui/core": "0.5.4" }, "peerDependencies": { "@opentui/react": "0.5.4", "@opentui/solid": "0.5.4", "react": ">=19.2.0", "solid-js": "1.9.12" }, "optionalPeers": ["@opentui/react", "@opentui/solid", "react", "solid-js"] }, "sha512-WAB6EH6C7SicmK/ycvx+mUu00sQ755gToZqBx8vgLhS7qZ07s/WLi88Ys0D6AA0i8lOiSIPA4uqei1jkV8UnBQ=="],
"@opentui/solid": ["@opentui/solid@0.5.3", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.5.3", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.12", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.12" } }, "sha512-IB6YC3ajucQeo06BMSNYRD7LHf8ndm6G5ETcYsS+51Mli5yW0ioyUr+6+qgqVtaQyOcJvaD1KWmK23pXLhsiwg=="],
"@opentui/solid": ["@opentui/solid@0.5.4", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.5.4", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.12", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.12" } }, "sha512-1injDvieTyYXD3RLC2EclaJK8XZ2+qyJ7wGeQPSzpcYEEcanb7G79KVWFlN7YUzqVAhTIictZDoQ6SRvIwL4Ww=="],
"@orama/orama": ["@orama/orama@3.1.18", "", {}, "sha512-a61ljmRVVyG5MC/698C8/FfFDw5a8LOIvyOLW5fztgUXqUpc1jOfQzOitSCbge657OgXXThmY3Tk8fpiDb4UcA=="],
+3 -3
View File
@@ -47,9 +47,9 @@
"@octokit/rest": "22.0.0",
"@hono/standard-validator": "0.2.0",
"@hono/zod-validator": "0.4.2",
"@opentui/core": "0.5.3",
"@opentui/keymap": "0.5.3",
"@opentui/solid": "0.5.3",
"@opentui/core": "0.5.4",
"@opentui/keymap": "0.5.4",
"@opentui/solid": "0.5.4",
"@tanstack/solid-virtual": "3.13.32",
"@shikijs/stream": "4.2.0",
"@standard-schema/spec": "1.1.0",
@@ -905,6 +905,7 @@ const onContentBlockDelta = Effect.fn("AnthropicMessages.onContentBlockDelta")(f
if (delta?.type === "input_json_delta" && event.index !== undefined) {
if (!delta.partial_json) return [state, NO_EVENTS] satisfies StepResult
if (!state.tools[event.index]) return [state, NO_EVENTS] satisfies StepResult
const result = ToolStream.appendExisting(
ADAPTER,
state.tools,
@@ -1010,6 +1010,34 @@ describe("Anthropic Messages route", () => {
}),
)
it.effect("ignores tool input deltas without a matching tool start", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{ type: "message_start", message: { usage: { input_tokens: 5 } } },
{ type: "content_block_start", index: 0, content_block: { type: "text", text: "" } },
{ type: "content_block_delta", index: 0, delta: { type: "text_delta", text: "Hello" } },
{
type: "content_block_delta",
index: 1,
delta: { type: "input_json_delta", partial_json: '{"query":"orphaned"}' },
},
{ type: "content_block_stop", index: 0 },
{ type: "message_delta", delta: { stop_reason: "end_turn" }, usage: { output_tokens: 1 } },
{ type: "message_stop" },
),
),
),
)
expect(response.text).toBe("Hello")
expect(response.toolCalls).toEqual([])
expect(response.finishReason).toEqual({ normalized: "stop", raw: "end_turn" })
}),
)
it.effect("settles pending tool calls at message_stop", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(request).pipe(
@@ -25,7 +25,7 @@ async function installSessionSwitchProbe(
let running = true
const reviewLevels: Record<string, string> = {
panel: "#review-panel",
tabs: '#review-panel [data-component="tabs"]',
tabs: '#review-panel [data-component="tabs"]',
body: '#review-panel [data-slot="session-review-v2-body"]',
review: '#review-panel [data-component="session-review-v2"]',
preview: '#review-panel [data-slot="session-review-v2-preview"]',
@@ -52,7 +52,7 @@ test("shows a pending question dock", async ({ page }) => {
rejectRequests.push(request.url())
})
await question.locator('[data-component="icon-button"][data-icon="chevron-down"]').click()
await question.getByRole("button", { name: "Minimize question" }).click()
await expect(question).toBeVisible()
await expect(question.getByText("Which implementation should be used?")).toBeVisible()
await expect(question.getByText("Select one answer")).toBeHidden()
@@ -63,7 +63,7 @@ test("shows a pending question dock", async ({ page }) => {
await expect(page.locator('[data-component="question-minimized-dock"]')).toHaveCount(0)
expect(rejectRequests).toEqual([])
await question.locator('[data-component="icon-button"][data-icon="chevron-down"]').click()
await question.getByRole("button", { name: "Restore question" }).click()
await expect(question).toBeVisible()
await expect(question.getByText("Which implementation should be used?")).toBeVisible()
await expect(question.getByRole("radio", { name: /Minimal/ })).toBeVisible()
@@ -131,15 +131,14 @@ test("labels V2 skill tools from IDs and result metadata", async ({ page }) => {
"aria-label",
"sample-skill",
)
await expect(page.locator(`[data-timeline-part-id="${completed}"] [data-component="text-shimmer"]`)).toHaveAttribute(
"aria-label",
"OpenCode",
)
await expect(
page.locator(`[data-timeline-part-id="${completed}"] [data-component="text-shimmer"]`),
).toHaveAttribute("aria-label", "OpenCode")
for (const id of [pending, completed]) {
const skill = page.locator(`[data-timeline-part-id="${id}"]`)
await expect(skill.locator('[data-slot="skill-tool-label"]')).toHaveText("Skill")
await expect(skill.locator('[data-slot="skill-tool-separator"]')).toHaveText("·")
await expect(skill.locator('use[href="#opencode-icon-post-skill"]')).toBeVisible()
await expect(skill.locator('use[href="#opencode-v2-icon-post-skill"]')).toBeVisible()
}
})
@@ -477,7 +477,7 @@ async function configureSmokePage(page: Page, directory: string) {
}
let recordFrame: number | undefined
const record = () => {
for (const toast of document.querySelectorAll<HTMLElement>('[data-component="toast"][data-variant="error"]')) {
for (const toast of document.querySelectorAll<HTMLElement>(".toast-v2--error")) {
const text = toast.textContent?.trim()
if (text && !smoke.__timelineSmokeErrorToasts!.includes(text)) smoke.__timelineSmokeErrorToasts!.push(text)
}
+2 -19
View File
@@ -3,7 +3,6 @@ import { batch, createEffect, onCleanup, onMount, Show } from "solid-js"
import { createStore } from "solid-js/store"
import { makeEventListener } from "@solid-primitives/event-listener"
import { Tooltip } from "@opencode-ai/ui/tooltip"
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
import { useLanguage } from "@/context/language"
import { usePlatform } from "@/context/platform"
@@ -102,16 +101,8 @@ function Cell(props: {
</div>
)
if (props.inline) {
return (
<TooltipV2 value={props.tip} placement="top">
{content()}
</TooltipV2>
)
}
return (
<Tooltip value={props.tip} placement="top">
<Tooltip appearance={props.inline ? "compact" : "standard"} value={props.tip} placement="top">
{content()}
</Tooltip>
)
@@ -151,16 +142,8 @@ function ToggleCell(props: {
</button>
)
if (props.inline) {
return (
<TooltipV2 value={props.tip} placement="top">
{content()}
</TooltipV2>
)
}
return (
<Tooltip value={props.tip} placement="top">
<Tooltip appearance={props.inline ? "compact" : "standard"} value={props.tip} placement="top">
{content()}
</Tooltip>
)
@@ -1,10 +1,10 @@
import { getDirectory, getFilename } from "@opencode-ai/util/path"
import { FileIcon } from "@opencode-ai/ui/file-icon"
import { ScrollView } from "@opencode-ai/ui/scroll-view"
import { Dialog, DialogBody } from "@opencode-ai/ui/v2/dialog-v2"
import { Icon } from "@opencode-ai/ui/v2/icon"
import { KeybindV2 } from "@opencode-ai/ui/v2/keybind-v2"
import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2"
import { Dialog, DialogBody } from "@opencode-ai/ui/dialog"
import { Icon } from "@opencode-ai/ui/icon"
import { Keybind } from "@opencode-ai/ui/keybind"
import { TextInput } from "@opencode-ai/ui/text-input"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { createEffect, createMemo, createResource, createSignal, For, Match, onCleanup, Show, Switch } from "solid-js"
import { commandPaletteOptions, formatKeybindParts, useCommand } from "@/context/command"
@@ -191,7 +191,7 @@ function CommandPaletteView(props: {
<Dialog class="command-palette-v2" size="large">
<DialogBody class="command-palette-v2-body">
<div class="command-palette-v2-search">
<TextInputV2
<TextInput
value={query()}
autofocus
autocomplete="off"
@@ -295,7 +295,7 @@ function PaletteRow(props: {
</div>
</div>
<Show when={props.item.keybind}>
<KeybindV2 keys={formatKeybindParts(props.item.keybind ?? "", props.language.t)} variant="neutral" />
<Keybind keys={formatKeybindParts(props.item.keybind ?? "", props.language.t)} variant="neutral" />
</Show>
</Match>
<Match when={props.item.type === "session"}>
@@ -13,7 +13,7 @@ function ConnectProviderDialogStory() {
onMount(open)
return (
<Button variant="secondary" onClick={open}>
<Button variant="neutral" onClick={open}>
Open connect provider dialog
</Button>
)
@@ -29,7 +29,7 @@ function ProviderConnectionDialogStory(props) {
onMount(open)
return (
<Button variant="secondary" onClick={open}>
<Button variant="neutral" onClick={open}>
Open {props.provider} connection dialog
</Button>
)
@@ -5,9 +5,8 @@ import { List } from "@opencode-ai/ui/list"
import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
import { Spinner } from "@opencode-ai/ui/spinner"
import { TextField } from "@opencode-ai/ui/text-field"
import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
import { DialogBody, DialogHeader, DialogTitle, DialogV2 } from "@opencode-ai/ui/v2/dialog-v2"
import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2"
import { DialogBody, DialogHeader, DialogTitle, Dialog } from "@opencode-ai/ui/dialog"
import { TextInput } from "@opencode-ai/ui/text-input"
import { showToast } from "@/utils/toast"
import { type Component, createMemo, createUniqueId, For, Match, onMount, Show, Switch } from "solid-js"
import { createStore } from "solid-js/store"
@@ -75,7 +74,7 @@ export const DialogConnectProvider: Component<{
}
return (
<DialogV2
<Dialog
containerClass="!h-[min(calc(100vh_-_16px),512px)] !w-[min(calc(100vw_-_16px),640px)]"
class="[font-family:var(--v2-font-family-sans)] [&_[data-slot=dialog-header]]:!px-5 [&_[data-slot=dialog-header-title]]:!text-[15px] [&_[data-slot=dialog-header-title]]:!tracking-[-0.13px]"
>
@@ -99,7 +98,7 @@ export const DialogConnectProvider: Component<{
<Content />
</div>
</DialogBody>
</DialogV2>
</Dialog>
)
}
@@ -168,7 +167,7 @@ function ProviderPicker(props: { directory?: string; onSelect: (provider: string
return (
<div ref={picker} class="flex min-h-0 flex-1 flex-col gap-4" onKeyDown={handleKeyDown}>
<div class="shrink-0 px-1 pt-px">
<TextInputV2
<TextInput
ref={search}
type="search"
class="!w-full [font-family:var(--v2-font-family-sans)]"
@@ -379,7 +378,7 @@ function ProviderConnection(props: {
setFormStore("value", field.key, value)
}}
/>
<Button class="w-auto" type="submit" size="large" variant="primary" disabled={!valid()}>
<Button class="w-auto" type="submit" size="large" variant="contrast" disabled={!valid()}>
{language.t("common.continue")}
</Button>
</Match>
@@ -516,7 +515,7 @@ function ProviderConnection(props: {
<form onSubmit={handleSubmit} class="flex flex-col items-start gap-5 self-stretch">
<label class="flex w-full flex-col gap-1 font-[530] leading-4 text-v2-text-text-base">
{language.t("provider.connect.apiKey.label", { provider: provider().name })}
<TextInputV2
<TextInput
ref={apiKey}
class="!w-full"
name="apiKey"
@@ -537,9 +536,9 @@ function ProviderConnection(props: {
</div>
)}
</Show>
<ButtonV2 type="submit" variant="contrast" data-action="provider-connect-submit">
<Button type="submit" variant="contrast" data-action="provider-connect-submit">
{language.t("common.continue")}
</ButtonV2>
</Button>
</form>
</div>
)
@@ -585,7 +584,7 @@ function ProviderConnection(props: {
<form onSubmit={handleSubmit} class="flex flex-col items-start gap-5 self-stretch">
<label class="flex w-full flex-col gap-1 font-[530] leading-4 text-v2-text-text-base">
{language.t("provider.connect.oauth.code.label", { method: controller.currentMethod()?.label ?? "" })}
<TextInputV2
<TextInput
ref={codeInput}
class="!w-full"
name="code"
@@ -605,9 +604,9 @@ function ProviderConnection(props: {
</div>
)}
</Show>
<ButtonV2 type="submit" variant="contrast">
<Button type="submit" variant="contrast">
{language.t("common.continue")}
</ButtonV2>
</Button>
</form>
</div>
)
@@ -1,6 +1,7 @@
import { Button } from "@opencode-ai/ui/button"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { Dialog } from "@opencode-ai/ui/dialog"
import { Dialog, DialogBody, DialogHeader, DialogTitle } from "@opencode-ai/ui/dialog"
import { Icon } from "@opencode-ai/ui/icon"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
import { useMutation } from "@tanstack/solid-query"
@@ -21,20 +22,21 @@ export function DialogCustomProvider(props: Props) {
const language = useLanguage()
return (
<Dialog
class="h-full"
title={
<IconButton
tabIndex={-1}
icon="arrow-left"
variant="ghost"
onClick={props.onBack}
aria-label={language.t("common.goBack")}
/>
}
transition
>
<CustomProviderForm />
<Dialog class="h-full">
<DialogHeader>
<DialogTitle>
<IconButton
tabIndex={-1}
icon={<Icon name="arrow-left" />}
variant="ghost"
onClick={props.onBack}
aria-label={language.t("common.goBack")}
/>
</DialogTitle>
</DialogHeader>
<DialogBody>
<CustomProviderForm />
</DialogBody>
</Dialog>
)
}
@@ -238,7 +240,7 @@ export function CustomProviderForm(props: { autofocus?: boolean } = {}) {
</div>
<IconButton
type="button"
icon="trash"
icon={<Icon name="trash" />}
variant="ghost"
class="mt-1.5"
onClick={() => removeModel(i())}
@@ -282,7 +284,7 @@ export function CustomProviderForm(props: { autofocus?: boolean } = {}) {
</div>
<IconButton
type="button"
icon="trash"
icon={<Icon name="trash" />}
variant="ghost"
class="mt-1.5"
onClick={() => removeHeader(i())}
@@ -301,7 +303,7 @@ export function CustomProviderForm(props: { autofocus?: boolean } = {}) {
class="w-auto self-start"
type="submit"
size="large"
variant="primary"
variant="contrast"
disabled={saveMutation.isPending}
>
{saveMutation.isPending ? language.t("common.saving") : language.t("common.submit")}
@@ -1,12 +1,11 @@
import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
import { Dialog, DialogFooter } from "@opencode-ai/ui/v2/dialog-v2"
import { Field } from "@opencode-ai/ui/v2/field-v2"
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
import { Button } from "@opencode-ai/ui/button"
import { Dialog, DialogFooter } from "@opencode-ai/ui/dialog"
import { Field } from "@opencode-ai/ui/field"
import { Icon } from "@opencode-ai/ui/icon"
import { ProjectAvatar, PROJECT_AVATAR_VARIANTS } from "@opencode-ai/ui/v2/project-avatar-v2"
import { TabsV2 } from "@opencode-ai/ui/v2/tabs-v2"
import { TextareaV2 } from "@opencode-ai/ui/v2/textarea-v2"
import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2"
import { ProjectAvatar, PROJECT_AVATAR_VARIANTS } from "@opencode-ai/ui/project-avatar"
import { Tabs } from "@opencode-ai/ui/tabs"
import { Textarea } from "@opencode-ai/ui/textarea"
import { TextInput } from "@opencode-ai/ui/text-input"
import { For, Show, createSignal, startTransition } from "solid-js"
import { useLanguage } from "@/context/language"
import { getProjectAvatarVariant, type LocalProject } from "@/context/layout"
@@ -37,46 +36,46 @@ function ProjectSettingsDialog(props: { project: LocalProject; server: ServerCon
const Footer = () => (
<DialogFooter>
<ButtonV2 type="button" variant="neutral" disabled={model.save.isPending} onClick={model.close}>
<Button type="button" variant="neutral" disabled={model.save.isPending} onClick={model.close}>
{language.t("common.cancel")}
</ButtonV2>
<ButtonV2 type="submit" variant="contrast" disabled={!model.supported || model.save.isPending}>
</Button>
<Button type="submit" variant="contrast" disabled={!model.supported || model.save.isPending}>
{model.save.isPending ? language.t("common.saving") : language.t("common.save")}
</ButtonV2>
</Button>
</DialogFooter>
)
return (
<Dialog size="x-large" variant="settings" class="project-settings-v2-dialog">
<TabsV2
<Tabs
orientation="vertical"
variant="settings"
value={tab()}
onChange={(value) => void startTransition(() => setTab(value))}
class="project-settings-v2"
>
<TabsV2.List>
<Tabs.List>
<div class="project-settings-v2-nav">
<TabsV2.Trigger value="general">
<Tabs.Trigger value="general">
<ProjectAvatar
fallback={projectName()}
variant={getProjectAvatarVariant(props.project.icon?.color)}
class="!size-4 shrink-0"
/>
<span class="truncate">{projectName()}</span>
</TabsV2.Trigger>
<TabsV2.Trigger value="scripts">
</Tabs.Trigger>
<Tabs.Trigger value="scripts">
<Icon name="code" size="small" />
{language.t("project.settings.scripts")}
</TabsV2.Trigger>
<TabsV2.Trigger value="extensions">
</Tabs.Trigger>
<Tabs.Trigger value="extensions">
<Icon name="extensions" size="small" />
{language.t("settings.tab.extensions")}
</TabsV2.Trigger>
</Tabs.Trigger>
</div>
</TabsV2.List>
</Tabs.List>
<TabsV2.Content value="general" class="project-settings-v2-panel">
<Tabs.Content value="general" class="project-settings-v2-panel">
<form onSubmit={model.submit} class="project-settings-v2-form">
<div class="project-settings-v2-scroll">
<div class="project-settings-page-header">
@@ -86,7 +85,7 @@ function ProjectSettingsDialog(props: { project: LocalProject; server: ServerCon
<Field>
<Field.Label>{language.t("dialog.project.edit.name")}</Field.Label>
<TextInputV2
<TextInput
autofocus
appearance="large"
class="!w-full"
@@ -132,7 +131,7 @@ function ProjectSettingsDialog(props: { project: LocalProject; server: ServerCon
"opacity-0": !model.store.iconHover,
}}
>
<IconV2 name={model.store.iconOverride ? "close" : "share"} />
<Icon name={model.store.iconOverride ? "close" : "share"} />
</span>
</button>
<input
@@ -188,9 +187,9 @@ function ProjectSettingsDialog(props: { project: LocalProject; server: ServerCon
</div>
<Footer />
</form>
</TabsV2.Content>
</Tabs.Content>
<TabsV2.Content value="scripts" class="project-settings-v2-panel">
<Tabs.Content value="scripts" class="project-settings-v2-panel">
<form onSubmit={model.submit} class="project-settings-v2-form">
<div class="project-settings-v2-scroll">
<div class="project-settings-page-header">
@@ -200,7 +199,7 @@ function ProjectSettingsDialog(props: { project: LocalProject; server: ServerCon
<Field>
<Field.Label>{language.t("dialog.project.edit.worktree.startup")}</Field.Label>
<Field.Prefix>{language.t("dialog.project.edit.worktree.startup.description")}</Field.Prefix>
<TextareaV2
<Textarea
class="!w-full [&_[data-slot=textarea-v2-textarea]]:font-mono"
rows={5}
value={model.store.startup}
@@ -212,12 +211,12 @@ function ProjectSettingsDialog(props: { project: LocalProject; server: ServerCon
</div>
<Footer />
</form>
</TabsV2.Content>
</Tabs.Content>
<TabsV2.Content value="extensions" class="project-settings-v2-panel">
<Tabs.Content value="extensions" class="project-settings-v2-panel">
<ProjectSettingsExtensions />
</TabsV2.Content>
</TabsV2>
</Tabs.Content>
</Tabs>
</Dialog>
)
}
+23 -18
View File
@@ -3,7 +3,7 @@ import { useNavigate, useParams } from "@solidjs/router"
import { useData } from "@/context/server"
import { usePrompt } from "@/context/prompt"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { Dialog } from "@opencode-ai/ui/dialog"
import { Dialog, DialogBody, DialogHeader, DialogTitle } from "@opencode-ai/ui/dialog"
import { List } from "@opencode-ai/ui/list"
import { showToast } from "@/utils/toast"
import { useLanguage } from "@/context/language"
@@ -94,23 +94,28 @@ export const DialogFork: Component = () => {
}
return (
<Dialog title={language.t("command.session.fork")}>
<List
class="flex-1 px-3 min-h-0 [&_[data-slot=list-scroll]]:flex-1 [&_[data-slot=list-scroll]]:min-h-0"
search={{ placeholder: language.t("common.search.placeholder"), autofocus: true }}
emptyMessage={language.t("dialog.fork.empty")}
key={(x) => x.id}
items={messages}
filterKeys={["text"]}
onSelect={handleSelect}
>
{(item) => (
<div class="w-full flex items-center gap-2">
<span class="truncate flex-1 min-w-0 text-left font-normal">{item.text}</span>
<span class="text-text-weak shrink-0 font-normal">{item.time}</span>
</div>
)}
</List>
<Dialog>
<DialogHeader>
<DialogTitle>{language.t("command.session.fork")}</DialogTitle>
</DialogHeader>
<DialogBody>
<List
class="flex-1 px-3 min-h-0 [&_[data-slot=list-scroll]]:flex-1 [&_[data-slot=list-scroll]]:min-h-0"
search={{ placeholder: language.t("common.search.placeholder"), autofocus: true }}
emptyMessage={language.t("dialog.fork.empty")}
key={(x) => x.id}
items={messages}
filterKeys={["text"]}
onSelect={handleSelect}
>
{(item) => (
<div class="w-full flex items-center gap-2">
<span class="truncate flex-1 min-w-0 text-left font-normal">{item.text}</span>
<span class="text-text-weak shrink-0 font-normal">{item.time}</span>
</div>
)}
</List>
</DialogBody>
</Dialog>
)
}
@@ -1,15 +1,12 @@
import { Dialog } from "@opencode-ai/ui/dialog"
import { List } from "@opencode-ai/ui/list"
import { Switch } from "@opencode-ai/ui/switch"
import { Tooltip } from "@opencode-ai/ui/tooltip"
import { Button } from "@opencode-ai/ui/button"
import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
import { Dialog as DialogV2, DialogBody, DialogHeader, DialogTitleGroup } from "@opencode-ai/ui/v2/dialog-v2"
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2"
import { Switch as SwitchV2 } from "@opencode-ai/ui/v2/switch-v2"
import { Dialog, DialogBody, DialogHeader, DialogTitleGroup } from "@opencode-ai/ui/dialog"
import { Icon } from "@opencode-ai/ui/icon"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { List } from "@opencode-ai/ui/list"
import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
import { Switch } from "@opencode-ai/ui/switch"
import { TextInput } from "@opencode-ai/ui/text-input"
import { Tooltip } from "@opencode-ai/ui/tooltip"
import { useFilteredList } from "@opencode-ai/ui/hooks"
import { For, Show, type Component } from "solid-js"
import { useLocal } from "@/context/local"
@@ -44,74 +41,80 @@ export const DialogManageModels: Component = () => {
}
return (
<Dialog
title={language.t("dialog.model.manage")}
description={language.t("dialog.model.manage.description")}
action={
<Dialog>
<DialogHeader hideClose>
<DialogTitleGroup
title={language.t("dialog.model.manage")}
description={language.t("dialog.model.manage.description")}
/>
<Button class="h-7 -my-1 text-14-medium" icon="plus-small" tabIndex={-1} onClick={handleConnectProvider}>
{language.t("command.provider.connect")}
</Button>
}
>
<List
class="px-3"
search={{ placeholder: language.t("dialog.model.search.placeholder"), autofocus: true }}
emptyMessage={language.t("dialog.model.empty")}
key={(x) => `${x?.provider?.id}:${x?.id}`}
items={local.model.list()}
filterKeys={["provider.name", "name", "id"]}
sortBy={(a, b) => a.name.localeCompare(b.name)}
groupBy={(x) => x.provider.id}
groupHeader={(group) => {
const provider = group.items[0].provider
return (
<>
<span>{provider.name}</span>
<Tooltip
placement="top"
value={language.t("dialog.model.manage.provider.toggle", { provider: provider.name })}
>
<Switch
class="-mr-1"
checked={providerVisible(provider.id)}
onChange={(checked) => setProviderVisibility(provider.id, checked)}
hideLabel
</DialogHeader>
<DialogBody>
<List
class="px-3"
search={{ placeholder: language.t("dialog.model.search.placeholder"), autofocus: true }}
emptyMessage={language.t("dialog.model.empty")}
key={(x) => `${x?.provider?.id}:${x?.id}`}
items={local.model.list()}
filterKeys={["provider.name", "name", "id"]}
sortBy={(a, b) => a.name.localeCompare(b.name)}
groupBy={(x) => x.provider.id}
groupHeader={(group) => {
const provider = group.items[0].provider
return (
<>
<span>{provider.name}</span>
<Tooltip
appearance="standard"
placement="top"
value={language.t("dialog.model.manage.provider.toggle", { provider: provider.name })}
>
{provider.name}
</Switch>
</Tooltip>
</>
)
}}
sortGroupsBy={(a, b) => {
const aRank = providerRank(a.items[0].provider.id)
const bRank = providerRank(b.items[0].provider.id)
const aPopular = aRank >= 0
const bPopular = bRank >= 0
if (aPopular && !bPopular) return -1
if (!aPopular && bPopular) return 1
return aRank - bRank
}}
onSelect={(x) => {
if (!x) return
const key = { modelID: x.id, providerID: x.provider.id }
local.model.setVisibility(key, !local.model.visible(key))
}}
>
{(i) => (
<div class="w-full flex items-center justify-between gap-x-3">
<span>{i.name}</span>
<div onClick={(e) => e.stopPropagation()}>
<Switch
checked={!!local.model.visible({ modelID: i.id, providerID: i.provider.id })}
onChange={(checked) => {
local.model.setVisibility({ modelID: i.id, providerID: i.provider.id }, checked)
}}
/>
<Switch
appearance="standard"
class="-mr-1"
checked={providerVisible(provider.id)}
onChange={(checked) => setProviderVisibility(provider.id, checked)}
hideLabel
>
{provider.name}
</Switch>
</Tooltip>
</>
)
}}
sortGroupsBy={(a, b) => {
const aRank = providerRank(a.items[0].provider.id)
const bRank = providerRank(b.items[0].provider.id)
const aPopular = aRank >= 0
const bPopular = bRank >= 0
if (aPopular && !bPopular) return -1
if (!aPopular && bPopular) return 1
return aRank - bRank
}}
onSelect={(x) => {
if (!x) return
const key = { modelID: x.id, providerID: x.provider.id }
local.model.setVisibility(key, !local.model.visible(key))
}}
>
{(i) => (
<div class="w-full flex items-center justify-between gap-x-3">
<span>{i.name}</span>
<div onClick={(e) => e.stopPropagation()}>
<Switch
appearance="standard"
checked={!!local.model.visible({ modelID: i.id, providerID: i.provider.id })}
onChange={(checked) => {
local.model.setVisibility({ modelID: i.id, providerID: i.provider.id }, checked)
}}
/>
</div>
</div>
</div>
)}
</List>
)}
</List>
</DialogBody>
</Dialog>
)
}
@@ -154,20 +157,20 @@ export const DialogManageModelsV2: Component = () => {
})
return (
<DialogV2 size="large" variant="settings" class="settings-v2-manage-models-dialog">
<Dialog size="large" variant="settings" class="settings-v2-manage-models-dialog">
<DialogHeader hideClose={true} closeLabel={language.t("common.close")}>
<DialogTitleGroup
title={language.t("dialog.model.manage")}
description={language.t("dialog.model.manage.description")}
/>
<ButtonV2 variant="neutral" icon="plus" onClick={handleConnectProvider}>
<Button variant="neutral" icon="plus" onClick={handleConnectProvider}>
{language.t("command.provider.connect")}
</ButtonV2>
</Button>
</DialogHeader>
<DialogBody class="flex min-h-0 flex-1 flex-col">
<div class="px-4 pt-px pb-3">
<div class="relative">
<TextInputV2
<TextInput
type="search"
appearance="base"
class="!w-full self-stretch"
@@ -182,12 +185,12 @@ export const DialogManageModelsV2: Component = () => {
aria-label={language.t("dialog.model.search.placeholder")}
/>
<Show when={list.filter()}>
<IconButtonV2
<IconButton
type="button"
variant="ghost-muted"
size="small"
class="settings-v2-tab-search-clear"
icon={<IconV2 name="close" size="large" class="text-v2-icon-icon-muted" />}
icon={<Icon name="close" size="large" class="text-v2-icon-icon-muted" />}
onClick={() => list.clear()}
aria-label={language.t("common.clear")}
/>
@@ -225,14 +228,14 @@ export const DialogManageModelsV2: Component = () => {
<h3 class="settings-v2-section-title">{group.items[0].provider.name}</h3>
</div>
<div>
<SwitchV2
<Switch
class="mr-6"
checked={providerVisible(group.category)}
onChange={(checked) => setProviderVisibility(group.category, checked)}
hideLabel
>
{group.items[0].provider.name}
</SwitchV2>
</Switch>
</div>
</div>
<SettingsListV2>
@@ -240,13 +243,13 @@ export const DialogManageModelsV2: Component = () => {
{(item) => (
<SettingsRowV2 title={item.name} description="">
<div>
<SwitchV2
<Switch
checked={local.model.visible({ modelID: item.id, providerID: item.provider.id })}
onChange={(checked) => setModelVisibility(item, checked)}
hideLabel
>
{item.name}
</SwitchV2>
</Switch>
</div>
</SettingsRowV2>
)}
@@ -260,6 +263,6 @@ export const DialogManageModelsV2: Component = () => {
</div>
</div>
</DialogBody>
</DialogV2>
</Dialog>
)
}
@@ -86,12 +86,12 @@ export function DialogReleaseNotes(props: { highlights: Highlight[] }) {
<Show
when={isLast()}
fallback={
<Button variant="secondary" size="large" onClick={handleNext}>
<Button variant="neutral" size="large" onClick={handleNext}>
{language.t("dialog.releaseNotes.action.next")}
</Button>
}
>
<Button variant="primary" size="large" onClick={handleClose}>
<Button variant="contrast" size="large" onClick={handleClose}>
{language.t("dialog.releaseNotes.action.getStarted")}
</Button>
</Show>
@@ -1,8 +1,8 @@
import "@pierre/trees/web-components"
import { FileTree } from "@pierre/trees"
import { Dialog, DialogBody, DialogFooter, DialogHeader, DialogTitle } from "@opencode-ai/ui/v2/dialog-v2"
import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2"
import { Dialog, DialogBody, DialogFooter, DialogHeader, DialogTitle } from "@opencode-ai/ui/dialog"
import { Button } from "@opencode-ai/ui/button"
import { TextInput } from "@opencode-ai/ui/text-input"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { createEffect, createMemo, createResource, createSignal, For, onCleanup, onMount, Show } from "solid-js"
import { useGlobal } from "@/context/global"
@@ -28,7 +28,7 @@ import {
pickerRoot,
} from "./directory-picker-domain"
import "./dialog-select-directory-v2.css"
import { DividerV2 } from "@opencode-ai/ui/v2/divider-v2"
import { Divider } from "@opencode-ai/ui/divider"
import { getFilename } from "@opencode-ai/util/path"
interface DialogSelectDirectoryV2Props {
@@ -293,10 +293,10 @@ export function DialogSelectDirectoryV2(props: DialogSelectDirectoryV2Props) {
<DialogHeader>
<DialogTitle>{props.title ?? language.t("command.project.open")}</DialogTitle>
</DialogHeader>
<DividerV2 />
<Divider />
<DialogBody class="directory-picker-v2-body pt-4!">
<div class="directory-picker-v2-path" ref={pathArea}>
<TextInputV2
<TextInput
value={input()}
autofocus
autocomplete="off"
@@ -318,15 +318,15 @@ export function DialogSelectDirectoryV2(props: DialogSelectDirectoryV2Props) {
onKeyDown={handleInputKey}
/>
<div class="directory-picker-v2-actions">
<ButtonV2 size="small" variant="ghost" onClick={() => void navigate(home())}>
<Button size="small" variant="ghost" onClick={() => void navigate(home())}>
~
</ButtonV2>
<ButtonV2 size="small" variant="ghost" onClick={() => void navigate(pickerRoot(root()) || root())}>
</Button>
<Button size="small" variant="ghost" onClick={() => void navigate(pickerRoot(root()) || root())}>
{language.t("dialog.directory.root")}
</ButtonV2>
<ButtonV2 size="small" variant="ghost" onClick={() => void navigate(pickerParent(root()))}>
</Button>
<Button size="small" variant="ghost" onClick={() => void navigate(pickerParent(root()))}>
{language.t("dialog.directory.parent")}
</ButtonV2>
</Button>
</div>
<Show when={suggestionsOpen() && currentSuggestions().length > 0}>
<div id="directory-picker-v2-suggestions" role="listbox" class="directory-picker-v2-suggestions">
@@ -379,12 +379,12 @@ export function DialogSelectDirectoryV2(props: DialogSelectDirectoryV2Props) {
<div class="directory-picker-v2-selection">{policy.result(root(), selected(), rootValid())}</div>
</DialogBody>
<DialogFooter>
<ButtonV2 variant="neutral" onClick={() => dialog.close()}>
<Button variant="neutral" onClick={() => dialog.close()}>
{language.t("common.cancel")}
</ButtonV2>
<ButtonV2 variant="contrast" disabled={!policy.result(root(), selected(), rootValid())} onClick={resolve}>
</Button>
<Button variant="contrast" disabled={!policy.result(root(), selected(), rootValid())} onClick={resolve}>
{action[policy.action]}
</ButtonV2>
</Button>
</DialogFooter>
</Dialog>
)
@@ -1,187 +0,0 @@
import { Dialog } from "@opencode-ai/ui/dialog"
import { FileIcon } from "@opencode-ai/ui/file-icon"
import { Icon } from "@opencode-ai/ui/icon"
import { Keybind } from "@opencode-ai/ui/keybind"
import { List } from "@opencode-ai/ui/list"
import { getDirectory, getFilename } from "@opencode-ai/util/path"
import { createMemo, createSignal, lazy, Match, Show, Switch } from "solid-js"
import { formatKeybind } from "@/context/command"
import { useServerSDK } from "@/context/server-sdk"
import { useLanguage } from "@/context/language"
import { usePlatform } from "@/context/platform"
import { useSessionLayout } from "@/pages/session/session-layout"
import { decode64 } from "@/utils/base64"
import { getRelativeTime } from "@/utils/time"
import {
createCommandPaletteFileEntry,
createCommandPaletteFileOpener,
createCommandPaletteModel,
uniqueCommandPaletteEntries,
type CommandPaletteEntry,
} from "./command-palette"
import { DialogCommandPaletteV2 } from "./dialog-command-palette-v2"
const DialogSelectFileV2 = lazy(() =>
import("./dialog-select-directory-v2").then((module) => ({ default: module.DialogSelectDirectoryV2 })),
)
type DialogSelectFileMode = "all" | "files"
export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFile?: (path: string) => void }) {
const platform = usePlatform()
const filesOnly = () => props.mode === "files"
if (!filesOnly()) {
return <DialogCommandPaletteV2 onOpenFile={props.onOpenFile} />
}
if (filesOnly() && platform.platform === "desktop") {
return <DialogSelectFileDesktopV2 onOpenFile={props.onOpenFile} />
}
return <DialogSelectFileLegacy filesOnly={filesOnly} onOpenFile={props.onOpenFile} />
}
function DialogSelectFileDesktopV2(props: { onOpenFile?: (path: string) => void }) {
const language = useLanguage()
const serverSDK = useServerSDK()
const { params } = useSessionLayout()
const projectDirectory = createMemo(() => decode64(params.dir) ?? "")
const openFile = createCommandPaletteFileOpener(props.onOpenFile)
return (
<DialogSelectFileV2
server={serverSDK.server}
mode="file"
start={projectDirectory()}
title={language.t("session.header.searchFiles")}
onSelect={(result) => {
if (typeof result !== "string") return
openFile(result)
}}
/>
)
}
function DialogSelectFileLegacy(props: { filesOnly: () => boolean; onOpenFile?: (path: string) => void }) {
const palette = createCommandPaletteModel(props)
const [grouped, setGrouped] = createSignal(false)
const items = async (text: string) => {
const query = text.trim()
setGrouped(query.length > 0)
if (!query && props.filesOnly()) {
const loaded = palette.file.tree.state("")?.loaded
const pending = loaded ? Promise.resolve() : palette.file.tree.list("")
const next = uniqueCommandPaletteEntries([...palette.recentFileEntries(), ...palette.rootFileEntries()])
if (loaded || next.length > 0) {
void pending
return next
}
await pending
return uniqueCommandPaletteEntries([...palette.recentFileEntries(), ...palette.rootFileEntries()])
}
if (!query) return [...palette.preferredCommandEntries(), ...palette.recentFileEntries()]
if (props.filesOnly()) {
const files = await palette.file.searchFiles(query)
const category = palette.language.t("palette.group.files")
return files.map((path) => createCommandPaletteFileEntry(path, category))
}
const [files, nextSessions] = await Promise.all([
palette.file.searchFiles(query),
Promise.resolve(palette.sessions(query)),
])
const category = palette.language.t("palette.group.files")
const entries = files.map((path) => createCommandPaletteFileEntry(path, category))
return [...palette.commandEntries(), ...nextSessions, ...entries]
}
return (
<Dialog class="pt-3 pb-0 !max-h-[480px]" transition>
<List
class="px-3"
search={{
placeholder: props.filesOnly()
? palette.language.t("session.header.searchFiles")
: palette.language.t("palette.search.placeholder"),
autofocus: true,
hideIcon: true,
}}
emptyMessage={palette.language.t("palette.empty")}
loadingMessage={palette.language.t("common.loading")}
items={items}
key={(item) => item.id}
filterKeys={["title", "description", "category"]}
skipFilter={(item) => item.type === "file"}
groupBy={grouped() ? (item) => item.category : () => ""}
onMove={(item: CommandPaletteEntry | undefined) => palette.highlight(item)}
onSelect={(item: CommandPaletteEntry | undefined) => palette.select(item)}
>
{(item) => (
<Switch
fallback={
<div class="w-full flex items-center justify-between rounded-md pl-1">
<div class="flex items-center gap-x-3 grow min-w-0">
<FileIcon node={{ path: item.path ?? "", type: "file" }} class="shrink-0 size-4" />
<div class="flex items-center text-14-regular">
<span class="text-text-weak whitespace-nowrap overflow-hidden overflow-ellipsis truncate min-w-0">
{getDirectory(item.path ?? "")}
</span>
<span class="text-text-strong whitespace-nowrap">{getFilename(item.path ?? "")}</span>
</div>
</div>
</div>
}
>
<Match when={item.type === "command"}>
<div class="w-full flex items-center justify-between gap-4">
<div class="flex items-center gap-2 min-w-0">
<span class="text-14-regular text-text-strong whitespace-nowrap">{item.title}</span>
<Show when={item.description}>
<span class="text-14-regular text-text-weak truncate">{item.description}</span>
</Show>
</div>
<Show when={item.keybind}>
<Keybind class="rounded-[4px]">{formatKeybind(item.keybind ?? "", palette.language.t)}</Keybind>
</Show>
</div>
</Match>
<Match when={item.type === "session"}>
<div class="w-full flex items-center justify-between rounded-md pl-1">
<div class="flex items-center gap-x-3 grow min-w-0">
<Icon name="bubble-5" size="small" class="shrink-0 text-icon-weak" />
<div class="flex items-center gap-2 min-w-0">
<span
class="text-14-regular text-text-strong truncate"
classList={{ "opacity-70": !!item.archived }}
>
{item.title}
</span>
<Show when={item.description}>
<span
class="text-14-regular text-text-weak truncate"
classList={{ "opacity-70": !!item.archived }}
>
{item.description}
</span>
</Show>
</div>
</div>
<Show when={item.updated}>
<span class="text-12-regular text-text-weak whitespace-nowrap ml-2">
{getRelativeTime(new Date(item.updated!).toISOString(), palette.language.t)}
</span>
</Show>
</div>
</Match>
</Switch>
)}
</List>
</Dialog>
)
}
@@ -1,7 +1,7 @@
import { Component, createMemo, Show } from "solid-js"
import { useData } from "@/context/server"
import { useWorkspaceLocation } from "@/context/location"
import { Dialog } from "@opencode-ai/ui/dialog"
import { Dialog, DialogBody, DialogHeader, DialogTitleGroup } from "@opencode-ai/ui/dialog"
import { List } from "@opencode-ai/ui/list"
import { Switch } from "@opencode-ai/ui/switch"
import { useLanguage } from "@/context/language"
@@ -31,65 +31,71 @@ export const DialogSelectMcp: Component = () => {
const totalCount = createMemo(() => items().length)
return (
<Dialog
title={language.t("dialog.mcp.title")}
description={language.t("dialog.mcp.description", { enabled: enabledCount(), total: totalCount() })}
>
<List
class="px-3"
search={{ placeholder: language.t("common.search.placeholder"), autofocus: true }}
emptyMessage={language.t("dialog.mcp.empty")}
key={(x) => x?.name ?? ""}
items={items}
filterKeys={["name", "status"]}
sortBy={(a, b) => a.name.localeCompare(b.name)}
onSelect={(x) => {
if (!x || x.status === "pending" || toggle.isPending) return
toggle.mutate(x.name)
}}
>
{(i) => {
const mcpStatus = () =>
data.location.mcp.server.list({ directory: sdk().directory })?.find((server) => server.name === i.name)
?.status
const status = () => mcpStatus()?.status
const statusLabel = () => {
const key = status() ? statusLabels[status() as keyof typeof statusLabels] : undefined
if (!key) return
return language.t(key)
}
const error = () => {
const s = mcpStatus()
if (s?.status === "failed") return s.error
}
const enabled = () => status() === "connected"
return (
<div class="w-full flex items-center justify-between gap-x-3">
<div class="flex flex-col gap-0.5 min-w-0">
<div class="flex items-center gap-2">
<span class="truncate">{i.name}</span>
<Show when={statusLabel()}>
<span class="text-11-regular text-text-weaker">{statusLabel()}</span>
<Dialog>
<DialogHeader>
<DialogTitleGroup
title={language.t("dialog.mcp.title")}
description={language.t("dialog.mcp.description", { enabled: enabledCount(), total: totalCount() })}
/>
</DialogHeader>
<DialogBody>
<List
class="px-3"
search={{ placeholder: language.t("common.search.placeholder"), autofocus: true }}
emptyMessage={language.t("dialog.mcp.empty")}
key={(x) => x?.name ?? ""}
items={items}
filterKeys={["name", "status"]}
sortBy={(a, b) => a.name.localeCompare(b.name)}
onSelect={(x) => {
if (!x || x.status === "pending" || toggle.isPending) return
toggle.mutate(x.name)
}}
>
{(i) => {
const mcpStatus = () =>
data.location.mcp.server.list({ directory: sdk().directory })?.find((server) => server.name === i.name)
?.status
const status = () => mcpStatus()?.status
const statusLabel = () => {
const key = status() ? statusLabels[status() as keyof typeof statusLabels] : undefined
if (!key) return
return language.t(key)
}
const error = () => {
const s = mcpStatus()
if (s?.status === "failed") return s.error
}
const enabled = () => status() === "connected"
return (
<div class="w-full flex items-center justify-between gap-x-3">
<div class="flex flex-col gap-0.5 min-w-0">
<div class="flex items-center gap-2">
<span class="truncate">{i.name}</span>
<Show when={statusLabel()}>
<span class="text-11-regular text-text-weaker">{statusLabel()}</span>
</Show>
</div>
<Show when={error()}>
<span class="text-11-regular text-text-weaker truncate">{error()}</span>
</Show>
</div>
<Show when={error()}>
<span class="text-11-regular text-text-weaker truncate">{error()}</span>
</Show>
<div onClick={(e) => e.stopPropagation()}>
<Switch
appearance="standard"
checked={enabled()}
disabled={status() === "pending" || (toggle.isPending && toggle.variables === i.name)}
onChange={() => {
if (toggle.isPending) return
toggle.mutate(i.name)
}}
/>
</div>
</div>
<div onClick={(e) => e.stopPropagation()}>
<Switch
checked={enabled()}
disabled={status() === "pending" || (toggle.isPending && toggle.variables === i.name)}
onChange={() => {
if (toggle.isPending) return
toggle.mutate(i.name)
}}
/>
</div>
</div>
)
}}
</List>
)
}}
</List>
</DialogBody>
</Dialog>
)
}
@@ -39,7 +39,7 @@ function SelectModelWithoutProviders() {
onMount(open)
return (
<Button variant="secondary" onClick={open}>
<Button variant="neutral" onClick={open}>
Open select model dialog
</Button>
)
@@ -1,8 +1,8 @@
import { DialogBody, DialogHeader, DialogTitle, DialogV2 } from "@opencode-ai/ui/v2/dialog-v2"
import { Icon } from "@opencode-ai/ui/v2/icon"
import { DialogBody, DialogHeader, DialogTitle, Dialog } from "@opencode-ai/ui/dialog"
import { Badge } from "@opencode-ai/ui/badge"
import { Icon } from "@opencode-ai/ui/icon"
import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
import { Tag } from "@opencode-ai/ui/v2/badge-v2"
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
import { Tooltip } from "@opencode-ai/ui/tooltip"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { useTheme } from "@opencode-ai/ui/theme"
import { createMemo, onCleanup, onMount, type Component, For, Show } from "solid-js"
@@ -66,7 +66,7 @@ export const DialogSelectModelUnpaidV2: Component<{ model?: ModelState }> = (pro
})
return (
<DialogV2
<Dialog
fit
containerClass="!h-auto max-h-[calc(100vh_-_16px)] !w-[min(calc(100vw_-_16px),640px)]"
class="[font-family:var(--v2-font-family-sans)] [&_[data-slot=dialog-header]]:!px-5 [&_[data-slot=dialog-header-title]]:!text-[15px] [&_[data-slot=dialog-header-title]]:!tracking-[-0.13px]"
@@ -84,7 +84,7 @@ export const DialogSelectModelUnpaidV2: Component<{ model?: ModelState }> = (pro
</div>
<For each={freeModels()}>
{(item) => (
<TooltipV2
<Tooltip
class="w-full"
placement="right-start"
gutter={6}
@@ -105,15 +105,15 @@ export const DialogSelectModelUnpaidV2: Component<{ model?: ModelState }> = (pro
onClick={() => selectModel(item)}
>
<span class="min-w-0 truncate">{displayModelName(item.name)}</span>
<Tag class="shrink-0">{language.t("model.tag.free")}</Tag>
<Badge class="shrink-0">{language.t("model.tag.free")}</Badge>
<Show when={item.latest}>
<Tag class="shrink-0">{language.t("model.tag.latest")}</Tag>
<Badge class="shrink-0">{language.t("model.tag.latest")}</Badge>
</Show>
<Show when={currentKey() === modelKey(item)}>
<Icon name="check" class="ml-auto size-4 shrink-0 text-v2-icon-icon-base" />
</Show>
</button>
</TooltipV2>
</Tooltip>
)}
</For>
</div>
@@ -172,6 +172,6 @@ export const DialogSelectModelUnpaidV2: Component<{ model?: ModelState }> = (pro
</div>
</div>
</DialogBody>
</DialogV2>
</Dialog>
)
}
@@ -1,9 +1,9 @@
import { Button } from "@opencode-ai/ui/button"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { Dialog } from "@opencode-ai/ui/dialog"
import { Dialog, DialogBody, DialogHeader, DialogTitle } from "@opencode-ai/ui/dialog"
import { List, type ListRef } from "@opencode-ai/ui/list"
import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
import { Tag } from "@opencode-ai/ui/tag"
import { Badge } from "@opencode-ai/ui/badge"
import { Tooltip } from "@opencode-ai/ui/tooltip"
import { type Component, Show } from "solid-js"
import { useLocal } from "@/context/local"
@@ -40,108 +40,113 @@ export const DialogSelectModelUnpaid: Component<{ model?: ModelState }> = (props
}
return (
<Dialog
title={language.t("dialog.model.select.title")}
class="overflow-y-auto [&_[data-slot=dialog-body]]:overflow-visible [&_[data-slot=dialog-body]]:flex-none"
>
<div class="flex flex-col gap-3 px-2.5" onKeyDown={handleKeyDown}>
<div class="text-14-medium text-text-base px-2.5">{language.t("dialog.model.unpaid.freeModels.title")}</div>
<List
class="px-3 [&_[data-slot=list-scroll]]:overflow-visible"
ref={(ref) => (listRef = ref)}
items={model.list}
current={model.current()}
key={(x) => `${x.provider.id}:${x.id}`}
itemWrapper={(item, node) => (
<Tooltip
class="w-full"
placement="right-start"
gutter={12}
value={
<ModelTooltip
model={item}
latest={item.latest}
free={item.provider.id === "opencode" && (!item.cost || item.cost.input === 0)}
/>
}
>
{node}
</Tooltip>
)}
onSelect={(x) => {
model.set(x ? { modelID: x.id, providerID: x.provider.id } : undefined, {
recent: true,
})
dialog.close()
}}
>
{(i) => (
<div class="w-full flex items-center gap-x-2.5">
<span>{i.name}</span>
<Tag>{language.t("model.tag.free")}</Tag>
<Show when={i.latest}>
<Tag>{language.t("model.tag.latest")}</Tag>
</Show>
</div>
)}
</List>
</div>
<div class="px-1.5 pb-1.5">
<div class="w-full rounded-sm border border-border-weak-base bg-surface-raised-base">
<div class="w-full flex flex-col items-start gap-4 px-1.5 pt-4 pb-4">
<div class="px-2 text-14-medium text-text-base">{language.t("dialog.model.unpaid.addMore.title")}</div>
<div class="w-full">
<List
class="w-full px-3"
key={(p) => p.id}
items={providers.popular}
activeIcon="plus-small"
sortBy={(a, b) => {
if (popularProviders.includes(a.id) && popularProviders.includes(b.id))
return popularProviders.indexOf(a.id) - popularProviders.indexOf(b.id)
return a.name.localeCompare(b.name)
}}
onSelect={(x) => {
if (!x) return
connect(x.id)
}}
<Dialog class="overflow-y-auto [&_[data-slot=dialog-body]]:overflow-visible [&_[data-slot=dialog-body]]:flex-none">
<DialogHeader>
<DialogTitle>{language.t("dialog.model.select.title")}</DialogTitle>
</DialogHeader>
<DialogBody>
<div class="flex flex-col gap-3 px-2.5" onKeyDown={handleKeyDown}>
<div class="text-14-medium text-text-base px-2.5">{language.t("dialog.model.unpaid.freeModels.title")}</div>
<List
class="px-3 [&_[data-slot=list-scroll]]:overflow-visible"
ref={(ref) => (listRef = ref)}
items={model.list}
current={model.current()}
key={(x) => `${x.provider.id}:${x.id}`}
itemWrapper={(item, node) => (
<Tooltip
appearance="standard"
class="w-full"
placement="right-start"
gutter={12}
value={
<ModelTooltip
model={item}
latest={item.latest}
free={item.provider.id === "opencode" && (!item.cost || item.cost.input === 0)}
/>
}
>
{(i) => (
<div class="w-full flex items-center gap-x-3">
<ProviderIcon data-slot="list-item-extra-icon" id={i.id} />
<span>{i.name}</span>
<Show when={i.id === "opencode"}>
<div class="text-14-regular text-text-weak">{language.t("dialog.provider.opencode.tagline")}</div>
</Show>
<Show when={i.id === "opencode"}>
<Tag>{language.t("dialog.provider.tag.recommended")}</Tag>
</Show>
<Show when={i.id === "opencode-go"}>
<>
{node}
</Tooltip>
)}
onSelect={(x) => {
model.set(x ? { modelID: x.id, providerID: x.provider.id } : undefined, {
recent: true,
})
dialog.close()
}}
>
{(i) => (
<div class="w-full flex items-center gap-x-2.5">
<span>{i.name}</span>
<Badge appearance="standard">{language.t("model.tag.free")}</Badge>
<Show when={i.latest}>
<Badge appearance="standard">{language.t("model.tag.latest")}</Badge>
</Show>
</div>
)}
</List>
</div>
<div class="px-1.5 pb-1.5">
<div class="w-full rounded-sm border border-border-weak-base bg-surface-raised-base">
<div class="w-full flex flex-col items-start gap-4 px-1.5 pt-4 pb-4">
<div class="px-2 text-14-medium text-text-base">{language.t("dialog.model.unpaid.addMore.title")}</div>
<div class="w-full">
<List
class="w-full px-3"
key={(p) => p.id}
items={providers.popular}
activeIcon="plus-small"
sortBy={(a, b) => {
if (popularProviders.includes(a.id) && popularProviders.includes(b.id))
return popularProviders.indexOf(a.id) - popularProviders.indexOf(b.id)
return a.name.localeCompare(b.name)
}}
onSelect={(x) => {
if (!x) return
connect(x.id)
}}
>
{(i) => (
<div class="w-full flex items-center gap-x-3">
<ProviderIcon data-slot="list-item-extra-icon" id={i.id} />
<span>{i.name}</span>
<Show when={i.id === "opencode"}>
<div class="text-14-regular text-text-weak">
{language.t("dialog.provider.opencodeGo.tagline")}
{language.t("dialog.provider.opencode.tagline")}
</div>
<Tag>{language.t("dialog.provider.tag.recommended")}</Tag>
</>
</Show>
<Show when={i.id === "anthropic"}>
<div class="text-14-regular text-text-weak">{language.t("dialog.provider.anthropic.note")}</div>
</Show>
</div>
)}
</List>
<Button
variant="ghost"
class="w-full justify-start px-[11px] py-3.5 gap-4.5 text-14-medium"
icon="dot-grid"
onClick={all}
>
{language.t("dialog.provider.viewAll")}
</Button>
</Show>
<Show when={i.id === "opencode"}>
<Badge appearance="standard">{language.t("dialog.provider.tag.recommended")}</Badge>
</Show>
<Show when={i.id === "opencode-go"}>
<>
<div class="text-14-regular text-text-weak">
{language.t("dialog.provider.opencodeGo.tagline")}
</div>
<Badge appearance="standard">{language.t("dialog.provider.tag.recommended")}</Badge>
</>
</Show>
<Show when={i.id === "anthropic"}>
<div class="text-14-regular text-text-weak">{language.t("dialog.provider.anthropic.note")}</div>
</Show>
</div>
)}
</List>
<Button
variant="ghost"
class="w-full justify-start px-[11px] py-3.5 gap-4.5 text-14-medium"
icon="dot-grid"
onClick={all}
>
{language.t("dialog.provider.viewAll")}
</Button>
</div>
</div>
</div>
</div>
</div>
</DialogBody>
</Dialog>
)
}
@@ -5,16 +5,14 @@ import { useLocal } from "@/context/local"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { popularProviders } from "@/hooks/use-providers"
import { Button } from "@opencode-ai/ui/button"
import { Badge } from "@opencode-ai/ui/badge"
import { Dialog, DialogBody, DialogHeader, DialogTitle } from "@opencode-ai/ui/dialog"
import { Icon } from "@opencode-ai/ui/icon"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { ScrollView } from "@opencode-ai/ui/scroll-view"
import { Tag } from "@opencode-ai/ui/tag"
import { Dialog } from "@opencode-ai/ui/dialog"
import { List } from "@opencode-ai/ui/list"
import { Tooltip } from "@opencode-ai/ui/tooltip"
import { Icon } from "@opencode-ai/ui/v2/icon"
import { Tag as TagV2 } from "@opencode-ai/ui/v2/badge-v2"
import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2"
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
import { Menu } from "@opencode-ai/ui/menu"
import { ModelTooltip } from "./model-tooltip"
import { useLanguage } from "@/context/language"
import { decode64 } from "@/utils/base64"
@@ -81,6 +79,7 @@ const ModelList: Component<{
}}
itemWrapper={(item, node) => (
<Tooltip
appearance="standard"
class="w-full"
placement="right-start"
gutter={12}
@@ -101,10 +100,10 @@ const ModelList: Component<{
<div class="w-full flex items-center gap-x-2 text-13-regular">
<span class="truncate">{i.name}</span>
<Show when={isFree(i.provider.id, i.cost)}>
<Tag>{language.t("model.tag.free")}</Tag>
<Badge appearance="standard">{language.t("model.tag.free")}</Badge>
</Show>
<Show when={i.latest}>
<Tag>{language.t("model.tag.latest")}</Tag>
<Badge appearance="standard">{language.t("model.tag.latest")}</Badge>
</Show>
</div>
)}
@@ -193,21 +192,19 @@ export function ModelSelectorPopover(props: {
class="p-1"
action={
<div class="flex items-center gap-1">
<Tooltip placement="top" value={language.t("command.provider.connect")}>
<Tooltip appearance="standard" placement="top" value={language.t("command.provider.connect")}>
<IconButton
icon="plus-small"
icon={<Icon name="plus-small" />}
variant="ghost"
iconSize="normal"
class="size-6"
aria-label={language.t("command.provider.connect")}
onClick={handleConnectProvider}
/>
</Tooltip>
<Tooltip placement="top" value={language.t("dialog.model.manage")}>
<Tooltip appearance="standard" placement="top" value={language.t("dialog.model.manage")}>
<IconButton
icon="sliders"
icon={<Icon name="sliders" />}
variant="ghost"
iconSize="normal"
class="size-6"
aria-label={language.t("dialog.model.manage")}
onClick={handleManage}
@@ -373,10 +370,10 @@ function ModelSelectorPopoverV2View(props: {
})
return (
<MenuV2 open={store.open} modal={false} placement="top-start" gutter={6} onOpenChange={setOpen}>
<MenuV2.Trigger as={props.trigger} />
<MenuV2.Portal>
<MenuV2.Content
<Menu open={store.open} modal={false} placement="top-start" gutter={6} onOpenChange={setOpen}>
<Menu.Trigger as={props.trigger} />
<Menu.Portal>
<Menu.Content
ref={(element: HTMLDivElement) => (contentRef = element)}
class="w-[284px] overflow-hidden rounded-md border-0 bg-v2-background-bg-layer-01 !p-0 shadow-[var(--v2-elevation-floating)] focus:outline-none"
onPointerDownOutside={dismiss.preventTriggerRestore}
@@ -449,14 +446,14 @@ function ModelSelectorPopoverV2View(props: {
>
<For each={groups()}>
{(group) => (
<MenuV2.Group>
<MenuV2.GroupLabel class="gap-2 px-3">
<Menu.Group>
<Menu.GroupLabel class="gap-2 px-3">
<span class="min-w-0 truncate">{group.items[0].provider.name}</span>
</MenuV2.GroupLabel>
<MenuV2.RadioGroup value={props.current}>
</Menu.GroupLabel>
<Menu.RadioGroup value={props.current}>
<For each={group.items}>
{(item) => (
<TooltipV2
<Tooltip
class="w-full"
placement="right-start"
gutter={6}
@@ -470,7 +467,7 @@ function ModelSelectorPopoverV2View(props: {
/>
}
>
<MenuV2.RadioItem
<Menu.RadioItem
value={modelKey(item)}
data-option-key={modelKey(item)}
data-selected-model={props.current === modelKey(item) ? true : undefined}
@@ -484,17 +481,17 @@ function ModelSelectorPopoverV2View(props: {
>
<span class="min-w-0 truncate leading-5">{item.name}</span>
<Show when={isFree(item.provider.id, item.cost)}>
<TagV2 class="shrink-0">{language.t("model.tag.free")}</TagV2>
<Badge class="shrink-0">{language.t("model.tag.free")}</Badge>
</Show>
<Show when={item.latest}>
<TagV2 class="shrink-0">{language.t("model.tag.latest")}</TagV2>
<Badge class="shrink-0">{language.t("model.tag.latest")}</Badge>
</Show>
</MenuV2.RadioItem>
</TooltipV2>
</Menu.RadioItem>
</Tooltip>
)}
</For>
</MenuV2.RadioGroup>
</MenuV2.Group>
</Menu.RadioGroup>
</Menu.Group>
)}
</For>
</Show>
@@ -502,7 +499,7 @@ function ModelSelectorPopoverV2View(props: {
</ScrollView>
<div class="h-px bg-v2-border-border-muted" />
<div class="flex flex-col p-0.5">
<MenuV2.Item
<Menu.Item
data-option-key={manageKey}
classList={{ "!bg-v2-overlay-simple-overlay-hover": store.active === manageKey }}
onMouseEnter={() => {
@@ -513,11 +510,11 @@ function ModelSelectorPopoverV2View(props: {
>
<Icon name="outline-sliders" size="small" />
<span class="min-w-0 flex-1 truncate leading-5">{language.t("dialog.model.manage")}</span>
</MenuV2.Item>
</Menu.Item>
</div>
</MenuV2.Content>
</MenuV2.Portal>
</MenuV2>
</Menu.Content>
</Menu.Portal>
</Menu>
)
}
@@ -540,18 +537,19 @@ export const DialogSelectModel: Component<{ provider?: string; model?: ModelStat
}
return (
<Dialog
title={language.t("dialog.model.select.title")}
action={
<Dialog>
<DialogHeader hideClose>
<DialogTitle>{language.t("dialog.model.select.title")}</DialogTitle>
<Button class="h-7 -my-1 text-14-medium" icon="plus-small" tabIndex={-1} onClick={provider}>
{language.t("command.provider.connect")}
</Button>
}
>
<ModelList provider={props.provider} model={props.model} onSelect={() => dialog.close()} />
<Button variant="ghost" class="ml-3 mt-5 mb-6 text-text-base self-start" onClick={manage}>
{language.t("dialog.model.manage")}
</Button>
</DialogHeader>
<DialogBody>
<ModelList provider={props.provider} model={props.model} onSelect={() => dialog.close()} />
<Button variant="ghost" class="ml-3 mt-5 mb-6 text-text-base self-start" onClick={manage}>
{language.t("dialog.model.manage")}
</Button>
</DialogBody>
</Dialog>
)
}
@@ -1,5 +1,6 @@
import { Button } from "@opencode-ai/ui/button"
import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
import { Menu } from "@opencode-ai/ui/menu"
import { Icon } from "@opencode-ai/ui/icon"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { List } from "@opencode-ai/ui/list"
import { TextField } from "@opencode-ai/ui/text-field"
@@ -156,49 +157,47 @@ export function ServerConnectionList(props: {
/>
<div class="flex items-center justify-center gap-4 pl-4">
<Show when={i.type === "http"}>
<DropdownMenu>
<DropdownMenu.Trigger
<Menu appearance="standard">
<Menu.Trigger
as={IconButton}
icon="dot-grid"
icon={<Icon name="dot-grid" />}
variant="ghost"
class="shrink-0 size-8 hover:bg-surface-base-hover data-[expanded]:bg-surface-base-active"
onClick={(e: MouseEvent) => e.stopPropagation()}
onPointerDown={(e: PointerEvent) => e.stopPropagation()}
/>
<DropdownMenu.Portal>
<DropdownMenu.Content class="mt-1">
<DropdownMenu.Item
<Menu.Portal>
<Menu.Content class="mt-1">
<Menu.Item
onSelect={() => {
if (i.type !== "http") return
props.onEdit(i)
}}
>
<DropdownMenu.ItemLabel>{language.t("dialog.server.menu.edit")}</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
{language.t("dialog.server.menu.edit")}
</Menu.Item>
<Show when={props.domain.defaults.available() && props.domain.defaults.key() !== key}>
<DropdownMenu.Item onSelect={() => props.domain.defaults.set(key)}>
<DropdownMenu.ItemLabel>{language.t("dialog.server.menu.default")}</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
<Menu.Item onSelect={() => props.domain.defaults.set(key)}>
{language.t("dialog.server.menu.default")}
</Menu.Item>
</Show>
<Show when={props.domain.defaults.available() && props.domain.defaults.key() === key}>
<DropdownMenu.Item onSelect={() => props.domain.defaults.set(null)}>
<DropdownMenu.ItemLabel>
{language.t("dialog.server.menu.defaultRemove")}
</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
<Menu.Item onSelect={() => props.domain.defaults.set(null)}>
{language.t("dialog.server.menu.defaultRemove")}
</Menu.Item>
</Show>
<Show when={props.domain.connection.canRemove(key)}>
<DropdownMenu.Separator />
<DropdownMenu.Item
<Menu.Separator />
<Menu.Item
onSelect={() => props.domain.connection.remove(key)}
class="text-text-on-critical-base hover:bg-surface-critical-weak"
>
<DropdownMenu.ItemLabel>{language.t("dialog.server.menu.delete")}</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
{language.t("dialog.server.menu.delete")}
</Menu.Item>
</Show>
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu>
</Menu.Content>
</Menu.Portal>
</Menu>
</Show>
</div>
</div>
@@ -208,7 +207,7 @@ export function ServerConnectionList(props: {
<div class="shrink-0 pb-5">
<Button
variant="secondary"
variant="neutral"
icon="plus-small"
size="large"
onClick={props.onAdd}
@@ -244,7 +243,7 @@ export function ServerConnectionForm(props: { form: ServerConnectionFormControll
/>
<div class="shrink-0 pb-5">
<Button
variant="primary"
variant="contrast"
size="large"
onClick={props.form.submit}
disabled={props.form.state.busy()}
@@ -2,7 +2,7 @@ import { usePlatform } from "@/context/platform"
import { useLanguage } from "@/context/language"
import { Button } from "@opencode-ai/ui/button"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { Dialog } from "@opencode-ai/ui/dialog"
import { Dialog, DialogBody, DialogHeader, DialogTitleGroup } from "@opencode-ai/ui/dialog"
import { JSX } from "solid-js"
export type DialogGoUpsellProps = {
@@ -30,17 +30,22 @@ export function DialogUsageExceeded(props: DialogGoUpsellProps) {
}
return (
<Dialog title={props.title} description={props.description} fit>
<div class="flex flex-col gap-4 pl-6 pr-2.5 pb-3">
<div class="flex justify-end gap-2">
<Button variant="ghost" size="large" onClick={dismiss}>
{language.t("dialog.usageExceeded.dontShowAgain")}
</Button>
<Button variant="primary" size="large" onClick={runAction}>
{props.actionLabel}
</Button>
<Dialog fit>
<DialogHeader>
<DialogTitleGroup title={props.title} description={props.description} />
</DialogHeader>
<DialogBody>
<div class="flex flex-col gap-4 pl-6 pr-2.5 pb-3">
<div class="flex justify-end gap-2">
<Button variant="ghost" size="large" onClick={dismiss}>
{language.t("dialog.usageExceeded.dontShowAgain")}
</Button>
<Button variant="contrast" size="large" onClick={runAction}>
{props.actionLabel}
</Button>
</div>
</div>
</div>
</DialogBody>
</Dialog>
)
}
+2 -2
View File
@@ -1,6 +1,6 @@
import { useFile } from "@/context/file"
import { FileIcon } from "@opencode-ai/ui/file-icon"
import "@opencode-ai/ui/v2/file-tree-v2.css"
import "@opencode-ai/ui/file-tree.css"
import {
createEffect,
createMemo,
@@ -13,7 +13,7 @@ import {
} from "solid-js"
import { Dynamic } from "solid-js/web"
import type { FileNode } from "@/types"
import { Icon } from "@opencode-ai/ui/v2/icon"
import { Icon } from "@opencode-ai/ui/icon"
import { pathToFileUrl, withFileDragImage, type Kind } from "@/components/file-tree"
import { createVirtualizer, defaultRangeExtractor } from "@tanstack/solid-virtual"
import {
@@ -1,12 +1,13 @@
import { Icon } from "@opencode-ai/ui/icon"
import { Switch } from "@opencode-ai/ui/v2/switch-v2"
import { TabsV2 } from "@opencode-ai/ui/v2/tabs-v2"
import { Switch } from "@opencode-ai/ui/switch"
import { Tabs } from "@opencode-ai/ui/tabs"
import { type Component, For, Show, createEffect, createMemo, createResource, createSignal } from "solid-js"
import { useLanguage } from "@/context/language"
import { useMcpToggle } from "@/context/mcp"
import { useWorkspaceLocation } from "@/context/location"
import { useServerSDK } from "@/context/server-sdk"
import { useData } from "@/context/server"
import { pluginLabels } from "@/utils/plugin"
import { ExternalLink } from "./external-link"
type SkillItem = {
@@ -101,10 +102,10 @@ export const ProjectSettingsExtensions: Component = () => {
() => (serverSDK.connection.status() === "connected" ? directorySDK().directory : undefined),
(directory) => serverSDK.api.plugin.list({ location: { directory } }).then((result) => result.data),
)
const globalPlugins = createMemo(() => (globalPluginList.latest ?? []).map((item) => item.id))
const globalPlugins = createMemo(() => pluginLabels(globalPluginList.latest ?? []))
const projectPlugins = createMemo(() => {
const shared = new Set(globalPlugins())
return (projectPluginList.latest ?? []).map((item) => item.id).filter((name) => !shared.has(name))
return pluginLabels(projectPluginList.latest ?? []).filter((name) => !shared.has(name))
})
const serverSkills = createMemo(() => data.location.skill.list() ?? [])
@@ -148,15 +149,15 @@ export const ProjectSettingsExtensions: Component = () => {
<span>{language.t("project.settings.extensions.description")}</span>
</div>
<TabsV2 variant="pill" defaultValue="mcps" class="project-settings-extension-tabs">
<TabsV2.List>
<TabsV2.Trigger value="mcps">{language.t("settings.extensions.tab.mcps")}</TabsV2.Trigger>
<TabsV2.Trigger value="plugins">{language.t("status.popover.tab.plugins")}</TabsV2.Trigger>
<TabsV2.Trigger value="skills">{language.t("settings.extensions.tab.skills")}</TabsV2.Trigger>
<Tabs variant="pill" defaultValue="mcps" class="project-settings-extension-tabs">
<Tabs.List>
<Tabs.Trigger value="mcps">{language.t("settings.extensions.tab.mcps")}</Tabs.Trigger>
<Tabs.Trigger value="plugins">{language.t("status.popover.tab.plugins")}</Tabs.Trigger>
<Tabs.Trigger value="skills">{language.t("settings.extensions.tab.skills")}</Tabs.Trigger>
{/* TODO: Restore LSP status when V2 exposes it. */}
</TabsV2.List>
</Tabs.List>
<TabsV2.Content value="mcps">
<Tabs.Content value="mcps">
<div class="project-settings-extension-section">
<div class="project-settings-extension-section-header">
<span>{language.t("project.settings.extensions.added")}</span>
@@ -167,9 +168,9 @@ export const ProjectSettingsExtensions: Component = () => {
</Show>
<SharedSection count={globalMcpNames().length}>{mcpRows(globalMcpNames())}</SharedSection>
</div>
</TabsV2.Content>
</Tabs.Content>
<TabsV2.Content value="plugins">
<Tabs.Content value="plugins">
<div class="project-settings-extension-section">
<div class="project-settings-extension-section-header">
<span>{language.t("project.settings.extensions.added")}</span>
@@ -180,9 +181,9 @@ export const ProjectSettingsExtensions: Component = () => {
</Show>
<SharedSection count={globalPlugins().length}>{pluginRows(globalPlugins())}</SharedSection>
</div>
</TabsV2.Content>
</Tabs.Content>
<TabsV2.Content value="skills">
<Tabs.Content value="skills">
<div class="project-settings-extension-section">
<div class="project-settings-extension-section-header">
<span>{language.t("project.settings.extensions.added")}</span>
@@ -195,8 +196,8 @@ export const ProjectSettingsExtensions: Component = () => {
</Show>
<SharedSection count={serverSkills().length}>{skillRows(serverSkills())}</SharedSection>
</div>
</TabsV2.Content>
</TabsV2>
</Tabs.Content>
</Tabs>
</div>
)
}
+11 -11
View File
@@ -1,10 +1,10 @@
import { ImagePreview } from "@opencode-ai/ui/image-preview"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
import { Icon } from "@opencode-ai/ui/v2/icon"
import { KeybindV2 } from "@opencode-ai/ui/v2/keybind-v2"
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
import { Button } from "@opencode-ai/ui/button"
import { Icon } from "@opencode-ai/ui/icon"
import { Keybind } from "@opencode-ai/ui/keybind"
import { Tooltip } from "@opencode-ai/ui/tooltip"
import type { ReferenceInfo } from "@opencode-ai/client/promise"
import { createEffect, createMemo, on, Show } from "solid-js"
import { ModelSelectorPopoverV2 } from "@/components/dialog-select-model"
@@ -503,20 +503,20 @@ function PromptInputV2ModelControl(props: {
)
return (
<Show when={!props.loading}>
<TooltipV2
<Tooltip
placement="top"
gutter={4}
value={
<>
{props.title}
<KeybindV2 keys={props.keybind} variant="neutral" />
<Keybind keys={props.keybind} variant="neutral" />
</>
}
>
<Show
when={props.paid}
fallback={
<ButtonV2
<Button
data-action="prompt-model"
data-control-type="dialog"
variant="ghost-muted"
@@ -527,13 +527,13 @@ function PromptInputV2ModelControl(props: {
onClick={props.onUnpaidClick}
>
{content()}
</ButtonV2>
</Button>
}
>
<ModelSelectorPopoverV2
model={props.model}
trigger={(triggerProps) => (
<ButtonV2
<Button
{...triggerProps}
variant="ghost-muted"
size="normal"
@@ -544,12 +544,12 @@ function PromptInputV2ModelControl(props: {
data-control-type="popover"
>
{content()}
</ButtonV2>
</Button>
)}
onClose={props.onClose}
/>
</Show>
</TooltipV2>
</Tooltip>
</Show>
)
}
@@ -9,10 +9,9 @@ import {
type ComponentProps,
} from "solid-js"
import { createStore } from "solid-js/store"
import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
import { Menu } from "@opencode-ai/ui/menu"
import { Icon } from "@opencode-ai/ui/icon"
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
import { ProjectAvatar } from "@opencode-ai/ui/v2/project-avatar-v2"
import { ProjectAvatar } from "@opencode-ai/ui/project-avatar"
import { getProjectAvatarVariant } from "@/context/layout"
import { useLanguage } from "@/context/language"
import { displayName, getProjectAvatarSource } from "@/pages/layout/helpers"
@@ -277,7 +276,8 @@ export function PromptProjectSelector(props: {
})
return (
<DropdownMenu
<Menu
appearance="standard"
open={triggerReady() && props.controller.open()}
placement={props.placement ?? "bottom"}
gutter={4}
@@ -287,9 +287,9 @@ export function PromptProjectSelector(props: {
props.controller.setOpen(open)
}}
>
<DropdownMenu.Trigger as={ProjectTrigger} ref={setTriggerRef} controller={props.controller} />
<DropdownMenu.Portal>
<DropdownMenu.Content
<Menu.Trigger as={ProjectTrigger} ref={setTriggerRef} controller={props.controller} />
<Menu.Portal>
<Menu.Content
ref={contentRef}
id="prompt-project-menu"
class="w-[243px] overflow-hidden rounded-md border-0 bg-v2-background-bg-layer-01 p-0 shadow-[var(--v2-elevation-floating)] focus:outline-none [&[data-closed]]:!animate-none"
@@ -360,13 +360,13 @@ export function PromptProjectSelector(props: {
<Show
when={props.controller.servers().length > 1}
fallback={
<DropdownMenu.RadioGroup value={selectedValue()}>
<Menu.RadioGroup value={selectedValue()}>
<For each={props.controller.projects()}>
{(project) => (
<ProjectItem project={project} controller={props.controller} onSelect={selectProject} />
)}
</For>
</DropdownMenu.RadioGroup>
</Menu.RadioGroup>
}
>
<For
@@ -381,7 +381,7 @@ export function PromptProjectSelector(props: {
<div class="flex h-7 select-none items-center pl-1.5 pr-3 text-[11px] font-[530] leading-none tracking-[0.05px] text-v2-text-text-faint">
{server!.name}
</div>
<DropdownMenu.RadioGroup value={selectedValue()}>
<Menu.RadioGroup value={selectedValue()}>
<For
each={props.controller.projects().filter((project) => project.server?.key === server!.key)}
>
@@ -389,7 +389,7 @@ export function PromptProjectSelector(props: {
<ProjectItem project={project} controller={props.controller} onSelect={selectProject} />
)}
</For>
</DropdownMenu.RadioGroup>
</Menu.RadioGroup>
</div>
)}
</For>
@@ -408,8 +408,8 @@ export function PromptProjectSelector(props: {
/>
}
>
<DropdownMenu.Sub>
<DropdownMenu.SubTrigger
<Menu.Sub>
<Menu.SubTrigger
id={props.controller.actionKey()}
data-option-key={props.controller.actionKey()}
class={projectActionClass}
@@ -419,24 +419,23 @@ export function PromptProjectSelector(props: {
onMouseEnter={() => props.controller.setActive(props.controller.actionKey())}
>
<Icon name="plus" size="small" />
<span data-slot="dropdown-menu-item-label" class="min-w-0 flex-1 truncate leading-5">
<span class="min-w-0 flex-1 truncate leading-5">
{props.controller.labels.add()}
</span>
<Icon name="chevron-right" size="small" class="shrink-0 text-v2-icon-icon-muted" />
</DropdownMenu.SubTrigger>
<DropdownMenu.Portal>
<DropdownMenu.SubContent class="min-w-[180px] overflow-hidden rounded-md border-0 bg-v2-background-bg-layer-01 p-0.5 shadow-[var(--v2-elevation-floating)] focus:outline-none">
</Menu.SubTrigger>
<Menu.Portal>
<Menu.SubContent class="min-w-[180px] overflow-hidden rounded-md border-0 bg-v2-background-bg-layer-01 p-0.5 shadow-[var(--v2-elevation-floating)] focus:outline-none">
<For each={props.controller.servers()}>
{(server) => <ServerAction server={server!} onSelect={selectAction} />}
</For>
</DropdownMenu.SubContent>
</DropdownMenu.Portal>
</DropdownMenu.Sub>
</Menu.SubContent>
</Menu.Portal>
</Menu.Sub>
</Show>
</div>
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu>
</Menu.Content>
</Menu.Portal>
</Menu>
)
}
@@ -507,7 +506,7 @@ function ProjectItem(props: {
}) {
const key = () => props.controller.projectKey(props.project)
return (
<DropdownMenu.RadioItem
<Menu.RadioItem
id={key()}
value={key()}
data-option-key={key()}
@@ -534,11 +533,8 @@ function ProjectItem(props: {
src={getProjectAvatarSource(props.project.id, props.project.icon)}
variant={getProjectAvatarVariant(props.project.icon?.color)}
/>
<DropdownMenu.ItemLabel class="min-w-0 truncate leading-5">{displayName(props.project)}</DropdownMenu.ItemLabel>
<DropdownMenu.ItemIndicator style={{ width: "14px", height: "14px", right: "12px" }}>
<IconV2 name="check" size="small" class="shrink-0 text-v2-icon-icon-base" />
</DropdownMenu.ItemIndicator>
</DropdownMenu.RadioItem>
<span class="min-w-0 truncate leading-5">{displayName(props.project)}</span>
</Menu.RadioItem>
)
}
@@ -552,7 +548,7 @@ function ProjectAction(props: {
}) {
const key = () => props.controller.actionKey(props.server)
return (
<DropdownMenu.Item
<Menu.Item
id={key()}
data-option-key={key()}
class="h-7 gap-2 rounded-sm px-3 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-base data-[highlighted]:!bg-v2-overlay-simple-overlay-hover"
@@ -573,17 +569,17 @@ function ProjectAction(props: {
onSelect={() => props.onSelect(props.server)}
>
<Icon name="plus" size="small" />
<DropdownMenu.ItemLabel class="min-w-0 truncate leading-5">
<span class="min-w-0 truncate leading-5">
{props.controller.labels.add()}
</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
</span>
</Menu.Item>
)
}
function ServerAction(props: { server: { key: string; name: string }; onSelect: (server: string) => void }) {
return (
<DropdownMenu.Item class={projectActionClass} onSelect={() => props.onSelect(props.server.key)}>
<DropdownMenu.ItemLabel class="min-w-0 flex-1 truncate leading-5">{props.server.name}</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
<Menu.Item class={projectActionClass} onSelect={() => props.onSelect(props.server.key)}>
<span class="min-w-0 flex-1 truncate leading-5">{props.server.name}</span>
</Menu.Item>
)
}
@@ -1,7 +1,7 @@
import { createMemo, createSignal, For, Show } from "solid-js"
import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2"
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
import { Icon } from "@opencode-ai/ui/v2/icon"
import { Menu } from "@opencode-ai/ui/menu"
import { Tooltip } from "@opencode-ai/ui/tooltip"
import { Icon } from "@opencode-ai/ui/icon"
import { getFilename } from "@opencode-ai/util/path"
import { useLanguage } from "@/context/language"
import { sameDirectory } from "@/utils/workspace"
@@ -58,7 +58,7 @@ export function PromptWorkspaceSelector(props: {
return (
<>
<span class="hidden select-none opacity-50 sm:inline mx-1">/</span>
<TooltipV2
<Tooltip
placement="top"
openDelay={800}
value={
@@ -77,8 +77,8 @@ export function PromptWorkspaceSelector(props: {
contentClass={props.onboarding ? "max-w-[280px]" : undefined}
class="min-w-0"
>
<MenuV2 placement="bottom" gutter={4} onOpenChange={onOpenChange}>
<MenuV2.Trigger
<Menu placement="bottom" gutter={4} onOpenChange={onOpenChange}>
<Menu.Trigger
aria-description={language.t("session.new.workspace.trigger.tooltip")}
class="flex h-6 min-w-0 max-w-[203px] items-center gap-1.5 rounded-sm px-1.5 hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none data-[expanded]:bg-v2-overlay-simple-overlay-pressed data-[expanded]:text-v2-text-text-muted"
>
@@ -92,14 +92,14 @@ export function PromptWorkspaceSelector(props: {
/>
</Show>
<Icon name="chevron-down" size="small" class="shrink-0 text-v2-icon-icon-muted" />
</MenuV2.Trigger>
<MenuV2.Portal>
<MenuV2.Content class="w-[200px]">
<MenuV2.Group>
<MenuV2.GroupLabel>{language.t("session.new.workspace.runIn")}</MenuV2.GroupLabel>
<MenuV2.Item onSelect={() => select("main")}>
</Menu.Trigger>
<Menu.Portal>
<Menu.Content class="w-[200px]">
<Menu.Group>
<Menu.GroupLabel>{language.t("session.new.workspace.runIn")}</Menu.GroupLabel>
<Menu.Item onSelect={() => select("main")}>
<Icon name="monitor" />
<TooltipV2
<Tooltip
placement="right"
openDelay={800}
value={
@@ -113,14 +113,14 @@ export function PromptWorkspaceSelector(props: {
class="min-w-0 flex-1"
>
<span class="min-w-0 truncate">{language.t("session.new.workspace.local")}</span>
</TooltipV2>
</Tooltip>
<Show when={selected() === "main"}>
<Icon name="check" size="small" class="shrink-0" />
</Show>
</MenuV2.Item>
<MenuV2.Item onSelect={() => select("create")}>
</Menu.Item>
<Menu.Item onSelect={() => select("create")}>
<Icon name="workspace-new" />
<TooltipV2
<Tooltip
placement="right"
openDelay={800}
value={
@@ -134,25 +134,25 @@ export function PromptWorkspaceSelector(props: {
class="min-w-0 flex-1"
>
<span class="min-w-0 truncate">{language.t("workspace.new")}</span>
</TooltipV2>
</Tooltip>
<Show when={selected() === "create"}>
<Icon name="check" size="small" class="shrink-0" />
</Show>
</MenuV2.Item>
</MenuV2.Group>
</Menu.Item>
</Menu.Group>
<Show
when={props.workspaces.length > 0}
fallback={
<>
<MenuV2.Separator class="h-[0.5px]" />
<MenuV2.Item onSelect={() => (pending = { type: "viewAll" })}>
<Menu.Separator class="h-[0.5px]" />
<Menu.Item onSelect={() => (pending = { type: "viewAll" })}>
<span class="min-w-0 flex-1 truncate">{language.t("common.viewAll")}</span>
</MenuV2.Item>
</Menu.Item>
</>
}
>
<MenuV2.Separator class="h-[0.5px]" />
<MenuV2.Sub
<Menu.Separator class="h-[0.5px]" />
<Menu.Sub
gutter={0}
overlap
overflowPadding={8}
@@ -166,7 +166,7 @@ export function PromptWorkspaceSelector(props: {
requestAnimationFrame(() => searchInput?.focus())
}}
>
<MenuV2.SubTrigger
<Menu.SubTrigger
onKeyDown={(event) => {
if (
event.key === "ArrowRight" ||
@@ -181,9 +181,9 @@ export function PromptWorkspaceSelector(props: {
<span class="min-w-0 flex-1 truncate">
{language.t("session.new.workspace.existing").replace(/(…|\.{3})$/, "")}
</span>
</MenuV2.SubTrigger>
<MenuV2.Portal>
<MenuV2.SubContent class="max-h-[calc(100dvh-16px)] w-[200px] overflow-y-auto">
</Menu.SubTrigger>
<Menu.Portal>
<Menu.SubContent class="max-h-[calc(100dvh-16px)] w-[200px] overflow-y-auto">
<Show when={props.workspaces.length >= 10}>
<div class="flex h-7 items-center gap-2 rounded-sm ps-3 pe-2 text-v2-icon-icon-muted">
<Icon name="magnifying-glass" size="small" class="shrink-0" />
@@ -211,27 +211,27 @@ export function PromptWorkspaceSelector(props: {
</Show>
<For each={workspaces()}>
{(workspace) => (
<MenuV2.Item onSelect={() => select(workspace)}>
<Menu.Item onSelect={() => select(workspace)}>
<Icon name="workspace-isolated" />
<span class="min-w-0 flex-1 truncate">{getFilename(workspace)}</span>
<Show when={selected() === workspace}>
<Icon name="check" size="small" class="shrink-0" />
</Show>
</MenuV2.Item>
</Menu.Item>
)}
</For>
<MenuV2.Separator class="h-[0.5px]" />
<MenuV2.Item onSelect={() => (pending = { type: "viewAll" })}>
<Menu.Separator class="h-[0.5px]" />
<Menu.Item onSelect={() => (pending = { type: "viewAll" })}>
<span class="min-w-0 flex-1 truncate">{language.t("common.viewAll")}</span>
</MenuV2.Item>
</MenuV2.SubContent>
</MenuV2.Portal>
</MenuV2.Sub>
</Menu.Item>
</Menu.SubContent>
</Menu.Portal>
</Menu.Sub>
</Show>
</MenuV2.Content>
</MenuV2.Portal>
</MenuV2>
</TooltipV2>
</Menu.Content>
</Menu.Portal>
</Menu>
</Tooltip>
<PromptGitStatus branch={props.branch} from={selected() === "create"} class="ms-1" />
</>
)
@@ -255,7 +255,7 @@ export function PromptGitStatus(props: { branch?: string; noGit?: boolean; from?
return (
<Show when={label()}>
{(value) => (
<TooltipV2
<Tooltip
placement="top"
value={value()}
class={`min-w-0 max-w-[220px] ${props.class ?? ""}`}
@@ -265,7 +265,7 @@ export function PromptGitStatus(props: { branch?: string; noGit?: boolean; from?
<Icon name={icon()} size="small" class="shrink-0 text-v2-icon-icon-muted" />
<span class="min-w-0 truncate">{value()}</span>
</div>
</TooltipV2>
</Tooltip>
)}
</Show>
)
@@ -1,6 +1,6 @@
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2"
import { Icon } from "@opencode-ai/ui/icon"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { Menu } from "@opencode-ai/ui/menu"
import { type Component, Show } from "solid-js"
import type { ServerActionsController } from "@/components/server/server-management-controller"
import { useLanguage } from "@/context/language"
@@ -59,19 +59,19 @@ export const ServerRowMenuView: Component<{
const builtin = () => ServerConnection.builtin(props.server)
const httpServer = () => (props.server.type === "http" ? props.server : undefined)
return (
<MenuV2 gutter={6} modal={false} placement="bottom-end" open={props.open} onOpenChange={props.onOpenChange}>
<MenuV2.Trigger
as={IconButtonV2}
<Menu gutter={6} modal={false} placement="bottom-end" open={props.open} onOpenChange={props.onOpenChange}>
<Menu.Trigger
as={IconButton}
variant="ghost-muted"
size="small"
icon={<IconV2 name="outline-dots" />}
icon={<Icon name="outline-dots" />}
aria-label={props.labels.more}
/>
<MenuV2.Portal>
<MenuV2.Content>
<MenuV2.Group>
<MenuV2.GroupLabel>{props.labels.server}</MenuV2.GroupLabel>
<MenuV2.Item
<Menu.Portal>
<Menu.Content>
<Menu.Group>
<Menu.GroupLabel>{props.labels.server}</Menu.GroupLabel>
<Menu.Item
disabled={builtin() || !httpServer()}
onSelect={() => {
const server = httpServer()
@@ -79,20 +79,20 @@ export const ServerRowMenuView: Component<{
}}
>
{props.labels.edit}
</MenuV2.Item>
</Menu.Item>
<Show when={props.canDefault && !props.isDefault}>
<MenuV2.Item onSelect={props.onSetDefault}>{props.labels.default}</MenuV2.Item>
<Menu.Item onSelect={props.onSetDefault}>{props.labels.default}</Menu.Item>
</Show>
<Show when={props.canDefault && props.isDefault}>
<MenuV2.Item onSelect={props.onRemoveDefault}>{props.labels.defaultRemove}</MenuV2.Item>
<Menu.Item onSelect={props.onRemoveDefault}>{props.labels.defaultRemove}</Menu.Item>
</Show>
<Show when={props.canRemove}>
<MenuV2.Separator />
<MenuV2.Item onSelect={props.onRemove}>{props.labels.delete}</MenuV2.Item>
<Menu.Separator />
<Menu.Item onSelect={props.onRemove}>{props.labels.delete}</Menu.Item>
</Show>
</MenuV2.Group>
</MenuV2.Content>
</MenuV2.Portal>
</MenuV2>
</Menu.Group>
</Menu.Content>
</Menu.Portal>
</Menu>
)
}
@@ -64,6 +64,7 @@ export function ServerRow(props: ServerRowProps) {
return (
<Tooltip
appearance="standard"
class="flex-1 min-w-0"
value={tooltipValue()}
contentStyle={{ "max-width": "none", "white-space": "nowrap" }}
@@ -1,8 +1,7 @@
import { Show, createMemo, type ComponentProps, type JSX } from "solid-js"
import { ProgressCircle } from "@opencode-ai/ui/progress-circle"
import { ProgressCircleV2 } from "@opencode-ai/ui/v2/progress-circle-v2"
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { Tooltip } from "@opencode-ai/ui/tooltip"
import { createMediaQuery } from "@solid-primitives/media"
import { useFile } from "@/context/file"
@@ -16,7 +15,7 @@ import { createSessionTabs } from "@/pages/session/helpers"
interface SessionContextUsageProps {
variant?: "button" | "indicator"
placement?: ComponentProps<typeof TooltipV2>["placement"]
placement?: ComponentProps<typeof Tooltip>["placement"]
}
function ContextTooltipRow(props: { name: JSX.Element; value: JSX.Element }) {
@@ -111,24 +110,21 @@ export function SessionContextUsage(props: SessionContextUsageProps) {
const circle = () => (
<div class="flex items-center justify-center">
<ProgressCircle
appearance="indicator"
size={16}
strokeWidth={2}
percentage={context()?.usage ?? 0}
style={
variant() === "indicator"
? {
"--progress-circle-background": "var(--v2-background-bg-layer-04, var(--border-weak-base))",
"--progress-circle-background-overlay": "var(--v2-overlay-simple-overlay-pressed, transparent)",
"--progress-circle-progress": "var(--v2-icon-icon-base, var(--icon-base))",
}
: undefined
}
style={{
"--progress-circle-background": "var(--v2-background-bg-layer-04, var(--border-weak-base))",
"--progress-circle-background-overlay": "var(--v2-overlay-simple-overlay-pressed, transparent)",
"--progress-circle-progress": "var(--v2-icon-icon-base, var(--icon-base))",
}}
/>
</div>
)
const circleV2 = () => (
<div class="flex items-center justify-center">
<ProgressCircleV2 percentage={context()?.usage ?? 0} />
<ProgressCircle appearance="compact" percentage={context()?.usage ?? 0} />
</div>
)
@@ -145,11 +141,11 @@ export function SessionContextUsage(props: SessionContextUsageProps) {
return (
<Show when={params.id}>
<TooltipV2 value={tooltipValue()} placement={props.placement ?? "top"} shift={-8}>
<Tooltip value={tooltipValue()} placement={props.placement ?? "top"} shift={-8}>
<Show
when={variant() === "indicator"}
fallback={
<IconButtonV2
<IconButton
type="button"
variant="ghost-muted"
size="large"
@@ -161,7 +157,7 @@ export function SessionContextUsage(props: SessionContextUsageProps) {
>
{circle()}
</Show>
</TooltipV2>
</Tooltip>
</Show>
)
}
@@ -1,5 +1,5 @@
import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2"
import { Icon } from "@opencode-ai/ui/v2/icon"
import { Menu } from "@opencode-ai/ui/menu"
import { Icon } from "@opencode-ai/ui/icon"
import { getDirectory, getFilename } from "@opencode-ai/util/path"
import { createStore } from "solid-js/store"
import { createSignal, For, Show, type ComponentProps, type JSX } from "solid-js"
@@ -17,7 +17,7 @@ export function SessionWorkspaceMenu(props: {
sessionID: string
project: Project
directory: string
placement?: ComponentProps<typeof MenuV2>["placement"]
placement?: ComponentProps<typeof Menu>["placement"]
gutter?: number
class?: string
contentClass?: string
@@ -71,57 +71,57 @@ export function SessionWorkspaceMenu(props: {
}
return (
<MenuV2
<Menu
placement={props.placement ?? "bottom-end"}
gutter={props.gutter ?? 4}
modal={false}
onOpenChange={onOpenChange}
>
<MenuV2.Trigger class={props.class} disabled={blocked()}>
<Menu.Trigger class={props.class} disabled={blocked()}>
{props.children}
</MenuV2.Trigger>
<MenuV2.Portal>
<MenuV2.Content class={`w-[200px] ${props.contentClass ?? ""}`}>
<MenuV2.Group>
<MenuV2.GroupLabel>{language.t("workspace.move.menu.title")}</MenuV2.GroupLabel>
</Menu.Trigger>
<Menu.Portal>
<Menu.Content class={`w-[200px] ${props.contentClass ?? ""}`}>
<Menu.Group>
<Menu.GroupLabel>{language.t("workspace.move.menu.title")}</Menu.GroupLabel>
<Show when={pathKey(props.directory) !== pathKey(props.project.worktree)}>
<MenuV2.Item disabled={!!store.selected || blocked()} onSelect={() => void move(props.project.worktree)}>
<Menu.Item disabled={!!store.selected || blocked()} onSelect={() => void move(props.project.worktree)}>
<Icon name="monitor" />
{language.t("session.new.workspace.local")}
</MenuV2.Item>
</Menu.Item>
</Show>
<MenuV2.Item disabled={!!store.selected || blocked()} onSelect={() => void move("create")}>
<Menu.Item disabled={!!store.selected || blocked()} onSelect={() => void move("create")}>
<Icon name="workspace-new" />
{language.t("workspace.new")}
</MenuV2.Item>
</Menu.Item>
<Show when={workspaces().length > 0}>
<MenuV2.Sub gutter={0} overlap overflowPadding={8}>
<MenuV2.SubTrigger>
<Menu.Sub gutter={0} overlap overflowPadding={8}>
<Menu.SubTrigger>
<Icon name="workspace-isolated" />
{language.t("session.new.workspace.existing").replace(/(…|\.{3})$/, "")}
</MenuV2.SubTrigger>
<MenuV2.Portal>
<MenuV2.SubContent class="max-h-[calc(100dvh-16px)] w-[200px] overflow-y-auto">
</Menu.SubTrigger>
<Menu.Portal>
<Menu.SubContent class="max-h-[calc(100dvh-16px)] w-[200px] overflow-y-auto">
<For each={workspaces()}>
{(workspace) => (
<MenuV2.Item disabled={!!store.selected || blocked()} onSelect={() => void move(workspace)}>
<Menu.Item disabled={!!store.selected || blocked()} onSelect={() => void move(workspace)}>
<Icon name="workspace-isolated" />
<span class="min-w-0 flex-1 truncate">{getFilename(workspace)}</span>
</MenuV2.Item>
</Menu.Item>
)}
</For>
</MenuV2.SubContent>
</MenuV2.Portal>
</MenuV2.Sub>
</Menu.SubContent>
</Menu.Portal>
</Menu.Sub>
</Show>
</MenuV2.Group>
<MenuV2.Separator class="h-[0.5px] bg-v2-border-border-base" />
<MenuV2.Item onSelect={() => openWorkspaces()}>
</Menu.Group>
<Menu.Separator class="h-[0.5px] bg-v2-border-border-base" />
<Menu.Item onSelect={() => openWorkspaces()}>
<span class="min-w-0 flex-1 truncate">{language.t("common.viewAll")}</span>
</MenuV2.Item>
</MenuV2.Content>
</MenuV2.Portal>
</MenuV2>
</Menu.Item>
</Menu.Content>
</Menu.Portal>
</Menu>
)
}
@@ -2,10 +2,9 @@ import { For, Show } from "solid-js"
import { AppIcon } from "@opencode-ai/ui/app-icon"
import { Icon } from "@opencode-ai/ui/icon"
import { Spinner } from "@opencode-ai/ui/spinner"
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2"
import { SplitButtonV2, SplitButtonV2Action, SplitButtonV2MenuTrigger } from "@opencode-ai/ui/v2/split-button-v2"
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
import { Menu } from "@opencode-ai/ui/menu"
import { SplitButton, SplitButtonAction, SplitButtonMenuTrigger } from "@opencode-ai/ui/split-button"
import { Tooltip } from "@opencode-ai/ui/tooltip"
import { useLanguage } from "@/context/language"
import { type OpenApp, useOpenInApp } from "@/components/session/open-in-app"
@@ -15,13 +14,13 @@ export function OpenInAppV2(props: { directory: () => string }) {
return (
<Show when={props.directory() && state.canOpen()}>
<SplitButtonV2 class="session-review-v2-open-in-app" onPointerDown={(event) => event.stopPropagation()}>
<TooltipV2
<SplitButton class="session-review-v2-open-in-app" onPointerDown={(event) => event.stopPropagation()}>
<Tooltip
placement="bottom"
value={language.t("session.header.open.ariaLabel", { app: state.current().label })}
class="flex items-center"
>
<SplitButtonV2Action
<SplitButtonAction
onPointerDown={(event) => event.stopPropagation()}
onClick={(event) => {
event.stopPropagation()
@@ -34,28 +33,28 @@ export function OpenInAppV2(props: { directory: () => string }) {
<Show when={state.opening()} fallback={<AppIcon id={state.current().icon} class="size-[18px]" />}>
<Spinner class="size-3.5" />
</Show>
</SplitButtonV2Action>
</TooltipV2>
<MenuV2
</SplitButtonAction>
</Tooltip>
<Menu
gutter={4}
modal={false}
placement="bottom-end"
open={state.menu.open}
onOpenChange={(open) => state.setMenu("open", open)}
>
<MenuV2.Trigger
as={SplitButtonV2MenuTrigger}
<Menu.Trigger
as={SplitButtonMenuTrigger}
disabled={state.opening()}
aria-label={language.t("session.header.open.menu")}
onPointerDown={(event) => event.stopPropagation()}
>
<IconV2 name="chevron-down" size="small" />
</MenuV2.Trigger>
<MenuV2.Portal>
<MenuV2.Content class="open-in-app-v2-menu">
<MenuV2.Group>
<MenuV2.GroupLabel>{language.t("session.header.openIn")}</MenuV2.GroupLabel>
<MenuV2.RadioGroup
<Icon name="chevron-down" size="small" />
</Menu.Trigger>
<Menu.Portal>
<Menu.Content class="open-in-app-v2-menu">
<Menu.Group>
<Menu.GroupLabel>{language.t("session.header.openIn")}</Menu.GroupLabel>
<Menu.RadioGroup
value={state.current().id}
onChange={(value) => {
state.selectApp(value as OpenApp)
@@ -63,7 +62,7 @@ export function OpenInAppV2(props: { directory: () => string }) {
>
<For each={state.options()}>
{(option) => (
<MenuV2.RadioItem
<Menu.RadioItem
value={option.id}
disabled={state.opening()}
onSelect={() => {
@@ -74,13 +73,13 @@ export function OpenInAppV2(props: { directory: () => string }) {
>
<AppIcon id={option.icon} />
{option.label}
</MenuV2.RadioItem>
</Menu.RadioItem>
)}
</For>
</MenuV2.RadioGroup>
</MenuV2.Group>
<MenuV2.Separator />
<MenuV2.Item
</Menu.RadioGroup>
</Menu.Group>
<Menu.Separator />
<Menu.Item
onSelect={() => {
state.setMenu("open", false)
state.copyPath()
@@ -88,11 +87,11 @@ export function OpenInAppV2(props: { directory: () => string }) {
>
<Icon name="copy" size="small" class="text-icon-weak" />
{language.t("session.header.open.copyPath")}
</MenuV2.Item>
</MenuV2.Content>
</MenuV2.Portal>
</MenuV2>
</SplitButtonV2>
</Menu.Item>
</Menu.Content>
</Menu.Portal>
</Menu>
</SplitButton>
</Show>
)
}
@@ -1,4 +1,3 @@
import { Tooltip } from "@opencode-ai/ui/tooltip"
import { createMemo, Show } from "solid-js"
import { createMediaQuery } from "@solid-primitives/media"
import { Portal } from "solid-js/web"
@@ -7,10 +6,10 @@ import { useLanguage } from "@/context/language"
import { useSettings } from "@/context/settings"
import { useSessionLayout } from "@/pages/session/session-layout"
import { StatusPopoverV2 } from "../status-popover"
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
import { KeybindV2 } from "@opencode-ai/ui/v2/keybind-v2"
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { Icon } from "@opencode-ai/ui/icon"
import { Keybind } from "@opencode-ai/ui/keybind"
import { Tooltip } from "@opencode-ai/ui/tooltip"
import { reviewTooltipKeybind } from "../command-tooltip-keybind"
import { useTitlebarRightMount } from "../titlebar"
@@ -60,24 +59,24 @@ function SessionHeaderV2Actions(props: { state: SessionHeaderV2ActionsState }) {
return (
<div class="flex items-center gap-2">
<Show when={props.state.statusVisible}>
<Tooltip placement="bottom" value={props.state.statusLabel}>
<Tooltip appearance="standard" placement="bottom" value={props.state.statusLabel}>
<StatusPopoverV2 />
</Tooltip>
</Show>
<Show when={props.state.reviewVisible}>
<TooltipV2
<Tooltip
class="shrink-0"
placement="bottom"
value={
<>
{props.state.reviewLabel}
<Show when={props.state.reviewKeybind.length > 0}>
<KeybindV2 keys={props.state.reviewKeybind} variant="neutral" />
<Keybind keys={props.state.reviewKeybind} variant="neutral" />
</Show>
</>
}
>
<IconButtonV2
<IconButton
type="button"
variant="ghost-muted"
size="large"
@@ -87,9 +86,9 @@ function SessionHeaderV2Actions(props: { state: SessionHeaderV2ActionsState }) {
aria-label={props.state.reviewLabel}
aria-expanded={props.state.reviewOpened}
aria-controls="review-panel"
icon={<IconV2 name="sidebar-right" />}
icon={<Icon name="sidebar-right" />}
/>
</TooltipV2>
</Tooltip>
</Show>
</div>
)
@@ -1,9 +1,8 @@
import { createMemo, Show } from "solid-js"
import type { JSX } from "solid-js"
import { useSortable } from "@dnd-kit/solid/sortable"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { KeybindV2 } from "@opencode-ai/ui/v2/keybind-v2"
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
import { Keybind } from "@opencode-ai/ui/keybind"
import { Tooltip } from "@opencode-ai/ui/tooltip"
import { Tabs } from "@opencode-ai/ui/tabs"
import { useFile } from "@/context/file"
import { useLanguage } from "@/context/language"
@@ -40,31 +39,29 @@ export function SortableTabV2(props: {
<div class="relative">
<Tabs.Trigger
value={props.tab}
onMiddleClick={() => props.onTabClose(props.tab)}
onDblClick={() => props.onTabDoubleClick?.(props.tab)}
closeButton={
<TooltipV2
<Tooltip
value={
<>
{language.t("common.closeTab")}
<Show when={closeTabKeybind().length > 0}>
<KeybindV2 keys={closeTabKeybind()} variant="neutral" />
<Keybind keys={closeTabKeybind()} variant="neutral" />
</Show>
</>
}
placement="bottom"
gutter={10}
>
<IconButton
icon="close-small"
variant="ghost"
<Tabs.CloseButton
class="h-5 w-5"
onClick={() => props.onTabClose(props.tab)}
aria-label={language.t("common.closeTab")}
/>
</TooltipV2>
</Tooltip>
}
hideCloseButton
onMiddleClick={() => props.onTabClose(props.tab)}
onDblClick={() => props.onTabDoubleClick?.(props.tab)}
>
<Show when={content()}>{(value) => value()}</Show>
</Tabs.Trigger>
@@ -2,9 +2,8 @@ import type { JSX } from "solid-js"
import { Show, createEffect, onCleanup } from "solid-js"
import { createStore } from "solid-js/store"
import { useSortable } from "@dnd-kit/solid/sortable"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { Tabs } from "@opencode-ai/ui/tabs"
import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2"
import { Menu } from "@opencode-ai/ui/menu"
import { isDefaultTitle as isDefaultTerminalTitle } from "@/context/terminal-title"
import { useTerminal, type LocalPTY } from "@/context/terminal"
import { useLanguage } from "@/context/language"
@@ -116,8 +115,8 @@ export function SortableTerminalTabV2(props: { terminal: LocalPTY; index: number
return (
<div ref={sortable.ref} class="h-full flex items-center outline-none focus:outline-none focus-visible:outline-none">
<MenuV2.Context>
<MenuV2.Context.Trigger class="relative" as="div">
<Menu.Context>
<Menu.Context.Trigger class="relative" as="div">
<Tabs.Trigger
value={props.terminal.id}
onMouseDown={(e) => {
@@ -131,20 +130,11 @@ export function SortableTerminalTabV2(props: { terminal: LocalPTY; index: number
if (e.detail > 0) return
focus()
}}
onMiddleClick={close}
closeButton={
<IconButton
icon="close-small"
variant="ghost"
class="h-5 w-5"
onClick={(e) => {
e.stopPropagation()
close()
}}
aria-label={language.t("terminal.close")}
/>
<Tabs.CloseButton class="h-5 w-5" onClick={close} aria-label={language.t("terminal.close")} />
}
hideCloseButton
onMiddleClick={close}
>
<span
class="truncate"
@@ -169,9 +159,9 @@ export function SortableTerminalTabV2(props: { terminal: LocalPTY; index: number
/>
</div>
</Show>
</MenuV2.Context.Trigger>
<MenuV2.Context.Portal>
<MenuV2.Context.Content
</Menu.Context.Trigger>
<Menu.Context.Portal>
<Menu.Context.Content
onCloseAutoFocus={(e) => {
if (!editRequested) return
e.preventDefault()
@@ -179,11 +169,11 @@ export function SortableTerminalTabV2(props: { terminal: LocalPTY; index: number
requestAnimationFrame(() => edit())
}}
>
<MenuV2.Item onSelect={() => (editRequested = true)}>{language.t("common.rename")}</MenuV2.Item>
<MenuV2.Item onSelect={close}>{language.t("common.close")}</MenuV2.Item>
</MenuV2.Context.Content>
</MenuV2.Context.Portal>
</MenuV2.Context>
<Menu.Item onSelect={() => (editRequested = true)}>{language.t("common.rename")}</Menu.Item>
<Menu.Item onSelect={close}>{language.t("common.close")}</Menu.Item>
</Menu.Context.Content>
</Menu.Context.Portal>
</Menu.Context>
</div>
)
}
@@ -1,9 +1,9 @@
import { For, Show, createMemo, lazy, onCleanup } from "solid-js"
import { createStore } from "solid-js/store"
import { makeEventListener } from "@solid-primitives/event-listener"
import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2"
import { Button } from "@opencode-ai/ui/button"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { TextInput } from "@opencode-ai/ui/text-input"
import { showToast } from "@/utils/toast"
import fuzzysort from "fuzzysort"
import { DEFAULT_PALETTE_KEYBIND, formatKeybind, parseKeybind, useCommand } from "@/context/command"
@@ -11,7 +11,7 @@ import { useLanguage } from "@/context/language"
import { useSettings } from "@/context/settings"
import { SettingsListV2 } from "./settings-v2/parts/list"
const IconV2 = lazy(() => import("@opencode-ai/ui/v2/icon").then((module) => ({ default: module.Icon })))
const Icon = lazy(() => import("@opencode-ai/ui/icon").then((module) => ({ default: module.Icon })))
const IS_MAC = typeof navigator === "object" && /(Mac|iPod|iPhone|iPad)/.test(navigator.platform)
const PALETTE_ID = "command.palette"
@@ -387,12 +387,12 @@ function SettingsKeybindsV2View(props: {
<h2 class="settings-v2-tab-title">{language.t("settings.shortcuts.title")}</h2>
<span class="text-11-regular text-v2-text-text-muted">{language.t("settings.shortcuts.description")}</span>
</div>
<ButtonV2 variant="ghost" onClick={props.onReset} disabled={!props.hasOverrides}>
<Button variant="ghost" onClick={props.onReset} disabled={!props.hasOverrides}>
{language.t("settings.shortcuts.reset.button")}
</ButtonV2>
</Button>
</div>
<div class="settings-v2-tab-search">
<TextInputV2
<TextInput
type="search"
appearance="base"
value={store.filter}
@@ -405,12 +405,12 @@ function SettingsKeybindsV2View(props: {
aria-label={language.t("settings.shortcuts.search.placeholder")}
/>
<Show when={store.filter}>
<IconButtonV2
<IconButton
type="button"
variant="ghost-muted"
size="small"
class="settings-v2-tab-search-clear"
icon={<IconV2 name="close" size="large" class="text-v2-icon-icon-muted" />}
icon={<Icon name="close" size="large" class="text-v2-icon-icon-muted" />}
onClick={() => setStore("filter", "")}
/>
</Show>
@@ -1,6 +1,6 @@
import { Component, createMemo } from "solid-js"
import { SelectV2 } from "@opencode-ai/ui/v2/select-v2"
import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2"
import { Select } from "@opencode-ai/ui/select"
import { TextInput } from "@opencode-ai/ui/text-input"
import { useLanguage } from "@/context/language"
import { ExternalLink } from "../external-link"
import { SettingsListV2 } from "./parts/list"
@@ -42,7 +42,7 @@ const FontSetting: Component<{
return (
<SettingsRowV2 title={language.t(config().title)} description={language.t(config().description)}>
<div class="w-full sm:w-[220px]">
<TextInputV2
<TextInput
data-action={config().action}
type="text"
appearance="base"
@@ -83,8 +83,7 @@ export const SettingsAppearanceV2: Component = () => {
title={language.t("settings.general.row.colorScheme.title")}
description={language.t("settings.general.row.colorScheme.description")}
>
<SelectV2
appearance="inline"
<Select
data-action="settings-color-scheme"
options={schemeOptions}
current={schemeOptions.find((option) => option === appearance.scheme.current())}
@@ -110,8 +109,7 @@ export const SettingsAppearanceV2: Component = () => {
</>
}
>
<SelectV2
appearance="inline"
<Select
data-action="settings-theme"
options={appearance.theme.options()}
current={appearance.theme.current()}
@@ -1,7 +1,7 @@
import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
import { Dialog, DialogBody, DialogFooter, DialogHeader, DialogTitle } from "@opencode-ai/ui/v2/dialog-v2"
import { DividerV2 } from "@opencode-ai/ui/v2/divider-v2"
import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2"
import { Button } from "@opencode-ai/ui/button"
import { Dialog, DialogBody, DialogFooter, DialogHeader, DialogTitle } from "@opencode-ai/ui/dialog"
import { Divider } from "@opencode-ai/ui/divider"
import { TextInput } from "@opencode-ai/ui/text-input"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { useMutation } from "@tanstack/solid-query"
import { type Component, Show, createEffect, createMemo, createSignal, onCleanup, onMount } from "solid-js"
@@ -69,12 +69,12 @@ export const DialogServerV2: Component<{
<DialogHeader hideClose={true}>
<DialogTitle>{title()}</DialogTitle>
</DialogHeader>
<DividerV2 />
<Divider />
<DialogBody class="flex w-full min-w-0 flex-1 flex-col px-4 pt-4 pb-2">
<div class="flex w-full min-w-0 flex-col gap-6">
<div class="flex w-full min-w-0 flex-col gap-2">
<label class="settings-v2-server-dialog-label">{language.t("dialog.server.add.url")}</label>
<TextInputV2
<TextInput
type="text"
appearance="large"
class="!w-full self-stretch"
@@ -92,7 +92,7 @@ export const DialogServerV2: Component<{
</div>
<div class="flex w-full min-w-0 flex-col gap-2">
<label class="settings-v2-server-dialog-label">{language.t("dialog.server.add.name")}</label>
<TextInputV2
<TextInput
type="text"
appearance="large"
class="!w-full self-stretch"
@@ -106,7 +106,7 @@ export const DialogServerV2: Component<{
<div class="grid w-full min-w-0 grid-cols-2 gap-4">
<div class="flex min-w-0 flex-col gap-2">
<label class="settings-v2-server-dialog-label">{language.t("dialog.server.add.username")}</label>
<TextInputV2
<TextInput
type="text"
appearance="large"
class="!w-full self-stretch"
@@ -119,7 +119,7 @@ export const DialogServerV2: Component<{
</div>
<div class="flex min-w-0 flex-col gap-2">
<label class="settings-v2-server-dialog-label">{language.t("dialog.server.add.password")}</label>
<TextInputV2
<TextInput
type="password"
appearance="large"
class="!w-full self-stretch"
@@ -134,12 +134,12 @@ export const DialogServerV2: Component<{
</div>
</DialogBody>
<DialogFooter>
<ButtonV2 variant="neutral" disabled={form.state.busy()} onClick={() => dialog.close()}>
<Button variant="neutral" disabled={form.state.busy()} onClick={() => dialog.close()}>
{language.t("common.cancel")}
</ButtonV2>
<ButtonV2 variant="contrast" disabled={form.state.busy()} onClick={form.submit}>
</Button>
<Button variant="contrast" disabled={form.state.busy()} onClick={form.submit}>
{submitLabel()}
</ButtonV2>
</Button>
</DialogFooter>
</Dialog>
)
@@ -1,6 +1,6 @@
import { Component, createEffect, createMemo, createSignal, startTransition } from "solid-js"
import { Dialog } from "@opencode-ai/ui/v2/dialog-v2"
import { TabsV2 } from "@opencode-ai/ui/v2/tabs-v2"
import { Dialog } from "@opencode-ai/ui/dialog"
import { Tabs } from "@opencode-ai/ui/tabs"
import { Icon } from "@opencode-ai/ui/icon"
import { useLanguage } from "@/context/language"
import { usePlatform } from "@/context/platform"
@@ -73,66 +73,66 @@ export const DialogSettings: Component<{
return (
<Dialog size="x-large" variant="settings" class="settings-v2-dialog">
<TabsV2
<Tabs
orientation="vertical"
variant="settings"
value={tab()}
onChange={(value) => void startTransition(() => setTab(value))}
class="settings-v2"
>
<TabsV2.List>
<Tabs.List>
<div class="flex flex-col justify-between h-full w-full">
<div class="flex flex-col gap-4 w-full">
{/* Group 1: Preferences */}
<div class="flex flex-col gap-1 w-full">
<TabsV2.Trigger value="general">
<Tabs.Trigger value="general">
<Icon name="sliders" />
{language.t("settings.tab.preferences")}
</TabsV2.Trigger>
<TabsV2.Trigger value="appearance">
</Tabs.Trigger>
<Tabs.Trigger value="appearance">
<Icon name="appearance" />
{language.t("settings.general.section.appearance")}
</TabsV2.Trigger>
<TabsV2.Trigger value="notifications">
</Tabs.Trigger>
<Tabs.Trigger value="notifications">
<Icon name="notifications" />
{language.t("settings.tab.notifications")}
</TabsV2.Trigger>
<TabsV2.Trigger value="shortcuts">
</Tabs.Trigger>
<Tabs.Trigger value="shortcuts">
<Icon name="keyboard" />
{language.t("settings.tab.shortcuts")}
</TabsV2.Trigger>
</Tabs.Trigger>
</div>
{/* Group 2: Environment & Workspaces */}
<div class="flex flex-col gap-1 w-full">
<TabsV2.Trigger value="servers">
<Tabs.Trigger value="servers">
<Icon name="server" />
{language.t("status.popover.tab.servers")}
</TabsV2.Trigger>
<TabsV2.Trigger value="projects">
</Tabs.Trigger>
<Tabs.Trigger value="projects">
<Icon name="folder" />
{language.t("settings.tab.projects")}
</TabsV2.Trigger>
<TabsV2.Trigger value="workspaces">
</Tabs.Trigger>
<Tabs.Trigger value="workspaces">
<Icon name="workspace-isolated" />
{language.t("settings.tab.workspaces")}
</TabsV2.Trigger>
</Tabs.Trigger>
</div>
{/* Group 3: Capabilities & Extensions */}
<div class="flex flex-col gap-1 w-full">
<TabsV2.Trigger value="providers">
<Tabs.Trigger value="providers">
<Icon name="providers" />
{language.t("settings.providers.title")}
</TabsV2.Trigger>
<TabsV2.Trigger value="models">
</Tabs.Trigger>
<Tabs.Trigger value="models">
<Icon name="models" />
{language.t("settings.models.title")}
</TabsV2.Trigger>
<TabsV2.Trigger value="extensions">
</Tabs.Trigger>
<Tabs.Trigger value="extensions">
<Icon name="extensions" />
{language.t("settings.tab.extensions")}
</TabsV2.Trigger>
</Tabs.Trigger>
</div>
</div>
@@ -141,41 +141,41 @@ export const DialogSettings: Component<{
<span>v{platform.version}</span>
</div>
</div>
</TabsV2.List>
</Tabs.List>
<TabsV2.Content value="general" class="settings-v2-panel">
<Tabs.Content value="general" class="settings-v2-panel">
<SettingsGeneral server={server()} sessionID={props.sessionID} />
</TabsV2.Content>
<TabsV2.Content value="appearance" class="settings-v2-panel">
</Tabs.Content>
<Tabs.Content value="appearance" class="settings-v2-panel">
<SettingsAppearanceV2 />
</TabsV2.Content>
<TabsV2.Content value="notifications" class="settings-v2-panel">
</Tabs.Content>
<Tabs.Content value="notifications" class="settings-v2-panel">
<SettingsNotificationsV2 />
</TabsV2.Content>
<TabsV2.Content value="shortcuts" class="settings-v2-panel">
</Tabs.Content>
<Tabs.Content value="shortcuts" class="settings-v2-panel">
<SettingsKeybinds />
</TabsV2.Content>
<TabsV2.Content value="servers" class="settings-v2-panel">
</Tabs.Content>
<Tabs.Content value="servers" class="settings-v2-panel">
<SettingsServersV2 />
</TabsV2.Content>
<TabsV2.Content value="projects" class="settings-v2-panel">
</Tabs.Content>
<Tabs.Content value="projects" class="settings-v2-panel">
<SettingsProjectsV2 />
</TabsV2.Content>
</Tabs.Content>
<SettingsServerScope directory={directory()}>
<TabsV2.Content value="workspaces" class="settings-v2-panel">
<Tabs.Content value="workspaces" class="settings-v2-panel">
<SettingsWorkspacesV2 activeDirectory={directory()} />
</TabsV2.Content>
<TabsV2.Content value="providers" class="settings-v2-panel">
</Tabs.Content>
<Tabs.Content value="providers" class="settings-v2-panel">
<SettingsProvidersV2 directory={directory()} onBack={showProviders} />
</TabsV2.Content>
<TabsV2.Content value="models" class="settings-v2-panel">
</Tabs.Content>
<Tabs.Content value="models" class="settings-v2-panel">
<SettingsModelsV2 />
</TabsV2.Content>
<TabsV2.Content value="extensions" class="settings-v2-panel">
</Tabs.Content>
<Tabs.Content value="extensions" class="settings-v2-panel">
<SettingsExtensionsV2 />
</TabsV2.Content>
</Tabs.Content>
</SettingsServerScope>
</TabsV2>
</Tabs>
</Dialog>
)
}
@@ -1,11 +1,12 @@
import { Component, For, createEffect, createMemo, createResource } from "solid-js"
import { Icon } from "@opencode-ai/ui/icon"
import { Switch } from "@opencode-ai/ui/v2/switch-v2"
import { TabsV2 } from "@opencode-ai/ui/v2/tabs-v2"
import { Switch } from "@opencode-ai/ui/switch"
import { Tabs } from "@opencode-ai/ui/tabs"
import { useLanguage } from "@/context/language"
import { useData } from "@/context/server"
import { useServerSDK } from "@/context/server-sdk"
import { useMcpToggle } from "@/context/mcp"
import { pluginLabels } from "@/utils/plugin"
import { ExternalLink } from "../external-link"
import { InlineServerSelect } from "./parts/server-select"
import "./settings-v2.css"
@@ -44,7 +45,7 @@ export const SettingsExtensionsV2: Component = () => {
() => serverSdk.connection.status() === "connected",
() => serverSdk.api.plugin.list().then((result) => result.data),
)
const plugins = createMemo<PluginRowItem[]>(() => (pluginList.latest ?? []).map((item) => ({ name: item.id })))
const plugins = createMemo<PluginRowItem[]>(() => pluginLabels(pluginList.latest ?? []).map((name) => ({ name })))
createEffect(() => {
if (serverSdk.connection.status() !== "connected") return
@@ -65,14 +66,14 @@ export const SettingsExtensionsV2: Component = () => {
</div>
<div class="settings-v2-tab-body">
<TabsV2 variant="pill" defaultValue="mcps" class="settings-v2-extensions-tabs">
<TabsV2.List>
<TabsV2.Trigger value="mcps">{language.t("settings.extensions.tab.mcps")}</TabsV2.Trigger>
<TabsV2.Trigger value="plugins">{language.t("status.popover.tab.plugins")}</TabsV2.Trigger>
<TabsV2.Trigger value="skills">{language.t("settings.extensions.tab.skills")}</TabsV2.Trigger>
</TabsV2.List>
<Tabs variant="pill" defaultValue="mcps" class="settings-v2-extensions-tabs">
<Tabs.List>
<Tabs.Trigger value="mcps">{language.t("settings.extensions.tab.mcps")}</Tabs.Trigger>
<Tabs.Trigger value="plugins">{language.t("status.popover.tab.plugins")}</Tabs.Trigger>
<Tabs.Trigger value="skills">{language.t("settings.extensions.tab.skills")}</Tabs.Trigger>
</Tabs.List>
<TabsV2.Content value="mcps">
<Tabs.Content value="mcps">
<div class="settings-v2-section">
<div class="flex items-center justify-between">
<span class="text-13-medium text-v2-text-text-base">
@@ -96,9 +97,9 @@ export const SettingsExtensionsV2: Component = () => {
</For>
</div>
</div>
</TabsV2.Content>
</Tabs.Content>
<TabsV2.Content value="plugins">
<Tabs.Content value="plugins">
<div class="settings-v2-section">
<div class="flex items-center justify-between">
<span class="text-13-medium text-v2-text-text-base">
@@ -119,9 +120,9 @@ export const SettingsExtensionsV2: Component = () => {
</For>
</div>
</div>
</TabsV2.Content>
</Tabs.Content>
<TabsV2.Content value="skills">
<Tabs.Content value="skills">
<div class="settings-v2-section">
<div class="flex items-center justify-between">
<span class="text-13-medium text-v2-text-text-base">
@@ -147,8 +148,8 @@ export const SettingsExtensionsV2: Component = () => {
</For>
</div>
</div>
</TabsV2.Content>
</TabsV2>
</Tabs.Content>
</Tabs>
</div>
</>
)
@@ -1,9 +1,9 @@
import { Component, Show, createMemo, createResource } from "solid-js"
import { createMediaQuery } from "@solid-primitives/media"
import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
import { SelectV2 } from "@opencode-ai/ui/v2/select-v2"
import { Switch } from "@opencode-ai/ui/v2/switch-v2"
import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2"
import { Button } from "@opencode-ai/ui/button"
import { Select } from "@opencode-ai/ui/select"
import { Switch } from "@opencode-ai/ui/switch"
import { TextInput } from "@opencode-ai/ui/text-input"
import { useLanguage } from "@/context/language"
import { usePlatform } from "@/context/platform"
import { useUpdaterAction } from "../updater-action"
@@ -100,8 +100,7 @@ const WorkspaceDestinationSetting: Component = () => {
title={language.t("settings.workspaces.default.title")}
description={language.t("settings.workspaces.default.description")}
>
<SelectV2
appearance="inline"
<Select
options={options()}
current={options().find((option) => option.value === settings.workspaces.defaultDestination())}
value={(option) => option.value}
@@ -127,8 +126,7 @@ const ShellSetting: Component<{ controller: ShellSettingsController }> = (props)
title={language.t("settings.general.row.shell.title")}
description={language.t("settings.general.row.shell.description")}
>
<SelectV2
appearance="inline"
<Select
data-action="settings-shell"
options={options()}
current={options().find((option) => option.value === props.controller.current()) ?? options()[0]}
@@ -156,8 +154,7 @@ const AppearanceSection: Component<{ controller: AppearanceSettingsController }>
title={language.t("settings.general.row.colorScheme.title")}
description={language.t("settings.general.row.colorScheme.description")}
>
<SelectV2
appearance="inline"
<Select
data-action="settings-color-scheme"
options={schemeOptions}
current={schemeOptions.find((option) => option === props.controller.scheme.current())}
@@ -183,8 +180,7 @@ const AppearanceSection: Component<{ controller: AppearanceSettingsController }>
</>
}
>
<SelectV2
appearance="inline"
<Select
data-action="settings-theme"
options={props.controller.theme.options()}
current={props.controller.theme.current()}
@@ -213,7 +209,7 @@ const FontSetting: Component<{
return (
<SettingsRowV2 title={language.t(config().title)} description={language.t(config().description)}>
<div class="w-full sm:w-[220px]">
<TextInputV2
<TextInput
data-action={config().action}
type="text"
appearance="base"
@@ -254,8 +250,7 @@ const SoundSetting: Component<{
const config = () => soundSettings[props.kind]
return (
<SettingsRowV2 title={language.t(config().title)} description={language.t(config().description)}>
<SelectV2
appearance="inline"
<Select
data-action={config().action}
options={soundOptions}
current={props.channel.current()}
@@ -283,8 +278,7 @@ const LanguageSetting = () => {
title={language.t("settings.general.row.language.title")}
description={language.t("settings.general.row.language.description")}
>
<SelectV2
appearance="inline"
<Select
data-action="settings-language"
options={options()}
placement="bottom-end"
@@ -500,9 +494,9 @@ export const SettingsGeneral: Component<{
title={language.t("settings.updates.row.check.title")}
description={language.t("settings.updates.row.check.description")}
>
<ButtonV2 size="normal" variant="neutral" disabled={!updater.action().run} onClick={() => updater.run()}>
<Button size="normal" variant="neutral" disabled={!updater.action().run} onClick={() => updater.run()}>
{language.t(updater.action().label)}
</ButtonV2>
</Button>
</SettingsRowV2>
</SettingsListV2>
</div>
@@ -1,9 +1,9 @@
import { useFilteredList } from "@opencode-ai/ui/hooks"
import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
import { Switch } from "@opencode-ai/ui/v2/switch-v2"
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2"
import { Switch } from "@opencode-ai/ui/switch"
import { Icon } from "@opencode-ai/ui/icon"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { TextInput } from "@opencode-ai/ui/text-input"
import { type Component, For, Show } from "solid-js"
import { createStore } from "solid-js/store"
import { useLanguage } from "@/context/language"
@@ -62,7 +62,7 @@ export const SettingsModelsV2: Component = () => {
<InlineServerSelect />
</div>
<div class="settings-v2-tab-search">
<TextInputV2
<TextInput
type="search"
appearance="base"
value={list.filter()}
@@ -75,12 +75,12 @@ export const SettingsModelsV2: Component = () => {
aria-label={language.t("dialog.model.search.placeholder")}
/>
<Show when={list.filter()}>
<IconButtonV2
<IconButton
type="button"
variant="ghost-muted"
size="small"
class="settings-v2-tab-search-clear"
icon={<IconV2 name="close" size="large" class="text-v2-icon-icon-muted" />}
icon={<Icon name="close" size="large" class="text-v2-icon-icon-muted" />}
onClick={() => list.clear()}
/>
</Show>
@@ -1,6 +1,6 @@
import { Component } from "solid-js"
import { SelectV2 } from "@opencode-ai/ui/v2/select-v2"
import { Switch } from "@opencode-ai/ui/v2/switch-v2"
import { Select } from "@opencode-ai/ui/select"
import { Switch } from "@opencode-ai/ui/switch"
import { useLanguage } from "@/context/language"
import { useSettings } from "@/context/settings"
import { SettingsListV2 } from "./parts/list"
@@ -34,8 +34,7 @@ const SoundSetting: Component<{
const config = () => soundSettings[props.kind]
return (
<SettingsRowV2 title={language.t(config().title)} description={language.t(config().description)}>
<SelectV2
appearance="inline"
<Select
data-action={config().action}
options={soundOptions}
current={props.channel.current()}
@@ -1,5 +1,5 @@
import { Show, createMemo, type Component } from "solid-js"
import { SelectV2 } from "@opencode-ai/ui/v2/select-v2"
import { Select } from "@opencode-ai/ui/select"
import { useGlobal } from "@/context/global"
import { ServerConnection, serverName } from "@/context/servers"
@@ -20,8 +20,7 @@ export const InlineServerSelect: Component<{
return (
<Show when={options().length > 1}>
<SelectV2
appearance="inline"
<Select
data-action="settings-server-select"
options={options()}
current={current()}
@@ -1,7 +1,7 @@
import { Component, For, Show, createMemo, createSignal } from "solid-js"
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
import { ProjectAvatar } from "@opencode-ai/ui/v2/project-avatar-v2"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { Icon } from "@opencode-ai/ui/icon"
import { ProjectAvatar } from "@opencode-ai/ui/project-avatar"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { useLanguage } from "@/context/language"
import { useGlobal } from "@/context/global"
@@ -51,11 +51,11 @@ export const SettingsProjectsV2: Component = () => {
<span class="text-13-medium text-v2-text-text-base truncate">{name()}</span>
</div>
<div class="flex items-center gap-2 shrink-0 opacity-0 group-hover:opacity-100 transition-opacity">
<IconButtonV2
<IconButton
type="button"
variant="ghost-muted"
size="small"
icon={<IconV2 name="settings-gear" size="small" class="text-v2-icon-icon-muted" />}
icon={<Icon name="settings-gear" size="small" class="text-v2-icon-icon-muted" />}
onClick={(event: MouseEvent) => {
event.stopPropagation()
openProjectSettings(props.project, props.server)
@@ -1,5 +1,5 @@
import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
import { Tag } from "@opencode-ai/ui/v2/badge-v2"
import { Button } from "@opencode-ai/ui/button"
import { Badge } from "@opencode-ai/ui/badge"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
import { showToast } from "@/utils/toast"
@@ -160,7 +160,7 @@ export const SettingsProvidersV2: Component<{
/>
<div class="settings-v2-provider-main">
<span class="settings-v2-provider-name truncate">{item.name}</span>
<Tag>{type(item)}</Tag>
<Badge>{type(item)}</Badge>
</div>
</div>
<Show
@@ -171,9 +171,9 @@ export const SettingsProvidersV2: Component<{
</span>
}
>
<ButtonV2 size="normal" variant="ghost-muted" onClick={() => void disconnect(item.id, item.name)}>
<Button size="normal" variant="ghost-muted" onClick={() => void disconnect(item.id, item.name)}>
{language.t("common.disconnect")}
</ButtonV2>
</Button>
</Show>
</div>
)}
@@ -199,7 +199,7 @@ export const SettingsProvidersV2: Component<{
<div class="settings-v2-provider-main">
<span class="settings-v2-provider-name">{item.name}</span>
<Show when={item.id === "opencode" || item.id === "opencode-go"}>
<Tag>{language.t("dialog.provider.tag.recommended")}</Tag>
<Badge>{language.t("dialog.provider.tag.recommended")}</Badge>
</Show>
</div>
<Show when={note(item.id)}>
@@ -207,9 +207,9 @@ export const SettingsProvidersV2: Component<{
</Show>
</div>
</div>
<ButtonV2 size="normal" variant="neutral" icon="plus" onClick={() => connect(item.id)}>
<Button size="normal" variant="neutral" icon="plus" onClick={() => connect(item.id)}>
{language.t("common.connect")}
</ButtonV2>
</Button>
</div>
)}
</For>
@@ -226,14 +226,14 @@ export const SettingsProvidersV2: Component<{
<div class="settings-v2-provider-copy">
<div class="settings-v2-provider-main">
<span class="settings-v2-provider-name">{language.t("provider.custom.title")}</span>
<Tag>{language.t("settings.providers.tag.custom")}</Tag>
<Badge>{language.t("settings.providers.tag.custom")}</Badge>
</div>
<p class="settings-v2-provider-description">
{language.t("settings.providers.custom.description")}
</p>
</div>
</div>
<ButtonV2
<Button
size="normal"
variant="neutral"
icon="plus"
@@ -246,7 +246,7 @@ export const SettingsProvidersV2: Component<{
}}
>
{language.t("common.connect")}
</ButtonV2>
</Button>
</div>
</Show>
</SettingsListV2>
@@ -1,7 +1,7 @@
import { Tag } from "@opencode-ai/ui/v2/badge-v2"
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2"
import { Badge } from "@opencode-ai/ui/badge"
import { Icon } from "@opencode-ai/ui/icon"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { TextInput } from "@opencode-ai/ui/text-input"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import fuzzysort from "fuzzysort"
import { type Component, For, Show, createMemo } from "solid-js"
@@ -61,7 +61,7 @@ export const SettingsServersV2: Component = () => {
</div>
<Show when={showSearch()}>
<div class="settings-v2-tab-search">
<TextInputV2
<TextInput
type="search"
appearance="base"
value={store.filter}
@@ -74,12 +74,12 @@ export const SettingsServersV2: Component = () => {
aria-label={language.t("dialog.server.search.placeholder")}
/>
<Show when={store.filter}>
<IconButtonV2
<IconButton
type="button"
variant="ghost-muted"
size="small"
class="settings-v2-tab-search-clear"
icon={<IconV2 name="close" size="large" class="text-v2-icon-icon-muted" />}
icon={<Icon name="close" size="large" class="text-v2-icon-icon-muted" />}
onClick={() => setStore("filter", "")}
/>
</Show>
@@ -126,7 +126,7 @@ export const SettingsServersV2: Component = () => {
</div>
<div class="settings-v2-servers-actions">
<Show when={controller.defaults.available() && isDefault()}>
<Tag>{language.t("dialog.server.status.default")}</Tag>
<Badge>{language.t("dialog.server.status.default")}</Badge>
</Show>
<ServerRowMenu server={item} domain={controller} onEdit={openEdit} />
</div>
@@ -1,5 +1,5 @@
@import "@opencode-ai/ui/v2/text-input-v2.css";
@import "@opencode-ai/ui/v2/button-v2.css";
@import "@opencode-ai/ui/text-input.css";
@import "@opencode-ai/ui/button.css";
[data-component="tabs-v2"][data-variant="settings"] {
height: 100%;
@@ -3,12 +3,12 @@ import { For, Show, createMemo } from "solid-js"
import { createStore } from "solid-js/store"
import type { SessionInfo } from "@opencode-ai/client/promise"
import { useQuery } from "@tanstack/solid-query"
import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
import { Dialog, DialogFooter, DialogHeader, DialogTitleGroup } from "@opencode-ai/ui/v2/dialog-v2"
import { Icon } from "@opencode-ai/ui/v2/icon"
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2"
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
import { Button } from "@opencode-ai/ui/button"
import { Dialog, DialogFooter, DialogHeader, DialogTitleGroup } from "@opencode-ai/ui/dialog"
import { Icon } from "@opencode-ai/ui/icon"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { Menu } from "@opencode-ai/ui/menu"
import { Tooltip } from "@opencode-ai/ui/tooltip"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { getFilename } from "@opencode-ai/util/path"
import { useLanguage } from "@/context/language"
@@ -273,33 +273,33 @@ export const SettingsWorkspacesV2: Component<{ activeDirectory?: string }> = (pr
</span>
<div class="settings-v2-workspaces-toolbar-actions">
<Show when={projects().length > 1}>
<MenuV2 placement="bottom-end" gutter={6}>
<MenuV2.Trigger class="flex h-6 max-w-48 items-center gap-1 rounded-sm px-2 text-13-medium hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none data-[expanded]:bg-v2-overlay-simple-overlay-pressed">
<Menu placement="bottom-end" gutter={6}>
<Menu.Trigger class="flex h-6 max-w-48 items-center gap-1 rounded-sm px-2 text-13-medium hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none data-[expanded]:bg-v2-overlay-simple-overlay-pressed">
<span class="min-w-0 truncate">
{projectOptions().find((option) => option.id === selectedProject())?.label}
</span>
<Icon name="chevron-down" size="small" class="shrink-0 text-v2-icon-icon-muted" />
</MenuV2.Trigger>
<MenuV2.Portal>
<MenuV2.Content>
</Menu.Trigger>
<Menu.Portal>
<Menu.Content>
<For each={projectOptions()}>
{(option) => (
<MenuV2.Item onSelect={() => setStore("project", option.id)}>
<Menu.Item onSelect={() => setStore("project", option.id)}>
<span class="min-w-0 flex-1 truncate">{option.label}</span>
<Show when={selectedProject() === option.id}>
<Icon name="check" size="small" class="shrink-0" />
</Show>
</MenuV2.Item>
</Menu.Item>
)}
</For>
</MenuV2.Content>
</MenuV2.Portal>
</MenuV2>
</Menu.Content>
</Menu.Portal>
</Menu>
</Show>
<Show when={filtered().length > 0}>
<MenuV2 placement="bottom-end" gutter={4}>
<MenuV2.Trigger
as={IconButtonV2}
<Menu placement="bottom-end" gutter={4}>
<Menu.Trigger
as={IconButton}
type="button"
variant="ghost-muted"
size="small"
@@ -307,16 +307,16 @@ export const SettingsWorkspacesV2: Component<{ activeDirectory?: string }> = (pr
disabled={!!store.transaction}
icon={<Icon name="outline-dots" size="small" />}
/>
<MenuV2.Portal>
<MenuV2.Content>
<MenuV2.Item onSelect={confirmDeleteAll}>
<Menu.Portal>
<Menu.Content>
<Menu.Item onSelect={confirmDeleteAll}>
<span class="settings-v2-workspaces-delete-all">
{language.t("settings.workspaces.deleteAll")}
</span>
</MenuV2.Item>
</MenuV2.Content>
</MenuV2.Portal>
</MenuV2>
</Menu.Item>
</Menu.Content>
</Menu.Portal>
</Menu>
</Show>
</div>
</div>
@@ -335,7 +335,7 @@ export const SettingsWorkspacesV2: Component<{ activeDirectory?: string }> = (pr
<div class="settings-v2-workspaces-row-header">
<div class="settings-v2-workspaces-copy">
<div class="settings-v2-workspaces-main">
<TooltipV2
<Tooltip
value={workspace.directory}
placement="top-start"
contentClass="max-w-[calc(100vw-32px)] break-all"
@@ -348,24 +348,21 @@ export const SettingsWorkspacesV2: Component<{ activeDirectory?: string }> = (pr
>
{workspace.directory}
</span>
</TooltipV2>
</Tooltip>
</div>
<span class="settings-v2-workspaces-meta">{sessionCount(workspace)}</span>
</div>
<div class="settings-v2-workspaces-row-actions">
<Show when={lastActive(workspace)}>
{(value) => (
<TooltipV2
value={language.t("settings.workspaces.lastActiveSession")}
placement="top-end"
>
<Tooltip value={language.t("settings.workspaces.lastActiveSession")} placement="top-end">
<span tabIndex={0} class="settings-v2-workspaces-active">
{value()}
</span>
</TooltipV2>
</Tooltip>
)}
</Show>
<IconButtonV2
<IconButton
type="button"
variant="ghost-muted"
size="small"
@@ -428,12 +425,12 @@ function DialogDeleteAllWorkspaces(props: { count: number; project: string; onDe
/>
</DialogHeader>
<DialogFooter>
<ButtonV2 type="button" variant="neutral" onClick={() => dialog.close()}>
<Button type="button" variant="neutral" onClick={() => dialog.close()}>
{language.t("common.cancel")}
</ButtonV2>
<ButtonV2 type="button" variant="danger" onClick={remove}>
</Button>
<Button type="button" variant="danger" onClick={remove}>
{language.t("settings.workspaces.deleteAll")}
</ButtonV2>
</Button>
</DialogFooter>
</Dialog>
)
@@ -486,17 +483,17 @@ function DialogDeleteWorkspace(props: {
/>
</DialogHeader>
<DialogFooter>
<ButtonV2 type="button" variant="neutral" onClick={() => dialog.close()}>
<Button type="button" variant="neutral" onClick={() => dialog.close()}>
{language.t("common.cancel")}
</ButtonV2>
<ButtonV2
</Button>
<Button
type="button"
variant="danger"
disabled={status.isPending || status.isError || status.data?.result.active}
onClick={remove}
>
{language.t("workspace.delete.button")}
</ButtonV2>
</Button>
</DialogFooter>
</Dialog>
)
@@ -6,6 +6,7 @@ import { useMcpToggle } from "@/context/mcp"
import { useWorkspaceLocation } from "@/context/location"
import { useData } from "@/context/server"
import { useServerSDK } from "@/context/server-sdk"
import { pluginLabels } from "@/utils/plugin"
const pluginEmptyMessage = (value: string, file: string): JSXElement => {
const parts = value.split(file)
@@ -38,7 +39,7 @@ export function StatusPopoverBody(props: { shown: boolean }) {
() => (props.shown ? sdk().directory : undefined),
(directory) => serverSDK.api.plugin.list({ location: { directory } }).then((result) => result.data),
)
const plugins = createMemo(() => (pluginList.latest ?? []).map((item) => item.id))
const plugins = createMemo(() => pluginLabels(pluginList.latest ?? []))
const pluginCount = createMemo(() => plugins().length)
const pluginEmpty = createMemo(() => pluginEmptyMessage(language.t("dialog.plugins.empty"), "opencode.json"))
@@ -47,10 +48,9 @@ export function StatusPopoverBody(props: { shown: boolean }) {
<Tabs
aria-label={language.t("status.popover.ariaLabel")}
class="tabs bg-background-strong rounded-xl overflow-hidden"
data-component="tabs"
data-active="mcp"
defaultValue="mcp"
variant="alt"
variant="underline"
>
<Tabs.List data-slot="tablist" class="bg-transparent border-b-0 px-4 pt-2 pb-0 gap-4 h-10">
<Tabs.Trigger value="mcp" data-slot="tab" class="text-12-regular">
@@ -110,6 +110,7 @@ export function StatusPopoverBody(props: { shown: boolean }) {
</span>
<div onClick={(event) => event.stopPropagation()}>
<Switch
appearance="standard"
checked={enabled()}
disabled={toggleMcp.isPending && toggleMcp.variables === name}
onChange={() => {
@@ -1,7 +1,6 @@
import { Button } from "@opencode-ai/ui/button"
import { Icon } from "@opencode-ai/ui/icon"
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { Popover } from "@opencode-ai/ui/popover"
import { Suspense, createMemo, createSignal, lazy, Show, type JSX } from "solid-js"
import { useLanguage } from "@/context/language"
@@ -157,7 +156,7 @@ function StatusPopoverView(props: { state: StatusPopoverState }) {
<Popover
open={props.state.shown}
onOpenChange={props.state.onOpenChange}
triggerAs={IconButtonV2}
triggerAs={IconButton}
triggerProps={{
variant: "ghost-muted",
size: "large",
@@ -167,7 +166,7 @@ function StatusPopoverView(props: { state: StatusPopoverState }) {
}}
trigger={
<div class="relative size-4">
<IconV2 name={props.state.shown ? "status-active" : "status"} />
<Icon name={props.state.shown ? "status-active" : "status"} />
<div
class={`absolute -top-1 -right-1 size-2 rounded-full border border-[var(--v2-background-bg-deep)] ${serverStatusDotClass(props.state)}`}
/>
@@ -2,8 +2,8 @@ import { createEffect, createMemo, createSignal, onCleanup, Show, type Ref } fro
import { makeEventListener } from "@solid-primitives/event-listener"
import { createResizeObserver } from "@solid-primitives/resize-observer"
import { createMutation } from "@tanstack/solid-query"
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { Icon } from "@opencode-ai/ui/icon"
import { useGlobal, useServerCtx } from "@/context/global"
import { useLanguage } from "@/context/language"
import { ServerConnection, serverName, useServers } from "@/context/servers"
@@ -281,7 +281,7 @@ export function TabNavItem(props: {
</a>
<div data-slot="tab-close">
<IconButtonV2
<IconButton
size="small"
variant="ghost-muted"
class="hover-reveal relative z-10 group-hover:opacity-100 group-data-[active=true]:opacity-100 group-data-[editing=true]:opacity-100"
@@ -290,7 +290,7 @@ export function TabNavItem(props: {
event.stopPropagation()
}}
onClick={closeTab}
icon={<IconV2 name="xmark-small" />}
icon={<Icon name="xmark-small" />}
/>
</div>
</div>
@@ -377,7 +377,7 @@ export function DraftTabItem(props: {
class="flex h-full min-w-0 flex-1 flex-row items-center gap-1.5 text-[13px] font-medium text-v2-text-text-faint group-data-[active='true']:text-v2-text-text-base [-webkit-user-drag:none]"
>
<span class="flex size-4 shrink-0 items-center justify-center">
<IconV2 name="edit" />
<Icon name="edit" />
</span>
<span
data-titlebar-tab-title
@@ -387,7 +387,7 @@ export function DraftTabItem(props: {
</span>
</a>
<div data-slot="tab-close">
<IconButtonV2
<IconButton
size="small"
variant="ghost-muted"
onPointerDown={(event) => {
@@ -400,7 +400,7 @@ export function DraftTabItem(props: {
}}
class="hover-reveal relative z-10 group-hover:opacity-100 group-data-[active=true]:opacity-100 group-data-[editing=true]:opacity-100"
onClick={closeTab}
icon={<IconV2 name="xmark-small" />}
icon={<Icon name="xmark-small" />}
aria-label={language.t("common.closeTab")}
/>
</div>
+14 -14
View File
@@ -12,10 +12,10 @@ import {
} from "solid-js"
import { createStore } from "solid-js/store"
import { useLocation, useNavigate } from "@solidjs/router"
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
import { KeybindV2 } from "@opencode-ai/ui/v2/keybind-v2"
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { Icon } from "@opencode-ai/ui/icon"
import { Keybind } from "@opencode-ai/ui/keybind"
import { Tooltip } from "@opencode-ai/ui/tooltip"
import { LayoutRoute, useLayout } from "@/context/layout"
import { usePlatform } from "@/context/platform"
@@ -330,28 +330,28 @@ export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visibl
<Show when={windows() || linux()}>
<WindowsAppMenu command={command} platform={platform} />
</Show>
<TooltipV2
<Tooltip
placement="bottom"
value={
<>
{language.t("home.title")}
<KeybindV2 keys={command.keybindParts("home.toggle")} variant="neutral" />
<Keybind keys={command.keybindParts("home.toggle")} variant="neutral" />
</>
}
class="shrink-0"
>
<IconButtonV2
<IconButton
type="button"
variant="ghost-muted"
size="large"
class="!w-9 shrink-0"
icon={<IconV2 name="grid-plus" />}
icon={<Icon name="grid-plus" />}
state={layout.route().type === "home" ? "pressed" : undefined}
onClick={toggleHome}
aria-label={language.t("home.title")}
aria-pressed={layout.route().type === "home"}
/>
</TooltipV2>
</Tooltip>
<TitlebarTabStrip
tabs={tabsStore}
@@ -368,25 +368,25 @@ export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visibl
}}
onReorder={(keys) => tabsStoreActions.reorder(keys)}
/>
<TooltipV2
<Tooltip
placement="bottom"
value={
<>
{language.t("command.session.new")}
<KeybindV2 keys={newTabTooltipKeybind(command)} variant="neutral" />
<Keybind keys={newTabTooltipKeybind(command)} variant="neutral" />
</>
}
>
<IconButtonV2
<IconButton
type="button"
variant="ghost-muted"
size="large"
class="shrink-0"
icon={<IconV2 name="plus" />}
icon={<Icon name="plus" />}
onClick={openNewTab}
aria-label={language.t("command.session.new")}
/>
</TooltipV2>
</Tooltip>
<div class="flex-1" />
<TitlebarV2Right state={v2RightState()} />
</div>
@@ -1,8 +1,7 @@
import { For, Show, type JSX } from "solid-js"
import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
import { For, type JSX } from "solid-js"
import { Menu } from "@opencode-ai/ui/menu"
import { Icon } from "@opencode-ai/ui/icon"
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { useCommand } from "@/context/command"
import { DESKTOP_MENU, desktopMenuVisible, type DesktopMenuAction, type DesktopMenuEntry } from "@/desktop-menu"
@@ -47,32 +46,32 @@ export function WindowsAppMenu(props: {
}
return (
<DropdownMenu gutter={4} modal={false} placement="bottom-start">
<Menu appearance="standard" gutter={4} modal={false} placement="bottom-start">
<div
data-component="desktop-icon-button"
class="flex h-7 w-9 shrink-0 items-center justify-center rounded-[6px] px-1"
>
<DropdownMenu.Trigger
as={IconButtonV2}
<Menu.Trigger
as={IconButton}
variant="ghost-muted"
size="large"
icon={<IconV2 name="menu" />}
icon={<Icon name="menu" />}
aria-label={language.t("desktop.menu.ariaLabel")}
onPointerDown={rememberFocus}
onKeyDown={rememberFocus}
/>
</div>
<DropdownMenu.Portal>
<DropdownMenu.Content class="desktop-app-menu">
<DropdownMenu.Group>
<DropdownMenu.GroupLabel class="desktop-app-menu-heading">OpenCode</DropdownMenu.GroupLabel>
<Menu.Portal>
<Menu.Content class="desktop-app-menu">
<Menu.Group>
<Menu.GroupLabel class="desktop-app-menu-heading">OpenCode</Menu.GroupLabel>
<For each={DESKTOP_MENU.filter((menu) => desktopMenuVisible(menu, "windows"))}>
{(menu) => (
<DesktopMenuSubmenu label={language.t(menu.labelKey)}>
<For each={menu.items?.filter((entry) => desktopMenuVisible(entry, "windows"))}>
{(entry) => {
// Static menu data: an early return keeps the union narrowing a Show fallback would lose.
if (entry.type === "separator") return <DropdownMenu.Separator />
if (entry.type === "separator") return <Menu.Separator />
return (
<DesktopMenuItem
label={entry.labelKey ? language.t(entry.labelKey) : ""}
@@ -86,36 +85,28 @@ export function WindowsAppMenu(props: {
</DesktopMenuSubmenu>
)}
</For>
</DropdownMenu.Group>
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu>
</Menu.Group>
</Menu.Content>
</Menu.Portal>
</Menu>
)
}
function DesktopMenuSubmenu(props: { label: string; children: JSX.Element }) {
return (
<DropdownMenu.Sub>
<DropdownMenu.SubTrigger>
<span data-slot="dropdown-menu-item-label">{props.label}</span>
<span data-slot="desktop-app-menu-chevron">
<Icon name="chevron-right" size="small" />
</span>
</DropdownMenu.SubTrigger>
<DropdownMenu.Portal>
<DropdownMenu.SubContent class="desktop-app-menu">{props.children}</DropdownMenu.SubContent>
</DropdownMenu.Portal>
</DropdownMenu.Sub>
<Menu.Sub>
<Menu.SubTrigger>{props.label}</Menu.SubTrigger>
<Menu.Portal>
<Menu.SubContent class="desktop-app-menu desktop-app-menu-sub">{props.children}</Menu.SubContent>
</Menu.Portal>
</Menu.Sub>
)
}
function DesktopMenuItem(props: { label: string; keybind?: string; disabled?: boolean; onSelect: () => void }) {
return (
<DropdownMenu.Item disabled={props.disabled} onSelect={props.onSelect}>
<DropdownMenu.ItemLabel>{props.label}</DropdownMenu.ItemLabel>
<Show when={props.keybind}>
<span data-slot="desktop-app-menu-keybind">{props.keybind}</span>
</Show>
</DropdownMenu.Item>
<Menu.Item disabled={props.disabled} onSelect={props.onSelect} shortcut={props.keybind}>
{props.label}
</Menu.Item>
)
}
+1 -1
View File
@@ -11,7 +11,7 @@ import { decode64 } from "@/utils/base64"
import { same } from "@/utils/same"
import { createScrollPersistence, type SessionScroll } from "./layout-scroll"
import { createPathHelpers } from "./file/path"
import type { ProjectAvatarVariant } from "@opencode-ai/ui/v2/project-avatar-v2"
import type { ProjectAvatarVariant } from "@opencode-ai/ui/project-avatar"
import { SessionStateKey } from "@/utils/server-scope"
import { createSessionKeyReader, ensureSessionKey, pruneSessionKeys } from "./layout-helpers"
import { requireServerKey } from "@/utils/session-route"
+10 -21
View File
@@ -1,6 +1,6 @@
@import "@opencode-ai/ui/styles/tailwind";
@import "@opencode-ai/session-ui/styles";
@import "@opencode-ai/ui/v2/styles/tailwind.css";
@import "@opencode-ai/ui/styles/tokens";
@import "tw-animate-css";
@font-face {
@@ -30,23 +30,22 @@
container-name: getting-started;
}
[data-component="dropdown-menu-content"].desktop-app-menu,
[data-component="dropdown-menu-sub-content"].desktop-app-menu {
[data-component="menu-v2-content"].desktop-app-menu {
min-width: 160px;
padding: 2px;
}
[data-component="dropdown-menu-content"].desktop-app-menu {
[data-component="menu-v2-content"].desktop-app-menu:not(.desktop-app-menu-sub) {
width: 160px;
}
[data-component="dropdown-menu-sub-content"].desktop-app-menu {
[data-component="menu-v2-content"].desktop-app-menu-sub {
width: max-content;
min-width: 240px;
max-width: min(320px, calc(100vw - 24px));
}
[data-component="dropdown-menu-content"].desktop-app-menu [data-slot="dropdown-menu-group-label"] {
[data-component="menu-v2-content"].desktop-app-menu [data-slot="menu-v2-group-label"] {
display: flex;
align-items: center;
height: 28px;
@@ -57,10 +56,7 @@
color: var(--text-weak);
}
[data-component="dropdown-menu-content"].desktop-app-menu [data-slot="dropdown-menu-item"],
[data-component="dropdown-menu-content"].desktop-app-menu [data-slot="dropdown-menu-sub-trigger"],
[data-component="dropdown-menu-sub-content"].desktop-app-menu [data-slot="dropdown-menu-item"],
[data-component="dropdown-menu-sub-content"].desktop-app-menu [data-slot="dropdown-menu-sub-trigger"] {
[data-component="menu-v2-content"].desktop-app-menu [data-component="menu-v2-item"] {
min-height: 28px;
padding: 0 12px;
gap: 8px;
@@ -68,12 +64,11 @@
line-height: 1;
}
[data-component="dropdown-menu-content"].desktop-app-menu [data-slot="dropdown-menu-item-label"],
[data-component="dropdown-menu-sub-content"].desktop-app-menu [data-slot="dropdown-menu-item-label"] {
[data-component="menu-v2-content"].desktop-app-menu [data-slot="menu-v2-item-content"] {
white-space: nowrap;
}
[data-slot="desktop-app-menu-keybind"] {
[data-component="menu-v2-content"].desktop-app-menu [data-slot="menu-v2-item-shortcut"] {
margin-left: auto;
color: var(--text-weak);
font-size: var(--font-size-x-small);
@@ -81,19 +76,13 @@
white-space: nowrap;
}
[data-slot="desktop-app-menu-chevron"] {
display: flex;
margin-left: auto;
color: var(--icon-base);
}
[data-component="getting-started-actions"] {
display: flex;
flex-direction: column;
gap: 0.75rem; /* gap-3 */
}
[data-component="getting-started-actions"] > [data-component="button"] {
[data-component="getting-started-actions"] > [data-component="button-v2"] {
width: 100%;
}
@@ -103,7 +92,7 @@
align-items: center;
}
[data-component="getting-started-actions"] > [data-component="button"] {
[data-component="getting-started-actions"] > [data-component="button-v2"] {
width: auto;
}
}
@@ -6,11 +6,11 @@ import { AutoScroller, Feedback, PointerActivationConstraints } from "@dnd-kit/d
import { RestrictToVerticalAxis } from "@dnd-kit/abstract/modifiers"
import { RestrictToElement } from "@dnd-kit/dom/modifiers"
import { ScrollView } from "@opencode-ai/ui/scroll-view"
import { ProjectAvatar } from "@opencode-ai/ui/v2/project-avatar-v2"
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2"
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
import { ProjectAvatar } from "@opencode-ai/ui/project-avatar"
import { Icon } from "@opencode-ai/ui/icon"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { Menu } from "@opencode-ai/ui/menu"
import { Tooltip } from "@opencode-ai/ui/tooltip"
import { getProjectAvatarVariant, type HomeProjectSelection, type LocalProject } from "@/context/layout"
import { ServerConnection } from "@/context/servers"
import { useLanguage } from "@/context/language"
@@ -82,18 +82,18 @@ export function HomeProjectsView(props: HomeProjectsViewProps) {
<div class="flex h-7 min-w-0 shrink-0 items-center justify-between pl-1.5 pr-3">
<div class="text-v2-text-text-muted [font-weight:530]">{props.language.t("home.projects")}</div>
<Show when={props.servers.length === 1 && !(props.projects.length === 0 && props.recentlyClosed.length > 0)}>
<TooltipV2 placement="bottom" value={props.language.t("home.project.add")}>
<IconButtonV2
<Tooltip placement="bottom" value={props.language.t("home.project.add")}>
<IconButton
data-action="home-add-project"
variant="ghost-muted"
size="large"
class="titlebar-icon [&_[data-slot=icon-svg]]:text-v2-icon-icon-muted"
icon={<IconV2 name="folder-add-left" />}
icon={<Icon name="folder-add-left" />}
disabled={props.serverHealth(props.servers[0])?.healthy === false}
onClick={() => props.onChooseProject(props.servers[0])}
aria-label={props.language.t("home.project.add")}
/>
</TooltipV2>
</Tooltip>
</Show>
</div>
<ScrollView data-slot="home-projects-scroll" class="min-h-0 min-w-0 shrink">
@@ -161,7 +161,7 @@ export function HomeUtilityNav(props: {
class="text-v2-text-text-faint [&>[data-slot=icon-svg]]:text-v2-icon-icon-muted"
onClick={props.onOpenSettings}
>
<IconV2 name="settings-gear" size="small" />
<Icon name="settings-gear" size="small" />
<span class={HOME_PROJECT_NAV_LABEL}>{props.language.t("sidebar.settings")}</span>
</HomeProjectNavButton>
<HomeProjectNavButton
@@ -169,7 +169,7 @@ export function HomeUtilityNav(props: {
class="text-v2-text-text-faint [&>[data-slot=icon-svg]]:text-v2-icon-icon-muted"
onClick={props.onOpenHelp}
>
<IconV2 name="help" size="small" />
<Icon name="help" size="small" />
<span class={HOME_PROJECT_NAV_LABEL}>{props.language.t("sidebar.help")}</span>
</HomeProjectNavButton>
</div>
@@ -234,7 +234,7 @@ function HomeServerRow(props: {
}}
onPointerDown={(event) => event.preventDefault()}
>
<IconV2
<Icon
name="chevron-down"
size="small"
class="transition-transform duration-150 ease-in-out"
@@ -280,17 +280,17 @@ function HomeServerRow(props: {
open={props.contextMenuOpen(contextMenuID())}
onOpenChange={(open) => props.onSetContextMenuOpen(contextMenuID(), open)}
/>
<TooltipV2 class="flex shrink-0 items-center" placement="bottom" value={props.language.t("home.project.add")}>
<IconButtonV2
<Tooltip class="flex shrink-0 items-center" placement="bottom" value={props.language.t("home.project.add")}>
<IconButton
data-action="home-add-project"
variant="ghost-muted"
size="small"
icon={<IconV2 name="folder-add-left" />}
icon={<Icon name="folder-add-left" />}
aria-label={props.language.t("home.project.add")}
disabled={props.health?.healthy === false}
onClick={() => props.onChooseProject(props.server)}
/>
</TooltipV2>
</Tooltip>
</div>
</div>
)
@@ -394,7 +394,7 @@ function HomeProjectEmpty(
disabled={unreachable()}
onClick={() => props.onChooseProject(props.server)}
>
<IconV2 name="folder-add-left" size="small" />
<Icon name="folder-add-left" size="small" />
<span class={HOME_PROJECT_NAV_LABEL}>{props.language.t("home.project.add")}</span>
</HomeProjectNavButton>
<Show when={props.items.length > 0}>
@@ -423,7 +423,7 @@ function HomeRecentlyClosedRow(
return worktree
}
return (
<TooltipV2 placement="right" value={path()}>
<Tooltip placement="right" value={path()}>
<HomeProjectNavButton
type="button"
data-component="home-recently-closed-row"
@@ -434,7 +434,7 @@ function HomeRecentlyClosedRow(
<HomeProjectAvatar project={props.project} outline />
<span class={HOME_PROJECT_NAV_LABEL}>{displayName(props.project)}</span>
</HomeProjectNavButton>
</TooltipV2>
</Tooltip>
)
}
@@ -523,55 +523,55 @@ function HomeProjectRow(
`}
data-menu={props.contextMenuOpen(contextMenuID())}
>
<MenuV2
<Menu
gutter={6}
modal={false}
placement="bottom-end"
open={props.contextMenuOpen(contextMenuID())}
onOpenChange={(open) => props.onSetContextMenuOpen(contextMenuID(), open)}
>
<MenuV2.Trigger
as={IconButtonV2}
<Menu.Trigger
as={IconButton}
data-action="home-project-menu"
variant="ghost-muted"
size="small"
icon={<IconV2 name="outline-dots" />}
icon={<Icon name="outline-dots" />}
aria-label={props.language.t("common.moreOptions")}
/>
<MenuV2.Portal>
<MenuV2.Content>
<MenuV2.Item onSelect={() => props.onOpenProjectNewSession(props.server, props.project.worktree)}>
<Menu.Portal>
<Menu.Content>
<Menu.Item onSelect={() => props.onOpenProjectNewSession(props.server, props.project.worktree)}>
{props.language.t("command.session.new")}
</MenuV2.Item>
<MenuV2.Item onSelect={() => props.onEditProject(props.server, props.project)}>
</Menu.Item>
<Menu.Item onSelect={() => props.onEditProject(props.server, props.project)}>
{props.language.t("dialog.project.edit.title")}
</MenuV2.Item>
</Menu.Item>
<Show when={props.canRevealProject(props.server)}>
<MenuV2.Item onSelect={() => props.onRevealProject(props.server, props.project)}>
<Menu.Item onSelect={() => props.onRevealProject(props.server, props.project)}>
{props.language.t(
fileManagerApp(platform.platform === "desktop" ? (platform.os ?? "unknown") : "unknown")
.actionLabel,
)}
</MenuV2.Item>
</Menu.Item>
</Show>
<MenuV2.Item
<Menu.Item
disabled={props.unseen === 0}
onSelect={() => props.onClearNotifications(props.server, props.project)}
>
{props.language.t("sidebar.project.clearNotifications")}
</MenuV2.Item>
<MenuV2.Separator />
<MenuV2.Item onSelect={() => props.onCloseProject(props.server, props.project.worktree)}>
</Menu.Item>
<Menu.Separator />
<Menu.Item onSelect={() => props.onCloseProject(props.server, props.project.worktree)}>
{props.language.t("common.close")}
</MenuV2.Item>
</MenuV2.Content>
</MenuV2.Portal>
</MenuV2>
<IconButtonV2
</Menu.Item>
</Menu.Content>
</Menu.Portal>
</Menu>
<IconButton
data-action="home-project-new-session"
variant="ghost-muted"
size="small"
icon={<IconV2 name="edit" />}
icon={<Icon name="edit" />}
aria-label={props.language.t("command.session.new")}
onClick={() => props.onOpenProjectNewSession(props.server, props.project.worktree)}
/>
@@ -2,10 +2,10 @@ import type { SessionInfo } from "@opencode-ai/client/promise"
import { createMemo, For, Show, Suspense } from "solid-js"
import { Spinner } from "@opencode-ai/ui/spinner"
import { ScrollView } from "@opencode-ai/ui/scroll-view"
import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
import { Button } from "@opencode-ai/ui/button"
import { Icon } from "@opencode-ai/ui/icon"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { Tooltip } from "@opencode-ai/ui/tooltip"
import { useLanguage } from "@/context/language"
import { ServerConnection } from "@/context/servers"
import { SessionTabAvatarView } from "@/pages/layout/session-tab-avatar"
@@ -83,7 +83,7 @@ export function HomeSessionsView(props: HomeSessionsViewProps) {
<Suspense>
<Show when={props.groups.length > 0 && props.canCreateSession}>
<div class="pointer-events-none absolute right-0 top-[84px] z-20 flex lg:top-[108px]">
<ButtonV2
<Button
data-action="home-new-session"
variant="ghost-muted"
size="normal"
@@ -92,7 +92,7 @@ export function HomeSessionsView(props: HomeSessionsViewProps) {
onClick={props.onCreateSession}
>
{props.language.t("command.session.new")}
</ButtonV2>
</Button>
</div>
</Show>
</Suspense>
@@ -273,7 +273,7 @@ function HomeSessionSearch(props: HomeSessionsViewProps) {
duration-[120ms] ease-in-out hover:bg-v2-background-bg-layer-02 focus-within:bg-v2-background-bg-layer-02
`}
>
<IconV2 name="magnifying-glass" />
<Icon name="magnifying-glass" />
<input
ref={props.onSetSearchInput}
class={`
@@ -317,12 +317,12 @@ function HomeSessionSearch(props: HomeSessionsViewProps) {
}}
/>
<Show when={props.searchValue}>
<IconButtonV2
<IconButton
type="button"
variant="ghost-muted"
size="small"
class="relative z-20 shrink-0"
icon={<IconV2 name="close" size="large" class="text-v2-icon-icon-muted" />}
icon={<Icon name="close" size="large" class="text-v2-icon-icon-muted" />}
aria-label={props.searchPlaceholder}
onClick={() => {
props.onSearchClose()
@@ -458,12 +458,12 @@ function HomeSessionRow(props: HomeSessionsViewProps & { record: HomeSessionReco
group-hover/session:opacity-100 focus-within:opacity-100
`}
>
<TooltipV2 class="flex shrink-0 items-center" placement="bottom" value={props.language.t("common.archive")}>
<IconButtonV2
<Tooltip class="flex shrink-0 items-center" placement="bottom" value={props.language.t("common.archive")}>
<IconButton
data-action="home-session-archive"
variant="ghost-muted"
size="large"
icon={<IconV2 name="archive" />}
icon={<Icon name="archive" />}
aria-label={props.language.t("common.archive")}
onClick={(event) => {
event.preventDefault()
@@ -471,7 +471,7 @@ function HomeSessionRow(props: HomeSessionsViewProps & { record: HomeSessionReco
void props.onArchiveSession(props.record.session)
}}
/>
</TooltipV2>
</Tooltip>
</div>
</Show>
</div>
@@ -525,9 +525,9 @@ function HomeSessionsEmpty(props: { onNewSession?: () => void; language: ReturnT
</p>
<Show when={props.onNewSession}>
{(onNewSession) => (
<ButtonV2 data-action="home-new-session" variant="neutral" size="normal" icon="edit" onClick={onNewSession()}>
<Button data-action="home-new-session" variant="neutral" size="normal" icon="edit" onClick={onNewSession()}>
{props.language.t("command.session.new")}
</ButtonV2>
</Button>
)}
</Show>
</div>
@@ -1,9 +1,8 @@
import type { LocalProject } from "@/context/layout"
import { getProjectAvatarVariant } from "@/context/layout"
import { getProjectAvatarVariant, type LocalProject } from "@/context/layout"
import type { ServerConnection } from "@/context/servers"
import { displayName, getProjectAvatarSource } from "@/pages/layout/helpers"
import { useSessionTabAvatarState } from "@/pages/layout/project-avatar-state"
import { ProjectAvatar } from "@opencode-ai/ui/v2/project-avatar-v2"
import { ProjectAvatar } from "@opencode-ai/ui/project-avatar"
import { SessionProgressIndicatorV2 } from "@opencode-ai/session-ui/v2/session-progress-indicator-v2"
import { Show } from "solid-js"
@@ -1,8 +1,7 @@
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { Tooltip } from "@opencode-ai/ui/tooltip"
import { Icon } from "@opencode-ai/ui/v2/icon"
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
import { WordmarkV2 } from "@opencode-ai/ui/v2/wordmark-v2"
import { Icon } from "@opencode-ai/ui/icon"
import { Wordmark } from "@opencode-ai/ui/wordmark"
import { Show, createMemo, createSignal } from "solid-js"
import { createStore } from "solid-js/store"
import { Portal } from "solid-js/web"
@@ -47,7 +46,7 @@ export function NewSessionView(props: {
>
<div class="absolute inset-x-0 top-[25.375%] flex justify-center px-6">
<div class={NEW_SESSION_CONTENT_WIDTH}>
<WordmarkV2 class="h-auto w-full text-v2-background-bg-inverse" />
<Wordmark class="h-auto w-full text-v2-background-bg-inverse" />
<div class="mt-8 flex flex-col gap-8">
<PromptInputV2Composer controller={props.input} accentSubmit={props.workspace.selection.workspace()} />
<Show when={props.project.empty()}>
@@ -96,7 +95,7 @@ export function NewSessionStatus(props: { mount: HTMLElement | null; visible: bo
{(mount) => (
<Portal mount={mount}>
<Show when={props.visible}>
<Tooltip placement="bottom" value={language.t("status.popover.trigger")}>
<Tooltip appearance="standard" placement="bottom" value={language.t("status.popover.trigger")}>
<StatusPopoverV2 />
</Tooltip>
</Show>
@@ -153,7 +152,7 @@ function ProviderTip() {
<Icon name="chevron-down" size="small" class="-rotate-90" />
</span>
</button>
<TooltipV2
<Tooltip
class="hover-reveal absolute left-full top-0 flex h-6 w-7 items-center justify-end delay-0 duration-0 group-hover/provider-tip:delay-[250ms] group-hover/provider-tip:duration-150 group-hover/provider-tip:opacity-100 focus-within:delay-0 focus-within:duration-0 focus-within:opacity-100"
placement="top"
openDelay={1000}
@@ -167,7 +166,7 @@ function ProviderTip() {
>
<Icon name="xmark-small" />
</button>
</TooltipV2>
</Tooltip>
</div>
</div>
</Show>
@@ -21,8 +21,8 @@ export function useNewSessionCommands(input: {
title: language.t("command.palette"),
hidden: true,
onSelect: async () => {
const { DialogSelectFile } = await import("@/components/dialog-select-file")
void dialog.show(() => <DialogSelectFile />)
const { DialogCommandPaletteV2 } = await import("@/components/dialog-command-palette-v2")
void dialog.show(() => <DialogCommandPaletteV2 />)
},
},
{
+6 -26
View File
@@ -30,10 +30,9 @@ import { createStore } from "solid-js/store"
import type { SessionReviewLineComment } from "@opencode-ai/session-ui/session-review"
import { ResizeHandle } from "@opencode-ai/ui/resize-handle"
import { Select } from "@opencode-ai/ui/select"
import { SelectV2 } from "@opencode-ai/ui/v2/select-v2"
import { isScrollKeyTarget, scrollKey, scrollKeyOwner } from "@opencode-ai/ui/scroll-view"
import { Tabs } from "@opencode-ai/ui/tabs"
import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
import { Button } from "@opencode-ai/ui/button"
import { createAutoScroll } from "@opencode-ai/ui/hooks"
import { previewSelectedLines } from "@opencode-ai/session-ui/pierre/selection-bridge"
import { showToast } from "@/utils/toast"
@@ -195,9 +194,9 @@ function SessionErrorFallback(props: { error: unknown; sessionID?: string; serve
</div>
)}
</Show>
<ButtonV2 variant="neutral" size="normal" icon="xmark-small" onClick={closeTab}>
<Button variant="neutral" size="normal" icon="xmark-small" onClick={closeTab}>
{language.t("session.error.notFound.closeTab")}
</ButtonV2>
</Button>
</div>
</div>
)
@@ -1039,25 +1038,6 @@ export default function Page() {
return (
<Select
options={changesOptions()}
current={reviewMode()}
label={changesLabel}
onSelect={(option) => option && controller.layout.view().review.setMode(option)}
variant="ghost"
size="small"
valueClass="text-14-medium"
/>
)
}
const changesTitleV2 = () => {
if (!canReview()) {
return null
}
return (
<SelectV2
appearance="inline"
options={changesOptions()}
current={reviewMode()}
label={changesLabel}
@@ -1161,7 +1141,7 @@ export default function Page() {
// updates such as session switches.
const reviewPanelV2Props = () => ({
get title() {
return changesTitleV2()
return changesTitle()
},
get empty() {
return reviewEmptyV2()
@@ -1752,22 +1732,22 @@ export default function Page() {
>
<Tabs.Trigger
value="session"
classes={{ button: compact ? "w-full !py-2" : "w-full" }}
classList={{
"!w-1/2 !max-w-none": true,
"!border-b-0 !border-t !border-border-weak-base [&:has([data-selected])]:!border-t-transparent": bottom,
}}
classes={{ button: compact ? "w-full !py-2" : "w-full" }}
onClick={() => setStore("mobileTab", "session")}
>
{language.t("session.tab.session")}
</Tabs.Trigger>
<Tabs.Trigger
value="changes"
classes={{ button: compact ? "w-full !py-2" : "w-full" }}
classList={{
"!w-1/2 !max-w-none !border-r-0": true,
"!border-b-0 !border-t !border-border-weak-base [&:has([data-selected])]:!border-t-transparent": bottom,
}}
classes={{ button: compact ? "w-full !py-2" : "w-full" }}
onClick={() => setStore("mobileTab", "changes")}
>
{hasReview()
@@ -1,6 +1,6 @@
import { useLanguage } from "@/context/language"
import { useCommand } from "@/context/command"
import { KeybindV2 } from "@opencode-ai/ui/v2/keybind-v2"
import { Keybind } from "@opencode-ai/ui/keybind"
import { For, createMemo } from "solid-js"
import { createStore } from "solid-js/store"
import { SessionComposerPullout } from "./session-composer-pullout"
@@ -52,7 +52,7 @@ export function SessionBackgroundDock(props: {
<span>
<span class="text-v2-text-text-muted">{moving()}</span>
<span class="pl-2">
<KeybindV2 keys={command.keybindParts("session.background")} variant="neutral" />
<Keybind keys={command.keybindParts("session.background")} variant="neutral" />
</span>
</span>
)}
@@ -1,4 +1,5 @@
import { IconButton } from "@opencode-ai/ui/icon-button"
import { Icon } from "@opencode-ai/ui/icon"
import { useSpring } from "@opencode-ai/ui/motion-spring"
import { TextReveal } from "@opencode-ai/ui/text-reveal"
import { createResizeObserver } from "@solid-primitives/resize-observer"
@@ -107,7 +108,7 @@ export function SessionComposerPullout(props: {
<IconButton
data-action={`session-${props.name}-toggle-button`}
data-collapsed={props.collapsed ? "true" : "false"}
icon="chevron-down"
icon={<Icon name="chevron-down" />}
size="normal"
variant="ghost"
style={{ transform: `rotate(${value() * 180}deg)` }}
@@ -3,6 +3,7 @@ import { createStore } from "solid-js/store"
import { Button } from "@opencode-ai/ui/button"
import { DockTray } from "@opencode-ai/ui/dock-surface"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { Icon } from "@opencode-ai/ui/icon"
import { useLanguage } from "@/context/language"
export function SessionFollowupDock(props: {
@@ -48,7 +49,7 @@ export function SessionFollowupDock(props: {
<div class="ml-auto shrink-0">
<IconButton
data-collapsed={store.collapsed ? "true" : "false"}
icon="chevron-down"
icon={<Icon name="chevron-down" />}
size="normal"
variant="ghost"
style={{ transform: `rotate(${store.collapsed ? 180 : 0}deg)` }}
@@ -79,7 +80,7 @@ export function SessionFollowupDock(props: {
<span class="min-w-0 flex-1 truncate text-13-regular text-text-strong">{item.text}</span>
<Button
size="small"
variant="secondary"
variant="neutral"
class="shrink-0"
disabled={!!props.sending}
onClick={() => props.onSend(item.id)}
@@ -38,14 +38,14 @@ export function SessionPermissionDock(props: {
{language.t("ui.permission.deny")}
</Button>
<Button
variant="secondary"
variant="neutral"
size="normal"
onClick={() => props.onDecide("always")}
disabled={props.responding}
>
{language.t("ui.permission.allowAlways")}
</Button>
<Button variant="primary" size="normal" onClick={() => props.onDecide("once")} disabled={props.responding}>
<Button variant="contrast" size="normal" onClick={() => props.onDecide("once")} disabled={props.responding}>
{language.t("ui.permission.allowOnce")}
</Button>
</div>
@@ -2,6 +2,7 @@ import { For, Show, createEffect, createMemo, onCleanup, onMount, type Component
import { createStore } from "solid-js/store"
import { useMutation } from "@tanstack/solid-query"
import { Button } from "@opencode-ai/ui/button"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { DockPrompt } from "@opencode-ai/session-ui/dock-prompt"
import { Icon } from "@opencode-ai/ui/icon"
import { useSpring } from "@opencode-ai/ui/motion-spring"
@@ -499,19 +500,14 @@ export const SessionQuestionDock: Component<{ request: FormInfo; onSubmit: () =>
</For>
</div>
</Show>
<button
type="button"
data-component="icon-button"
data-icon="chevron-down"
data-size="normal"
data-variant="ghost"
<IconButton
icon={<Icon name="chevron-down" size="small" />}
variant="ghost"
disabled={sending()}
style={{ transform: `rotate(${hidden() * 180}deg)` }}
onClick={store.minimized ? restore : minimize}
aria-label={language.t(store.minimized ? "session.question.restore" : "session.question.minimize")}
>
<Icon name="chevron-down" size="small" />
</button>
/>
</div>
</>
}
@@ -522,12 +518,12 @@ export const SessionQuestionDock: Component<{ request: FormInfo; onSubmit: () =>
</Button>
<div data-slot="question-footer-actions">
<Show when={store.tab > 0}>
<Button variant="secondary" size="large" disabled={sending()} onClick={back}>
<Button variant="neutral" size="large" disabled={sending()} onClick={back}>
{language.t("ui.common.back")}
</Button>
</Show>
<Button
variant={last() ? "primary" : "secondary"}
variant={last() ? "contrast" : "neutral"}
size="large"
disabled={sending()}
onClick={next}
@@ -1,8 +1,8 @@
import { For, Show, createEffect, createMemo } from "solid-js"
import { createStore } from "solid-js/store"
import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
import { Button } from "@opencode-ai/ui/button"
import { Icon } from "@opencode-ai/ui/icon"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { useLanguage } from "@/context/language"
export function SessionRevertDock(props: {
@@ -45,7 +45,7 @@ export function SessionRevertDock(props: {
onClick={toggle}
onKeyDown={onHeaderKeyDown}
>
<IconV2 name="outline-reset" size="normal" class="text-v2-icon-icon-muted" />
<Icon name="outline-reset" size="normal" class="text-v2-icon-icon-muted" />
<span
classList={{
"font-[440] shrink-0 cursor-default text-[13px] leading-5 tracking-[-0.04px]": true,
@@ -61,8 +61,8 @@ export function SessionRevertDock(props: {
</span>
</Show>
<div class="ml-auto shrink-0">
<IconButtonV2
icon={<IconV2 name="outline-chevron-down" size="small" />}
<IconButton
icon={<Icon name="outline-chevron-down" size="small" />}
size="large"
variant="ghost-muted"
style={{ transform: `rotate(${store.collapsed ? 180 : 0}deg)` }}
@@ -95,7 +95,7 @@ export function SessionRevertDock(props: {
<span class="min-w-0 flex-1 truncate text-[13px] font-[400] leading-5 tracking-[-0.04px] text-v2-text-text-muted">
{item.text}
</span>
<ButtonV2
<Button
size="small"
variant="neutral"
class="shrink-0"
@@ -103,7 +103,7 @@ export function SessionRevertDock(props: {
onClick={() => props.onRestore(item.id)}
>
{language.t("session.revertDock.restore")}
</ButtonV2>
</Button>
</div>
)}
</For>
+15 -15
View File
@@ -7,8 +7,8 @@ import { useFileComponent } from "@opencode-ai/ui/context/file"
import { cloneSelectedLineRange, previewSelectedLines } from "@opencode-ai/session-ui/pierre/selection-bridge"
import { createLineCommentControllerV2 } from "@opencode-ai/session-ui/v2/line-comment-annotations-v2"
import { sampledChecksum } from "@opencode-ai/util/encode"
import { LineCommentV2OverflowIcon } from "@opencode-ai/ui/v2/line-comment-v2"
import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2"
import { LineCommentOverflowIcon } from "@opencode-ai/ui/line-comment"
import { Menu } from "@opencode-ai/ui/menu"
import { Tabs } from "@opencode-ai/ui/tabs"
import { ScrollView } from "@opencode-ai/ui/scroll-view"
import { showToast } from "@/utils/toast"
@@ -26,7 +26,7 @@ type SessionFileViewProps = {
const selectionSide = (range: SelectedLineRange) => range.endSide ?? range.side ?? "additions"
function FileCommentMenuV2(props: {
function FileCommentMenu(props: {
moreLabel: string
editLabel: string
deleteLabel: string
@@ -35,17 +35,17 @@ function FileCommentMenuV2(props: {
}) {
return (
<div onMouseDown={(event) => event.stopPropagation()} onClick={(event) => event.stopPropagation()}>
<MenuV2 gutter={4}>
<MenuV2.Trigger as="button" type="button" data-slot="line-comment-v2-overflow" aria-label={props.moreLabel}>
<LineCommentV2OverflowIcon />
</MenuV2.Trigger>
<MenuV2.Portal>
<MenuV2.Content>
<MenuV2.Item onSelect={props.onEdit}>{props.editLabel}</MenuV2.Item>
<MenuV2.Item onSelect={props.onDelete}>{props.deleteLabel}</MenuV2.Item>
</MenuV2.Content>
</MenuV2.Portal>
</MenuV2>
<Menu gutter={4}>
<Menu.Trigger as="button" type="button" data-slot="line-comment-v2-overflow" aria-label={props.moreLabel}>
<LineCommentOverflowIcon />
</Menu.Trigger>
<Menu.Portal>
<Menu.Content>
<Menu.Item onSelect={props.onEdit}>{props.editLabel}</Menu.Item>
<Menu.Item onSelect={props.onDelete}>{props.deleteLabel}</Menu.Item>
</Menu.Content>
</Menu.Portal>
</Menu>
</div>
)
}
@@ -331,7 +331,7 @@ export function SessionFileView(props: SessionFileViewProps) {
},
editSubmitLabel: language.t("common.save"),
renderCommentActions: (_, controls) => (
<FileCommentMenuV2
<FileCommentMenu
moreLabel={language.t("common.moreOptions")}
editLabel={language.t("common.edit")}
deleteLabel={language.t("common.delete")}
@@ -10,9 +10,8 @@ import { IconButton } from "@opencode-ai/ui/icon-button"
import { Icon } from "@opencode-ai/ui/icon"
import { ResizeHandle } from "@opencode-ai/ui/resize-handle"
import { Mark } from "@opencode-ai/ui/logo"
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
import { KeybindV2 } from "@opencode-ai/ui/v2/keybind-v2"
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
import { Keybind } from "@opencode-ai/ui/keybind"
import { Tooltip } from "@opencode-ai/ui/tooltip"
import type { FileDiffInfo } from "@opencode-ai/client/promise"
import FileTree from "@/components/file-tree"
@@ -313,30 +312,27 @@ export function SessionSidePanel(props: {
<Show when={contextOpen()}>
<Tabs.Trigger
value="context"
onMiddleClick={() => tabs().close("context")}
closeButton={
<TooltipV2
<Tooltip
value={
<>
{language.t("common.closeTab")}
<Show when={closeTabKeybind().length > 0}>
<KeybindV2 keys={closeTabKeybind()} variant="neutral" />
<Keybind keys={closeTabKeybind()} variant="neutral" />
</Show>
</>
}
placement="bottom"
gutter={10}
>
<IconButton
icon="close-small"
variant="ghost"
class="h-5 w-5"
<Tabs.CloseButton
onClick={() => tabs().close("context")}
aria-label={language.t("common.closeTab")}
/>
</TooltipV2>
</Tooltip>
}
hideCloseButton
onMiddleClick={() => tabs().close("context")}
>
<div class="flex items-center gap-2">
<SessionContextUsage variant="indicator" />
@@ -360,30 +356,27 @@ export function SessionSidePanel(props: {
>
<Tabs.Trigger
value={SESSION_OPEN_FILE_TAB}
onMiddleClick={() => tabs().close(SESSION_OPEN_FILE_TAB)}
closeButton={
<TooltipV2
<Tooltip
value={
<>
{language.t("common.closeTab")}
<Show when={closeTabKeybind().length > 0}>
<KeybindV2 keys={closeTabKeybind()} variant="neutral" />
<Keybind keys={closeTabKeybind()} variant="neutral" />
</Show>
</>
}
placement="bottom"
gutter={10}
>
<IconButton
icon="close-small"
variant="ghost"
class="h-5 w-5"
<Tabs.CloseButton
onClick={() => tabs().close(SESSION_OPEN_FILE_TAB)}
aria-label={language.t("common.closeTab")}
/>
</TooltipV2>
</Tooltip>
}
hideCloseButton
onMiddleClick={() => tabs().close(SESSION_OPEN_FILE_TAB)}
>
<div class="flex items-center gap-1.5 italic">
<Icon name="open-file" size="small" />
@@ -394,26 +387,26 @@ export function SessionSidePanel(props: {
)}
</For>
<div class="h-full shrink-0 sticky right-0 z-10 flex items-center justify-center bg-v2-background-bg-base">
<TooltipV2
<Tooltip
value={
<>
{language.t("command.file.open")}
<Show when={openFileKeybind().length > 0}>
<KeybindV2 keys={openFileKeybind()} variant="neutral" />
<Keybind keys={openFileKeybind()} variant="neutral" />
</Show>
</>
}
placement="bottom"
class="flex items-center"
>
<IconButtonV2
<IconButton
icon={<Icon name="plus-small" />}
variant="ghost-muted"
size="large"
onClick={() => openFileBrowser()}
aria-label={language.t("command.file.open")}
/>
</TooltipV2>
</Tooltip>
</div>
</Tabs.List>
<div
@@ -503,7 +496,7 @@ export function SessionSidePanel(props: {
classList={{ "border-l border-border-weaker-base": reviewOpen() }}
>
<Tabs
variant="pill"
variant="surface"
value={fileTreeTab()}
onChange={setFileTreeTabValue}
class="h-full"
@@ -10,8 +10,9 @@ import { RestrictToElement } from "@dnd-kit/dom/modifiers"
import { Tabs } from "@opencode-ai/ui/tabs"
import { ResizeHandle } from "@opencode-ai/ui/resize-handle"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
import { KeybindV2 } from "@opencode-ai/ui/v2/keybind-v2"
import { Icon } from "@opencode-ai/ui/icon"
import { Tooltip } from "@opencode-ai/ui/tooltip"
import { Keybind } from "@opencode-ai/ui/keybind"
import { SortableTerminalTabV2 } from "@/components/session/session-sortable-terminal-tab-v2"
import { Terminal } from "@/components/terminal"
@@ -249,7 +250,7 @@ export function TerminalPanelV2(props: { stacked?: boolean } = {}) {
>
<div class="flex flex-col h-full">
<Tabs
variant="normal"
variant="panel"
value={terminal.active()}
onChange={(id) => terminal.open(id)}
class="!h-[52px] !flex-none"
@@ -266,12 +267,12 @@ export function TerminalPanelV2(props: { stacked?: boolean } = {}) {
{(pty, index) => <SortableTerminalTabV2 terminal={pty} index={index()} onClose={close} />}
</For>
<div class="h-full flex items-center justify-center">
<TooltipV2
<Tooltip
value={
<>
{language.t("command.terminal.new")}
<Show when={newTerminalKeybind().length > 0}>
<KeybindV2 keys={newTerminalKeybind()} variant="neutral" />
<Keybind keys={newTerminalKeybind()} variant="neutral" />
</Show>
</>
}
@@ -279,13 +280,12 @@ export function TerminalPanelV2(props: { stacked?: boolean } = {}) {
class="flex items-center"
>
<IconButton
icon="plus-small"
icon={<Icon name="plus-small" size="large" />}
variant="ghost"
iconSize="large"
onClick={() => terminal.new({ focus: true })}
aria-label={language.t("command.terminal.new")}
/>
</TooltipV2>
</Tooltip>
</div>
</Tabs.List>
</Tabs>
@@ -1,6 +1,6 @@
import type { SessionMessageInfo } from "@opencode-ai/client/promise"
import { DialogFooter, DialogHeader, DialogTitleGroup, DialogV2 } from "@opencode-ai/ui/v2/dialog-v2"
import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
import { DialogFooter, DialogHeader, DialogTitleGroup, Dialog } from "@opencode-ai/ui/dialog"
import { Button } from "@opencode-ai/ui/button"
import { useNavigate } from "@solidjs/router"
import { createEffect, createMemo, on } from "solid-js"
import { createStore } from "solid-js/store"
@@ -186,7 +186,7 @@ export function createTimelineController(input: { session: TimelineSessionSource
dialog.close()
}
return (
<DialogV2 fit>
<Dialog fit>
<DialogHeader hideClose>
<DialogTitleGroup
title={language.t("session.delete.title")}
@@ -194,14 +194,14 @@ export function createTimelineController(input: { session: TimelineSessionSource
/>
</DialogHeader>
<DialogFooter>
<ButtonV2 variant="ghost" onClick={() => dialog.close()}>
<Button variant="ghost" onClick={() => dialog.close()}>
{language.t("common.cancel")}
</ButtonV2>
<ButtonV2 variant="danger" onClick={confirm}>
</Button>
<Button variant="danger" onClick={confirm}>
{language.t("session.delete.button")}
</ButtonV2>
</Button>
</DialogFooter>
</DialogV2>
</Dialog>
)
}
@@ -15,13 +15,13 @@ import { createVirtualizer, defaultRangeExtractor, elementScroll, type VirtualIt
import { Card } from "@opencode-ai/ui/card"
import { MessageDivider, SessionShellMessage, type UserActions } from "@opencode-ai/session-ui/message-part"
import { DiffChanges } from "@opencode-ai/ui/diff-changes"
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2"
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
import { ProjectAvatar } from "@opencode-ai/ui/v2/project-avatar-v2"
import { Icon } from "@opencode-ai/ui/icon"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { InlineInput } from "@opencode-ai/ui/inline-input"
import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
import { Menu } from "@opencode-ai/ui/menu"
import { Tooltip } from "@opencode-ai/ui/tooltip"
import { ProjectAvatar } from "@opencode-ai/ui/project-avatar"
import { Button } from "@opencode-ai/ui/button"
import { SessionRetry } from "@opencode-ai/session-ui/session-retry"
import { isScrollKeyTarget, scrollKey, scrollKeyOwner, ScrollView } from "@opencode-ai/ui/scroll-view"
import { TextReveal } from "@opencode-ai/ui/text-reveal"
@@ -138,7 +138,7 @@ function WorkspaceMoveAction(props: {
: "flex h-[46px] w-full items-center gap-2 rounded-b-[6px] px-3 pe-9 pt-2.5 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-muted focus-visible:outline-none"
}
>
<IconV2 name="workspace-new" class="shrink-0 text-v2-icon-icon-muted" />
<Icon name="workspace-new" class="shrink-0 text-v2-icon-icon-muted" />
<span class="min-w-0 truncate">{language.t("workspace.move.title")}</span>
</SessionWorkspaceMenu>
<button
@@ -154,7 +154,7 @@ function WorkspaceMoveAction(props: {
props.onDismiss()
}}
>
<IconV2 name="xmark-small" />
<Icon name="xmark-small" />
</button>
</div>
)
@@ -203,12 +203,12 @@ function SessionSummaryPanel(props: {
gutter={-22}
class={`${row} hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none data-[expanded]:bg-v2-overlay-simple-overlay-pressed`}
>
<IconV2 name={props.local ? "monitor" : "workspace-isolated"} class="shrink-0 text-v2-icon-icon-muted" />
<Icon name={props.local ? "monitor" : "workspace-isolated"} class="shrink-0 text-v2-icon-icon-muted" />
<span class="min-w-0 flex-1 truncate text-start">{location()}</span>
<IconV2 name="chevron-down" size="small" class="shrink-0 text-v2-icon-icon-muted" />
<Icon name="chevron-down" size="small" class="shrink-0 text-v2-icon-icon-muted" />
</SessionWorkspaceMenu>
<div class={row}>
<IconV2 name="branch" class="shrink-0 text-v2-icon-icon-muted" />
<Icon name="branch" class="shrink-0 text-v2-icon-icon-muted" />
<Show
when={props.branch}
fallback={
@@ -235,13 +235,13 @@ function SessionSummaryPanel(props: {
class={`${row} hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none`}
onClick={props.onReview}
>
<IconV2 name="review" class="shrink-0 text-v2-icon-icon-muted" />
<Icon name="review" class="shrink-0 text-v2-icon-icon-muted" />
<Show when={props.diffs} fallback={<span>{language.t("session.review.loadingChanges")}</span>}>
{(diffs) => (
<Show when={diffs().length > 0} fallback={<span>{language.t("session.review.noChanges")}</span>}>
<span>{language.plural("ui.sessionTurn.diffs.changed", diffs().length)}</span>
<span class="text-v2-text-text-muted">·</span>
<DiffChanges changes={diffs()} />
<DiffChanges appearance="standard" changes={diffs()} />
</Show>
)}
</Show>
@@ -1175,11 +1175,11 @@ function MessageTimelineView(
when={workspaceSession()}
fallback={
<span class="flex size-6 shrink-0 items-center justify-center text-v2-icon-icon-muted">
<IconV2 name="monitor" />
<Icon name="monitor" />
</span>
}
>
<TooltipV2
<Tooltip
placement="bottom-start"
value={sessionDirectory()}
contentClass="max-w-[calc(100vw-32px)] break-all"
@@ -1189,9 +1189,9 @@ function MessageTimelineView(
aria-label={sessionDirectory()}
class="flex size-6 shrink-0 items-center justify-center text-v2-icon-icon-accent"
>
<IconV2 name="workspace-isolated" />
<Icon name="workspace-isolated" />
</span>
</TooltipV2>
</Tooltip>
</Show>
<Show when={parentID()}>
<button
@@ -1268,8 +1268,8 @@ function MessageTimelineView(
onOpenChange={setSummary}
>
<KobaltePopover.Trigger
as={IconButtonV2}
icon={<IconV2 name="window-analytics" />}
as={IconButton}
icon={<Icon name="window-analytics" />}
variant="ghost-muted"
size="large"
state={summaryOpen() ? "pressed" : undefined}
@@ -1300,7 +1300,7 @@ function MessageTimelineView(
)}
</Show>
<Show when={!parentID()}>
<MenuV2
<Menu
gutter={6}
placement="bottom-end"
open={title.menuOpen}
@@ -1309,9 +1309,9 @@ function MessageTimelineView(
if (open) return
}}
>
<MenuV2.Trigger
as={IconButtonV2}
icon={<IconV2 name="outline-dots" />}
<Menu.Trigger
as={IconButton}
icon={<Icon name="outline-dots" />}
variant="ghost-muted"
size="large"
state={share.open || title.pendingShare ? "pressed" : undefined}
@@ -1321,8 +1321,8 @@ function MessageTimelineView(
more = el
}}
/>
<MenuV2.Portal>
<MenuV2.Content
<Menu.Portal>
<Menu.Content
style={{ width: "120px", "min-width": "120px" }}
onCloseAutoFocus={(event) => {
if (title.pendingRename) {
@@ -1340,34 +1340,34 @@ function MessageTimelineView(
}
}}
>
<MenuV2.Item
<Menu.Item
onSelect={() => {
setTitle("pendingRename", true)
setTitle("menuOpen", false)
}}
>
{language.t("common.rename")}
</MenuV2.Item>
</Menu.Item>
<Show when={shareEnabled()}>
<MenuV2.Item
<Menu.Item
onSelect={() => {
setTitle({ pendingShare: true, menuOpen: false })
}}
>
{language.t("session.share.action.share")}...
</MenuV2.Item>
</Menu.Item>
</Show>
<MenuV2.Item onSelect={() => void props.action.export(id)}>
<Menu.Item onSelect={() => void props.action.export(id)}>
{language.t("common.export")}...
</MenuV2.Item>
</Menu.Item>
{/* TODO: Need a V2 session archive API. */}
<MenuV2.Separator />
<MenuV2.Item onSelect={() => props.action.showDelete(id)}>
<Menu.Separator />
<Menu.Item onSelect={() => props.action.showDelete(id)}>
{language.t("common.delete")}...
</MenuV2.Item>
</MenuV2.Content>
</MenuV2.Portal>
</MenuV2>
</Menu.Item>
</Menu.Content>
</Menu.Portal>
</Menu>
<KobaltePopover
open={share.open}
@@ -1415,7 +1415,7 @@ function MessageTimelineView(
<Show
when={shareUrl()}
fallback={
<ButtonV2
<Button
variant="contrast"
class="w-full"
onClick={() => void props.action.share()}
@@ -1424,7 +1424,7 @@ function MessageTimelineView(
{props.pending.share()
? language.t("session.share.action.publishing")
: language.t("session.share.action.publish")}
</ButtonV2>
</Button>
}
>
<div class="flex flex-col gap-2">
@@ -1441,26 +1441,26 @@ function MessageTimelineView(
>
{shareUrl()}
</div>
<IconButtonV2
<IconButton
type="button"
size="small"
variant="ghost-muted"
icon={<IconV2 name="outline-copy" />}
icon={<Icon name="outline-copy" />}
aria-label={language.t("session.share.copy.copyLink")}
onClick={() => void props.action.copyShareUrl()}
/>
<IconButtonV2
<IconButton
type="button"
size="small"
variant="ghost-muted"
icon={<IconV2 name="outline-square-arrow" />}
icon={<Icon name="outline-square-arrow" />}
aria-label={language.t("session.share.action.view")}
onClick={props.action.viewShare}
disabled={props.pending.unshare()}
/>
</div>
<div class="flex w-full">
<ButtonV2
<Button
variant="outline"
class="w-full"
onClick={() => void props.action.unshare()}
@@ -1469,7 +1469,7 @@ function MessageTimelineView(
{props.pending.unshare()
? language.t("session.share.action.unpublishing")
: language.t("session.share.action.unpublish")}
</ButtonV2>
</Button>
</div>
</div>
</Show>
@@ -4,9 +4,8 @@ import type { SessionStatus } from "@opencode-ai/client/promise"
import { onCleanup } from "solid-js"
import { createStore } from "solid-js/store"
import { useSessionLayout } from "./session-layout"
import { useDialog } from "@opencode-ai/ui/context"
import { useDialog, useI18n } from "@opencode-ai/ui/context"
import { DialogUsageExceeded } from "@/components/dialog-usage-exceeded"
import { useI18n } from "@opencode-ai/ui/context"
const GO_UPSELL_FREE_TIER_LAST_SEEN_AT = "go_upsell_last_seen_at"
const GO_UPSELL_FREE_TIER_DONT_SHOW = "go_upsell_dont_show"
@@ -219,8 +219,8 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
const openFile = () => {
void openDialog(
() => import("@/components/dialog-select-file"),
(x) => dialog.show(() => <x.DialogSelectFile onOpenFile={showAllFiles} />),
() => import("@/components/dialog-command-palette-v2"),
(x) => dialog.show(() => <x.DialogCommandPaletteV2 onOpenFile={showAllFiles} />),
)
}
@@ -7,7 +7,7 @@ import {
SessionReviewV2Sidebar,
} from "@opencode-ai/session-ui/v2/session-review-v2"
import { SessionReviewFilePreviewV2 } from "@opencode-ai/session-ui/v2/session-review-file-preview-v2"
import { DiffChanges } from "@opencode-ai/ui/v2/diff-changes-v2"
import { DiffChanges } from "@opencode-ai/ui/diff-changes"
import type {
SessionReviewComment,
SessionReviewCommentActions,
@@ -1,5 +1,5 @@
import { FileIcon } from "@opencode-ai/ui/file-icon"
import "@opencode-ai/ui/v2/file-tree-v2.css"
import "@opencode-ai/ui/file-tree.css"
import { getDirectory, getFilename } from "@opencode-ai/util/path"
import { createEffect, createMemo, createSignal, For, Show } from "solid-js"
import { kindChange, kindLabel, type Kind } from "@/components/file-tree-v2"
+16
View File
@@ -0,0 +1,16 @@
import { describe, expect, test } from "bun:test"
import type { PluginInfo } from "@opencode-ai/client"
import { pluginLabels } from "./plugin"
describe("pluginLabels", () => {
test("omits built-in plugins", () => {
const plugins: PluginInfo[] = [
{ id: "opencode.internal", source: { type: "builtin" }, status: "active", tui: false },
{ id: "package-plugin", source: { type: "package", package: "example" }, status: "active", tui: false },
{ id: "local-plugin", source: { type: "local", path: "/tmp/plugin.ts" }, status: "active", tui: false },
{ id: "sdk-plugin", source: { type: "sdk" }, status: "active", tui: false },
]
expect(pluginLabels(plugins)).toEqual(["package-plugin", "local-plugin", "sdk-plugin"])
})
})
+12
View File
@@ -0,0 +1,12 @@
import type { PluginInfo } from "@opencode-ai/client"
export function pluginLabel(plugin: PluginInfo) {
if (plugin.id) return plugin.id
if (plugin.source.type === "package") return plugin.source.package
if (plugin.source.type === "local") return plugin.source.path
return plugin.source.type
}
export function pluginLabels(plugins: readonly PluginInfo[]) {
return plugins.filter((plugin) => plugin.source.type !== "builtin").map(pluginLabel)
}
+14 -9
View File
@@ -1,15 +1,18 @@
import { Icon, type IconProps } from "@opencode-ai/ui/icon"
import type { ToastOptions, ToastVariant } from "@opencode-ai/ui/toast"
import { ToastV2, showToastV2, toasterV2 } from "@opencode-ai/ui/v2/toast-v2"
import { Toast, showToast, toaster, type ToastOptions } from "@opencode-ai/ui/toast"
export function ToastRegion() {
return <ToastV2.Region />
type AppToastOptions = Omit<ToastOptions, "icon"> & {
icon?: IconProps["name"]
}
export function showToast(options: ToastOptions | string) {
if (typeof options === "string") return showToastV2(options)
export function ToastRegion() {
return <Toast.Region />
}
return showToastV2({
function showAppToast(options: AppToastOptions | string) {
if (typeof options === "string") return showToast(options)
return showToast({
...options,
icon: resolveIcon(options.icon, options.variant),
actions: options.actions?.map((action) => ({
@@ -19,11 +22,13 @@ export function showToast(options: ToastOptions | string) {
})
}
export { showAppToast as showToast }
export function dismissToast(toastId: number) {
return toasterV2.dismiss(toastId)
return toaster.dismiss(toastId)
}
function resolveIcon(icon: IconProps["name"] | undefined, variant: ToastVariant | undefined) {
function resolveIcon(icon: IconProps["name"] | undefined, variant: ToastOptions["variant"]) {
const name = icon ?? (variant === "success" ? "check" : undefined)
if (!name) return
return <Icon name={name} />
+33 -38
View File
@@ -1,15 +1,15 @@
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { showToast } from "@opencode-ai/ui/toast"
import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
import { Dialog, DialogBody, DialogFooter, DialogHeader, DialogTitle } from "@opencode-ai/ui/v2/dialog-v2"
import { DividerV2 } from "@opencode-ai/ui/v2/divider-v2"
import { LoaderV2 } from "@opencode-ai/ui/v2/loader-v2"
import { RadioGroupV2, RadioItemV2 } from "@opencode-ai/ui/v2/radio-v2"
import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2"
import { Button } from "@opencode-ai/ui/button"
import { Dialog, DialogBody, DialogFooter, DialogHeader, DialogTitle } from "@opencode-ai/ui/dialog"
import { Divider } from "@opencode-ai/ui/divider"
import { Loader } from "@opencode-ai/ui/loader"
import { RadioGroup, RadioItem } from "@opencode-ai/ui/radio"
import { TextInput } from "@opencode-ai/ui/text-input"
import { createMemo, For, Show } from "solid-js"
import { createStore } from "solid-js/store"
import { useLanguage } from "@/context/language"
import { usePlatform } from "@/context/platform"
import { showToast } from "@/utils/toast"
import { useWslAddServerProbes } from "./add-server-probes"
import { useWslServers } from "./context"
import { addServerViewModel, type AddServerText } from "./settings-model"
@@ -50,7 +50,7 @@ export function DialogAddWslServer(props: DialogWslServerProps = {}) {
fallback={<div class="settings-v2-wsl-loading">{controller.loadError()}</div>}
>
<div class="settings-v2-wsl-loading">
<LoaderV2 />
<Loader />
</div>
</Show>
</Dialog>
@@ -73,7 +73,7 @@ export function DialogAddWslServer(props: DialogWslServerProps = {}) {
>
<Dialog fit class="settings-v2-wsl-dialog">
<div class="settings-v2-wsl-loading">
<LoaderV2 />
<Loader />
</div>
</Dialog>
</Show>
@@ -85,13 +85,13 @@ export function DialogAddWslServer(props: DialogWslServerProps = {}) {
{controller.view() === "main" ? language.t("wsl.server.add") : language.t("wsl.onboarding.installDistro")}
</DialogTitle>
</DialogHeader>
<DividerV2 />
<Divider />
<Show
when={controller.view() === "main"}
fallback={
<>
<DialogBody class="settings-v2-wsl-dialog-body settings-v2-wsl-catalog-picker">
<TextInputV2
<TextInput
class="settings-v2-wsl-catalog-search"
appearance="large"
placeholder={language.t("wsl.onboarding.searchDistros")}
@@ -100,7 +100,7 @@ export function DialogAddWslServer(props: DialogWslServerProps = {}) {
onInput={(event) => controller.setCatalogSearch(event.currentTarget.value)}
/>
<div class="settings-v2-wsl-catalog-list">
<RadioGroupV2
<RadioGroup
hideLabel
class="settings-v2-wsl-distro-group"
label={language.t("wsl.onboarding.installDistro")}
@@ -110,7 +110,7 @@ export function DialogAddWslServer(props: DialogWslServerProps = {}) {
>
<For each={model().filteredInstallableDistros}>
{(item) => (
<RadioItemV2
<RadioItem
class="settings-v2-wsl-distro-row settings-v2-wsl-catalog-row"
value={item.name}
disabled={model().busy}
@@ -118,23 +118,23 @@ export function DialogAddWslServer(props: DialogWslServerProps = {}) {
/>
)}
</For>
</RadioGroupV2>
</RadioGroup>
</div>
</DialogBody>
<DialogFooter>
<ButtonV2 variant="neutral" disabled={model().busy} onClick={controller.closeCatalog}>
<Button variant="neutral" disabled={model().busy} onClick={controller.closeCatalog}>
{language.t("common.cancel")}
</ButtonV2>
<ButtonV2
</Button>
<Button
variant={model().installingCatalogDistro ? "loading" : "contrast"}
disabled={!model().installingCatalogDistro && (model().busy || !model().catalogTarget)}
style={{ width: "99px" }}
onClick={controller.installCatalogDistro}
>
<Show when={model().installingCatalogDistro} fallback={language.t("wsl.onboarding.installDistro")}>
<LoaderV2 />
<Loader />
</Show>
</ButtonV2>
</Button>
</DialogFooter>
</>
}
@@ -142,14 +142,9 @@ export function DialogAddWslServer(props: DialogWslServerProps = {}) {
<DialogBody class="settings-v2-wsl-dialog-body">
<div class="settings-v2-wsl-section-header">
<span class="settings-v2-wsl-section-title">{language.t("wsl.onboarding.installedDistros")}</span>
<ButtonV2
variant="ghost-muted"
size="small"
disabled={model().busy}
onClick={controller.refreshDistros}
>
<Button variant="ghost-muted" size="small" disabled={model().busy} onClick={controller.refreshDistros}>
{language.t("wsl.onboarding.checkAgain")}
</ButtonV2>
</Button>
</div>
<Show
@@ -165,7 +160,7 @@ export function DialogAddWslServer(props: DialogWslServerProps = {}) {
}
>
<div class="settings-v2-wsl-distro-list">
<RadioGroupV2
<RadioGroup
hideLabel
class="settings-v2-wsl-distro-group"
label={language.t("wsl.onboarding.installedDistros")}
@@ -177,7 +172,7 @@ export function DialogAddWslServer(props: DialogWslServerProps = {}) {
{(item) => {
const status = () => model().distroStatuses[item.name] ?? null
return (
<RadioItemV2
<RadioItem
class={`settings-v2-wsl-distro-row${item.version === 1 ? " settings-v2-wsl-distro-row--unsupported" : ""}`}
value={item.name}
disabled={item.version === 1 || model().busy}
@@ -195,7 +190,7 @@ export function DialogAddWslServer(props: DialogWslServerProps = {}) {
)
}}
</For>
</RadioGroupV2>
</RadioGroup>
</div>
</Show>
@@ -230,19 +225,19 @@ export function DialogAddWslServer(props: DialogWslServerProps = {}) {
</DialogBody>
<DialogFooter>
<ButtonV2 variant="neutral" disabled={controller.adding()} onClick={controller.close}>
<Button variant="neutral" disabled={controller.adding()} onClick={controller.close}>
{language.t("common.cancel")}
</ButtonV2>
<ButtonV2
</Button>
<Button
variant={primaryButton().loading ? "loading" : primaryButton().variant}
disabled={!primaryButton().loading && primaryButton().disabled}
style={primaryButtonStyle()}
onClick={controller.runPrimary}
>
<Show when={primaryButton().loading} fallback={translate(language, primaryButton().label)}>
<LoaderV2 />
<Loader />
</Show>
</ButtonV2>
</Button>
</DialogFooter>
</Show>
</Dialog>
@@ -439,14 +434,14 @@ function DialogWslSetup(props: {
</Show>
</div>
<Show when={props.state === "unavailable" && props.installable}>
<ButtonV2 variant="neutral" disabled={props.busy} onClick={props.onInstall}>
<Button variant="neutral" disabled={props.busy} onClick={props.onInstall}>
{language.t("wsl.onboarding.installWsl")}
</ButtonV2>
</Button>
</Show>
<Show when={props.state !== "unavailable"}>
<ButtonV2 variant="neutral" onClick={() => dialog.close()}>
<Button variant="neutral" onClick={() => dialog.close()}>
{language.t("common.close")}
</ButtonV2>
</Button>
</Show>
</div>
</Dialog>
+40 -42
View File
@@ -1,9 +1,9 @@
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { Tag } from "@opencode-ai/ui/v2/badge-v2"
import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2"
import { Badge } from "@opencode-ai/ui/badge"
import { Button } from "@opencode-ai/ui/button"
import { Icon } from "@opencode-ai/ui/icon"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { Menu } from "@opencode-ai/ui/menu"
import { useMutation } from "@tanstack/solid-query"
import fuzzysort from "fuzzysort"
import { type Accessor, For, Show, createMemo } from "solid-js"
@@ -32,22 +32,22 @@ export function AddServerMenu(props: { onAddServer: () => void }) {
<Show
when={platform.wslServers}
fallback={
<ButtonV2 variant="ghost-muted" icon="plus" onClick={props.onAddServer}>
<Button variant="ghost-muted" icon="plus" onClick={props.onAddServer}>
{language.t("dialog.server.add.button")}
</ButtonV2>
</Button>
}
>
<MenuV2 gutter={4} modal={false} placement="bottom-end">
<MenuV2.Trigger as={ButtonV2} variant="ghost-muted" icon="plus">
<Menu gutter={4} modal={false} placement="bottom-end">
<Menu.Trigger as={Button} variant="ghost-muted" icon="plus">
{language.t("dialog.server.add.button")}
</MenuV2.Trigger>
<MenuV2.Portal>
<MenuV2.Content>
<MenuV2.Item onSelect={props.onAddServer}>{language.t("dialog.server.add.button")}</MenuV2.Item>
<MenuV2.Item onSelect={openAddWsl}>{language.t("wsl.server.add")}</MenuV2.Item>
</MenuV2.Content>
</MenuV2.Portal>
</MenuV2>
</Menu.Trigger>
<Menu.Portal>
<Menu.Content>
<Menu.Item onSelect={props.onAddServer}>{language.t("dialog.server.add.button")}</Menu.Item>
<Menu.Item onSelect={openAddWsl}>{language.t("wsl.server.add")}</Menu.Item>
</Menu.Content>
</Menu.Portal>
</Menu>
</Show>
)
}
@@ -113,54 +113,52 @@ export function WslServerSettings(props: {
</div>
<div class="settings-v2-servers-actions">
<Show when={props.domain.defaults.available() && props.domain.defaults.key() === key}>
<Tag>{language.t("dialog.server.status.default")}</Tag>
<Badge>{language.t("dialog.server.status.default")}</Badge>
</Show>
<Show when={opencodeAction()}>
{(label) => (
<ButtonV2
<Button
size="small"
disabled={busy() || request.isPending}
onClick={() => api && request.mutate(() => api.installOpencode(item.config.distro))}
>
{busy() ? language.t("wsl.server.updating") : language.t(label())}
</ButtonV2>
</Button>
)}
</Show>
<MenuV2 gutter={4} modal={false} placement="bottom-end">
<MenuV2.Trigger
as={IconButtonV2}
<Menu gutter={4} modal={false} placement="bottom-end">
<Menu.Trigger
as={IconButton}
variant="ghost-muted"
size="small"
icon={<IconV2 name="outline-dots" />}
icon={<Icon name="outline-dots" />}
aria-label={language.t("common.moreOptions")}
/>
<MenuV2.Portal>
<MenuV2.Content>
<MenuV2.Group>
<MenuV2.GroupLabel>{language.t("wsl.server.menu.label")}</MenuV2.GroupLabel>
<Menu.Portal>
<Menu.Content>
<Menu.Group>
<Menu.GroupLabel>{language.t("wsl.server.menu.label")}</Menu.GroupLabel>
<Show when={wslRuntimeRetryable(item.runtime)}>
<MenuV2.Item onSelect={() => api && request.mutate(() => api.startServer(key))}>
<Menu.Item onSelect={() => api && request.mutate(() => api.startServer(key))}>
{language.t("wsl.server.retryStart")}
</MenuV2.Item>
</Menu.Item>
</Show>
<Show when={props.domain.defaults.available() && props.domain.defaults.key() !== key}>
<MenuV2.Item onSelect={() => props.domain.defaults.set(key)}>
<Menu.Item onSelect={() => props.domain.defaults.set(key)}>
{language.t("dialog.server.menu.default")}
</MenuV2.Item>
</Menu.Item>
</Show>
<Show when={props.domain.defaults.available() && props.domain.defaults.key() === key}>
<MenuV2.Item onSelect={() => props.domain.defaults.set(null)}>
<Menu.Item onSelect={() => props.domain.defaults.set(null)}>
{language.t("dialog.server.menu.defaultRemove")}
</MenuV2.Item>
</Menu.Item>
</Show>
<MenuV2.Separator />
<MenuV2.Item onSelect={() => remove(key)}>
{language.t("dialog.server.menu.delete")}
</MenuV2.Item>
</MenuV2.Group>
</MenuV2.Content>
</MenuV2.Portal>
</MenuV2>
<Menu.Separator />
<Menu.Item onSelect={() => remove(key)}>{language.t("dialog.server.menu.delete")}</Menu.Item>
</Menu.Group>
</Menu.Content>
</Menu.Portal>
</Menu>
</div>
</div>
)
+19 -19
View File
@@ -1,44 +1,44 @@
import { beforeEach, describe, expect, test } from "bun:test"
import { createSignal, type JSX } from "solid-js"
import { showToastV2, toasterV2 } from "@opencode-ai/ui/v2/toast-v2"
import { showToast, toaster } from "@opencode-ai/ui/toast"
describe("showToastV2", () => {
describe("showToast", () => {
// The toast registry is module state, so each test starts from an empty stack.
beforeEach(() => {
toasterV2.dismiss()
toaster.dismiss()
})
test("coalesces exact active content", () => {
const first = showToastV2({ title: "Repeated error", description: "Try again" })
const second = showToastV2({ title: "Repeated error", description: "Try again" })
const different = showToastV2({ title: "Repeated error", description: "A different error" })
const first = showToast({ title: "Repeated error", description: "Try again" })
const second = showToast({ title: "Repeated error", description: "Try again" })
const different = showToast({ title: "Repeated error", description: "A different error" })
expect(second).toBe(first)
expect(different).not.toBe(first)
toasterV2.dismiss(first)
toasterV2.dismiss(different)
toaster.dismiss(first)
toaster.dismiss(different)
})
test("allows dismissed content to appear again", () => {
const first = showToastV2("Dismiss and retry")
toasterV2.dismiss(first)
const first = showToast("Dismiss and retry")
toaster.dismiss(first)
const second = showToastV2("Dismiss and retry")
const second = showToast("Dismiss and retry")
expect(second).not.toBe(first)
toasterV2.dismiss(second)
toaster.dismiss(second)
})
test("recreates matching content when it is not the topmost toast", () => {
const first = showToastV2("First toast")
const topmost = showToastV2("Topmost toast")
const repeated = showToastV2("First toast")
const first = showToast("First toast")
const topmost = showToast("Topmost toast")
const repeated = showToast("First toast")
expect(repeated).not.toBe(first)
toasterV2.dismiss(topmost)
toasterV2.dismiss(repeated)
toaster.dismiss(topmost)
toaster.dismiss(repeated)
})
test("creates no reactive computations at call time", () => {
@@ -50,7 +50,7 @@ describe("showToastV2", () => {
return undefined
}) as unknown as JSX.Element
const id = showToastV2({ description: "test", icon })
const id = showToast({ description: "test", icon })
// Resolving the icon at call time creates an ownerless computation that is
// never disposed and tracks its dependencies forever; it must only resolve
@@ -59,6 +59,6 @@ describe("showToastV2", () => {
setTick(1)
expect(reads).toBe(0)
toasterV2.dismiss(id)
toaster.dismiss(id)
})
})
+2 -1
View File
@@ -23,6 +23,7 @@
},
"dependencies": {
"@agentclientprotocol/sdk": "1.2.1",
"@clack/prompts": "1.0.0-alpha.1",
"@effect/platform-node": "catalog:",
"@opencode-ai/client": "workspace:*",
"@opencode-ai/plugin": "workspace:*",
@@ -41,8 +42,8 @@
"solid-js": "catalog:",
"tree-sitter-bash": "0.25.0",
"tree-sitter-powershell": "0.25.10",
"web-tree-sitter": "0.25.10",
"uqr": "0.1.3",
"web-tree-sitter": "0.25.10",
"ws": "8.21.0"
},
"devDependencies": {
+1 -1
View File
@@ -109,7 +109,7 @@ for (const item of targets) {
external: ["node-gyp"],
format: "esm",
minify: true,
sourcemap: "inline",
sourcemap: Script.channel === "dev" || Script.channel === "local" ? "inline" : "none",
splitting: true,
compile: {
autoloadBunfig: false,
+7 -12
View File
@@ -1,8 +1,9 @@
#!/usr/bin/env bun
import { NodeFileSystem } from "@effect/platform-node"
import { Service } from "@opencode-ai/client/effect/service"
import { ServiceStatus } from "@opencode-ai/protocol/groups/health"
import { Schema } from "effect"
import { Effect, Schema } from "effect"
import fs from "node:fs/promises"
import os from "node:os"
import path from "node:path"
@@ -63,28 +64,22 @@ try {
})
if (unauthorizedOpenApi.status !== 401)
throw new Error("Compiled service exposed application routes without authentication")
const unauthorizedStop = await fetch(new URL("/api/service/stop", info.url), {
const stopRoute = await fetch(new URL("/api/service/stop", info.url), {
method: "POST",
headers: { "content-type": "application/json" },
headers: { ...headers, "content-type": "application/json" },
body: JSON.stringify({ instanceID: info.id }),
signal: AbortSignal.timeout(5_000),
})
if (unauthorizedStop.status !== 401) throw new Error("Compiled service accepted unauthenticated stop")
if (stopRoute.status !== 404) throw new Error("Compiled service exposed the removed HTTP stop route")
const winner = processes.find((process) => process.pid === info.pid)
const loser = processes.find((process) => process.pid !== info.pid)
if (!winner || !loser) throw new Error("Compiled contenders did not elect one registered owner")
if (!(await exitsWithin(loser, 10_000))) throw new Error("Losing compiled contender did not exit")
const stopped = await Schema.decodeUnknownPromise(ServiceStatus.StopResponse)(
await fetch(new URL("/api/service/stop", info.url), {
method: "POST",
headers: { ...headers, "content-type": "application/json" },
body: JSON.stringify({ instanceID: info.id }),
signal: AbortSignal.timeout(5_000),
}).then((response) => response.json()),
await Effect.runPromise(
Service.stop({ file: registration }).pipe(Effect.provide(NodeFileSystem.layer)),
)
if (!stopped.accepted) throw new Error("Compiled service rejected exact-instance stop")
if (!(await exitsWithin(winner, 10_000))) throw new Error("Compiled service did not stop")
for (let attempt = 0; attempt < 200 && (await Bun.file(registration).exists()); attempt++) await Bun.sleep(25)
if (await Bun.file(registration).exists()) throw new Error("Compiled service registration was not removed")
+54 -12
View File
@@ -86,12 +86,37 @@ const Root = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCODE_CLI_NAME
],
}),
Spec.make("auth", {
description: "Manage authentication",
description: "manage AI providers and credentials",
commands: [
Spec.make("login", {
description: "Log in to a well-known authentication provider",
Spec.make("list", {
description: "list providers and credentials",
params: {
url: Argument.string("url").pipe(Argument.withDescription("Well-known provider URL")),
...ServerParams,
format: Flag.choice("format", ["default", "json"]).pipe(
Flag.withDescription("Output format"),
Flag.withDefault("default"),
),
},
}),
Spec.make("login", {
description: "log in to a provider",
params: {
...ServerParams,
target: Argument.string("target").pipe(
Argument.withDescription("Integration ID, name, or well-known provider URL"),
Argument.optional,
),
method: Flag.string("method").pipe(Flag.withDescription("Authentication method ID"), Flag.optional),
},
}),
Spec.make("logout", {
description: "log out from a configured provider",
params: {
...ServerParams,
target: Argument.string("target").pipe(
Argument.withDescription("Integration ID or name"),
Argument.optional,
),
},
}),
],
@@ -145,11 +170,7 @@ const Root = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCODE_CLI_NAME
description: "Export session data as JSON",
params: {
...ServerParams,
session: Flag.string("session").pipe(
Flag.withAlias("s"),
Flag.withDescription("Session ID to export to stdout"),
Flag.optional,
),
session: Argument.string("session").pipe(Argument.withDescription("Session ID to export"), Argument.optional),
sanitize: Flag.boolean("sanitize").pipe(
Flag.withDescription("Redact sensitive transcript and file data"),
Flag.withDefault(false),
@@ -254,15 +275,36 @@ const Root = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCODE_CLI_NAME
Spec.make("stop", { description: "Stop the background server" }),
Spec.make("get", {
description: "Get service configuration",
params: { key: Argument.string("key").pipe(Argument.optional) },
params: {
key: Argument.string("key").pipe(Argument.withDescription("Service setting or env"), Argument.optional),
name: Argument.string("name").pipe(
Argument.withDescription("Environment variable name"),
Argument.optional,
),
},
}),
Spec.make("set", {
description: "Set service configuration",
params: { key: Argument.string("key"), value: Argument.string("value") },
params: {
key: Argument.string("key").pipe(Argument.withDescription("Service setting or env")),
value: Argument.string("value").pipe(
Argument.withDescription("Setting value or environment variable name"),
),
nestedValue: Argument.string("env-value").pipe(
Argument.withDescription("Environment variable value"),
Argument.optional,
),
},
}),
Spec.make("unset", {
description: "Unset service configuration",
params: { key: Argument.string("key") },
params: {
key: Argument.string("key").pipe(Argument.withDescription("Service setting or env")),
name: Argument.string("name").pipe(
Argument.withDescription("Environment variable name"),
Argument.optional,
),
},
}),
],
}),
@@ -0,0 +1,153 @@
import { confirm, log, multiselect, password, select, text, type Option } from "@clack/prompts"
import { Effect } from "effect"
import type { FormAnswer, FormField, FormFields } from "@opencode-ai/client"
import { openUrl, prompt, requireInteractive } from "../../../ui/prompt"
const skip = Symbol("skip")
const custom = Symbol("custom")
export const answerForm = Effect.fn("cli.auth.form")(function* (fields: FormFields | undefined) {
if (!fields) return undefined
yield* requireInteractive("Authentication form input requires an interactive terminal")
const answer: FormAnswer = {}
for (const field of fields) {
if (!active(field, answer)) continue
const value = yield* answerField(field)
if (value !== undefined) answer[field.key] = value
}
return answer
})
export const secret = Effect.fn("cli.auth.secret")(function* (message: string) {
yield* requireInteractive("API key input requires an interactive terminal")
return yield* prompt<string>(() => password({ message, validate: (value) => (!value ? "Required" : undefined) }))
})
const answerField = Effect.fn("cli.auth.form.field")(function* (field: FormField) {
const message = field.title ?? field.key
if (field.description) log.info(field.description)
if (field.type === "external") {
log.info(field.url)
yield* openUrl(field.url)
const acknowledged = yield* prompt<boolean>(() =>
confirm({ message: message || "Continue after completing this step?", initialValue: true }),
)
if (!acknowledged) return yield* Effect.fail(new Error(`${message || "External step"} is required`))
return true
}
if (field.type === "boolean") {
if (field.required) return yield* prompt<boolean>(() => confirm({ message, initialValue: field.default ?? true }))
const options: Array<Option<boolean | typeof skip>> = [
{ value: true, label: "Yes" },
{ value: false, label: "No" },
{ value: skip, label: "Skip" },
]
const value = yield* prompt<boolean | typeof skip>(() =>
select<boolean | typeof skip>({
message,
options,
initialValue: field.default ?? skip,
}),
)
if (value === skip) return undefined
return value
}
if (field.type === "multiselect") {
const options: Array<Option<string | typeof custom>> = field.options.map((option) => ({
value: option.value,
label: option.label,
hint: option.description,
}))
if (field.custom) options.push({ value: custom, label: "Type another value" })
const values = yield* prompt<Array<string | typeof custom>>(() =>
multiselect<string | typeof custom>({
message,
options,
initialValues: field.default,
required: field.required || (field.minItems ?? 0) > 0,
}),
)
const selected = values.filter((value): value is string => value !== custom)
if (values.includes(custom)) {
selected.push(yield* prompt<string>(() => text({ message: "Enter value", validate: required })))
}
if (field.minItems !== undefined && selected.length < field.minItems) {
return yield* Effect.fail(new Error(`Select at least ${field.minItems}`))
}
if (field.maxItems !== undefined && selected.length > field.maxItems) {
return yield* Effect.fail(new Error(`Select at most ${field.maxItems}`))
}
return selected
}
if (field.type === "string" && field.options) {
const options: Array<Option<string | typeof custom | typeof skip>> = field.options.map((option) => ({
value: option.value,
label: option.label,
hint: option.description,
}))
if (field.custom) options.push({ value: custom, label: "Type your own answer" })
if (!field.required) options.push({ value: skip, label: "Skip" })
const value = yield* prompt<string | typeof custom | typeof skip>(() =>
select<string | typeof custom | typeof skip>({ message, options, initialValue: field.default }),
)
if (value === skip) return undefined
if (value !== custom) return value
}
const value = yield* prompt<string>(() =>
text({
message,
placeholder: field.type === "string" ? field.placeholder : undefined,
initialValue: field.default === undefined ? undefined : String(field.default),
validate: (input) => validateText(field, input),
}),
)
if (!value && !field.required) return undefined
if (field.type === "string") return value
return Number(value)
})
function active(field: FormField, answer: FormAnswer) {
if (field.type === "external" || !field.when) return true
return field.when.every((condition) => {
const value = answer[condition.key]
if (value === undefined) return false
const matches = Array.isArray(value) ? value.includes(String(condition.value)) : value === condition.value
return condition.op === "eq" ? matches : !matches
})
}
function required(value: string | undefined) {
return value ? undefined : "Required"
}
function validateText(field: Exclude<FormField, { type: "boolean" | "external" | "multiselect" }>, value?: string) {
if (!value) return field.required ? "Required" : undefined
if (field.type === "number" || field.type === "integer") {
const number = Number(value)
if (!Number.isFinite(number)) return "Expected a number"
if (field.type === "integer" && !Number.isInteger(number)) return "Expected an integer"
if (typeof field.minimum === "number" && number < field.minimum) return `Must be at least ${field.minimum}`
if (typeof field.maximum === "number" && number > field.maximum) return `Must be at most ${field.maximum}`
return undefined
}
if (field.minLength !== undefined && value.length < field.minLength)
return `Must be at least ${field.minLength} characters`
if (field.maxLength !== undefined && value.length > field.maxLength)
return `Must be at most ${field.maxLength} characters`
if (field.pattern) {
try {
if (!new RegExp(field.pattern).test(value)) return "Invalid format"
} catch {
return "Invalid format"
}
}
if (field.format === "uri" && !URL.canParse(value)) return "Expected a URL"
if (field.format === "email" && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) return "Expected an email address"
if (field.format === "date") {
if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) return "Expected a date"
const date = new Date(`${value}T00:00:00.000Z`)
if (Number.isNaN(date.getTime()) || date.toISOString().slice(0, 10) !== value) return "Expected a date"
}
if (field.format === "date-time" && Number.isNaN(Date.parse(value))) return "Expected a date and time"
return undefined
}
@@ -0,0 +1,51 @@
import { EOL } from "node:os"
import { Effect, Option } from "effect"
import { Commands } from "../../commands"
import { Runtime } from "../../../framework/runtime"
import { createClient, loadIntegrations } from "./shared"
import { errorMessage } from "../../../ui/prompt"
export default Runtime.handler(Commands.commands.auth.commands.list, (input) =>
list(input).pipe(
Effect.catch((error) =>
Effect.sync(() => {
process.stderr.write(errorMessage(error) + EOL)
process.exitCode = 1
}),
),
),
)
const list = Effect.fn("cli.auth.list")(function* (input) {
const client = yield* createClient({ server: Option.getOrUndefined(input.server), standalone: input.standalone })
const integrations = (yield* loadIntegrations(client)).filter((integration) => integration.connections.length > 0)
if (input.format === "json") {
process.stdout.write(
JSON.stringify(
integrations.map((integration) => ({
id: integration.id,
name: integration.name,
connections: integration.connections,
})),
null,
2,
) + EOL,
)
return
}
const rows = integrations.flatMap((integration) =>
integration.connections.map((connection) => ({
integration: integration.name,
source: connection.type === "credential" ? connection.label : connection.name,
type: connection.type === "credential" ? "stored" : "environment",
})),
)
if (rows.length === 0) {
process.stdout.write("No authenticated integrations" + EOL)
return
}
const width = Math.max(...rows.map((row) => row.integration.length)) + 2
process.stdout.write(
rows.map((row) => row.integration.padEnd(width) + row.source.padEnd(28) + row.type).join(EOL) + EOL,
)
})
+253 -38
View File
@@ -1,52 +1,267 @@
import { EOL } from "node:os"
import { Effect } from "effect"
import { Service } from "@opencode-ai/client/effect/service"
import { OpenCode, type IntegrationCommandStatusOutput, type OpenCodeClient } from "@opencode-ai/client/promise"
import { autocomplete, intro, log, outro, select, spinner, text } from "@clack/prompts"
import { Effect, Option } from "effect"
import type { FormAnswer, IntegrationInfo, OpenCodeClient } from "@opencode-ai/client"
import { Commands } from "../../commands"
import { Runtime } from "../../../framework/runtime"
import { ServiceConfig } from "../../../services/service-config"
import { handlePromptErrors, openUrl, prompt, requireInteractive } from "../../../ui/prompt"
import { answerForm, secret } from "./form"
import {
createClient,
connectMethods,
loadIntegrations,
location,
request,
resolveIntegration,
resolveMethod,
type ConnectMethod,
} from "./shared"
const location = { directory: process.cwd() }
const integrationPriority = new Map([
["opencode", 0],
["opencode-go", 1],
["openai", 2],
["github-copilot", 3],
["google", 4],
["anthropic", 5],
["openrouter", 6],
["vercel", 7],
])
export default Runtime.handler(
Commands.commands.auth.commands.login,
Effect.fn("cli.auth.login")(function* (input) {
process.stdout.write("Logging in..." + EOL + EOL)
const endpoint = yield* Service.ensure(yield* ServiceConfig.options())
const client = OpenCode.make({ baseUrl: endpoint.url, headers: Service.headers(endpoint) })
yield* request(() => client.integration.wellknown.add({ url: input.url, location }))
const integrationID = input.url.replace(/\/+$/, "")
const started = yield* request(() =>
client.integration.command.connect({ integrationID, methodID: "login", location }),
)
yield* Effect.addFinalizer(() =>
request(() =>
client.integration.command.cancel({ integrationID, attemptID: started.data.attemptID, location }),
).pipe(Effect.ignore),
)
const status = yield* wait(client, integrationID, started.data.attemptID)
if (status.status === "failed") return yield* Effect.fail(new Error(status.message))
if (status.status === "expired") return yield* Effect.fail(new Error("Authentication expired"))
process.stdout.write("Logged in" + EOL)
}),
Effect.fn("cli.auth.login")((input) =>
login({
target: Option.getOrUndefined(input.target),
method: Option.getOrUndefined(input.method),
server: Option.getOrUndefined(input.server),
standalone: input.standalone,
}).pipe(handlePromptErrors),
),
)
const wait = (
const login = Effect.fn("cli.auth.login.run")(function* (input: {
target?: string
method?: string
server?: string
standalone: boolean
}) {
if (!input.target)
yield* requireInteractive("Pass an integration ID or name when running without an interactive terminal")
intro("Connect an integration")
const client = yield* createClient({ server: input.server, standalone: input.standalone })
const integration = yield* findIntegration(client, input.target)
const methods = connectMethods(integration)
if (methods.length === 0) yield* Effect.fail(new Error(`${integration.name} has no interactive login methods`))
const method = yield* chooseMethod(methods, input.method)
const answer = method.type === "command" ? undefined : yield* answerForm(method.form)
yield* authenticate(client, integration, method, answer)
outro("Done")
})
const findIntegration = Effect.fn("cli.auth.login.integration")(function* (client: OpenCodeClient, target?: string) {
if (target && URL.canParse(target)) {
const protocol = new URL(target).protocol
if (protocol === "http:" || protocol === "https:") {
const progress = spinner()
progress.start("Discovering authentication provider...")
yield* request((signal) => client.integration.wellknown.add({ url: target, location }, { signal })).pipe(
Effect.tap(() => Effect.sync(() => progress.stop("Authentication provider discovered"))),
Effect.tapCause(() => Effect.sync(() => progress.stop("Discovery failed", 1))),
)
}
}
const integrations = yield* loadIntegrations(client)
if (target) return yield* resolveIntegration(integrations, target)
const available = integrations
.filter((integration) => connectMethods(integration).length > 0)
.toSorted(
(a, b) =>
(integrationPriority.get(a.id) ?? integrationPriority.size) -
(integrationPriority.get(b.id) ?? integrationPriority.size) ||
a.name.localeCompare(b.name) ||
a.id.localeCompare(b.id),
)
if (available.length === 0) return yield* Effect.fail(new Error("No authentication integrations are available"))
const id = yield* prompt<string>(() =>
autocomplete({
message: "Select integration",
maxItems: 8,
options: available.map((integration) => {
const option = { value: integration.id, label: integration.name, hint: integration.id }
if (integration.connections.length > 0) return { ...option, hint: "connected" }
if (integration.id === "opencode") return { ...option, hint: "recommended" }
return option
}),
}),
)
return yield* resolveIntegration(available, id)
})
const chooseMethod = Effect.fn("cli.auth.login.method")(function* (methods: ConnectMethod[], target?: string) {
if (target) return yield* resolveMethod(methods, target)
if (methods.length === 1) return methods[0]
yield* requireInteractive("Pass --method when running without an interactive terminal")
const id = yield* prompt<string>(() =>
select({
message: "Select login method",
options: methods.map((method) => {
if (method.type === "key") return { value: "key", label: method.label ?? "API key" }
return { value: method.id, label: method.label }
}),
}),
)
return yield* resolveMethod(methods, id)
})
const authenticate = Effect.fn("cli.auth.login.authenticate")(function* (
client: OpenCodeClient,
integration: IntegrationInfo,
method: ConnectMethod,
answer?: FormAnswer,
) {
if (method.type === "key") return yield* keyLogin(client, integration, method, answer)
if (method.type === "command") return yield* commandLogin(client, integration, method)
return yield* oauthLogin(client, integration, method, answer)
})
const keyLogin = Effect.fn("cli.auth.login.key")(function* (
client: OpenCodeClient,
integration: IntegrationInfo,
method: Extract<ConnectMethod, { type: "key" }>,
answer?: FormAnswer,
) {
const key = yield* secret(method.label ?? `Enter your ${integration.name} API key`)
const progress = spinner()
progress.start("Saving credential...")
yield* request((signal) =>
client.integration.connect.key({ integrationID: integration.id, key, answer, location }, { signal }),
).pipe(
Effect.tap(() => Effect.sync(() => progress.stop(`Connected to ${integration.name}`))),
Effect.tapCause(() => Effect.sync(() => progress.stop("Authentication failed", 1))),
)
})
const oauthLogin = Effect.fn("cli.auth.login.oauth")(function* (
client: OpenCodeClient,
integration: IntegrationInfo,
method: Extract<ConnectMethod, { type: "oauth" }>,
answer?: FormAnswer,
) {
const progress = spinner()
progress.start("Starting authorization...")
const started = yield* request((signal) =>
client.integration.oauth.connect(
{ integrationID: integration.id, methodID: method.id, answer, location },
{ signal },
),
).pipe(Effect.tapCause(() => Effect.sync(() => progress.stop("Authentication failed", 1))))
const attempt = started.data
yield* Effect.addFinalizer(() =>
request(() =>
client.integration.oauth.cancel(
{ integrationID: integration.id, attemptID: attempt.attemptID, location },
{ signal: AbortSignal.timeout(5_000) },
),
).pipe(Effect.ignore),
)
progress.stop("Authorization started")
log.info(attempt.instructions)
log.info(attempt.url)
if (process.stdin.isTTY && process.stdout.isTTY) yield* openUrl(attempt.url)
if (attempt.mode === "code") {
yield* requireInteractive("This login requires an interactive terminal to enter the authorization code")
const code = yield* prompt<string>(() =>
text({ message: "Paste the authorization code", validate: (value) => (!value ? "Required" : undefined) }),
)
const completing = spinner()
completing.start("Completing authorization...")
yield* request((signal) =>
client.integration.oauth.complete(
{ integrationID: integration.id, attemptID: attempt.attemptID, code, location },
{ signal },
),
).pipe(
Effect.tap(() => Effect.sync(() => completing.stop(`Connected to ${integration.name}`))),
Effect.tapCause(() => Effect.sync(() => completing.stop("Authentication failed", 1))),
)
return
}
const waiting = spinner()
waiting.start("Waiting for authorization...")
const status = yield* waitForOAuth(client, integration.id, attempt.attemptID).pipe(
Effect.tapCause(() => Effect.sync(() => waiting.stop("Authentication failed", 1))),
)
if (status.status === "complete") {
waiting.stop(`Connected to ${integration.name}`)
return
}
waiting.stop("Authentication failed", 1)
if (status.status === "failed") yield* Effect.fail(new Error(status.message))
yield* Effect.fail(new Error("Authorization expired"))
})
const commandLogin = Effect.fn("cli.auth.login.command")(function* (
client: OpenCodeClient,
integration: IntegrationInfo,
method: Extract<ConnectMethod, { type: "command" }>,
) {
const progress = spinner()
progress.start("Starting authentication command...")
const started = yield* request((signal) =>
client.integration.command.connect({ integrationID: integration.id, methodID: method.id, location }, { signal }),
).pipe(Effect.tapCause(() => Effect.sync(() => progress.stop("Authentication failed", 1))))
yield* Effect.addFinalizer(() =>
request(() =>
client.integration.command.cancel(
{
integrationID: integration.id,
attemptID: started.data.attemptID,
location,
},
{ signal: AbortSignal.timeout(5_000) },
),
).pipe(Effect.ignore),
)
const status = yield* waitForCommand(client, integration.id, started.data.attemptID, (message) =>
progress.message(message.trim() || "Waiting for authentication command..."),
).pipe(Effect.tapCause(() => Effect.sync(() => progress.stop("Authentication failed", 1))))
if (status.status === "complete") {
progress.stop(`Connected to ${integration.name}`)
return
}
progress.stop("Authentication failed", 1)
if (status.status === "failed") yield* Effect.fail(new Error(status.message))
yield* Effect.fail(new Error("Authentication expired"))
})
const waitForOAuth = Effect.fn("cli.auth.login.oauth.wait")(function* (
client: OpenCodeClient,
integrationID: string,
attemptID: string,
shown = false,
): Effect.Effect<Exclude<IntegrationCommandStatusOutput["data"], { status: "pending" }>, unknown> =>
Effect.gen(function* () {
const response = yield* request(() => client.integration.command.status({ integrationID, attemptID, location }))
) {
while (true) {
const response = yield* request((signal) =>
client.integration.oauth.status({ integrationID, attemptID, location }, { signal }),
)
if (response.data.status !== "pending") return response.data
const output = response.data.message?.trim()
if (!shown && output) process.stdout.write(output + EOL + EOL)
yield* Effect.sleep(500)
return yield* wait(client, integrationID, attemptID, shown || !!output)
})
}
})
function request<A>(task: () => Promise<A>) {
return Effect.tryPromise({ try: task, catch: (cause) => cause })
}
const waitForCommand = Effect.fn("cli.auth.login.command.wait")(function* (
client: OpenCodeClient,
integrationID: string,
attemptID: string,
update: (message: string) => void,
) {
while (true) {
const response = yield* request((signal) =>
client.integration.command.status({ integrationID, attemptID, location }, { signal }),
)
if (response.data.status !== "pending") return response.data
if (response.data.message) update(response.data.message)
yield* Effect.sleep(500)
}
})
@@ -0,0 +1,83 @@
import { autocomplete, intro, outro, spinner } from "@clack/prompts"
import { Effect, Option } from "effect"
import type { IntegrationInfo } from "@opencode-ai/client"
import { Commands } from "../../commands"
import { Runtime } from "../../../framework/runtime"
import { handlePromptErrors, prompt, requireInteractive } from "../../../ui/prompt"
import { createClient, loadIntegrations, location, request, resolveIntegration } from "./shared"
export default Runtime.handler(
Commands.commands.auth.commands.logout,
Effect.fn("cli.auth.logout")((input) =>
logout({
target: Option.getOrUndefined(input.target),
server: Option.getOrUndefined(input.server),
standalone: input.standalone,
}).pipe(handlePromptErrors),
),
)
const logout = Effect.fn("cli.auth.logout.run")(function* (input: {
target?: string
server?: string
standalone: boolean
}) {
if (!input.target)
yield* requireInteractive("Pass an integration ID or name when running without an interactive terminal")
intro("Remove credential")
const client = yield* createClient({ server: input.server, standalone: input.standalone })
const integrations = yield* loadIntegrations(client)
const integration = yield* chooseIntegration(integrations, input.target)
const credentials = integration.connections.filter((connection) => connection.type === "credential")
if (credentials.length === 0) {
const environment = integration.connections
.filter((connection) => connection.type === "env")
.map((connection) => connection.name)
if (environment.length) {
yield* Effect.fail(
new Error(
`${integration.name} is authenticated through ${environment.join(", ")}; unset the environment variable to disconnect`,
),
)
}
yield* Effect.fail(new Error(`No stored credentials for ${integration.name}`))
}
const progress = spinner()
progress.start("Removing credential...")
yield* Effect.forEach(
credentials,
(connection) =>
request((signal) => client.credential.remove({ credentialID: connection.id, location }, { signal })),
{ concurrency: "unbounded", discard: true },
).pipe(
Effect.tap(() => Effect.sync(() => progress.stop(`Disconnected from ${integration.name}`))),
Effect.tapCause(() => Effect.sync(() => progress.stop("Failed to remove credential", 1))),
)
outro("Done")
})
const chooseIntegration = Effect.fn("cli.auth.logout.integration")(function* (
integrations: IntegrationInfo[],
target?: string,
) {
if (target) return yield* resolveIntegration(integrations, target)
const configured = integrations.filter((integration) =>
integration.connections.some((connection) => connection.type === "credential"),
)
if (configured.length === 0) return yield* Effect.fail(new Error("No stored credentials found"))
const id = yield* prompt<string>(() =>
autocomplete({
message: "Select integration",
maxItems: 8,
options: configured.map((integration) => ({
value: integration.id,
label: integration.name,
hint: integration.connections
.filter((connection) => connection.type === "credential")
.map((connection) => connection.label)
.join(", "),
})),
}),
)
return yield* resolveIntegration(configured, id)
})
@@ -0,0 +1,64 @@
import { Effect } from "effect"
import { OpenCode, type IntegrationInfo, type IntegrationMethod, type OpenCodeClient } from "@opencode-ai/client"
import { Service } from "@opencode-ai/client/effect/service"
import { ServerConnection } from "../../../services/server-connection"
export const location = { directory: process.cwd() }
export const createClient = Effect.fn("cli.auth.client")(function* (input: ServerConnection.Args) {
const server = yield* ServerConnection.resolve(input)
return OpenCode.make({ baseUrl: server.endpoint.url, headers: Service.headers(server.endpoint) })
})
export function request<A>(run: (signal: AbortSignal) => Promise<A>) {
return Effect.tryPromise({ try: run, catch: (cause) => cause })
}
export const loadIntegrations = Effect.fn("cli.auth.integrations")(function* (client: OpenCodeClient) {
// The model endpoint is the existing public readiness boundary for the initial plugin generation.
yield* request((signal) => client.model.default({ location }, { signal }))
return yield* request((signal) => client.integration.list({ location }, { signal })).pipe(
Effect.map((response) => response.data),
)
})
export const resolveIntegration = Effect.fn("cli.auth.resolve-integration")(function* (
integrations: IntegrationInfo[],
target: string,
) {
const normalized = target.replace(/\/+$/, "")
const byID = integrations.find((integration) => integration.id === normalized)
if (byID) return byID
const matches = integrations.filter((integration) => integration.name.toLowerCase() === normalized.toLowerCase())
if (matches.length === 1) return matches[0]
if (matches.length > 1) {
return yield* Effect.fail(
new Error(
`Integration name "${target}" is ambiguous: ${matches.map((integration) => integration.id).join(", ")}`,
),
)
}
return yield* Effect.fail(new Error(`Integration not found: ${target}`))
})
export type ConnectMethod = Exclude<IntegrationMethod, { type: "env" }>
export function connectMethods(integration: IntegrationInfo) {
return integration.methods
.filter((method): method is ConnectMethod => method.type !== "env")
.toSorted((a, b) => Number(a.type === "key") - Number(b.type === "key"))
}
export const resolveMethod = Effect.fn("cli.auth.resolve-method")(function* (methods: ConnectMethod[], target: string) {
const normalized = target.toLowerCase()
const matches = methods.filter((method) => {
if (method.type === "key") return normalized === "key" || method.label?.toLowerCase() === normalized
return method.id === target || method.label.toLowerCase() === normalized
})
if (matches.length === 1) return matches[0]
if (matches.length > 1) return yield* Effect.fail(new Error(`Authentication method "${target}" is ambiguous`))
const available = methods.map((method) => (method.type === "key" ? "key" : method.id)).join(", ")
return yield* Effect.fail(
new Error(`Authentication method not found: ${target}${available ? `. Available: ${available}` : ""}`),
)
})
+11 -1
View File
@@ -4,7 +4,7 @@ import { run } from "@opencode-ai/tui"
import { Commands } from "../commands"
import { Runtime } from "../../framework/runtime"
import { Config } from "../../config"
import { Context, Effect, FileSystem, Option } from "effect"
import { Context, Effect, FileSystem, Option, Queue } from "effect"
import { ServerConnection } from "../../services/server-connection"
import { Updater } from "../../services/updater"
import { UpdatePreflight } from "../../services/update-preflight"
@@ -19,11 +19,21 @@ export default Runtime.handler(Commands, (input) =>
if (requestedDirectory !== undefined) process.chdir(requestedDirectory)
const preflight = UpdatePreflight.make()
yield* Effect.addFinalizer(() => Effect.promise(() => preflight.close()))
const serviceStarts = yield* Queue.unbounded<{
readonly reason: "missing" | "version-mismatch"
readonly previousVersion?: string
}>()
yield* Queue.take(serviceStarts).pipe(
Effect.flatMap((event) => Effect.logInfo("background service starting", event)),
Effect.forever,
Effect.forkScoped,
)
const server = yield* ServerConnection.resolve({
server: requestedServer,
standalone: input.standalone,
mismatch: "replace",
onStart: (reason, previousVersion) => {
Queue.offerUnsafe(serviceStarts, { reason, previousVersion })
if (reason === "version-mismatch" && preflight.begin(previousVersion)) return
process.stderr.write(
reason === "version-mismatch"
+78 -130
View File
@@ -1,140 +1,88 @@
import { OpenCode, type SessionInfo } from "@opencode-ai/client"
import { autocomplete, cancel, intro, isCancel, log, outro } from "@clack/prompts"
import { OpenCode } from "@opencode-ai/client"
import { Service } from "@opencode-ai/client/effect/service"
import { Effect, Option } from "effect"
import { EOL, tmpdir } from "node:os"
import path from "node:path"
import { emitKeypressEvents, type Key } from "node:readline"
import { EOL } from "node:os"
import { Commands } from "../commands"
import { Runtime } from "../../framework/runtime"
import { ServerConnection } from "../../services/server-connection"
import { errorMessage } from "../../ui/prompt"
export default Runtime.handler(
Commands.commands.export,
Effect.fn("cli.export")(function* (input) {
const server = yield* ServerConnection.resolve({
server: Option.getOrUndefined(input.server),
standalone: input.standalone,
})
const client = OpenCode.make({
baseUrl: server.endpoint.url,
headers: Service.headers(server.endpoint),
})
const requested = Option.getOrUndefined(input.session)
const selected = requested
? undefined
: yield* Effect.promise(async () => {
const location = await client.location.get({ location: { directory: process.cwd() } })
const page = await client.session.list({
directory: location.directory,
workspace: location.workspaceID,
parentID: null,
order: "desc",
limit: 50,
Effect.fn("cli.export")((input) =>
Effect.gen(function* () {
const requested = Option.getOrUndefined(input.session)
if (!requested && !process.stdin.isTTY) {
yield* Effect.fail(new Error("Pass a session ID when running without an interactive terminal"))
}
const server = yield* ServerConnection.resolve({
server: Option.getOrUndefined(input.server),
standalone: input.standalone,
})
const client = OpenCode.make({
baseUrl: server.endpoint.url,
headers: Service.headers(server.endpoint),
})
const sessionID = requested
? requested
: yield* Effect.gen(function* () {
intro("Export session", { output: process.stderr })
const location = yield* Effect.tryPromise({
try: () => client.location.get({ location: { directory: process.cwd() } }),
catch: (cause) => cause,
})
const page = yield* Effect.tryPromise({
try: () =>
client.session.list({
directory: location.directory,
workspace: location.workspaceID,
parentID: null,
order: "desc",
limit: 50,
}),
catch: (cause) => cause,
})
if (page.data.length === 0) {
log.error("No sessions found", { output: process.stderr })
outro("Done", { output: process.stderr })
return undefined
}
const selected = yield* Effect.tryPromise({
try: () =>
autocomplete({
message: "Select session to export",
maxItems: 10,
options: page.data.map((session) => ({
label: session.title,
value: session.id,
hint: `${new Date(session.time.updated).toLocaleString()} - ${session.id.slice(-8)}`,
})),
output: process.stderr,
}),
catch: (cause) => cause,
})
if (isCancel(selected)) {
cancel("Cancelled", { output: process.stderr })
process.exitCode = 130
return undefined
}
outro("Exporting session...", { output: process.stderr })
return selected
})
if (page.data.length === 0) {
process.stderr.write(`No sessions found${EOL}`)
return undefined
}
return selectSession(page.data, input.sanitize)
})
const sessionID = requested ?? selected?.session.id
if (!sessionID) return
const data = yield* Effect.promise(() =>
client.session.export({ sessionID, sanitize: selected?.sanitize ?? input.sanitize }),
)
process.stdout.write(yield* Effect.promise(() => writeExport(data, sessionID, requested !== undefined)))
}),
if (!sessionID) return
const data = yield* Effect.tryPromise({
try: () => client.session.export({ sessionID, sanitize: input.sanitize }),
catch: (cause) => cause,
})
process.stdout.write(JSON.stringify(data, null, 2) + EOL)
}).pipe(
Effect.catch((error) =>
Effect.sync(() => {
process.stderr.write(errorMessage(error) + EOL)
process.exitCode = 1
}),
),
),
),
)
type Selection = { session: SessionInfo; sanitize: boolean }
function selectSession(sessions: SessionInfo[], initialSanitize: boolean) {
if (!process.stdin.isTTY) return Promise.reject(new Error("Session ID is required when stdin is not interactive"))
const input = process.stdin
const output = process.stderr
const wasRaw = input.isRaw
const wasPaused = input.isPaused()
const date = new Intl.DateTimeFormat(undefined, {
month: "short",
day: "numeric",
hour: "numeric",
minute: "2-digit",
})
const columns = output.columns ?? 100
const titleWidth = Math.max(8, Math.min(48, columns - 34))
let selected = 0
let offset = 0
let sanitize = initialSanitize
let height = 0
const render = () => {
const visible = sessions.slice(offset, offset + 10)
const lines = [" \x1b[36mExport session\x1b[0m", ""]
lines.push(
...visible.map((session) => {
const index = sessions.indexOf(session)
const title = (session.title ?? "Untitled session").slice(0, titleWidth).padEnd(titleWidth)
const updated = date.format(session.time.updated).slice(0, 18).padEnd(18)
const row = `${index === selected ? ">" : " "} ${title} ${updated} ${session.id.slice(-8)}`
return index === selected ? `\x1b[1m${row}\x1b[0m` : row
}),
"",
` [${sanitize ? "x" : " "}] sanitize sensitive data`,
"",
" navigate \x1b[2mup/down\x1b[0m sanitize \x1b[2mspace\x1b[0m export \x1b[2menter\x1b[0m cancel \x1b[2mesc\x1b[0m",
)
if (height > 0) output.write(`\x1b[${height}F\x1b[J`)
output.write(lines.join(EOL) + EOL)
height = lines.length
}
const clear = () => {
if (height > 0) output.write(`\x1b[${height}F\x1b[J`)
output.write("\x1b[?25h")
input.removeListener("keypress", onKeypress)
input.setRawMode(wasRaw ?? false)
if (wasPaused) input.pause()
}
const onKeypress = (value: string | undefined, key: Key) => {
if (key.name === "up") {
selected = (selected - 1 + sessions.length) % sessions.length
if (selected === sessions.length - 1) offset = Math.max(0, sessions.length - 10)
if (selected < offset) offset = selected
}
if (key.name === "down") {
selected = (selected + 1) % sessions.length
if (selected === 0) offset = 0
if (selected >= offset + 10) offset = selected - 9
}
if (key.name === "space" || value === " ") sanitize = !sanitize
if (key.name === "return") return finish(sessions[selected])
if (key.name === "escape" || (key.ctrl && key.name === "c")) return cancel()
render()
}
const finish = (session: SessionInfo) => {
clear()
resolveSelection?.({ session, sanitize })
}
const cancel = () => {
clear()
resolveSelection?.()
}
let resolveSelection: ((selection?: Selection) => void) | undefined
emitKeypressEvents(input)
input.setRawMode(true)
input.resume()
input.on("keypress", onKeypress)
output.write("\x1b[?25l")
render()
return new Promise<Selection | undefined>((resolve) => {
resolveSelection = resolve
})
}
export async function writeExport(data: unknown, sessionID: string, stdout: boolean) {
const json = JSON.stringify(data, null, 2) + EOL
if (stdout) return json
const file = path.join(tmpdir(), `opencode-session-${sessionID}-${crypto.randomUUID().slice(0, 8)}.json`)
await Bun.write(file, json)
return file + EOL
}

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