mirror of
https://github.com/anomalyco/opencode.git
synced 2026-10-08 08:18:32 +00:00
Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0f2b10993d | ||
|
|
8f4861875f | ||
|
|
6861df7638 | ||
|
|
53533fd0bf | ||
|
|
3b684acd8d | ||
|
|
5d05835023 | ||
|
|
0d07f915d2 | ||
|
|
216c311744 | ||
|
|
6297cd5a0b |
No files matched your search
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
@@ -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:",
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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", {
|
||||
|
||||
@@ -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]"
|
||||
|
||||
@@ -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}`
|
||||
})
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 })),
|
||||
),
|
||||
}),
|
||||
|
||||
@@ -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")
|
||||
}),
|
||||
),
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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"] {
|
||||
|
||||
@@ -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:",
|
||||
|
||||
@@ -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 |
|
||||
|
||||
Reference in new issue
Block a user