mirror of
https://github.com/anomalyco/opencode.git
synced 2026-09-09 02:16:23 +00:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
143ce7c1a0 |
@@ -1,67 +0,0 @@
|
||||
import { expect, story } from "../../storybook/playwright/story"
|
||||
|
||||
story("settings menu reconnect retains its prompt handler across server updates", async ({ mount, page }) => {
|
||||
const component = await mount("app-dialog-ssh--settings-reconnect")
|
||||
await component.getByRole("button", { name: "More options" }).click()
|
||||
await page.getByRole("menuitem", { name: "Connect", exact: true }).click()
|
||||
const dialog = page.getByRole("dialog")
|
||||
await expect(dialog.getByRole("textbox")).toBeVisible()
|
||||
await dialog.getByRole("textbox").fill("fixture-password")
|
||||
await dialog.getByRole("button", { name: "Continue" }).click()
|
||||
await expect(dialog.getByRole("textbox", { name: "Verification code:" })).toBeVisible()
|
||||
await dialog.getByRole("textbox").fill("123456")
|
||||
await dialog.getByRole("button", { name: "Continue" }).click()
|
||||
await expect(dialog).toHaveCount(0)
|
||||
await expect(component.getByRole("button", { name: "Authenticate", exact: true })).toHaveCount(0)
|
||||
})
|
||||
|
||||
story("cancelling a version mismatch permits reconnecting again", async ({ mount, page }) => {
|
||||
const component = await mount("app-dialog-ssh--incompatible-session")
|
||||
await component.getByRole("button", { name: "Reconnect", exact: true }).click()
|
||||
const dialog = page.getByRole("dialog")
|
||||
await expect(dialog.getByRole("alert")).toBeVisible()
|
||||
await dialog.getByRole("button", { name: "Cancel", exact: true }).click()
|
||||
await expect(dialog).toHaveCount(0)
|
||||
await component.getByRole("button", { name: "Reconnect", exact: true }).click()
|
||||
await expect(dialog.getByRole("alert")).toBeVisible()
|
||||
await dialog.getByRole("button", { name: "Cancel", exact: true }).click()
|
||||
await expect(dialog).toHaveCount(0)
|
||||
})
|
||||
|
||||
story("adding a server keeps all SSH challenges in the original connection dialog", async ({ mount, page }) => {
|
||||
await mount("app-dialog-ssh--host")
|
||||
const dialog = page.getByRole("dialog")
|
||||
await dialog.getByRole("textbox", { name: "Host or SSH command" }).fill("ssh devbox")
|
||||
await dialog.getByRole("button", { name: "Add server", exact: true }).click()
|
||||
await expect(dialog).toHaveCount(1)
|
||||
await expect(dialog.getByRole("button", { name: "Cancel", exact: true })).toBeFocused()
|
||||
await dialog.getByRole("button", { name: "Trust and connect" }).click()
|
||||
await dialog.getByRole("textbox").fill("fixture-password")
|
||||
await dialog.getByRole("button", { name: "Continue", exact: true }).click()
|
||||
await dialog.getByRole("textbox", { name: "Verification code:" }).fill("123456")
|
||||
await dialog.getByRole("button", { name: "Continue", exact: true }).click()
|
||||
await expect(dialog).toHaveCount(0)
|
||||
})
|
||||
|
||||
story("updating an incompatible connection continues authentication in the same dialog", async ({ mount, page }) => {
|
||||
const component = await mount("app-dialog-ssh--incompatible-session")
|
||||
await component.getByRole("button", { name: "Reconnect", exact: true }).click()
|
||||
const dialog = page.getByRole("dialog")
|
||||
await dialog.getByRole("button", { name: "Update and reconnect", exact: true }).click()
|
||||
await expect(dialog).toHaveCount(1)
|
||||
await dialog.getByRole("textbox").fill("fixture-password")
|
||||
await dialog.getByRole("button", { name: "Continue", exact: true }).click()
|
||||
await dialog.getByRole("textbox", { name: "Verification code:" }).fill("123456")
|
||||
await dialog.getByRole("button", { name: "Continue", exact: true }).click()
|
||||
await expect(dialog).toHaveCount(0)
|
||||
await expect(component.getByText("Session connected")).toBeVisible()
|
||||
})
|
||||
|
||||
story("key-based reconnect completes without opening an authentication dialog", async ({ mount, page }) => {
|
||||
const component = await mount("app-dialog-ssh--key-reconnect")
|
||||
await component.getByRole("button", { name: "Reconnect", exact: true }).click()
|
||||
await expect(component.getByRole("button", { name: "Connecting to SSH server" })).toBeDisabled()
|
||||
await expect(page.getByRole("dialog")).toHaveCount(0)
|
||||
await expect(component.getByText("Session connected")).toBeVisible()
|
||||
await expect(page.getByRole("dialog")).toHaveCount(0)
|
||||
})
|
||||
@@ -39,7 +39,7 @@ for (const width of [1400, 390]) {
|
||||
reducedMotion: true,
|
||||
viewport: { width, height: 900 },
|
||||
})
|
||||
await page.getByRole("button", { name: "1 used Patch", exact: true }).click()
|
||||
await page.getByRole("button", { name: "Used 1 Patch", exact: true }).click()
|
||||
const patch = page.locator('[data-component="apply-patch-tool"]')
|
||||
const trigger = patch.getByRole("button", { name: /patch-border.ts/ })
|
||||
await expect(trigger).toHaveAttribute("aria-expanded", "false")
|
||||
|
||||
@@ -308,8 +308,8 @@ for (const delivery of ["steer", "queue"] as const) {
|
||||
})
|
||||
const tools = page.locator('[data-timeline-part-ids="tool_queue_read,tool_queue_grep"]')
|
||||
await expect(tools).toBeVisible()
|
||||
await expect(tools).toHaveText(/^2 used\s*Read, Grep$/)
|
||||
await expect(tools.locator('[data-slot="basic-tool-tool-title"]')).toHaveText("Read, Grep")
|
||||
await expect(tools).toHaveText(/^Used\s*1 Read, 1 Grep$/)
|
||||
await expect(tools.locator('[data-slot="basic-tool-tool-title"]')).toHaveText("1 Read, 1 Grep")
|
||||
await expect(thinking).toHaveCount(0)
|
||||
await expect(pending).toBeVisible()
|
||||
expect(mock.rows.map((row) => ({ id: row.id, delivery: row.delivery }))).toEqual([
|
||||
@@ -318,7 +318,7 @@ for (const delivery of ["steer", "queue"] as const) {
|
||||
await transcript.screenshot({ path: testInfo.outputPath("pending-steer.png") })
|
||||
|
||||
// Soft assertions let delivery run too, even when the pending ordering regresses.
|
||||
await expect.soft(tools.or(pending)).toHaveText([/^2 used\s*Read, Grep$/, /U2: Also check the retry path\./])
|
||||
await expect.soft(tools.or(pending)).toHaveText([/^Used\s*1 Read, 1 Grep$/, /U2: Also check the retry path\./])
|
||||
await expect
|
||||
.soft(transcript.locator('[data-timeline-row="AssistantPart"]').filter({ has: tools }))
|
||||
.toHaveAttribute("data-message-id", userID)
|
||||
@@ -350,7 +350,7 @@ for (const delivery of ["steer", "queue"] as const) {
|
||||
await expect(response).toHaveAttribute("data-message-id", inboxID)
|
||||
await expect(thinking).toHaveCount(0)
|
||||
await expect(tools.or(pending).or(response)).toHaveText([
|
||||
/^2 used\s*Read, Grep$/,
|
||||
/^Used\s*1 Read, 1 Grep$/,
|
||||
/U2: Also check the retry path\./,
|
||||
/A3: Now checking the retry path for U2\./,
|
||||
])
|
||||
|
||||
@@ -25,7 +25,7 @@ test("space activates a focused timeline button instead of scrolling", async ({
|
||||
seedHistory: true,
|
||||
})
|
||||
const scroller = page.locator(".scroll-view__viewport", { has: page.locator("[data-timeline-row]") })
|
||||
const trigger = page.getByRole("button", { name: "1 used Shell", exact: true })
|
||||
const trigger = page.getByRole("button", { name: "Used 1 Shell", exact: true })
|
||||
await expect
|
||||
.poll(() => scroller.evaluate((element) => element.scrollHeight - element.clientHeight))
|
||||
.toBeGreaterThan(300)
|
||||
|
||||
@@ -93,8 +93,8 @@ test.describe("regression: session timeline local row state", () => {
|
||||
await expectSessionTitle(page, title)
|
||||
|
||||
const group = page.locator('[data-component="collapsed-tool-group"]')
|
||||
const summary = group.getByRole("button", { name: /^\d+ used Patch$/ })
|
||||
await expect(summary).toHaveAccessibleName("1 used Patch")
|
||||
const summary = group.getByRole("button", { name: /^Used \d+ Patch$/ })
|
||||
await expect(summary).toHaveAccessibleName("Used 1 Patch")
|
||||
await summary.click()
|
||||
await group.locator(`[data-timeline-part-id="${editPartID}"]`).evaluate((element) => {
|
||||
element.setAttribute("data-disclosure-probe", "existing")
|
||||
@@ -110,8 +110,8 @@ test.describe("regression: session timeline local row state", () => {
|
||||
if (count === 3) await trigger.click()
|
||||
const id = `prt_patch_${count}`
|
||||
events.push(...toolEvents({ ...part, id, callID: id }))
|
||||
await expect(summary).toHaveAccessibleName(`${count} used Patch`)
|
||||
await expect(summary.locator('[data-slot="basic-tool-tool-title"]')).toHaveText("Patch")
|
||||
await expect(summary).toHaveAccessibleName(`Used ${count} Patch`)
|
||||
await expect(summary.locator('[data-slot="basic-tool-tool-title"]')).toHaveText(`${count} Patch`)
|
||||
await expect(group).toHaveAttribute("data-timeline-part-ids", new RegExp(`${id}$`))
|
||||
await expect(trigger).toHaveAttribute("aria-expanded", String(count === 2))
|
||||
await expect(summary).toHaveAttribute("aria-expanded", "true")
|
||||
|
||||
@@ -55,7 +55,7 @@ test.describe("regression: session timeline context group resize", () => {
|
||||
await devtools.send("Emulation.setCPUThrottlingRate", { rate: 4 })
|
||||
const context = page.locator(`[data-timeline-part-ids="${contextIDs.join(",")}"]`).first()
|
||||
await expectAppVisible(context)
|
||||
await expect(context.getByRole("button")).toHaveAccessibleName("4 used Read, Glob, Grep, List")
|
||||
await expect(context.getByRole("button")).toHaveAccessibleName("Used 1 Read, 1 Glob, 1 Grep, 1 List")
|
||||
|
||||
const contextSelector = `[data-timeline-part-ids="${contextIDs.join(",")}"]`
|
||||
const regions = defineVisualRegions({
|
||||
@@ -88,7 +88,7 @@ test.describe("regression: session timeline context group resize", () => {
|
||||
await page.waitForTimeout(delay)
|
||||
}
|
||||
|
||||
await expect(context.getByRole("button")).toHaveAccessibleName("4 used Read, Glob, Grep, List")
|
||||
await expect(context.getByRole("button")).toHaveAccessibleName("Used 1 Read, 1 Glob, 1 Grep, 1 List")
|
||||
await page.waitForTimeout(700)
|
||||
const trace = await stopVisualProbe<keyof typeof regions>(page)
|
||||
const labels = trace.samples
|
||||
@@ -107,7 +107,7 @@ test.describe("regression: session timeline context group resize", () => {
|
||||
]),
|
||||
)
|
||||
|
||||
expect(labels).toEqual(["4 used Read, Glob, Grep, List"])
|
||||
expect(labels).toEqual(["Used 1 Read, 1 Glob, 1 Grep, 1 List"])
|
||||
expect(issues, JSON.stringify(trace.samples, null, 2)).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -16,8 +16,8 @@ for (const locale of ["de", "ar"] as const) {
|
||||
})
|
||||
|
||||
const group = page.locator(`[data-timeline-part-ids="${ids.join(",")}"]`)
|
||||
const names = locale === "de" ? "Lesen, Glob" : "\u0642\u0631\u0627\u0621\u0629, Glob"
|
||||
await expect(group.getByRole("button")).toHaveAccessibleName(`2 used ${names}`)
|
||||
const names = locale === "de" ? "1 Lesen, 1 Glob" : "1 \u0642\u0631\u0627\u0621\u0629, 1 Glob"
|
||||
await expect(group.getByRole("button")).toHaveAccessibleName(`Used ${names}`)
|
||||
await expect(group.locator('[data-slot="basic-tool-tool-title"]')).toHaveText(names)
|
||||
await expect(page.locator("html")).toHaveAttribute("lang", locale)
|
||||
})
|
||||
|
||||
@@ -386,7 +386,7 @@ test("separates blocking and already-backgrounded work into two rows", async ({
|
||||
const used = page
|
||||
.locator('[data-timeline-part-ids="call_backgrounded,call_shell_backgrounded,call_blocking"]')
|
||||
.locator(':scope > [data-component="collapsible"] > [data-slot="collapsible-trigger"]')
|
||||
await expect(used).toHaveText(/^3 used\s*Agent, Shell$/)
|
||||
await expect(used).toHaveText(/^Used\s*2 Agent, 1 Shell$/)
|
||||
await expect(used).toHaveAttribute("aria-expanded", "false")
|
||||
await used.click()
|
||||
await expect(used).toHaveAttribute("aria-expanded", "true")
|
||||
|
||||
@@ -46,7 +46,7 @@ test("changes timeline presets and saves custom thinking details", async ({ page
|
||||
.toEqual({ placement: "grouped", details: "collapsed" })
|
||||
await settings.getByRole("button", { name: "Back to app", exact: true }).click()
|
||||
await expect(settings).toBeHidden()
|
||||
await page.getByRole("button", { name: "1 used Thought", exact: true }).click()
|
||||
await page.getByRole("button", { name: "Used 1 Thought", exact: true }).click()
|
||||
await expect(part.getByRole("button")).toHaveAttribute("aria-expanded", "false")
|
||||
await part.getByRole("button").click()
|
||||
await expect(part.getByText("The selected mode controls these details.", { exact: true })).toBeVisible()
|
||||
|
||||
@@ -45,10 +45,10 @@ test("expands a mixed collapsed tool stack without expanding its individual call
|
||||
const group = page.locator(
|
||||
'[data-timeline-part-ids="prt_stack_shell_1,prt_stack_explore,prt_stack_patch,prt_stack_shell_2"]',
|
||||
)
|
||||
const summary = group.getByRole("button", { name: "4 used Shell, Agent, Patch", exact: true })
|
||||
const summary = group.getByRole("button", { name: "Used 2 Shell, 1 Agent, 1 Patch", exact: true })
|
||||
await expect(summary).toHaveAttribute("aria-expanded", "false")
|
||||
await expect(summary).toHaveCSS("height", "28px")
|
||||
await expect(summary.locator('[data-slot="basic-tool-tool-title"]')).toHaveText("Shell, Agent, Patch")
|
||||
await expect(summary.locator('[data-slot="basic-tool-tool-title"]')).toHaveText("2 Shell, 1 Agent, 1 Patch")
|
||||
await expect(summary.locator('[data-component="tag"]')).toHaveCount(0)
|
||||
await summary.click()
|
||||
await expect(summary).toHaveAttribute("aria-expanded", "true")
|
||||
@@ -75,8 +75,8 @@ test("leaves tools expanded by settings outside the collapsed stack", async ({ p
|
||||
|
||||
await expect(page.locator('[data-timeline-part-id="prt_expanded_shell"]')).toBeVisible()
|
||||
const group = page.locator('[data-timeline-part-ids="prt_collapsed_patch,prt_collapsed_read"]')
|
||||
await expect(group.getByRole("button", { name: "2 used Patch, Read", exact: true })).toBeVisible()
|
||||
await expect(group.locator('[data-slot="basic-tool-tool-title"]')).toHaveText("Patch, Read")
|
||||
await expect(group.getByRole("button", { name: "Used 1 Patch, 1 Read", exact: true })).toBeVisible()
|
||||
await expect(group.locator('[data-slot="basic-tool-tool-title"]')).toHaveText("1 Patch, 1 Read")
|
||||
await expect(page.locator('[data-timeline-spacing="tool"]')).toHaveCSS("padding-top", "8px")
|
||||
})
|
||||
|
||||
@@ -114,7 +114,7 @@ test("combines follow-up patches into one three-file stack inside Used", async (
|
||||
],
|
||||
})
|
||||
const group = page.locator('[data-component="collapsed-tool-group"]')
|
||||
await group.getByRole("button", { name: "2 used Shell, Patch", exact: true }).click()
|
||||
await group.getByRole("button", { name: "Used 1 Shell, 1 Patch", exact: true }).click()
|
||||
await expect(group.getByText("2 files", { exact: true })).toBeVisible()
|
||||
await timeline.send(
|
||||
partUpdated(
|
||||
@@ -129,7 +129,7 @@ test("combines follow-up patches into one three-file stack inside Used", async (
|
||||
),
|
||||
),
|
||||
)
|
||||
await expect(group.getByRole("button", { name: "3 used Shell, Patch", exact: true })).toHaveAttribute(
|
||||
await expect(group.getByRole("button", { name: "Used 1 Shell, 2 Patch", exact: true })).toHaveAttribute(
|
||||
"aria-expanded",
|
||||
"true",
|
||||
)
|
||||
@@ -162,7 +162,7 @@ test("keeps failed search calls and their error cards inside the collapsed stack
|
||||
await setupTimeline(page, { messages: [userMessage(), assistantMessage(parts)] })
|
||||
|
||||
const group = page.locator('[data-component="collapsed-tool-group"]')
|
||||
const summary = group.getByRole("button", { name: "2 used Glob, Grep", exact: true })
|
||||
const summary = group.getByRole("button", { name: "Used 1 Glob, 1 Grep", exact: true })
|
||||
await expect(summary).toHaveAttribute("aria-expanded", "false")
|
||||
await summary.click()
|
||||
await expect(group.locator('[data-kind="tool-error-card"]')).toHaveCount(2)
|
||||
|
||||
@@ -181,7 +181,7 @@ for (const grouped of [false, true]) {
|
||||
await expect(working).toHaveCount(0)
|
||||
return
|
||||
}
|
||||
const trigger = group.getByRole("button", { name: "2 used Shell", exact: true, includeHidden: true })
|
||||
const trigger = group.getByRole("button", { name: "Used 2 Shell", exact: true, includeHidden: true })
|
||||
await expect(trigger).toHaveAttribute("aria-expanded", "false")
|
||||
await expect(working).toBeVisible()
|
||||
await trigger.click()
|
||||
|
||||
@@ -51,7 +51,7 @@ test("shows parent lineage while the child timeline loads", async ({ page }) =>
|
||||
|
||||
await page.goto(sessionHref(parentID))
|
||||
await expectSessionTitle(page, parentTitle)
|
||||
await page.getByRole("button", { name: "1 used Agent", exact: true }).click()
|
||||
await page.getByRole("button", { name: "Used 1 Agent", exact: true }).click()
|
||||
await page.locator(`a[href="${sessionHref(childID)}"]`).click()
|
||||
await Promise.all([requested.promise, expect(page).toHaveURL(sessionHref(childID))])
|
||||
await Promise.all([
|
||||
@@ -76,7 +76,7 @@ test("keeps the parent visible while the child session resolves", async ({ page
|
||||
await page.goto(sessionHref(parentID))
|
||||
await expectSessionTitle(page, parentTitle)
|
||||
|
||||
await page.getByRole("button", { name: "1 used Agent", exact: true }).click()
|
||||
await page.getByRole("button", { name: "Used 1 Agent", exact: true }).click()
|
||||
await page.locator(`a[href="${sessionHref(childID)}"]`).click()
|
||||
await requested.promise
|
||||
await Promise.all([expect(page).toHaveURL(sessionHref(parentID)), expectSessionTitle(page, parentTitle)]).finally(
|
||||
@@ -194,7 +194,7 @@ async function setup(page: Page, events?: () => OpenCodeEvent[]) {
|
||||
async function openChildFromParent(page: Page) {
|
||||
await page.goto(sessionHref(parentID))
|
||||
await expectSessionTitle(page, parentTitle)
|
||||
await page.getByRole("button", { name: "1 used Agent", exact: true }).click()
|
||||
await page.getByRole("button", { name: "Used 1 Agent", exact: true }).click()
|
||||
|
||||
const card = page.locator(`a[href="${sessionHref(childID)}"]`)
|
||||
await expect(card).toBeVisible()
|
||||
|
||||
@@ -10,7 +10,6 @@
|
||||
"./i18n/desktop-native": "./src/runtime/i18n/desktop-native.ts",
|
||||
"./updater": "./src/shell/updates/types.ts",
|
||||
"./wsl/types": "./src/servers/wsl/types.ts",
|
||||
"./ssh": "./src/servers/ssh/types.ts",
|
||||
"./vite": "./vite.js",
|
||||
"./index.css": "./src/index.css"
|
||||
},
|
||||
|
||||
@@ -17,8 +17,6 @@ import { ServerConnection, ServersProvider } from "@/runtime/server/registry"
|
||||
import { SettingsProvider } from "@/settings/model"
|
||||
import { TabsProvider } from "@/shell/tabs/tabs"
|
||||
import { WslServersProvider } from "@/servers/wsl/context"
|
||||
import { SshProvider } from "@/servers/ssh/context"
|
||||
import { SshRestore } from "@/servers/ssh/restore"
|
||||
import { ErrorPage } from "@/shell/errors/error"
|
||||
import { AppRoutes, File, preloadRoute } from "@/shell/routes/routes"
|
||||
|
||||
@@ -83,9 +81,7 @@ export function AppBaseProviders(
|
||||
<QueryProvider>
|
||||
<WslServersProvider>
|
||||
<DialogProvider>
|
||||
<SshProvider>
|
||||
<FileComponentProvider component={File}>{props.children}</FileComponentProvider>
|
||||
</SshProvider>
|
||||
<FileComponentProvider component={File}>{props.children}</FileComponentProvider>
|
||||
</DialogProvider>
|
||||
</WslServersProvider>
|
||||
</QueryProvider>
|
||||
@@ -113,7 +109,6 @@ export function AppInterface(props: {
|
||||
<BodyTypography />
|
||||
<CommandProvider>
|
||||
<DesktopCommands />
|
||||
<SshRestore />
|
||||
<HighlightsProvider>
|
||||
{props.children}
|
||||
{rootProps.children}
|
||||
|
||||
@@ -841,8 +841,8 @@ export function ComposerEditorSubmitButton(props: {
|
||||
disabled={!props.stopping && props.disabled}
|
||||
tabIndex={props.mode === "normal" ? undefined : -1}
|
||||
icon={<Icon name={props.stopping ? "stop" : props.mode === "shell" ? "arrow-undo-down" : "arrow-up"} />}
|
||||
variant="submit"
|
||||
class="size-7 rounded-md p-[6px]"
|
||||
variant="contrast"
|
||||
class="size-7 rounded-md p-[6px] disabled:opacity-50"
|
||||
aria-label={props.stopping ? props.stopLabel : props.sendLabel}
|
||||
onClick={(event) => {
|
||||
event.preventDefault()
|
||||
|
||||
@@ -37,7 +37,6 @@ export type ComposerEditorView = {
|
||||
agent?: ComposerSelectControl
|
||||
variant?: ComposerSelectControl
|
||||
submit: {
|
||||
available?: Accessor<boolean>
|
||||
stopping: Accessor<boolean>
|
||||
working?: Accessor<boolean>
|
||||
queue?: ComposerQueue
|
||||
@@ -334,7 +333,6 @@ export function createComposerEditor(input: {
|
||||
draft.removeAttachment(id)
|
||||
},
|
||||
canSubmit() {
|
||||
if (input.view.submit.available?.() === false) return false
|
||||
if (input.view.draftOnly) return false
|
||||
const persisted = draft.state
|
||||
if (state.mode === "shell") {
|
||||
@@ -367,7 +365,6 @@ export function createComposerEditor(input: {
|
||||
dispatch({ type: "mode.shell" })
|
||||
},
|
||||
submit(options?: { alternate?: boolean }) {
|
||||
if (input.view.submit.available?.() === false) return
|
||||
if (input.view.draftOnly) return
|
||||
input.view.submit.onSubmit(options)
|
||||
dispatch({ type: "popover.close" })
|
||||
|
||||
@@ -11,7 +11,7 @@ import { useLanguage } from "@/runtime/i18n/language"
|
||||
import { useLayout } from "@/shell/state/layout"
|
||||
import { usePlatform } from "@/runtime/platform/platform"
|
||||
import { useWorkspaceLocation } from "@/workspaces/location"
|
||||
import { useData, useServer } from "@/runtime/server/current"
|
||||
import { useData } from "@/runtime/server/current"
|
||||
import { createSessionTabs } from "@/session/helpers"
|
||||
import { showToast } from "@/shell/notifications/toast"
|
||||
import { formatServerError } from "@/runtime/server/errors"
|
||||
@@ -30,8 +30,6 @@ export type ComposerModel = ComposerEditorModel & {
|
||||
export function createComposerModel(adapter: ComposerAdapter, options?: { queue?: ComposerQueue }): ComposerModel {
|
||||
const sdk = useWorkspaceLocation()
|
||||
const data = useData()
|
||||
const server = useServer()
|
||||
const available = () => server.conn.type !== "ssh" || server.ctx.sdk.connection.status() === "connected"
|
||||
const files = useFile()
|
||||
const layout = useLayout()
|
||||
const comments = useComments()
|
||||
@@ -396,12 +394,10 @@ export function createComposerModel(adapter: ComposerAdapter, options?: { queue?
|
||||
keybind: () => command.keybindParts("model.variant.cycle"),
|
||||
},
|
||||
submit: {
|
||||
available,
|
||||
stopping,
|
||||
working: adapter.working,
|
||||
queue: options?.queue,
|
||||
onSubmit: (submitOptions) => {
|
||||
if (!available()) return
|
||||
const queue = options?.queue
|
||||
// Confirming an edit re-admits the queued prompt instead of sending
|
||||
// the composer value as a new prompt. Enter keeps it queued in
|
||||
|
||||
@@ -20,5 +20,4 @@ export { createDraftStore } from "./runtime/persistence/drafts"
|
||||
export { createNamespaceStorage, type NamespaceStorage } from "./runtime/persistence/namespace"
|
||||
export { flushPersisted } from "./runtime/persistence/persist"
|
||||
export { useWslServers } from "./servers/wsl/context"
|
||||
export { useSsh } from "./servers/ssh/context"
|
||||
export { type UpdaterPlatform, type UpdaterState } from "./shell/updates/types"
|
||||
|
||||
@@ -15,7 +15,6 @@ import { Persistence } from "@/runtime/persistence/schema"
|
||||
import type { HomeController } from "../model"
|
||||
import { useGlobal } from "@/runtime/server/runtime"
|
||||
import { SessionTransfer } from "@opencode/schema/session-transfer"
|
||||
import { useSshAuthenticate } from "@/servers/ssh/authenticate"
|
||||
|
||||
export const HomeServersSchema = Schema.Struct({
|
||||
collapsed: Persistence.record(Persistence.fallback(Schema.Boolean, () => false)),
|
||||
@@ -29,7 +28,6 @@ export function createHomeProjectsController(home: HomeController) {
|
||||
const openSettings = useSettingsCommand()
|
||||
const serverManagement = useServerActionsController()
|
||||
const global = useGlobal()
|
||||
const authenticate = useSshAuthenticate()
|
||||
const [_state, setState, _, ready] = persisted(Persist.global("home.servers"), HomeServersSchema, { collapsed: {} })
|
||||
const [state] = createResource(
|
||||
() => ready.promise ?? Promise.resolve(),
|
||||
@@ -44,15 +42,6 @@ export function createHomeProjectsController(home: HomeController) {
|
||||
return platform.platform === "desktop" && !!platform.openPath && ServerConnection.local(conn)
|
||||
}
|
||||
|
||||
function choose(conn: ServerConnection.Any) {
|
||||
pickDirectory({
|
||||
server: conn,
|
||||
title: language.t("command.project.open"),
|
||||
multiple: true,
|
||||
onSelect: (result) => home.project.add(conn, homeProjectDirectories(result)),
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
copy: {
|
||||
language,
|
||||
@@ -82,25 +71,15 @@ export function createHomeProjectsController(home: HomeController) {
|
||||
void dialog.show(() => <DialogServer mode="edit" server={conn} />)
|
||||
})
|
||||
},
|
||||
authenticate: (conn: ServerConnection.Any) => authenticate(conn),
|
||||
focus: (conn: ServerConnection.Any) => {
|
||||
if (authenticate(conn, () => home.selection.focusServer(conn))) return
|
||||
home.selection.focusServer(conn)
|
||||
},
|
||||
focus: home.selection.focusServer,
|
||||
},
|
||||
project: {
|
||||
list: home.project.list,
|
||||
recentlyClosed: home.project.recentlyClosed,
|
||||
homedir: home.project.homedir,
|
||||
select: (conn: ServerConnection.Any, directory: string) => {
|
||||
if (authenticate(conn, () => home.project.select(conn, directory))) return
|
||||
home.project.select(conn, directory)
|
||||
},
|
||||
select: home.project.select,
|
||||
add: home.project.add,
|
||||
openNewSession: (conn: ServerConnection.Any, directory: string) => {
|
||||
if (authenticate(conn, () => home.project.openProjectNewSession(conn, directory))) return
|
||||
home.project.openProjectNewSession(conn, directory)
|
||||
},
|
||||
openNewSession: home.project.openProjectNewSession,
|
||||
canImportSession: !!platform.openAttachmentPickerDialog,
|
||||
importSession: (conn: ServerConnection.Any, project: LocalProject) => {
|
||||
if (!platform.openAttachmentPickerDialog) return
|
||||
@@ -146,9 +125,13 @@ export function createHomeProjectsController(home: HomeController) {
|
||||
.forEach((directory) => notification.project.markViewed(directory))
|
||||
},
|
||||
choose: (conn: ServerConnection.Any) => {
|
||||
if (authenticate(conn, () => choose(conn))) return
|
||||
if (home.server.health(conn)?.healthy === false) return
|
||||
choose(conn)
|
||||
pickDirectory({
|
||||
server: conn,
|
||||
title: language.t("command.project.open"),
|
||||
multiple: true,
|
||||
onSelect: (result) => home.project.add(conn, homeProjectDirectories(result)),
|
||||
})
|
||||
},
|
||||
close: (conn: ServerConnection.Any, directory: string) => {
|
||||
const next = closeHomeProject(
|
||||
|
||||
@@ -26,7 +26,6 @@ export function HomeProjects(props: {
|
||||
onWheel={props.scroll.viewport.containWheel}
|
||||
onChooseProject={props.projects.project.choose}
|
||||
onFocusServer={props.projects.server.focus}
|
||||
onAuthenticateServer={props.projects.server.authenticate}
|
||||
onToggleCollapsed={props.projects.server.toggleCollapsed}
|
||||
onEditServer={props.projects.server.edit}
|
||||
onSetDefaultServer={props.projects.server.setDefault}
|
||||
|
||||
@@ -1,59 +0,0 @@
|
||||
/* Home server/project rows keep the label underneath the hover actions.
|
||||
The actions carry a tab-style background with a fade on the left, and the
|
||||
label fades out where it slides underneath. Mirrors tab-nav.css. */
|
||||
[data-home-row] {
|
||||
--home-row-surface: var(--v2-background-bg-base);
|
||||
--home-row-background: color-mix(
|
||||
in srgb,
|
||||
var(--home-row-surface) var(--home-row-opacity, 100%),
|
||||
var(--v2-background-bg-base)
|
||||
);
|
||||
background: var(--home-row-background);
|
||||
}
|
||||
|
||||
[data-home-row]:is(:hover, :focus-within, [data-dragging="true"], :has([data-menu="true"])) {
|
||||
--home-row-surface: var(--v2-background-bg-layer-01);
|
||||
}
|
||||
|
||||
[data-home-row][data-selected] {
|
||||
--home-row-surface: var(--v2-background-bg-layer-03);
|
||||
}
|
||||
|
||||
[data-home-row][data-dimmed="true"] {
|
||||
--home-row-opacity: 60%;
|
||||
}
|
||||
|
||||
/* Keep the background outside the button's disabled-content opacity. */
|
||||
[data-home-row] > button {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
[data-home-row] [data-slot="home-row-actions"] {
|
||||
background: linear-gradient(to right, transparent, var(--home-row-background) 8px);
|
||||
}
|
||||
|
||||
[data-home-row]:dir(rtl) [data-slot="home-row-actions"] {
|
||||
background: linear-gradient(to left, transparent, var(--home-row-background) 8px);
|
||||
}
|
||||
|
||||
[data-home-row]:is(:hover, :focus-within, :has([data-menu="true"])) [data-slot="home-row-label"] {
|
||||
-webkit-mask-image: linear-gradient(to right, black 0, black calc(100% - 44px), transparent calc(100% - 36px));
|
||||
mask-image: linear-gradient(to right, black 0, black calc(100% - 44px), transparent calc(100% - 36px));
|
||||
}
|
||||
|
||||
[data-home-row]:is(:hover, :focus-within, :has([data-menu="true"])):dir(rtl) [data-slot="home-row-label"] {
|
||||
-webkit-mask-image: linear-gradient(to left, black 0, black calc(100% - 44px), transparent calc(100% - 36px));
|
||||
mask-image: linear-gradient(to left, black 0, black calc(100% - 44px), transparent calc(100% - 36px));
|
||||
}
|
||||
|
||||
@media (hover: none) {
|
||||
[data-home-row] [data-slot="home-row-label"] {
|
||||
-webkit-mask-image: linear-gradient(to right, black 0, black calc(100% - 44px), transparent calc(100% - 36px));
|
||||
mask-image: linear-gradient(to right, black 0, black calc(100% - 44px), transparent calc(100% - 36px));
|
||||
}
|
||||
|
||||
[data-home-row]:dir(rtl) [data-slot="home-row-label"] {
|
||||
-webkit-mask-image: linear-gradient(to left, black 0, black calc(100% - 44px), transparent calc(100% - 36px));
|
||||
mask-image: linear-gradient(to left, black 0, black calc(100% - 44px), transparent calc(100% - 36px));
|
||||
}
|
||||
}
|
||||
@@ -10,8 +10,6 @@ import { ScrollView } from "@opencode/ui/scroll-view"
|
||||
import { ProjectAvatar } from "@opencode/ui/project-avatar"
|
||||
import { Icon } from "@opencode/ui/icon"
|
||||
import { IconButton } from "@opencode/ui/icon-button"
|
||||
import { Button } from "@opencode/ui/button"
|
||||
import { Spinner } from "@opencode/ui/spinner"
|
||||
import { Menu } from "@opencode/ui/menu"
|
||||
import { Tooltip } from "@opencode/ui/tooltip"
|
||||
import { getProjectAvatarVariant, type HomeProjectSelection, type LocalProject } from "@/shell/state/layout"
|
||||
@@ -23,7 +21,6 @@ import { ServerRowMenuView, serverMenuLabels } from "@/servers/registry/row-menu
|
||||
import { ServerHealthIndicator } from "@/servers/registry/row"
|
||||
import { type ServerHealth } from "@/runtime/server/health"
|
||||
import { fileManagerApp } from "@/home/projects/file-manager"
|
||||
import "./view.css"
|
||||
|
||||
const HOME_PROJECT_NAV_LABEL = "min-w-0 flex-1 overflow-hidden text-ellipsis whitespace-nowrap"
|
||||
|
||||
@@ -49,7 +46,6 @@ export type HomeProjectsViewProps = {
|
||||
onWheel: (event: WheelEvent) => void
|
||||
onChooseProject: (server: ServerConnection.Any) => void
|
||||
onFocusServer: (server: ServerConnection.Any) => void
|
||||
onAuthenticateServer?: (server: ServerConnection.Any) => void
|
||||
onToggleCollapsed: (server: ServerConnection.Any) => void
|
||||
onEditServer: (server: ServerConnection.Http) => void
|
||||
onSetDefaultServer: (server: ServerConnection.Any | undefined) => void
|
||||
@@ -126,10 +122,6 @@ export function HomeProjectsView(props: HomeProjectsViewProps) {
|
||||
props.onFocusServer(server)
|
||||
setState("open", false)
|
||||
}}
|
||||
onAuthenticateServer={(server) => {
|
||||
setState("open", false)
|
||||
props.onAuthenticateServer?.(server)
|
||||
}}
|
||||
onChooseProject={(server) => {
|
||||
setState("open", false)
|
||||
props.onChooseProject(server)
|
||||
@@ -204,12 +196,7 @@ function HomeProjectsPanel(props: HomeProjectsViewProps) {
|
||||
</HomeProjectNavButton>
|
||||
</Show>
|
||||
<Show
|
||||
when={
|
||||
props.servers.length > 1 ||
|
||||
props.servers.some(
|
||||
(server) => server.type === "ssh" && (server.authenticationRequired || server.connecting),
|
||||
)
|
||||
}
|
||||
when={props.servers.length > 1}
|
||||
fallback={
|
||||
<Show when={props.servers[0]}>
|
||||
{(server) => (
|
||||
@@ -244,8 +231,6 @@ function HomeProjectsPanel(props: HomeProjectsViewProps) {
|
||||
const healthy = () => !!props.serverHealth(item)?.healthy
|
||||
const hasProjects = () => projects().length > 0
|
||||
const collapsed = () => props.collapsed(item)
|
||||
const authentication = () => item.type === "ssh" && item.authenticationRequired
|
||||
const connecting = () => item.type === "ssh" && item.connecting
|
||||
return (
|
||||
<div class="flex min-w-0 flex-col gap-1">
|
||||
<HomeServerRow
|
||||
@@ -256,26 +241,7 @@ function HomeProjectsPanel(props: HomeProjectsViewProps) {
|
||||
collapsed={collapsed()}
|
||||
health={props.serverHealth(item)}
|
||||
/>
|
||||
<Show when={authentication() || connecting()}>
|
||||
<div class="mx-3 h-px bg-v2-border-border-base" />
|
||||
<div class="px-1.5 py-1">
|
||||
<Button
|
||||
data-action="home-server-authenticate"
|
||||
class="w-full"
|
||||
size="small"
|
||||
variant="neutral"
|
||||
disabled={connecting()}
|
||||
aria-busy={!!connecting()}
|
||||
onClick={() => props.onAuthenticateServer?.(item)}
|
||||
>
|
||||
<Show when={connecting()}>
|
||||
<Spinner class="size-3.5" />
|
||||
</Show>
|
||||
{props.language.t(connecting() ? "ssh.stage.connecting" : "ssh.action.authenticate")}
|
||||
</Button>
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={healthy() && !authentication() && !connecting() && hasProjects() && !collapsed()}>
|
||||
<Show when={healthy() && hasProjects() && !collapsed()}>
|
||||
<div class="mx-3 h-px bg-v2-border-border-base" />
|
||||
<HomeProjectList {...props} {...contextMenuProps} server={item} items={projects()} />
|
||||
</Show>
|
||||
@@ -348,7 +314,6 @@ function HomeServerRow(props: {
|
||||
health: ServerHealth | undefined
|
||||
}) {
|
||||
const healthy = () => !!props.health?.healthy
|
||||
const authentication = () => props.server.type === "ssh" && props.server.authenticationRequired
|
||||
const incompatible = () => !!props.health?.incompatible
|
||||
const canToggle = () => healthy() && props.projectsForServer(props.server).length > 0
|
||||
const contextMenuID = () => serverContextMenuID(props.server)
|
||||
@@ -361,23 +326,16 @@ function HomeServerRow(props: {
|
||||
appearance="standard"
|
||||
placement="top"
|
||||
class="flex h-7 w-full min-w-0"
|
||||
inactive={!incompatible() && !authentication()}
|
||||
value={
|
||||
authentication()
|
||||
? props.language.t("ssh.stage.authentication")
|
||||
: props.language.t("server.row.incompatible", { version: props.health?.version ?? "1" })
|
||||
}
|
||||
inactive={!incompatible()}
|
||||
value={props.language.t("server.row.incompatible", { version: props.health?.version ?? "1" })}
|
||||
>
|
||||
<div
|
||||
class="group/server relative flex h-7 w-full min-w-0 items-center rounded-[6px]"
|
||||
data-home-row
|
||||
data-dimmed={!healthy() && !incompatible()}
|
||||
data-selected={props.selected ? "" : undefined}
|
||||
>
|
||||
<div class="group/server relative flex h-7 w-full min-w-0 items-center rounded-[6px]">
|
||||
<HomeProjectNavButton
|
||||
type="button"
|
||||
class="pr-16"
|
||||
classList={{ "opacity-60": !healthy() && !incompatible() }}
|
||||
data-selected={props.selected ? "" : undefined}
|
||||
disabled={!healthy() && !authentication()}
|
||||
disabled={!healthy()}
|
||||
onClick={() => props.onFocusServer(props.server)}
|
||||
>
|
||||
<span
|
||||
@@ -411,18 +369,10 @@ function HomeServerRow(props: {
|
||||
/>
|
||||
</span>
|
||||
<div class="flex size-4 shrink-0 items-center justify-center -mr-0.5">
|
||||
<ServerHealthIndicator
|
||||
health={props.health}
|
||||
connecting={props.server.type === "ssh" && props.server.connecting}
|
||||
authenticationRequired={authentication()}
|
||||
/>
|
||||
<ServerHealthIndicator health={props.health} />
|
||||
</div>
|
||||
<span
|
||||
data-slot="home-row-label"
|
||||
class="flex min-w-0 flex-1 items-center gap-1"
|
||||
classList={{ "opacity-60": !healthy() && !incompatible() }}
|
||||
>
|
||||
<span class="min-w-0 overflow-hidden text-ellipsis whitespace-nowrap">
|
||||
<span class="flex min-w-0 items-center gap-1">
|
||||
<span class={HOME_PROJECT_NAV_LABEL}>
|
||||
{props.server.displayName ?? new URL(props.server.http.url).host}
|
||||
</span>
|
||||
<Show when={props.server.label}>
|
||||
@@ -440,9 +390,8 @@ function HomeServerRow(props: {
|
||||
</span>
|
||||
</HomeProjectNavButton>
|
||||
<div
|
||||
data-slot="home-row-actions"
|
||||
class={`
|
||||
hover-reveal absolute bottom-0 right-1 top-0 flex items-center gap-1 rounded-r-[6px] pl-2
|
||||
hover-reveal absolute right-1 top-1/2 flex -translate-y-1/2 items-center gap-1
|
||||
group-hover/server:opacity-100 focus-within:opacity-100 data-[menu=true]:opacity-100
|
||||
`}
|
||||
data-menu={props.contextMenuOpen(contextMenuID())}
|
||||
@@ -469,7 +418,7 @@ function HomeServerRow(props: {
|
||||
size="small"
|
||||
icon={<Icon name="folder-add-left" />}
|
||||
aria-label={props.language.t("home.project.add")}
|
||||
disabled={props.health?.healthy === false && !authentication()}
|
||||
disabled={props.health?.healthy === false}
|
||||
onClick={() => props.onChooseProject(props.server)}
|
||||
/>
|
||||
</Tooltip>
|
||||
@@ -653,10 +602,6 @@ function HomeProjectRow(
|
||||
ref={sortable.ref}
|
||||
class="group/project relative flex h-7 min-w-0 items-center rounded-[6px]"
|
||||
classList={{ "z-10": sortable.isDragSource() }}
|
||||
data-home-row
|
||||
data-dimmed={serverUnreachable()}
|
||||
data-dragging={sortable.isDragSource()}
|
||||
data-selected={props.selected ? "" : undefined}
|
||||
onContextMenu={(event) => {
|
||||
event.preventDefault()
|
||||
props.onSetContextMenuOpen(contextMenuID(), true)
|
||||
@@ -665,7 +610,7 @@ function HomeProjectRow(
|
||||
<HomeProjectNavButton
|
||||
type="button"
|
||||
data-component="home-project-row"
|
||||
class="disabled:opacity-60"
|
||||
class="pr-16 disabled:opacity-60"
|
||||
classList={{
|
||||
"bg-v2-background-bg-layer-01 text-v2-text-text-base": sortable.isDragSource(),
|
||||
}}
|
||||
@@ -702,14 +647,11 @@ function HomeProjectRow(
|
||||
}}
|
||||
>
|
||||
<HomeProjectAvatar project={props.project} />
|
||||
<span data-slot="home-row-label" class={HOME_PROJECT_NAV_LABEL}>
|
||||
{displayName(props.project)}
|
||||
</span>
|
||||
<span class={HOME_PROJECT_NAV_LABEL}>{displayName(props.project)}</span>
|
||||
</HomeProjectNavButton>
|
||||
<div
|
||||
data-slot="home-row-actions"
|
||||
class={`
|
||||
hover-reveal absolute bottom-0 right-1 top-0 flex items-center gap-1 rounded-r-[6px] pl-2
|
||||
hover-reveal absolute right-1 top-1/2 flex -translate-y-1/2 items-center gap-1
|
||||
group-hover/project:opacity-100 focus-within:opacity-100 data-[menu=true]:opacity-100
|
||||
`}
|
||||
data-menu={props.contextMenuOpen(contextMenuID())}
|
||||
|
||||
@@ -2,54 +2,6 @@ import { DESKTOP_NATIVE_ENGLISH } from "./desktop-native"
|
||||
|
||||
export const dict = {
|
||||
...DESKTOP_NATIVE_ENGLISH,
|
||||
"ssh.label": "SSH",
|
||||
"ssh.offline": "Not connected to {{host}}. Your draft is preserved; remote work may still be running.",
|
||||
"ssh.placeholder": "ssh user@example.com",
|
||||
"ssh.add": "Add SSH server",
|
||||
"ssh.server.menu.label": "SSH server",
|
||||
"ssh.target": "Host or SSH command",
|
||||
"ssh.connect": "Connect",
|
||||
"ssh.connectTo": "Connect to {{host}}",
|
||||
"ssh.authenticate": "SSH authentication",
|
||||
"ssh.action.authenticate": "Authenticate",
|
||||
"ssh.session.disconnected": "SSH connection inactive",
|
||||
"ssh.session.connecting": "Connecting to SSH server",
|
||||
"ssh.session.reconnectDescription":
|
||||
"Reconnect to view this session and continue working. Your remote session is preserved.",
|
||||
"ssh.session.reconnect": "Reconnect",
|
||||
"ssh.authenticationRequired": "Authentication required for {{host}}",
|
||||
"ssh.trust": "Trust and connect",
|
||||
"ssh.continue": "Continue",
|
||||
"ssh.retry": "Retry",
|
||||
"ssh.update": "Update and reconnect",
|
||||
"ssh.openProject": "Open project",
|
||||
"ssh.project": "Open project on {{host}}",
|
||||
"ssh.disconnect": "Disconnect",
|
||||
"ssh.forget": "Forget connection",
|
||||
"ssh.stage.disconnected": "Disconnected. The remote server is left running.",
|
||||
"ssh.stage.connecting": "Connecting over SSH…",
|
||||
"ssh.stage.checking": "Checking OpenCode…",
|
||||
"ssh.stage.downloading": "Downloading server…",
|
||||
"ssh.stage.uploading": "Uploading server…",
|
||||
"ssh.stage.starting": "Connecting to OpenCode…",
|
||||
"ssh.stage.ready": "Connected",
|
||||
"ssh.stage.authentication": "Authentication required",
|
||||
"ssh.stage.incompatible": "Server update required",
|
||||
"ssh.stage.failed": "Connection failed",
|
||||
"ssh.error.input":
|
||||
"Enter a host or SSH connection command. Remote commands and unsupported SSH options aren’t allowed.",
|
||||
"ssh.error.connection": "Could not establish the SSH connection. Check your network and SSH configuration.",
|
||||
"ssh.error.platform":
|
||||
"This remote platform is not supported. Automatic setup currently requires Linux or macOS on x64 or arm64.",
|
||||
"ssh.error.version": "The remote service must match this Desktop version before connecting.",
|
||||
"ssh.error.install":
|
||||
"Could not install the remote server. Check connectivity, disk space, and that tar is installed.",
|
||||
"ssh.error.unpublished":
|
||||
"This Desktop version has no published remote server. For development builds, install and start V2 on the host, then retry.",
|
||||
"ssh.error.service": "SSH connected, but the OpenCode server did not become ready.",
|
||||
"ssh.error.host-key":
|
||||
"The host’s identity could not be verified. Verify its fingerprint before updating your SSH known hosts.",
|
||||
"ssh.error.ssh-missing": "OpenSSH was not found. Install an OpenSSH client and ensure ssh is available on PATH.",
|
||||
"session.location.unavailable": "Session location unavailable",
|
||||
"session.location.description": "Choose another directory to continue this session.",
|
||||
"session.location.choose": "Choose directory",
|
||||
@@ -106,7 +58,6 @@ export const dict = {
|
||||
|
||||
"command.session.new": "New session",
|
||||
"command.file.open": "Open file",
|
||||
"command.browser.open": "Open browser",
|
||||
"command.tab.close": "Close tab",
|
||||
"command.tab.reopenClosed": "Reopen closed tab",
|
||||
"command.context.addSelection": "Add selection to context",
|
||||
|
||||
@@ -4,7 +4,6 @@ import type { Accessor } from "solid-js"
|
||||
import type { DesktopMenuAction } from "@/shell/commands/desktop-menu"
|
||||
import { ServerConnection } from "@/runtime/server/registry"
|
||||
import type { WslServersPlatform } from "@/servers/wsl/types"
|
||||
import type { SshPlatform } from "@/servers/ssh/types"
|
||||
import type { UpdaterPlatform } from "@/shell/updates/types"
|
||||
import type { DraftStore } from "@/runtime/persistence/drafts"
|
||||
import type { BrowserPanePlatform } from "./browser-pane"
|
||||
@@ -87,7 +86,6 @@ type PlatformBase = {
|
||||
|
||||
/** Manage WSL sidecar servers (Electron on Windows only) */
|
||||
wslServers?: WslServersPlatform
|
||||
sshServers?: SshPlatform
|
||||
|
||||
/** Webview zoom level (desktop only) */
|
||||
webviewZoom?: Accessor<number>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { OpenCodeEvent } from "@opencode/client/promise"
|
||||
import { createClientConnection, createPtyClient, type ClientConnectionStatus } from "@opencode/client/solid"
|
||||
import { createGlobalEmitter } from "@solid-primitives/event-bus"
|
||||
import { type Accessor, createEffect, on, onCleanup } from "solid-js"
|
||||
import { type Accessor, onCleanup } from "solid-js"
|
||||
import { createApiForServer, type ServerApi } from "@/runtime/server/api"
|
||||
import { usePlatform } from "@/runtime/platform/platform"
|
||||
import { ServerConnection } from "./registry"
|
||||
@@ -74,18 +74,8 @@ type ServerSDKBase = {
|
||||
function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerScope): ServerSDKBase {
|
||||
const platform = usePlatform()
|
||||
const transport = createServerTransport({ http: server.http, fetch: platform.fetch })
|
||||
if (server.type === "ssh") {
|
||||
createEffect(
|
||||
on(
|
||||
() => `${server.http.url}\0${server.http.password ?? ""}`,
|
||||
() => transport.update(server.http),
|
||||
{ defer: true },
|
||||
),
|
||||
)
|
||||
}
|
||||
const events = createOpenCodeEventSource()
|
||||
const reconnect =
|
||||
server.type === "ssh" || (server.type === "sidecar" && server.variant === "base") ? server.reconnect : undefined
|
||||
const reconnect = server.type === "sidecar" && server.variant === "base" ? server.reconnect : undefined
|
||||
|
||||
const connection = createClientConnection(transport.api, {
|
||||
reconnect: reconnect ? async (signal) => transport.update(await reconnect(signal)) : undefined,
|
||||
|
||||
@@ -5,7 +5,7 @@ import { ClientError, OpenCode } from "@opencode/client"
|
||||
import { Accessor, createEffect, onCleanup } from "solid-js"
|
||||
import { createStore, reconcile } from "solid-js/store"
|
||||
|
||||
export type ServerHealth = { healthy: boolean; version?: string; incompatible?: boolean; checking?: boolean }
|
||||
export type ServerHealth = { healthy: boolean; version?: string; incompatible?: boolean }
|
||||
|
||||
interface CheckServerHealthOptions {
|
||||
timeoutMs?: number
|
||||
@@ -142,73 +142,25 @@ export function useCheckServerHealth() {
|
||||
}
|
||||
|
||||
export const useServerHealth = (servers: Accessor<ServerConnection.Any[]>, enabled: Accessor<boolean>) => {
|
||||
return createServerHealth(servers, enabled, useCheckServerHealth())
|
||||
}
|
||||
|
||||
export function createServerHealth(
|
||||
servers: Accessor<ServerConnection.Any[]>,
|
||||
enabled: Accessor<boolean>,
|
||||
check: (http: ServerConnection.HttpBase) => Promise<ServerHealth>,
|
||||
) {
|
||||
const checkServerHealth = useCheckServerHealth()
|
||||
const [status, setStatus] = createStore({} as Record<ServerConnection.Key, ServerHealth | undefined>)
|
||||
const endpoints = new Map<ServerConnection.Key, string>()
|
||||
|
||||
createEffect(() => {
|
||||
if (!enabled()) {
|
||||
endpoints.clear()
|
||||
setStatus(reconcile({}))
|
||||
return
|
||||
}
|
||||
// Snapshot transport fields synchronously so a newly established SSH tunnel
|
||||
// invalidates both the old result and any probe still using the old endpoint.
|
||||
const list = servers().map((conn) => ({
|
||||
key: ServerConnection.key(conn),
|
||||
type: conn.type,
|
||||
http: conn.http,
|
||||
stage: conn.type === "ssh" ? conn.stage : undefined,
|
||||
}))
|
||||
for (const conn of list) {
|
||||
if (conn.stage && conn.stage !== "ready") {
|
||||
endpoints.delete(conn.key)
|
||||
setStatus(
|
||||
conn.key,
|
||||
reconcile(
|
||||
conn.stage === "failed"
|
||||
? { healthy: false }
|
||||
: conn.stage === "incompatible"
|
||||
? { healthy: false, incompatible: true }
|
||||
: undefined,
|
||||
),
|
||||
)
|
||||
continue
|
||||
}
|
||||
const endpoint = cacheKey(conn.http)
|
||||
if (conn.type === "ssh" && endpoints.get(conn.key) !== endpoint) {
|
||||
setStatus(conn.key, reconcile({ healthy: false, checking: true }))
|
||||
}
|
||||
endpoints.set(conn.key, endpoint)
|
||||
}
|
||||
for (const key of endpoints.keys()) {
|
||||
if (!list.some((conn) => conn.key === key)) endpoints.delete(key)
|
||||
}
|
||||
const list = servers()
|
||||
let dead = false
|
||||
|
||||
const refresh = async () => {
|
||||
const results: Record<string, ServerHealth | undefined> = {}
|
||||
const results: Record<string, ServerHealth> = {}
|
||||
await Promise.all(
|
||||
list.map(async (conn) => {
|
||||
if (conn.stage && conn.stage !== "ready") {
|
||||
results[conn.key] =
|
||||
conn.stage === "failed"
|
||||
? { healthy: false }
|
||||
: conn.stage === "incompatible"
|
||||
? { healthy: false, incompatible: true }
|
||||
: undefined
|
||||
return
|
||||
}
|
||||
const result = await check(conn.http)
|
||||
results[conn.key] = result
|
||||
if (!dead) setStatus(conn.key, reconcile(result))
|
||||
const key = ServerConnection.key(conn)
|
||||
const result = await checkServerHealth(conn.http)
|
||||
results[key] = result
|
||||
if (!dead) setStatus(key, result)
|
||||
}),
|
||||
)
|
||||
if (dead) return
|
||||
|
||||
@@ -5,7 +5,6 @@ import { Persist, persisted } from "@/runtime/persistence/storage"
|
||||
import { pathKey } from "@/workspaces/path-key"
|
||||
import { ServerScope } from "@/runtime/server/scope"
|
||||
import { ServerHttp, ServerHttpBase, ServerKey, serverState } from "./persistence"
|
||||
import type { SshItem } from "@/servers/ssh/types"
|
||||
|
||||
type ServerState = ReturnType<typeof serverState>["current"]["Type"]
|
||||
// The store retains more history than is displayed. Consumers filter recently closed entries
|
||||
@@ -25,7 +24,6 @@ export function normalizeServerUrl(input: string) {
|
||||
export function serverName(conn?: ServerConnection.Any, ignoreDisplayName = false) {
|
||||
if (!conn) return ""
|
||||
if (conn.displayName && !ignoreDisplayName) return conn.displayName
|
||||
if (conn.type === "ssh") return conn.host
|
||||
return conn.http.url.replace(/^https?:\/\//, "").replace(/\/+$/, "")
|
||||
}
|
||||
|
||||
@@ -161,14 +159,9 @@ export namespace ServerConnection {
|
||||
// Remote server desktop can SSH into
|
||||
export type Ssh = {
|
||||
type: "ssh"
|
||||
stage?: SshItem["stage"]
|
||||
connecting?: boolean
|
||||
authenticationRequired?: boolean
|
||||
id?: string
|
||||
host: string
|
||||
// SSH client exposes an HTTP server for the app to use as a proxy
|
||||
http: HttpBase
|
||||
reconnect?: (signal: AbortSignal) => Promise<HttpBase>
|
||||
} & Base
|
||||
|
||||
export type Any =
|
||||
@@ -185,7 +178,7 @@ export namespace ServerConnection {
|
||||
return Key.make("sidecar")
|
||||
}
|
||||
case "ssh":
|
||||
return Key.make(`ssh:${conn.id ?? conn.host}`)
|
||||
return Key.make(`ssh:${conn.host}`)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -8,7 +8,6 @@ import { useSettings } from "@/settings/model"
|
||||
import { useTabs } from "@/shell/tabs/tabs"
|
||||
import { type ServerHealth } from "@/runtime/server/health"
|
||||
import { showToast } from "@/shell/notifications/toast"
|
||||
import { useSsh } from "../ssh/context"
|
||||
|
||||
function showRequestError(language: ReturnType<typeof useLanguage>, err: unknown) {
|
||||
showToast({
|
||||
@@ -51,7 +50,6 @@ function useDefaultServer() {
|
||||
|
||||
export function useServerActionsController() {
|
||||
const server = useServers()
|
||||
const ssh = useSsh()
|
||||
const tabs = useTabs()
|
||||
const platform = usePlatform()
|
||||
const language = useLanguage()
|
||||
@@ -60,7 +58,6 @@ export function useServerActionsController() {
|
||||
const remove = async (key: ServerConnection.Key) => {
|
||||
try {
|
||||
if (key.startsWith("wsl:")) await platform.wslServers?.removeServer(key)
|
||||
if (key.startsWith("ssh:")) await ssh.forget(key.slice(4))
|
||||
tabs.removeServer(key)
|
||||
server.remove(key)
|
||||
if ((await platform.getDefaultServer?.()) === key) await defaults.set(null)
|
||||
|
||||
@@ -5,7 +5,6 @@ import { type Component, Show } from "solid-js"
|
||||
import type { ServerActionsController } from "@/servers/registry/controller"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import { ServerConnection } from "@/runtime/server/registry"
|
||||
import { SshMenu } from "../ssh/menu"
|
||||
|
||||
export const ServerRowMenu: Component<{
|
||||
server: ServerConnection.Any
|
||||
@@ -16,7 +15,6 @@ export const ServerRowMenu: Component<{
|
||||
}> = (props) => {
|
||||
const language = useLanguage()
|
||||
const key = ServerConnection.key(props.server)
|
||||
if (props.server.type === "ssh" && props.server.id) return <SshMenu id={props.server.id} domain={props.domain} />
|
||||
return (
|
||||
<ServerRowMenuView
|
||||
server={props.server}
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
import { For } from "solid-js"
|
||||
import { ServerHealthIndicator } from "./row"
|
||||
import type { ServerHealth } from "@/runtime/server/health"
|
||||
|
||||
const states: { label: string; connecting?: boolean; authenticationRequired?: boolean; health?: ServerHealth }[] = [
|
||||
{
|
||||
label: "Authentication required (overrides failed health)",
|
||||
authenticationRequired: true,
|
||||
health: { healthy: false },
|
||||
},
|
||||
{
|
||||
label: "Authentication required (overrides pending health)",
|
||||
authenticationRequired: true,
|
||||
health: { healthy: false, checking: true },
|
||||
},
|
||||
{ label: "Connecting (previous health check failed)", connecting: true, health: { healthy: false } },
|
||||
{ label: "Tunnel ready, checking its new endpoint", health: { healthy: false, checking: true } },
|
||||
{ label: "Connected", health: { healthy: true } },
|
||||
{ label: "Failed", health: { healthy: false } },
|
||||
{ label: "Incompatible", health: { healthy: false, incompatible: true } },
|
||||
{ label: "Not checked" },
|
||||
]
|
||||
|
||||
export default { title: "App/Servers/Health indicator", id: "app-server-health" }
|
||||
export const States = {
|
||||
render: () => (
|
||||
<div class="flex flex-col gap-4">
|
||||
<For each={states}>
|
||||
{(state) => (
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="flex size-4 shrink-0 items-center justify-center">
|
||||
<ServerHealthIndicator
|
||||
health={state.health}
|
||||
connecting={state.connecting}
|
||||
authenticationRequired={state.authenticationRequired}
|
||||
/>
|
||||
</div>
|
||||
<span>{state.label}</span>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
),
|
||||
}
|
||||
@@ -1,7 +1,5 @@
|
||||
import { Tooltip } from "@opencode/ui/tooltip"
|
||||
import { Icon } from "@opencode/ui/icon"
|
||||
import { Spinner } from "@opencode/ui/spinner"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import { createResizeObserver } from "@solid-primitives/resize-observer"
|
||||
import {
|
||||
children,
|
||||
@@ -104,53 +102,22 @@ export function ServerRow(props: ServerRowProps) {
|
||||
)
|
||||
}
|
||||
|
||||
export function ServerHealthIndicator(props: {
|
||||
health?: ServerHealth
|
||||
connecting?: boolean
|
||||
authenticationRequired?: boolean
|
||||
}) {
|
||||
const language = useLanguage()
|
||||
export function ServerHealthIndicator(props: { health?: ServerHealth }) {
|
||||
return (
|
||||
<Show
|
||||
when={props.authenticationRequired}
|
||||
when={props.health?.incompatible}
|
||||
fallback={
|
||||
<Show
|
||||
when={props.connecting || props.health?.checking}
|
||||
fallback={
|
||||
<Show
|
||||
when={props.health?.incompatible}
|
||||
fallback={
|
||||
<div
|
||||
classList={{
|
||||
"size-1.5 rounded-full shrink-0 my-[3.5px]": true,
|
||||
"bg-icon-success-base": props.health?.healthy === true,
|
||||
"bg-icon-critical-base": props.health?.healthy === false,
|
||||
"bg-border-weak-base": props.health === undefined,
|
||||
}}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Icon name="warning" size="small" class="shrink-0 text-icon-warning-base" />
|
||||
</Show>
|
||||
}
|
||||
>
|
||||
<span
|
||||
role="status"
|
||||
aria-label={language.t("ssh.stage.connecting")}
|
||||
class="inline-flex h-3.5 w-1.5 shrink-0 items-center justify-center text-v2-icon-icon-muted"
|
||||
>
|
||||
<Spinner class="size-3 shrink-0" />
|
||||
</span>
|
||||
</Show>
|
||||
<div
|
||||
classList={{
|
||||
"size-1.5 rounded-full shrink-0 my-[3.5px]": true,
|
||||
"bg-icon-success-base": props.health?.healthy === true,
|
||||
"bg-icon-critical-base": props.health?.healthy === false,
|
||||
"bg-border-weak-base": props.health === undefined,
|
||||
}}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<span
|
||||
role="status"
|
||||
aria-label={language.t("ssh.stage.authentication")}
|
||||
class="inline-flex h-3.5 w-1.5 shrink-0 items-center justify-center text-v2-icon-icon-muted"
|
||||
>
|
||||
<Icon name="lock" size="small" class="shrink-0" />
|
||||
</span>
|
||||
<Icon name="warning" size="small" class="shrink-0 text-icon-warning-base" />
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
import { useSsh } from "./context"
|
||||
import type { ServerConnection } from "@/runtime/server/registry"
|
||||
|
||||
export function useSshAuthenticate() {
|
||||
const ssh = useSsh()
|
||||
return (server: ServerConnection.Any, onConnected?: () => void) => {
|
||||
if (server.type !== "ssh" || !server.authenticationRequired) return false
|
||||
const item = ssh.servers.find((item) => item.config.id === server.id)
|
||||
if (!item) return false
|
||||
ssh.connect(item.config, { onConnected })
|
||||
return true
|
||||
}
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
import { createEffect } from "solid-js"
|
||||
import type { SshItem } from "./types"
|
||||
|
||||
// Offer authentication once per selected tab. Cancelling must not immediately
|
||||
// reopen the prompt; background hosts never open a dialog here.
|
||||
export function createSshAuthentication(input: {
|
||||
selection: () => string | undefined
|
||||
item: () => SshItem | undefined
|
||||
busy: () => boolean
|
||||
open: (item: SshItem) => void
|
||||
}) {
|
||||
const state = { selection: undefined as string | undefined, offered: false }
|
||||
createEffect(() => {
|
||||
const selection = input.selection()
|
||||
if (state.selection !== selection) {
|
||||
state.selection = selection
|
||||
state.offered = false
|
||||
}
|
||||
const item = input.item()
|
||||
if (!selection || state.offered || item?.stage !== "authentication" || item.authenticatingElsewhere || input.busy())
|
||||
return
|
||||
state.offered = true
|
||||
input.open(item)
|
||||
})
|
||||
}
|
||||
@@ -1,59 +0,0 @@
|
||||
import { useDialog } from "@opencode/ui/context/dialog"
|
||||
import { createMemo, Show, type ParentProps } from "solid-js"
|
||||
import { useCurrentRoute } from "@/shell/state/layout"
|
||||
import { useTabs } from "@/shell/tabs/tabs"
|
||||
import { useSsh } from "./context"
|
||||
import { createSshAuthentication } from "./authentication-state"
|
||||
import { SshConnectionPanel } from "./connection-panel"
|
||||
|
||||
export function SshAuthentication(props: ParentProps) {
|
||||
const ssh = useSsh()
|
||||
const route = useCurrentRoute()
|
||||
const tabs = useTabs()
|
||||
const dialog = useDialog()
|
||||
const item = createMemo(() => {
|
||||
const current = route()
|
||||
const key =
|
||||
current.type === "session"
|
||||
? current.server
|
||||
: current.type === "draft"
|
||||
? tabs.store.find((tab) => tab.type === "draft" && tab.draftID === current.draftID)?.server
|
||||
: undefined
|
||||
return ssh.servers.find((item) => `ssh:${item.config.id}` === key && item.stage !== "ready")
|
||||
})
|
||||
createSshAuthentication({
|
||||
selection: () => {
|
||||
const current = route()
|
||||
if (current.type === "session") return `${current.server}:${current.sessionId}`
|
||||
if (current.type === "draft") return current.draftID
|
||||
return undefined
|
||||
},
|
||||
item,
|
||||
busy: () => !!dialog.active,
|
||||
open: (item) => ssh.connect(item.config),
|
||||
})
|
||||
return (
|
||||
<div class="relative flex size-full min-h-0 min-w-0 flex-col">
|
||||
{/* Keep the route mounted so reconnecting preserves its draft and local UI state. */}
|
||||
<div
|
||||
class="flex size-full min-h-0 min-w-0 flex-col"
|
||||
classList={{ invisible: !!item() }}
|
||||
inert={!!item()}
|
||||
aria-hidden={item() ? true : undefined}
|
||||
>
|
||||
{props.children}
|
||||
</div>
|
||||
<Show when={item()}>
|
||||
{(item) => (
|
||||
<div class="absolute inset-0">
|
||||
<SshConnectionPanel
|
||||
item={item()}
|
||||
pending={ssh.pending(item().config.id)}
|
||||
onReconnect={() => ssh.connect(item().config)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
import { Button } from "@opencode/ui/button"
|
||||
import { Icon } from "@opencode/ui/icon"
|
||||
import { Spinner } from "@opencode/ui/spinner"
|
||||
import { Show } from "solid-js"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import { sshName } from "./name"
|
||||
import { isSshConnecting } from "./status"
|
||||
import type { SshItem } from "./types"
|
||||
|
||||
export function SshConnectionPanel(props: { item: SshItem; pending?: boolean; onReconnect: () => void }) {
|
||||
const language = useLanguage()
|
||||
const connecting = () => props.pending || isSshConnecting(props.item.stage)
|
||||
return (
|
||||
<section
|
||||
data-component="ssh-connection-panel"
|
||||
class="flex h-full min-h-0 flex-col items-center justify-center gap-4 overflow-y-auto bg-v2-background-bg-base px-6 py-8 text-center"
|
||||
>
|
||||
<Icon name="lock" size="large" class="text-v2-icon-icon-muted" />
|
||||
<div class="flex max-w-sm flex-col items-center gap-2" role="status" aria-live="polite">
|
||||
<h2 class="text-16-medium text-v2-text-text-base">{language.t("ssh.session.disconnected")}</h2>
|
||||
<bdi dir="auto" class="max-w-full break-all text-13-regular text-v2-text-text-muted">
|
||||
{sshName(props.item.config)}
|
||||
</bdi>
|
||||
<p class="text-13-regular text-v2-text-text-muted">{language.t("ssh.session.reconnectDescription")}</p>
|
||||
</div>
|
||||
<Show when={props.item.error}>
|
||||
{(error) => (
|
||||
<p role="alert" class="max-w-sm text-13-regular text-v2-text-text-muted">
|
||||
{language.t(`ssh.error.${error()}`)}
|
||||
</p>
|
||||
)}
|
||||
</Show>
|
||||
<Button variant="neutral" disabled={connecting()} aria-busy={connecting()} onClick={props.onReconnect}>
|
||||
<Show when={connecting()}>
|
||||
<Spinner class="size-3.5" />
|
||||
</Show>
|
||||
{language.t(
|
||||
connecting()
|
||||
? "ssh.session.connecting"
|
||||
: props.item.stage === "authentication"
|
||||
? "ssh.action.authenticate"
|
||||
: "ssh.session.reconnect",
|
||||
)}
|
||||
</Button>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -1,69 +0,0 @@
|
||||
import { createSimpleContext } from "@opencode/ui/context"
|
||||
import { useDialog } from "@opencode/ui/context/dialog"
|
||||
import { queryOptions, useQuery, useQueryClient } from "@tanstack/solid-query"
|
||||
import { createEffect, onCleanup, untrack, type ParentProps } from "solid-js"
|
||||
import { usePlatform } from "@/runtime/platform/platform"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import { showToast } from "@/shell/notifications/toast"
|
||||
import { createSshController } from "./controller"
|
||||
import { DialogSsh } from "./dialog"
|
||||
import type { SshState } from "./types"
|
||||
|
||||
const key = ["platform", "sshServers"] as const
|
||||
const context = createSimpleContext({
|
||||
name: "Ssh",
|
||||
init: () => {
|
||||
const platform = usePlatform()
|
||||
const client = useQueryClient()
|
||||
const language = useLanguage()
|
||||
const query = useQuery(() =>
|
||||
queryOptions<SshState>({
|
||||
queryKey: key,
|
||||
queryFn: () => platform.sshServers?.getState() ?? Promise.resolve({ servers: [] }),
|
||||
staleTime: Infinity,
|
||||
}),
|
||||
)
|
||||
createEffect(() => {
|
||||
const off = platform.sshServers?.subscribe((state) => client.setQueryData(key, state))
|
||||
if (off) onCleanup(off)
|
||||
})
|
||||
return {
|
||||
get servers() {
|
||||
return query.data?.servers ?? []
|
||||
},
|
||||
get loading() {
|
||||
return query.isLoading
|
||||
},
|
||||
...createSshController({
|
||||
items: () => query.data?.servers ?? [],
|
||||
api: platform.sshServers,
|
||||
refresh: () => query.refetch({ throwOnError: true }),
|
||||
error: () => showToast({ variant: "error", title: language.t("common.requestFailed") }),
|
||||
}),
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
export const useSsh = () => context.use()
|
||||
|
||||
export function SshProvider(props: ParentProps) {
|
||||
return (
|
||||
<context.provider>
|
||||
<SshDialogs />
|
||||
{props.children}
|
||||
</context.provider>
|
||||
)
|
||||
}
|
||||
|
||||
function SshDialogs() {
|
||||
const ssh = useSsh()
|
||||
// Capture an owner inside the SSH context, independent of transient rows and menus.
|
||||
const dialog = useDialog()
|
||||
createEffect(() => {
|
||||
const item = ssh.dialog.next()
|
||||
if (!item || dialog.active) return
|
||||
ssh.dialog.opened(item.config.id)
|
||||
untrack(() => void dialog.push(() => <DialogSsh config={item.config} promptOnly />))
|
||||
})
|
||||
return null
|
||||
}
|
||||
@@ -1,153 +0,0 @@
|
||||
import { Effect, Fiber } from "effect"
|
||||
import { createEffect, onCleanup } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import type { SshConfig, SshItem, SshPlatform } from "./types"
|
||||
import { isSshConnecting } from "./status"
|
||||
|
||||
export function createSshController(input: {
|
||||
items: () => readonly SshItem[]
|
||||
api: Pick<SshPlatform, "start" | "respond" | "cancel" | "disconnect" | "forget"> | undefined
|
||||
refresh: () => Promise<unknown>
|
||||
error: () => void
|
||||
}) {
|
||||
const [attempts, setAttempts] = createStore<
|
||||
Record<
|
||||
string,
|
||||
| {
|
||||
active: boolean
|
||||
submitting: boolean
|
||||
prompted: boolean
|
||||
answered?: string
|
||||
error: boolean
|
||||
onConnected?: () => void
|
||||
}
|
||||
| undefined
|
||||
>
|
||||
>({})
|
||||
const tasks = new Map<string, Fiber.Fiber<void>>()
|
||||
const item = (id: string) => input.items().find((item) => item.config.id === id)
|
||||
const settle = (id: string) => {
|
||||
const attempt = attempts[id]
|
||||
if (!attempt?.active) return
|
||||
const onConnected = attempt.onConnected
|
||||
setAttempts(id, { active: false, onConnected: undefined })
|
||||
if (item(id)?.stage === "ready" && onConnected) queueMicrotask(onConnected)
|
||||
}
|
||||
const run = (id: string, effect: Effect.Effect<unknown, unknown>) => {
|
||||
setAttempts(id, { submitting: true, error: false })
|
||||
tasks.set(
|
||||
id,
|
||||
Effect.runFork(
|
||||
effect.pipe(
|
||||
Effect.asVoid,
|
||||
Effect.catch(() =>
|
||||
Effect.sync(() => {
|
||||
setAttempts(id, "error", true)
|
||||
if (!attempts[id]?.prompted) input.error()
|
||||
}),
|
||||
),
|
||||
Effect.ensuring(
|
||||
Effect.sync(() => {
|
||||
tasks.delete(id)
|
||||
setAttempts(id, "submitting", false)
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
onCleanup(() => {
|
||||
Effect.runFork(Effect.forEach([...tasks.values()], Fiber.interrupt, { discard: true }))
|
||||
})
|
||||
createEffect(() => {
|
||||
for (const item of input.items()) {
|
||||
const attempt = attempts[item.config.id]
|
||||
if (!attempt?.active || attempt.submitting) continue
|
||||
if (
|
||||
item.stage === "ready" ||
|
||||
item.stage === "failed" ||
|
||||
item.stage === "disconnected" ||
|
||||
item.authenticatingElsewhere ||
|
||||
(attempt.prompted && item.stage === "authentication" && !item.prompt)
|
||||
) {
|
||||
settle(item.config.id)
|
||||
}
|
||||
}
|
||||
})
|
||||
return {
|
||||
item,
|
||||
submitting: (id: string) => !!attempts[id]?.submitting,
|
||||
error: (id: string) => !!attempts[id]?.error,
|
||||
answered: (id: string) =>
|
||||
!!item(id)?.prompt && !attempts[id]?.error && attempts[id]?.answered === item(id)?.prompt?.id,
|
||||
pending: (id: string) =>
|
||||
!!attempts[id]?.submitting ||
|
||||
!!item(id)?.authenticatingElsewhere ||
|
||||
isSshConnecting(item(id)?.stage ?? "disconnected"),
|
||||
dialog: {
|
||||
next: () =>
|
||||
input.items().find((item) => {
|
||||
const attempt = attempts[item.config.id]
|
||||
return (
|
||||
attempt?.active &&
|
||||
!attempt.submitting &&
|
||||
!attempt.prompted &&
|
||||
!attempt.error &&
|
||||
(item.prompt || item.stage === "incompatible")
|
||||
)
|
||||
}),
|
||||
opened: (id: string) => setAttempts(id, "prompted", true),
|
||||
},
|
||||
connect: (config: SshConfig, options?: { dialog?: boolean; replace?: boolean; onConnected?: () => void }) => {
|
||||
const api = input.api
|
||||
if (!api || item(config.id)?.authenticatingElsewhere) return
|
||||
if (
|
||||
attempts[config.id]?.submitting ||
|
||||
(attempts[config.id]?.active && !attempts[config.id]?.error && !options?.replace)
|
||||
)
|
||||
return
|
||||
setAttempts(config.id, {
|
||||
active: true,
|
||||
submitting: true,
|
||||
prompted: !!options?.dialog,
|
||||
answered: undefined,
|
||||
error: false,
|
||||
onConnected: options?.onConnected ?? (options?.replace ? attempts[config.id]?.onConnected : undefined),
|
||||
})
|
||||
run(
|
||||
config.id,
|
||||
Effect.gen(function* () {
|
||||
yield* Effect.tryPromise(() => api.start({ ...config, replace: options?.replace }))
|
||||
// Observe admission before treating an older disconnected snapshot as cancellation.
|
||||
yield* Effect.tryPromise(input.refresh)
|
||||
}),
|
||||
)
|
||||
},
|
||||
respond: (id: string, prompt: string, value: string) => {
|
||||
const api = input.api
|
||||
if (!api || item(id)?.prompt?.id !== prompt || attempts[id]?.submitting) return
|
||||
if (attempts[id]?.answered === prompt && !attempts[id]?.error) return
|
||||
setAttempts(id, "answered", prompt)
|
||||
run(
|
||||
id,
|
||||
Effect.tryPromise(() => api.respond(id, prompt, value)),
|
||||
)
|
||||
},
|
||||
cancel: (id: string) => {
|
||||
const task = tasks.get(id)
|
||||
const api = input.api
|
||||
Effect.runFork(
|
||||
Effect.gen(function* () {
|
||||
if (task) yield* Fiber.interrupt(task)
|
||||
setAttempts(id, undefined)
|
||||
if (!api) return
|
||||
yield* Effect.tryPromise(() => api.cancel(id))
|
||||
if (!item(id)?.saved) yield* Effect.tryPromise(() => api.forget(id))
|
||||
}).pipe(Effect.ignore),
|
||||
)
|
||||
},
|
||||
restore: (config: SshConfig) => input.api?.start({ ...config, background: true }),
|
||||
disconnect: (id: string) => input.api?.disconnect(id),
|
||||
forget: (id: string) => input.api?.forget(id),
|
||||
}
|
||||
}
|
||||
@@ -1,289 +0,0 @@
|
||||
import { Button } from "@opencode/ui/button"
|
||||
import { useDialog } from "@opencode/ui/context/dialog"
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/solid-query"
|
||||
import { onCleanup, onMount, Show } from "solid-js"
|
||||
import { PlatformProvider } from "@/runtime/platform/platform"
|
||||
import { SshProvider, useSsh } from "./context"
|
||||
import { useSshAuthenticate } from "./authenticate"
|
||||
import { HomeProjectsView } from "@/home/projects/view"
|
||||
import { ServerConnection } from "@/runtime/server/registry"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import { DialogSsh } from "./dialog"
|
||||
import type { SshItem, SshPlatform, SshState } from "./types"
|
||||
import { SshConnectionPanel } from "./connection-panel"
|
||||
import { SshServerSettings } from "./settings"
|
||||
|
||||
function Fixture(props: {
|
||||
session?: boolean
|
||||
settings?: boolean
|
||||
incompatible?: boolean
|
||||
keyOnly?: boolean
|
||||
connectionDelay?: number
|
||||
initial?: "connecting" | "password" | "confirmation" | "failure" | "required"
|
||||
responseDelay?: number
|
||||
}) {
|
||||
const state: { item?: SshItem; before?: SshItem; step: number; timer?: ReturnType<typeof setTimeout> } = {
|
||||
step: 0,
|
||||
item:
|
||||
props.initial === "required"
|
||||
? {
|
||||
config: { id: "story", target: "ssh linuxbook", name: "" },
|
||||
stage: props.session || props.settings ? "disconnected" : "authentication",
|
||||
saved: true,
|
||||
detail: "",
|
||||
}
|
||||
: undefined,
|
||||
}
|
||||
onCleanup(() => clearTimeout(state.timer))
|
||||
const listeners = new Set<(state: SshState) => void>()
|
||||
const snapshot = (): SshState => ({ servers: state.item ? [state.item] : [] })
|
||||
const update = (changes: Partial<SshItem>) => {
|
||||
if (!state.item) return
|
||||
state.item = { ...state.item, ...changes }
|
||||
listeners.forEach((listener) => listener(snapshot()))
|
||||
}
|
||||
const prompts = [
|
||||
{
|
||||
id: "host-key",
|
||||
text: "The authenticity of host 'dev.example.com' can't be established.\nED25519 key fingerprint is SHA256:EXAMPLE-FINGERPRINT-FOR-STORY-ONLY.\nAre you sure you want to continue connecting?",
|
||||
confirm: true,
|
||||
},
|
||||
{ id: "password", text: "brendon@dev.example.com's password:", confirm: false },
|
||||
{ id: "otp", text: "Verification code:", confirm: false },
|
||||
]
|
||||
const api: SshPlatform = {
|
||||
getState: async () => snapshot(),
|
||||
subscribe(callback) {
|
||||
listeners.add(callback)
|
||||
return () => {
|
||||
listeners.delete(callback)
|
||||
}
|
||||
},
|
||||
hosts: async () => ["devbox", "staging", "build-host"],
|
||||
start: async (input) => {
|
||||
clearTimeout(state.timer)
|
||||
state.before = state.item
|
||||
state.step = props.initial === "password" || props.initial === "required" ? 1 : 0
|
||||
state.item = {
|
||||
config: input,
|
||||
saved: props.initial === "required",
|
||||
stage: "connecting",
|
||||
detail: "",
|
||||
destination: "brendon@dev.example.com:22",
|
||||
}
|
||||
if (props.initial === "connecting") return
|
||||
if (props.incompatible && !input.replace) {
|
||||
update({ stage: "incompatible", error: "version" })
|
||||
return
|
||||
}
|
||||
if (props.connectionDelay) {
|
||||
update({ stage: "connecting" })
|
||||
state.timer = setTimeout(
|
||||
() => update(props.keyOnly ? { stage: "ready" } : { stage: "authentication", prompt: prompts[state.step] }),
|
||||
props.connectionDelay,
|
||||
)
|
||||
return
|
||||
}
|
||||
update(
|
||||
props.initial === "failure"
|
||||
? {
|
||||
stage: "failed",
|
||||
error: "connection",
|
||||
detail: "ssh: connect to host dev.example.com port 22: Connection refused",
|
||||
}
|
||||
: { stage: "authentication", prompt: prompts[state.step] },
|
||||
)
|
||||
},
|
||||
respond: async () => {
|
||||
const next = () => {
|
||||
state.step += 1
|
||||
update(
|
||||
state.step < prompts.length
|
||||
? { stage: "authentication", prompt: prompts[state.step] }
|
||||
: { stage: "ready", prompt: undefined },
|
||||
)
|
||||
}
|
||||
if (!props.responseDelay) return next()
|
||||
update({ stage: "connecting", prompt: undefined })
|
||||
state.timer = setTimeout(next, props.responseDelay)
|
||||
},
|
||||
resolve: async () => null,
|
||||
disconnect: async () => {
|
||||
clearTimeout(state.timer)
|
||||
update({ stage: "disconnected", prompt: undefined })
|
||||
},
|
||||
cancel: async () => {
|
||||
if (state.item?.stage === "incompatible") return
|
||||
clearTimeout(state.timer)
|
||||
update({ ...state.before, stage: state.before?.stage ?? "disconnected", prompt: undefined })
|
||||
},
|
||||
forget: async () => {
|
||||
state.item = undefined
|
||||
listeners.forEach((listener) => listener(snapshot()))
|
||||
},
|
||||
openConfig: async () => {},
|
||||
}
|
||||
return (
|
||||
<PlatformProvider
|
||||
value={{
|
||||
platform: "desktop",
|
||||
windowID: "ssh-story",
|
||||
sshServers: api,
|
||||
openExternal() {},
|
||||
restart: async () => {},
|
||||
notify: async () => {},
|
||||
openDirectoryPickerDialog: async () => null,
|
||||
}}
|
||||
>
|
||||
<QueryClientProvider client={new QueryClient()}>
|
||||
<SshProvider>
|
||||
{props.settings ? (
|
||||
<AuthenticationSettings />
|
||||
) : props.session ? (
|
||||
<AuthenticationSession />
|
||||
) : props.initial === "required" ? (
|
||||
<AuthenticationHome />
|
||||
) : (
|
||||
<Open initial={props.initial} />
|
||||
)}
|
||||
</SshProvider>
|
||||
</QueryClientProvider>
|
||||
</PlatformProvider>
|
||||
)
|
||||
}
|
||||
|
||||
function AuthenticationSettings() {
|
||||
return (
|
||||
<div class="settings-servers" style={{ width: "min(100%, 480px)" }}>
|
||||
<SshServerSettings
|
||||
filter=""
|
||||
domain={{
|
||||
collection: { items: () => [], health: () => ({}) },
|
||||
defaults: { available: () => false, key: () => null, set: async () => {} },
|
||||
connection: {
|
||||
canRemove: () => false,
|
||||
remove: async () => {},
|
||||
canHide: () => false,
|
||||
isHidden: () => false,
|
||||
setHidden: () => {},
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function AuthenticationSession() {
|
||||
const ssh = useSsh()
|
||||
return (
|
||||
<div style={{ height: "70vh" }}>
|
||||
<Show when={ssh.servers[0]}>
|
||||
{(item) => (
|
||||
<Show when={item().stage !== "ready"} fallback={<div>Session connected</div>}>
|
||||
<SshConnectionPanel
|
||||
item={item()}
|
||||
pending={ssh.pending(item().config.id)}
|
||||
onReconnect={() => ssh.connect(item().config)}
|
||||
/>
|
||||
</Show>
|
||||
)}
|
||||
</Show>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function AuthenticationHome() {
|
||||
const language = useLanguage()
|
||||
const ssh = useSsh()
|
||||
const authenticate = useSshAuthenticate()
|
||||
const server: ServerConnection.Ssh = {
|
||||
type: "ssh",
|
||||
id: "story",
|
||||
host: "linuxbook",
|
||||
displayName: "linuxbook",
|
||||
label: "SSH",
|
||||
http: { url: "http://127.0.0.1:0" },
|
||||
get authenticationRequired() {
|
||||
return ssh.servers[0]?.stage === "authentication"
|
||||
},
|
||||
get connecting() {
|
||||
return ssh.servers[0]?.stage === "connecting"
|
||||
},
|
||||
}
|
||||
const projects = [{ worktree: "/home/user/project", expanded: true }]
|
||||
return (
|
||||
<div style={{ width: "min(100%, 340px)" }}>
|
||||
<HomeProjectsView
|
||||
dropdown
|
||||
language={language}
|
||||
servers={[server]}
|
||||
projects={projects}
|
||||
recentlyClosed={[]}
|
||||
selection={{ server: ServerConnection.key(server) }}
|
||||
homedir="/home/user"
|
||||
serverHealth={() => ({ healthy: ssh.servers[0]?.stage === "ready" })}
|
||||
projectsForServer={() => projects}
|
||||
collapsed={() => false}
|
||||
canDefaultServer={false}
|
||||
defaultServerKey={null}
|
||||
canRevealProject={() => false}
|
||||
unseenCount={() => 0}
|
||||
onWheel={() => {}}
|
||||
onChooseProject={(server) => {
|
||||
authenticate(server)
|
||||
}}
|
||||
onFocusServer={(server) => {
|
||||
authenticate(server)
|
||||
}}
|
||||
onAuthenticateServer={(server) => {
|
||||
authenticate(server)
|
||||
}}
|
||||
onToggleCollapsed={() => {}}
|
||||
onEditServer={() => {}}
|
||||
onSetDefaultServer={() => {}}
|
||||
canRemoveServer={() => false}
|
||||
onRemoveServer={() => {}}
|
||||
canHideServer={() => false}
|
||||
onHideServer={() => {}}
|
||||
onMoveProject={() => {}}
|
||||
onSelectProject={() => {}}
|
||||
onAddProjects={() => {}}
|
||||
onOpenProjectNewSession={() => {}}
|
||||
canImportSession={false}
|
||||
onImportSession={() => {}}
|
||||
onEditProject={() => {}}
|
||||
onRevealProject={() => {}}
|
||||
onClearNotifications={() => {}}
|
||||
onCloseProject={() => {}}
|
||||
onOpenSettings={() => {}}
|
||||
onOpenHelp={() => {}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Open(props: { initial?: string }) {
|
||||
const dialog = useDialog()
|
||||
const open = () =>
|
||||
dialog.show(() => (
|
||||
<DialogSsh
|
||||
config={props.initial ? { id: "story", target: "devbox", name: "Development" } : undefined}
|
||||
connect={!!props.initial}
|
||||
/>
|
||||
))
|
||||
onMount(open)
|
||||
return <Button onClick={open}>Open SSH connection</Button>
|
||||
}
|
||||
|
||||
export default { title: "App/Dialogs/SSH", id: "app-dialog-ssh" }
|
||||
export const AuthenticationRequired = { render: () => <Fixture initial="required" /> }
|
||||
export const SettingsReconnect = { render: () => <Fixture initial="required" settings connectionDelay={200} /> }
|
||||
export const IncompatibleSession = { render: () => <Fixture initial="required" session incompatible /> }
|
||||
export const InactiveSession = { render: () => <Fixture initial="required" session connectionDelay={3000} /> }
|
||||
export const KeyReconnect = { render: () => <Fixture initial="required" session keyOnly connectionDelay={3000} /> }
|
||||
export const Host = { render: () => <Fixture /> }
|
||||
export const Connecting = { render: () => <Fixture initial="connecting" /> }
|
||||
export const Password = { render: () => <Fixture initial="password" /> }
|
||||
export const SlowPassword = { render: () => <Fixture initial="password" responseDelay={5000} /> }
|
||||
export const Confirmation = { render: () => <Fixture initial="confirmation" /> }
|
||||
export const Failure = { render: () => <Fixture initial="failure" /> }
|
||||
@@ -1,246 +0,0 @@
|
||||
import { Button } from "@opencode/ui/button"
|
||||
import { Dialog, DialogBody, DialogFooter, DialogHeader, DialogTitle } from "@opencode/ui/dialog"
|
||||
import { Divider } from "@opencode/ui/divider"
|
||||
import { TextInput } from "@opencode/ui/text-input"
|
||||
import { useDialog } from "@opencode/ui/context/dialog"
|
||||
import { createEffect, createMemo, onCleanup, onMount, Show } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import { ServerConnection, useServers } from "@/runtime/server/registry"
|
||||
import { useTabs } from "@/shell/tabs/tabs"
|
||||
import { useDirectoryPicker } from "@/workspaces/selection/picker"
|
||||
import { useSsh } from "./context"
|
||||
import type { SshConfig, SshItem } from "./types"
|
||||
import { sshName } from "./name"
|
||||
import { isSshConnecting } from "./status"
|
||||
import "@/settings/settings.css"
|
||||
import "./ssh.css"
|
||||
|
||||
export function useOpenSshProject() {
|
||||
const servers = useServers()
|
||||
const picker = useDirectoryPicker()
|
||||
const tabs = useTabs()
|
||||
const language = useLanguage()
|
||||
return (id: string) => {
|
||||
const server = servers.list.find((server) => server.type === "ssh" && server.id === id)
|
||||
if (!server) return
|
||||
picker({
|
||||
server,
|
||||
title: language.t("ssh.project", { host: server.displayName || (server.type === "ssh" ? server.host : "") }),
|
||||
onSelect: (value) => {
|
||||
const directory = Array.isArray(value) ? value[0] : value
|
||||
if (!directory) return
|
||||
const key = ServerConnection.key(server)
|
||||
servers.projects.forServer(key).open(directory)
|
||||
void tabs.newDraft({ server: key, directory })
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export function DialogSsh(props: {
|
||||
config?: SshConfig
|
||||
connect?: boolean
|
||||
promptOnly?: boolean
|
||||
openProject?: boolean
|
||||
onConnected?: () => void
|
||||
}) {
|
||||
const dialog = useDialog()
|
||||
const language = useLanguage()
|
||||
const ssh = useSsh()
|
||||
// Entry-point behavior is fixed for the lifetime of this dialog. Settings
|
||||
// connects without needing the project/tab contexts used by the palette.
|
||||
const openProject = props.openProject ? useOpenSshProject() : undefined
|
||||
const id = props.config?.id ?? crypto.randomUUID()
|
||||
let cancelButton: HTMLButtonElement | undefined
|
||||
const [state, setState] = createStore({
|
||||
target: props.config?.target ?? "",
|
||||
name: props.config?.name ?? "",
|
||||
started: !!props.promptOnly,
|
||||
prompted: !!props.promptOnly,
|
||||
response: "",
|
||||
complete: false,
|
||||
})
|
||||
const item = createMemo(() => ssh.item(id))
|
||||
const error = createMemo(() => {
|
||||
if (ssh.error(id)) return language.t("common.requestFailed")
|
||||
const error = item()?.error
|
||||
return error ? language.t(`ssh.error.${error}`) : undefined
|
||||
})
|
||||
const busy = createMemo(
|
||||
() => ssh.submitting(id) || (state.started && !ssh.error(id) && isSshConnecting(item()?.stage ?? "connecting")),
|
||||
)
|
||||
const prompt = createMemo<SshItem["prompt"]>((previous) => item()?.prompt ?? (busy() ? previous : undefined))
|
||||
const waiting = () => busy() || ssh.answered(id)
|
||||
const start = (replace = false) => {
|
||||
if (busy() || !state.target.trim()) return
|
||||
setState({ started: true, prompted: !!prompt() })
|
||||
ssh.connect({ id, target: state.target, name: state.name }, { dialog: true, replace })
|
||||
}
|
||||
const respond = () => {
|
||||
const current = item()?.prompt
|
||||
if (!current || waiting() || (!current.confirm && !state.response)) return
|
||||
ssh.respond(id, current.id, current.confirm ? "yes" : state.response)
|
||||
}
|
||||
createEffect(() => {
|
||||
prompt()?.id
|
||||
setState("response", "")
|
||||
if (prompt()) setState("prompted", true)
|
||||
// Never let a focused Continue button become Trust between SSH challenges.
|
||||
if (prompt()?.confirm) queueMicrotask(() => cancelButton?.focus())
|
||||
})
|
||||
createEffect(() => {
|
||||
if (!state.started || item()?.stage !== "ready" || ssh.submitting(id) || state.complete) return
|
||||
setState("complete", true)
|
||||
dialog.close()
|
||||
if (openProject) queueMicrotask(() => openProject(id))
|
||||
if (props.onConnected) queueMicrotask(props.onConnected)
|
||||
})
|
||||
onMount(() => {
|
||||
if (props.connect) start()
|
||||
})
|
||||
onCleanup(() => {
|
||||
if (state.started && !state.complete) ssh.cancel(id)
|
||||
})
|
||||
const keyDown = (event: KeyboardEvent) => {
|
||||
if (event.key !== "Enter" || event.isComposing) return
|
||||
event.preventDefault()
|
||||
if (prompt()) return
|
||||
start(item()?.stage === "incompatible")
|
||||
}
|
||||
return (
|
||||
<Dialog fit class="settings-server-dialog">
|
||||
<DialogHeader hideClose={true}>
|
||||
<DialogTitle>
|
||||
{state.prompted || props.config
|
||||
? language.t("ssh.connectTo", { host: sshName(state) })
|
||||
: language.t("ssh.add")}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<Divider />
|
||||
<DialogBody class="flex w-full min-w-0 flex-1 flex-col px-4 pt-4 pb-2">
|
||||
<div class="flex w-full min-w-0 flex-col gap-6">
|
||||
<Show when={!props.promptOnly && (!state.prompted || (!!error() && !prompt()))}>
|
||||
<div class="flex w-full min-w-0 flex-col gap-2">
|
||||
<label class="settings-server-dialog-label" for="ssh-target">
|
||||
{language.t("ssh.target")}
|
||||
</label>
|
||||
<TextInput
|
||||
id="ssh-target"
|
||||
type="text"
|
||||
appearance="large"
|
||||
class="!w-full self-stretch"
|
||||
dir="ltr"
|
||||
value={state.target}
|
||||
autofocus
|
||||
placeholder={language.t("ssh.placeholder")}
|
||||
spellcheck={false}
|
||||
autocomplete="off"
|
||||
disabled={busy() || !!prompt()}
|
||||
invalid={!!error()}
|
||||
onInput={(event) => setState("target", event.currentTarget.value)}
|
||||
onKeyDown={keyDown}
|
||||
/>
|
||||
</div>
|
||||
<div class="flex w-full min-w-0 flex-col gap-2">
|
||||
<label class="settings-server-dialog-label" for="ssh-name">
|
||||
{language.t("dialog.server.add.name")}
|
||||
</label>
|
||||
<TextInput
|
||||
id="ssh-name"
|
||||
type="text"
|
||||
appearance="large"
|
||||
class="!w-full self-stretch"
|
||||
dir="auto"
|
||||
value={state.name}
|
||||
placeholder={language.t("dialog.server.add.namePlaceholder")}
|
||||
disabled={busy() || !!prompt()}
|
||||
onInput={(event) => setState("name", event.currentTarget.value)}
|
||||
onKeyDown={keyDown}
|
||||
/>
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={prompt()} keyed>
|
||||
{(prompt) => (
|
||||
<div class="flex w-full min-w-0 flex-col gap-2">
|
||||
<pre id="ssh-prompt" class="ssh-prompt" dir="auto">
|
||||
{prompt.text}
|
||||
</pre>
|
||||
<Show when={!prompt.confirm}>
|
||||
<TextInput
|
||||
id="ssh-response"
|
||||
aria-labelledby="ssh-prompt"
|
||||
ref={(element) =>
|
||||
queueMicrotask(() => {
|
||||
if (element.isConnected) element.focus()
|
||||
})
|
||||
}
|
||||
type="password"
|
||||
appearance="large"
|
||||
class="!w-full self-stretch"
|
||||
autofocus
|
||||
value={state.response}
|
||||
autocomplete="off"
|
||||
spellcheck={false}
|
||||
disabled={waiting()}
|
||||
onInput={(event) => setState("response", event.currentTarget.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter" && !event.isComposing) {
|
||||
event.preventDefault()
|
||||
respond()
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Show>
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
<Show when={error()}>
|
||||
{(error) => (
|
||||
<span class="settings-server-dialog-error !leading-[var(--line-height-compact)]" role="alert">
|
||||
{error()}
|
||||
</span>
|
||||
)}
|
||||
</Show>
|
||||
</div>
|
||||
</DialogBody>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
ref={(element: HTMLButtonElement) => {
|
||||
cancelButton = element
|
||||
}}
|
||||
variant="neutral"
|
||||
onClick={() => dialog.close()}
|
||||
>
|
||||
{language.t("common.cancel")}
|
||||
</Button>
|
||||
<Show
|
||||
when={prompt()}
|
||||
fallback={
|
||||
<Button
|
||||
variant="contrast"
|
||||
disabled={busy() || !state.target.trim()}
|
||||
onClick={() => start(item()?.stage === "incompatible")}
|
||||
>
|
||||
{busy()
|
||||
? language.t("ssh.stage.connecting")
|
||||
: item()?.stage === "incompatible"
|
||||
? language.t("ssh.update")
|
||||
: props.config
|
||||
? language.t("ssh.connect")
|
||||
: language.t("dialog.server.add.button")}
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
{(prompt) => (
|
||||
<Button variant="contrast" disabled={waiting() || (!prompt().confirm && !state.response)} onClick={respond}>
|
||||
{waiting()
|
||||
? language.t("ssh.stage.connecting")
|
||||
: language.t(prompt().confirm ? "ssh.trust" : "ssh.continue")}
|
||||
</Button>
|
||||
)}
|
||||
</Show>
|
||||
</DialogFooter>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
import { Icon } from "@opencode/ui/icon"
|
||||
import { IconButton } from "@opencode/ui/icon-button"
|
||||
import { Menu } from "@opencode/ui/menu"
|
||||
import { Show } from "solid-js"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import type { ServerActionsController } from "@/servers/registry/controller"
|
||||
import { ServerConnection } from "@/runtime/server/registry"
|
||||
import { useSsh } from "./context"
|
||||
|
||||
export function SshMenu(props: { id: string; domain: ServerActionsController }) {
|
||||
const ssh = useSsh()
|
||||
const language = useLanguage()
|
||||
const item = () => ssh.item(props.id)
|
||||
const key = () => ServerConnection.Key.make(`ssh:${props.id}`)
|
||||
return (
|
||||
<Show when={item()}>
|
||||
{(item) => (
|
||||
<Menu gutter={4} modal={false} placement="bottom-end">
|
||||
<Menu.Trigger
|
||||
as={IconButton}
|
||||
variant="ghost-muted"
|
||||
size="small"
|
||||
icon={<Icon name="outline-dots" />}
|
||||
aria-label={language.t("common.moreOptions")}
|
||||
/>
|
||||
<Menu.Portal>
|
||||
<Menu.Content>
|
||||
<Menu.Group>
|
||||
<Menu.GroupLabel>{language.t("ssh.server.menu.label")}</Menu.GroupLabel>
|
||||
<Show when={item().stage !== "ready"}>
|
||||
<Menu.Item disabled={ssh.pending(props.id)} onSelect={() => ssh.connect(item().config)}>
|
||||
{language.t(item().stage === "authentication" ? "ssh.authenticate" : "ssh.connect")}
|
||||
</Menu.Item>
|
||||
</Show>
|
||||
<Show when={props.domain.defaults.available() && props.domain.defaults.key() !== key()}>
|
||||
<Menu.Item onSelect={() => props.domain.defaults.set(key())}>
|
||||
{language.t("dialog.server.menu.default")}
|
||||
</Menu.Item>
|
||||
</Show>
|
||||
<Show when={props.domain.defaults.available() && props.domain.defaults.key() === key()}>
|
||||
<Menu.Item onSelect={() => props.domain.defaults.set(null)}>
|
||||
{language.t("dialog.server.menu.defaultRemove")}
|
||||
</Menu.Item>
|
||||
</Show>
|
||||
<Menu.Separator />
|
||||
<Menu.Item onSelect={() => void props.domain.connection.remove(key())}>
|
||||
{language.t("dialog.server.menu.delete")}
|
||||
</Menu.Item>
|
||||
</Menu.Group>
|
||||
</Menu.Content>
|
||||
</Menu.Portal>
|
||||
</Menu>
|
||||
)}
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import { sshHostname, sshName } from "./name"
|
||||
|
||||
test.each([
|
||||
["brendan-box.exe.xyz", "brendan-box.exe.xyz"],
|
||||
["ssh brendan-box.exe.xyz", "brendan-box.exe.xyz"],
|
||||
["ssh anomaly@brendan-box.exe.xyz", "brendan-box.exe.xyz"],
|
||||
['ssh -p 2222 -i "/keys/my key" -J jump@example.com anomaly@devbox', "devbox"],
|
||||
["ssh -o 'ProxyCommand=ssh jump -W %h:%p' 'anomaly@devbox'", "devbox"],
|
||||
[" ssh 'brendan-'box.exe.xyz ", "brendan-box.exe.xyz"],
|
||||
["ssh user@[2001:db8::1]", "[2001:db8::1]"],
|
||||
["", ""],
|
||||
])("uses the hostname from %s", (target, hostname) => {
|
||||
expect(sshHostname(target)).toBe(hostname)
|
||||
expect(sshName({ target, name: "" })).toBe(hostname)
|
||||
})
|
||||
|
||||
test("preserves custom names and the original command", () => {
|
||||
const config = { name: "Development", target: "ssh -p 2222 anomaly@devbox" }
|
||||
expect(sshName(config)).toBe("Development")
|
||||
expect(config.target).toBe("ssh -p 2222 anomaly@devbox")
|
||||
})
|
||||
@@ -1,16 +0,0 @@
|
||||
import type { SshConfig } from "./types"
|
||||
|
||||
export function sshHostname(target: string) {
|
||||
// Accepted SSH targets end with a hostname or user@hostname, never a remote
|
||||
// command. Strip shell quoting for display only; keep the saved target intact.
|
||||
return (
|
||||
(target.trim().split(/\s+/).at(-1) ?? "")
|
||||
.replace(/["'\\]/g, "")
|
||||
.split("@")
|
||||
.at(-1) ?? ""
|
||||
)
|
||||
}
|
||||
|
||||
export function sshName(config: Pick<SshConfig, "name" | "target">) {
|
||||
return config.name || sshHostname(config.target)
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
import { createEffect } from "solid-js"
|
||||
import type { SshStart, SshState } from "./types"
|
||||
|
||||
export function createSshRestore(input: {
|
||||
state: () => SshState | undefined
|
||||
start: (input: SshStart) => Promise<void> | undefined
|
||||
}) {
|
||||
const restored = new Set<string>()
|
||||
createEffect(() => {
|
||||
for (const item of input.state()?.servers ?? []) {
|
||||
if (!item.saved || restored.has(item.config.id)) continue
|
||||
// Mark active connections too, so a later manual disconnect is respected.
|
||||
restored.add(item.config.id)
|
||||
if (item.stage === "disconnected") void input.start({ ...item.config, background: true })
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
import { useSsh } from "./context"
|
||||
import { createSshRestore } from "./restore-state"
|
||||
|
||||
export function SshRestore() {
|
||||
const ssh = useSsh()
|
||||
createSshRestore({
|
||||
state: () => ({ servers: ssh.servers }),
|
||||
start: ssh.restore,
|
||||
})
|
||||
return null
|
||||
}
|
||||
@@ -1,87 +0,0 @@
|
||||
import { For, Show } from "solid-js"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import type { ServerCollectionController } from "@/servers/registry/controller"
|
||||
import { ServerHealthIndicator } from "@/servers/registry/row"
|
||||
import { ServerConnection } from "@/runtime/server/registry"
|
||||
import { useSsh } from "./context"
|
||||
import { SshMenu } from "./menu"
|
||||
import { Badge } from "@opencode/ui/badge"
|
||||
import { Button } from "@opencode/ui/button"
|
||||
import { Spinner } from "@opencode/ui/spinner"
|
||||
import { sshName } from "./name"
|
||||
import { isSshConnecting } from "./status"
|
||||
|
||||
export function SshServerSettings(props: { filter: string; domain: ServerCollectionController }) {
|
||||
const ssh = useSsh()
|
||||
const language = useLanguage()
|
||||
return (
|
||||
<For
|
||||
each={ssh.servers.filter(
|
||||
(item) =>
|
||||
item.saved && `${item.config.name} ${item.config.target}`.toLowerCase().includes(props.filter.toLowerCase()),
|
||||
)}
|
||||
>
|
||||
{(item) => {
|
||||
const key = ServerConnection.Key.make(`ssh:${item.config.id}`)
|
||||
const health = () => props.domain.collection.health()[key]
|
||||
const indicator = () => {
|
||||
if (item.stage === "ready") return health() ?? { healthy: true }
|
||||
if (item.stage === "incompatible") return { healthy: false, incompatible: true }
|
||||
if (item.stage === "failed") return { healthy: false }
|
||||
return undefined
|
||||
}
|
||||
return (
|
||||
<div class="settings-servers-row">
|
||||
<div class="settings-servers-lead">
|
||||
<ServerHealthIndicator
|
||||
health={indicator()}
|
||||
connecting={isSshConnecting(item.stage)}
|
||||
authenticationRequired={item.stage === "authentication"}
|
||||
/>
|
||||
<div class="settings-servers-copy">
|
||||
<span class="flex min-w-0 items-center gap-1">
|
||||
<bdi class="settings-servers-name truncate" dir={item.config.name ? "auto" : "ltr"}>
|
||||
{sshName(item.config)}
|
||||
</bdi>
|
||||
<span class="shrink-0 rounded-[3px] border border-v2-border-border-base px-1 py-0.5 text-[9px] leading-none text-v2-text-text-muted">
|
||||
{language.t("ssh.label")}
|
||||
</span>
|
||||
</span>
|
||||
<Show
|
||||
when={item.stage === "authentication"}
|
||||
fallback={
|
||||
<Show when={health()?.version}>
|
||||
{(version) => <span class="settings-servers-meta">v{version()}</span>}
|
||||
</Show>
|
||||
}
|
||||
>
|
||||
<span class="settings-servers-meta">{language.t("ssh.stage.authentication")}</span>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
<div class="settings-servers-actions">
|
||||
<Show when={item.stage === "authentication" || ssh.pending(item.config.id)}>
|
||||
<Button
|
||||
size="small"
|
||||
variant="ghost-muted"
|
||||
disabled={ssh.pending(item.config.id)}
|
||||
aria-busy={ssh.pending(item.config.id)}
|
||||
onClick={() => ssh.connect(item.config)}
|
||||
>
|
||||
<Show when={ssh.pending(item.config.id)}>
|
||||
<Spinner class="size-3.5" />
|
||||
</Show>
|
||||
{language.t(ssh.pending(item.config.id) ? "ssh.session.connecting" : "ssh.action.authenticate")}
|
||||
</Button>
|
||||
</Show>
|
||||
<Show when={props.domain.defaults.available() && props.domain.defaults.key() === key}>
|
||||
<Badge>{language.t("dialog.server.status.default")}</Badge>
|
||||
</Show>
|
||||
<SshMenu id={item.config.id} domain={props.domain} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
)
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
.ssh-prompt {
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
font-family: var(--font-family-mono);
|
||||
font-size: 12px;
|
||||
line-height: var(--line-height-compact);
|
||||
max-height: 200px;
|
||||
overflow: auto;
|
||||
user-select: text;
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
import type { SshItem } from "./types"
|
||||
|
||||
export function isSshConnecting(stage: SshItem["stage"]) {
|
||||
return (
|
||||
stage === "connecting" ||
|
||||
stage === "checking" ||
|
||||
stage === "downloading" ||
|
||||
stage === "uploading" ||
|
||||
stage === "starting"
|
||||
)
|
||||
}
|
||||
@@ -1,72 +0,0 @@
|
||||
import { Schema } from "effect"
|
||||
export { sshHostname, sshName } from "./name"
|
||||
export { isSshConnecting } from "./status"
|
||||
|
||||
export const SshConfig = Schema.Struct({ id: Schema.String, target: Schema.String, name: Schema.String })
|
||||
export type SshConfig = typeof SshConfig.Type
|
||||
export const SshHttp = Schema.Struct({ url: Schema.String, password: Schema.String })
|
||||
export type SshHttp = typeof SshHttp.Type
|
||||
export const SshStage = Schema.Literals([
|
||||
"disconnected",
|
||||
"connecting",
|
||||
"checking",
|
||||
"downloading",
|
||||
"uploading",
|
||||
"starting",
|
||||
"ready",
|
||||
"authentication",
|
||||
"incompatible",
|
||||
"failed",
|
||||
])
|
||||
export const SshPrompt = Schema.Struct({
|
||||
id: Schema.String,
|
||||
text: Schema.String,
|
||||
confirm: Schema.Boolean,
|
||||
})
|
||||
export const SshItem = Schema.Struct({
|
||||
config: SshConfig,
|
||||
saved: Schema.Boolean,
|
||||
destination: Schema.optional(Schema.String),
|
||||
stage: SshStage,
|
||||
http: Schema.optional(SshHttp),
|
||||
prompt: Schema.optional(SshPrompt),
|
||||
authenticatingElsewhere: Schema.optional(Schema.Boolean),
|
||||
detail: Schema.String,
|
||||
error: Schema.optional(
|
||||
Schema.Literals([
|
||||
"connection",
|
||||
"input",
|
||||
"platform",
|
||||
"version",
|
||||
"install",
|
||||
"service",
|
||||
"host-key",
|
||||
"ssh-missing",
|
||||
"unpublished",
|
||||
]),
|
||||
),
|
||||
})
|
||||
export type SshItem = typeof SshItem.Type
|
||||
export const SshState = Schema.Struct({ servers: Schema.Array(SshItem) })
|
||||
export type SshState = typeof SshState.Type
|
||||
export const SshStart = Schema.Struct({
|
||||
id: Schema.String,
|
||||
target: Schema.String,
|
||||
name: Schema.String,
|
||||
replace: Schema.optional(Schema.Boolean),
|
||||
background: Schema.optional(Schema.Boolean),
|
||||
})
|
||||
export type SshStart = typeof SshStart.Type
|
||||
|
||||
export type SshPlatform = {
|
||||
getState(): Promise<SshState>
|
||||
subscribe(callback: (state: SshState) => void): () => void
|
||||
hosts(): Promise<readonly string[]>
|
||||
start(input: SshStart): Promise<void>
|
||||
resolve(id: string): Promise<SshHttp | null>
|
||||
respond(id: string, prompt: string, value: string): Promise<void>
|
||||
disconnect(id: string): Promise<void>
|
||||
cancel(id: string): Promise<void>
|
||||
forget(id: string): Promise<void>
|
||||
openConfig(): Promise<void>
|
||||
}
|
||||
@@ -16,7 +16,6 @@ import { showToast } from "@/shell/notifications/toast"
|
||||
import { DialogAddWslServer } from "./dialog"
|
||||
import { useWslServers } from "./context"
|
||||
import { wslOpencodeAction, wslRuntimeRetryable } from "./model"
|
||||
import { DialogSsh } from "../ssh/dialog"
|
||||
|
||||
export function isWslServer(server: ServerConnection.Any) {
|
||||
return server.type === "sidecar" && server.variant === "wsl"
|
||||
@@ -31,7 +30,7 @@ export function AddServerMenu(props: { onAddServer: () => void }) {
|
||||
}
|
||||
return (
|
||||
<Show
|
||||
when={platform.wslServers || platform.sshServers}
|
||||
when={platform.wslServers}
|
||||
fallback={
|
||||
<Button variant="ghost-muted" icon="plus" onClick={props.onAddServer}>
|
||||
{language.t("dialog.server.add.button")}
|
||||
@@ -45,12 +44,7 @@ export function AddServerMenu(props: { onAddServer: () => void }) {
|
||||
<Menu.Portal>
|
||||
<Menu.Content>
|
||||
<Menu.Item onSelect={props.onAddServer}>{language.t("dialog.server.add.button")}</Menu.Item>
|
||||
<Show when={platform.sshServers}>
|
||||
<Menu.Item onSelect={() => void dialog.push(() => <DialogSsh />)}>{language.t("ssh.add")}</Menu.Item>
|
||||
</Show>
|
||||
<Show when={platform.wslServers}>
|
||||
<Menu.Item onSelect={openAddWsl}>{language.t("wsl.server.add")}</Menu.Item>
|
||||
</Show>
|
||||
<Menu.Item onSelect={openAddWsl}>{language.t("wsl.server.add")}</Menu.Item>
|
||||
</Menu.Content>
|
||||
</Menu.Portal>
|
||||
</Menu>
|
||||
|
||||
@@ -4,7 +4,6 @@ import { createStore } from "solid-js/store"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import type { BrowserPaneCommand } from "@/runtime/platform/browser-pane"
|
||||
import { useServer } from "@/runtime/server/current"
|
||||
import { useCommand } from "@/shell/commands/command"
|
||||
import type { SessionModel } from "../model"
|
||||
import { isSessionBrowserTab, sessionBrowserTab } from "../helpers"
|
||||
import { useBrowserAttachments } from "./attachments"
|
||||
@@ -13,7 +12,6 @@ export function createSessionBrowser(session: SessionModel) {
|
||||
const attachments = useBrowserAttachments()
|
||||
const language = useLanguage()
|
||||
const server = useServer()
|
||||
const commands = useCommand()
|
||||
const [local, setLocal] = createStore({ error: undefined as string | undefined })
|
||||
const attachment = () => {
|
||||
const sessionID = session.identity.sessionID()
|
||||
@@ -49,20 +47,6 @@ export function createSessionBrowser(session: SessionModel) {
|
||||
if (owner.current()) setLocal("error", language.t("common.requestFailed"))
|
||||
})
|
||||
}
|
||||
const open = () => {
|
||||
if (!available()) return
|
||||
command({ type: "tabs.open" })
|
||||
}
|
||||
commands.register("session.browser", () => [
|
||||
{
|
||||
id: "browser.open",
|
||||
title: language.t("command.browser.open"),
|
||||
category: language.t("command.category.view"),
|
||||
keybind: "mod+shift+b",
|
||||
disabled: !available(),
|
||||
onSelect: open,
|
||||
},
|
||||
])
|
||||
createEffect(() => {
|
||||
const sessionID = session.identity.sessionID()
|
||||
if (!sessionID) return
|
||||
@@ -125,7 +109,7 @@ export function createSessionBrowser(session: SessionModel) {
|
||||
error: () => local.error ?? attachment()?.error,
|
||||
registration: () => attachment()?.registration,
|
||||
close: (tabID: Browser.TabID) => session.layout.tabs().close(sessionBrowserTab(tabID)),
|
||||
open,
|
||||
open: () => command({ type: "tabs.open" }),
|
||||
command,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -228,7 +228,6 @@ export function SessionSidePanel(props: {
|
||||
return active !== "review" && active !== "context" && active !== "empty" && !isSessionBrowserTab(active)
|
||||
})
|
||||
const openFileKeybind = createMemo(() => command.keybindParts("file.open"))
|
||||
const openBrowserKeybind = createMemo(() => command.keybindParts("browser.open"))
|
||||
const closeTabKeybind = createMemo(() => command.keybindParts("file.close"))
|
||||
createEffect(() => {
|
||||
if (!file.ready()) return
|
||||
@@ -466,7 +465,7 @@ export function SessionSidePanel(props: {
|
||||
placement="bottom"
|
||||
class="flex items-center"
|
||||
>
|
||||
<Menu appearance="standard" modal={false} placement="bottom-start" gutter={4}>
|
||||
<Menu appearance="standard" modal={false} placement="bottom-end" gutter={4}>
|
||||
<Menu.Trigger
|
||||
as={IconButton}
|
||||
icon={<Icon name="plus" />}
|
||||
@@ -480,7 +479,6 @@ export function SessionSidePanel(props: {
|
||||
<Menu.Portal>
|
||||
<Menu.Content>
|
||||
<Menu.Item
|
||||
class="!gap-6"
|
||||
onSelect={openFileBrowser}
|
||||
shortcut={
|
||||
<Show when={openFileKeybind().length > 0}>
|
||||
@@ -493,15 +491,7 @@ export function SessionSidePanel(props: {
|
||||
<span>{language.t("command.file.open")}</span>
|
||||
</div>
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
class="!gap-6"
|
||||
onSelect={props.browser.open}
|
||||
shortcut={
|
||||
<Show when={openBrowserKeybind().length > 0}>
|
||||
<Keybind keys={openBrowserKeybind()} variant="neutral" />
|
||||
</Show>
|
||||
}
|
||||
>
|
||||
<Menu.Item onSelect={props.browser.open}>
|
||||
<div class="flex items-center gap-2">
|
||||
<Icon name="window-cursor" size="small" />
|
||||
<span>{language.t("session.tab.browser")}</span>
|
||||
|
||||
@@ -45,7 +45,7 @@ export function SessionPermissionDock(props: {
|
||||
>
|
||||
{language.t("ui.permission.allowAlways")}
|
||||
</Button>
|
||||
<Button variant="submit" size="normal" onClick={() => props.onDecide("once")} disabled={props.responding}>
|
||||
<Button variant="contrast" size="normal" onClick={() => props.onDecide("once")} disabled={props.responding}>
|
||||
{language.t("ui.permission.allowOnce")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -523,7 +523,7 @@ export const SessionQuestionDock: Component<{ request: FormInfo; onSubmit: () =>
|
||||
</Button>
|
||||
</Show>
|
||||
<Button
|
||||
variant={last() ? "submit" : "neutral"}
|
||||
variant={last() ? "contrast" : "neutral"}
|
||||
size="large"
|
||||
disabled={sending()}
|
||||
onClick={next}
|
||||
|
||||
@@ -69,7 +69,7 @@ export function SessionWebSearchDock(props: { model: WebSearchRequestModel; onSu
|
||||
</Button>
|
||||
</Show>
|
||||
<Button
|
||||
variant="submit"
|
||||
variant="neutral"
|
||||
size="small"
|
||||
onClick={() => {
|
||||
const selected = props.model.selected()
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { DataProvider } from "@opencode/session-ui/context"
|
||||
import { BackgroundMoveHint, BackgroundWorkSummary } from "./message-timeline"
|
||||
|
||||
const tasks = [
|
||||
@@ -31,13 +30,7 @@ export const InlineMoveHint = {
|
||||
export const SummaryPanelEntry = {
|
||||
render: () => (
|
||||
<div class="w-[280px] rounded-[6px] bg-v2-background-bg-base px-0.5 py-1.5 shadow-[var(--v2-elevation-raised)]">
|
||||
<DataProvider
|
||||
data={{ session: [], session_status: {}, session_diff: {} }}
|
||||
directory="/project"
|
||||
onSessionHref={(id) => `#${id}`}
|
||||
>
|
||||
<BackgroundWorkSummary tasks={tasks} />
|
||||
</DataProvider>
|
||||
<BackgroundWorkSummary tasks={tasks} />
|
||||
</div>
|
||||
),
|
||||
}
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import { createEffect, createMemo, createSignal, For, on, onCleanup, Show, type Accessor, type JSX } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { Dynamic } from "solid-js/web"
|
||||
import { createAnimatedPresence } from "@/runtime/animated-presence"
|
||||
import type { SessionUserActions } from "@opencode/session-ui/actions"
|
||||
import { useData } from "@opencode/session-ui/context"
|
||||
import { Button } from "@opencode/ui/button"
|
||||
import { DiffChanges } from "@opencode/ui/diff-changes"
|
||||
import { Icon } from "@opencode/ui/icon"
|
||||
@@ -18,7 +16,7 @@ import { getFilename } from "@opencode/util/path"
|
||||
import { Popover } from "@kobalte/core/popover"
|
||||
import { SessionContextUsage } from "@/session/timeline/session-context-usage"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import { useServer } from "@/runtime/server/current"
|
||||
import { useData, useServer } from "@/runtime/server/current"
|
||||
import { useWorkspaceLocation } from "@/workspaces/location"
|
||||
import { Timeline, TimelineRow } from "@opencode/session-ui/timeline/projection"
|
||||
import { createSessionTimelineRowRenderer } from "@opencode/session-ui/timeline/row"
|
||||
@@ -72,7 +70,6 @@ export function BackgroundMoveHint(props: { keybind?: string[]; onMove?: () => v
|
||||
|
||||
export function BackgroundWorkSummary(props: { tasks: BackgroundTask[]; mobile?: boolean }) {
|
||||
const language = useLanguage()
|
||||
const data = useData()
|
||||
const [open, setOpen] = createSignal(false)
|
||||
const [triggerRef, setTriggerRef] = createSignal<HTMLButtonElement>()
|
||||
const tasks = createMemo<BackgroundTask[]>((previous = []) => (props.tasks.length > 0 ? props.tasks : previous))
|
||||
@@ -109,7 +106,10 @@ export function BackgroundWorkSummary(props: { tasks: BackgroundTask[]; mobile?:
|
||||
}}
|
||||
aria-label={language.plural("session.background.tasksRunning", tasks().length)}
|
||||
>
|
||||
<Icon name="outline-arrow-to-corner-top-right" class="shrink-0 text-v2-icon-icon-muted" />
|
||||
<Icon
|
||||
name="outline-arrow-to-corner-top-right"
|
||||
class="shrink-0 text-v2-icon-icon-muted"
|
||||
/>
|
||||
<TextShimmer
|
||||
as="span"
|
||||
text={language.plural("session.background.tasksRunning", tasks().length)}
|
||||
@@ -125,26 +125,13 @@ export function BackgroundWorkSummary(props: { tasks: BackgroundTask[]; mobile?:
|
||||
>
|
||||
<For each={tasks().slice(0, 10)}>
|
||||
{(task) => (
|
||||
<Dynamic
|
||||
component={task.type === "subagent" ? "a" : "div"}
|
||||
<div
|
||||
data-component="session-background-list-item"
|
||||
class="flex h-7 min-w-0 items-center gap-2 rounded-[4px] px-3 text-[13px] font-[440] leading-[var(--line-height-compact)] tracking-[-0.04px]"
|
||||
classList={{
|
||||
"hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none":
|
||||
task.type === "subagent",
|
||||
}}
|
||||
href={task.type === "subagent" ? data.sessionHref?.(task.id) : undefined}
|
||||
onClick={(event: MouseEvent) => {
|
||||
if (task.type !== "subagent" || !data.navigateToSession) return
|
||||
if (event.button !== 0 || event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) return
|
||||
event.preventDefault()
|
||||
setOpen(false)
|
||||
data.navigateToSession(task.id)
|
||||
}}
|
||||
class="flex h-7 min-w-0 items-center gap-2 rounded-[4px] px-3 text-[13px] font-[440] leading-none tracking-[-0.04px]"
|
||||
>
|
||||
<span class="shrink-0 text-v2-text-text-base">{taskType(task)}</span>
|
||||
<span class="min-w-0 flex-1 truncate text-v2-text-text-faint">{task.label}</span>
|
||||
</Dynamic>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</Popover.Content>
|
||||
@@ -402,8 +389,8 @@ function MessageTimelineView(
|
||||
},
|
||||
) {
|
||||
const language = useLanguage()
|
||||
const data = useData()
|
||||
const server = useServer()
|
||||
const data = server.ctx.data
|
||||
const settings = useSettings()
|
||||
const sdk = useWorkspaceLocation()
|
||||
const sessionID = props.data.sessionID
|
||||
|
||||
@@ -13,8 +13,6 @@ import { ServerConnection, serverName } from "@/runtime/server/registry"
|
||||
import { useServerCollectionController } from "@/servers/registry/controller"
|
||||
import { DialogServer } from "@/servers/connect/dialog"
|
||||
import { SettingsList } from "@/settings/list"
|
||||
import { SshServerSettings } from "@/servers/ssh/settings"
|
||||
import { useSsh } from "@/servers/ssh/context"
|
||||
import { AddServerMenu, isWslServer, useFilteredWslServers, WslServerSettings } from "@/servers/wsl/settings"
|
||||
import "@/settings/settings.css"
|
||||
|
||||
@@ -24,14 +22,13 @@ export const SettingsServers: Component = () => {
|
||||
const controller = useServerCollectionController()
|
||||
const [store, setStore] = createStore({ filter: "" })
|
||||
const wslServers = useFilteredWslServers(() => store.filter)
|
||||
const ssh = useSsh()
|
||||
|
||||
const showSearch = createMemo(
|
||||
() => controller.collection.items().filter((item) => !isWslServer(item)).length + wslServers().length > 1,
|
||||
)
|
||||
|
||||
const filtered = createMemo(() => {
|
||||
const items = controller.collection.items().filter((item) => !isWslServer(item) && item.type !== "ssh")
|
||||
const items = controller.collection.items().filter((item) => !isWslServer(item))
|
||||
const query = store.filter.trim()
|
||||
if (!query) return items
|
||||
return fuzzysort
|
||||
@@ -92,7 +89,7 @@ export const SettingsServers: Component = () => {
|
||||
|
||||
<div class="settings-tab-body settings-servers">
|
||||
<Show
|
||||
when={filtered().length > 0 || wslServers().length > 0 || ssh.servers.some((item) => item.saved)}
|
||||
when={filtered().length > 0 || wslServers().length > 0}
|
||||
fallback={
|
||||
<div class="settings-servers-status">
|
||||
<span>{store.filter ? language.t("palette.empty") : language.t("dialog.server.empty")}</span>
|
||||
@@ -103,7 +100,6 @@ export const SettingsServers: Component = () => {
|
||||
}
|
||||
>
|
||||
<SettingsList>
|
||||
<SshServerSettings filter={store.filter} domain={controller} />
|
||||
<WslServerSettings domain={controller} servers={wslServers} />
|
||||
<For each={filtered()}>
|
||||
{(item) => {
|
||||
|
||||
@@ -1,24 +1,14 @@
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import { usePlatform } from "@/runtime/platform/platform"
|
||||
import { useCommand, type CommandOption } from "./command"
|
||||
import { useDialog } from "@opencode/ui/context/dialog"
|
||||
import { DialogSsh } from "@/servers/ssh/dialog"
|
||||
|
||||
export function DesktopCommands() {
|
||||
const command = useCommand()
|
||||
const language = useLanguage()
|
||||
const platform = usePlatform()
|
||||
const dialog = useDialog()
|
||||
|
||||
command.register("desktop", () => {
|
||||
const commands: CommandOption[] = []
|
||||
if (platform.sshServers)
|
||||
commands.push({
|
||||
id: "server.ssh.add",
|
||||
title: language.t("ssh.add"),
|
||||
category: language.t("command.category.server"),
|
||||
onSelect: () => void dialog.push(() => <DialogSsh openProject />),
|
||||
})
|
||||
if (platform.platform !== "desktop" || !platform.exportDebugLogs) return commands
|
||||
commands.push({
|
||||
id: "logs.export",
|
||||
|
||||
@@ -8,7 +8,6 @@ import { ToastRegion } from "@/shell/notifications/toast"
|
||||
import { TitlebarRightProvider } from "@/shell/titlebar/right-slot"
|
||||
import { useSettingsSurface } from "@/settings/surface"
|
||||
import { useSettings } from "@/settings/model"
|
||||
import { SshAuthentication } from "@/servers/ssh/authentication"
|
||||
|
||||
const DebugBar = lazy(() => import("@/shell/debug/debug-bar").then((module) => ({ default: module.DebugBar })))
|
||||
|
||||
@@ -98,9 +97,9 @@ export default function Layout(props: ParentProps) {
|
||||
"--settings-top-inset": mobile() && !bottomTitlebar() ? "0px" : "var(--shell-top-inset, 8px)",
|
||||
}}
|
||||
>
|
||||
<SshAuthentication>
|
||||
<div class="flex size-full min-h-0 min-w-0 flex-col">
|
||||
<Suspense>{props.children}</Suspense>
|
||||
</SshAuthentication>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
<Show when={import.meta.env.DEV && state.debugTools}>
|
||||
|
||||
@@ -679,11 +679,8 @@ export function Titlebar(props: {
|
||||
onReorder={(keys) => tabsStoreActions.reorder(keys)}
|
||||
/>
|
||||
</div>
|
||||
<div data-slot="vertical-tabs-footer" class="mt-2 flex w-full shrink-0 flex-col gap-2">
|
||||
<div data-slot="vertical-tabs-footer" class="mt-2 flex w-full shrink-0 flex-col">
|
||||
<TitlebarRightMount vertical />
|
||||
<Show when={updateState().visible}>
|
||||
<TitlebarUpdateIconButton state={updateState()} vertical />
|
||||
</Show>
|
||||
</div>
|
||||
</Portal>
|
||||
)}
|
||||
@@ -694,9 +691,7 @@ export function Titlebar(props: {
|
||||
<Show when={!mobile()}>
|
||||
<div class="flex-1" />
|
||||
</Show>
|
||||
<Show when={!props.verticalTabs}>
|
||||
<TitlebarRight state={rightState()} />
|
||||
</Show>
|
||||
<TitlebarRight state={rightState()} mount={!props.verticalTabs} />
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
@@ -719,52 +714,34 @@ type TitlebarRightState = {
|
||||
update: TitlebarUpdatePillState
|
||||
}
|
||||
|
||||
function TitlebarRight(props: { state: TitlebarRightState }) {
|
||||
function TitlebarRight(props: { state: TitlebarRightState; mount?: boolean }) {
|
||||
return (
|
||||
<div class="relative z-20 flex shrink-0 items-center justify-end gap-0 overflow-visible">
|
||||
<Show when={props.state.update.visible}>
|
||||
<TitlebarUpdateIconButton state={props.state.update} />
|
||||
</Show>
|
||||
<TitlebarRightMount />
|
||||
<Show when={props.mount !== false}>
|
||||
<TitlebarRightMount />
|
||||
</Show>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function TitlebarUpdateIconButton(props: { state: TitlebarUpdatePillState; vertical?: boolean }) {
|
||||
const label = () => (
|
||||
<span
|
||||
class="shrink-0 text-[11px] leading-4 text-v2-text-text-accent [font-weight:530] opacity-0 motion-safe:transition-all duration-150 ease-out group-hover:opacity-100 group-hover:translate-x-0 group-focus-within:opacity-100 group-focus-within:translate-x-0 motion-reduce:translate-x-0"
|
||||
classList={{
|
||||
"ms-px me-4 -translate-x-2 rtl:translate-x-2": props.vertical,
|
||||
"ms-2 me-px translate-x-2 rtl:-translate-x-2": !props.vertical,
|
||||
}}
|
||||
>
|
||||
{props.state.label}
|
||||
</span>
|
||||
)
|
||||
function TitlebarUpdateIconButton(props: { state: TitlebarUpdatePillState }) {
|
||||
return (
|
||||
<div
|
||||
data-slot="titlebar-update"
|
||||
class="group relative shrink-0 rounded-full bg-v2-background-bg-deep transition-[width] duration-150 ease-out hover:z-30 focus-within:z-30 motion-reduce:transition-none"
|
||||
classList={{
|
||||
"h-7 w-7 self-start hover:w-[84px] focus-within:w-[84px]": props.vertical,
|
||||
"me-3 h-5 w-5 hover:w-[68px] focus-within:w-[68px]": !props.vertical,
|
||||
}}
|
||||
>
|
||||
<div class="group relative mr-3 h-5 w-5 shrink-0 rounded-full bg-v2-background-bg-deep transition-[width] duration-150 ease-out hover:z-30 hover:w-[68px] focus-within:z-30 focus-within:w-[68px] motion-reduce:transition-none">
|
||||
<button
|
||||
type="button"
|
||||
class="absolute top-0 z-10 flex h-full w-full items-center overflow-hidden rounded-full bg-v2-icon-icon-accent/20 text-v2-icon-icon-accent transition-[background-color] duration-150 ease-out group-hover:bg-[color-mix(in_srgb,var(--v2-icon-icon-accent)_20%,var(--v2-background-bg-deep))] group-focus-within:bg-[color-mix(in_srgb,var(--v2-icon-icon-accent)_20%,var(--v2-background-bg-deep))] focus-visible:outline-none disabled:opacity-60 motion-reduce:transition-none [app-region:no-drag]"
|
||||
classList={{ "start-0 justify-start": props.vertical, "end-0 justify-end": !props.vertical }}
|
||||
class="absolute right-0 top-0 z-10 flex h-5 w-5 items-center justify-end overflow-hidden rounded-full bg-v2-icon-icon-accent/20 text-v2-icon-icon-accent transition-[width,background-color] duration-150 ease-out group-hover:w-[68px] group-hover:bg-[color-mix(in_srgb,var(--v2-icon-icon-accent)_20%,var(--v2-background-bg-deep))] group-focus-within:w-[68px] group-focus-within:bg-[color-mix(in_srgb,var(--v2-icon-icon-accent)_20%,var(--v2-background-bg-deep))] focus-visible:outline-none disabled:opacity-60 motion-reduce:transition-none"
|
||||
onClick={props.state.onInstall}
|
||||
disabled={props.state.installing}
|
||||
aria-busy={props.state.installing}
|
||||
aria-label={props.state.ariaLabel}
|
||||
>
|
||||
<Show when={!props.vertical}>{label()}</Show>
|
||||
<span
|
||||
class="flex shrink-0 items-center justify-center"
|
||||
classList={{ "size-7": props.vertical, "size-5": !props.vertical }}
|
||||
>
|
||||
<span class="shrink-0 ml-[8px] mr-px text-[11px] text-v2-text-text-accent [font-weight:530] opacity-0 translate-x-2 motion-safe:transition-all duration-150 ease-out group-hover:opacity-100 group-hover:translate-x-0 group-focus-within:opacity-100 group-focus-within:translate-x-0 motion-reduce:translate-x-0">
|
||||
{props.state.label}
|
||||
</span>
|
||||
<span class="flex size-5 shrink-0 items-center justify-center">
|
||||
<Show
|
||||
when={!props.state.installing}
|
||||
fallback={<span data-slot="titlebar-update-loader" aria-hidden="true" />}
|
||||
@@ -774,7 +751,6 @@ function TitlebarUpdateIconButton(props: { state: TitlebarUpdatePillState; verti
|
||||
</svg>
|
||||
</Show>
|
||||
</span>
|
||||
<Show when={props.vertical}>{label()}</Show>
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,100 +0,0 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import { createRoot } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { createSshAuthentication } from "../src/servers/ssh/authentication-state"
|
||||
import type { SshItem } from "../src/servers/ssh/types"
|
||||
|
||||
test("background authentication stays quiet; selecting a tab prompts once and cancellation is respected", () => {
|
||||
const opened: string[] = []
|
||||
const fixture = createRoot((dispose) => {
|
||||
const [state, setState] = createStore<{ selection?: string; busy: boolean; item: SshItem }>({
|
||||
busy: false,
|
||||
item: { config: { id: "host", target: "linuxbook", name: "" }, stage: "authentication", saved: true, detail: "" },
|
||||
})
|
||||
createSshAuthentication({
|
||||
selection: () => state.selection,
|
||||
item: () => state.item,
|
||||
busy: () => state.busy,
|
||||
open: (item) => {
|
||||
opened.push(item.config.id)
|
||||
setState("busy", true)
|
||||
},
|
||||
})
|
||||
return { dispose, setState }
|
||||
})
|
||||
try {
|
||||
expect(opened).toEqual([])
|
||||
fixture.setState("selection", "session-1")
|
||||
expect(opened).toEqual(["host"])
|
||||
fixture.setState("busy", false)
|
||||
fixture.setState("item", "detail", "new status")
|
||||
expect(opened).toHaveLength(1)
|
||||
fixture.setState("selection", undefined)
|
||||
fixture.setState("selection", "session-1")
|
||||
expect(opened).toHaveLength(2)
|
||||
} finally {
|
||||
fixture.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
test("a selected tab waits for authentication and other dialogs before offering a prompt", () => {
|
||||
const opened: string[] = []
|
||||
const fixture = createRoot((dispose) => {
|
||||
const [state, setState] = createStore<{ busy: boolean; item: SshItem }>({
|
||||
busy: true,
|
||||
item: { config: { id: "host", target: "linuxbook", name: "" }, stage: "connecting", saved: true, detail: "" },
|
||||
})
|
||||
createSshAuthentication({
|
||||
selection: () => "draft-1",
|
||||
item: () => state.item,
|
||||
busy: () => state.busy,
|
||||
open: (item) => {
|
||||
opened.push(item.config.id)
|
||||
},
|
||||
})
|
||||
return { dispose, setState }
|
||||
})
|
||||
try {
|
||||
expect(opened).toEqual([])
|
||||
fixture.setState("item", "stage", "authentication")
|
||||
expect(opened).toEqual([])
|
||||
fixture.setState("busy", false)
|
||||
expect(opened).toEqual(["host"])
|
||||
fixture.setState("item", "stage", "connecting")
|
||||
fixture.setState("item", "stage", "authentication")
|
||||
expect(opened).toHaveLength(1)
|
||||
} finally {
|
||||
fixture.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
test("selecting a tab waits for another window to release authentication", () => {
|
||||
const opened: string[] = []
|
||||
const fixture = createRoot((dispose) => {
|
||||
const [state, setState] = createStore<{ item: SshItem }>({
|
||||
item: {
|
||||
config: { id: "host", target: "linuxbook", name: "" },
|
||||
stage: "authentication",
|
||||
authenticatingElsewhere: true,
|
||||
saved: true,
|
||||
detail: "",
|
||||
},
|
||||
})
|
||||
createSshAuthentication({
|
||||
selection: () => "session-1",
|
||||
item: () => state.item,
|
||||
busy: () => false,
|
||||
open: (item) => opened.push(item.config.id),
|
||||
})
|
||||
return { dispose, setState }
|
||||
})
|
||||
try {
|
||||
expect(opened).toEqual([])
|
||||
fixture.setState("item", "detail", "still waiting in another window")
|
||||
expect(opened).toEqual([])
|
||||
fixture.setState("item", "authenticatingElsewhere", false)
|
||||
expect(opened).toEqual(["host"])
|
||||
} finally {
|
||||
fixture.dispose()
|
||||
}
|
||||
})
|
||||
@@ -1,38 +0,0 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import { createRoot } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { createComposerEditor } from "../src/composer/editor/interaction"
|
||||
import type { ComposerPersistedState } from "../src/composer/types"
|
||||
|
||||
test("a disconnected composer preserves text and ignores submissions until reconnect", () => {
|
||||
createRoot((dispose) => {
|
||||
const [state, setState] = createStore({ connected: false, submissions: 0 })
|
||||
const store = createStore<ComposerPersistedState>({
|
||||
prompt: [{ type: "text", content: "keep my draft", start: 0, end: 13 }],
|
||||
context: { items: [] },
|
||||
})
|
||||
const editor = createComposerEditor({
|
||||
store,
|
||||
commands: () => [],
|
||||
context: () => [],
|
||||
searchContextFiles: () => [],
|
||||
view: {
|
||||
submit: {
|
||||
available: () => state.connected,
|
||||
stopping: () => false,
|
||||
onStop() {},
|
||||
onSubmit: () => setState("submissions", (count) => count + 1),
|
||||
},
|
||||
},
|
||||
})
|
||||
expect(editor.canSubmit()).toBe(false)
|
||||
editor.submit()
|
||||
expect(state.submissions).toBe(0)
|
||||
expect(editor.value()).toBe("keep my draft")
|
||||
setState("connected", true)
|
||||
expect(editor.canSubmit()).toBe(true)
|
||||
editor.submit()
|
||||
expect(state.submissions).toBe(1)
|
||||
dispose()
|
||||
})
|
||||
})
|
||||
@@ -1,257 +0,0 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import { createEffect, createRoot } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { createSshController } from "../src/servers/ssh/controller"
|
||||
import type { SshItem } from "../src/servers/ssh/types"
|
||||
|
||||
function fixture() {
|
||||
return createRoot((dispose) => {
|
||||
const config = { id: "host", target: "ssh linuxbook", name: "" }
|
||||
const [state, setState] = createStore<{ items: SshItem[]; busy: boolean }>({
|
||||
items: [{ config, stage: "disconnected", saved: true, detail: "" }],
|
||||
busy: false,
|
||||
})
|
||||
const calls = { starts: 0, responses: 0, cancels: 0, forgets: 0, prompts: 0, errors: 0, connected: 0 }
|
||||
const admission = Promise.withResolvers<void>()
|
||||
const refresh = Promise.withResolvers<void>()
|
||||
const response = Promise.withResolvers<void>()
|
||||
const cancelled = Promise.withResolvers<void>()
|
||||
const ssh = createSshController({
|
||||
items: () => state.items,
|
||||
api: {
|
||||
start: () => {
|
||||
calls.starts++
|
||||
return admission.promise
|
||||
},
|
||||
respond: () => {
|
||||
calls.responses++
|
||||
return calls.responses === 1 ? response.promise : Promise.resolve()
|
||||
},
|
||||
cancel: async () => {
|
||||
calls.cancels++
|
||||
setState("items", 0, { stage: "disconnected", prompt: undefined })
|
||||
cancelled.resolve()
|
||||
},
|
||||
forget: async () => {
|
||||
calls.forgets++
|
||||
setState("items", [])
|
||||
},
|
||||
disconnect: async () => {},
|
||||
},
|
||||
refresh: () => refresh.promise,
|
||||
error: () => calls.errors++,
|
||||
})
|
||||
createEffect(() => {
|
||||
const item = ssh.dialog.next()
|
||||
if (!item || state.busy) return
|
||||
ssh.dialog.opened(item.config.id)
|
||||
calls.prompts++
|
||||
})
|
||||
const admitted = async () => {
|
||||
admission.resolve()
|
||||
refresh.resolve()
|
||||
await refresh.promise
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
}
|
||||
return { dispose, config, setState, calls, admission, refresh, response, cancelled, admitted, ssh }
|
||||
})
|
||||
}
|
||||
|
||||
test("key-based reconnect stays pending through admission and refetch without opening a dialog", async () => {
|
||||
const app = fixture()
|
||||
try {
|
||||
app.ssh.connect(app.config, { onConnected: () => app.calls.connected++ })
|
||||
app.ssh.connect(app.config)
|
||||
expect(app.ssh.pending("host")).toBe(true)
|
||||
app.admission.resolve()
|
||||
await app.admission.promise
|
||||
await Promise.resolve()
|
||||
expect(app.ssh.pending("host")).toBe(true)
|
||||
expect(app.calls.starts).toBe(1)
|
||||
app.setState("items", 0, "stage", "connecting")
|
||||
await app.admitted()
|
||||
expect(app.ssh.pending("host")).toBe(true)
|
||||
expect(app.calls.prompts).toBe(0)
|
||||
app.setState("items", 0, "stage", "ready")
|
||||
await Promise.resolve()
|
||||
expect(app.ssh.pending("host")).toBe(false)
|
||||
expect(app.calls.connected).toBe(1)
|
||||
} finally {
|
||||
app.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
test("reconnect waits for an available dialog and opens only once across SSH challenges", async () => {
|
||||
const app = fixture()
|
||||
try {
|
||||
app.setState("busy", true)
|
||||
app.ssh.connect(app.config)
|
||||
app.setState("items", 0, { stage: "authentication", prompt: { id: "password", text: "Password:", confirm: false } })
|
||||
await app.admitted()
|
||||
expect(app.calls.prompts).toBe(0)
|
||||
app.setState("busy", false)
|
||||
expect(app.calls.prompts).toBe(1)
|
||||
app.setState("items", 0, "prompt", { id: "otp", text: "Code:", confirm: false })
|
||||
expect(app.calls.prompts).toBe(1)
|
||||
expect(app.calls.starts).toBe(1)
|
||||
} finally {
|
||||
app.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
test("a connection form owns its challenges and reports request failures inline", async () => {
|
||||
const app = fixture()
|
||||
try {
|
||||
app.ssh.connect(app.config, { dialog: true })
|
||||
app.admission.reject(new Error("IPC unavailable"))
|
||||
await app.admission.promise.catch(() => {})
|
||||
await Promise.resolve()
|
||||
expect(app.ssh.error("host")).toBe(true)
|
||||
expect(app.ssh.submitting("host")).toBe(false)
|
||||
expect(app.calls.errors).toBe(0)
|
||||
expect(app.calls.prompts).toBe(0)
|
||||
} finally {
|
||||
app.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
test("responding suppresses duplicate submissions while allowing the next SSH challenge", async () => {
|
||||
const app = fixture()
|
||||
try {
|
||||
app.ssh.connect(app.config)
|
||||
app.setState("items", 0, { stage: "authentication", prompt: { id: "password", text: "Password:", confirm: false } })
|
||||
await app.admitted()
|
||||
app.ssh.respond("host", "expired", "ignored")
|
||||
app.ssh.respond("host", "password", "secret")
|
||||
app.ssh.respond("host", "password", "secret")
|
||||
expect(app.calls.responses).toBe(1)
|
||||
expect(app.ssh.submitting("host")).toBe(true)
|
||||
app.response.resolve()
|
||||
await app.response.promise
|
||||
await Promise.resolve()
|
||||
expect(app.ssh.answered("host")).toBe(true)
|
||||
app.ssh.respond("host", "password", "secret")
|
||||
expect(app.calls.responses).toBe(1)
|
||||
app.setState("items", 0, "prompt", { id: "otp", text: "Code:", confirm: false })
|
||||
expect(app.ssh.answered("host")).toBe(false)
|
||||
app.ssh.respond("host", "otp", "123456")
|
||||
expect(app.calls.responses).toBe(2)
|
||||
} finally {
|
||||
app.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
test("failed reconnect becomes retryable without opening a connection form", async () => {
|
||||
const app = fixture()
|
||||
try {
|
||||
app.ssh.connect(app.config)
|
||||
app.setState("items", 0, "stage", "connecting")
|
||||
await app.admitted()
|
||||
app.setState("items", 0, "stage", "failed")
|
||||
expect(app.ssh.pending("host")).toBe(false)
|
||||
expect(app.calls.prompts).toBe(0)
|
||||
app.ssh.connect(app.config)
|
||||
expect(app.calls.starts).toBe(2)
|
||||
} finally {
|
||||
app.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
test("a failed response can be retried without losing the reconnect continuation", async () => {
|
||||
const app = fixture()
|
||||
try {
|
||||
app.ssh.connect(app.config, { onConnected: () => app.calls.connected++ })
|
||||
app.setState("items", 0, { stage: "authentication", prompt: { id: "password", text: "Password:", confirm: false } })
|
||||
await app.admitted()
|
||||
app.ssh.respond("host", "password", "secret")
|
||||
app.response.reject(new Error("IPC unavailable"))
|
||||
await app.response.promise.catch(() => {})
|
||||
await Promise.resolve()
|
||||
expect(app.ssh.error("host")).toBe(true)
|
||||
expect(app.ssh.answered("host")).toBe(false)
|
||||
expect(app.calls.errors).toBe(0)
|
||||
app.ssh.respond("host", "password", "secret")
|
||||
await Promise.resolve()
|
||||
expect(app.calls.responses).toBe(2)
|
||||
expect(app.ssh.error("host")).toBe(false)
|
||||
app.setState("items", 0, "stage", "ready")
|
||||
await Promise.resolve()
|
||||
expect(app.calls.connected).toBe(1)
|
||||
} finally {
|
||||
app.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
test("cancelling a version-mismatch dialog allows another reconnect", async () => {
|
||||
const app = fixture()
|
||||
try {
|
||||
app.ssh.connect(app.config)
|
||||
app.setState("items", 0, "stage", "incompatible")
|
||||
await app.admitted()
|
||||
expect(app.calls.prompts).toBe(1)
|
||||
app.ssh.cancel("host")
|
||||
await app.cancelled.promise
|
||||
app.ssh.connect(app.config)
|
||||
app.setState("items", 0, "stage", "incompatible")
|
||||
await app.admitted()
|
||||
expect(app.calls.starts).toBe(2)
|
||||
expect(app.calls.prompts).toBe(2)
|
||||
expect(app.calls.forgets).toBe(0)
|
||||
} finally {
|
||||
app.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
test("updating from the authentication dialog preserves the continuation and invokes it once", async () => {
|
||||
const app = fixture()
|
||||
try {
|
||||
app.ssh.connect(app.config, { onConnected: () => app.calls.connected++ })
|
||||
app.setState("items", 0, "stage", "incompatible")
|
||||
await app.admitted()
|
||||
app.ssh.connect(app.config, { dialog: true, replace: true })
|
||||
app.setState("items", 0, "stage", "connecting")
|
||||
await app.admitted()
|
||||
app.setState("items", 0, "stage", "ready")
|
||||
await Promise.resolve()
|
||||
app.setState("items", 0, "detail", "updated")
|
||||
await Promise.resolve()
|
||||
expect(app.calls.connected).toBe(1)
|
||||
expect(app.calls.prompts).toBe(1)
|
||||
} finally {
|
||||
app.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
test("cancelling an unsaved connection interrupts admission and forgets it", async () => {
|
||||
const app = fixture()
|
||||
try {
|
||||
app.setState("items", 0, "saved", false)
|
||||
app.ssh.connect(app.config, { dialog: true })
|
||||
app.ssh.cancel("host")
|
||||
await app.cancelled.promise
|
||||
await Promise.resolve()
|
||||
expect(app.calls.cancels).toBe(1)
|
||||
expect(app.calls.forgets).toBe(1)
|
||||
expect(app.ssh.item("host")).toBeUndefined()
|
||||
expect(app.ssh.submitting("host")).toBe(false)
|
||||
app.admission.resolve()
|
||||
} finally {
|
||||
app.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
test("another window's authentication stays pending without starting a competing attempt", () => {
|
||||
const app = fixture()
|
||||
try {
|
||||
app.setState("items", 0, { stage: "authentication", authenticatingElsewhere: true })
|
||||
app.ssh.connect(app.config)
|
||||
expect(app.ssh.pending("host")).toBe(true)
|
||||
expect(app.calls.starts).toBe(0)
|
||||
app.setState("items", 0, "authenticatingElsewhere", false)
|
||||
app.ssh.connect(app.config)
|
||||
expect(app.calls.starts).toBe(1)
|
||||
} finally {
|
||||
app.dispose()
|
||||
}
|
||||
})
|
||||
@@ -1,130 +0,0 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import { createRoot } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { createServerHealth, type ServerHealth } from "../src/runtime/server/health"
|
||||
import { ServerConnection } from "../src/runtime/server/registry"
|
||||
import type { SshItem } from "../src/servers/ssh/types"
|
||||
|
||||
test("SSH health is checked only with an active tunnel, and cancellation clears stale failures", async () => {
|
||||
const requests: ReturnType<typeof Promise.withResolvers<ServerHealth>>[] = []
|
||||
const app = createRoot((dispose) => {
|
||||
const [state, setState] = createStore<{ stage: SshItem["stage"] }>({ stage: "disconnected" })
|
||||
const connection: ServerConnection.Ssh = {
|
||||
type: "ssh",
|
||||
id: "fixture",
|
||||
host: "devbox",
|
||||
http: { url: "http://127.0.0.1:12345" },
|
||||
get stage() {
|
||||
return state.stage
|
||||
},
|
||||
}
|
||||
const health = createServerHealth(
|
||||
() => [connection],
|
||||
() => true,
|
||||
() => {
|
||||
const request = Promise.withResolvers<ServerHealth>()
|
||||
requests.push(request)
|
||||
return request.promise
|
||||
},
|
||||
)
|
||||
return { dispose, setState, health: () => health[ServerConnection.key(connection)] }
|
||||
})
|
||||
try {
|
||||
expect(app.health()).toBeUndefined()
|
||||
app.setState("stage", "connecting")
|
||||
app.setState("stage", "authentication")
|
||||
app.setState("stage", "disconnected")
|
||||
expect(requests).toHaveLength(0)
|
||||
expect(app.health()).toBeUndefined()
|
||||
app.setState("stage", "ready")
|
||||
expect(requests).toHaveLength(1)
|
||||
expect(app.health()?.checking).toBe(true)
|
||||
app.setState("stage", "authentication")
|
||||
requests[0]?.resolve({ healthy: false })
|
||||
await Promise.resolve()
|
||||
expect(app.health()).toBeUndefined()
|
||||
app.setState("stage", "failed")
|
||||
expect(app.health()?.healthy).toBe(false)
|
||||
app.setState("stage", "authentication")
|
||||
expect(app.health()).toBeUndefined()
|
||||
app.setState("stage", "ready")
|
||||
requests[1]?.resolve({ healthy: false })
|
||||
await Promise.resolve()
|
||||
expect(app.health()).toEqual({ healthy: false })
|
||||
} finally {
|
||||
app.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
function fixture() {
|
||||
const requests: ReturnType<typeof Promise.withResolvers<ServerHealth>>[] = []
|
||||
return createRoot((dispose) => {
|
||||
const [state, setState] = createStore({ url: "http://127.0.0.1:0", connecting: true })
|
||||
const connection: ServerConnection.Ssh = {
|
||||
type: "ssh",
|
||||
id: "fixture",
|
||||
host: "devbox",
|
||||
get http() {
|
||||
return { url: state.url }
|
||||
},
|
||||
get connecting() {
|
||||
return state.connecting
|
||||
},
|
||||
}
|
||||
const health = createServerHealth(
|
||||
() => [connection],
|
||||
() => true,
|
||||
() => {
|
||||
const request = Promise.withResolvers<ServerHealth>()
|
||||
requests.push(request)
|
||||
return request.promise
|
||||
},
|
||||
)
|
||||
return { dispose, setState, requests, health: () => health[ServerConnection.key(connection)] }
|
||||
})
|
||||
}
|
||||
|
||||
test("a new SSH endpoint stays checking after connection completes instead of showing the old failure", async () => {
|
||||
const app = fixture()
|
||||
try {
|
||||
app.requests[0]?.resolve({ healthy: false })
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
expect(app.health()?.healthy).toBe(false)
|
||||
app.setState({ url: "http://127.0.0.1:12345", connecting: false })
|
||||
expect(app.health()).toEqual({ healthy: false, checking: true })
|
||||
app.requests[1]?.resolve({ healthy: true, version: "2.0.0" })
|
||||
await Promise.resolve()
|
||||
expect(app.health()).toEqual({ healthy: true, version: "2.0.0" })
|
||||
} finally {
|
||||
app.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
test("a late failure from the old endpoint cannot overwrite the new endpoint check", async () => {
|
||||
const app = fixture()
|
||||
try {
|
||||
app.setState({ url: "http://127.0.0.1:12345", connecting: false })
|
||||
app.requests[0]?.resolve({ healthy: false })
|
||||
await Promise.resolve()
|
||||
expect(app.health()).toEqual({ healthy: false, checking: true })
|
||||
app.requests[1]?.resolve({ healthy: true })
|
||||
await Promise.resolve()
|
||||
expect(app.health()).toEqual({ healthy: true })
|
||||
} finally {
|
||||
app.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
test("a failed check of the new tunnel stops checking and still reports failure", async () => {
|
||||
const app = fixture()
|
||||
try {
|
||||
app.setState({ url: "http://127.0.0.1:12345", connecting: false })
|
||||
expect(app.health()?.checking).toBe(true)
|
||||
app.requests[1]?.resolve({ healthy: false })
|
||||
await Promise.resolve()
|
||||
expect(app.health()).toEqual({ healthy: false })
|
||||
} finally {
|
||||
app.dispose()
|
||||
}
|
||||
})
|
||||
@@ -1,68 +0,0 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import { createRoot } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { createSshRestore } from "../src/servers/ssh/restore-state"
|
||||
import type { SshItem, SshStart, SshState } from "../src/servers/ssh/types"
|
||||
|
||||
const server = (id: string, stage: SshItem["stage"] = "disconnected", saved = true): SshItem => ({
|
||||
config: { id, target: `ssh ${id}`, name: "" },
|
||||
saved,
|
||||
stage,
|
||||
detail: "",
|
||||
})
|
||||
|
||||
test("restores every saved server after loading without tabs or a default server", () => {
|
||||
const starts: SshStart[] = []
|
||||
const fixture = createRoot((dispose) => {
|
||||
const [state, setState] = createStore<{ current?: SshState }>({})
|
||||
createSshRestore({
|
||||
state: () => state.current,
|
||||
start: (input) => {
|
||||
starts.push(input)
|
||||
return Promise.resolve()
|
||||
},
|
||||
})
|
||||
return { dispose, setState }
|
||||
})
|
||||
try {
|
||||
expect(starts).toEqual([])
|
||||
fixture.setState("current", {
|
||||
servers: [server("devbox"), server("buildbox"), server("draft", "disconnected", false)],
|
||||
})
|
||||
expect(starts).toEqual([
|
||||
{ ...server("devbox").config, background: true },
|
||||
{ ...server("buildbox").config, background: true },
|
||||
])
|
||||
fixture.setState("current", "servers", 0, "stage", "connecting")
|
||||
fixture.setState("current", "servers", 0, "stage", "disconnected")
|
||||
expect(starts).toHaveLength(2)
|
||||
} finally {
|
||||
fixture.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
test("does not restart active connections or authentication prompts after state updates", () => {
|
||||
const starts: SshStart[] = []
|
||||
const fixture = createRoot((dispose) => {
|
||||
const [state, setState] = createStore<SshState>({
|
||||
servers: [server("ready", "ready"), server("busy", "connecting"), server("prompt", "authentication")],
|
||||
})
|
||||
createSshRestore({
|
||||
state: () => state,
|
||||
start: (input) => {
|
||||
starts.push(input)
|
||||
return Promise.resolve()
|
||||
},
|
||||
})
|
||||
return { dispose, setState }
|
||||
})
|
||||
try {
|
||||
expect(starts).toEqual([])
|
||||
fixture.setState("servers", 0, "stage", "disconnected")
|
||||
fixture.setState("servers", 1, "stage", "failed")
|
||||
fixture.setState("servers", 2, "stage", "disconnected")
|
||||
expect(starts).toEqual([])
|
||||
} finally {
|
||||
fixture.dispose()
|
||||
}
|
||||
})
|
||||
@@ -98,6 +98,17 @@ const Root = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCODE_CLI_NAME
|
||||
Spec.make("paths", { description: "Show global paths (data, config, cache, state)" }),
|
||||
],
|
||||
}),
|
||||
Spec.make("console", {
|
||||
description: "Manage OpenCode Console access",
|
||||
commands: [
|
||||
Spec.make("login", {
|
||||
description: "Log in to OpenCode Console",
|
||||
params: {
|
||||
url: Argument.string("url").pipe(Argument.withDescription("Console server URL"), Argument.optional),
|
||||
},
|
||||
}),
|
||||
],
|
||||
}),
|
||||
Spec.make("auth", {
|
||||
description: "manage AI providers and credentials",
|
||||
commands: [
|
||||
@@ -123,31 +134,13 @@ const Root = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCODE_CLI_NAME
|
||||
},
|
||||
}),
|
||||
Spec.make("logout", {
|
||||
description: "log out of a saved account",
|
||||
description: "log out from a configured provider",
|
||||
params: {
|
||||
...ServerParams,
|
||||
target: Argument.string("target").pipe(
|
||||
Argument.withDescription("Integration ID or name"),
|
||||
Argument.optional,
|
||||
),
|
||||
credential: Argument.string("credential").pipe(
|
||||
Argument.withDescription("Credential ID or label (opens an account picker when omitted)"),
|
||||
Argument.optional,
|
||||
),
|
||||
},
|
||||
}),
|
||||
Spec.make("switch", {
|
||||
description: "switch the active account for an integration",
|
||||
params: {
|
||||
...ServerParams,
|
||||
target: Argument.string("target").pipe(
|
||||
Argument.withDescription("Integration ID or name"),
|
||||
Argument.optional,
|
||||
),
|
||||
credential: Argument.string("credential").pipe(
|
||||
Argument.withDescription("Credential ID or label (opens an account picker when omitted)"),
|
||||
Argument.optional,
|
||||
),
|
||||
},
|
||||
}),
|
||||
],
|
||||
@@ -268,6 +261,28 @@ const Root = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCODE_CLI_NAME
|
||||
json: Flag.boolean("json").pipe(Flag.withDescription("Output statistics as JSON"), Flag.withDefault(false)),
|
||||
},
|
||||
}),
|
||||
Spec.make("export", {
|
||||
description: "Export session data as JSON",
|
||||
params: {
|
||||
...ServerParams,
|
||||
session: Argument.string("session").pipe(Argument.withDescription("Session ID to export"), Argument.optional),
|
||||
sanitize: Flag.boolean("sanitize").pipe(
|
||||
Flag.withDescription("Redact sensitive transcript and file data"),
|
||||
Flag.withDefault(false),
|
||||
),
|
||||
},
|
||||
}),
|
||||
Spec.make("import", {
|
||||
description: "Import session data from a JSON file or URL",
|
||||
params: {
|
||||
...ServerParams,
|
||||
file: Argument.string("file").pipe(Argument.withDescription("JSON file or URL to import")),
|
||||
directory: Flag.string("directory").pipe(
|
||||
Flag.withDescription("Directory in which to import the session"),
|
||||
Flag.optional,
|
||||
),
|
||||
},
|
||||
}),
|
||||
Spec.make("mini", {
|
||||
description: "Start the minimal interactive interface",
|
||||
params: {
|
||||
@@ -372,31 +387,6 @@ const Root = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCODE_CLI_NAME
|
||||
sessionID: Argument.string("sessionID").pipe(Argument.withDescription("Session ID to delete")),
|
||||
},
|
||||
}),
|
||||
Spec.make("export", {
|
||||
description: "Export session data as JSON",
|
||||
params: {
|
||||
...ServerParams,
|
||||
session: Argument.string("session").pipe(
|
||||
Argument.withDescription("Session ID to export"),
|
||||
Argument.optional,
|
||||
),
|
||||
sanitize: Flag.boolean("sanitize").pipe(
|
||||
Flag.withDescription("Redact sensitive transcript and file data"),
|
||||
Flag.withDefault(false),
|
||||
),
|
||||
},
|
||||
}),
|
||||
Spec.make("import", {
|
||||
description: "Import session data from a JSON file or URL",
|
||||
params: {
|
||||
...ServerParams,
|
||||
file: Argument.string("file").pipe(Argument.withDescription("JSON file or URL to import")),
|
||||
directory: Flag.string("directory").pipe(
|
||||
Flag.withDescription("Directory in which to import the session"),
|
||||
Flag.optional,
|
||||
),
|
||||
},
|
||||
}),
|
||||
],
|
||||
}),
|
||||
Spec.make("service", {
|
||||
|
||||
@@ -1,74 +0,0 @@
|
||||
import { autocomplete } from "@clack/prompts"
|
||||
import { Effect } from "effect"
|
||||
import type { IntegrationInfo } from "@opencode/client"
|
||||
import { prompt } from "../../../ui/prompt"
|
||||
import { resolveIntegration } from "./shared"
|
||||
|
||||
export const chooseIntegration = Effect.fn("cli.auth.account.integration")(function* (
|
||||
integrations: IntegrationInfo[],
|
||||
target?: string,
|
||||
) {
|
||||
if (target) return yield* resolveIntegration(integrations, target)
|
||||
const configured = integrations.filter((integration) =>
|
||||
integration.connections.some((connection) => connection.type === "credential"),
|
||||
)
|
||||
if (configured.length === 0) return yield* Effect.fail(new Error("No stored credentials found"))
|
||||
const id = yield* prompt<string>(() =>
|
||||
autocomplete({
|
||||
message: "Select integration",
|
||||
maxItems: 8,
|
||||
options: configured.map((integration) => ({
|
||||
value: integration.id,
|
||||
label: integration.name,
|
||||
hint: integration.connections
|
||||
.filter((connection) => connection.type === "credential")
|
||||
.map((connection) => connection.label)
|
||||
.join(", "),
|
||||
})),
|
||||
}),
|
||||
)
|
||||
return yield* resolveIntegration(configured, id)
|
||||
})
|
||||
|
||||
export const chooseCredential = Effect.fn("cli.auth.account.credential")(function* (
|
||||
integration: IntegrationInfo,
|
||||
action: "log out" | "switch to",
|
||||
target?: string,
|
||||
) {
|
||||
const credentials = integration.connections.filter((connection) => connection.type === "credential")
|
||||
if (credentials.length === 0) {
|
||||
const environment = integration.connections
|
||||
.filter((connection) => connection.type === "env")
|
||||
.map((connection) => connection.name)
|
||||
if (environment.length && action === "log out") {
|
||||
yield* Effect.fail(
|
||||
new Error(
|
||||
`${integration.name} is authenticated through ${environment.join(", ")}; unset the environment variable to disconnect`,
|
||||
),
|
||||
)
|
||||
}
|
||||
yield* Effect.fail(new Error(`No stored credentials for ${integration.name}`))
|
||||
}
|
||||
if (target) {
|
||||
const byID = credentials.find((credential) => credential.id === target)
|
||||
if (byID) return byID.id
|
||||
const matches = credentials.filter((credential) => credential.label.toLowerCase() === target.toLowerCase())
|
||||
if (matches.length === 1) return matches[0].id
|
||||
if (matches.length > 1)
|
||||
return yield* Effect.fail(
|
||||
new Error(`Credential label "${target}" is ambiguous. Use an ID: ${matches.map((item) => item.id).join(", ")}`),
|
||||
)
|
||||
return yield* Effect.fail(new Error(`Credential not found for ${integration.name}: ${target}`))
|
||||
}
|
||||
return yield* prompt<string>(() =>
|
||||
autocomplete({
|
||||
message: `Select ${integration.name} account to ${action}`,
|
||||
maxItems: 8,
|
||||
options: credentials.map((credential, index) => ({
|
||||
value: credential.id,
|
||||
label: index === 0 ? `${credential.label} (active)` : credential.label,
|
||||
hint: credential.id,
|
||||
})),
|
||||
}),
|
||||
)
|
||||
})
|
||||
@@ -1,17 +1,16 @@
|
||||
import { intro, outro, spinner } from "@clack/prompts"
|
||||
import { autocomplete, intro, outro, spinner } from "@clack/prompts"
|
||||
import { Effect, Option } from "effect"
|
||||
import type { IntegrationInfo } from "@opencode/client"
|
||||
import { Commands } from "../../commands"
|
||||
import { Runtime } from "../../../framework/runtime"
|
||||
import { handlePromptErrors, requireInteractive } from "../../../ui/prompt"
|
||||
import { createClient, loadIntegrations, location, request } from "./shared"
|
||||
import { chooseCredential, chooseIntegration } from "./account"
|
||||
import { handlePromptErrors, prompt, requireInteractive } from "../../../ui/prompt"
|
||||
import { createClient, loadIntegrations, location, request, resolveIntegration } from "./shared"
|
||||
|
||||
export default Runtime.handler(
|
||||
Commands.commands.auth.commands.logout,
|
||||
Effect.fn("cli.auth.logout")((input) =>
|
||||
logout({
|
||||
target: Option.getOrUndefined(input.target),
|
||||
credential: Option.getOrUndefined(input.credential),
|
||||
server: Option.getOrUndefined(input.server),
|
||||
standalone: input.standalone,
|
||||
}).pipe(handlePromptErrors),
|
||||
@@ -20,24 +19,65 @@ export default Runtime.handler(
|
||||
|
||||
const logout = Effect.fn("cli.auth.logout.run")(function* (input: {
|
||||
target?: string
|
||||
credential?: string
|
||||
server?: string
|
||||
standalone: boolean
|
||||
}) {
|
||||
if (!input.target)
|
||||
yield* requireInteractive("Pass an integration ID or name when running without an interactive terminal")
|
||||
if (!input.credential)
|
||||
yield* requireInteractive("Pass a credential ID or label when running without an interactive terminal")
|
||||
intro("Log out of an account")
|
||||
intro("Remove credential")
|
||||
const client = yield* createClient({ server: input.server, standalone: input.standalone })
|
||||
const integrations = yield* loadIntegrations(client)
|
||||
const integration = yield* chooseIntegration(integrations, input.target)
|
||||
const credentialID = yield* chooseCredential(integration, "log out", input.credential)
|
||||
const credentials = integration.connections.filter((connection) => connection.type === "credential")
|
||||
if (credentials.length === 0) {
|
||||
const environment = integration.connections
|
||||
.filter((connection) => connection.type === "env")
|
||||
.map((connection) => connection.name)
|
||||
if (environment.length) {
|
||||
yield* Effect.fail(
|
||||
new Error(
|
||||
`${integration.name} is authenticated through ${environment.join(", ")}; unset the environment variable to disconnect`,
|
||||
),
|
||||
)
|
||||
}
|
||||
yield* Effect.fail(new Error(`No stored credentials for ${integration.name}`))
|
||||
}
|
||||
const progress = spinner()
|
||||
progress.start("Removing credential...")
|
||||
yield* request((signal) => client.credential.remove({ credentialID, location }, { signal })).pipe(
|
||||
Effect.tap(() => Effect.sync(() => progress.stop(`Removed account from ${integration.name}`))),
|
||||
yield* Effect.forEach(
|
||||
credentials,
|
||||
(connection) =>
|
||||
request((signal) => client.credential.remove({ credentialID: connection.id, location }, { signal })),
|
||||
{ concurrency: "unbounded", discard: true },
|
||||
).pipe(
|
||||
Effect.tap(() => Effect.sync(() => progress.stop(`Disconnected from ${integration.name}`))),
|
||||
Effect.tapCause(() => Effect.sync(() => progress.stop("Failed to remove credential", 1))),
|
||||
)
|
||||
outro("Done")
|
||||
})
|
||||
|
||||
const chooseIntegration = Effect.fn("cli.auth.logout.integration")(function* (
|
||||
integrations: IntegrationInfo[],
|
||||
target?: string,
|
||||
) {
|
||||
if (target) return yield* resolveIntegration(integrations, target)
|
||||
const configured = integrations.filter((integration) =>
|
||||
integration.connections.some((connection) => connection.type === "credential"),
|
||||
)
|
||||
if (configured.length === 0) return yield* Effect.fail(new Error("No stored credentials found"))
|
||||
const id = yield* prompt<string>(() =>
|
||||
autocomplete({
|
||||
message: "Select integration",
|
||||
maxItems: 8,
|
||||
options: configured.map((integration) => ({
|
||||
value: integration.id,
|
||||
label: integration.name,
|
||||
hint: integration.connections
|
||||
.filter((connection) => connection.type === "credential")
|
||||
.map((connection) => connection.label)
|
||||
.join(", "),
|
||||
})),
|
||||
}),
|
||||
)
|
||||
return yield* resolveIntegration(configured, id)
|
||||
})
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
import { intro, outro, spinner } from "@clack/prompts"
|
||||
import { Effect, Option } from "effect"
|
||||
import { Commands } from "../../commands"
|
||||
import { Runtime } from "../../../framework/runtime"
|
||||
import { handlePromptErrors, requireInteractive } from "../../../ui/prompt"
|
||||
import { createClient, loadIntegrations, location, request } from "./shared"
|
||||
import { chooseCredential, chooseIntegration } from "./account"
|
||||
|
||||
export default Runtime.handler(
|
||||
Commands.commands.auth.commands.switch,
|
||||
Effect.fn("cli.auth.switch")((input) =>
|
||||
switchAccount({
|
||||
target: Option.getOrUndefined(input.target),
|
||||
credential: Option.getOrUndefined(input.credential),
|
||||
server: Option.getOrUndefined(input.server),
|
||||
standalone: input.standalone,
|
||||
}).pipe(handlePromptErrors),
|
||||
),
|
||||
)
|
||||
|
||||
const switchAccount = Effect.fn("cli.auth.switch.run")(function* (input: {
|
||||
target?: string
|
||||
credential?: string
|
||||
server?: string
|
||||
standalone: boolean
|
||||
}) {
|
||||
if (!input.target)
|
||||
yield* requireInteractive("Pass an integration ID or name when running without an interactive terminal")
|
||||
if (!input.credential)
|
||||
yield* requireInteractive("Pass a credential ID or label when running without an interactive terminal")
|
||||
intro("Switch account")
|
||||
const client = yield* createClient({ server: input.server, standalone: input.standalone })
|
||||
const integrations = yield* loadIntegrations(client)
|
||||
const integration = yield* chooseIntegration(integrations, input.target)
|
||||
const credentialID = yield* chooseCredential(integration, "switch to", input.credential)
|
||||
const progress = spinner()
|
||||
progress.start("Switching account...")
|
||||
yield* request((signal) => client.credential.activate({ credentialID, location }, { signal })).pipe(
|
||||
Effect.tap(() => Effect.sync(() => progress.stop(`Switched account for ${integration.name}`))),
|
||||
Effect.tapCause(() => Effect.sync(() => progress.stop("Failed to switch account", 1))),
|
||||
)
|
||||
outro("Done")
|
||||
})
|
||||
@@ -0,0 +1,110 @@
|
||||
import { Cause, Effect, Exit, Option } from "effect"
|
||||
import { Service } from "@opencode/client/effect/service"
|
||||
import { OpenCode, type OpenCodeClient } from "@opencode/client/promise"
|
||||
import { AppProcess } from "@opencode/util/process"
|
||||
import { Commands } from "../../commands"
|
||||
import { Runtime } from "../../../framework/runtime"
|
||||
import { ServiceConfig } from "../../../services/service-config"
|
||||
import { createTimelineHost, type TimelineHost } from "../../../ui/timeline"
|
||||
import { errorMessage } from "../../../util/error"
|
||||
|
||||
const integrationID = "opencode"
|
||||
const location = { directory: process.cwd() }
|
||||
|
||||
export default Runtime.handler(
|
||||
Commands.commands.console.commands.login,
|
||||
Effect.fn("cli.console.login")(function* (input) {
|
||||
const timeline = yield* Effect.acquireRelease(
|
||||
Effect.promise(() => createTimelineHost()),
|
||||
(value) => request(() => value.close()).pipe(Effect.ignore),
|
||||
)
|
||||
const exit = yield* login(timeline, Option.getOrUndefined(input.url)).pipe(
|
||||
Effect.raceFirst(AppProcess.waitForAbort(timeline.signal)),
|
||||
Effect.exit,
|
||||
)
|
||||
if (Exit.isSuccess(exit)) return
|
||||
|
||||
const cancelled = timeline.signal.aborted
|
||||
yield* request(() =>
|
||||
timeline.failure(cancelled ? "Authorization cancelled" : errorMessage(Cause.squash(exit.cause))),
|
||||
).pipe(Effect.ignore)
|
||||
process.exitCode = cancelled ? 130 : 1
|
||||
}),
|
||||
)
|
||||
|
||||
const login = Effect.fn("cli.console.login.run")(function* (timeline: TimelineHost, server?: string) {
|
||||
yield* request(() => timeline.intro("Log in"))
|
||||
yield* request(() => timeline.pending("Connecting to OpenCode..."))
|
||||
|
||||
const endpoint = yield* Service.ensure(yield* ServiceConfig.options())
|
||||
const client = OpenCode.make({ baseUrl: endpoint.url, headers: Service.headers(endpoint) })
|
||||
const found = yield* request((signal) => client.integration.get({ integrationID, location }, { signal }))
|
||||
const integration = yield* required(found.data, "OpenCode Console integration is unavailable")
|
||||
const method = yield* required(
|
||||
integration.methods.find((candidate) => candidate.type === "oauth"),
|
||||
"OpenCode Console login is unavailable",
|
||||
)
|
||||
|
||||
yield* request(() => timeline.pending("Starting authorization..."))
|
||||
const started = yield* request((signal) =>
|
||||
client.integration.oauth.connect(
|
||||
{
|
||||
integrationID,
|
||||
methodID: method.id,
|
||||
...(server ? { answer: { server } } : {}),
|
||||
location,
|
||||
},
|
||||
{ signal },
|
||||
),
|
||||
)
|
||||
const attempt = started.data
|
||||
yield* Effect.addFinalizer(() =>
|
||||
request(() =>
|
||||
client.integration.oauth.cancel(
|
||||
{ integrationID, attemptID: attempt.attemptID, location },
|
||||
{ signal: AbortSignal.timeout(5_000) },
|
||||
),
|
||||
).pipe(Effect.ignore),
|
||||
)
|
||||
if (attempt.mode !== "auto") yield* Effect.fail(new Error("OpenCode Console requires a device login"))
|
||||
|
||||
yield* request(() => timeline.item(`Go to: ${attempt.url}`))
|
||||
yield* request(() => timeline.item(attempt.instructions))
|
||||
yield* request(async () => {
|
||||
const { default: open } = await import("open")
|
||||
await open(attempt.url)
|
||||
}).pipe(Effect.ignore)
|
||||
yield* request(() => timeline.pending("Waiting for authorization..."))
|
||||
|
||||
const status = yield* waitForConsoleLogin(client, integrationID, attempt.attemptID)
|
||||
if (status.status === "failed") yield* Effect.fail(new Error(status.message))
|
||||
if (status.status === "expired") yield* Effect.fail(new Error("Device code expired"))
|
||||
|
||||
yield* request(() => timeline.success("Connected to OpenCode Console"))
|
||||
yield* request(() => timeline.outro("Done"))
|
||||
})
|
||||
|
||||
const waitForConsoleLogin = Effect.fn("cli.console.login.wait")(function* (
|
||||
client: OpenCodeClient,
|
||||
integrationID: string,
|
||||
attemptID: string,
|
||||
) {
|
||||
while (true) {
|
||||
const response = yield* request((signal) =>
|
||||
client.integration.oauth.status({ integrationID, attemptID, location }, { signal }),
|
||||
)
|
||||
if (response.data.status !== "pending") return response.data
|
||||
yield* Effect.sleep(500)
|
||||
}
|
||||
})
|
||||
|
||||
function request<A>(task: (signal: AbortSignal) => Promise<A>) {
|
||||
return Effect.tryPromise({
|
||||
try: task,
|
||||
catch: (cause) => cause,
|
||||
})
|
||||
}
|
||||
|
||||
function required<A>(value: A | null | undefined, message: string) {
|
||||
return value === null || value === undefined ? Effect.fail(new Error(message)) : Effect.succeed(value)
|
||||
}
|
||||
+6
-6
@@ -3,14 +3,14 @@ import { OpenCode } from "@opencode/client"
|
||||
import { Service } from "@opencode/client/effect/service"
|
||||
import { Effect, Option } from "effect"
|
||||
import { EOL } from "node:os"
|
||||
import { Commands } from "../../commands"
|
||||
import { Runtime } from "../../../framework/runtime"
|
||||
import { ServerConnection } from "../../../services/server-connection"
|
||||
import { errorMessage } from "../../../util/error"
|
||||
import { Commands } from "../commands"
|
||||
import { Runtime } from "../../framework/runtime"
|
||||
import { ServerConnection } from "../../services/server-connection"
|
||||
import { errorMessage } from "../../util/error"
|
||||
|
||||
export default Runtime.handler(
|
||||
Commands.commands.session.commands.export,
|
||||
Effect.fn("cli.session.export")((input) =>
|
||||
Commands.commands.export,
|
||||
Effect.fn("cli.export")((input) =>
|
||||
Effect.gen(function* () {
|
||||
const requested = Option.getOrUndefined(input.session)
|
||||
if (!requested && !process.stdin.isTTY) {
|
||||
+5
-5
@@ -5,13 +5,13 @@ import { SessionTransfer } from "@opencode/schema/session-transfer"
|
||||
import { Effect, Option, Schema } from "effect"
|
||||
import { EOL } from "node:os"
|
||||
import path from "node:path"
|
||||
import { Commands } from "../../commands"
|
||||
import { Runtime } from "../../../framework/runtime"
|
||||
import { ServerConnection } from "../../../services/server-connection"
|
||||
import { Commands } from "../commands"
|
||||
import { Runtime } from "../../framework/runtime"
|
||||
import { ServerConnection } from "../../services/server-connection"
|
||||
|
||||
export default Runtime.handler(
|
||||
Commands.commands.session.commands.import,
|
||||
Effect.fn("cli.session.import")(function* (input) {
|
||||
Commands.commands.import,
|
||||
Effect.fn("cli.import")(function* (input) {
|
||||
const text = yield* Effect.tryPromise({
|
||||
try: () =>
|
||||
input.file.startsWith("http://") || input.file.startsWith("https://")
|
||||
@@ -15,11 +15,6 @@ import { Npm } from "@opencode/util/npm"
|
||||
import { Heap } from "./heap"
|
||||
import { CpuProfile } from "./cpu-profile"
|
||||
|
||||
if (process.env.OPENCODE_SSH_ASKPASS_PORT) {
|
||||
const { askpass } = await import("./ssh-askpass")
|
||||
process.exit(await Effect.runPromise(askpass.pipe(Effect.provide(NodeServices.layer))))
|
||||
}
|
||||
|
||||
const Handlers = Runtime.handlers(Commands, {
|
||||
$: () => import("./commands/handlers/default"),
|
||||
upgrade: () => import("./commands/handlers/upgrade"),
|
||||
@@ -29,13 +24,15 @@ const Handlers = Runtime.handlers(Commands, {
|
||||
list: () => import("./commands/handlers/auth/list"),
|
||||
login: () => import("./commands/handlers/auth/login"),
|
||||
logout: () => import("./commands/handlers/auth/logout"),
|
||||
switch: () => import("./commands/handlers/auth/switch"),
|
||||
},
|
||||
debug: {
|
||||
agents: () => import("./commands/handlers/debug/agents"),
|
||||
config: () => import("./commands/handlers/debug/config"),
|
||||
paths: () => import("./commands/handlers/debug/paths"),
|
||||
},
|
||||
console: {
|
||||
login: () => import("./commands/handlers/console/login"),
|
||||
},
|
||||
mcp: {
|
||||
list: () => import("./commands/handlers/mcp/list"),
|
||||
add: () => import("./commands/handlers/mcp/add"),
|
||||
@@ -51,14 +48,14 @@ const Handlers = Runtime.handlers(Commands, {
|
||||
},
|
||||
models: () => import("./commands/handlers/models"),
|
||||
stats: () => import("./commands/handlers/stats"),
|
||||
export: () => import("./commands/handlers/export"),
|
||||
import: () => import("./commands/handlers/import"),
|
||||
mini: () => import("./commands/handlers/mini"),
|
||||
run: () => import("./commands/handlers/run"),
|
||||
pair: () => import("./commands/handlers/pair"),
|
||||
session: {
|
||||
list: () => import("./commands/handlers/session/list"),
|
||||
delete: () => import("./commands/handlers/session/delete"),
|
||||
export: () => import("./commands/handlers/session/export"),
|
||||
import: () => import("./commands/handlers/session/import"),
|
||||
},
|
||||
service: {
|
||||
start: () => import("./commands/handlers/service/start"),
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import { createServer } from "node:net"
|
||||
import path from "node:path"
|
||||
|
||||
test("the executable askpass branch returns only the response, without CLI output", async () => {
|
||||
const requests: string[] = []
|
||||
const server = createServer((socket) => {
|
||||
socket.once("data", (data: Buffer) => {
|
||||
requests.push(data.toString())
|
||||
socket.end(JSON.stringify({ value: 'passphrase"with spaces' }))
|
||||
})
|
||||
})
|
||||
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve))
|
||||
const address = server.address()
|
||||
if (!address || typeof address === "string") throw new Error("missing listener")
|
||||
try {
|
||||
const child = Bun.spawn([process.execPath, path.join(import.meta.dir, "index.ts"), "Enter passphrase:"], {
|
||||
env: {
|
||||
...process.env,
|
||||
OPENCODE_SSH_ASKPASS_PORT: String(address.port),
|
||||
OPENCODE_SSH_ASKPASS_TOKEN: "fixture",
|
||||
SSH_ASKPASS_PROMPT: "confirm",
|
||||
},
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
})
|
||||
expect(await new Response(child.stdout).text()).toBe('passphrase"with spaces\n')
|
||||
expect(await child.exited).toBe(0)
|
||||
expect(requests.map((request) => JSON.parse(request))).toEqual([
|
||||
{ token: "fixture", text: "Enter passphrase:", confirm: true },
|
||||
])
|
||||
expect(await new Response(child.stderr).text()).toBe("")
|
||||
} finally {
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()))
|
||||
}
|
||||
}, 30_000)
|
||||
@@ -1,40 +0,0 @@
|
||||
import { NodeSocket } from "@effect/platform-node"
|
||||
import { Effect, Schema, Stdio, Stream } from "effect"
|
||||
|
||||
const Response = Schema.fromJsonString(Schema.Struct({ value: Schema.NullOr(Schema.String) }))
|
||||
const Port = Schema.NumberFromString.check(Schema.isInt(), Schema.isGreaterThan(0), Schema.isLessThanOrEqualTo(65535))
|
||||
|
||||
// OpenSSH invokes the executable directly, including on Windows. Run outside
|
||||
// normal CLI observability so neither prompts nor responses enter its logs.
|
||||
export const askpass = Effect.gen(function* () {
|
||||
const port = yield* Schema.decodeUnknownEffect(Port)(process.env.OPENCODE_SSH_ASKPASS_PORT)
|
||||
const stdio = yield* Stdio.Stdio
|
||||
const socket = yield* NodeSocket.makeNet({ host: "127.0.0.1", port })
|
||||
const write = yield* socket.writer
|
||||
const response = { text: "" }
|
||||
yield* Effect.all(
|
||||
[
|
||||
socket.runString((text) =>
|
||||
Effect.sync(() => {
|
||||
response.text += text
|
||||
}),
|
||||
),
|
||||
write(
|
||||
JSON.stringify({
|
||||
token: process.env.OPENCODE_SSH_ASKPASS_TOKEN,
|
||||
text: process.argv.slice(2).join(" "),
|
||||
confirm: process.env.SSH_ASKPASS_PROMPT === "confirm",
|
||||
}) + "\n",
|
||||
),
|
||||
],
|
||||
{ concurrency: "unbounded", discard: true },
|
||||
)
|
||||
const result = yield* Schema.decodeUnknownEffect(Response)(response.text)
|
||||
if (result.value === null) return 1
|
||||
yield* Stream.make(result.value + "\n").pipe(Stream.run(stdio.stdout({ endOnDone: false })))
|
||||
return 0
|
||||
}).pipe(
|
||||
Effect.scoped,
|
||||
Effect.timeout("5 minutes"),
|
||||
Effect.orElseSucceed(() => 1),
|
||||
)
|
||||
@@ -71,14 +71,13 @@ test("export is raw by default and supports explicit sanitization", async () =>
|
||||
})
|
||||
|
||||
try {
|
||||
const [stdout, , exitCode] = await run(["session", "export", info.id, "--server", server.url.toString()])
|
||||
const [stdout, , exitCode] = await run(["export", info.id, "--server", server.url.toString()])
|
||||
const exported = JSON.parse(stdout)
|
||||
|
||||
expect(exitCode).toBe(0)
|
||||
expect(exported).toEqual(transfer)
|
||||
|
||||
const [sanitized, , sanitizedExitCode] = await run([
|
||||
"session",
|
||||
"export",
|
||||
info.id,
|
||||
"--sanitize",
|
||||
@@ -111,7 +110,7 @@ test("export requires a session outside an interactive terminal", async () => {
|
||||
})
|
||||
|
||||
try {
|
||||
const [stdout, stderr, exitCode] = await run(["session", "export", "--server", server.url.toString()])
|
||||
const [stdout, stderr, exitCode] = await run(["export", "--server", server.url.toString()])
|
||||
|
||||
expect(exitCode).toBe(1)
|
||||
expect(stdout).toBe("")
|
||||
@@ -139,7 +138,7 @@ test("export reports a missing session without a stack trace", async () => {
|
||||
})
|
||||
|
||||
try {
|
||||
const [stdout, stderr, exitCode] = await run(["session", "export", sessionID, "--server", server.url.toString()])
|
||||
const [stdout, stderr, exitCode] = await run(["export", sessionID, "--server", server.url.toString()])
|
||||
|
||||
expect(exitCode).toBe(1)
|
||||
expect(stdout).toBe("")
|
||||
@@ -174,15 +173,7 @@ test("import validates a file and sends it to the resolved location", async () =
|
||||
})
|
||||
|
||||
try {
|
||||
const [stdout, , exitCode] = await run([
|
||||
"session",
|
||||
"import",
|
||||
file,
|
||||
"--directory",
|
||||
root,
|
||||
"--server",
|
||||
server.url.toString(),
|
||||
])
|
||||
const [stdout, , exitCode] = await run(["import", file, "--directory", root, "--server", server.url.toString()])
|
||||
|
||||
expect(exitCode).toBe(0)
|
||||
expect(stdout).toBe(`Imported session: ${info.id}${os.EOL}`)
|
||||
@@ -214,7 +205,7 @@ test("import reports an existing session without a stack trace", async () => {
|
||||
})
|
||||
|
||||
try {
|
||||
const [stdout, stderr, exitCode] = await run(["session", "import", file, "--server", server.url.toString()])
|
||||
const [stdout, stderr, exitCode] = await run(["import", file, "--server", server.url.toString()])
|
||||
|
||||
expect(exitCode).toBe(0)
|
||||
expect(stdout).toBe("")
|
||||
|
||||
@@ -1029,6 +1029,19 @@ export type SessionLogOutput =
|
||||
readonly location?: Location.Ref | undefined
|
||||
readonly data: { readonly sessionID: Session.ID; readonly to: SessionMessage.ID }
|
||||
}
|
||||
| {
|
||||
readonly id: Event.ID
|
||||
readonly created: number
|
||||
readonly metadata?: { readonly [x: string]: unknown } | undefined
|
||||
readonly type: "session.message.content.updated"
|
||||
readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version }
|
||||
readonly location?: Location.Ref | undefined
|
||||
readonly data: {
|
||||
readonly sessionID: Session.ID
|
||||
readonly messageID: SessionMessage.ID
|
||||
readonly content: ReadonlyArray<SessionMessage.AssistantContentEncoded>
|
||||
}
|
||||
}
|
||||
| {
|
||||
readonly id: Event.ID
|
||||
readonly created: number
|
||||
@@ -1048,19 +1061,6 @@ export type SessionLogOutput =
|
||||
}
|
||||
}
|
||||
}
|
||||
| {
|
||||
readonly id: Event.ID
|
||||
readonly created: number
|
||||
readonly metadata?: { readonly [x: string]: unknown } | undefined
|
||||
readonly type: "session.message.content.updated"
|
||||
readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version }
|
||||
readonly location?: Location.Ref | undefined
|
||||
readonly data: {
|
||||
readonly sessionID: Session.ID
|
||||
readonly messageID: SessionMessage.ID
|
||||
readonly content: ReadonlyArray<SessionMessage.AssistantContentEncoded>
|
||||
}
|
||||
}
|
||||
)
|
||||
| EventLog.Synced
|
||||
export type SessionLogOperation<E = never> = (input: SessionLogInput) => Stream.Stream<SessionLogOutput, E>
|
||||
@@ -1081,6 +1081,18 @@ export type SessionMessageInput = { readonly sessionID: Session.ID; readonly mes
|
||||
export type SessionMessageOutput = SessionMessage.Info
|
||||
export type SessionMessageOperation<E = never> = (input: SessionMessageInput) => Effect.Effect<SessionMessageOutput, E>
|
||||
|
||||
export type SessionMessageUpdateInput = {
|
||||
readonly sessionID: Session.ID
|
||||
readonly messageID: SessionMessage.ID
|
||||
readonly content: ReadonlyArray<
|
||||
SessionMessage.AssistantText | SessionMessage.AssistantReasoning | SessionMessage.AssistantTool
|
||||
>
|
||||
}
|
||||
export type SessionMessageUpdateOutput = SessionMessage.Assistant
|
||||
export type SessionMessageUpdateOperation<E = never> = (
|
||||
input: SessionMessageUpdateInput,
|
||||
) => Effect.Effect<SessionMessageUpdateOutput, E>
|
||||
|
||||
export type SessionEnvironmentInput = {
|
||||
readonly sessionID: Session.ID
|
||||
readonly variables: { readonly [x: string]: string }
|
||||
@@ -1139,6 +1151,7 @@ export interface SessionApi<E = never> {
|
||||
readonly interrupt: SessionInterruptOperation<E>
|
||||
readonly background: SessionBackgroundOperation<E>
|
||||
readonly message: SessionMessageOperation<E>
|
||||
readonly messageUpdate: SessionMessageUpdateOperation<E>
|
||||
readonly environment: SessionEnvironmentOperation<E>
|
||||
readonly view: SessionViewOperation<E>
|
||||
}
|
||||
|
||||
@@ -92,6 +92,8 @@ import type {
|
||||
SessionBackgroundOutput,
|
||||
SessionMessageInput,
|
||||
SessionMessageOutput,
|
||||
SessionMessageUpdateInput,
|
||||
SessionMessageUpdateOutput,
|
||||
SessionEnvironmentInput,
|
||||
SessionEnvironmentOutput,
|
||||
SessionViewInput,
|
||||
@@ -689,6 +691,17 @@ const EndpointSessionMessage = (raw: RawClient["server.session"]) => (input: Ses
|
||||
),
|
||||
)
|
||||
|
||||
const EndpointSessionMessageUpdate = (raw: RawClient["server.session"]) => (input: SessionMessageUpdateInput) =>
|
||||
preserveEffect<SessionMessageUpdateOutput>()(
|
||||
raw["session.messageUpdate"]({
|
||||
params: { sessionID: input["sessionID"], messageID: input["messageID"] },
|
||||
payload: { content: input["content"] },
|
||||
}).pipe(
|
||||
Effect.mapError(mapClientError),
|
||||
Effect.map((value) => value.data),
|
||||
),
|
||||
)
|
||||
|
||||
const EndpointSessionEnvironment = (raw: RawClient["server.session"]) => (input: SessionEnvironmentInput) =>
|
||||
preserveEffect<SessionEnvironmentOutput>()(
|
||||
raw["session.environment"]({
|
||||
@@ -749,6 +762,7 @@ const adaptGroupSession = (raw: RawClient["server.session"]) => ({
|
||||
interrupt: EndpointSessionInterrupt(raw),
|
||||
background: EndpointSessionBackground(raw),
|
||||
message: EndpointSessionMessage(raw),
|
||||
messageUpdate: EndpointSessionMessageUpdate(raw),
|
||||
environment: EndpointSessionEnvironment(raw),
|
||||
view: EndpointSessionView(raw),
|
||||
})
|
||||
|
||||
@@ -86,6 +86,8 @@ import type {
|
||||
SessionBackgroundOutput,
|
||||
SessionMessageInput,
|
||||
SessionMessageOutput,
|
||||
SessionMessageUpdateInput,
|
||||
SessionMessageUpdateOutput,
|
||||
SessionEnvironmentInput,
|
||||
SessionEnvironmentOutput,
|
||||
SessionViewInput,
|
||||
@@ -984,6 +986,18 @@ export function make(options: ClientOptions) {
|
||||
},
|
||||
requestOptions,
|
||||
).then((value) => value.data),
|
||||
messageUpdate: (input: SessionMessageUpdateInput, requestOptions?: RequestOptions) =>
|
||||
request<{ readonly data: SessionMessageUpdateOutput }>(
|
||||
{
|
||||
method: "PATCH",
|
||||
path: `/api/session/${encodeURIComponent(input.sessionID)}/message/${encodeURIComponent(input.messageID)}`,
|
||||
body: { content: input["content"] },
|
||||
successStatus: 200,
|
||||
declaredStatuses: [400, 401, 404, 409],
|
||||
empty: false,
|
||||
},
|
||||
requestOptions,
|
||||
).then((value) => value.data),
|
||||
environment: (input: SessionEnvironmentInput, requestOptions?: RequestOptions) =>
|
||||
request<SessionEnvironmentOutput>(
|
||||
{
|
||||
|
||||
@@ -2252,8 +2252,8 @@ export type SessionEventDurable =
|
||||
| SessionRevertStaged
|
||||
| SessionRevertCleared
|
||||
| SessionRevertCommitted
|
||||
| SessionUsageRecorded
|
||||
| SessionMessageContentUpdated
|
||||
| SessionUsageRecorded
|
||||
|
||||
export type IntegrationInfo = {
|
||||
id: string
|
||||
@@ -2317,6 +2317,7 @@ export type V2Event =
|
||||
| SessionRevertStaged
|
||||
| SessionRevertCleared
|
||||
| SessionRevertCommitted
|
||||
| SessionMessageContentUpdated
|
||||
| FilesystemChanged
|
||||
| ReferenceUpdated
|
||||
| PermissionAsked
|
||||
@@ -4269,6 +4270,91 @@ export type SessionMessageInput = {
|
||||
|
||||
export type SessionMessageOutput = { data: SessionMessageInfo }["data"]
|
||||
|
||||
export type SessionMessageUpdateInput = {
|
||||
readonly sessionID: { readonly sessionID: string; readonly messageID: string }["sessionID"]
|
||||
readonly messageID: { readonly sessionID: string; readonly messageID: string }["messageID"]
|
||||
readonly content: {
|
||||
readonly content: ReadonlyArray<
|
||||
| { readonly type: "text"; readonly text: string; readonly state?: { readonly [x: string]: JsonValue } }
|
||||
| {
|
||||
readonly type: "reasoning"
|
||||
readonly text: string
|
||||
readonly state?: { readonly [x: string]: JsonValue }
|
||||
readonly time?: { readonly created: number; readonly completed?: number }
|
||||
}
|
||||
| {
|
||||
readonly type: "tool"
|
||||
readonly id: string
|
||||
readonly name: string
|
||||
readonly executed?: boolean
|
||||
readonly providerState?: { readonly [x: string]: JsonValue }
|
||||
readonly providerResultState?: { readonly [x: string]: JsonValue }
|
||||
readonly state:
|
||||
| { readonly status: "streaming"; readonly input: string }
|
||||
| {
|
||||
readonly status: "running"
|
||||
readonly input: { readonly [x: string]: JsonValue }
|
||||
readonly metadata: { readonly [x: string]: JsonValue }
|
||||
}
|
||||
| {
|
||||
readonly status: "completed"
|
||||
readonly input: { readonly [x: string]: JsonValue }
|
||||
readonly content: readonly [
|
||||
(
|
||||
| { readonly type: "text"; readonly text: string }
|
||||
| {
|
||||
readonly type: "file"
|
||||
readonly uri: string
|
||||
readonly mime: string
|
||||
readonly name?: string | null
|
||||
}
|
||||
),
|
||||
...Array<
|
||||
| { readonly type: "text"; readonly text: string }
|
||||
| {
|
||||
readonly type: "file"
|
||||
readonly uri: string
|
||||
readonly mime: string
|
||||
readonly name?: string | null
|
||||
}
|
||||
>,
|
||||
]
|
||||
readonly metadata?: { readonly [x: string]: JsonValue }
|
||||
}
|
||||
| {
|
||||
readonly status: "error"
|
||||
readonly input: { readonly [x: string]: JsonValue }
|
||||
readonly error: { readonly type: string; readonly message: string; readonly status?: number }
|
||||
readonly content?: readonly [
|
||||
(
|
||||
| { readonly type: "text"; readonly text: string }
|
||||
| {
|
||||
readonly type: "file"
|
||||
readonly uri: string
|
||||
readonly mime: string
|
||||
readonly name?: string | null
|
||||
}
|
||||
),
|
||||
...Array<
|
||||
| { readonly type: "text"; readonly text: string }
|
||||
| {
|
||||
readonly type: "file"
|
||||
readonly uri: string
|
||||
readonly mime: string
|
||||
readonly name?: string | null
|
||||
}
|
||||
>,
|
||||
]
|
||||
readonly metadata?: { readonly [x: string]: JsonValue }
|
||||
}
|
||||
readonly time: { readonly created: number; readonly ran?: number; readonly completed?: number }
|
||||
}
|
||||
>
|
||||
}["content"]
|
||||
}
|
||||
|
||||
export type SessionMessageUpdateOutput = { data: SessionMessageAssistant }["data"]
|
||||
|
||||
export type SessionEnvironmentInput = {
|
||||
readonly sessionID: { readonly sessionID: string }["sessionID"]
|
||||
readonly variables: { readonly variables: { readonly [x: string]: string } }["variables"]
|
||||
|
||||
@@ -827,6 +827,16 @@ export function createData(config: CreateDataInput) {
|
||||
match.time.completed = event.created
|
||||
})
|
||||
return
|
||||
case "session.message.content.updated": {
|
||||
if (store.session.message[event.data.sessionID])
|
||||
message.editAssistant(event.data.sessionID, event.data.messageID, (assistant) => {
|
||||
assistant.content = [...event.data.content]
|
||||
})
|
||||
if (!sync.pending(`session.message:${event.data.sessionID}`)) return
|
||||
result.session.message.invalidate(event.data.sessionID)
|
||||
refresh(() => result.session.message.sync(event.data.sessionID))
|
||||
return
|
||||
}
|
||||
case "session.step.started":
|
||||
message.update(event.data.sessionID, (draft, index) => {
|
||||
const position = index.get(event.data.assistantMessageID)
|
||||
|
||||
@@ -744,6 +744,77 @@ test.each(["success", "failure", "cancel", "cancel-retry", "cancel-page", "join-
|
||||
},
|
||||
)
|
||||
|
||||
test("preserves assistant content replacement events across an active message read", async () => {
|
||||
const listeners = new Set<Parameters<CreateDataInput["event"]["listen"]>[0]>()
|
||||
const release = Promise.withResolvers<void>()
|
||||
let requests = 0
|
||||
const content = [
|
||||
{ type: "text" as const, text: "replacement" },
|
||||
{ type: "reasoning" as const, text: "reasoning", time: { created: 3 } },
|
||||
]
|
||||
const api = OpenCode.make({
|
||||
baseUrl: "http://opencode.local",
|
||||
fetch: async () => {
|
||||
const current = ++requests
|
||||
if (current === 2) await release.promise
|
||||
return Response.json({
|
||||
data: [
|
||||
{
|
||||
id: "msg_assistant",
|
||||
type: "assistant",
|
||||
agent: "build",
|
||||
model: { id: "model", providerID: "provider" },
|
||||
content: current === 3 ? content : [{ type: "text", text: "original" }],
|
||||
time: { created: 1, completed: 2 },
|
||||
},
|
||||
],
|
||||
cursor: {},
|
||||
})
|
||||
},
|
||||
})
|
||||
const setup = createRoot((dispose) => ({
|
||||
data: createData({
|
||||
api: () => api,
|
||||
directory: "/project",
|
||||
event: {
|
||||
on: () => () => {},
|
||||
listen(handler) {
|
||||
listeners.add(handler)
|
||||
return () => listeners.delete(handler)
|
||||
},
|
||||
},
|
||||
}),
|
||||
dispose,
|
||||
}))
|
||||
|
||||
try {
|
||||
await setup.data.session.message.sync("ses_refresh")
|
||||
setup.data.session.message.invalidate("ses_refresh")
|
||||
const stale = setup.data.session.message.sync("ses_refresh")
|
||||
await wait(() => requests === 2)
|
||||
const updated: OpenCodeEvent = {
|
||||
id: "evt_message_updated",
|
||||
created: 3,
|
||||
type: "session.message.content.updated",
|
||||
durable: { aggregateID: "ses_refresh", seq: 3, version: 1 },
|
||||
data: {
|
||||
sessionID: "ses_refresh",
|
||||
messageID: "msg_assistant",
|
||||
content,
|
||||
},
|
||||
}
|
||||
listeners.forEach((listener) => listener({ name: updated.type, details: updated }))
|
||||
|
||||
expect(setup.data.session.message.list("ses_refresh")[0]).toMatchObject({ content })
|
||||
release.resolve()
|
||||
await stale
|
||||
await wait(() => requests === 3)
|
||||
expect(setup.data.session.message.list("ses_refresh")[0]).toMatchObject({ content })
|
||||
} finally {
|
||||
setup.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
test.each([
|
||||
"session.execution.succeeded",
|
||||
"session.execution.failed",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Effect, Schema } from "effect"
|
||||
import { executeWithLimits } from "./interpreter/execute.js"
|
||||
import { executeProgram } from "./interpreter/execute.js"
|
||||
import { type Services, type ToolDescription, ToolRuntime } from "./tool-runtime.js"
|
||||
import type { Tools } from "./tools.js"
|
||||
|
||||
@@ -30,26 +30,23 @@ export type ResolvedExecutionLimits = {
|
||||
readonly maxOutputBytes: number | undefined
|
||||
}
|
||||
|
||||
/** Options for one CodeMode execution. */
|
||||
export type ExecuteOptions<Provided extends Record<string, unknown> = {}> = {
|
||||
/** Source for one program in the supported JavaScript subset. */
|
||||
code: string
|
||||
/** Configuration shared by `CodeMode.make` and `CodeMode.execute`. */
|
||||
export type Options<Provided extends Record<string, unknown> = {}> = ToolRuntime.ToolCallHooks<Services<Provided>> & {
|
||||
/** Explicit tools exposed to the program as `tools`. */
|
||||
tools?: Provided & Tools<Services<Provided>>
|
||||
/** Per-execution overrides for the default resource limits. */
|
||||
/** Resource limits enforced on each execution. */
|
||||
limits?: ExecutionLimits
|
||||
/** Observes decoded tool input immediately before tool execution. */
|
||||
onToolCallStart?: (call: ToolRuntime.ToolCallStarted) => Effect.Effect<void, never, Services<Provided>>
|
||||
/** Observes each admitted tool call as it succeeds, fails, or is interrupted. */
|
||||
onToolCallEnd?: (call: ToolRuntime.ToolCallEnded) => Effect.Effect<void, never, Services<Provided>>
|
||||
}
|
||||
|
||||
/** Options for one CodeMode execution. */
|
||||
export type ExecuteOptions<Provided extends Record<string, unknown> = {}> = Options<Provided> & {
|
||||
/** Source for one program in the supported JavaScript subset. */
|
||||
code: string
|
||||
}
|
||||
|
||||
/** A JSON value that can cross the confined interpreter boundary. */
|
||||
export type DataValue = Schema.Json
|
||||
|
||||
/** Configuration shared by `CodeMode.make` and `CodeMode.execute`. */
|
||||
export type Options<Provided extends Record<string, unknown> = {}> = Omit<ExecuteOptions<Provided>, "code">
|
||||
|
||||
/** Schema for a host tool input containing CodeMode source. */
|
||||
export const Input = Schema.Struct({ code: Schema.String })
|
||||
export type Input = typeof Input.Type
|
||||
@@ -128,21 +125,16 @@ const resolveExecutionLimits = (limits?: ExecutionLimits): ResolvedExecutionLimi
|
||||
/** Executes one Effect-native CodeMode program without constructing a reusable runtime. */
|
||||
export const execute = <const Provided extends Record<string, unknown>>(
|
||||
options: ExecuteOptions<Provided>,
|
||||
): Effect.Effect<Result, never, Services<Provided>> => {
|
||||
const tools = (options.tools ?? {}) as Tools<Services<Provided>>
|
||||
return executeWithLimits(options, resolveExecutionLimits(options.limits), ToolRuntime.searchIndex(tools))
|
||||
}
|
||||
): Effect.Effect<Result, never, Services<Provided>> => make(options).execute(options.code)
|
||||
|
||||
/** Creates an Effect-native runtime over explicit, schema-described tools. */
|
||||
export const make = <const Provided extends Record<string, unknown> = {}>(
|
||||
options: Options<Provided> = {} as Options<Provided>,
|
||||
options: Options<Provided> = {},
|
||||
): Runtime<Services<Provided>> => {
|
||||
const tools = (options.tools ?? {}) as Tools<Services<Provided>>
|
||||
const prepared = ToolRuntime.prepare((options.tools ?? {}) as Tools<Services<Provided>>)
|
||||
const limits = resolveExecutionLimits(options.limits)
|
||||
const prepared = ToolRuntime.prepare(tools)
|
||||
|
||||
return {
|
||||
catalog: () => prepared.catalog,
|
||||
execute: (code) => executeWithLimits<Provided>({ ...options, code }, limits, prepared.searchIndex),
|
||||
execute: (code) => executeProgram(code, prepared, limits, options),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
export * as Data from "./data.js"
|
||||
|
||||
import type { DiagnosticKind } from "./codemode.js"
|
||||
import { Values } from "./values.js"
|
||||
|
||||
/** A null-prototype object owned by the program. */
|
||||
export type SafeObject = Record<string, unknown>
|
||||
|
||||
const MAX_VALUE_DEPTH = 32
|
||||
|
||||
export class ToolRuntimeError extends Error {
|
||||
constructor(
|
||||
readonly kind: Extract<
|
||||
DiagnosticKind,
|
||||
"UnknownTool" | "InvalidToolInput" | "InvalidToolOutput" | "InvalidDataValue" | "ToolCallLimitExceeded"
|
||||
>,
|
||||
message: string,
|
||||
readonly suggestions: ReadonlyArray<string> = [],
|
||||
) {
|
||||
super(message)
|
||||
this.name = "ToolRuntimeError"
|
||||
}
|
||||
}
|
||||
|
||||
const blockedMemberNames = new Set(["__proto__", "constructor", "prototype"])
|
||||
|
||||
export const isBlockedMember = (name: string): boolean => blockedMemberNames.has(name)
|
||||
|
||||
/**
|
||||
* Brings a host-produced runtime value into the program: runtime values pass through, their host
|
||||
* counterparts (Date, RegExp, Map, Set, URL, URLSearchParams) are wrapped, and objects become
|
||||
* null-prototype copies. Arrays keep extra enumerable properties such as `index` and `groups`.
|
||||
*/
|
||||
export const toProgram = (value: unknown, label: string): unknown => copy(value, label, "program", 0, new Set())
|
||||
|
||||
/**
|
||||
* Brings host data into the program: Date and URL become strings, other host collections become
|
||||
* empty objects, and objects become null-prototype copies. Used for tool results and parsed JSON.
|
||||
*/
|
||||
export const fromData = (value: unknown, label: string): unknown => copy(value, label, "data", 0, new Set())
|
||||
|
||||
/**
|
||||
* Takes a program value out as plain JSON: runtime values serialize like `JSON.stringify` would,
|
||||
* non-finite numbers become null, and array holes become null. `undefined` object properties are
|
||||
* dropped ("json") or become null ("result", for program results where the consumer must never see
|
||||
* undefined); a bare `undefined` follows the same rule.
|
||||
*/
|
||||
export const toData = (value: unknown, label: string, undefinedAs: "json" | "result" = "json"): unknown =>
|
||||
copy(value, label, undefinedAs, 0, new Set())
|
||||
|
||||
// "program" and "data" build program-owned null-prototype objects; "json" and "result" build
|
||||
// ordinary objects for the host.
|
||||
type Mode = "program" | "data" | "json" | "result"
|
||||
|
||||
const copy = (value: unknown, label: string, mode: Mode, depth: number, seen: Set<object>): unknown => {
|
||||
if (depth > MAX_VALUE_DEPTH) {
|
||||
throw new ToolRuntimeError("InvalidDataValue", `${label} exceeds the maximum value depth of ${MAX_VALUE_DEPTH}.`)
|
||||
}
|
||||
if (value === undefined) return mode === "result" ? null : undefined
|
||||
if (typeof value === "number") return (mode === "json" || mode === "result") && !Number.isFinite(value) ? null : value
|
||||
if (value === null || typeof value === "string" || typeof value === "boolean") return value
|
||||
if (typeof value !== "object") {
|
||||
throw new ToolRuntimeError("InvalidDataValue", `${label} must contain data only.`)
|
||||
}
|
||||
if (value instanceof Values.Promise) {
|
||||
throw new ToolRuntimeError(
|
||||
"InvalidDataValue",
|
||||
`${label} contains an un-awaited Promise; await tool calls (e.g. \`const result = await tools.ns.tool(...)\`) before using their results.`,
|
||||
)
|
||||
}
|
||||
|
||||
if (mode === "program") {
|
||||
if (Values.isValue(value)) return value
|
||||
if (value instanceof Date) return new Values.Date(value.getTime())
|
||||
if (value instanceof RegExp) return new Values.RegExp(value.source, value.flags)
|
||||
if (value instanceof Map) {
|
||||
const wrapped = new Values.Map()
|
||||
for (const [key, item] of value.entries()) {
|
||||
wrapped.map.set(copy(key, label, mode, depth + 1, seen), copy(item, label, mode, depth + 1, seen))
|
||||
}
|
||||
return wrapped
|
||||
}
|
||||
if (value instanceof Set) {
|
||||
const wrapped = new Values.Set()
|
||||
for (const item of value.values()) wrapped.set.add(copy(item, label, mode, depth + 1, seen))
|
||||
return wrapped
|
||||
}
|
||||
if (value instanceof URL) return new Values.URL(new URL(value.href))
|
||||
if (value instanceof URLSearchParams) return new Values.URLSearchParams(new URLSearchParams(value))
|
||||
}
|
||||
|
||||
const plain = mode === "program" || mode === "data"
|
||||
if (value instanceof Values.Date) return Number.isFinite(value.time) ? new Date(value.time).toISOString() : null
|
||||
if (value instanceof Date) return Number.isFinite(value.getTime()) ? value.toISOString() : null
|
||||
if (value instanceof Values.URL) return value.url.href
|
||||
if (value instanceof URL) return value.href
|
||||
// Remaining runtime values and their host counterparts serialize as empty objects, like JSON.stringify.
|
||||
if (
|
||||
Values.isValue(value) ||
|
||||
value instanceof RegExp ||
|
||||
value instanceof Map ||
|
||||
value instanceof Set ||
|
||||
value instanceof URLSearchParams
|
||||
) {
|
||||
return plain ? (Object.create(null) as SafeObject) : {}
|
||||
}
|
||||
|
||||
if (seen.has(value)) {
|
||||
throw new ToolRuntimeError("InvalidDataValue", `${label} contains a circular value.`)
|
||||
}
|
||||
seen.add(value)
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
// Host output densifies holes to null like JSON; program copies keep them.
|
||||
const copied = plain
|
||||
? value.map((item) => copy(item, label, mode, depth + 1, seen))
|
||||
: Array.from(value, (item) => copy(item, label, mode, depth + 1, seen) ?? null)
|
||||
if (mode === "program") {
|
||||
for (const [key, item] of Object.entries(value)) {
|
||||
if (Object.hasOwn(copied, key)) continue
|
||||
if (isBlockedMember(key)) {
|
||||
throw new ToolRuntimeError("InvalidDataValue", `${label} contains blocked property '${key}'.`)
|
||||
}
|
||||
Reflect.set(copied, key, copy(item, label, mode, depth + 1, seen))
|
||||
}
|
||||
}
|
||||
seen.delete(value)
|
||||
return copied
|
||||
}
|
||||
|
||||
const prototype = Object.getPrototypeOf(value)
|
||||
if (prototype !== Object.prototype && prototype !== null) {
|
||||
throw new ToolRuntimeError("InvalidDataValue", `${label} must contain plain objects only.`)
|
||||
}
|
||||
|
||||
const copied: SafeObject = plain ? (Object.create(null) as SafeObject) : {}
|
||||
for (const [key, item] of Object.entries(value)) {
|
||||
if (isBlockedMember(key)) {
|
||||
throw new ToolRuntimeError("InvalidDataValue", `${label} contains blocked property '${key}'.`)
|
||||
}
|
||||
const next = copy(item, label, mode, depth + 1, seen)
|
||||
if (next === undefined && mode === "json") continue
|
||||
copied[key] = next
|
||||
}
|
||||
seen.delete(value)
|
||||
return copied
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Effect } from "effect"
|
||||
import type { Diagnostic } from "../codemode.js"
|
||||
import { ToolError } from "../tool-error.js"
|
||||
import { copyOut, ToolRuntimeError, type SafeObject } from "../tool-runtime.js"
|
||||
import { type SafeObject, toData, ToolRuntimeError } from "../data.js"
|
||||
import { type AstNode, formatLocation, InterpreterRuntimeError, ProgramThrow, sourceLocation } from "./model.js"
|
||||
import { containsRuntimeReference } from "./references.js"
|
||||
import { type SyncIteratorRunner } from "./iterator.js"
|
||||
@@ -45,7 +45,7 @@ export const normalizeError = (error: unknown): Diagnostic => {
|
||||
message = (value as { message: string }).message
|
||||
} else {
|
||||
try {
|
||||
message = JSON.stringify(copyOut(value, "json")) ?? String(value)
|
||||
message = JSON.stringify(toData(value, "Thrown value")) ?? String(value)
|
||||
} catch {
|
||||
message = String(value)
|
||||
}
|
||||
|
||||
@@ -3,20 +3,21 @@ import { Cause, Effect, Scope } from "effect"
|
||||
// #transpile: conditional import — full typescript on node/bun, an identity
|
||||
// pass-through on workerd (the compiler is ~11 MiB and can't init there).
|
||||
import { transpile } from "#transpile"
|
||||
import type { DataValue, Diagnostic, ExecuteOptions, ResolvedExecutionLimits, Result } from "../codemode.js"
|
||||
import { copyIn, copyOut, ToolRuntime, type Services } from "../tool-runtime.js"
|
||||
import type { Tools } from "../tools.js"
|
||||
import type { DataValue, Diagnostic, ResolvedExecutionLimits, Result } from "../codemode.js"
|
||||
import { toData } from "../data.js"
|
||||
import { ToolRuntime } from "../tool-runtime.js"
|
||||
import { normalizeError } from "./errors.js"
|
||||
import { InterpreterRuntimeError, isRecord, type ProgramNode } from "./model.js"
|
||||
import { PromiseRuntime } from "./promises.js"
|
||||
import { Interpreter } from "./runtime.js"
|
||||
|
||||
export const executeWithLimits = <const Provided extends Record<string, unknown>>(
|
||||
options: ExecuteOptions<Provided>,
|
||||
export const executeProgram = <R>(
|
||||
code: string,
|
||||
prepared: ToolRuntime.Prepared<R>,
|
||||
limits: ResolvedExecutionLimits,
|
||||
searchIndex: ToolRuntime.DiscoveryPlan["searchIndex"],
|
||||
): Effect.Effect<Result, never, Services<Provided>> => {
|
||||
if (options.code.trim().length === 0) {
|
||||
hooks: ToolRuntime.ToolCallHooks<R>,
|
||||
): Effect.Effect<Result, never, R> => {
|
||||
if (code.trim().length === 0) {
|
||||
return Effect.succeed({
|
||||
ok: false,
|
||||
error: { kind: "ParseError", message: "Code cannot be empty." },
|
||||
@@ -26,35 +27,21 @@ export const executeWithLimits = <const Provided extends Record<string, unknown>
|
||||
|
||||
// Allocate execution state inside suspension so reused Effects never share it.
|
||||
return Effect.suspend(() => {
|
||||
const tools = ToolRuntime.make(
|
||||
(options.tools ?? {}) as Tools<Services<Provided>>,
|
||||
limits.maxToolCalls,
|
||||
searchIndex,
|
||||
{
|
||||
onToolCallStart: options.onToolCallStart,
|
||||
onToolCallEnd: options.onToolCallEnd,
|
||||
},
|
||||
)
|
||||
const tools = ToolRuntime.make(prepared, limits.maxToolCalls, hooks)
|
||||
const logs: Array<string> = []
|
||||
const logged = () => (logs.length > 0 ? { logs: [...logs] } : {})
|
||||
// Set only after copy-out so timeouts cannot report invalid values as completed.
|
||||
let returned: { value: DataValue; promises: PromiseRuntime<Services<Provided>> } | undefined
|
||||
let returned: { value: DataValue; promises: PromiseRuntime<R> } | undefined
|
||||
|
||||
const base = Effect.acquireUseRelease(
|
||||
Scope.make("parallel"),
|
||||
(scope) =>
|
||||
Effect.gen(function* () {
|
||||
const program = parseProgram(options.code)
|
||||
const promises = new PromiseRuntime<Services<Provided>>(scope)
|
||||
const interpreter = new Interpreter<Services<Provided>>(
|
||||
tools.execute,
|
||||
tools.search,
|
||||
tools.keys,
|
||||
promises,
|
||||
logs,
|
||||
)
|
||||
const program = parseProgram(code)
|
||||
const promises = new PromiseRuntime<R>(scope)
|
||||
const interpreter = new Interpreter<R>(tools.execute, tools.search, tools.keys, promises, logs)
|
||||
const value = yield* interpreter.run(program)
|
||||
const result = copyOut(copyIn(value, "Execution result"), "nullify") as DataValue
|
||||
const result = toData(value, "Execution result", "result") as DataValue
|
||||
returned = { value: result, promises }
|
||||
const warnings = yield* promises.interrupt()
|
||||
return {
|
||||
|
||||
@@ -15,7 +15,8 @@ import {
|
||||
UriFunction,
|
||||
} from "./model.js"
|
||||
import { containsOpaqueReference, isRuntimeReference, rejectCircularInsertion, typeofValue } from "./references.js"
|
||||
import { compareText, isBlockedMember, type SafeObject } from "../tool-runtime.js"
|
||||
import { isBlockedMember, type SafeObject, toProgram } from "../data.js"
|
||||
import { compareText } from "../tool-runtime.js"
|
||||
import { Values } from "../values.js"
|
||||
import { dateSetterArgumentCount, invokeDateMethod, invokeDateStatic } from "../stdlib/date.js"
|
||||
import { invokeMathMethod } from "../stdlib/math.js"
|
||||
@@ -24,7 +25,7 @@ import { invokeObjectMethod } from "../stdlib/object.js"
|
||||
import { invokeRegExpMethod, invokeRegExpStatic, matchToValue, toHostRegex } from "../stdlib/regexp.js"
|
||||
import { invokeStringStatic } from "../stdlib/string.js"
|
||||
import { invokeURLMethod, invokeURLStatic, uriArgument } from "../stdlib/url.js"
|
||||
import { boundedData, coerceToNumber, coerceToString, errorBrandName } from "../stdlib/value.js"
|
||||
import { coerceToNumber, coerceToString, errorBrandName } from "../stdlib/value.js"
|
||||
import { preserveConsumerError, type SyncIteratorRunner } from "./iterator.js"
|
||||
|
||||
export type CallbackRunner<R> = {
|
||||
@@ -292,7 +293,7 @@ const invokeStringMethod = (value: string, name: string, args: Array<unknown>, n
|
||||
const matched = value.match(pattern)
|
||||
if (matched === null) return null
|
||||
// Preserve the own `index` and `groups` properties on non-global matches.
|
||||
if (pattern.global) return boundedData(matched, "String.match result")
|
||||
if (pattern.global) return toProgram(matched, "String.match result")
|
||||
return matchToValue(matched)
|
||||
}
|
||||
case "matchAll": {
|
||||
@@ -347,7 +348,7 @@ const invokeStringMethod = (value: string, name: string, args: Array<unknown>, n
|
||||
default:
|
||||
throw new InterpreterRuntimeError(`String method '${name}' is not available.`, node)
|
||||
}
|
||||
return boundedData(result, `String.${name} result`)
|
||||
return toProgram(result, `String.${name} result`)
|
||||
}
|
||||
|
||||
export const arrayStatics = new Set(["isArray", "of", "from"])
|
||||
@@ -540,19 +541,19 @@ const invokeStringReplacer = <R>(
|
||||
let end = 0
|
||||
for (const match of matches) {
|
||||
const replacement = yield* apply(match.args)
|
||||
// Error values are branded plain objects; boundedData would strip the brand before coercion.
|
||||
// Error values are branded plain objects; toProgram would strip the brand before coercion.
|
||||
output.push(
|
||||
value.slice(end, match.offset),
|
||||
replacement instanceof Values.Promise
|
||||
? "[object Promise]"
|
||||
: errorBrandName(replacement)
|
||||
? coerceToString(replacement)
|
||||
: coerceToString(boundedData(replacement, `String.${name} replacer result`)),
|
||||
: coerceToString(toProgram(replacement, `String.${name} replacer result`)),
|
||||
)
|
||||
end = match.offset + match.match.length
|
||||
}
|
||||
output.push(value.slice(end))
|
||||
return boundedData(output.join(""), `String.${name} result`)
|
||||
return toProgram(output.join(""), `String.${name} result`)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -876,7 +877,7 @@ const invokeArrayMethod = <R>(
|
||||
if (args.length > 1 || (args.length === 1 && typeof args[0] !== "string")) {
|
||||
throw new InterpreterRuntimeError("Array.join expects zero arguments or one string separator.", node)
|
||||
}
|
||||
const input = boundedData(target, "Array.join input") as Array<unknown>
|
||||
const input = toProgram(target, "Array.join input") as Array<unknown>
|
||||
return Effect.succeed(
|
||||
input.map((item) => coerceToString(item ?? "")).join(args.length === 0 ? "," : (args[0] as string)),
|
||||
)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { Effect } from "effect"
|
||||
import type { DiagnosticKind } from "../codemode.js"
|
||||
import type { SafeObject } from "../tool-runtime.js"
|
||||
import type { SafeObject } from "../data.js"
|
||||
import type { Values } from "../values.js"
|
||||
|
||||
export type SourcePosition = {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Cause, Deferred, Effect, Exit, Fiber, Scope } from "effect"
|
||||
import type { Diagnostic } from "../codemode.js"
|
||||
import type { SafeObject } from "../tool-runtime.js"
|
||||
import type { SafeObject } from "../data.js"
|
||||
import {
|
||||
type AstNode,
|
||||
CodeModeFunction,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Cause, Deferred, Effect, Exit } from "effect"
|
||||
import { isBlockedMember, ToolReference, ToolRuntimeError, type SafeObject } from "../tool-runtime.js"
|
||||
import { isBlockedMember, ToolRuntimeError, type SafeObject, toProgram } from "../data.js"
|
||||
import { ToolReference } from "../tool-runtime.js"
|
||||
import {
|
||||
type AstNode,
|
||||
AsyncIteratorSymbol,
|
||||
@@ -90,7 +91,6 @@ import {
|
||||
urlArgument,
|
||||
} from "../stdlib/url.js"
|
||||
import {
|
||||
boundedData,
|
||||
coerceToNumber,
|
||||
coerceToString,
|
||||
compoundOperators,
|
||||
@@ -1342,7 +1342,7 @@ export class Interpreter<R> {
|
||||
this.constructRegExp([regex.pattern, typeof regex.flags === "string" ? regex.flags : ""], node),
|
||||
)
|
||||
}
|
||||
return Effect.sync(() => boundedData(node.value, "Literal"))
|
||||
return Effect.sync(() => toProgram(node.value, "Literal"))
|
||||
}
|
||||
case "Identifier":
|
||||
return Effect.sync(() => this.scopes.get(getString(node, "name"), node))
|
||||
@@ -1614,7 +1614,7 @@ export class Interpreter<R> {
|
||||
).as("TypeError")
|
||||
}
|
||||
if (Values.isValue(init)) return new Values.URLSearchParams(new URLSearchParams())
|
||||
const data = boundedData(init, "new URLSearchParams input")
|
||||
const data = toProgram(init, "new URLSearchParams input")
|
||||
if (data === null || typeof data !== "object") {
|
||||
throw new InterpreterRuntimeError(
|
||||
"new URLSearchParams(...) expects a query string, data object, iterable pairs, or URLSearchParams.",
|
||||
@@ -1659,7 +1659,7 @@ export class Interpreter<R> {
|
||||
const lhs = yield* self.evaluateExpression(getNode(node, "left"))
|
||||
const rhs = yield* self.evaluateExpression(getNode(node, "right"))
|
||||
if (operator === "instanceof") return instanceofValue(lhs, rhs, node)
|
||||
return boundedData(self.applyBinaryOperator(operator, lhs, rhs, node), "Binary expression result")
|
||||
return toProgram(self.applyBinaryOperator(operator, lhs, rhs, node), "Binary expression result")
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1776,7 +1776,7 @@ export class Interpreter<R> {
|
||||
default:
|
||||
throw new InterpreterRuntimeError(`Unsupported unary operator '${operator}'.`, node)
|
||||
}
|
||||
return boundedData(result, "Unary expression result")
|
||||
return toProgram(result, "Unary expression result")
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1798,7 +1798,7 @@ export class Interpreter<R> {
|
||||
if (operator !== "=") {
|
||||
const current = self.scopes.get(name, left)
|
||||
const rightValue = yield* self.evaluateExpression(getNode(node, "right"))
|
||||
const next = boundedData(
|
||||
const next = toProgram(
|
||||
self.applyCompoundAssignment(operator, current, rightValue, node),
|
||||
"Assignment result",
|
||||
)
|
||||
@@ -1811,7 +1811,7 @@ export class Interpreter<R> {
|
||||
return yield* self.modifyMember(left, (current) =>
|
||||
Effect.map(self.evaluateExpression(getNode(node, "right")), (rightValue) => {
|
||||
if (operator === "=") return { write: true, next: rightValue, result: rightValue }
|
||||
const next = boundedData(
|
||||
const next = toProgram(
|
||||
self.applyCompoundAssignment(operator, current, rightValue, node),
|
||||
"Assignment result",
|
||||
)
|
||||
@@ -1961,13 +1961,13 @@ export class Interpreter<R> {
|
||||
if (callable.namespace === "Array" && callable.name === "of") {
|
||||
return invokeGlobalMethod(callable, args, node)
|
||||
}
|
||||
return boundedData(invokeGlobalMethod(callable, args, node), `${callable.namespace}.${callable.name} result`)
|
||||
return toProgram(invokeGlobalMethod(callable, args, node), `${callable.namespace}.${callable.name} result`)
|
||||
}
|
||||
if (callable instanceof JsonMethodReference) {
|
||||
return yield* invokeJsonMethod(self.runner, callable.name, args, node)
|
||||
}
|
||||
if (callable instanceof CoercionFunction) {
|
||||
return boundedData(invokeCoercion(callable, args, node), `${callable.name} result`)
|
||||
return toProgram(invokeCoercion(callable, args, node), `${callable.name} result`)
|
||||
}
|
||||
if (callable instanceof UriFunction) {
|
||||
return invokeUriFunction(callable, args, node)
|
||||
@@ -2014,7 +2014,7 @@ export class Interpreter<R> {
|
||||
|
||||
private invokeObjectMethodOnTools(name: string, ref: ToolReference, node: AstNode): unknown {
|
||||
if (name === "keys") {
|
||||
return boundedData(this.enumerableKeys(ref)!, "Object.keys result")
|
||||
return toProgram(this.enumerableKeys(ref)!, "Object.keys result")
|
||||
}
|
||||
throw new InterpreterRuntimeError(
|
||||
`Object.${name}(...) cannot read tool references: they are not plain data. Use Object.keys(tools) for names, or search({ query }) for signatures.`,
|
||||
@@ -2445,7 +2445,7 @@ export class Interpreter<R> {
|
||||
|
||||
if (index < expressions.length) {
|
||||
const raw = yield* self.evaluateExpression(asNode(expressions[index], "expressions"))
|
||||
output += coerceToString(boundedData(raw, "Template interpolation"))
|
||||
output += coerceToString(toProgram(raw, "Template interpolation"))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { fromSchemaOpenApi3_0, fromSchemaOpenApi3_1 } from "effect/JsonSchema"
|
||||
import type { JsonSchema } from "../tool.js"
|
||||
import { isBlockedMember } from "../tool-runtime.js"
|
||||
import { isBlockedMember } from "../data.js"
|
||||
import type {
|
||||
Body,
|
||||
Document,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { toData, toProgram } from "../data.js"
|
||||
import { containsOpaqueReference, containsRuntimeReference, isRuntimeReference } from "../interpreter/references.js"
|
||||
import { copyIn, copyOut } from "../tool-runtime.js"
|
||||
import { Values } from "../values.js"
|
||||
import { boundedData, coerceToString } from "./value.js"
|
||||
import { coerceToString } from "./value.js"
|
||||
|
||||
export const consoleMethods = new Set(["log", "info", "debug", "warn", "error", "dir", "table"])
|
||||
|
||||
@@ -66,7 +66,7 @@ const formatConsoleValue = (value: unknown, seen: Set<object>, depth: number): s
|
||||
const formatConsoleTable = (value: unknown, columnsArgument: unknown): string => {
|
||||
if (value === undefined) return "undefined"
|
||||
if (containsOpaqueReference(value)) return "[opaque reference]"
|
||||
const data = boundedData(value, "console.table argument")
|
||||
const data = toProgram(value, "console.table argument")
|
||||
const columns = consoleTableColumns(columnsArgument)
|
||||
const rows = consoleTableRows(data, columns)
|
||||
const keys = columns ?? Array.from(new Set(rows.flatMap((row) => Object.keys(row.values))))
|
||||
@@ -80,7 +80,7 @@ const formatConsoleTable = (value: unknown, columnsArgument: unknown): string =>
|
||||
const consoleTableColumns = (value: unknown): ReadonlyArray<string> | undefined => {
|
||||
if (value === undefined) return undefined
|
||||
if (containsRuntimeReference(value)) return undefined
|
||||
const columns = copyOut(copyIn(value, "console.table columns"), "nullify")
|
||||
const columns = toData(value, "console.table columns", "result")
|
||||
return Array.isArray(columns) ? columns.map((column) => String(column)) : undefined
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { CallbackRunner } from "../interpreter/methods.js"
|
||||
import { applyCollectionCallback } from "../interpreter/methods.js"
|
||||
import { type AstNode, InterpreterRuntimeError } from "../interpreter/model.js"
|
||||
import { typeofValue } from "../interpreter/references.js"
|
||||
import { copyIn, copyOut, type SafeObject } from "../tool-runtime.js"
|
||||
import { fromData, type SafeObject, toData, toProgram } from "../data.js"
|
||||
import { Values } from "../values.js"
|
||||
|
||||
export const jsonStatics = new Set(["parse", "stringify"])
|
||||
@@ -28,7 +28,7 @@ const parse = <R>(
|
||||
|
||||
const parsed = (() => {
|
||||
try {
|
||||
return copyIn(JSON.parse(text), "JSON.parse result")
|
||||
return fromData(JSON.parse(text), "JSON.parse result")
|
||||
} catch (error) {
|
||||
throw new InterpreterRuntimeError(
|
||||
`JSON.parse received invalid JSON: ${error instanceof Error ? error.message : String(error)}`,
|
||||
@@ -71,29 +71,27 @@ const stringify = <R>(
|
||||
const space = args[2]
|
||||
const indent = typeof space === "number" || typeof space === "string" ? space : undefined
|
||||
const replacer = args[1]
|
||||
const callable = typeofValue(replacer) === "function"
|
||||
const checked = copyIn(args[0], "JSON.stringify value", callable)
|
||||
const input = callable ? args[0] : checked
|
||||
|
||||
if (Array.isArray(replacer)) {
|
||||
const properties = replacer
|
||||
.filter((item): item is string | number => typeof item === "string" || typeof item === "number")
|
||||
.map(String)
|
||||
return Effect.succeed(JSON.stringify(copyOut(input, "json"), properties, indent))
|
||||
}
|
||||
if (!callable) {
|
||||
return Effect.succeed(JSON.stringify(copyOut(input, "json"), null, indent))
|
||||
if (typeofValue(replacer) !== "function") {
|
||||
const properties = Array.isArray(replacer)
|
||||
? replacer
|
||||
.filter((item): item is string | number => typeof item === "string" || typeof item === "number")
|
||||
.map(String)
|
||||
: null
|
||||
return Effect.succeed(JSON.stringify(toData(args[0], "JSON.stringify value"), properties, indent))
|
||||
}
|
||||
|
||||
// Validate up front; the replacer walk below reads the original value.
|
||||
toProgram(args[0], "JSON.stringify value")
|
||||
const apply = applyCollectionCallback(runner, replacer, "JSON.stringify", node)
|
||||
const root: SafeObject = Object.create(null) as SafeObject
|
||||
root[""] = input
|
||||
root[""] = args[0]
|
||||
const stack = new Set<object>()
|
||||
const visit = (holder: SafeObject | Array<unknown>, key: string): Effect.Effect<unknown, unknown, R> =>
|
||||
Effect.gen(function* () {
|
||||
const value = yield* apply([key, toJSONValue(holder[key as keyof typeof holder])])
|
||||
if (value === undefined || typeofValue(value) === "function") return undefined
|
||||
copyIn(value, "JSON.stringify replacer result", true)
|
||||
toProgram(value, "JSON.stringify replacer result")
|
||||
if (typeof value === "number") return Number.isFinite(value) ? value : null
|
||||
if (value === null || typeof value === "string" || typeof value === "boolean") return value
|
||||
if (Array.isArray(value)) {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { type AstNode, InterpreterRuntimeError } from "../interpreter/model.js"
|
||||
import { boundedData, coerceToString } from "./value.js"
|
||||
import { toProgram } from "../data.js"
|
||||
import { coerceToString } from "./value.js"
|
||||
|
||||
export const numberMethods = new Set(["toFixed", "toPrecision", "toExponential", "toString", "valueOf"])
|
||||
|
||||
@@ -50,7 +51,7 @@ export const invokeNumberMethod = (value: number, name: string, args: Array<unkn
|
||||
default:
|
||||
throw new InterpreterRuntimeError(`Number method '${name}' is not available.`, node)
|
||||
}
|
||||
return boundedData(result, `Number.${name} result`)
|
||||
return toProgram(result, `Number.${name} result`)
|
||||
}
|
||||
|
||||
export const invokeNumberStatic = (name: string, args: Array<unknown>, node: AstNode): unknown => {
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { Effect } from "effect"
|
||||
import { type AstNode, AsyncIteratorSymbol, InterpreterRuntimeError, IteratorSymbol } from "../interpreter/model.js"
|
||||
import { containsOpaqueReference, rejectCircularInsertion } from "../interpreter/references.js"
|
||||
import { isBlockedMember } from "../tool-runtime.js"
|
||||
import { isBlockedMember } from "../data.js"
|
||||
import { Values } from "../values.js"
|
||||
import { boundedData, coerceToString } from "./value.js"
|
||||
import { toProgram } from "../data.js"
|
||||
import { coerceToString } from "./value.js"
|
||||
import { preserveConsumerError, type SyncIteratorRunner } from "../interpreter/iterator.js"
|
||||
|
||||
export const objectMethodsPreservingIdentity = new Set(["assign", "values", "entries", "fromEntries"])
|
||||
@@ -115,8 +116,8 @@ export const invokeObjectFromEntries = <R>(
|
||||
)
|
||||
}
|
||||
const entry = step.value as Record<string, unknown>
|
||||
boundedData(entry[0], "Object.fromEntries key")
|
||||
boundedData(entry[1], "Object.fromEntries value")
|
||||
toProgram(entry[0], "Object.fromEntries key")
|
||||
toProgram(entry[1], "Object.fromEntries value")
|
||||
const key = coerceToString(entry[0])
|
||||
if (isBlockedMember(key)) throw new InterpreterRuntimeError(`Property '${key}' is not available.`, node)
|
||||
out[key] = entry[1]
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { type AstNode, InterpreterRuntimeError } from "../interpreter/model.js"
|
||||
import { isBlockedMember, type SafeObject } from "../tool-runtime.js"
|
||||
import { isBlockedMember, type SafeObject } from "../data.js"
|
||||
import { Values } from "../values.js"
|
||||
import { coerceToNumber, coerceToString } from "./value.js"
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { type AstNode, InterpreterRuntimeError, UriFunction } from "../interpreter/model.js"
|
||||
import { Values } from "../values.js"
|
||||
import { boundedData, coerceToString } from "./value.js"
|
||||
import { toProgram } from "../data.js"
|
||||
import { coerceToString } from "./value.js"
|
||||
|
||||
export const urlProperties = new Set([
|
||||
"href",
|
||||
@@ -46,7 +47,7 @@ export const urlSearchParamsMethods = new Set([
|
||||
"toString",
|
||||
])
|
||||
|
||||
export const uriArgument = (value: unknown, label: string): string => coerceToString(boundedData(value, label))
|
||||
export const uriArgument = (value: unknown, label: string): string => coerceToString(toProgram(value, label))
|
||||
|
||||
export const invokeUriFunction = (ref: UriFunction, args: Array<unknown>, node: AstNode): string => {
|
||||
const value = uriArgument(args[0], `${ref.name} input`)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { type AstNode, CoercionFunction, InterpreterRuntimeError } from "../interpreter/model.js"
|
||||
import { copyIn, type SafeObject } from "../tool-runtime.js"
|
||||
import { type SafeObject, toProgram } from "../data.js"
|
||||
import { Values } from "../values.js"
|
||||
|
||||
export const errorConstructors = new Set([
|
||||
@@ -33,8 +33,6 @@ export const errorBrandName = (value: unknown): string | undefined =>
|
||||
? ((value as Record<PropertyKey, unknown>)[ErrorBrand] as string | undefined)
|
||||
: undefined
|
||||
|
||||
export const boundedData = (value: unknown, label: string): unknown => copyIn(value, label, true)
|
||||
|
||||
export const coerceToString = (value: unknown): string => {
|
||||
if (value === null) return "null"
|
||||
if (value === undefined) return "undefined"
|
||||
@@ -79,7 +77,7 @@ export const invokeCoercion = (ref: CoercionFunction, args: Array<unknown>, node
|
||||
if (ref.name === "String") return ""
|
||||
}
|
||||
const raw = args[0]
|
||||
// Error values are plain SafeObjects; the boundedData path below would strip their brand.
|
||||
// Error values are plain SafeObjects; the toProgram path below would strip their brand.
|
||||
if (ref.name === "String" && errorBrandName(raw) !== undefined) return coerceToString(raw)
|
||||
if (Values.isValue(raw)) {
|
||||
if (ref.name === "Boolean") return true
|
||||
@@ -90,7 +88,7 @@ export const invokeCoercion = (ref: CoercionFunction, args: Array<unknown>, node
|
||||
if (ref.name === "parseInt") return parseInt(coerceToString(raw))
|
||||
return parseFloat(coerceToString(raw))
|
||||
}
|
||||
const value = boundedData(raw, `${ref.name} input`)
|
||||
const value = toProgram(raw, `${ref.name} input`)
|
||||
if (ref.name === "Number") return coerceToNumber(value)
|
||||
if (ref.name === "Boolean") return Boolean(value)
|
||||
if (ref.name === "isFinite") return Number.isFinite(coerceToNumber(value))
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Cause, Effect, Exit, Formatter, Schema } from "effect"
|
||||
import type { DiagnosticKind } from "./codemode.js"
|
||||
import { fromData, toData, ToolRuntimeError } from "./data.js"
|
||||
import { toolError } from "./tool-error.js"
|
||||
import {
|
||||
decodeInput as decodeToolInput,
|
||||
@@ -13,7 +13,6 @@ import {
|
||||
import { isNamespace, type Namespace } from "./namespace.js"
|
||||
import { isTool, type Tool } from "./tool.js"
|
||||
import type { Tools } from "./tools.js"
|
||||
import { Values } from "./values.js"
|
||||
|
||||
export const compareText = (left: string, right: string) => (left < right ? -1 : left > right ? 1 : 0)
|
||||
|
||||
@@ -52,7 +51,9 @@ export type ToolCallEnded = {
|
||||
}
|
||||
|
||||
export type ToolCallHooks<R = never> = {
|
||||
/** Observes decoded tool input immediately before tool execution. */
|
||||
readonly onToolCallStart?: ((call: ToolCallStarted) => Effect.Effect<void, never, R>) | undefined
|
||||
/** Observes each admitted tool call as it succeeds, fails, or is interrupted. */
|
||||
readonly onToolCallEnd?: ((call: ToolCallEnded) => Effect.Effect<void, never, R>) | undefined
|
||||
}
|
||||
|
||||
@@ -62,8 +63,6 @@ export type ToolDescription = {
|
||||
readonly signature: string
|
||||
}
|
||||
|
||||
export type SafeObject = Record<string, unknown>
|
||||
|
||||
const defaultSearchLimit = 10
|
||||
const PositiveInt = Schema.Int.check(Schema.isGreaterThan(0))
|
||||
const NonNegativeInt = Schema.Int.check(Schema.isGreaterThanOrEqualTo(0))
|
||||
@@ -94,168 +93,6 @@ export class ToolReference {
|
||||
constructor(readonly path: ReadonlyArray<string>) {}
|
||||
}
|
||||
|
||||
const MAX_VALUE_DEPTH = 32
|
||||
|
||||
export class ToolRuntimeError extends Error {
|
||||
constructor(
|
||||
readonly kind: Extract<
|
||||
DiagnosticKind,
|
||||
"UnknownTool" | "InvalidToolInput" | "InvalidToolOutput" | "InvalidDataValue" | "ToolCallLimitExceeded"
|
||||
>,
|
||||
message: string,
|
||||
readonly suggestions: ReadonlyArray<string> = [],
|
||||
) {
|
||||
super(message)
|
||||
this.name = "ToolRuntimeError"
|
||||
}
|
||||
}
|
||||
|
||||
const blockedMemberNames = new Set(["__proto__", "constructor", "prototype"])
|
||||
|
||||
export const isBlockedMember = (name: string): boolean => blockedMemberNames.has(name)
|
||||
|
||||
// Checkpoint mode preserves CodeMode values; boundary mode JSON-normalizes them.
|
||||
export const copyIn = (value: unknown, label: string, preserveCodeModeValues = false): unknown =>
|
||||
copyBounded(value, label, 0, new Set(), preserveCodeModeValues)
|
||||
|
||||
const copyBounded = (
|
||||
value: unknown,
|
||||
label: string,
|
||||
depth: number,
|
||||
seen: Set<object>,
|
||||
preserveCodeModeValues: boolean,
|
||||
): unknown => {
|
||||
if (depth > MAX_VALUE_DEPTH) {
|
||||
throw new ToolRuntimeError("InvalidDataValue", `${label} exceeds the maximum value depth of ${MAX_VALUE_DEPTH}.`)
|
||||
}
|
||||
if (
|
||||
value === null ||
|
||||
value === undefined ||
|
||||
typeof value === "string" ||
|
||||
typeof value === "boolean" ||
|
||||
typeof value === "number"
|
||||
) {
|
||||
return value
|
||||
}
|
||||
|
||||
if (typeof value !== "object") {
|
||||
throw new ToolRuntimeError("InvalidDataValue", `${label} must contain data only.`)
|
||||
}
|
||||
|
||||
if (value instanceof Values.Promise) {
|
||||
throw new ToolRuntimeError(
|
||||
"InvalidDataValue",
|
||||
`${label} contains an un-awaited Promise; await tool calls (e.g. \`const result = await tools.ns.tool(...)\`) before using their results.`,
|
||||
)
|
||||
}
|
||||
|
||||
if (preserveCodeModeValues) {
|
||||
if (Values.isValue(value)) return value
|
||||
if (value instanceof Date) return new Values.Date(value.getTime())
|
||||
if (value instanceof RegExp) return new Values.RegExp(value.source, value.flags)
|
||||
if (value instanceof Map) {
|
||||
const wrapped = new Values.Map()
|
||||
for (const [key, item] of value.entries()) {
|
||||
wrapped.map.set(copyBounded(key, label, depth + 1, seen, true), copyBounded(item, label, depth + 1, seen, true))
|
||||
}
|
||||
return wrapped
|
||||
}
|
||||
if (value instanceof Set) {
|
||||
const wrapped = new Values.Set()
|
||||
for (const item of value.values()) wrapped.set.add(copyBounded(item, label, depth + 1, seen, true))
|
||||
return wrapped
|
||||
}
|
||||
if (value instanceof URL) return new Values.URL(new URL(value.href))
|
||||
if (value instanceof URLSearchParams) return new Values.URLSearchParams(new URLSearchParams(value))
|
||||
}
|
||||
|
||||
if (value instanceof Values.Date) {
|
||||
return Number.isFinite(value.time) ? new Date(value.time).toISOString() : null
|
||||
}
|
||||
if (value instanceof Date) {
|
||||
return Number.isFinite(value.getTime()) ? value.toISOString() : null
|
||||
}
|
||||
if (value instanceof Values.URL) return value.url.href
|
||||
if (value instanceof URL) return value.href
|
||||
// Remaining runtime values and their host counterparts serialize as empty objects, like JSON.stringify.
|
||||
if (
|
||||
Values.isValue(value) ||
|
||||
value instanceof RegExp ||
|
||||
value instanceof Map ||
|
||||
value instanceof Set ||
|
||||
value instanceof URLSearchParams
|
||||
) {
|
||||
return Object.create(null) as SafeObject
|
||||
}
|
||||
|
||||
if (seen.has(value)) {
|
||||
throw new ToolRuntimeError("InvalidDataValue", `${label} contains a circular value.`)
|
||||
}
|
||||
|
||||
seen.add(value)
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
const copied = value.map((item) => copyBounded(item, label, depth + 1, seen, preserveCodeModeValues))
|
||||
if (preserveCodeModeValues) {
|
||||
// Checkpoint copies retain array metadata that boundary copies omit.
|
||||
for (const [key, item] of Object.entries(value)) {
|
||||
if (Object.hasOwn(copied, key)) continue
|
||||
if (isBlockedMember(key)) {
|
||||
throw new ToolRuntimeError("InvalidDataValue", `${label} contains blocked property '${key}'.`)
|
||||
}
|
||||
Reflect.set(copied, key, copyBounded(item, label, depth + 1, seen, true))
|
||||
}
|
||||
}
|
||||
seen.delete(value)
|
||||
return copied
|
||||
}
|
||||
|
||||
const prototype = Object.getPrototypeOf(value)
|
||||
if (prototype !== Object.prototype && prototype !== null) {
|
||||
throw new ToolRuntimeError("InvalidDataValue", `${label} must contain plain objects only.`)
|
||||
}
|
||||
|
||||
const copied: SafeObject = Object.create(null) as SafeObject
|
||||
for (const [key, item] of Object.entries(value)) {
|
||||
if (isBlockedMember(key)) {
|
||||
throw new ToolRuntimeError("InvalidDataValue", `${label} contains blocked property '${key}'.`)
|
||||
}
|
||||
copied[key] = copyBounded(item, label, depth + 1, seen, preserveCodeModeValues)
|
||||
}
|
||||
seen.delete(value)
|
||||
return copied
|
||||
}
|
||||
|
||||
// "json" mirrors JSON.stringify (undefined object values drop, undefined array elements become
|
||||
// null, a bare undefined passes through): use it wherever data leaves as JSON, like tool
|
||||
// arguments and stringify-style formatting. "nullify" turns every undefined, including a bare
|
||||
// one, into null: use it for program results, where the consumer must never see undefined.
|
||||
export type CopyOutMode = "json" | "nullify"
|
||||
|
||||
export const copyOut = (value: unknown, mode: CopyOutMode): unknown => {
|
||||
if (value === undefined && mode === "nullify") return null
|
||||
if (typeof value === "number" && !Number.isFinite(value)) {
|
||||
return null
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
// Array.from densifies holes so sparse arrays normalize at the boundary like JSON does.
|
||||
return Array.from(value, (item) => {
|
||||
const copied = copyOut(item, mode)
|
||||
return copied === undefined && mode === "json" ? null : copied
|
||||
})
|
||||
}
|
||||
|
||||
if (value !== null && typeof value === "object" && !(value instanceof ToolReference)) {
|
||||
return Object.fromEntries(
|
||||
Object.entries(value)
|
||||
.map(([key, item]) => [key, copyOut(item, mode)] as const)
|
||||
.filter(([, item]) => !(item === undefined && mode === "json")),
|
||||
)
|
||||
}
|
||||
|
||||
return value
|
||||
}
|
||||
|
||||
// Dots in tool names are namespace separators; the last tool for a canonical path wins.
|
||||
type ToolNode<R> = {
|
||||
tool?: Tool<R>
|
||||
@@ -314,11 +151,9 @@ const describeTool = <R>(visible: VisibleTool<R>): ToolDescription => ({
|
||||
: `${toolExpression(visible.path)}(input: ${inputTypeScript(visible.tool, true)}): Promise<${outputTypeScript(visible.tool, true)}>`,
|
||||
})
|
||||
|
||||
// Discovery bytes are durable instructions, so order only after canonical-path collisions settle.
|
||||
const visibleTools = <R>(tools: Tools<R>) =>
|
||||
flattenTools(toolTrie(tools)).sort((left, right) => compareText(left.path, right.path))
|
||||
|
||||
export type DiscoveryPlan = {
|
||||
/** Tools indexed once per runtime: the lookup trie plus the model-facing catalog and search index. */
|
||||
export type Prepared<R = never> = {
|
||||
readonly root: ToolNode<R>
|
||||
readonly catalog: ReadonlyArray<ToolDescription>
|
||||
readonly searchIndex: ReadonlyArray<SearchEntry>
|
||||
}
|
||||
@@ -426,11 +261,12 @@ const toSearchEntry = <R>(visible: VisibleTool<R>): SearchEntry => ({
|
||||
.toLowerCase(),
|
||||
})
|
||||
|
||||
export const searchIndex = <R>(tools: Tools<R>): ReadonlyArray<SearchEntry> => visibleTools(tools).map(toSearchEntry)
|
||||
|
||||
export const prepare = <R>(tools: Tools<R>): DiscoveryPlan => {
|
||||
const visible = visibleTools(tools)
|
||||
export const prepare = <R>(tools: Tools<R>): Prepared<R> => {
|
||||
const root = toolTrie(tools)
|
||||
// Discovery bytes are durable instructions, so order only after canonical-path collisions settle.
|
||||
const visible = flattenTools(root).sort((left, right) => compareText(left.path, right.path))
|
||||
return {
|
||||
root,
|
||||
catalog: visible.map(describeTool),
|
||||
searchIndex: visible.map(toSearchEntry),
|
||||
}
|
||||
@@ -463,22 +299,21 @@ const resolve = <R>(root: ToolNode<R>, path: ReadonlyArray<string>): Tool<R> =>
|
||||
}
|
||||
|
||||
export type ToolRuntime<R = never> = {
|
||||
readonly root: ToolReference
|
||||
readonly calls: Array<ToolCall>
|
||||
readonly execute: (path: ReadonlyArray<string>, args: Array<unknown>) => Effect.Effect<unknown, unknown, R>
|
||||
readonly search: (args: Array<unknown>) => Effect.Effect<unknown, unknown, R>
|
||||
readonly keys: (path: ReadonlyArray<string>) => ReadonlyArray<string>
|
||||
}
|
||||
|
||||
/** Per-execution call state over tools prepared once for the runtime. */
|
||||
export const make = <R>(
|
||||
tools: Tools<R>,
|
||||
prepared: Prepared<R>,
|
||||
maxToolCalls: number | undefined,
|
||||
searchIndex: ReadonlyArray<SearchEntry>,
|
||||
hooks?: ToolCallHooks<R>,
|
||||
): ToolRuntime<R> => {
|
||||
const calls: Array<ToolCall> = []
|
||||
const root = toolTrie(tools)
|
||||
const searchTool = makeSearchTool(searchIndex)
|
||||
const root = prepared.root
|
||||
const searchTool = makeSearchTool(prepared.searchIndex)
|
||||
|
||||
const observeEnd = <A, E>(effect: Effect.Effect<A, E, R>, call: ToolCallStarted): Effect.Effect<A, E, R> => {
|
||||
const onEnd = hooks?.onToolCallEnd
|
||||
@@ -538,7 +373,7 @@ export const make = <R>(
|
||||
}),
|
||||
)
|
||||
return yield* Effect.try({
|
||||
try: () => copyIn(decodeToolOutput(tool, raw), `Result from tool '${name}'`),
|
||||
try: () => fromData(decodeToolOutput(tool, raw), `Result from tool '${name}'`),
|
||||
catch: (cause) => new ToolRuntimeError("InvalidToolOutput", `Invalid output from tool '${name}': ${cause}`),
|
||||
})
|
||||
}),
|
||||
@@ -547,21 +382,16 @@ export const make = <R>(
|
||||
})
|
||||
|
||||
return {
|
||||
root: new ToolReference([]),
|
||||
calls,
|
||||
keys: (path) => namespaceKeys(root, path),
|
||||
search: (args) =>
|
||||
Effect.suspend(() =>
|
||||
executeTool(
|
||||
"search",
|
||||
searchTool,
|
||||
args.map((arg) => copyOut(copyIn(arg, "Arguments for tool 'search'"), "json")),
|
||||
),
|
||||
executeTool("search", searchTool, args.map((arg) => toData(arg, "Arguments for tool 'search'"))),
|
||||
),
|
||||
execute: (path, args) =>
|
||||
Effect.gen(function* () {
|
||||
const name = canonicalSegments(path).join(".")
|
||||
const externalArgs = args.map((arg) => copyOut(copyIn(arg, `Arguments for tool '${name}'`), "json"))
|
||||
const externalArgs = args.map((arg) => toData(arg, `Arguments for tool '${name}'`))
|
||||
const tool = resolve(root, path)
|
||||
return yield* executeTool(name, tool, externalArgs)
|
||||
}),
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { CodeMode } from "../src/index.js"
|
||||
import { ToolRuntime } from "../src/tool-runtime.js"
|
||||
import { Data } from "../src/data.js"
|
||||
|
||||
// Runs a CodeMode program with no host tools and returns the CodeMode.Result. These tests pin the
|
||||
// JS-parity behaviors for the "99% of ordinary defensive JavaScript just works" goal: cases where
|
||||
@@ -299,23 +299,23 @@ describe("H1: NaN/Infinity flow as intermediates and normalize to null at the bo
|
||||
|
||||
test("copyOut normalizes non-finite numbers to null (the shared return + tool-arg boundary)", () => {
|
||||
// Tool-call arguments funnel through copyOut too, so this one function pins both boundaries.
|
||||
expect(ToolRuntime.copyOut(NaN, "json")).toBeNull()
|
||||
expect(ToolRuntime.copyOut(Infinity, "json")).toBeNull()
|
||||
expect(ToolRuntime.copyOut(-Infinity, "nullify")).toBeNull()
|
||||
expect(ToolRuntime.copyOut(42, "json")).toBe(42)
|
||||
expect(ToolRuntime.copyOut({ a: NaN, b: [Infinity, 1] }, "json")).toEqual({ a: null, b: [null, 1] })
|
||||
expect(Data.toData(NaN, "value")).toBeNull()
|
||||
expect(Data.toData(Infinity, "value")).toBeNull()
|
||||
expect(Data.toData(-Infinity, "value", "result")).toBeNull()
|
||||
expect(Data.toData(42, "value")).toBe(42)
|
||||
expect(Data.toData({ a: NaN, b: [Infinity, 1] }, "value")).toEqual({ a: null, b: [null, 1] })
|
||||
})
|
||||
})
|
||||
|
||||
describe("copyOut undefined handling per boundary mode", () => {
|
||||
test("json mode mirrors JSON.stringify for undefined", () => {
|
||||
expect(ToolRuntime.copyOut({ q: undefined, keep: 1 }, "json")).toStrictEqual({ keep: 1 })
|
||||
expect(ToolRuntime.copyOut([1, undefined, 2], "json")).toStrictEqual([1, null, 2])
|
||||
expect(ToolRuntime.copyOut({ nested: { a: undefined, b: [undefined] } }, "json")).toStrictEqual({
|
||||
expect(Data.toData({ q: undefined, keep: 1 }, "value")).toStrictEqual({ keep: 1 })
|
||||
expect(Data.toData([1, undefined, 2], "value")).toStrictEqual([1, null, 2])
|
||||
expect(Data.toData({ nested: { a: undefined, b: [undefined] } }, "value")).toStrictEqual({
|
||||
nested: { b: [null] },
|
||||
})
|
||||
expect(ToolRuntime.copyOut(undefined, "json")).toBeUndefined()
|
||||
expect(ToolRuntime.copyOut({ a: undefined }, "nullify")).toStrictEqual({ a: null })
|
||||
expect(Data.toData(undefined, "value")).toBeUndefined()
|
||||
expect(Data.toData({ a: undefined }, "value", "result")).toStrictEqual({ a: null })
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user