Compare commits

...
27 changed files with 753 additions and 72 deletions

No files matched your search

+14 -2
View File
@@ -131,6 +131,7 @@
"@opencode/util": "workspace:*",
"@opentui/core": "catalog:",
"@opentui/solid": "catalog:",
"@opentunnel/client": "0.2.0",
"@parcel/watcher": "2.5.1",
"@silvia-odwyer/photon-node": "0.3.4",
"diff": "catalog:",
@@ -966,6 +967,7 @@
"name": "@opencode/ui",
"version": "2.0.24",
"dependencies": {
"@kitlangton/solid-motion": "0.2.2",
"@kobalte/core": "catalog:",
"@pierre/diffs": "catalog:",
"@solid-primitives/event-listener": "catalog:",
@@ -2018,6 +2020,8 @@
"@jsx-email/text": ["@jsx-email/text@1.0.2", "", { "peerDependencies": { "react": "^18.2.0" } }, "sha512-0zzwEwrKtY6tfjPJF0r3krKCDpP/ySYDvkn4+MvIFrIH5RZKmn3XDa5o/3hkbxMwpLn4MsXGIXn9XzMTaqTfUA=="],
"@kitlangton/solid-motion": ["@kitlangton/solid-motion@0.2.2", "", { "dependencies": { "motion-dom": "14.0.0", "motion-utils": "14.0.0" }, "peerDependencies": { "solid-js": "^1.8.0" } }, "sha512-Fp7T8gJta3aRGJakBPQ6WIWmF6U6TmWhT6sGNNgkBiJ8cOVfuKkF9yYXzhvgRtKpo7WfzxtNLku907dLHhTDtw=="],
"@kobalte/core": ["@kobalte/core@0.13.13", "", { "dependencies": { "@floating-ui/dom": "^1.5.1", "@internationalized/number": "^3.2.1", "@kobalte/utils": "^0.9.2", "@solid-primitives/props": "^3.1.8", "@solid-primitives/resize-observer": "^2.0.26", "solid-presence": "^0.2.0", "solid-prevent-scroll": "^0.1.11" }, "peerDependencies": { "solid-js": "^1.9.8" } }, "sha512-czBC+IQdOgJoW7DJjeh0rht7SPmTPgWsKg7Jo3RHMfwx028WxJtDylx9dJuJuD/GUae15+E4nYafu8LwuixFYQ=="],
"@kobalte/utils": ["@kobalte/utils@0.9.2", "", { "dependencies": { "@solid-primitives/event-listener": "^2.2.14", "@solid-primitives/keyed": "^1.2.0", "@solid-primitives/map": "^0.4.7", "@solid-primitives/media": "^2.2.4", "@solid-primitives/props": "^3.1.8", "@solid-primitives/refs": "^1.0.5", "@solid-primitives/utils": "^6.2.1" }, "peerDependencies": { "solid-js": "^1.8.8" } }, "sha512-jRVXr+zsVHxzDXRoh+CDeXzvCsFJ6uiHhqqNQ26Cw9ZsZ3D6nqPUBt1gGVtj2ZPmRL3a9Uk1v8D1aJ8/I12Dow=="],
@@ -2324,6 +2328,10 @@
"@opentui/solid": ["@opentui/solid@0.5.16", "", { "dependencies": { "@babel/core": "7.29.7", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.5.16", "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-gCULWG+HORd5IVydiCchlmULgY1PQccODJvJKDNYJHDmhwyg9/sNSFUxoYajEXLZ40pUIL8ENAK+6p34Z9m/JQ=="],
"@opentunnel/client": ["@opentunnel/client@0.2.0", "", { "dependencies": { "@opentunnel/protocol": "0.2.0", "effect": "4.0.0-rc.112" } }, "sha512-UHbLK+y2aPdcTuu4FaCz3Ezcm52Z4TxGp5Hdy9aLYLsz9GUlPpPFaYXjdq8pFyfQlA9qAyI/z5zx5FlZx3aSbw=="],
"@opentunnel/protocol": ["@opentunnel/protocol@0.2.0", "", { "dependencies": { "effect": "4.0.0-rc.112" } }, "sha512-XQAm3InG70HysML+NiSPxCCfoP+ZzWuxXcCFFFh/qxrvMxuJyOrxsPT49vTo2rDCgrYYdbiFqpz8oXIJh0BYXw=="],
"@oslojs/asn1": ["@oslojs/asn1@1.0.0", "", { "dependencies": { "@oslojs/binary": "1.0.0" } }, "sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA=="],
"@oslojs/binary": ["@oslojs/binary@1.0.0", "", {}, "sha512-9RCU6OwXU6p67H4NODbuxv2S3eenuQ4/WFLrsq+K/k682xrznH5EVWA7N4VFk9VYVcbFtKqur5YQQZc0ySGhsQ=="],
@@ -4790,9 +4798,9 @@
"motion": ["motion@12.34.5", "", { "dependencies": { "framer-motion": "^12.34.5", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-N06NLJ9IeBHeielRqIvYvjPfXuRdyTxa+9++BgpGa+hY2D7TcMkI6QzV3jaRuv0aZRXgMa7cPy9YcBUBisPzAQ=="],
"motion-dom": ["motion-dom@12.43.0", "", { "dependencies": { "motion-utils": "^12.39.0" } }, "sha512-azKON4d9S65PEoFUiQTMTgPheEmzf2QngdRc50AKfJp9Q9mmcBVw22c8eMq9k8kxOFHdL7+WZY7N/5F/lwiDag=="],
"motion-dom": ["motion-dom@14.0.0", "", { "dependencies": { "motion-utils": "14.0.0" } }, "sha512-aU3ApXo1yTpeUi3JU+jbDk2TELjxPE3MbBMUP7nZHlARb6RweJK+ojmLjpuB0YsY6zhbBl1YWGo8LMsI1/Llqw=="],
"motion-utils": ["motion-utils@12.39.0", "", {}, "sha512-8nadJAJjTtqRkmRF36FoJTrywK9nnFmnPwnSMyxaOCU7GDjN9RTMJIxx9De8ErM+vpPhMccr/6fo5WciyQLnMQ=="],
"motion-utils": ["motion-utils@14.0.0", "", {}, "sha512-jBsyjVxTTRZNeuvd/v+btVXr90LQkYl4HhRu9T2ARc+PMnvkmjsjRVeAg4Qm10b6TlTw+kykWI8gu/6LuvpLSQ=="],
"mrmime": ["mrmime@2.0.1", "", {}, "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ=="],
@@ -7462,6 +7470,10 @@
"minipass-pipeline/minipass/yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="],
"motion/framer-motion/motion-dom": ["motion-dom@12.43.0", "", { "dependencies": { "motion-utils": "^12.39.0" } }, "sha512-azKON4d9S65PEoFUiQTMTgPheEmzf2QngdRc50AKfJp9Q9mmcBVw22c8eMq9k8kxOFHdL7+WZY7N/5F/lwiDag=="],
"motion/framer-motion/motion-utils": ["motion-utils@12.39.0", "", {}, "sha512-8nadJAJjTtqRkmRF36FoJTrywK9nnFmnPwnSMyxaOCU7GDjN9RTMJIxx9De8ErM+vpPhMccr/6fo5WciyQLnMQ=="],
"openid-client/lru-cache/yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="],
"p-locate/p-limit/yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="],
+1 -1
View File
@@ -2,7 +2,7 @@
exact = true
# Only install newly resolved package versions published at least 3 days ago.
minimumReleaseAge = 259200
minimumReleaseAgeExcludes = ["@ai-sdk/amazon-bedrock", "@ai-sdk/anthropic", "@brendonovich/vite-plugin-opencode", "@opencode/sdk", "@opencode-ai/pty", "@opencode-ai/pty-darwin-arm64", "@opencode-ai/pty-darwin-x64", "@opencode-ai/pty-linux-arm64-gnu", "@opencode-ai/pty-linux-arm64-musl", "@opencode-ai/pty-linux-x64-gnu", "@opencode-ai/pty-linux-x64-musl", "@opentui/core", "@opentui/core-darwin-arm64", "@opentui/core-darwin-x64", "@opentui/core-linux-arm64", "@opentui/core-linux-arm64-musl", "@opentui/core-linux-x64", "@opentui/core-linux-x64-musl", "@opentui/core-win32-arm64", "@opentui/core-win32-x64", "@opentui/keymap", "@opentui/solid", "opentui-spinner", "gitlab-ai-provider", "opencode-gitlab-auth", "@ff-labs/fff-node", "@ff-labs/fff-bun", "@ff-labs/fff-bin-darwin-arm64", "@ff-labs/fff-bin-darwin-x64", "@ff-labs/fff-bin-linux-arm64-gnu", "@ff-labs/fff-bin-linux-arm64-musl", "@ff-labs/fff-bin-linux-x64-gnu", "@ff-labs/fff-bin-linux-x64-musl", "@ff-labs/fff-bin-win32-arm64", "@ff-labs/fff-bin-win32-x64", "@pierre/diffs", "@pierre/theming", "app-builder-lib", "dmg-builder", "electron", "electron-builder", "electron-publish", "blume", "mermaid"]
minimumReleaseAgeExcludes = ["@ai-sdk/amazon-bedrock", "@ai-sdk/anthropic", "@brendonovich/vite-plugin-opencode", "@kitlangton/solid-motion", "@opencode/sdk", "@opencode-ai/pty", "@opencode-ai/pty-darwin-arm64", "@opencode-ai/pty-darwin-x64", "@opencode-ai/pty-linux-arm64-gnu", "@opencode-ai/pty-linux-arm64-musl", "@opencode-ai/pty-linux-x64-gnu", "@opencode-ai/pty-linux-x64-musl", "@opentui/core", "@opentui/core-darwin-arm64", "@opentui/core-darwin-x64", "@opentui/core-linux-arm64", "@opentui/core-linux-arm64-musl", "@opentui/core-linux-x64", "@opentui/core-linux-x64-musl", "@opentui/core-win32-arm64", "@opentui/core-win32-x64", "@opentui/keymap", "@opentui/solid", "@opentunnel/client", "@opentunnel/protocol", "opentui-spinner", "gitlab-ai-provider", "opencode-gitlab-auth", "@ff-labs/fff-node", "@ff-labs/fff-bun", "@ff-labs/fff-bin-darwin-arm64", "@ff-labs/fff-bin-darwin-x64", "@ff-labs/fff-bin-linux-arm64-gnu", "@ff-labs/fff-bin-linux-arm64-musl", "@ff-labs/fff-bin-linux-x64-gnu", "@ff-labs/fff-bin-linux-x64-musl", "@ff-labs/fff-bin-win32-arm64", "@ff-labs/fff-bin-win32-x64", "@pierre/diffs", "@pierre/theming", "app-builder-lib", "dmg-builder", "electron", "electron-builder", "electron-publish", "blume", "mermaid"]
[test]
root = "./do-not-run-tests-from-root"
+3 -3
View File
@@ -1,7 +1,7 @@
{
"nodeModules": {
"x86_64-linux": "sha256-GHLheOh+zY847BpZ9G/vCfjPT7jUt8doODFOQP9REjw=",
"aarch64-linux": "sha256-+To0HYoZzNJ5Qd7y22/ZvljcxQbchGBvYaCPYhte2As=",
"aarch64-darwin": "sha256-KVGWrTpe8SB8PgKAyb+2FFRBvXLkJlJVsVTHa/bUhg0="
"x86_64-linux": "sha256-m8XFudp6AwHWDZO7rDXCTkNF0XzgdnWUAg0VCgmGoDQ=",
"aarch64-linux": "sha256-CQmS4coITqsMmtgi/g4Xivy/d6sDIklAE8duCUkAazs=",
"aarch64-darwin": "sha256-fSeacvDBLUCSKIeGCTgZsmqfo2rohARHvFgYLaNXHi8="
}
}
@@ -1012,6 +1012,99 @@ test.describe("background shortcut", () => {
await expect(backgroundCard).toContainText("Background task (background)")
})
for (const fixture of [
{ name: "from far below", before: 40, searches: 40, after: 80 },
// The collapsed timeline fits the viewport, so it only becomes scrollable once the group opens.
{ name: "that opens the last row", before: 0, searches: 40, after: 0 },
// Reaching the shell lands on the end of the timeline, which would otherwise follow new content again.
{ name: "at the end of the timeline", before: 40, searches: 0, after: 0 },
]) {
test(`holds a revealed shell ${fixture.name} just under its stuck group header`, async ({ page }) => {
const timeline = await setupTimeline(page, {
settings: { timelineDetail: detailed },
sessionStatus: { [sessionID]: { type: "busy" } },
messages: [
userMessage(),
assistantMessage(
[
...(fixture.before ? [textPart("prt_earlier", notes("Earlier", fixture.before))] : []),
...searches(0, 42 - fixture.searches),
toolPart(
"prt_far_shell",
"shell",
"completed",
{ command: "sleep 120" },
{ output: "working", metadata: { shellID: "shell_far", status: "running" } },
),
// The open group outgrows the viewport, so aligning the group instead of the shell misses it.
...searches(42 - fixture.searches, fixture.searches),
...(fixture.after ? [textPart("prt_follow_up", notes("Follow-up", fixture.after))] : []),
],
{ completed: false },
),
],
})
await timeline.transport.send({
id: "evt_far_shell_created",
created: 3,
type: "shell.created",
location: { directory },
data: {
info: {
id: "shell_far",
status: "running",
command: "sleep 120",
cwd: directory,
shell: "bash",
file: "/tmp/far.out",
metadata: { sessionID },
time: { started: 2 },
},
},
})
const group = page
.locator('[data-component="collapsed-tool-group"]')
.locator(':scope > [data-component="collapsible"] > [data-slot="collapsible-trigger"]')
const shellTrigger = page.locator('[data-timeline-part-id="prt_far_shell"] [data-slot="collapsible-trigger"]')
const aligned = () =>
expect
.poll(async () => {
const [header, shell] = await Promise.all([group.boundingBox(), shellTrigger.boundingBox()])
return header && shell ? Math.round(shell.y - (header.y + header.height)) : undefined
})
.toBe(0)
await expect(
fixture.after ? page.getByText(`Follow-up note ${fixture.after}.`, { exact: true }) : group,
).toBeInViewport()
await expect(shellTrigger).toHaveCount(0)
await page.getByRole("button", { name: "1 running", exact: true }).click()
await page
.getByRole("menu", { name: "1 running", exact: true })
.getByRole("menuitem", { name: /sleep 120/ })
.click()
await expect(group).toHaveAttribute("aria-expanded", "true")
await expect(shellTrigger).toHaveAttribute("aria-expanded", "true")
await expect(shellTrigger).toBeInViewport()
// A shell at the very end cannot rise under the header until more content arrives below it.
if (fixture.searches) await aligned()
// Nothing covers the row, so it takes the next click.
await shellTrigger.click({ trial: true })
// The agent keeps writing below the shell; the shell stays where it was revealed.
await timeline.send(partUpdated(textPart("prt_late", notes("Late", 30))))
await expect(page.getByText("Late note 30.", { exact: true })).toBeAttached()
await aligned()
})
}
test("hides the running switcher when viewing the only running subagent", async ({ page }) => {
const childID = "ses_only_running_child"
@@ -1735,6 +1828,16 @@ function pauseExitAnimations(locator: Locator) {
})
}
function notes(label: string, count: number) {
return Array.from({ length: count }, (_, index) => `${label} note ${index + 1}.`).join("\n\n")
}
function searches(from: number, count: number) {
return Array.from({ length: count }, (_, index) =>
toolPart(`prt_search_${from + index}`, "grep", "completed", { pattern: `needle_${from + index}` }),
)
}
function runningSubagent(): SessionMessageAssistant {
return {
...completed,
@@ -110,7 +110,14 @@ export function createTimelineVirtualizer(input: Input) {
{ defer: true },
),
)
const [rendering, setRendering] = createStore({ initialTail: coldBottomMount, scrollAdjustment: 0 })
const [rendering, setRendering] = createStore<{
initialTail: boolean
scrollAdjustment: number
/** A revealed tool whose top edge stays put until the user scrolls. */
anchor?: { key: string; partID: string }
}>({ initialTail: coldBottomMount, scrollAdjustment: 0 })
const rows = input.projection.rows
const rowByKey = input.projection.rowByKey
@@ -137,6 +144,7 @@ export function createTimelineVirtualizer(input: Input) {
const id = input.projection.activeMessageID()
const active = id ? (input.projection.messageLastRowIndex().get(id) ?? -1) : -1
const initialTail = rendering.initialTail && input.pinned()
const anchored = rendering.anchor ? rowKeys().indexOf(rendering.anchor.key) : -1
return (range: Range) => {
// Batch a bounded cheap suffix, but stop before unknown/large content.
@@ -164,7 +172,7 @@ export function createTimelineVirtualizer(input: Input) {
: defaultRangeExtractor({ ...range, overscan: 2 })
return filterVirtualIndexes(
[...new Set([...indexes, ...(active < 0 ? [] : [active])])].sort((a, b) => a - b),
[...new Set([...indexes, ...[active, anchored].filter((index) => index >= 0)])].sort((a, b) => a - b),
range.count,
)
}
@@ -336,6 +344,7 @@ export function createTimelineVirtualizer(input: Input) {
})
})
batchingColdSizes = false
pinAnchor()
if (coldPending) pinColdBottom()
settleColdBottom()
@@ -403,19 +412,60 @@ export function createTimelineVirtualizer(input: Input) {
const key = found.group.key
setToolOpen(
found.group.type === "context"
? { [`context:${key}`]: true, [`${key}:tool:${found.partID}`]: true }
: { [key]: true },
)
input.onUnpin()
prepareNavigation()
virtualizer.scrollToIndex(found.index, { align: "center" })
// Opening the group and anchoring its row render the tool synchronously, wherever the row is.
batch(() => {
setToolOpen(
found.group.type === "context"
? { [`context:${key}`]: true, [`${key}:tool:${found.partID}`]: true }
: { [key]: true },
)
setRendering("anchor", { key: TimelineRow.key(rows()[found.index]!), partID: found.partID })
})
// Until its ResizeObserver delivers, the opened row keeps its collapsed size, so the timeline may not scroll yet
// (and cannot unpin) or ends above the tool. Commit the real size first.
const opened = virtualContent?.querySelector<HTMLElement>(`[data-index="${found.index}"]`)
if (opened) resizeItem(found.index, opened.offsetHeight)
input.onUnpin()
pinAnchor()
return true
}
// Puts the anchored tool's top edge at its scroll margin, just below the headers that stick above it. Its own
// growth extends downward, so only size changes at or above its row can move it; each of those calls this again.
function pinAnchor() {
const anchor = rendering.anchor
const root = listRoot()
if (!anchor || !root) return
const element = virtualContent?.querySelector<HTMLElement>(`[data-timeline-part-id="${CSS.escape(anchor.partID)}"]`)
// Following the end is a different position to hold.
if (!element || !active() || input.pinned()) return releaseAnchor()
const offset = Math.min(
root.scrollHeight - root.clientHeight,
Math.max(
0,
root.scrollTop +
element.getBoundingClientRect().top -
root.getBoundingClientRect().top -
parseFloat(getComputedStyle(element).scrollMarginTop),
),
)
if (Math.abs(offset - root.scrollTop) > 1) virtualizer.scrollToOffset(offset)
}
function releaseAnchor() {
if (rendering.anchor) setRendering("anchor", undefined)
}
function prepareNavigation() {
releaseAnchor()
if (touchStart === undefined) touchScrolling = false
flushTouchAdjustment()
}
@@ -600,6 +650,7 @@ export function createTimelineVirtualizer(input: Input) {
// Upward input is the one intent geometry cannot recover: nudging up while still a pixel from
// the end must stop following, even though the resulting position still looks like the end.
const handleListWheel = (event: WheelEvent & { currentTarget: HTMLDivElement }) => {
releaseAnchor()
input.onUserScroll(event.target)
if (event.deltaY < 0) input.onUnpin()
@@ -607,6 +658,7 @@ export function createTimelineVirtualizer(input: Input) {
const handleListTouchStart = (event: TouchEvent) => {
clearTouchTarget()
releaseAnchor()
input.onUserScroll(event.target)
touchScrolling = true
touchStart = event.touches[0]?.clientY
@@ -663,6 +715,7 @@ export function createTimelineVirtualizer(input: Input) {
// Drag-selecting past the edge and dragging the scrollbar both scroll without a wheel or key,
// so a held pointer is what separates those from the virtualizer's own measurement adjustments.
const handleListPointerDown = (event: PointerEvent & { currentTarget: HTMLDivElement }) => {
releaseAnchor()
input.onUserScroll(event.target)
pointerHeld = true
}
@@ -688,6 +741,7 @@ export function createTimelineVirtualizer(input: Input) {
if (!isScrollKeyTarget(event.target, key)) return
if (scrollKeyOwner(event.currentTarget, event.target, key) !== event.currentTarget) return
releaseAnchor()
input.onUserScroll(event.currentTarget)
if (upwardKeys.has(key)) input.onUnpin()
@@ -706,7 +760,8 @@ export function createTimelineVirtualizer(input: Input) {
const atEnd = maxScroll - scrollTop <= endEpsilon
const arrived = scrollTop > previousTop + endEpsilon || maxScroll < previousMaxScroll
if (maxScroll <= 1 || (atEnd && arrived)) input.onPin()
// An anchor holds its tool even when reaching it lands at the end; only the user's own scroll lets it go.
if (maxScroll <= 1 || (atEnd && arrived && !rendering.anchor)) input.onPin()
else if ((pointerHeld || touchScrolling) && scrollTop < previousTop - endEpsilon) input.onUnpin()
settleColdBottom()
input.onScheduleScrollState(root)
+1
View File
@@ -37,6 +37,7 @@
"@opencode/util": "workspace:*",
"@opentui/core": "catalog:",
"@opentui/solid": "catalog:",
"@opentunnel/client": "0.2.0",
"@parcel/watcher": "2.5.1",
"@silvia-odwyer/photon-node": "0.3.4",
"diff": "catalog:",
+4
View File
@@ -116,6 +116,10 @@ function copyBinary(source) {
function resolveBinary(name) {
const packagePath = require.resolve(`${name}/package.json`)
// Package managers keep an older platform package when the matching version is not yet on the registry,
// which would silently install the previous release under the new launcher.
const version = JSON.parse(fs.readFileSync(packagePath, "utf8")).version
if (version !== dependencies[name]) throw new Error(`${name} is ${version}, expected ${dependencies[name]}`)
return path.join(path.dirname(packagePath), "bin", sourceBinary)
}
+4
View File
@@ -530,6 +530,10 @@ const Root = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCODE_CLI_NAME
),
Flag.optional,
),
remote: Flag.boolean("remote").pipe(
Flag.withDescription("Pair through the OpenTunnel remote address, enabling remote access if needed"),
Flag.withDefault(false),
),
},
}),
Spec.make("serve", {
+38 -6
View File
@@ -1,24 +1,28 @@
import { EOL } from "os"
import { Effect, Option } from "effect"
import { Effect, Option, Schedule } from "effect"
import { Service } from "@opencode/client/effect/service"
import { OpenCode } from "@opencode/client/promise"
import { renderUnicodeCompact } from "uqr"
import { Commands } from "../commands"
import { Runtime } from "../../framework/runtime"
import { RemoteTunnel } from "../../services/remote-tunnel"
import { ServiceConfig } from "../../services/service-config"
export default Runtime.handler(
Commands.commands.pair,
Effect.fn("cli.pair")(function* (input: Runtime.Input<typeof Commands.commands.pair>) {
if ((yield* ServiceConfig.read()).disabled === true)
const config = yield* ServiceConfig.read()
if (config.disabled === true)
return yield* Effect.fail(
new Error("Pairing requires the background service; run `opencode service unset disabled` first"),
)
if (input.remote && Option.isSome(input.url))
return yield* Effect.fail(new Error("--remote cannot be combined with --url"))
// Changing the setting restarts the service, and the ensure below starts it again with the tunnel.
if (input.remote && config.remote !== true) yield* ServiceConfig.set("remote", "true")
const endpoint = yield* Service.ensure(yield* ServiceConfig.options())
const client = OpenCode.make({ baseUrl: endpoint.url, headers: Service.headers(endpoint) })
const urls = Option.isSome(input.url)
? [input.url.value]
: (yield* Effect.tryPromise(() => client.server.info())).urls
const urls = yield* pairingURLs(client, input)
const pairing = yield* Effect.tryPromise(() => client.server.pair())
const links = urls.map((url) => new URL(`/auth/connect/${pairing.code}`, url).href)
// Loopback URLs are useless to the scanning device, so the QR code only carries reachable addresses.
@@ -45,7 +49,7 @@ export default Runtime.handler(
].join(EOL) + EOL,
)
if (Option.isSome(input.url)) return
if (input.remote || Option.isSome(input.url)) return
const url = new URL(endpoint.url)
if (!["localhost", "127.0.0.1", "[::1]"].includes(url.hostname)) return
process.stderr.write(
@@ -61,6 +65,34 @@ export default Runtime.handler(
}),
)
const pairingURLs = Effect.fnUntraced(function* (
client: ReturnType<typeof OpenCode.make>,
input: Runtime.Input<typeof Commands.commands.pair>,
) {
if (input.remote) return [yield* remoteURL(client)]
if (Option.isSome(input.url)) return [input.url.value]
return (yield* Effect.tryPromise(() => client.server.info())).urls
})
// The service attaches the tunnel in the background, so wait for its URL to appear in server info.
const remoteURL = Effect.fnUntraced(function* (client: ReturnType<typeof OpenCode.make>) {
const tunnelURL = Effect.gen(function* () {
const hostname = yield* RemoteTunnel.hostname()
const info = yield* Effect.tryPromise(() => client.server.info())
const url = info.urls.find((candidate) => hostname !== undefined && new URL(candidate).hostname === hostname)
if (url === undefined) return yield* Effect.fail(new Error("Remote tunnel is not ready"))
return url
})
return yield* tunnelURL.pipe(
Effect.retry({ schedule: Schedule.spaced("1 second") }),
Effect.timeoutOrElse({
duration: "3 minutes",
orElse: () =>
Effect.fail(new Error("Timed out waiting for the remote tunnel; run `opencode pair --remote` again to retry")),
}),
)
})
function isLoopback(hostname: string) {
return (
hostname === "localhost" || hostname.endsWith(".localhost") || hostname.startsWith("127.") || hostname === "[::1]"
+16
View File
@@ -14,6 +14,7 @@ import { Env } from "./env"
import { ServiceConfig } from "./services/service-config"
import { RetainedImage } from "./services/retained-image"
import { ServiceRegistration } from "./services/service-registration"
import { RemoteTunnel } from "./services/remote-tunnel"
import { WebUi } from "./services/web-ui"
import { databasePath } from "./database-path"
@@ -88,6 +89,7 @@ const processEffect = Effect.fnUntraced(function* (options: Options) {
if (!password) return yield* Effect.fail(new Error("Missing server password"))
const instanceID = randomUUID()
const transform = yield* WebUi.handler()
const remote = { urls: [] as ReadonlyArray<string> }
const launch = start(
{
app: {
@@ -144,6 +146,7 @@ const processEffect = Effect.fnUntraced(function* (options: Options) {
}),
},
transform,
() => remote.urls,
)
const server = yield* launch.pipe(
Effect.catch((error) => {
@@ -170,6 +173,19 @@ const processEffect = Effect.fnUntraced(function* (options: Options) {
}),
)
if (server === undefined) return
if (serviceOptions !== undefined && config.remote === true && server.address._tag === "TcpAddress") {
const bound = server.address.hostname
// A wildcard bind also listens on loopback, which is all the tunnel needs to reach.
const host = bound === "0.0.0.0" || bound === "::" ? "127.0.0.1" : bound.includes(":") ? `[${bound}]` : bound
yield* Effect.forkScoped(
RemoteTunnel.run({
target: `${host}:${server.address.port}`,
onURL: (url) => {
remote.urls = url === undefined ? [] : [url]
},
}),
)
}
const url = HttpServer.formatAddress(server.address)
console.log(options.mode === "stdio" ? JSON.stringify({ url }) : `server listening on ${url}`)
if (foreground && !environmentPassword) console.log(`server password ${password}`)
@@ -0,0 +1,73 @@
export * as RemoteTunnel from "./remote-tunnel"
import type { OpenTunnelError } from "@opentunnel/client/effect"
import { Cause, Effect, Schedule } from "effect"
import { EOL } from "os"
import { OPENCODE_CHANNEL } from "../version"
// OpenTunnel keeps one tunnel per device in its default profile, shared by the opentunnel CLI and every app
// using the SDK; each claims its own routes. The service claims a subdomain rather than the tunnel hostname,
// which the CLI may route, and each channel's service gets its own subdomain.
export function route(channel = OPENCODE_CHANNEL) {
if (channel === "latest") return "opencode"
return `opencode-${channel.toLowerCase().replace(/[^a-z0-9-]/g, "-")}`.slice(0, 63).replace(/-+$/, "")
}
// Holds the route for the life of the service. The SDK reconnects through network failures itself, so only
// setup failures reach the retry here; a rejected token or failed certificate stops it for good.
export const run = Effect.fnUntraced(function* (input: {
readonly target: string
readonly onURL: (url: string | undefined) => void
}) {
const { OpenTunnelClient, OpenTunnelAttachError } = yield* Effect.promise(() => import("@opentunnel/client/effect"))
const fatal = (error: OpenTunnelError) =>
error._tag === "OpenTunnelClientError" &&
(error.cause instanceof OpenTunnelAttachError || error.message.startsWith("Certificate issuance failed"))
yield* Effect.gen(function* () {
const client = yield* OpenTunnelClient
const connection = yield* client.tunnel.connect({ routes: { [route()]: input.target } })
input.onURL(`https://${route()}.${connection.tunnel.hostname}`)
yield* connection.closed
}).pipe(
Effect.scoped,
Effect.ensuring(Effect.sync(() => input.onURL(undefined))),
Effect.tapError((error) =>
fatal(error) ? Effect.void : Effect.logWarning("remote access tunnel unavailable; retrying", { cause: error }),
),
Effect.retry({
while: (error) => !fatal(error),
schedule: Schedule.min([Schedule.exponential("1 second"), Schedule.spaced("30 seconds")]),
}),
Effect.provide(OpenTunnelClient.layer()),
Effect.catchCause((cause) =>
Cause.hasInterruptsOnly(cause)
? Effect.failCause(cause)
: Effect.logError("remote access tunnel stopped; run `opencode service set remote true` to retry", { cause }),
),
)
})
// A device without a tunnel creates its shared one here, in the foreground, because issuing the certificate
// takes a while; the service then only attaches its route on start. An interrupted issuance resumes next time.
export const ensure = Effect.fnUntraced(function* () {
const { OpenTunnelClient } = yield* Effect.promise(() => import("@opentunnel/client/effect"))
return yield* Effect.gen(function* () {
const client = yield* OpenTunnelClient
if ((yield* client.tunnel.get()) === undefined)
process.stderr.write("Setting up remote access; this can take a minute..." + EOL)
return `${route()}.${(yield* client.tunnel.ensure()).hostname}`
}).pipe(
Effect.provide(OpenTunnelClient.layer()),
Effect.timeoutOrElse({
duration: "5 minutes",
orElse: () => Effect.fail(new Error("Timed out creating the remote access tunnel; run the command again to resume")),
}),
)
})
// The tunnel hostname is persisted once the certificate is ready, so this is undefined until then.
export const hostname = Effect.fnUntraced(function* () {
const { OpenTunnelStorage } = yield* Effect.promise(() => import("@opentunnel/client/effect"))
const identity = yield* OpenTunnelStorage.xdg().load("default")
return identity === undefined ? undefined : `${route()}.${identity.hostname}`
})
+21 -1
View File
@@ -6,6 +6,7 @@ import { Effect, FileSystem, Option, Schema } from "effect"
import { randomBytes } from "crypto"
import path from "path"
import { selfCommand } from "../util/process"
import { RemoteTunnel } from "./remote-tunnel"
// The CLI's service configuration file, plus the Service.EnsureOptions binding that
// points the client package's service operations at this CLI: which
@@ -13,6 +14,7 @@ import { selfCommand } from "../util/process"
export const Info = Schema.Struct({
disabled: Schema.optional(Schema.Boolean),
remote: Schema.optional(Schema.Boolean),
hostname: Schema.optional(Schema.String),
port: Schema.optional(Schema.Int.check(Schema.isGreaterThanOrEqualTo(1), Schema.isLessThanOrEqualTo(65_535))),
password: Schema.optional(Schema.String),
@@ -21,7 +23,7 @@ export const Info = Schema.Struct({
})
export type Info = typeof Info.Type
const keys = ["disabled", "hostname", "port", "password", "cors", "env"] as const
const keys = ["disabled", "remote", "hostname", "port", "password", "cors", "env"] as const
type Key = (typeof keys)[number]
const decodeInfo = Schema.decodeUnknownEffect(Schema.fromJsonString(Info))
@@ -81,6 +83,7 @@ export const migrateConfig = Effect.fnUntraced(function* (legacy: string, file:
function configKey(key: string): Key {
if (
key === "disabled" ||
key === "remote" ||
key === "hostname" ||
key === "port" ||
key === "password" ||
@@ -163,6 +166,9 @@ export const get = Effect.fn("cli.service-config.get")(function* (key?: string,
case "disabled": {
return String((yield* read()).disabled ?? false)
}
case "remote": {
return String((yield* read()).remote ?? false)
}
case "hostname": {
return (yield* read()).hostname ?? ""
}
@@ -195,6 +201,14 @@ export const set = Effect.fn("cli.service-config.set")(function* (key: string, v
yield* write({ ...(yield* read()), disabled: value === "true" })
return
}
case "remote": {
if (value !== "true" && value !== "false") throw new Error("Remote must be true or false")
// A tunnel that cannot be created leaves remote access off instead of a service that keeps retrying.
if (value === "true") yield* RemoteTunnel.ensure()
yield* Service.stop(yield* options())
yield* write({ ...(yield* read()), remote: value === "true" })
return
}
case "hostname": {
yield* Service.stop(yield* options())
yield* write({ ...(yield* read()), hostname: value })
@@ -244,6 +258,12 @@ export const unset = Effect.fn("cli.service-config.unset")(function* (key: strin
yield* write(next)
return
}
case "remote": {
yield* Service.stop(yield* options())
const { remote: _remote, ...next } = yield* read()
yield* write(next)
return
}
case "hostname": {
yield* Service.stop(yield* options())
const { hostname: _hostname, ...next } = yield* read()
+13
View File
@@ -0,0 +1,13 @@
import { validateRoutes } from "@opentunnel/client/effect"
import { expect, test } from "bun:test"
import { RemoteTunnel } from "../src/services/remote-tunnel"
test("each channel serves remote access on its own valid subdomain of the shared tunnel", () => {
const channels = ["latest", "dev", "beta", "Preview/Feature_X.1", "a".repeat(80), "trailing-"]
const routes = channels.map((channel) => RemoteTunnel.route(channel))
expect(routes.slice(0, 3)).toEqual(["opencode", "opencode-dev", "opencode-beta"])
expect(new Set(routes).size).toBe(routes.length)
// The SDK rejects invalid route names, which would keep the service from ever attaching.
expect(() => validateRoutes(Object.fromEntries(routes.map((name) => [name, "127.0.0.1:4096"])))).not.toThrow()
})
+20
View File
@@ -43,6 +43,26 @@ test("service disabled accepts only booleans without changing configuration on i
}
})
// Enabling remote creates a real tunnel, so only the paths that stay local are covered here.
test("service remote accepts only booleans and persists across set and unset", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-service-remote-config-"))
const layer = Global.layerWith({ config: path.join(root, "config"), state: path.join(root, "state") })
const run = <A, E>(effect: Effect.Effect<A, E, Global.Service | FileSystem.FileSystem>) =>
Effect.runPromise(effect.pipe(Effect.provide(layer), Effect.provide(NodeFileSystem.layer)))
try {
expect(await run(ServiceConfig.get("remote"))).toBe("false")
await expect(run(ServiceConfig.set("remote", "on"))).rejects.toThrow("Remote must be true or false")
expect(await run(ServiceConfig.read())).toEqual({})
await run(ServiceConfig.set("remote", "false"))
expect(await run(ServiceConfig.read())).toEqual({ remote: false })
expect(await run(ServiceConfig.get("remote"))).toBe("false")
await run(ServiceConfig.unset("remote"))
expect(await run(ServiceConfig.read())).toEqual({})
} finally {
await fs.rm(root, { recursive: true, force: true })
}
})
test("local channel stores service config with the local service filename", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-service-"))
try {
+13 -6
View File
@@ -8,6 +8,7 @@ export * as WriteTool from "./write.js"
import type { Context } from "@opencode/plugin/effect/plugin"
import { ToolFailure } from "@opencode/ai"
import { FileDiff } from "@opencode/schema/file-diff"
import { Effect, Schema } from "effect"
import { Bom } from "@opencode/util/bom"
import { Environment } from "../../environment/index.js"
@@ -32,6 +33,7 @@ export const Output = Schema.Struct({
target: Schema.String,
resource: Schema.String,
existed: Schema.Boolean,
files: Schema.Array(FileDiff.Info),
})
export type Output = typeof Output.Type
@@ -85,13 +87,18 @@ export const Plugin = {
source,
})
const result = yield* fileMutation.writeTextPreservingBom({ target, content: input.content })
const bom = (yield* FileMutation.readText(environment.files, target.absolute)).bom
if (yield* formatter.file(target.absolute)) {
yield* FileMutation.syncTextBom(environment.files, target.absolute, bom)
}
return result
const written = yield* FileMutation.readText(environment.files, target.absolute)
const formatted = (yield* formatter.file(target.absolute))
? yield* FileMutation.syncTextBom(environment.files, target.absolute, written.bom)
: written.text
return {
...result,
files: [
fileDiff(result.resource, current?.text ?? "", formatted, result.existed ? "modified" : "added"),
],
} satisfies Output
}).pipe(
Effect.map((output) => ({ output, content: toModelContent(output) })),
Effect.map((output) => ({ output, content: toModelContent(output), metadata: { files: output.files } })),
Effect.mapError((error) => new ToolFailure({ message: `Unable to write ${input.path}`, error })),
),
}),
+39 -8
View File
@@ -110,6 +110,15 @@ describe("WriteTool", () => {
Effect.gen(function* () {
expect((yield* toolDefinitions(registry)).map((tool) => tool.name)).toEqual(["write", "execute"])
const settled = yield* executeTool(registry, call({ path: "src/new.txt", content: "created" }))
const files = [
{
file: "src/new.txt",
status: "added",
additions: 1,
deletions: 0,
patch: expect.stringContaining("+created"),
},
]
expect(settled).toEqual({
status: "completed",
output: {
@@ -117,8 +126,10 @@ describe("WriteTool", () => {
target: path.join(yield* Effect.promise(() => fs.realpath(tmp.path)), "src", "new.txt"),
resource: "src/new.txt",
existed: false,
files,
},
content: [{ type: "text", text: "Created file successfully: src/new.txt" }],
metadata: { files },
})
expect(yield* Effect.promise(() => fs.readFile(path.join(tmp.path, "src", "new.txt"), "utf8"))).toBe(
"created",
@@ -145,7 +156,7 @@ describe("WriteTool", () => {
}),
)
it.live("formats the committed file", () =>
it.live("formats the committed file and returns the diff for final formatted content", () =>
withTempDir((tmp) => {
const fixture = makeWriteFixture()
const target = path.join(tmp.path, "formatted.txt")
@@ -154,13 +165,33 @@ describe("WriteTool", () => {
await fs.writeFile(file, (await fs.readFile(file, "utf8")).toUpperCase())
return true
})
return withTool(tmp.path, fixture, (registry) =>
Effect.gen(function* () {
expect(yield* executeTool(registry, call({ path: "formatted.txt", content: "format me" }))).toMatchObject({
status: "completed",
})
expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("FORMAT ME")
}),
return Effect.promise(() => fs.writeFile(target, "before\n")).pipe(
Effect.andThen(
withTool(tmp.path, fixture, (registry) =>
executeTool(registry, call({ path: "formatted.txt", content: "format me\n" })),
),
),
Effect.andThen((settled) =>
Effect.gen(function* () {
expect(settled.status).toBe("completed")
if (settled.status !== "completed") return
const files = [
{
file: "formatted.txt",
status: "modified",
additions: 1,
deletions: 1,
patch: expect.stringMatching(/-before\n\+FORMAT ME\n/),
},
]
expect(settled.output).toMatchObject({ files })
expect(settled.metadata).toMatchObject({ files })
expect(fixture.assertions[0]?.metadata).toMatchObject({
files: [{ patch: expect.stringContaining("+format me") }],
})
expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("FORMAT ME\n")
}),
),
)
}),
)
+3 -1
View File
@@ -47,6 +47,7 @@ export const start = Effect.fn("ServerProcess.start")(function* <E, R>(
options: ServerOptions,
lifecycle?: Lifecycle<E, R>,
transform?: Transform,
remoteURLs?: () => ReadonlyArray<string>,
) {
const password = options.password
if (!password) return yield* Effect.fail(new Error("Missing server password"))
@@ -55,12 +56,13 @@ export const start = Effect.fn("ServerProcess.start")(function* <E, R>(
const shutdown = yield* Latch.make()
const status = yield* Status.make()
const bound = yield* listen({ hostname, port })
const urls = () => {
const localURLs = () => {
const address = bound.server.address()
if (address === null || typeof address === "string") return []
const host = address.family === "IPv6" ? `[${address.address}]` : address.address
return ServerInfo.connectionURLs(`http://${host}:${address.port}`, hostname)
}
const urls = () => [...localURLs(), ...(remoteURLs?.() ?? [])]
const application = yield* Ref.make(Option.none<App>())
const app = dispatch(password, status, application, options.app?.version ?? "unknown", urls, Global.Path.tmp)
// Request fibers may continue inbound trace context, but must not inherit the server startup parent.
+23
View File
@@ -236,3 +236,26 @@ async function readUntil(reader: ReadableStreamDefaultReader<Uint8Array>, expect
if (new TextDecoder().decode(next.value).includes(expected)) return
}
}
it.live("server info lists remote URLs alongside local addresses while they are available", () =>
Effect.gen(function* () {
const remote = { urls: ["https://example.opentunnel.xyz"] as ReadonlyArray<string> }
const server = yield* ServerProcess.start<never, never>(
{ hostname: "127.0.0.1", port: 0, password: "secret", database: { path: ":memory:" } },
undefined,
undefined,
() => remote.urls,
)
const base = HttpServer.formatAddress(server.address)
const info = () =>
Effect.promise(() =>
fetch(new URL("/api/info", base), { headers: { authorization: `Basic ${btoa("opencode:secret")}` } }).then(
(response) => response.json() as Promise<{ urls: ReadonlyArray<string> }>,
),
)
expect((yield* info()).urls).toEqual([base, "https://example.opentunnel.xyz"])
remote.urls = []
expect((yield* info()).urls).toEqual([base])
}),
)
@@ -817,6 +817,8 @@
[data-component="tool-part-wrapper"] {
width: 100%;
/* Revealing a tool lands its title just below the headers that stick above it. */
scroll-margin-top: var(--sticky-accordion-top, 0px);
}
[data-component="dock-prompt"][data-kind="permission"] {
+1
View File
@@ -109,6 +109,7 @@
"vite-plugin-solid": "catalog:"
},
"dependencies": {
"@kitlangton/solid-motion": "0.2.2",
"@kobalte/core": "catalog:",
"@pierre/diffs": "catalog:",
"@solid-primitives/event-listener": "catalog:",
+5 -5
View File
@@ -1,12 +1,8 @@
@import "../submit.css";
* {
[data-component="button-v2"] {
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-rendering: geometricPrecision;
}
[data-component="button-v2"] {
position: relative;
display: inline-flex;
align-items: center;
@@ -26,6 +22,10 @@
outline: none;
}
[data-component="button-v2"] > * {
pointer-events: none;
}
[data-component="button-v2"]:focus {
outline: none;
}
@@ -1,12 +1,8 @@
@import "../submit.css";
* {
[data-component="icon-button-v2"] {
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-rendering: geometricPrecision;
}
[data-component="icon-button-v2"] {
display: inline-flex;
align-items: center;
justify-content: center;
@@ -3,6 +3,8 @@
}
[data-slot="segmented-control-v2"] {
position: relative;
isolation: isolate;
box-sizing: border-box;
display: flex;
flex-direction: row;
@@ -16,9 +18,28 @@
border-radius: 6px;
box-shadow: 0 0 0 0.5px var(--v2-border-border-base);
flex: none;
user-select: none;
-webkit-user-select: none;
}
[data-slot="segmented-control-v2-indicator"] {
position: absolute;
top: 0;
bottom: 0;
left: 0;
border-radius: 6px;
background: var(--v2-background-bg-base);
box-shadow: 0 0 0 0.5px var(--v2-border-border-strong);
pointer-events: none;
user-select: none;
-webkit-user-select: none;
z-index: 0;
will-change: transform, width;
}
[data-slot="segmented-control-v2-item"] {
position: relative;
z-index: 1;
box-sizing: border-box;
display: flex;
flex-direction: column;
@@ -34,20 +55,26 @@
background: transparent;
box-shadow: none;
cursor: pointer;
user-select: none;
-webkit-user-select: none;
font-style: normal;
font-weight: 440;
font-size: 13px;
line-height: var(--line-height-compact);
line-height: 100%;
letter-spacing: -0.04px;
font-variant-numeric: tabular-nums;
font-variation-settings: "slnt" 0;
color: var(--v2-text-text-muted);
transition:
color 0.15s ease,
background-color 0.12s ease,
color 0.12s ease,
box-shadow 0.12s ease;
}
[data-slot="segmented-control-v2-item"]:where(:hover:not(:disabled):not([data-pressed])) {
color: var(--v2-text-text-base);
}
[data-slot="segmented-control-v2-item"]:where(:disabled) {
cursor: not-allowed;
opacity: 0.45;
@@ -60,12 +87,15 @@
}
[data-slot="segmented-control-v2-item"]:where([data-pressed]) {
background: var(--v2-background-bg-base);
color: var(--v2-text-text-base);
box-shadow: 0 0 0 0.5px var(--v2-border-border-strong);
z-index: 1;
}
[data-slot="segmented-control-v2"]:not([data-ready]) [data-slot="segmented-control-v2-item"]:where([data-pressed]) {
background: var(--v2-background-bg-base);
box-shadow: 0 0 0 0.5px var(--v2-border-border-strong);
}
[data-slot="segmented-control-v2-item-label"] {
display: flex;
flex-direction: row;
@@ -77,4 +107,7 @@
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
pointer-events: none;
user-select: none;
-webkit-user-select: none;
}
@@ -0,0 +1,55 @@
import { describe, expect, test } from "bun:test"
import { measureOffset, SEGMENTED_CONTROL_SPRING, type OffsetElement } from "./segmented-control"
describe("SegmentedControl", () => {
test("uses snappier non-bouncy spring default", () => {
expect(SEGMENTED_CONTROL_SPRING).toEqual({
type: "spring",
visualDuration: 0.15,
bounce: 0,
})
})
test("measures button offset along offsetParent chain to root", () => {
const root: OffsetElement = {
offsetLeft: 0,
offsetWidth: 232,
offsetParent: null,
getBoundingClientRect: () => ({ left: 0 }),
}
const wrapper: OffsetElement = {
offsetLeft: 8,
offsetWidth: 216,
offsetParent: root,
getBoundingClientRect: () => ({ left: 8 }),
}
const button: OffsetElement = {
offsetLeft: 44,
offsetWidth: 76,
offsetParent: wrapper,
getBoundingClientRect: () => ({ left: 52 }),
}
expect(measureOffset(root, button)).toEqual({ x: 52, width: 76 })
})
test("falls back to bounding client rect difference when offsetParent does not reach root", () => {
const root: OffsetElement = {
offsetLeft: 0,
offsetWidth: 232,
offsetParent: null,
getBoundingClientRect: () => ({ left: 100 }),
}
const button: OffsetElement = {
offsetLeft: 0,
offsetWidth: 64,
offsetParent: null,
getBoundingClientRect: () => ({ left: 164 }),
}
expect(measureOffset(root, button)).toEqual({ x: 64, width: 64 })
})
})
@@ -1,25 +1,37 @@
import { animate, motion, useMotionValue, type Transition } from "@kitlangton/solid-motion"
import {
createContext,
createEffect,
createMemo,
createSignal,
mergeProps,
onCleanup,
onMount,
Show,
splitProps,
useContext,
type Accessor,
type ComponentProps,
type JSX,
type ParentProps,
} from "solid-js"
import type { ComponentProps } from "solid-js"
import "./segmented-control.css"
type OnChange = (value: string | null) => void
export const SEGMENTED_CONTROL_SPRING: Transition = {
type: "spring",
visualDuration: 0.15,
bounce: 0,
}
type SegmentedControlContextValue = {
selected: Accessor<string | null>
groupDisabled: Accessor<boolean>
select: (value: string) => void
clearIfAllowed: (value: string) => void
focusNext: (from: HTMLButtonElement, direction: 1 | -1) => void
registerItem: (value: string, el: HTMLButtonElement) => () => void
}
const SegmentedControlContext = createContext<SegmentedControlContextValue>()
@@ -32,6 +44,33 @@ function useSegmentedControlContext() {
return ctx
}
export type OffsetElement = {
offsetLeft: number
offsetWidth: number
offsetParent: Element | OffsetElement | null
getBoundingClientRect: () => { left: number }
}
function isOffsetElement(node: Element | OffsetElement | null): node is OffsetElement {
return node !== null && "offsetLeft" in node && "offsetParent" in node
}
export function measureOffset(root: OffsetElement, button: OffsetElement) {
let offset = 0
let node: OffsetElement | null = button
while (node && node !== root) {
offset += node.offsetLeft
node = isOffsetElement(node.offsetParent) ? node.offsetParent : null
}
if (node !== root) {
offset = button.getBoundingClientRect().left - root.getBoundingClientRect().left
}
return { x: offset, width: button.offsetWidth }
}
export type SegmentedControlProps = Omit<ComponentProps<"div">, "onChange"> &
ParentProps<{
/** Selected value when controlled (including `null` when empty). Omit key for uncontrolled. */
@@ -42,10 +81,12 @@ export type SegmentedControlProps = Omit<ComponentProps<"div">, "onChange"> &
/** When true, clicking the active segment clears selection (`onChange(null)`). Default false. */
allowDeselect?: boolean
disabled?: boolean
/** Motion transition for the active indicator pill. */
transition?: Transition
}>
export function SegmentedControl(props: SegmentedControlProps) {
const isControlled = createMemo(() => Object.hasOwn(props as object, "value"))
const isControlled = createMemo(() => Object.hasOwn(props, "value"))
const merged = mergeProps({ allowDeselect: false, disabled: false }, props)
const [local, rest] = splitProps(merged, [
@@ -57,13 +98,88 @@ export function SegmentedControl(props: SegmentedControlProps) {
"onChange",
"allowDeselect",
"disabled",
"transition",
"ref",
])
const [internal, setInternal] = createSignal<string | null>(local.defaultValue ?? null)
const [layoutVersion, setLayoutVersion] = createSignal(0)
const [ready, setReady] = createSignal(false)
const selected = createMemo(() => (isControlled() ? (local.value ?? null) : internal()))
const reducedMotion = () => globalThis.matchMedia?.("(prefers-reduced-motion: reduce)")?.matches === true
const x = useMotionValue(0)
const width = useMotionValue(0)
let rootEl: HTMLDivElement | undefined
let observer: ResizeObserver | undefined
const items = new Map<string, HTMLButtonElement>()
let target = { x: 0, width: 0 }
let lastSelected: string | null = null
const syncIndicator = (shouldAnimate: boolean) => {
const current = selected()
const button = current !== null ? items.get(current) : undefined
if (!rootEl || !button) {
setReady(false)
return
}
const next = measureOffset(rootEl, button)
if (next.width === 0) return
target = next
if (!ready() || !shouldAnimate || reducedMotion()) {
x.jump(next.x)
width.jump(next.width)
setReady(true)
return
}
const transition = local.transition ?? SEGMENTED_CONTROL_SPRING
animate(x, next.x, transition)
animate(width, next.width, transition)
}
onMount(() => {
if (!("ResizeObserver" in globalThis)) return
observer = new ResizeObserver(() => {
const current = selected()
const button = current !== null ? items.get(current) : undefined
if (!rootEl || !button) return
const next = measureOffset(rootEl, button)
if (next.width === 0) return
if (ready() && next.x === target.x && next.width === target.width) return
syncIndicator(false)
})
if (rootEl) observer.observe(rootEl)
items.forEach((button) => observer?.observe(button))
onCleanup(() => observer?.disconnect())
})
createEffect(() => {
const current = selected()
layoutVersion()
const shouldAnimate = ready() && lastSelected !== null && current !== null && current !== lastSelected
lastSelected = current
syncIndicator(shouldAnimate)
})
const setSelected = (next: string | null) => {
if (!isControlled()) setInternal(next)
local.onChange?.(next)
@@ -92,19 +208,32 @@ export function SegmentedControl(props: SegmentedControlProps) {
next?.focus()
}
const registerItem = (value: string, el: HTMLButtonElement) => {
items.set(value, el)
observer?.observe(el)
setLayoutVersion((v) => v + 1)
return () => {
if (items.get(value) === el) items.delete(value)
observer?.unobserve(el)
setLayoutVersion((v) => v + 1)
}
}
const ctx: SegmentedControlContextValue = {
selected,
groupDisabled: () => !!local.disabled,
select,
clearIfAllowed,
focusNext,
registerItem,
}
const assignRef = (el: HTMLDivElement | undefined) => {
const assignRef = (el: HTMLDivElement) => {
rootEl = el
const r = local.ref
if (typeof r === "function") (r as (el: HTMLDivElement | undefined) => void)(el)
else if (r != null && typeof r === "object" && "value" in r) (r as { value: HTMLDivElement | undefined }).value = el
if (r instanceof Function) r(el)
}
return (
@@ -115,11 +244,15 @@ export function SegmentedControl(props: SegmentedControlProps) {
role="group"
data-component="segmented-control-v2"
data-slot="segmented-control-v2"
data-ready={ready() ? "" : undefined}
classList={{
...local.classList,
[local.class ?? ""]: !!local.class,
}}
>
<Show when={ready() && selected() !== null}>
<motion.span data-slot="segmented-control-v2-indicator" aria-hidden="true" style={{ x, width }} />
</Show>
{local.children}
</div>
</SegmentedControlContext.Provider>
@@ -134,9 +267,17 @@ export type SegmentedControlItemProps = Omit<ComponentProps<"button">, "type" |
function invokeButtonHandler<E extends Event>(
handler: JSX.EventHandlerUnion<HTMLButtonElement, E> | undefined,
e: E & { currentTarget: HTMLButtonElement },
e: Parameters<JSX.EventHandler<HTMLButtonElement, E>>[0],
) {
if (typeof handler === "function") (handler as (ev: typeof e) => void)(e)
if (handler instanceof Function) {
handler(e)
return
}
if (Array.isArray(handler)) {
handler[0](handler[1], e)
}
}
export function SegmentedControlItem(props: SegmentedControlItemProps) {
@@ -150,10 +291,18 @@ export function SegmentedControlItem(props: SegmentedControlItemProps) {
"disabled",
"onClick",
"onKeyDown",
"ref",
])
const ctx = useSegmentedControlContext()
let buttonEl: HTMLButtonElement | undefined
createEffect(() => {
if (!buttonEl) return
const cleanup = ctx.registerItem(local.value, buttonEl)
onCleanup(cleanup)
})
const pressed = createMemo(() => ctx.selected() === local.value)
const disabled = createMemo(() => ctx.groupDisabled() || !!local.disabled)
@@ -162,27 +311,38 @@ export function SegmentedControlItem(props: SegmentedControlItemProps) {
if (e.defaultPrevented || disabled()) return
if (pressed()) ctx.clearIfAllowed(local.value)
else ctx.select(local.value)
if (pressed()) {
ctx.clearIfAllowed(local.value)
return
}
ctx.select(local.value)
}
const onKeyDown: JSX.EventHandlerUnion<HTMLButtonElement, KeyboardEvent> = (e) => {
invokeButtonHandler(local.onKeyDown, e)
if (e.defaultPrevented || disabled()) return
const t = e.currentTarget
const horizontal = t.closest(`[data-slot="segmented-control-v2"]`)?.matches(":dir(rtl)") ? -1 : 1
if (e.key === "ArrowRight") {
e.preventDefault()
ctx.focusNext(t, horizontal)
} else if (e.key === "ArrowLeft") {
e.preventDefault()
ctx.focusNext(t, -horizontal as 1 | -1)
return
}
// accessibility stuff
else if (e.key === "Home") {
if (e.key === "ArrowLeft") {
e.preventDefault()
ctx.focusNext(t, horizontal === 1 ? -1 : 1)
return
}
if (e.key === "Home") {
e.preventDefault()
const root = t.closest(`[data-slot="segmented-control-v2"]`)
@@ -191,7 +351,11 @@ export function SegmentedControlItem(props: SegmentedControlItemProps) {
)
first?.focus()
} else if (e.key === "End") {
return
}
if (e.key === "End") {
e.preventDefault()
const root = t.closest(`[data-slot="segmented-control-v2"]`)
@@ -204,9 +368,17 @@ export function SegmentedControlItem(props: SegmentedControlItemProps) {
}
}
const assignRef = (el: HTMLButtonElement) => {
buttonEl = el
const r = local.ref
if (r instanceof Function) r(el)
}
return (
<button
{...rest}
ref={assignRef}
type="button"
data-slot="segmented-control-v2-item"
data-pressed={pressed() ? "" : undefined}
@@ -223,3 +395,11 @@ export function SegmentedControlItem(props: SegmentedControlItemProps) {
</button>
)
}
export const SegmentedControlV2 = SegmentedControl
export const SegmentedControlItemV2 = SegmentedControlItem
export type SegmentedControlV2Props = SegmentedControlProps
export type SegmentedControlItemV2Props = SegmentedControlItemProps
@@ -1,10 +1,6 @@
* {
[data-component="tabs-v2"] {
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-rendering: geometricPrecision;
}
[data-component="tabs-v2"] {
width: 100%;
height: 100%;
display: flex;
@@ -66,6 +66,7 @@ You can also access the models directly through the following API endpoints.
| Claude Opus 4.7 | claude-opus-4-7 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
| Claude Opus 4.6 | claude-opus-4-6 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
| Claude Opus 4.5 | claude-opus-4-5 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
| Claude Sonnet 5.5 | claude-sonnet-5-5 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
| Claude Sonnet 5 | claude-sonnet-5 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
| Claude Sonnet 4.6 | claude-sonnet-4-6 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
| Claude Sonnet 4.5 | claude-sonnet-4-5 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
@@ -247,6 +248,7 @@ Console uses pay-as-you-go pricing. Below are the prices **per 1M tokens**.
| Claude Opus 4.7 | $5.00 | $25.00 | $0.50 | $6.25 |
| Claude Opus 4.6 | $5.00 | $25.00 | $0.50 | $6.25 |
| Claude Opus 4.5 | $5.00 | $25.00 | $0.50 | $6.25 |
| Claude Sonnet 5.5 | $2.00 | $10.00 | $0.20 | $2.50 |
| Claude Sonnet 5 | $2.00 | $10.00 | $0.20 | $2.50 |
| Claude Sonnet 4.6 | $3.00 | $15.00 | $0.30 | $3.75 |
| Claude Sonnet 4.5 (≤ 200K tokens) | $3.00 | $15.00 | $0.30 | $3.75 |