mirror of
https://github.com/anomalyco/opencode.git
synced 2026-09-08 18:06:25 +00:00
Compare commits
25
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
57843b0979 | ||
|
|
8aec1aba21 | ||
|
|
1ead17547b | ||
|
|
0c1bf08ca6 | ||
|
|
8b09f6415a | ||
|
|
e791afdfa3 | ||
|
|
e655fed6c3 | ||
|
|
ccbc018072 | ||
|
|
4432956490 | ||
|
|
375bf4908f | ||
|
|
cc6bff39a0 | ||
|
|
8a5709324f | ||
|
|
be58ca806c | ||
|
|
9e42e5cc4c | ||
|
|
c2a1649dd4 | ||
|
|
ded9c7e505 | ||
|
|
f9bc2233dd | ||
|
|
7487999e06 | ||
|
|
2eea36e731 | ||
|
|
4fef8edbe8 | ||
|
|
50c552f763 | ||
|
|
a3d5923aca | ||
|
|
ea2c0184ce | ||
|
|
09c318094c | ||
|
|
22a534a0bb |
@@ -168,6 +168,7 @@
|
||||
"@yuuang/ffi-rs-linux-x64-gnu": "1.3.2",
|
||||
"@yuuang/ffi-rs-win32-arm64-msvc": "1.3.2",
|
||||
"@yuuang/ffi-rs-win32-x64-msvc": "1.3.2",
|
||||
"solid-refresh": "0.6.3",
|
||||
"vite": "catalog:",
|
||||
"vite-plugin-solid": "catalog:",
|
||||
},
|
||||
|
||||
+4
-4
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"nodeModules": {
|
||||
"x86_64-linux": "sha256-EKhY3iZDrbNrBhntWpSdtLcmNLte6yVBxpIrCxr1uNM=",
|
||||
"aarch64-linux": "sha256-0OjDGZHgcnnk6IxkfK6ogeeqsGTqY/dcaZ/XzT23sgA=",
|
||||
"aarch64-darwin": "sha256-Zk51gnOicaLtPuqCYfgARhm2TjL222w1Y0Em288o0YY=",
|
||||
"x86_64-darwin": "sha256-hvDZ9zCV6zOSx6i7JZ1kVUMht+JI/jc8/y+aYrNHQ1E="
|
||||
"x86_64-linux": "sha256-/5VErB3NjnKi0/LHqqJgcDadD9woNLMZZYxUjriRvJI=",
|
||||
"aarch64-linux": "sha256-CTXqFEvQIiKDe0OmtdkdY8KLQtQOxdsJNCom/Clzc1c=",
|
||||
"aarch64-darwin": "sha256-vF2+/jgWhF1Smef9U3nSpTS3RI5ZcriV0mjg1q9s8YM=",
|
||||
"x86_64-darwin": "sha256-suCQ+yDT048D3EbjFAzplyZedYZYAihseLkqg6c+wHc="
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,8 @@
|
||||
"scripts": {
|
||||
"dev": "bun run --cwd packages/cli src/index.ts",
|
||||
"dev:live": "sh -c 'OPENCODE_TUI_CHANNEL=dev OPENCODE_PASSWORD=\"$(opencode2 service get password)\" exec bun run dev \"$@\" --server \"$(opencode2 service status)\"' --",
|
||||
"dev:vite": "bun run --cwd packages/cli --conditions=browser dev/vite.ts",
|
||||
"dev:vite:live": "sh -c 'OPENCODE_TUI_CHANNEL=dev OPENCODE_PASSWORD=\"$(opencode2 service get password)\" exec bun run dev:vite \"$@\" --server \"$(opencode2 service status)\"' --",
|
||||
"dev:desktop": "bun --cwd packages/desktop dev",
|
||||
"dev:web": "bun --cwd packages/app dev",
|
||||
"dev:console": "ulimit -n 10240 2>/dev/null; bun run --cwd packages/console/app dev",
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
@@ -1,6 +1,25 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import type { Page, Route } from "@playwright/test"
|
||||
import { mockOpenCodeServer } from "../../utils/mock-server"
|
||||
import { createMockServerHandler, mockOpenCodeServer } from "../../utils/mock-server"
|
||||
|
||||
test("serves an empty config document list for composer defaults", async () => {
|
||||
const server = createMockServerHandler({
|
||||
provider: {},
|
||||
directory: "C:/OpenCode",
|
||||
project: {},
|
||||
sessions: [],
|
||||
pageMessages: () => ({ items: [] }),
|
||||
})
|
||||
try {
|
||||
const response = await server.handler(
|
||||
new Request("http://localhost/api/config?location%5Bdirectory%5D=C%3A%2FOpenCode"),
|
||||
)
|
||||
expect(response.status).toBe(200)
|
||||
expect(await response.json()).toEqual([])
|
||||
} finally {
|
||||
await server.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
test("applies message latency after a list response gate is released", async () => {
|
||||
const events: string[] = []
|
||||
|
||||
@@ -342,6 +342,7 @@ async function mockServers(
|
||||
if (route.request().method() === "GET" && sessionPermission)
|
||||
return json(route, { data: options.sessionPending?.[sessionPermission[1]!] ?? [] })
|
||||
if (requestDirectory && requestDirectory !== directory) return json(route, { name: "InvalidDirectory" }, 500)
|
||||
if (url.pathname === "/api/config") return json(route, [])
|
||||
if (url.pathname === "/api/provider")
|
||||
return json(route, {
|
||||
location: { directory },
|
||||
|
||||
@@ -602,6 +602,7 @@ async function mockServer(page: Page) {
|
||||
if (url.origin !== server) return route.fallback()
|
||||
if (url.pathname === `/api/session/${unresolvedSessionID}`) return new Promise(() => {})
|
||||
if (url.pathname === "/api/event") return sse(route)
|
||||
if (url.pathname === "/api/config") return json(route, [])
|
||||
if (url.pathname === "/api/session")
|
||||
return json(route, { data: sessions.map((session) => currentSession(session)), cursor: {} })
|
||||
if (url.pathname === "/api/session/active") return json(route, { data: {} })
|
||||
|
||||
@@ -39,6 +39,7 @@ const Group = HttpApiGroup.make("mock")
|
||||
}),
|
||||
)
|
||||
.add(HttpApiEndpoint.get("reference", "/api/reference", { success: Json }))
|
||||
.add(HttpApiEndpoint.get("config", "/api/config", { success: Json }))
|
||||
.add(HttpApiEndpoint.get("agent", "/api/agent", { success: Json }))
|
||||
.add(HttpApiEndpoint.get("provider", "/api/provider", { success: Json }))
|
||||
.add(HttpApiEndpoint.get("model", "/api/model", { success: Json }))
|
||||
|
||||
@@ -207,6 +207,7 @@ function mockHandlers(config: MockServerConfig, state: { cursors: Map<string, st
|
||||
)
|
||||
.handleAll({
|
||||
health: () => Effect.succeed({ healthy: true, version: "2.0.0", pid: 1 }),
|
||||
config: () => Effect.succeed([]),
|
||||
reference: () =>
|
||||
Effect.succeed({
|
||||
location: {
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { batch, type Accessor, createMemo, startTransition } from "solid-js"
|
||||
import { batch, type Accessor, createEffect, createMemo, on } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import type { ComposerControls } from "./adapter"
|
||||
import { useLayout } from "@/shell/state/layout"
|
||||
import { useLocal, type ModelKey, type ModelSelection } from "@/providers/models/selection"
|
||||
@@ -9,6 +10,7 @@ import { normalizeAgentList } from "@/runtime/server/global-sync/utils"
|
||||
import { useModels } from "@/providers/models/models"
|
||||
import { cycleModelVariant, getConfiguredAgentVariant, resolveModelVariant } from "@/providers/models/variant"
|
||||
import { useComposerState } from "./persistence"
|
||||
import { useConfiguredModel } from "@/providers/models/configured"
|
||||
|
||||
export function createComposerControls(input: { sessionKey: Accessor<string>; model?: ModelSelection }) {
|
||||
const layout = useLayout()
|
||||
@@ -31,6 +33,7 @@ export function createComposerControls(input: { sessionKey: Accessor<string>; mo
|
||||
selection: input.model ?? local.model,
|
||||
paid: providers.paid().length > 0,
|
||||
loading:
|
||||
!(input.model ?? local.model).ready() ||
|
||||
(local.agent.visible() && data.location.agent.list({ directory: sdk().directory }) === undefined) ||
|
||||
!providers.ready(),
|
||||
},
|
||||
@@ -43,15 +46,31 @@ export function createComposerControls(input: { sessionKey: Accessor<string>; mo
|
||||
}
|
||||
|
||||
export function createComposerModelSelection(input: {
|
||||
agent: () => { model?: ModelKey; variant?: string } | undefined
|
||||
agent: () => { name: string; model?: ModelKey; variant?: string } | undefined
|
||||
}) {
|
||||
const sdk = useWorkspaceLocation()
|
||||
const models = useModels()
|
||||
const local = useLocal()
|
||||
const prompt = useComposerState()
|
||||
const configuredModel = useConfiguredModel()
|
||||
const [remembered, setRemembered] = createStore<Record<string, ModelKey | undefined>>({})
|
||||
createEffect(
|
||||
on(
|
||||
() => input.agent()?.name,
|
||||
(name, previous) => {
|
||||
if (!name || !previous || name === previous) return
|
||||
batch(() => {
|
||||
const model = prompt.model.current()
|
||||
setRemembered(previous, model ? { providerID: model.providerID, modelID: model.modelID } : undefined)
|
||||
prompt.model.set(remembered[name] ? { ...remembered[name] } : undefined)
|
||||
})
|
||||
},
|
||||
),
|
||||
)
|
||||
const providers = useProviders(() => sdk().directory)
|
||||
const connected = createMemo(() => new Set(providers.connected().map((item) => item.id)))
|
||||
|
||||
const valid = (model: ModelKey) => {
|
||||
const valid = (model: Pick<ModelKey, "providerID" | "modelID">) => {
|
||||
const provider = providers.all().get(model.providerID)
|
||||
return !!provider?.models[model.modelID] && connected().has(model.providerID)
|
||||
}
|
||||
@@ -62,7 +81,8 @@ export function createComposerModelSelection(input: {
|
||||
return modelID ? [{ providerID: provider.id, modelID }] : []
|
||||
})[0]
|
||||
const current = () => {
|
||||
const key = [prompt.model.current(), input.agent()?.model, recent(), fallback()].find(
|
||||
if (!configuredModel.ready()) return
|
||||
const key = [prompt.model.current(), input.agent()?.model, configuredModel(), recent(), fallback()].find(
|
||||
(item): item is ModelKey => !!item && valid(item),
|
||||
)
|
||||
return key ? models.find(key) : undefined
|
||||
@@ -74,7 +94,9 @@ export function createComposerModelSelection(input: {
|
||||
.filter((item): item is NonNullable<typeof item> => !!item),
|
||||
)
|
||||
const selection = {
|
||||
ready: models.ready,
|
||||
trackSessionCommit: local.model.trackSessionCommit,
|
||||
remembered: () => Object.fromEntries(Object.entries(remembered).map(([name, model]) => [name, { model }])),
|
||||
ready: Object.assign(() => models.ready() && configuredModel.ready(), { promise: models.ready.promise }),
|
||||
current,
|
||||
recent: recentModels,
|
||||
list: models.list,
|
||||
@@ -83,19 +105,24 @@ export function createComposerModelSelection(input: {
|
||||
const item = current()
|
||||
if (!item) return
|
||||
const index = items.findIndex((entry) => entry.provider.id === item.provider.id && entry.id === item.id)
|
||||
if (index === -1) return
|
||||
const next = items[(index + direction + items.length) % items.length]
|
||||
const next =
|
||||
items[
|
||||
index === -1 ? (direction === 1 ? 0 : items.length - 1) : (index + direction + items.length) % items.length
|
||||
]
|
||||
if (next) selection.set({ providerID: next.provider.id, modelID: next.id })
|
||||
},
|
||||
set(item: ModelKey | undefined, options?: { recent?: boolean }) {
|
||||
void startTransition(() =>
|
||||
batch(() => {
|
||||
prompt.model.set(item ? { ...item, variant: prompt.model.current()?.variant } : undefined)
|
||||
if (!item) return
|
||||
models.setVisibility(item, true)
|
||||
if (options?.recent) models.recent.push(item)
|
||||
}),
|
||||
)
|
||||
batch(() => {
|
||||
if (item && !valid(item)) return
|
||||
const previous = current()
|
||||
const same = item && previous?.provider.id === item.providerID && previous.id === item.modelID
|
||||
prompt.model.set(
|
||||
item ? { ...item, variant: same ? (selection.variant.current() ?? null) : undefined } : undefined,
|
||||
)
|
||||
if (!item) return
|
||||
models.setVisibility(item, true)
|
||||
if (options?.recent) models.recent.push(item)
|
||||
})
|
||||
},
|
||||
visible: models.visible,
|
||||
setVisibility: models.setVisibility,
|
||||
@@ -104,38 +131,41 @@ export function createComposerModelSelection(input: {
|
||||
const item = input.agent()
|
||||
const model = current()
|
||||
if (!item || !model) return
|
||||
return getConfiguredAgentVariant({
|
||||
agent: { model: item.model, variant: item.variant },
|
||||
model: { providerID: model.provider.id, modelID: model.id, variants: model.variants },
|
||||
})
|
||||
const global = configuredModel()
|
||||
return (
|
||||
getConfiguredAgentVariant({
|
||||
agent: { model: item.model, variant: item.variant },
|
||||
model: { providerID: model.provider.id, modelID: model.id, variants: model.variants },
|
||||
}) ??
|
||||
getConfiguredAgentVariant({
|
||||
agent: { model: global, variant: global?.variant },
|
||||
model: { providerID: model.provider.id, modelID: model.id, variants: model.variants },
|
||||
})
|
||||
)
|
||||
},
|
||||
selected() {
|
||||
return prompt.model.current()?.variant
|
||||
const model = prompt.model.current()
|
||||
return model && valid(model) ? model.variant : undefined
|
||||
},
|
||||
current() {
|
||||
const resolved = resolveModelVariant({
|
||||
const model = current()
|
||||
return resolveModelVariant({
|
||||
variants: this.list(),
|
||||
selected: this.selected(),
|
||||
configured: this.configured(),
|
||||
preferred: model ? models.variant.get({ providerID: model.provider.id, modelID: model.id }) : undefined,
|
||||
})
|
||||
if (resolved) return resolved
|
||||
const model = current()
|
||||
if (!model) return
|
||||
const saved = models.variant.get({ providerID: model.provider.id, modelID: model.id })
|
||||
if (saved && this.list().includes(saved)) return saved
|
||||
},
|
||||
list() {
|
||||
return Object.keys(current()?.variants ?? {})
|
||||
},
|
||||
set(value: string | undefined) {
|
||||
void startTransition(() =>
|
||||
batch(() => {
|
||||
const model = current()
|
||||
if (!model) return
|
||||
prompt.model.set({ providerID: model.provider.id, modelID: model.id, variant: value ?? null })
|
||||
models.variant.set({ providerID: model.provider.id, modelID: model.id }, value)
|
||||
}),
|
||||
)
|
||||
batch(() => {
|
||||
const model = current()
|
||||
if (!model) return
|
||||
prompt.model.set({ providerID: model.provider.id, modelID: model.id, variant: value ?? null })
|
||||
models.variant.set({ providerID: model.provider.id, modelID: model.id }, value)
|
||||
})
|
||||
},
|
||||
cycle() {
|
||||
const variants = this.list()
|
||||
@@ -143,13 +173,13 @@ export function createComposerModelSelection(input: {
|
||||
this.set(
|
||||
cycleModelVariant({
|
||||
variants,
|
||||
selected: this.selected(),
|
||||
configured: this.configured(),
|
||||
selected: this.current() ?? null,
|
||||
configured: undefined,
|
||||
}),
|
||||
)
|
||||
},
|
||||
},
|
||||
} satisfies ModelSelection
|
||||
}
|
||||
|
||||
return selection
|
||||
return selection satisfies ModelSelection
|
||||
}
|
||||
|
||||
@@ -91,6 +91,8 @@ function session(input: {
|
||||
admitted?: (messageID: string) => boolean
|
||||
shell?: () => Promise<unknown>
|
||||
command?: ComposerSession["api"]["command"]
|
||||
switchAgent?: ComposerSession["api"]["switchAgent"]
|
||||
switchModel?: ComposerSession["api"]["switchModel"]
|
||||
}): ComposerSession {
|
||||
return {
|
||||
id: "session-1",
|
||||
@@ -99,12 +101,16 @@ function session(input: {
|
||||
current: input.current ?? (() => undefined),
|
||||
admitted: input.admitted ?? (() => false),
|
||||
api: {
|
||||
switchAgent: async () => {
|
||||
input.calls.push("switch-agent")
|
||||
},
|
||||
switchModel: async () => {
|
||||
input.calls.push("switch-model")
|
||||
},
|
||||
switchAgent:
|
||||
input.switchAgent ??
|
||||
(async () => {
|
||||
input.calls.push("switch-agent")
|
||||
}),
|
||||
switchModel:
|
||||
input.switchModel ??
|
||||
(async () => {
|
||||
input.calls.push("switch-model")
|
||||
}),
|
||||
shell: input.shell ?? (async () => undefined),
|
||||
command: input.command ?? (async () => undefined),
|
||||
},
|
||||
@@ -122,6 +128,146 @@ function session(input: {
|
||||
}
|
||||
|
||||
describe("Composer submission", () => {
|
||||
test("applies the captured agent and model before a custom command without passing over its overrides", async () => {
|
||||
const state = createMemoryComposerState({ prompt: "/review changes" }).capture()
|
||||
const calls: string[] = []
|
||||
const selected = controls()
|
||||
const agent = Promise.withResolvers<void>()
|
||||
const started = Promise.withResolvers<void>()
|
||||
const committed = Promise.withResolvers<void>()
|
||||
const completed = Promise.withResolvers<void>()
|
||||
const target = session({
|
||||
calls,
|
||||
prompt: async () => {
|
||||
throw new Error("command must not call prompt")
|
||||
},
|
||||
switchAgent: async (request) => {
|
||||
expect(request.agent).toBe("build")
|
||||
calls.push("agent")
|
||||
started.resolve()
|
||||
await agent.promise
|
||||
},
|
||||
switchModel: async (request) => {
|
||||
expect(request.model).toEqual({ providerID: "provider-1", id: "model-1", variant: "balanced" })
|
||||
calls.push("model")
|
||||
await committed.promise
|
||||
},
|
||||
command: async (request) => {
|
||||
expect(request).toMatchObject({ command: "review", text: "changes", delivery: "steer" })
|
||||
expect(request).not.toHaveProperty("model")
|
||||
expect(request).not.toHaveProperty("agent")
|
||||
calls.push("command")
|
||||
completed.resolve()
|
||||
},
|
||||
})
|
||||
selected.model.selection = {
|
||||
...selection,
|
||||
trackSessionCommit: (_id, value) => {
|
||||
expect(value).toEqual({
|
||||
agent: "build",
|
||||
model: { providerID: "provider-1", modelID: "model-1" },
|
||||
variant: "balanced",
|
||||
})
|
||||
calls.push("track")
|
||||
return () => calls.push("cancel")
|
||||
},
|
||||
}
|
||||
const adapter: ActiveComposerAdapter = {
|
||||
kind: "active-session",
|
||||
state,
|
||||
ready: () => true,
|
||||
controls: () => selected,
|
||||
working: () => false,
|
||||
session: () => target,
|
||||
interrupt: async () => undefined,
|
||||
submitted() {},
|
||||
setEditor() {},
|
||||
}
|
||||
await submitInput(adapter, undefined, "normal", () => [{ name: "review" }]).submit(new Event("submit"))
|
||||
await started.promise
|
||||
expect(calls).toEqual(["track", "agent"])
|
||||
selected.agents.current = "plan"
|
||||
selected.model.selection = { ...selection, variant: { ...selection.variant, current: () => "high" } }
|
||||
agent.resolve()
|
||||
committed.resolve()
|
||||
await completed.promise
|
||||
expect(calls).toEqual(["track", "agent", "model", "command"])
|
||||
})
|
||||
|
||||
test("commits the model even when cached session state already matches", async () => {
|
||||
const state = createMemoryComposerState({ prompt: "continue" }).capture()
|
||||
const calls: string[] = []
|
||||
const done = Promise.withResolvers<void>()
|
||||
const target = session({
|
||||
calls,
|
||||
current: () => ({ agent: "build", model: { providerID: "provider-1", id: "model-1", variant: "balanced" } }),
|
||||
prompt: async () => done.resolve(),
|
||||
})
|
||||
const adapter: ActiveComposerAdapter = {
|
||||
kind: "active-session",
|
||||
state,
|
||||
ready: () => true,
|
||||
controls,
|
||||
working: () => false,
|
||||
session: () => target,
|
||||
interrupt: async () => undefined,
|
||||
submitted() {},
|
||||
setEditor() {},
|
||||
}
|
||||
await submitInput(adapter).submit(new Event("submit"))
|
||||
await done.promise
|
||||
expect(calls).toEqual(["switch-model", "prompt"])
|
||||
})
|
||||
|
||||
test("cancels selection tracking and does not execute a command when selection fails", async () => {
|
||||
const state = createMemoryComposerState({ prompt: "/review changes" }).capture()
|
||||
const calls: string[] = []
|
||||
const selected = controls()
|
||||
const failed = Promise.withResolvers<unknown>()
|
||||
const error = new Error("model unavailable")
|
||||
const target = session({
|
||||
calls,
|
||||
prompt: async () => {
|
||||
calls.push("prompt")
|
||||
},
|
||||
switchModel: async () => {
|
||||
throw error
|
||||
},
|
||||
command: async () => {
|
||||
calls.push("command")
|
||||
},
|
||||
})
|
||||
selected.model.selection = {
|
||||
...selection,
|
||||
trackSessionCommit: () => {
|
||||
calls.push("track")
|
||||
return () => {
|
||||
calls.push("cancel")
|
||||
}
|
||||
},
|
||||
}
|
||||
const adapter: ActiveComposerAdapter = {
|
||||
kind: "active-session",
|
||||
state,
|
||||
ready: () => true,
|
||||
controls: () => selected,
|
||||
working: () => false,
|
||||
session: () => target,
|
||||
interrupt: async () => undefined,
|
||||
submitted() {},
|
||||
setEditor() {},
|
||||
}
|
||||
await submitInput(
|
||||
adapter,
|
||||
{ missingSelection() {}, failed: (_kind, error) => failed.resolve(error) },
|
||||
"normal",
|
||||
() => [{ name: "review" }],
|
||||
).submit(new Event("submit"))
|
||||
expect(await failed.promise).toBe(error)
|
||||
expect(calls).toEqual(["track", "switch-agent", "cancel"])
|
||||
expect(state.current()[0]).toMatchObject({ content: "/review changes" })
|
||||
})
|
||||
|
||||
test("submits a slash skill with its trailing text and attachments", async () => {
|
||||
const state = createMemoryComposerState({ prompt: "/show-me explain " }).capture()
|
||||
state.set([
|
||||
|
||||
@@ -11,6 +11,7 @@ import { createComposerSubmission } from "./submission-state"
|
||||
import { buildPromptRequest } from "./request"
|
||||
import { setCursorPosition } from "./editor/dom"
|
||||
import { blobDataUrl } from "@/runtime/persistence/drafts"
|
||||
import type { ModelSelection } from "@/providers/models/selection"
|
||||
|
||||
const submitting = new WeakSet<object>()
|
||||
|
||||
@@ -88,7 +89,7 @@ export function createComposerSubmit(input: ComposerSubmitInput) {
|
||||
session.handoff?.set(handoffMessage(value))
|
||||
const optimisticBusy = !input.adapter.working()
|
||||
if (optimisticBusy) session.data.session.setStatus(session.id, "running")
|
||||
const sending = sendPrompt(session, value).then(
|
||||
const sending = sendPrompt(session, value, input.adapter.controls().model.selection.trackSessionCommit).then(
|
||||
() => ({ ok: true as const }),
|
||||
(error) => ({ ok: false as const, error }),
|
||||
)
|
||||
@@ -124,9 +125,12 @@ export function createComposerSubmit(input: ComposerSubmitInput) {
|
||||
// Commands always steer: the server applies a command's configured
|
||||
// agent and model immediately at admission, so queueing one would
|
||||
// reconfigure the turn it is supposed to wait behind.
|
||||
void sendCommand(session, { ...value, delivery: "steer" }, command).catch((error) =>
|
||||
failSubmission(input, session, "command", error, restore, value.id),
|
||||
)
|
||||
void sendCommand(
|
||||
session,
|
||||
{ ...value, delivery: "steer" },
|
||||
command,
|
||||
input.adapter.controls().model.selection.trackSessionCommit,
|
||||
).catch((error) => failSubmission(input, session, "command", error, restore, value.id))
|
||||
return
|
||||
}
|
||||
} finally {
|
||||
@@ -315,8 +319,10 @@ async function sendCommand(
|
||||
session: ComposerSession,
|
||||
value: ComposerSubmission,
|
||||
command: { command: string; arguments: string },
|
||||
track?: ModelSelection["trackSessionCommit"],
|
||||
) {
|
||||
const request = await buildSubmissionRequest(session, value)
|
||||
await applySelection(session, value.selection, track)
|
||||
await session.api.command({
|
||||
sessionID: session.id,
|
||||
command: command.command,
|
||||
@@ -328,7 +334,33 @@ async function sendCommand(
|
||||
})
|
||||
}
|
||||
|
||||
async function sendPrompt(session: ComposerSession, value: ComposerSubmission) {
|
||||
async function applySelection(
|
||||
session: ComposerSession,
|
||||
selection: ComposerSelection,
|
||||
track?: ModelSelection["trackSessionCommit"],
|
||||
) {
|
||||
const cancel = track?.(session.id, selection)
|
||||
try {
|
||||
const current = session.current()
|
||||
if (current?.agent !== selection.agent) {
|
||||
await session.api.switchAgent({ sessionID: session.id, agent: selection.agent })
|
||||
}
|
||||
// The server deduplicates unchanged selections; cached SSE state may still be behind an earlier switch.
|
||||
await session.api.switchModel({
|
||||
sessionID: session.id,
|
||||
model: { id: selection.model.modelID, providerID: selection.model.providerID, variant: selection.variant },
|
||||
})
|
||||
} catch (error) {
|
||||
cancel?.()
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async function sendPrompt(
|
||||
session: ComposerSession,
|
||||
value: ComposerSubmission,
|
||||
track?: ModelSelection["trackSessionCommit"],
|
||||
) {
|
||||
const request = await buildSubmissionRequest(session, value)
|
||||
// Switching agent or model reconfigures the session immediately, and with it
|
||||
// the remainder of a running turn. A steer targets that turn, so its
|
||||
@@ -336,24 +368,7 @@ async function sendPrompt(session: ComposerSession, value: ComposerSubmission) {
|
||||
// waits behind, so it runs with the session selection at delivery time (the
|
||||
// intended selection stays recorded in its metadata).
|
||||
if (value.delivery === "steer") {
|
||||
const current = session.current()
|
||||
if (current?.agent !== value.selection.agent) {
|
||||
await session.api.switchAgent({ sessionID: session.id, agent: value.selection.agent })
|
||||
}
|
||||
if (
|
||||
current?.model?.providerID !== value.selection.model.providerID ||
|
||||
current.model.id !== value.selection.model.modelID ||
|
||||
(current.model.variant ?? "default") !== (value.selection.variant ?? "default")
|
||||
) {
|
||||
await session.api.switchModel({
|
||||
sessionID: session.id,
|
||||
model: {
|
||||
id: value.selection.model.modelID,
|
||||
providerID: value.selection.model.providerID,
|
||||
variant: value.selection.variant,
|
||||
},
|
||||
})
|
||||
}
|
||||
await applySelection(session, value.selection, track)
|
||||
}
|
||||
|
||||
const admission = {
|
||||
|
||||
@@ -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())}
|
||||
|
||||
@@ -110,6 +110,7 @@ export function createNewSessionComposerAdapter(props: {
|
||||
agent: selection.agent,
|
||||
model: selection.model,
|
||||
variant: selection.variant ?? null,
|
||||
choices: model.remembered(),
|
||||
})
|
||||
if (!pending) tabs.promoteDraft(draftID, { server: server.key, sessionId: created.id })
|
||||
submission.retarget(
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { createEffect, createMemo, on } from "solid-js"
|
||||
import { useData } from "@/runtime/server/current"
|
||||
import { useWorkspaceLocation } from "@/workspaces/location"
|
||||
import { useServerSDK } from "@/runtime/server/client"
|
||||
|
||||
export function useConfiguredModel() {
|
||||
const data = useData()
|
||||
const location = useWorkspaceLocation()
|
||||
const server = useServerSDK()
|
||||
createEffect(
|
||||
on(
|
||||
() => [location().directory, server.connection.status()] as const,
|
||||
([directory]) => {
|
||||
void data.location.config.sync({ directory }).catch(() => undefined)
|
||||
},
|
||||
),
|
||||
)
|
||||
const documents = () => data.location.config.list({ directory: location().directory })
|
||||
const model = createMemo(() => {
|
||||
const entry = documents()?.findLast((entry) => entry.type === "document" && entry.info.model !== undefined)
|
||||
const model = entry?.type === "document" ? entry.info.model : undefined
|
||||
if (!model) return
|
||||
if (typeof model !== "string") return { providerID: model.providerID, modelID: model.model, variant: model.variant }
|
||||
const [providerID, ...parts] = model.split("/")
|
||||
return { providerID, modelID: parts.join("/"), variant: undefined }
|
||||
})
|
||||
return Object.assign(model, { ready: () => documents() !== undefined })
|
||||
}
|
||||
@@ -122,10 +122,10 @@ const createModelsController = (directory: Accessor<string | undefined>) => {
|
||||
const setVariant = (model: ModelKey, value: string | undefined) => {
|
||||
const key = variantKey(model)
|
||||
if (!store.variant) {
|
||||
setStore("variant", { [key]: value })
|
||||
setStore("variant", { [key]: value ?? "default" })
|
||||
return
|
||||
}
|
||||
setStore("variant", key, value)
|
||||
setStore("variant", key, value ?? "default")
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Popover } from "@kobalte/core/popover"
|
||||
import { Component, ComponentProps, createEffect, createMemo, For, JSX, Show } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { useLocal } from "@/providers/models/selection"
|
||||
import { useLocal, type ModelSelection } from "@/providers/models/selection"
|
||||
import { useDialog } from "@opencode/ui/context/dialog"
|
||||
import { popularProviders } from "@/providers/catalog/providers"
|
||||
import { Button } from "@opencode/ui/button"
|
||||
@@ -27,7 +27,7 @@ import "@/settings/settings.css"
|
||||
const isFree = (provider: string, cost: { input: number } | undefined) =>
|
||||
provider === "opencode" && (!cost || cost.input === 0)
|
||||
|
||||
type ModelState = ReturnType<typeof useLocal>["model"]
|
||||
type ModelState = ModelSelection
|
||||
type ModelItem = ReturnType<ModelState["list"]>[number]
|
||||
|
||||
const modelKey = (model: ModelItem) => `${model.provider.id}:${model.id}`
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { createSimpleContext } from "@opencode/ui/context"
|
||||
import { base64Encode } from "@opencode/util/encode"
|
||||
import { useParams } from "@solidjs/router"
|
||||
import { batch, createEffect, createMemo, startTransition } from "solid-js"
|
||||
import { batch, createEffect, createMemo, onCleanup } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { Schema, SchemaGetter } from "effect"
|
||||
import { useModels } from "@/providers/models/models"
|
||||
@@ -16,6 +16,7 @@ import { useData } from "@/runtime/server/current"
|
||||
import { normalizeAgentList } from "@/runtime/server/global-sync/utils"
|
||||
import { useServerSDK } from "@/runtime/server/client"
|
||||
import { ScopedKey, type ServerScope } from "@/runtime/server/scope"
|
||||
import { useConfiguredModel } from "./configured"
|
||||
|
||||
const ModelKeySchema = Schema.Struct({
|
||||
providerID: Schema.String,
|
||||
@@ -24,11 +25,15 @@ const ModelKeySchema = Schema.Struct({
|
||||
})
|
||||
export type ModelKey = typeof ModelKeySchema.Type
|
||||
|
||||
const StateSchema = Schema.Struct({
|
||||
agent: Persistence.optional(Schema.String),
|
||||
const ChoiceSchema = Schema.Struct({
|
||||
model: Persistence.optional(ModelKeySchema),
|
||||
variant: Persistence.optional(Schema.NullOr(Schema.String)),
|
||||
})
|
||||
const StateSchema = Schema.Struct({
|
||||
...ChoiceSchema.fields,
|
||||
agent: Persistence.optional(Schema.String),
|
||||
choices: Persistence.optional(Schema.Record(Schema.String, ChoiceSchema)),
|
||||
})
|
||||
type State = typeof StateSchema.Type
|
||||
|
||||
const SessionsSchema = Schema.Record(
|
||||
@@ -78,6 +83,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
||||
const providers = useProviders(() => sdk().directory)
|
||||
const models = useModels()
|
||||
const settings = useSettings()
|
||||
const configuredModel = useConfiguredModel()
|
||||
|
||||
const id = createMemo(() => params.id || undefined)
|
||||
const list = createMemo(() =>
|
||||
@@ -98,16 +104,9 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
||||
current?: string
|
||||
draft?: State
|
||||
promoting?: State
|
||||
last?: {
|
||||
type: "agent" | "model" | "variant"
|
||||
agent?: string
|
||||
model?: ModelKey | null
|
||||
variant?: string | null
|
||||
}
|
||||
}>({
|
||||
current: list()[0]?.name,
|
||||
draft: undefined,
|
||||
last: undefined,
|
||||
})
|
||||
|
||||
const validModel = (model: ModelKey) => {
|
||||
@@ -176,13 +175,17 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
||||
}
|
||||
}
|
||||
|
||||
const fallback = createMemo<ModelKey | undefined>(() => recentModel() ?? defaultModel())
|
||||
const fallback = createMemo(() => firstModel(configuredModel, recentModel, defaultModel))
|
||||
const durable = () => {
|
||||
const session = id()
|
||||
return session ? data.session.get(session) : undefined
|
||||
}
|
||||
|
||||
const agent = {
|
||||
list,
|
||||
visible: agentsVisible,
|
||||
current() {
|
||||
return pickAgent(agentsVisible() ? (scope()?.agent ?? store.current) : "build")
|
||||
return pickAgent(scope()?.agent ?? durable()?.agent ?? (agentsVisible() ? store.current : "build"))
|
||||
},
|
||||
set(name: string | undefined) {
|
||||
const item = pickAgent(name)
|
||||
@@ -192,25 +195,24 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
||||
}
|
||||
|
||||
batch(() => {
|
||||
setStore("current", item.name)
|
||||
setStore("last", {
|
||||
type: "agent",
|
||||
agent: item.name,
|
||||
model: item.model,
|
||||
variant: item.variant ?? null,
|
||||
})
|
||||
const previous = snapshot()
|
||||
if (previous.agent === item.name) return
|
||||
const prev = scope()
|
||||
const choices = {
|
||||
...prev?.choices,
|
||||
...(previous.agent ? { [previous.agent]: { model: previous.model, variant: previous.variant } } : {}),
|
||||
}
|
||||
setStore("current", item.name)
|
||||
const next = {
|
||||
agent: item.name,
|
||||
model: item.model ?? prev?.model,
|
||||
variant: item.variant ?? prev?.variant,
|
||||
model: choices[item.name]?.model,
|
||||
variant: choices[item.name]?.variant,
|
||||
choices,
|
||||
} satisfies State
|
||||
const session = id()
|
||||
if (session) {
|
||||
setSaved("session", session, next)
|
||||
return
|
||||
}
|
||||
setStore("draft", next)
|
||||
write(next)
|
||||
// Pin both choices while the agent and model acknowledgments arrive separately.
|
||||
const selected = current()
|
||||
if (selected) model.set({ providerID: selected.provider.id, modelID: selected.id })
|
||||
})
|
||||
},
|
||||
move(direction: 1 | -1) {
|
||||
@@ -230,8 +232,15 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
||||
}
|
||||
|
||||
const current = createMemo(() => {
|
||||
if (!configuredModel.ready()) return
|
||||
const item = firstModel(
|
||||
() => scope()?.model,
|
||||
() => {
|
||||
const session = durable()
|
||||
if (session?.agent && session.agent !== agent.current()?.name) return
|
||||
const model = session?.model
|
||||
return model && { providerID: model.providerID, modelID: model.id }
|
||||
},
|
||||
() => agent.current()?.model,
|
||||
fallback,
|
||||
)
|
||||
@@ -243,26 +252,41 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
||||
const item = agent.current()
|
||||
const model = current()
|
||||
if (!item || !model) return
|
||||
return getConfiguredAgentVariant({
|
||||
agent: { model: item.model, variant: item.variant },
|
||||
model: { providerID: model.provider.id, modelID: model.id, variants: model.variants },
|
||||
})
|
||||
const global = configuredModel()
|
||||
return (
|
||||
getConfiguredAgentVariant({
|
||||
agent: { model: item.model, variant: item.variant },
|
||||
model: { providerID: model.provider.id, modelID: model.id, variants: model.variants },
|
||||
}) ??
|
||||
getConfiguredAgentVariant({
|
||||
agent: { model: global, variant: global?.variant },
|
||||
model: { providerID: model.provider.id, modelID: model.id, variants: model.variants },
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
const selected = () => scope()?.variant
|
||||
const selected = () => {
|
||||
const draft = scope()
|
||||
if (draft?.model && validModel(draft.model)) return draft.variant
|
||||
const session = durable()
|
||||
if (session?.agent && session.agent !== agent.current()?.name) return
|
||||
const value = session?.model
|
||||
if (value && validModel({ providerID: value.providerID, modelID: value.id })) return value.variant ?? null
|
||||
}
|
||||
|
||||
const snapshot = () => {
|
||||
const model = current()
|
||||
const selected = current()
|
||||
return {
|
||||
agent: agent.current()?.name,
|
||||
model: model ? { providerID: model.provider.id, modelID: model.id } : undefined,
|
||||
variant: selected(),
|
||||
model: selected ? { providerID: selected.provider.id, modelID: selected.id } : undefined,
|
||||
variant: selected ? (model.variant.current() ?? null) : undefined,
|
||||
} satisfies State
|
||||
}
|
||||
|
||||
const write = (next: Partial<State>) => {
|
||||
const state = {
|
||||
...(scope() ?? { agent: agent.current()?.name }),
|
||||
...scope(),
|
||||
agent: agent.current()?.name,
|
||||
...next,
|
||||
} satisfies State
|
||||
|
||||
@@ -274,22 +298,59 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
||||
setStore("draft", state)
|
||||
}
|
||||
|
||||
const recent = createMemo(() => models.recent.list().map(models.find).filter(Boolean))
|
||||
const recent = createMemo(() => models.recent.list().filter(validModel).map(models.find).filter(Boolean))
|
||||
const pending = new Map<string, State>()
|
||||
const sameSelection = (a: State, b: State) =>
|
||||
a.agent === b.agent &&
|
||||
a.model?.providerID === b.model?.providerID &&
|
||||
a.model?.modelID === b.model?.modelID &&
|
||||
(a.variant ?? "default") === (b.variant ?? "default")
|
||||
|
||||
const reconcile = (sessionID: string) => {
|
||||
const expected = pending.get(sessionID)
|
||||
const session = data.session.get(sessionID)
|
||||
if (!expected || !session?.model) return
|
||||
if (
|
||||
!sameSelection(expected, {
|
||||
agent: session.agent,
|
||||
model: { providerID: session.model.providerID, modelID: session.model.id },
|
||||
variant: session.model.variant,
|
||||
})
|
||||
)
|
||||
return
|
||||
pending.delete(sessionID)
|
||||
const draft = saved.session[sessionID]
|
||||
if (id() !== sessionID || !draft || !sameSelection(draft, expected)) return
|
||||
setSaved("session", sessionID, { agent: undefined, model: undefined, variant: undefined })
|
||||
}
|
||||
onCleanup(serverSDK.event.on("session.model.selected", (event) => reconcile(event.data.sessionID)))
|
||||
onCleanup(serverSDK.event.on("session.agent.selected", (event) => reconcile(event.data.sessionID)))
|
||||
onCleanup(
|
||||
serverSDK.event.on("session.deleted", (event) => {
|
||||
pending.delete(event.data.sessionID)
|
||||
setSaved("session", event.data.sessionID, undefined)
|
||||
}),
|
||||
)
|
||||
|
||||
const model = {
|
||||
ready: models.ready,
|
||||
ready: Object.assign(() => models.ready() && configuredModel.ready(), { promise: models.ready.promise }),
|
||||
current,
|
||||
recent,
|
||||
list: models.list,
|
||||
trackSessionCommit(sessionID: string, selection: { agent: string; model: ModelKey; variant?: string }) {
|
||||
pending.set(sessionID, selection)
|
||||
reconcile(sessionID)
|
||||
return () => {
|
||||
if (pending.get(sessionID) === selection) pending.delete(sessionID)
|
||||
}
|
||||
},
|
||||
cycle(direction: 1 | -1) {
|
||||
const items = recent()
|
||||
const item = current()
|
||||
if (!item) return
|
||||
|
||||
const index = items.findIndex((entry) => entry?.provider.id === item.provider.id && entry?.id === item.id)
|
||||
if (index === -1) return
|
||||
|
||||
let next = index + direction
|
||||
let next = index === -1 ? (direction === 1 ? 0 : items.length - 1) : index + direction
|
||||
if (next < 0) next = items.length - 1
|
||||
if (next >= items.length) next = 0
|
||||
|
||||
@@ -298,21 +359,18 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
||||
model.set({ providerID: entry.provider.id, modelID: entry.id })
|
||||
},
|
||||
set(item: ModelKey | undefined, options?: { recent?: boolean }) {
|
||||
startTransition(() =>
|
||||
batch(() => {
|
||||
setStore("last", {
|
||||
type: "model",
|
||||
agent: agent.current()?.name,
|
||||
model: item ?? null,
|
||||
variant: selected(),
|
||||
})
|
||||
write({ model: item })
|
||||
if (!item) return
|
||||
models.setVisibility(item, true)
|
||||
if (!options?.recent) return
|
||||
models.recent.push(item)
|
||||
}),
|
||||
)
|
||||
batch(() => {
|
||||
if (item && !validModel(item)) return
|
||||
const previous = current()
|
||||
const same = item && previous?.provider.id === item.providerID && previous.id === item.modelID
|
||||
write({ model: item, variant: same ? (model.variant.current() ?? null) : undefined })
|
||||
if (!item) return
|
||||
// A session draft owns its variant even when preferences change in another session.
|
||||
if (id() && !same) write({ variant: model.variant.current() ?? null })
|
||||
models.setVisibility(item, true)
|
||||
if (!options?.recent) return
|
||||
models.recent.push(item)
|
||||
})
|
||||
},
|
||||
visible(item: ModelKey) {
|
||||
return models.visible(item)
|
||||
@@ -324,16 +382,13 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
||||
configured,
|
||||
selected,
|
||||
current() {
|
||||
const resolved = resolveModelVariant({
|
||||
const model = current()
|
||||
return resolveModelVariant({
|
||||
variants: this.list(),
|
||||
selected: this.selected(),
|
||||
configured: this.configured(),
|
||||
preferred: model ? models.variant.get({ providerID: model.provider.id, modelID: model.id }) : undefined,
|
||||
})
|
||||
if (resolved) return resolved
|
||||
const model = current()
|
||||
if (!model) return
|
||||
const saved = models.variant.get({ providerID: model.provider.id, modelID: model.id })
|
||||
if (saved && this.list().includes(saved)) return saved
|
||||
},
|
||||
list() {
|
||||
const item = current()
|
||||
@@ -341,21 +396,12 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
||||
return Object.keys(item.variants)
|
||||
},
|
||||
set(value: string | undefined) {
|
||||
startTransition(() =>
|
||||
batch(() => {
|
||||
const model = current()
|
||||
setStore("last", {
|
||||
type: "variant",
|
||||
agent: agent.current()?.name,
|
||||
model: model ? { providerID: model.provider.id, modelID: model.id } : null,
|
||||
variant: value ?? null,
|
||||
})
|
||||
write({ variant: value ?? null })
|
||||
if (model) {
|
||||
models.variant.set({ providerID: model.provider.id, modelID: model.id }, value ?? undefined)
|
||||
}
|
||||
}),
|
||||
)
|
||||
batch(() => {
|
||||
const model = current()
|
||||
if (!model) return
|
||||
write({ model: { providerID: model.provider.id, modelID: model.id }, variant: value ?? null })
|
||||
models.variant.set({ providerID: model.provider.id, modelID: model.id }, value)
|
||||
})
|
||||
},
|
||||
cycle() {
|
||||
const items = this.list()
|
||||
@@ -363,8 +409,8 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
||||
this.set(
|
||||
cycleModelVariant({
|
||||
variants: items,
|
||||
selected: this.selected(),
|
||||
configured: this.configured(),
|
||||
selected: this.current() ?? null,
|
||||
configured: undefined,
|
||||
}),
|
||||
)
|
||||
},
|
||||
@@ -383,20 +429,34 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
||||
promote(dir: string, session: string, state?: State) {
|
||||
const next = clone(state ?? snapshot())
|
||||
if (!next) return
|
||||
// Creation already owns the active selection; keep only agent memory once it is in the read model.
|
||||
// Otherwise a first-message command's configured overrides would stay hidden behind this handoff.
|
||||
const created = data.session.get(session)
|
||||
const selection = created?.model
|
||||
const committed =
|
||||
selection &&
|
||||
sameSelection(next, {
|
||||
agent: created.agent,
|
||||
model: { providerID: selection.providerID, modelID: selection.id },
|
||||
variant: selection.variant,
|
||||
})
|
||||
? { choices: next.choices }
|
||||
: next
|
||||
const key = handoffKey(serverSDK.scope, dir, session)
|
||||
handoff.set(key, next)
|
||||
handoff.set(key, committed)
|
||||
|
||||
if (dir === sdk().directory) {
|
||||
setSaved("session", session, next)
|
||||
setSaved("session", session, committed)
|
||||
}
|
||||
|
||||
setStore("promoting", next)
|
||||
setStore("promoting", committed)
|
||||
setStore("draft", undefined)
|
||||
},
|
||||
restore(msg: { sessionID: string; agent: string; model: ModelKey }) {
|
||||
const session = id()
|
||||
if (!session) return
|
||||
if (msg.sessionID !== session) return
|
||||
if (durable()?.model) return
|
||||
if (saved.session[session] !== undefined) return
|
||||
if (handoff.has(handoffKey(serverSDK.scope, sdk().directory, session))) return
|
||||
|
||||
@@ -412,4 +472,5 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
||||
},
|
||||
})
|
||||
|
||||
export type ModelSelection = ReturnType<typeof useLocal>["model"]
|
||||
export type ModelSelection = Omit<ReturnType<typeof useLocal>["model"], "trackSessionCommit"> &
|
||||
Partial<Pick<ReturnType<typeof useLocal>["model"], "trackSessionCommit">>
|
||||
|
||||
@@ -6,13 +6,13 @@ import { Tooltip } from "@opencode/ui/tooltip"
|
||||
import { useDialog } from "@opencode/ui/context/dialog"
|
||||
import { useTheme } from "@opencode/ui/theme"
|
||||
import { createMemo, onCleanup, onMount, type Component, For, Show } from "solid-js"
|
||||
import { useLocal } from "@/providers/models/selection"
|
||||
import { useLocal, type ModelSelection } from "@/providers/models/selection"
|
||||
import { useIntegrations } from "@/providers/catalog/integrations"
|
||||
import { decode64 } from "@/runtime/persistence/base64"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import { ModelTooltip } from "./tooltip"
|
||||
|
||||
type ModelState = ReturnType<typeof useLocal>["model"]
|
||||
type ModelState = ModelSelection
|
||||
const featuredProviders = ["opencode-go", "opencode", "openai", "anthropic", "google", "github-copilot"]
|
||||
const displayModelName = (name: string) => name.replace(/\s+(?:\(free\)|free)$/i, "")
|
||||
|
||||
|
||||
@@ -64,14 +64,14 @@ describe("model variant", () => {
|
||||
expect(value).toBe("xhigh")
|
||||
})
|
||||
|
||||
test("wraps from configured last variant to first", () => {
|
||||
test("cycles from configured last variant to default", () => {
|
||||
const value = cycleModelVariant({
|
||||
variants: ["low", "high", "xhigh"],
|
||||
selected: undefined,
|
||||
configured: "xhigh",
|
||||
})
|
||||
|
||||
expect(value).toBe("low")
|
||||
expect(value).toBeUndefined()
|
||||
})
|
||||
|
||||
test("cycles from an explicit default to the first variant", () => {
|
||||
@@ -83,4 +83,22 @@ describe("model variant", () => {
|
||||
|
||||
expect(value).toBe("low")
|
||||
})
|
||||
|
||||
test("prefers a saved variant to configuration, including explicit Default", () => {
|
||||
const input = { variants: ["low", "high"], selected: undefined, configured: "high" }
|
||||
expect(resolveModelVariant({ ...input, preferred: "low" })).toBe("low")
|
||||
expect(resolveModelVariant({ ...input, preferred: "default" })).toBeUndefined()
|
||||
expect(resolveModelVariant({ ...input, preferred: "low", selected: null })).toBeUndefined()
|
||||
expect(resolveModelVariant({ ...input, preferred: "low", selected: "high" })).toBe("high")
|
||||
expect(cycleModelVariant({ ...input, preferred: "high" })).toBeUndefined()
|
||||
expect(cycleModelVariant({ ...input, preferred: "default" })).toBe("low")
|
||||
})
|
||||
|
||||
test("normalizes unavailable selections instead of silently applying another variant", () => {
|
||||
expect(resolveModelVariant({ variants: ["low"], selected: "high", configured: "low" })).toBeUndefined()
|
||||
expect(
|
||||
resolveModelVariant({ variants: ["low"], selected: undefined, preferred: "high", configured: "low" }),
|
||||
).toBeUndefined()
|
||||
expect(cycleModelVariant({ variants: [], selected: undefined, configured: undefined })).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -16,6 +16,7 @@ type VariantInput = {
|
||||
variants: string[]
|
||||
selected: string | null | undefined
|
||||
configured: string | undefined
|
||||
preferred?: string
|
||||
}
|
||||
|
||||
export function getConfiguredAgentVariant(input: { agent: Agent | undefined; model: Model | undefined }) {
|
||||
@@ -24,29 +25,16 @@ export function getConfiguredAgentVariant(input: { agent: Agent | undefined; mod
|
||||
if (!input.model?.variants) return undefined
|
||||
if (input.agent.model.providerID !== input.model.providerID) return undefined
|
||||
if (input.agent.model.modelID !== input.model.modelID) return undefined
|
||||
if (!(input.agent.variant in input.model.variants)) return undefined
|
||||
return input.agent.variant
|
||||
}
|
||||
|
||||
export function resolveModelVariant(input: VariantInput) {
|
||||
if (input.selected === null) return undefined
|
||||
if (input.selected && input.variants.includes(input.selected)) return input.selected
|
||||
if (input.configured && input.variants.includes(input.configured)) return input.configured
|
||||
return undefined
|
||||
const value = input.selected ?? input.preferred ?? input.configured
|
||||
return value && value !== "default" && input.variants.includes(value) ? value : undefined
|
||||
}
|
||||
|
||||
export function cycleModelVariant(input: VariantInput) {
|
||||
if (input.variants.length === 0) return undefined
|
||||
if (input.selected === null) return input.variants[0]
|
||||
if (input.selected && input.variants.includes(input.selected)) {
|
||||
const index = input.variants.indexOf(input.selected)
|
||||
if (index === input.variants.length - 1) return undefined
|
||||
return input.variants[index + 1]
|
||||
}
|
||||
if (input.configured && input.variants.includes(input.configured)) {
|
||||
const index = input.variants.indexOf(input.configured)
|
||||
if (index === input.variants.length - 1) return input.variants[0]
|
||||
return input.variants[index + 1]
|
||||
}
|
||||
return input.variants[0]
|
||||
const current = resolveModelVariant(input)
|
||||
return input.variants[current ? input.variants.indexOf(current) + 1 : 0]
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -230,3 +230,72 @@ describe("draft store text externalization", () => {
|
||||
expect(JSON.parse(memory.documents.get("doc")!).prompt[0].content.blob.ids).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe("draft store image retention", () => {
|
||||
const image = (byte: number) => new Blob([new Uint8Array(6).fill(byte)], { type: "image/png" })
|
||||
const fresh = (grace = 0) => {
|
||||
const memory = memoryDriver()
|
||||
return { memory, store: createDraftStore(memory.driver, { grace }) }
|
||||
}
|
||||
// Release timers fire on the macrotask queue; a zero grace has fired after one tick.
|
||||
const tick = () => new Promise((resolve) => setTimeout(resolve, 5))
|
||||
// An image with no object URL left gets a new one when its bytes are uploaded again.
|
||||
const released = async (store: ReturnType<typeof createDraftStore>, byte: number, url: string) =>
|
||||
(await store.putBlob(image(byte))).url !== url
|
||||
|
||||
test("an uploaded image no document references is released after the grace", async () => {
|
||||
const { store } = fresh()
|
||||
const orphan = await store.putBlob(image(1))
|
||||
await tick()
|
||||
expect(await released(store, 1, orphan.url)).toBe(true)
|
||||
})
|
||||
|
||||
test("an image referenced within the grace is kept", async () => {
|
||||
const { store } = fresh(50)
|
||||
const pasted = await store.putBlob(image(2))
|
||||
await store.setDocument("pinned", { prompt: [{ type: "image", blob: pasted }] })
|
||||
await new Promise((resolve) => setTimeout(resolve, 80))
|
||||
expect(await released(store, 2, pasted.url)).toBe(false)
|
||||
})
|
||||
|
||||
test("saving a document without an image or removing the document releases it", async () => {
|
||||
const { store } = fresh()
|
||||
const dropped = await store.putBlob(image(3))
|
||||
const removed = await store.putBlob(image(4))
|
||||
await store.setDocument("edited", { prompt: [{ type: "image", blob: dropped }] })
|
||||
await store.setDocument("closed", { prompt: [{ type: "image", blob: removed }] })
|
||||
await tick()
|
||||
expect(await released(store, 3, dropped.url)).toBe(false)
|
||||
expect(await released(store, 4, removed.url)).toBe(false)
|
||||
await store.setDocument("edited", { prompt: [{ type: "text", content: "typed over it" }] })
|
||||
await store.removeItem("closed")
|
||||
await tick()
|
||||
expect(await released(store, 3, dropped.url)).toBe(true)
|
||||
expect(await released(store, 4, removed.url)).toBe(true)
|
||||
})
|
||||
|
||||
test("an image referenced by two documents survives until both drop it", async () => {
|
||||
const { store } = fresh()
|
||||
const shared = await store.putBlob(image(5))
|
||||
await store.setDocument("composer", { prompt: [{ type: "image", blob: shared }] })
|
||||
await store.setDocument("history", { entries: [{ prompt: [{ type: "image", blob: shared }] }] })
|
||||
await store.setDocument("composer", { prompt: [] })
|
||||
await tick()
|
||||
expect(await released(store, 5, shared.url)).toBe(false)
|
||||
await store.setDocument("history", { entries: [] })
|
||||
await tick()
|
||||
expect(await released(store, 5, shared.url)).toBe(true)
|
||||
})
|
||||
|
||||
test("loading a document pins the images it references", async () => {
|
||||
const { memory, store } = fresh()
|
||||
const id = await memory.driver.putBlob(image(6))
|
||||
memory.documents.set("loaded", JSON.stringify({ prompt: [{ type: "image", blob: { id } }] }))
|
||||
const url = JSON.parse((await store.getItem("loaded"))!).prompt[0].blob.url
|
||||
await tick()
|
||||
expect(await released(store, 6, url)).toBe(false)
|
||||
await store.removeItem("loaded")
|
||||
await tick()
|
||||
expect(await released(store, 6, url)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -28,23 +28,77 @@ export const draftTextThreshold = 16 * 1024
|
||||
export const draftTextChunk = 64 * 1024
|
||||
const textCacheLimit = 64
|
||||
|
||||
const urls = new Map<string, string>()
|
||||
// The object URL already pins the Blob for the page's lifetime; keeping the Blob itself lets a
|
||||
// collected image be uploaded again without fetching the URL.
|
||||
const held = new Map<string, Blob>()
|
||||
// Decoded image bytes the renderer pins through object URLs. Every consumer of a `blob.url` is a
|
||||
// persisted draft document (composer prompt, prompt history), so an image is pinned exactly while a
|
||||
// stored document references it. Once the last reference disappears (removed from a draft, sent, or
|
||||
// a discarded duplicate paste) the URL is revoked after this grace, which covers the persist delay
|
||||
// between a paste and the save that references it, and the submit → history handoff.
|
||||
export const retainedBlobGrace = 30_000
|
||||
|
||||
type Retained = { blob: Blob; url: string; release: ReturnType<typeof setTimeout> | undefined }
|
||||
const retained = new Map<string, Retained>()
|
||||
// Document keys that reference each image id; an id with no keys is released after the grace.
|
||||
const refs = new Map<string, Set<string>>()
|
||||
// Image ids that were restored under a different id (a store without WebCrypto assigns fresh
|
||||
// ones); live references still carry the original.
|
||||
const aliases = new Map<string, string>()
|
||||
|
||||
function blobUrl(id: string, blob: Blob) {
|
||||
const existing = urls.get(id)
|
||||
if (existing) return existing
|
||||
function blobUrl(id: string, blob: Blob, grace?: number) {
|
||||
const existing = retained.get(id)
|
||||
if (existing) return existing.url
|
||||
const url = URL.createObjectURL(blob)
|
||||
urls.set(id, url)
|
||||
held.set(id, blob)
|
||||
// Without a grace the image has no store to reference it from and stays for the page's lifetime.
|
||||
const release = grace === undefined || refs.get(id)?.size ? undefined : setTimeout(() => revoke(id), grace)
|
||||
retained.set(id, { blob, url, release })
|
||||
return url
|
||||
}
|
||||
|
||||
// Record which image ids `key` now references; ids it dropped are released once no other document
|
||||
// references them, ids it gained stay pinned.
|
||||
function retain(key: string, ids: ReadonlySet<string>, grace: number) {
|
||||
for (const [id, keys] of refs) {
|
||||
if (ids.has(id) || !keys.delete(key) || keys.size) continue
|
||||
refs.delete(id)
|
||||
const entry = retained.get(id)
|
||||
if (entry) entry.release = setTimeout(() => revoke(id), grace)
|
||||
}
|
||||
for (const id of ids) {
|
||||
const keys = refs.get(id) ?? new Set<string>()
|
||||
keys.add(key)
|
||||
refs.set(id, keys)
|
||||
const entry = retained.get(id)
|
||||
if (!entry) continue
|
||||
clearTimeout(entry.release)
|
||||
entry.release = undefined
|
||||
}
|
||||
}
|
||||
|
||||
function revoke(id: string) {
|
||||
const entry = retained.get(id)
|
||||
if (!entry) return
|
||||
URL.revokeObjectURL(entry.url)
|
||||
retained.delete(id)
|
||||
for (const [from, to] of aliases) if (to === id) aliases.delete(from)
|
||||
}
|
||||
|
||||
// Image ids a document references: `{ blob: { id } }` parts, not text chunk lists.
|
||||
function imageIDs(value: unknown, into = new Set<string>()): Set<string> {
|
||||
if (Array.isArray(value)) {
|
||||
value.forEach((entry) => imageIDs(entry, into))
|
||||
return into
|
||||
}
|
||||
if (!value || typeof value !== "object") return into
|
||||
const item = value as Record<string, unknown>
|
||||
const blob = item.blob
|
||||
if (blob && typeof blob === "object" && !("kind" in blob)) {
|
||||
const id = (blob as Record<string, unknown>).id
|
||||
if (typeof id === "string") into.add(id)
|
||||
return into
|
||||
}
|
||||
Object.values(item).forEach((entry) => imageIDs(entry, into))
|
||||
return into
|
||||
}
|
||||
|
||||
async function blobID(blob: Blob) {
|
||||
const bytes = crypto.subtle
|
||||
? new Uint8Array(await crypto.subtle.digest("SHA-256", await blob.arrayBuffer()))
|
||||
@@ -60,24 +114,25 @@ export async function createBlobReference(blob: Blob): Promise<BlobReference> {
|
||||
return { id, url: blobUrl(id, blob) }
|
||||
}
|
||||
|
||||
export function createDraftStore(driver: Driver): DraftStore {
|
||||
export function createDraftStore(driver: Driver, options: { grace?: number } = {}): DraftStore {
|
||||
const grace = options.grace ?? retainedBlobGrace
|
||||
const versions = new Map<string, number>()
|
||||
const loading = new Map<string, Promise<string | undefined>>()
|
||||
const loadBlobUrl = (id: string) => {
|
||||
const existing = urls.get(id)
|
||||
if (existing) return existing
|
||||
const existing = retained.get(id)
|
||||
if (existing) return existing.url
|
||||
const pending = loading.get(id)
|
||||
if (pending) return pending
|
||||
const next = driver
|
||||
.getBlob(id)
|
||||
.then((blob) => (blob ? blobUrl(id, blob) : undefined))
|
||||
.then((blob) => (blob ? blobUrl(id, blob, grace) : undefined))
|
||||
.finally(() => loading.delete(id))
|
||||
loading.set(id, next)
|
||||
return next
|
||||
}
|
||||
const putBlob = async (blob: Blob) => {
|
||||
const id = await driver.putBlob(blob)
|
||||
return { id, url: blobUrl(id, blob) }
|
||||
return { id, url: blobUrl(id, blob, grace) }
|
||||
}
|
||||
// Keyed by chunk content so unchanged chunks are never hashed or sent again while the draft is
|
||||
// edited. Bounded because each entry pins up to draftTextChunk characters. A hit is safe even if
|
||||
@@ -145,8 +200,8 @@ export function createDraftStore(driver: Driver): DraftStore {
|
||||
if (typeof blob.id === "string") {
|
||||
// A live reference keeps the id it was created with; publish the id its bytes now live under.
|
||||
const id = aliases.get(blob.id) ?? blob.id
|
||||
const kept = held.get(id)
|
||||
const url = typeof blob.url === "string" ? blob.url : urls.get(id)
|
||||
const kept = retained.get(id)?.blob
|
||||
const url = typeof blob.url === "string" ? blob.url : retained.get(id)?.url
|
||||
if (kept) sources.set(id, { blob: async () => kept })
|
||||
else if (url) sources.set(id, { blob: () => fetch(url).then((response) => response.blob()) })
|
||||
return { ...item, blob: { id } }
|
||||
@@ -192,8 +247,7 @@ export function createDraftStore(driver: Driver): DraftStore {
|
||||
remember(chunks, next, source.chunk)
|
||||
return
|
||||
}
|
||||
held.set(next, blob)
|
||||
blobUrl(next, blob)
|
||||
blobUrl(next, blob, grace)
|
||||
if (next === id) return
|
||||
// Later encodes of the still-live reference resolve straight to the new id. Re-point any
|
||||
// earlier alias chain so lookups stay one step.
|
||||
@@ -225,14 +279,19 @@ export function createDraftStore(driver: Driver): DraftStore {
|
||||
// stays visible until the bytes are back. Covers a blob collected while a cache, another tab,
|
||||
// or the composer's history still held its id.
|
||||
const missing = await driver.set(key, JSON.stringify(encoded), true)
|
||||
if (missing.length === 0) return
|
||||
if (missing.length === 0) {
|
||||
retain(key, imageIDs(encoded), grace)
|
||||
return
|
||||
}
|
||||
const renamed = await restore(missing, sources)
|
||||
if (versions.get(key) !== version) return
|
||||
const unrestored = missing.filter((id) => !renamed.has(id))
|
||||
if (unrestored.length)
|
||||
console.error(`[persistence] draft ${key} references blobs with no bytes to restore`, unrestored)
|
||||
// Anything still missing has no bytes anywhere; the owning codec drops such references on read.
|
||||
await driver.set(key, JSON.stringify(rename(encoded, renamed)), false)
|
||||
const final = rename(encoded, renamed)
|
||||
await driver.set(key, JSON.stringify(final), false)
|
||||
retain(key, imageIDs(final), grace)
|
||||
}
|
||||
return {
|
||||
getItem: async (key) => {
|
||||
@@ -241,6 +300,8 @@ export function createDraftStore(driver: Driver): DraftStore {
|
||||
const parsed = Schema.decodeUnknownOption(Schema.fromJsonString(Schema.Unknown))(value)
|
||||
// Let the owning persistence codec apply its invalid-document policy.
|
||||
if (Option.isNone(parsed)) return value
|
||||
// A loaded document is live in the composer: pin its images before decode mints their URLs.
|
||||
retain(key, imageIDs(parsed.value), grace)
|
||||
return JSON.stringify(await decode(parsed.value))
|
||||
},
|
||||
setItem: (key, value) => setDocument(key, JSON.parse(value)),
|
||||
@@ -248,6 +309,7 @@ export function createDraftStore(driver: Driver): DraftStore {
|
||||
removeItem: async (key) => {
|
||||
versions.set(key, (versions.get(key) ?? 0) + 1)
|
||||
await driver.remove(key)
|
||||
retain(key, new Set(), grace)
|
||||
},
|
||||
putBlob,
|
||||
}
|
||||
@@ -360,7 +422,8 @@ function referenced(json: string) {
|
||||
}
|
||||
|
||||
export async function blobDataUrl(blob: BlobReference, mime: string) {
|
||||
const data = await fetch(blob.url).then((response) => response.blob())
|
||||
const kept = retained.get(aliases.get(blob.id) ?? blob.id)
|
||||
const data = kept ? kept.blob : await fetch(blob.url).then((response) => response.blob())
|
||||
return new Promise<string>((resolve, reject) => {
|
||||
const reader = new FileReader()
|
||||
reader.addEventListener("error", () => reject(reader.error))
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -20,7 +20,7 @@ import { useComposerCommands } from "@/composer/commands"
|
||||
import { useSessionCommands } from "../commands/use-session-commands"
|
||||
import type { SessionModel } from "../model"
|
||||
import type { SessionScreenLayout } from "../screen-layout"
|
||||
import { restorePromptModel, syncPromptModel, syncSessionModel } from "../session-model-helpers"
|
||||
import { syncPromptModel, syncSessionModel } from "../session-model-helpers"
|
||||
import type { SessionTimelineInteraction } from "../timeline/interaction"
|
||||
import { createSessionRevert } from "../revert"
|
||||
import { SessionComposerRegion } from "./session-composer-region"
|
||||
@@ -62,14 +62,10 @@ export function createActiveSessionRegion(input: {
|
||||
},
|
||||
),
|
||||
)
|
||||
let restoredModelSession: string | undefined
|
||||
createEffect(() => {
|
||||
const id = input.session.identity.params.id
|
||||
if (!id || !prompt.ready() || !local.session.ready()) return
|
||||
if (restoredModelSession !== id) {
|
||||
restoredModelSession = id
|
||||
if (restorePromptModel(local, prompt)) return
|
||||
}
|
||||
// Prompt model is a submission mirror. Local drafts and durable session state own selection.
|
||||
syncPromptModel(local, prompt)
|
||||
})
|
||||
createEffect(
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { resetSessionModel, restorePromptModel, syncPromptModel, syncSessionModel } from "./session-model-helpers"
|
||||
import { resetSessionModel, syncPromptModel, syncSessionModel } from "./session-model-helpers"
|
||||
|
||||
const message = (input?: { agent?: string; model?: { providerID: string; modelID: string; variant?: string } }) => ({
|
||||
sessionID: "session",
|
||||
@@ -54,8 +54,7 @@ describe("syncPromptModel", () => {
|
||||
{
|
||||
model: {
|
||||
current: () => ({ id: "claude-sonnet-4", provider: { id: "anthropic" } }),
|
||||
set() {},
|
||||
variant: { current: () => "high", set() {} },
|
||||
variant: { current: () => "high" },
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -77,8 +76,7 @@ describe("syncPromptModel", () => {
|
||||
{
|
||||
model: {
|
||||
current: () => ({ id: model.modelID, provider: { id: model.providerID } }),
|
||||
set() {},
|
||||
variant: { current: () => model.variant, set() {} },
|
||||
variant: { current: () => model.variant },
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -93,54 +91,26 @@ describe("syncPromptModel", () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe("restorePromptModel", () => {
|
||||
test("restores the persisted prompt model into session selection", () => {
|
||||
describe("stale prompt model", () => {
|
||||
test("replaces the submission mirror without changing the effective selection", () => {
|
||||
const calls: unknown[] = []
|
||||
const restored = restorePromptModel(
|
||||
syncPromptModel(
|
||||
{
|
||||
model: {
|
||||
current: () => ({ id: "gpt", provider: { id: "openai" } }),
|
||||
set: (model) => calls.push(model),
|
||||
variant: {
|
||||
current: () => undefined,
|
||||
set: (variant) => calls.push(variant),
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
model: {
|
||||
current: () => ({ providerID: "anthropic", modelID: "claude", variant: "high" }),
|
||||
set() {},
|
||||
set: (value) => calls.push(value),
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
expect(restored).toBe(true)
|
||||
expect(calls).toEqual([{ providerID: "anthropic", modelID: "claude" }, "high"])
|
||||
})
|
||||
|
||||
test("does nothing without a persisted prompt model", () => {
|
||||
const calls: unknown[] = []
|
||||
const restored = restorePromptModel(
|
||||
{
|
||||
model: {
|
||||
current: () => ({ id: "gpt", provider: { id: "openai" } }),
|
||||
set: (model) => calls.push(model),
|
||||
variant: {
|
||||
current: () => undefined,
|
||||
set: (variant) => calls.push(variant),
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
model: {
|
||||
current: () => undefined,
|
||||
set() {},
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
expect(restored).toBe(false)
|
||||
expect(calls).toEqual([])
|
||||
expect(calls).toEqual([{ providerID: "openai", modelID: "gpt", variant: undefined }])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -12,10 +12,8 @@ type Local = {
|
||||
type ModelSelection = {
|
||||
model: {
|
||||
current(): { id: string; provider: { id: string } } | undefined
|
||||
set(model: { providerID: string; modelID: string }): void
|
||||
variant: {
|
||||
current(): string | undefined
|
||||
set(variant: string | undefined): void
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -51,18 +49,3 @@ export const syncPromptModel = (local: ModelSelection, prompt: PromptState) => {
|
||||
return
|
||||
prompt.model.set(next)
|
||||
}
|
||||
|
||||
export const restorePromptModel = (local: ModelSelection, prompt: PromptState) => {
|
||||
const model = prompt.model.current()
|
||||
if (!model) return false
|
||||
const current = local.model.current()
|
||||
if (
|
||||
current?.provider.id === model.providerID &&
|
||||
current.id === model.modelID &&
|
||||
local.model.variant.current() === (model.variant ?? undefined)
|
||||
)
|
||||
return true
|
||||
local.model.set({ providerID: model.providerID, modelID: model.modelID })
|
||||
local.model.variant.set(model.variant ?? undefined)
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -63,6 +63,36 @@ describe("SerializeAddon", () => {
|
||||
}
|
||||
})
|
||||
|
||||
describe("scrollback option", () => {
|
||||
test("reads only the requested tail and restores the cursor on its screen row", async () => {
|
||||
const { term, addon } = createTerminal(20, 5)
|
||||
await writeAndWait(term, Array.from({ length: 30 }, (_, i) => `line ${i}`).join("\r\n"))
|
||||
await writeAndWait(term, "\x1b[2A\x1b[3G")
|
||||
expect(term.buffer.normal.length).toBe(30)
|
||||
expect([term.buffer.normal.cursorX, term.buffer.normal.cursorY]).toEqual([2, 2])
|
||||
|
||||
const reads = spyOn(term.buffer.normal, "getLine")
|
||||
const serialized = addon.serialize({ scrollback: 3 })
|
||||
expect(new Set(reads.mock.calls.map((args) => args[0]))).toEqual(new Set([22, 23, 24, 25, 26, 27, 28, 29]))
|
||||
reads.mockRestore()
|
||||
|
||||
const restored = createTerminal(20, 5)
|
||||
await writeAndWait(restored.term, serialized)
|
||||
expect(restored.term.getScrollbackLength()).toBe(3)
|
||||
for (let row = 0; row < 8; row++) {
|
||||
expect(restored.term.buffer.normal.getLine(row)?.translateToString(true)).toBe(`line ${22 + row}`)
|
||||
}
|
||||
expect([restored.term.buffer.normal.cursorX, restored.term.buffer.normal.cursorY]).toEqual([2, 2])
|
||||
})
|
||||
|
||||
test("serializes the whole buffer when it has fewer rows than requested", async () => {
|
||||
const { term, addon } = createTerminal(20, 5)
|
||||
await writeAndWait(term, Array.from({ length: 30 }, (_, i) => `line ${i}`).join("\r\n"))
|
||||
|
||||
expect(addon.serialize({ scrollback: 100 })).toBe(addon.serialize())
|
||||
})
|
||||
})
|
||||
|
||||
test("preserves color scheme reporting mode", async () => {
|
||||
const { term, addon } = createTerminal()
|
||||
await writeAndWait(term, "\x1b[?2031h")
|
||||
|
||||
@@ -481,12 +481,12 @@ class StringSerializeHandler extends BaseSerializeHandler {
|
||||
|
||||
if (excludeFinalCursorPosition) return content
|
||||
|
||||
const absoluteCursorRow = (this._buffer.baseY ?? 0) + this._buffer.cursorY
|
||||
const cursorRow = constrain(absoluteCursorRow - this._firstRow + 1, 1, Number.MAX_SAFE_INTEGER)
|
||||
const cursorCol = this._buffer.cursorX + 1
|
||||
content += `\u001b[${cursorRow};${cursorCol}H`
|
||||
// CUP addresses the screen and ghostty-web reports cursorY relative to the screen, so the
|
||||
// serialized range start must not shift the row. The cursor line sits in the screen region
|
||||
// at the bottom of the buffer, after any scrollback rows.
|
||||
content += `\u001b[${this._buffer.cursorY + 1};${this._buffer.cursorX + 1}H`
|
||||
|
||||
const line = this._buffer.getLine(absoluteCursorRow)
|
||||
const line = this._buffer.getLine(this._buffer.length - this._terminal.rows + this._buffer.cursorY)
|
||||
const cell = line?.getCell(this._buffer.cursorX)
|
||||
const style = (() => {
|
||||
if (!cell) return this._buffer.getNullCell()
|
||||
|
||||
@@ -20,6 +20,12 @@ import { terminalWriter } from "@/session/terminal/writer"
|
||||
|
||||
const TOGGLE_TERMINAL_ID = "terminal.toggle"
|
||||
const DEFAULT_TOGGLE_TERMINAL_KEYBIND = "ctrl+`"
|
||||
// Serialization on unmount is a synchronous O(rows x cols) walk on the main thread and the
|
||||
// result is written to localStorage or desktop state for every terminal in the workspace.
|
||||
// Persisting the most recent 2k scrollback rows keeps restore fidelity for the history users
|
||||
// actually scroll back through while capping teardown cost and snapshot size; the live
|
||||
// terminal keeps its full 10k scrollback while mounted.
|
||||
const persistedScrollbackRows = 2_000
|
||||
export interface TerminalProps extends ComponentProps<"div"> {
|
||||
pty: LocalPTY
|
||||
autoFocus?: boolean
|
||||
@@ -152,7 +158,7 @@ const persistTerminal = (input: {
|
||||
if (!input.addon || !input.onCleanup || !input.term) return
|
||||
const buffer = (() => {
|
||||
try {
|
||||
return input.addon.serialize()
|
||||
return input.addon.serialize({ scrollback: persistedScrollbackRows })
|
||||
} catch {
|
||||
debugTerminal("failed to serialize terminal buffer")
|
||||
return ""
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -0,0 +1,459 @@
|
||||
import { afterEach, expect, mock, test } from "bun:test"
|
||||
import { createRequire } from "node:module"
|
||||
import { createComponent, createRoot } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import type { Agent } from "@/runtime/server/types"
|
||||
import type { ModelKey } from "@/providers/models/selection"
|
||||
import { ServerScope } from "@/runtime/server/scope"
|
||||
|
||||
// Bun does not compile Solid JSX. Compile the real context provider with the
|
||||
// same presets as Vite instead of replacing LocalProvider's implementation.
|
||||
const require = createRequire(import.meta.url)
|
||||
const solid = createRequire(require.resolve("vite-plugin-solid"))
|
||||
const { transformSync } = solid("@babel/core")
|
||||
Bun.plugin({
|
||||
name: "selection-solid-context",
|
||||
setup(build) {
|
||||
build.onLoad({ filter: /[\\/]ui[\\/]src[\\/]context[\\/]helper\.tsx$/ }, async (args) => ({
|
||||
contents: transformSync(await Bun.file(args.path).text(), {
|
||||
filename: args.path,
|
||||
presets: [solid.resolve("babel-preset-solid"), solid.resolve("@babel/preset-typescript")],
|
||||
}).code,
|
||||
loader: "js",
|
||||
}))
|
||||
},
|
||||
})
|
||||
|
||||
type Commit = { agent?: string; model?: { providerID: string; id: string; variant?: string } }
|
||||
type Event = { data: { sessionID: string } }
|
||||
type ConfigModel = string | { providerID: string; model: string; variant?: string }
|
||||
const key = (modelID: string): ModelKey => ({ providerID: "provider", modelID })
|
||||
const durable = (modelID: string, variant?: string, agent = "build"): Commit => ({
|
||||
agent,
|
||||
model: { providerID: "provider", id: modelID, variant },
|
||||
})
|
||||
const agent = (name: string, model?: ModelKey, variant?: string): Agent => ({
|
||||
name,
|
||||
mode: "primary",
|
||||
permission: [],
|
||||
options: {},
|
||||
model,
|
||||
variant,
|
||||
})
|
||||
|
||||
let active: ReturnType<typeof fixture>
|
||||
mock.module("@solidjs/router", () => ({ useParams: () => active.state.route }))
|
||||
mock.module("@/runtime/server/current", () => ({ useData: () => active.data }))
|
||||
mock.module("@/runtime/server/client", () => ({ useServerSDK: () => active.sdk }))
|
||||
mock.module("@/runtime/server/runtime", () => ({ useGlobal: () => ({ models: active.preferences }) }))
|
||||
mock.module("@/workspaces/location", () => ({ useWorkspaceLocation: () => () => ({ directory: active.directory }) }))
|
||||
mock.module("@/settings/model", () => ({
|
||||
useSettings: () => ({ visibility: { customAgents: () => active.state.visible } }),
|
||||
}))
|
||||
mock.module("@/composer/persistence", () => ({ useComposerState: () => active.prompt }))
|
||||
mock.module("@/shell/state/layout", () => ({ useLayout: () => undefined }))
|
||||
mock.module("@/runtime/platform/platform", () => ({
|
||||
usePlatform: () => ({
|
||||
platform: "web",
|
||||
openExternal() {},
|
||||
restart: async () => {},
|
||||
notify: async () => {},
|
||||
}),
|
||||
}))
|
||||
|
||||
const { LocalProvider, useLocal } = await import("@/providers/models/selection")
|
||||
const { ModelsProvider } = await import("@/providers/models/models")
|
||||
const { Persist } = await import("@/runtime/persistence/storage")
|
||||
const { createMemoryComposerState } = await import("@/composer/state")
|
||||
const { createComposerModelSelection } = await import("@/composer/selection")
|
||||
|
||||
const cleanups: Array<() => void> = []
|
||||
afterEach(() =>
|
||||
cleanups
|
||||
.splice(0)
|
||||
.reverse()
|
||||
.forEach((dispose) => dispose()),
|
||||
)
|
||||
|
||||
function fixture(input: { session?: Commit; agents?: Agent[]; config?: ConfigModel; preferred?: string } = {}) {
|
||||
const directory = `/selection-test/${crypto.randomUUID()}`
|
||||
const [state, set] = createStore({
|
||||
visible: true,
|
||||
configLoaded: true,
|
||||
connection: "connected",
|
||||
route: { id: "ses_a" as string | undefined },
|
||||
agents: input.agents ?? [agent("build"), agent("plan")],
|
||||
config: input.config as ConfigModel | undefined,
|
||||
sessions: { ses_a: input.session } as Record<string, Commit | undefined>,
|
||||
providers: [{ id: "provider", name: "Provider", package: "@ai-sdk/test", activation: "enabled" }],
|
||||
models: ["a", "b", "c"].map((id) => ({
|
||||
id,
|
||||
modelID: id,
|
||||
providerID: "provider",
|
||||
name: `Model ${id}`,
|
||||
settings: {},
|
||||
headers: {},
|
||||
capabilities: { tools: true, input: ["text"], output: ["text"] },
|
||||
variants: ["low", "high"].map((id) => ({ id, settings: {} })),
|
||||
time: { released: 1 },
|
||||
cost: [],
|
||||
status: "active",
|
||||
enabled: true,
|
||||
limit: { context: 128_000, output: 8192 },
|
||||
})),
|
||||
})
|
||||
const [preferences, setPreferences] = createStore({
|
||||
user: [] as Array<ModelKey & { visibility: "show" | "hide" }>,
|
||||
recent: [] as ModelKey[],
|
||||
variant: (input.preferred ? { "provider/a": input.preferred } : {}) as Record<string, string>,
|
||||
})
|
||||
const events = new Map<string, Set<(event: Event) => void>>()
|
||||
const configLoads: string[] = []
|
||||
const result = {
|
||||
prompt: createMemoryComposerState(),
|
||||
directory,
|
||||
state,
|
||||
set,
|
||||
setPreferences,
|
||||
preferences: { store: preferences, set: setPreferences, ready: () => true, recent: () => preferences.recent },
|
||||
data: {
|
||||
session: { get: (id: string) => state.sessions[id] },
|
||||
location: {
|
||||
agent: { list: () => state.agents },
|
||||
config: {
|
||||
list: () => (state.configLoaded ? [{ type: "document", info: { model: state.config } }] : undefined),
|
||||
sync: async () => {
|
||||
configLoads.push(state.connection)
|
||||
},
|
||||
},
|
||||
provider: { list: () => state.providers },
|
||||
model: { list: () => state.models },
|
||||
integration: { list: () => [] },
|
||||
},
|
||||
},
|
||||
sdk: {
|
||||
scope: ServerScope.local,
|
||||
connection: { status: () => state.connection },
|
||||
event: {
|
||||
on(type: string, handler: (event: Event) => void) {
|
||||
const handlers = events.get(type) ?? new Set()
|
||||
events.set(type, handlers)
|
||||
handlers.add(handler)
|
||||
return () => handlers.delete(handler)
|
||||
},
|
||||
},
|
||||
},
|
||||
emit(type: string, sessionID = "ses_a") {
|
||||
events.get(type)?.forEach((handler) => handler({ data: { sessionID } }))
|
||||
},
|
||||
configLoads,
|
||||
mount(draft = false) {
|
||||
active = result
|
||||
let local!: ReturnType<typeof useLocal>
|
||||
let composer: ReturnType<typeof createComposerModelSelection> | undefined
|
||||
const dispose = createRoot((dispose) => {
|
||||
createComponent(ModelsProvider, {
|
||||
directory,
|
||||
get children() {
|
||||
return createComponent(LocalProvider, {
|
||||
get children() {
|
||||
local = useLocal()
|
||||
if (draft) composer = createComposerModelSelection({ agent: local.agent.current })
|
||||
return null
|
||||
},
|
||||
})
|
||||
},
|
||||
})
|
||||
return dispose
|
||||
})
|
||||
cleanups.push(dispose)
|
||||
return { local, composer, dispose }
|
||||
},
|
||||
}
|
||||
const target = Persist.serverWorkspace(ServerScope.local, directory, "model-selection")
|
||||
cleanups.push(() => localStorage.removeItem(`${target.storage}:${target.key}`))
|
||||
return result
|
||||
}
|
||||
|
||||
function selection(local: ReturnType<typeof useLocal>) {
|
||||
return {
|
||||
agent: local.agent.current()?.name,
|
||||
model: local.model.current()?.id,
|
||||
variant: local.model.variant.current(),
|
||||
}
|
||||
}
|
||||
|
||||
test("restores durable agents even when the agent selector is hidden", () => {
|
||||
const f = fixture({ session: durable("b", "high", "plan") })
|
||||
f.set("visible", false)
|
||||
const { local } = f.mount()
|
||||
expect(local.agent.visible()).toBe(false)
|
||||
expect(selection(local)).toEqual({ agent: "plan", model: "b", variant: "high" })
|
||||
})
|
||||
|
||||
test("waits for initial configuration and reloads it after reconnecting", () => {
|
||||
const f = fixture({ config: "provider/b" })
|
||||
f.set("configLoaded", false)
|
||||
const { local } = f.mount()
|
||||
expect(local.model.ready()).toBe(false)
|
||||
expect(local.model.current()).toBeUndefined()
|
||||
f.set("connection", "reconnecting")
|
||||
f.set("connection", "connected")
|
||||
expect(f.configLoads).toEqual(["connected", "reconnecting", "connected"])
|
||||
f.set("configLoaded", true)
|
||||
expect(local.model.ready()).toBe(true)
|
||||
expect(local.model.current()?.id).toBe("b")
|
||||
})
|
||||
|
||||
test("new-session promotion does not mask a command's durable overrides", () => {
|
||||
const f = fixture({ session: durable("a", "low") })
|
||||
const { local } = f.mount()
|
||||
local.session.promote(f.directory, "ses_a", { agent: "build", model: key("a"), variant: "low" })
|
||||
f.set("sessions", "ses_a", durable("b", "high", "plan"))
|
||||
expect(selection(local)).toEqual({ agent: "plan", model: "b", variant: "high" })
|
||||
})
|
||||
|
||||
test("new-session drafts remember each agent's model and hand off inactive choices", () => {
|
||||
const f = fixture({ agents: [agent("build", key("a")), agent("plan", key("b"))] })
|
||||
f.set("route", "id", undefined)
|
||||
const { local, composer } = f.mount(true)
|
||||
if (!composer) throw new Error("missing draft composer")
|
||||
composer.set(key("c"))
|
||||
composer.variant.set("high")
|
||||
local.agent.set("plan")
|
||||
expect(composer.current()?.id).toBe("b")
|
||||
expect(composer.variant.current()).toBeUndefined()
|
||||
local.agent.set("build")
|
||||
expect(composer.current()?.id).toBe("c")
|
||||
expect(composer.variant.current()).toBe("high")
|
||||
local.agent.set("plan")
|
||||
const choices = composer.remembered()
|
||||
f.set("sessions", "ses_a", durable("b", undefined, "plan"))
|
||||
local.session.promote(f.directory, "ses_a", { agent: "plan", model: key("b"), choices })
|
||||
f.set("route", "id", "ses_a")
|
||||
local.agent.set("build")
|
||||
expect(local.model.current()?.id).toBe("c")
|
||||
})
|
||||
|
||||
test("session model picks snapshot the variant rather than following another session's preferences", () => {
|
||||
const f = fixture({ session: durable("a") })
|
||||
const { local } = f.mount()
|
||||
f.setPreferences("variant", "provider/b", "low")
|
||||
local.model.set(key("b"))
|
||||
f.setPreferences("variant", "provider/b", "high")
|
||||
expect(local.model.variant.current()).toBe("low")
|
||||
})
|
||||
|
||||
test("remembers distinct variants for agents using the same model, scoped to the session", () => {
|
||||
const f = fixture({ session: durable("a", "low"), agents: [agent("build", key("a")), agent("plan", key("a"))] })
|
||||
const { local } = f.mount()
|
||||
local.model.variant.set("high")
|
||||
local.agent.set("plan")
|
||||
local.model.variant.set("low")
|
||||
local.agent.set("build")
|
||||
expect(selection(local)).toEqual({ agent: "build", model: "a", variant: "high" })
|
||||
local.agent.set("plan")
|
||||
expect(selection(local)).toEqual({ agent: "plan", model: "a", variant: "low" })
|
||||
|
||||
f.set("sessions", "ses_b", durable("b", "high"))
|
||||
f.set("route", "id", "ses_b")
|
||||
expect(selection(local)).toEqual({ agent: "build", model: "b", variant: "high" })
|
||||
f.set("route", "id", "ses_a")
|
||||
expect(selection(local)).toEqual({ agent: "plan", model: "a", variant: "low" })
|
||||
})
|
||||
|
||||
test("restores each agent's model and variant after provider remount", () => {
|
||||
const f = fixture({ agents: [agent("build", key("a")), agent("plan", key("b"))] })
|
||||
const first = f.mount()
|
||||
first.local.model.variant.set("high")
|
||||
first.local.agent.set("plan")
|
||||
first.local.model.set(key("c"))
|
||||
first.local.model.variant.set("low")
|
||||
first.dispose()
|
||||
const { local } = f.mount()
|
||||
expect(selection(local)).toEqual({ agent: "plan", model: "c", variant: "low" })
|
||||
local.agent.set("build")
|
||||
expect(selection(local)).toEqual({ agent: "build", model: "a", variant: "high" })
|
||||
local.agent.set("plan")
|
||||
expect(selection(local)).toEqual({ agent: "plan", model: "c", variant: "low" })
|
||||
})
|
||||
|
||||
test("changing models drops the old variant even when both models support it", () => {
|
||||
const f = fixture({ session: durable("a", "high") })
|
||||
const { local } = f.mount()
|
||||
local.model.set(key("b"))
|
||||
expect(selection(local)).toEqual({ agent: "build", model: "b", variant: undefined })
|
||||
f.setPreferences("variant", "provider/c", "low")
|
||||
local.model.set(key("c"))
|
||||
expect(local.model.variant.current()).toBe("low")
|
||||
})
|
||||
|
||||
test("restores durable selection ahead of agent, global, and saved variant defaults", () => {
|
||||
const f = fixture({
|
||||
session: durable("a", "low", "plan"),
|
||||
agents: [agent("build"), agent("plan", key("b"), "high")],
|
||||
config: { providerID: "provider", model: "c", variant: "high" },
|
||||
preferred: "high",
|
||||
})
|
||||
const { local } = f.mount()
|
||||
expect(selection(local)).toEqual({ agent: "plan", model: "a", variant: "low" })
|
||||
local.session.restore({ sessionID: "ses_a", agent: "build", model: key("c") })
|
||||
expect(selection(local)).toEqual({ agent: "plan", model: "a", variant: "low" })
|
||||
})
|
||||
|
||||
test("uses historical message selection only when durable and local selection are absent", () => {
|
||||
const f = fixture()
|
||||
const { local } = f.mount()
|
||||
local.session.restore({ sessionID: "ses_b", agent: "plan", model: key("b") })
|
||||
expect(selection(local)).toEqual({ agent: "build", model: "a", variant: undefined })
|
||||
local.session.restore({ sessionID: "ses_a", agent: "plan", model: { ...key("b"), variant: "low" } })
|
||||
expect(selection(local)).toEqual({ agent: "plan", model: "b", variant: "low" })
|
||||
local.model.variant.set("high")
|
||||
local.session.restore({ sessionID: "ses_a", agent: "build", model: key("c") })
|
||||
expect(selection(local)).toEqual({ agent: "plan", model: "b", variant: "high" })
|
||||
})
|
||||
|
||||
test("invalid durable models fall through agent, global, recent, and connected defaults", () => {
|
||||
const f = fixture({
|
||||
session: durable("removed", "high"),
|
||||
agents: [agent("build", key("b"), "low")],
|
||||
config: "provider/c",
|
||||
})
|
||||
const { local } = f.mount()
|
||||
expect(selection(local)).toEqual({ agent: "build", model: "b", variant: "low" })
|
||||
f.set("agents", [agent("build", key("removed"))])
|
||||
expect(local.model.current()?.id).toBe("c")
|
||||
f.setPreferences("recent", [key("removed"), key("b")])
|
||||
f.set("config", "provider/removed")
|
||||
expect(local.model.current()?.id).toBe("b")
|
||||
f.setPreferences("recent", [key("removed")])
|
||||
expect(local.model.current()?.id).toBe("a")
|
||||
f.set("providers", [])
|
||||
expect(local.model.current()).toBeUndefined()
|
||||
})
|
||||
|
||||
test("global and agent model/variant defaults react to configuration replacement", () => {
|
||||
const f = fixture({ config: { providerID: "provider", model: "a", variant: "low" } })
|
||||
const { local } = f.mount()
|
||||
expect(selection(local)).toEqual({ agent: "build", model: "a", variant: "low" })
|
||||
f.set("config", { providerID: "provider", model: "b", variant: "high" })
|
||||
expect(selection(local)).toEqual({ agent: "build", model: "b", variant: "high" })
|
||||
f.set("agents", [agent("build", key("a"), "low")])
|
||||
expect(selection(local)).toEqual({ agent: "build", model: "a", variant: "low" })
|
||||
f.set("agents", [agent("build", key("c"), "high")])
|
||||
expect(selection(local)).toEqual({ agent: "build", model: "c", variant: "high" })
|
||||
f.set("agents", [agent("build")])
|
||||
f.set("config", "provider/b")
|
||||
expect(selection(local)).toEqual({ agent: "build", model: "b", variant: undefined })
|
||||
})
|
||||
|
||||
test("durable and explicitly selected Default override a saved variant preference", () => {
|
||||
const f = fixture({
|
||||
session: durable("a"),
|
||||
preferred: "high",
|
||||
config: { providerID: "provider", model: "a", variant: "low" },
|
||||
})
|
||||
const { local } = f.mount()
|
||||
expect(local.model.variant.current()).toBeUndefined()
|
||||
local.model.variant.set(undefined)
|
||||
expect(f.preferences.store.variant["provider/a"]).toBe("default")
|
||||
f.set("route", "id", "ses_b")
|
||||
expect(local.model.variant.current()).toBeUndefined()
|
||||
f.set("route", "id", "ses_a")
|
||||
f.setPreferences("variant", "provider/a", "high")
|
||||
expect(local.model.variant.current()).toBeUndefined()
|
||||
f.set("route", "id", "ses_b")
|
||||
expect(local.model.variant.current()).toBe("high")
|
||||
f.set("route", "id", "ses_a")
|
||||
expect(local.model.variant.current()).toBeUndefined()
|
||||
})
|
||||
|
||||
test("waits for both commit acknowledgments, then releases only the matching draft", () => {
|
||||
const f = fixture({ session: durable("b", "low") })
|
||||
const { local } = f.mount()
|
||||
local.agent.set("plan")
|
||||
local.model.set(key("a"))
|
||||
local.model.variant.set("high")
|
||||
local.model.trackSessionCommit("ses_a", { agent: "plan", model: key("a"), variant: "high" })
|
||||
f.set("sessions", "ses_a", durable("b", "low", "plan"))
|
||||
f.emit("session.agent.selected")
|
||||
expect(selection(local)).toEqual({ agent: "plan", model: "a", variant: "high" })
|
||||
f.set("sessions", "ses_a", durable("a", "high", "plan"))
|
||||
f.emit("session.model.selected")
|
||||
f.set("sessions", "ses_a", durable("c", "low", "plan"))
|
||||
expect(selection(local)).toEqual({ agent: "plan", model: "c", variant: "low" })
|
||||
})
|
||||
|
||||
test.each(["a", "b"])("a delayed commit preserves the newer %s/low selection", (model) => {
|
||||
const f = fixture()
|
||||
const { local } = f.mount()
|
||||
local.model.set(key("a"))
|
||||
local.model.variant.set("high")
|
||||
local.model.trackSessionCommit("ses_a", { agent: "build", model: key("a"), variant: "high" })
|
||||
local.model.set(key(model))
|
||||
local.model.variant.set("low")
|
||||
f.set("sessions", "ses_a", durable("a", "high"))
|
||||
f.emit("session.model.selected")
|
||||
expect(selection(local)).toEqual({ agent: "build", model, variant: "low" })
|
||||
})
|
||||
|
||||
test("a delayed commit preserves a newer agent even when model and variant match", () => {
|
||||
const f = fixture({ agents: [agent("build", key("a")), agent("plan", key("a"))] })
|
||||
const { local } = f.mount()
|
||||
local.model.variant.set("high")
|
||||
local.model.trackSessionCommit("ses_a", { agent: "build", model: key("a"), variant: "high" })
|
||||
local.agent.set("plan")
|
||||
f.set("sessions", "ses_a", durable("a", "high"))
|
||||
f.emit("session.model.selected")
|
||||
expect(selection(local)).toEqual({ agent: "plan", model: "a", variant: "high" })
|
||||
})
|
||||
|
||||
test("a commit received while its session is inactive does not discard its local selection", () => {
|
||||
const f = fixture()
|
||||
const { local } = f.mount()
|
||||
local.model.set(key("a"))
|
||||
local.model.variant.set("high")
|
||||
local.model.trackSessionCommit("ses_a", { agent: "build", model: key("a"), variant: "high" })
|
||||
f.set("sessions", "ses_b", durable("b", "low"))
|
||||
f.set("route", "id", "ses_b")
|
||||
f.set("sessions", "ses_a", durable("a", "high"))
|
||||
f.emit("session.model.selected")
|
||||
expect(selection(local)).toEqual({ agent: "build", model: "b", variant: "low" })
|
||||
f.set("sessions", "ses_a", durable("c", "low"))
|
||||
f.set("route", "id", "ses_a")
|
||||
expect(selection(local)).toEqual({ agent: "build", model: "a", variant: "high" })
|
||||
})
|
||||
|
||||
test("cancelling a failed commit retains the draft and does not cancel a newer commit", () => {
|
||||
const f = fixture()
|
||||
const { local } = f.mount()
|
||||
local.model.set(key("a"))
|
||||
local.model.variant.set("high")
|
||||
const cancel = local.model.trackSessionCommit("ses_a", { agent: "build", model: key("a"), variant: "high" })
|
||||
cancel()
|
||||
f.set("sessions", "ses_a", durable("a", "high"))
|
||||
f.emit("session.model.selected")
|
||||
f.set("sessions", "ses_a", durable("c", "low"))
|
||||
expect(local.model.current()?.id).toBe("a")
|
||||
|
||||
local.model.set(key("b"))
|
||||
local.model.variant.set("low")
|
||||
local.model.trackSessionCommit("ses_a", { agent: "build", model: key("b"), variant: "low" })
|
||||
cancel()
|
||||
f.set("sessions", "ses_a", durable("b", "low"))
|
||||
f.emit("session.model.selected")
|
||||
f.set("sessions", "ses_a", durable("c", "high"))
|
||||
expect(selection(local)).toEqual({ agent: "build", model: "c", variant: "high" })
|
||||
})
|
||||
|
||||
test.each([1, -1] as const)("cycles %p from outside recents to the correct end and wraps", (direction) => {
|
||||
const f = fixture({ session: durable("a") })
|
||||
f.setPreferences("recent", [key("removed"), key("b"), key("c")])
|
||||
const { local } = f.mount()
|
||||
local.model.cycle(direction)
|
||||
expect(local.model.current()?.id).toBe(direction === 1 ? "b" : "c")
|
||||
local.model.cycle(direction)
|
||||
expect(local.model.current()?.id).toBe(direction === 1 ? "c" : "b")
|
||||
local.model.cycle(direction)
|
||||
expect(local.model.current()?.id).toBe(direction === 1 ? "b" : "c")
|
||||
})
|
||||
@@ -0,0 +1,23 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import { fileURLToPath } from "node:url"
|
||||
|
||||
test("LocalProvider selection integration", async () => {
|
||||
// Isolate provider module substitutions from the rest of the browser suite.
|
||||
const child = Bun.spawn(
|
||||
[
|
||||
process.execPath,
|
||||
"test",
|
||||
"--conditions=browser",
|
||||
"--preload",
|
||||
"./happydom.ts",
|
||||
"./test-browser/fixtures/model-selection.ts",
|
||||
],
|
||||
{ cwd: fileURLToPath(new URL("..", import.meta.url)), stdout: "pipe", stderr: "pipe" },
|
||||
)
|
||||
const [status, stdout, stderr] = await Promise.all([
|
||||
child.exited,
|
||||
new Response(child.stdout).text(),
|
||||
new Response(child.stderr).text(),
|
||||
])
|
||||
expect(status, stdout + stderr).toBe(0)
|
||||
}, 30_000)
|
||||
@@ -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()
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,25 @@
|
||||
# Vite TUI entrypoint
|
||||
|
||||
From the repository root:
|
||||
|
||||
```sh
|
||||
bun run dev:vite:live /path/to/project
|
||||
```
|
||||
|
||||
This uses the normal CLI and its real TUI through Vite + `solid-refresh`. For an explicit server or private backend, use `dev:vite` with `--server URL` or `--standalone` respectively. Plain `dev:vite` uses normal CLI service discovery; `dev:vite:live` explicitly connects to the installed server without replacing it.
|
||||
|
||||
- Component edits hot-update through the existing Solid refresh runtime.
|
||||
- Full reloads await TUI cleanup and restore the current route: the selected session, Home workspace, or plugin page. They do not preserve composer drafts or other component-local state, and do not replay launch prompts, route prompts, `--continue`, or `--fork`.
|
||||
- Correcting syntax errors retries a failed reload. The backend stays alive.
|
||||
- Refreshable components get local error boundaries. A render failure during a hot update triggers one full UI reload. If the fresh render also fails, the error appears in the shared themed Dialog rather than causing a reload loop. Only the latest error is shown. Escape dismisses it; saving retries failed components. State within remounted components can still reset, especially when several components share an edited file.
|
||||
- Launcher/config/dependency changes require restarting the development client.
|
||||
|
||||
`vite.ts` registers a Bun runtime module that supplies the Vite runner for the CLI's existing static `@opencode/tui` import. This registration runs only in the dev launcher; production handlers and their import graph are unchanged. `tui.ts` owns Vite and the TUI lifecycle. `entry.ts` loads the real application source through Vite. `host.js` keeps lifecycle ownership outside Vite's reloadable module cache. No production CLI handler, TUI component, or route changes are needed.
|
||||
|
||||
`refresh.ts` delegates component replacement to stock `solid-refresh`, wrapping each returned component proxy in Solid's standard ErrorBoundary. It preserves registered context identities during module evaluation: Vite's native runner can re-evaluate cyclic dependencies without invoking their HMR accept callbacks, which is too late for stock context patching. `refresh-runtime.d.ts` supplies types for the package's existing deep runtime export.
|
||||
|
||||
Vite redirects imports of the TUI route context through `route.tsx`, a dev-only wrapper around the real provider. It saves plain route snapshots in the external `host.js`, including nested Home location and plugin page data. Production route code is unchanged. Recovery is armed only for a hot update, consumed before requesting a full reload, and disarmed when the update settles or the full reload starts.
|
||||
|
||||
The entry initializes the error overlay after loading the app graph because the shared dialog and theme modules themselves use the refresh runtime.
|
||||
|
||||
Tested on Linux/Bun with full-app rendering, message/palette HMR, draft preservation, and native-terminal full reload/error recovery. External native-loaded plugins remain experimental across full reloads because their process-lifetime runtime mappings can retain an older Solid generation.
|
||||
@@ -0,0 +1,17 @@
|
||||
/// <reference types="vite/client" />
|
||||
import { host } from "@opencode/cli/vite-host"
|
||||
import { configureErrorOverlay } from "./refresh"
|
||||
|
||||
if (import.meta.hot) {
|
||||
import.meta.hot.on("vite:afterUpdate", () => queueMicrotask(() => host.settle?.()))
|
||||
import.meta.hot.on("vite:beforeFullReload", async () => {
|
||||
await host.stop?.()
|
||||
host.reset?.()
|
||||
})
|
||||
}
|
||||
|
||||
const { run } = await import("../../tui/src/index")
|
||||
// Theme/dialog modules use refresh themselves; initialize their overlay after the app graph loads.
|
||||
const { ErrorOverlay } = await import("./error-overlay")
|
||||
configureErrorOverlay(ErrorOverlay)
|
||||
await host.mount?.(run)
|
||||
@@ -0,0 +1,56 @@
|
||||
/* @refresh skip */
|
||||
import { BoxRenderable, TextAttributes } from "@opentui/core"
|
||||
import { Portal, useRenderer, useTerminalDimensions } from "@opentui/solid"
|
||||
import { onCleanup, onMount } from "solid-js"
|
||||
import { useTheme } from "../../tui/src/context/theme"
|
||||
import { Dialog } from "../../tui/src/ui/dialog"
|
||||
import { Keymap } from "../../tui/src/context/keymap"
|
||||
|
||||
export function ErrorOverlay(props: { component: string; error: unknown; onClose: () => void }) {
|
||||
const renderer = useRenderer()
|
||||
const dimensions = useTerminalDimensions()
|
||||
const theme = useTheme("elevated")
|
||||
const focus = renderer.currentFocusedRenderable
|
||||
onCleanup(Keymap.use().mode.push("modal"))
|
||||
Keymap.createLayer(() => ({
|
||||
mode: "modal",
|
||||
priority: 1000,
|
||||
commands: [{ bind: "escape", title: "Close hot reload error", group: "Development", run: props.onClose }],
|
||||
}))
|
||||
onMount(() => focus?.blur())
|
||||
onCleanup(() => {
|
||||
if (focus && !focus.isDestroyed) focus.focus()
|
||||
})
|
||||
|
||||
return (
|
||||
<Portal
|
||||
ref={(container) => {
|
||||
if (!(container instanceof BoxRenderable)) return
|
||||
// Anchor Portal's wrapper above the app rather than after it in root layout.
|
||||
container.position = "absolute"
|
||||
container.left = 0
|
||||
container.top = 0
|
||||
container.zIndex = 5000
|
||||
}}
|
||||
>
|
||||
<Dialog centered onClose={props.onClose}>
|
||||
<box maxHeight={Math.max(1, dimensions().height - 3)} paddingX={2} paddingBottom={1} gap={1}>
|
||||
<box flexDirection="row" justifyContent="space-between" flexShrink={0}>
|
||||
<text fg={theme.text.feedback.error.default} attributes={TextAttributes.BOLD}>
|
||||
Error while hot reloading
|
||||
</text>
|
||||
<text fg={theme.text.subdued} onMouseUp={props.onClose}>
|
||||
esc
|
||||
</text>
|
||||
</box>
|
||||
<text maxHeight={Math.max(1, dimensions().height - 9)} fg={theme.text.default}>
|
||||
{props.error instanceof Error ? props.error.message : String(props.error)}
|
||||
</text>
|
||||
<text flexShrink={0} fg={theme.text.subdued}>
|
||||
{props.component} · Fix the component and save to retry.
|
||||
</text>
|
||||
</box>
|
||||
</Dialog>
|
||||
</Portal>
|
||||
)
|
||||
}
|
||||
Vendored
+16
@@ -0,0 +1,16 @@
|
||||
import type { Effect, Fiber, FileSystem } from "effect"
|
||||
import type { TuiInput } from "@opencode/tui"
|
||||
import type { Global } from "@opencode/util/global"
|
||||
import type { Route } from "../../tui/src/context/route"
|
||||
|
||||
export type Run = (input: TuiInput) => Effect.Effect<void, unknown, Global.Service | FileSystem.FileSystem>
|
||||
|
||||
export declare const host: {
|
||||
active?: Fiber.Fiber<void, unknown>
|
||||
mount?: (app: Run) => Promise<void>
|
||||
stop?: () => Promise<void>
|
||||
reset?: () => void
|
||||
recover?: () => boolean
|
||||
settle?: () => void
|
||||
route?: Route
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
// External to Vite's module cache: keep lifecycle and route state across reloads.
|
||||
export const host = {}
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
declare module "solid-refresh/dist/solid-refresh.mjs" {
|
||||
export * from "solid-refresh"
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
/// <reference types="vite/client" />
|
||||
import { createComponent, createSignal, ErrorBoundary, onCleanup, Show, type JSX } from "solid-js"
|
||||
import { $$component, $$refresh, type Registry } from "solid-refresh/dist/solid-refresh.mjs"
|
||||
import type { ErrorOverlay } from "./error-overlay"
|
||||
import { host } from "@opencode/cli/vite-host"
|
||||
|
||||
let overlay: typeof ErrorOverlay
|
||||
const [activeError, setActiveError] = createSignal<symbol>()
|
||||
export function configureErrorOverlay(component: typeof ErrorOverlay) {
|
||||
overlay = component
|
||||
}
|
||||
|
||||
export { $$context, $$decline, $$registry } from "solid-refresh/dist/solid-refresh.mjs"
|
||||
export { refresh as $$refresh }
|
||||
export { component as $$component }
|
||||
|
||||
function refresh(...args: Parameters<typeof $$refresh>) {
|
||||
// The native runner can re-evaluate a dependency in a cycle without accepting
|
||||
// an update for that module. Preserve context identity now, before an updated
|
||||
// consumer renders, rather than waiting for solid-refresh's accept callback.
|
||||
const previous = args[1].data?.["solid-refresh"]
|
||||
args[2].contexts.forEach((entry, id) => {
|
||||
const old = previous?.contexts.get(id)
|
||||
if (!old) return
|
||||
old.context.defaultValue = entry.context.defaultValue
|
||||
entry.context.id = old.context.id
|
||||
entry.context.Provider = old.context.Provider
|
||||
})
|
||||
$$refresh(...args)
|
||||
}
|
||||
|
||||
function component<P extends Record<string, unknown>>(
|
||||
registry: Registry,
|
||||
id: string,
|
||||
render: (props: P) => JSX.Element,
|
||||
options?: Parameters<typeof $$component>[3],
|
||||
) {
|
||||
const proxy = $$component(registry, id, render, options)
|
||||
return (props: P) =>
|
||||
createComponent(ErrorBoundary, {
|
||||
fallback(error: unknown, reset: () => void) {
|
||||
if (host.recover?.()) return null
|
||||
const token = Symbol(id)
|
||||
// Several instances can fail in one update. Stack neither dialogs nor translucent backdrops.
|
||||
setActiveError(token)
|
||||
onCleanup(() => {
|
||||
if (activeError() === token) setActiveError(undefined)
|
||||
})
|
||||
// Retry only this failed subtree. Resetting the app's boundary destroys its providers and route.
|
||||
import.meta.hot?.on("vite:afterUpdate", reset)
|
||||
onCleanup(() => import.meta.hot?.off("vite:afterUpdate", reset))
|
||||
return createComponent(Show, {
|
||||
keyed: true,
|
||||
get when() {
|
||||
return activeError() === token
|
||||
},
|
||||
get children() {
|
||||
return createComponent(overlay, { component: id, error, onClose: () => setActiveError(undefined) })
|
||||
},
|
||||
})
|
||||
},
|
||||
get children() {
|
||||
return createComponent(proxy, props)
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { createEffect, on, type ComponentProps } from "solid-js"
|
||||
import { unwrap } from "solid-js/store"
|
||||
import { host } from "@opencode/cli/vite-host"
|
||||
import { RouteProvider, useRoute } from "../../tui/src/context/route"
|
||||
|
||||
export {
|
||||
useRoute,
|
||||
useRouteData,
|
||||
type Route,
|
||||
type HomeRoute,
|
||||
type SessionRoute,
|
||||
type PluginRoute,
|
||||
} from "../../tui/src/context/route"
|
||||
export { ReloadableRouteProvider as RouteProvider }
|
||||
|
||||
function ReloadableRouteProvider(props: ComponentProps<typeof RouteProvider>) {
|
||||
return (
|
||||
<RouteProvider {...props} initialRoute={host.route ?? props.initialRoute}>
|
||||
<RememberRoute />
|
||||
{props.children}
|
||||
</RouteProvider>
|
||||
)
|
||||
}
|
||||
|
||||
function RememberRoute() {
|
||||
const route = useRoute()
|
||||
createEffect(
|
||||
on(
|
||||
() => JSON.stringify(route.data),
|
||||
() => {
|
||||
// A route's prompt is a one-shot handoff, not a composer draft.
|
||||
const value = structuredClone(unwrap({ ...route.data }))
|
||||
host.route =
|
||||
value.type === "home"
|
||||
? { type: "home", location: value.location }
|
||||
: value.type === "session"
|
||||
? { type: "session", sessionID: value.sessionID }
|
||||
: value
|
||||
},
|
||||
),
|
||||
)
|
||||
return null
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
import { createRequire } from "node:module"
|
||||
import path from "node:path"
|
||||
import { Effect, Exit, Fiber } from "effect"
|
||||
import { createRunnableDevEnvironment, createServer, isRunnableDevEnvironment } from "vite"
|
||||
import solid from "vite-plugin-solid"
|
||||
import refresh from "solid-refresh/babel"
|
||||
import { host, type Run } from "./host.js"
|
||||
|
||||
const require = createRequire(import.meta.url)
|
||||
|
||||
export const run: Run = Effect.fn("Tui.vite")(function* (input: Parameters<Run>[0]) {
|
||||
const fork = Effect.runForkWith(yield* Effect.context<Effect.Services<ReturnType<Run>>>())
|
||||
const finished = Promise.withResolvers<Exit.Exit<void, unknown>>()
|
||||
let initial = true
|
||||
let recoverable = false
|
||||
host.route = undefined
|
||||
host.settle = () => {
|
||||
recoverable = false
|
||||
}
|
||||
host.stop = async () => {
|
||||
const fiber = host.active
|
||||
host.active = undefined
|
||||
if (fiber) await Effect.runPromise(Fiber.interrupt(fiber))
|
||||
}
|
||||
host.mount = async (app) => {
|
||||
await host.stop?.()
|
||||
const fiber = fork(
|
||||
app({
|
||||
...input,
|
||||
args: initial
|
||||
? input.args
|
||||
: { ...input.args, prompt: undefined, sessionID: undefined, continue: false, fork: false },
|
||||
terminalHandoff: initial ? input.terminalHandoff : undefined,
|
||||
}),
|
||||
)
|
||||
initial = false
|
||||
host.active = fiber
|
||||
fiber.addObserver((exit) => {
|
||||
if (host.active !== fiber) return
|
||||
host.active = undefined
|
||||
finished.resolve(exit)
|
||||
})
|
||||
}
|
||||
|
||||
const server = yield* Effect.acquireRelease(
|
||||
Effect.tryPromise(() =>
|
||||
createServer({
|
||||
root: path.resolve(import.meta.dirname, "../../tui"),
|
||||
configFile: false,
|
||||
appType: "custom",
|
||||
clearScreen: false,
|
||||
logLevel: "error",
|
||||
server: { middlewareMode: true, ws: false },
|
||||
resolve: {
|
||||
alias: [
|
||||
{ find: /^solid-js(?:\/dist\/solid.js)?$/, replacement: require.resolve("solid-js/dist/dev.js") },
|
||||
{
|
||||
find: /^solid-js\/store(?:\/dist\/store.js)?$/,
|
||||
replacement: require.resolve("solid-js/store/dist/dev.js"),
|
||||
},
|
||||
],
|
||||
},
|
||||
plugins: [
|
||||
{
|
||||
name: "tui-refresh-boundaries",
|
||||
enforce: "pre",
|
||||
async resolveId(source, importer) {
|
||||
if (importer === path.join(import.meta.dirname, "route.tsx")) return
|
||||
if (!source.endsWith("/route") && !source.endsWith("/route.tsx")) return
|
||||
const resolved = await this.resolve(source, importer, { skipSelf: true })
|
||||
if (resolved?.id === path.resolve(import.meta.dirname, "../../tui/src/context/route.tsx"))
|
||||
return path.join(import.meta.dirname, "route.tsx")
|
||||
},
|
||||
load(id) {
|
||||
if (id === "/@solid-refresh")
|
||||
return `export * from ${JSON.stringify(path.join(import.meta.dirname, "refresh.ts"))}`
|
||||
},
|
||||
},
|
||||
solid({
|
||||
hot: false,
|
||||
dev: true,
|
||||
solid: { generate: "universal", moduleName: "@opentui/solid" },
|
||||
// Enable the existing refresh plugin in Vite's non-browser environment.
|
||||
babel: { plugins: [[refresh, { bundler: "vite" }]] },
|
||||
}),
|
||||
{
|
||||
name: "tui-recovery",
|
||||
hotUpdate() {
|
||||
if (this.environment.name !== "native") return
|
||||
recoverable = Boolean(host.active)
|
||||
if (host.active) return
|
||||
this.environment.moduleGraph.invalidateAll()
|
||||
this.environment.hot.send({ type: "full-reload" })
|
||||
return []
|
||||
},
|
||||
},
|
||||
],
|
||||
environments: {
|
||||
native: {
|
||||
consumer: "server",
|
||||
resolve: {
|
||||
conditions: ["bun", "development", "module"],
|
||||
externalConditions: ["bun", "node"],
|
||||
noExternal: [
|
||||
"solid-js",
|
||||
"solid-refresh",
|
||||
"@opentui/solid",
|
||||
"@opentui/keymap",
|
||||
"opentui-spinner",
|
||||
/^@solid-primitives\//,
|
||||
"@opencode/plugin",
|
||||
"@opencode/client",
|
||||
"@opencode/latex",
|
||||
"@opencode/merman",
|
||||
],
|
||||
// Exact subpaths are needed for workspace TypeScript exports.
|
||||
external: [
|
||||
"@opentui/core",
|
||||
"@opentui/core/testing",
|
||||
"effect",
|
||||
"@opencode/cli/vite-host",
|
||||
"@opencode/client",
|
||||
"@opencode/client/effect/service",
|
||||
"@opencode/client/promise",
|
||||
"@opencode/core/util/slug",
|
||||
"@opencode/schema",
|
||||
"@opencode/schema/event",
|
||||
"@opencode/schema/project",
|
||||
"@opencode/schema/session-id",
|
||||
"@opencode/schema/session-inbox",
|
||||
"@opencode/schema/session-message",
|
||||
"@opencode/schema/skill",
|
||||
"@opencode/schema/token-usage",
|
||||
"@opencode/schema/vcs",
|
||||
"@opencode/schema/worktree",
|
||||
"@opencode/simulation/frontend",
|
||||
"@opencode/simulation/protocol",
|
||||
"@opencode/theme/tui",
|
||||
"@opencode/theme/tui/v1",
|
||||
"@opencode/util/activity-calendar",
|
||||
"@opencode/util/flock",
|
||||
"@opencode/util/global",
|
||||
"@opencode/util/hash",
|
||||
"@opencode/util/session-title-fallback",
|
||||
],
|
||||
},
|
||||
optimizeDeps: { noDiscovery: true, include: [] },
|
||||
dev: {
|
||||
createEnvironment: (name, config) =>
|
||||
createRunnableDevEnvironment(name, config, {
|
||||
runnerOptions: {
|
||||
sourcemapInterceptor: false,
|
||||
hmr: { logger: { debug() {}, error: (error) => console.error(error) } },
|
||||
},
|
||||
}),
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
),
|
||||
(server) =>
|
||||
Effect.promise(async () => {
|
||||
await host.stop?.()
|
||||
await server.close()
|
||||
}),
|
||||
)
|
||||
const environment = server.environments.native
|
||||
if (!isRunnableDevEnvironment(environment)) return yield* Effect.die(new Error("Expected a runnable environment"))
|
||||
host.reset = () => {
|
||||
recoverable = false
|
||||
environment.runner.clearCache()
|
||||
}
|
||||
host.recover = () => {
|
||||
if (!recoverable) return false
|
||||
recoverable = false
|
||||
queueMicrotask(() => {
|
||||
input.log?.("warn", "TUI hot update failed; reloading", {})
|
||||
environment.moduleGraph.invalidateAll()
|
||||
environment.hot.send({ type: "full-reload" })
|
||||
})
|
||||
return true
|
||||
}
|
||||
yield* Effect.promise(() =>
|
||||
environment.runner.import(path.join(import.meta.dirname, "entry.ts")).catch(console.error),
|
||||
)
|
||||
const exit = yield* Effect.promise(() => finished.promise)
|
||||
if (Exit.isFailure(exit)) return yield* Effect.failCause(exit.cause)
|
||||
}, Effect.scoped)
|
||||
@@ -0,0 +1,15 @@
|
||||
import { plugin } from "bun"
|
||||
import { ensureSolidTransformPlugin } from "@opentui/solid/bun-plugin"
|
||||
|
||||
ensureSolidTransformPlugin()
|
||||
if (process.argv[2] !== "serve") {
|
||||
// Vite must initialize before the CLI installs its process/error handling on Bun.
|
||||
const { run } = await import("./tui")
|
||||
plugin({
|
||||
name: "vite-tui-entry",
|
||||
setup(build) {
|
||||
build.module("@opencode/tui", () => ({ loader: "object", exports: { run } }))
|
||||
},
|
||||
})
|
||||
}
|
||||
await import("../src/index")
|
||||
@@ -11,6 +11,7 @@
|
||||
"bin"
|
||||
],
|
||||
"exports": {
|
||||
"./vite-host": "./dev/host.js",
|
||||
"./run": "./src/run/index.ts",
|
||||
"./server-process": "./src/server-process.ts"
|
||||
},
|
||||
@@ -74,6 +75,7 @@
|
||||
"@parcel/watcher-linux-x64-glibc": "2.5.1",
|
||||
"@parcel/watcher-win32-arm64": "2.5.1",
|
||||
"@parcel/watcher-win32-x64": "2.5.1",
|
||||
"solid-refresh": "0.6.3",
|
||||
"vite": "catalog:",
|
||||
"vite-plugin-solid": "catalog:"
|
||||
}
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
@@ -0,0 +1,45 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import { createComponent, createContext, createRoot, useContext } from "solid-js"
|
||||
import { $$context, $$registry } from "solid-refresh/dist/solid-refresh.mjs"
|
||||
import { $$refresh } from "../dev/refresh"
|
||||
|
||||
test("re-evaluated dependencies retain their mounted context before any HMR accept callback", () => {
|
||||
const previous = $$registry()
|
||||
const mounted = $$context(previous, "Context", createContext("old default"))
|
||||
const next = $$registry()
|
||||
const updated = $$context(next, "Context", createContext("new default"))
|
||||
const unrelated = $$context($$registry(), "Context", createContext("unrelated"))
|
||||
let accepted = false
|
||||
$$refresh(
|
||||
"vite",
|
||||
{
|
||||
data: { "solid-refresh": previous, "solid-refresh-prev": previous },
|
||||
accept() {
|
||||
accepted = true
|
||||
},
|
||||
invalidate() {
|
||||
throw new Error("Unexpected invalidation")
|
||||
},
|
||||
decline() {
|
||||
throw new Error("Unexpected decline")
|
||||
},
|
||||
},
|
||||
next,
|
||||
)
|
||||
|
||||
// Only register acceptance: Vite re-evaluates cyclic dependencies without
|
||||
// necessarily sending those modules their own accepted update.
|
||||
expect(accepted).toBe(true)
|
||||
createRoot((dispose) => {
|
||||
createComponent(mounted.Provider, {
|
||||
value: "mounted provider",
|
||||
get children() {
|
||||
expect(useContext(updated)).toBe("mounted provider")
|
||||
expect(useContext(unrelated)).toBe("unrelated")
|
||||
return undefined
|
||||
},
|
||||
})
|
||||
expect(useContext(mounted)).toBe("new default")
|
||||
dispose()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,65 @@
|
||||
/** @jsxImportSource @opentui/solid */
|
||||
import { expect, test } from "bun:test"
|
||||
import { testRender } from "@opentui/solid"
|
||||
import { RouteProvider, useRoute, type Route } from "../dev/route"
|
||||
import { host } from "../dev/host.js"
|
||||
import { TuiStartupProvider } from "../../tui/src/context/runtime"
|
||||
|
||||
test("the dev route wrapper restores the current route without replaying its prompt", async () => {
|
||||
const saved = () => host.route
|
||||
let route!: ReturnType<typeof useRoute>
|
||||
function Probe() {
|
||||
route = useRoute()
|
||||
return null
|
||||
}
|
||||
async function render() {
|
||||
return testRender(
|
||||
() => (
|
||||
<TuiStartupProvider value={{ skipInitialLoading: true }}>
|
||||
<RouteProvider initialRoute={{ type: "session", sessionID: "ses_launch" }}>
|
||||
<Probe />
|
||||
</RouteProvider>
|
||||
</TuiStartupProvider>
|
||||
),
|
||||
{ width: 80, height: 24 },
|
||||
)
|
||||
}
|
||||
const routes: Route[] = [
|
||||
{ type: "home", location: { directory: "/selected/worktree", workspaceID: "wrk_test" } },
|
||||
{ type: "home", location: { directory: "/another/worktree", workspaceID: "wrk_other" } },
|
||||
{ type: "session", sessionID: "ses_selected" },
|
||||
{ type: "plugin", id: "test", name: "page", data: { nested: { selected: 1 } } },
|
||||
{ type: "plugin", id: "test", name: "page", data: { nested: { selected: 2 } } },
|
||||
]
|
||||
host.route = undefined
|
||||
const app = await render()
|
||||
try {
|
||||
await app.waitFor(() => host.route !== undefined)
|
||||
for (const value of routes) {
|
||||
route.navigate(
|
||||
value.type === "plugin"
|
||||
? value
|
||||
: {
|
||||
...value,
|
||||
prompt: { text: "one-shot handoff", files: [], agents: [], pasted: [] },
|
||||
},
|
||||
)
|
||||
await app.waitFor(() => JSON.stringify(host.route) === JSON.stringify(value))
|
||||
expect(saved()).toEqual(value)
|
||||
// Saved routes contain plain data, not a proxy tied to the old Solid tree.
|
||||
expect(structuredClone(saved())).toEqual(value)
|
||||
}
|
||||
} finally {
|
||||
app.renderer.destroy()
|
||||
}
|
||||
for (const value of routes) {
|
||||
host.route = value
|
||||
const restored = await render()
|
||||
try {
|
||||
expect(route.data).toEqual(value)
|
||||
} finally {
|
||||
restored.renderer.destroy()
|
||||
}
|
||||
}
|
||||
host.route = undefined
|
||||
})
|
||||
@@ -518,6 +518,7 @@ export type SessionLogOutput =
|
||||
readonly sessionID: Session.ID
|
||||
readonly parentID: Session.ID
|
||||
readonly boundary: Session.ForkBoundary
|
||||
readonly messages?: ReadonlyArray<SessionMessage.InfoEncoded> | undefined
|
||||
readonly instructions?:
|
||||
| { readonly [x: string & Brand.Brand<"Instruction.Key">]: string & Brand.Brand<"Instruction.Hash"> }
|
||||
| undefined
|
||||
@@ -714,7 +715,7 @@ export type SessionLogOutput =
|
||||
readonly assistantMessageID: SessionMessage.ID
|
||||
readonly finish: "stop" | "length" | "tool-calls" | "content-filter" | "error" | "unknown"
|
||||
readonly rawFinish?: string | undefined
|
||||
readonly providerState?: SessionMessage.ProviderState | undefined
|
||||
readonly providerState?: { readonly [x: string]: unknown } | undefined
|
||||
readonly cost: number & Brand.Brand<"Money.USD">
|
||||
readonly tokens: {
|
||||
readonly input: number
|
||||
@@ -739,7 +740,7 @@ export type SessionLogOutput =
|
||||
readonly error: { readonly type: string; readonly message: string; readonly status?: number | undefined }
|
||||
readonly finish?: "content-filter" | undefined
|
||||
readonly rawFinish?: string | undefined
|
||||
readonly providerState?: SessionMessage.ProviderState | undefined
|
||||
readonly providerState?: { readonly [x: string]: unknown } | undefined
|
||||
readonly cost?: (number & Brand.Brand<"Money.USD">) | undefined
|
||||
readonly tokens?:
|
||||
| {
|
||||
@@ -778,7 +779,7 @@ export type SessionLogOutput =
|
||||
readonly assistantMessageID: SessionMessage.ID
|
||||
readonly ordinal: number
|
||||
readonly text: string
|
||||
readonly state?: SessionMessage.ProviderState | undefined
|
||||
readonly state?: { readonly [x: string]: unknown } | undefined
|
||||
}
|
||||
}
|
||||
| {
|
||||
@@ -792,7 +793,7 @@ export type SessionLogOutput =
|
||||
readonly sessionID: Session.ID
|
||||
readonly assistantMessageID: SessionMessage.ID
|
||||
readonly ordinal: number
|
||||
readonly state?: SessionMessage.ProviderState | undefined
|
||||
readonly state?: { readonly [x: string]: unknown } | undefined
|
||||
}
|
||||
}
|
||||
| {
|
||||
@@ -807,7 +808,7 @@ export type SessionLogOutput =
|
||||
readonly assistantMessageID: SessionMessage.ID
|
||||
readonly ordinal: number
|
||||
readonly text: string
|
||||
readonly state?: SessionMessage.ProviderState | undefined
|
||||
readonly state?: { readonly [x: string]: unknown } | undefined
|
||||
}
|
||||
}
|
||||
| {
|
||||
@@ -851,7 +852,7 @@ export type SessionLogOutput =
|
||||
readonly id: string
|
||||
readonly input: { readonly [x: string]: unknown }
|
||||
readonly executed: boolean
|
||||
readonly state?: SessionMessage.ProviderState | undefined
|
||||
readonly state?: { readonly [x: string]: unknown } | undefined
|
||||
}
|
||||
}
|
||||
| {
|
||||
@@ -887,7 +888,7 @@ export type SessionLogOutput =
|
||||
]
|
||||
readonly metadata?: { readonly [x: string]: Schema.Json } | undefined
|
||||
readonly executed: boolean
|
||||
readonly resultState?: SessionMessage.ProviderState | undefined
|
||||
readonly resultState?: { readonly [x: string]: unknown } | undefined
|
||||
}
|
||||
}
|
||||
| {
|
||||
@@ -926,7 +927,7 @@ export type SessionLogOutput =
|
||||
| undefined
|
||||
readonly metadata?: { readonly [x: string]: Schema.Json } | undefined
|
||||
readonly executed: boolean
|
||||
readonly resultState?: SessionMessage.ProviderState | undefined
|
||||
readonly resultState?: { readonly [x: string]: unknown } | undefined
|
||||
}
|
||||
}
|
||||
| {
|
||||
@@ -969,7 +970,7 @@ export type SessionLogOutput =
|
||||
readonly sessionID: Session.ID
|
||||
readonly reason: "auto" | "manual"
|
||||
readonly model?: Model.Ref | undefined
|
||||
readonly providerState?: SessionMessage.ProviderState | undefined
|
||||
readonly providerState?: { readonly [x: string]: unknown } | undefined
|
||||
readonly providerContext?:
|
||||
| {
|
||||
readonly version: 1
|
||||
|
||||
@@ -159,6 +159,76 @@ export type InstructionEntryKey = string
|
||||
|
||||
export type SessionGenerateResponse = { data: { text: string } }
|
||||
|
||||
export type SessionMessageAgentSelected1 = {
|
||||
id: string
|
||||
metadata?: { [x: string]: any }
|
||||
time: { created: number }
|
||||
type: "agent-switched"
|
||||
agent: string
|
||||
previous?: string
|
||||
}
|
||||
|
||||
export type SessionMessageSynthetic1 = {
|
||||
id: string
|
||||
metadata?: { [x: string]: any }
|
||||
time: { created: number }
|
||||
text: string
|
||||
description?: string
|
||||
type: "synthetic"
|
||||
}
|
||||
|
||||
export type SessionMessageSystem1 = {
|
||||
id: string
|
||||
metadata?: { [x: string]: any }
|
||||
time: { created: number }
|
||||
type: "system"
|
||||
text: string
|
||||
description?: string
|
||||
}
|
||||
|
||||
export type SessionMessageSkill1 = {
|
||||
id: string
|
||||
metadata?: { [x: string]: any }
|
||||
time: { created: number }
|
||||
type: "skill"
|
||||
skill: string
|
||||
name: string
|
||||
text: string
|
||||
}
|
||||
|
||||
export type SessionMessageShell1 = {
|
||||
id: string
|
||||
metadata?: { [x: string]: any }
|
||||
time: { created: number; completed?: number }
|
||||
type: "shell"
|
||||
shellID: string
|
||||
command: string
|
||||
status: "running" | "exited" | "timeout" | "killed"
|
||||
exit?: number
|
||||
output?: { output: string; cursor: number; size: number; truncated: boolean }
|
||||
}
|
||||
|
||||
export type SessionMessageProviderState1 = { [x: string]: any }
|
||||
|
||||
export type SessionMessageToolStateRunning1 = {
|
||||
status: "running"
|
||||
input: { [x: string]: any }
|
||||
metadata: { [x: string]: JsonValue }
|
||||
}
|
||||
|
||||
export type ToolFileContent1 = { type: "file"; uri: string; mime: string; name?: string | undefined }
|
||||
|
||||
export type SessionMessageCompactionRunning1 = {
|
||||
type: "compaction"
|
||||
id: string
|
||||
metadata?: { [x: string]: any }
|
||||
time: { created: number }
|
||||
status: "running"
|
||||
reason: "auto" | "manual"
|
||||
summary: string
|
||||
recent: string
|
||||
}
|
||||
|
||||
export type SessionInboxSyntheticPayload1 = { text: string; description?: string; metadata?: { [x: string]: any } }
|
||||
|
||||
export type ShellInfo = {
|
||||
@@ -174,16 +244,6 @@ export type ShellInfo = {
|
||||
time: { started: number; completed?: number }
|
||||
}
|
||||
|
||||
export type SessionMessageProviderState1 = { [x: string]: any }
|
||||
|
||||
export type ToolFileContent1 = { type: "file"; uri: string; mime: string; name?: string | undefined }
|
||||
|
||||
export type SessionMessageToolStateRunning1 = {
|
||||
status: "running"
|
||||
input: { [x: string]: any }
|
||||
metadata: { [x: string]: JsonValue }
|
||||
}
|
||||
|
||||
export type EventLogSynced = { type: "log.synced"; aggregateID: string; seq?: number }
|
||||
|
||||
export type SessionInterruptResponse = { interrupted: boolean }
|
||||
@@ -454,6 +514,17 @@ export type SessionMessageLocationSwitched = {
|
||||
|
||||
export type SessionInboxMovePayload = { location: LocationRef; projectID: string; subpath?: string }
|
||||
|
||||
export type SessionMessageLocationSwitched1 = {
|
||||
id: string
|
||||
metadata?: { [x: string]: any }
|
||||
time: { created: number }
|
||||
type: "location-switched"
|
||||
location: LocationRef
|
||||
projectID?: string
|
||||
subpath?: string
|
||||
previous?: { location: LocationRef; projectID?: string; subpath?: string }
|
||||
}
|
||||
|
||||
export type V2EventRpc = {
|
||||
id: string
|
||||
created: number
|
||||
@@ -489,6 +560,15 @@ export type SessionMessageModelSelected = {
|
||||
previous?: ModelRef
|
||||
}
|
||||
|
||||
export type SessionMessageModelSelected1 = {
|
||||
id: string
|
||||
metadata?: { [x: string]: any }
|
||||
time: { created: number }
|
||||
type: "model-switched"
|
||||
model: ModelRef
|
||||
previous?: ModelRef
|
||||
}
|
||||
|
||||
export type PromptFileAttachment = {
|
||||
data: PromptBase64
|
||||
mime: string
|
||||
@@ -525,6 +605,16 @@ export type SessionMessageCompactionFailed = {
|
||||
error: SessionStructuredError
|
||||
}
|
||||
|
||||
export type SessionMessageCompactionFailed1 = {
|
||||
type: "compaction"
|
||||
id: string
|
||||
metadata?: { [x: string]: any }
|
||||
time: { created: number }
|
||||
status: "failed"
|
||||
reason: "auto" | "manual"
|
||||
error: SessionStructuredError
|
||||
}
|
||||
|
||||
export type SessionProviderContext = { version: 1; provenance: SessionProviderContextProvenance; messages: JsonValue }
|
||||
|
||||
export type SessionInboxSynthetic = {
|
||||
@@ -1200,37 +1290,13 @@ export type McpResourcesChanged = {
|
||||
data: { server: string }
|
||||
}
|
||||
|
||||
export type SessionShellStarted = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
type: "session.shell.started"
|
||||
durable: { aggregateID: string; seq: number; version: 1 }
|
||||
location?: LocationRef
|
||||
data: { sessionID: string; shell: ShellInfo }
|
||||
}
|
||||
export type SessionMessageAssistantText1 = { type: "text"; text: string; state?: SessionMessageProviderState1 }
|
||||
|
||||
export type SessionShellEnded = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
type: "session.shell.ended"
|
||||
durable: { aggregateID: string; seq: number; version: 1 }
|
||||
location?: LocationRef
|
||||
data: {
|
||||
sessionID: string
|
||||
shell: ShellInfo
|
||||
output: { output: string; cursor: number; size: number; truncated: boolean }
|
||||
}
|
||||
}
|
||||
|
||||
export type ShellCreated = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
type: "shell.created"
|
||||
location?: LocationRef
|
||||
data: { info: ShellInfo }
|
||||
export type SessionMessageAssistantReasoning1 = {
|
||||
type: "reasoning"
|
||||
text: string
|
||||
state?: SessionMessageProviderState1
|
||||
time?: { created: number; completed?: number }
|
||||
}
|
||||
|
||||
export type SessionStepEnded = {
|
||||
@@ -1333,16 +1399,40 @@ export type SessionToolCalled = {
|
||||
}
|
||||
}
|
||||
|
||||
export type SessionMessageAssistantText1 = { type: "text"; text: string; state?: SessionMessageProviderState1 }
|
||||
export type ToolContent1 = ToolTextContent | ToolFileContent1
|
||||
|
||||
export type SessionMessageAssistantReasoning1 = {
|
||||
type: "reasoning"
|
||||
text: string
|
||||
state?: SessionMessageProviderState1
|
||||
time?: { created: number; completed?: number }
|
||||
export type SessionShellStarted = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
type: "session.shell.started"
|
||||
durable: { aggregateID: string; seq: number; version: 1 }
|
||||
location?: LocationRef
|
||||
data: { sessionID: string; shell: ShellInfo }
|
||||
}
|
||||
|
||||
export type ToolContent1 = ToolTextContent | ToolFileContent1
|
||||
export type SessionShellEnded = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
type: "session.shell.ended"
|
||||
durable: { aggregateID: string; seq: number; version: 1 }
|
||||
location?: LocationRef
|
||||
data: {
|
||||
sessionID: string
|
||||
shell: ShellInfo
|
||||
output: { output: string; cursor: number; size: number; truncated: boolean }
|
||||
}
|
||||
}
|
||||
|
||||
export type ShellCreated = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
type: "shell.created"
|
||||
location?: LocationRef
|
||||
data: { info: ShellInfo }
|
||||
}
|
||||
|
||||
export type ModelCompatibility = {
|
||||
reasoningField?: ModelReasoningField
|
||||
@@ -1703,6 +1793,17 @@ export type SessionInboxUserPayload = {
|
||||
metadata?: { [x: string]: JsonValue }
|
||||
}
|
||||
|
||||
export type SessionMessageUser1 = {
|
||||
id: string
|
||||
metadata?: { [x: string]: any }
|
||||
time: { created: number }
|
||||
text: string
|
||||
files?: Array<PromptFileAttachment>
|
||||
agents?: Array<PromptAgentAttachment>
|
||||
skills?: Array<PromptSkillAttachment>
|
||||
type: "user"
|
||||
}
|
||||
|
||||
export type SessionInboxUserPayload1 = {
|
||||
text: string
|
||||
files?: Array<PromptFileAttachment>
|
||||
@@ -1740,6 +1841,20 @@ export type SessionMessageCompactionCompleted = {
|
||||
providerContext?: SessionProviderContext
|
||||
}
|
||||
|
||||
export type SessionMessageCompactionCompleted1 = {
|
||||
type: "compaction"
|
||||
id: string
|
||||
metadata?: { [x: string]: any }
|
||||
time: { created: number }
|
||||
status: "completed"
|
||||
reason: "auto" | "manual"
|
||||
model?: ModelRef
|
||||
providerState?: SessionMessageProviderState1
|
||||
summary: string
|
||||
recent: string
|
||||
providerContext?: SessionProviderContext
|
||||
}
|
||||
|
||||
export type SessionCompactionEnded = {
|
||||
id: string
|
||||
created: number
|
||||
@@ -1758,20 +1873,19 @@ export type SessionCompactionEnded = {
|
||||
}
|
||||
}
|
||||
|
||||
export type SessionForked = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
type: "session.forked"
|
||||
durable: { aggregateID: string; seq: number; version: 2 }
|
||||
location?: LocationRef
|
||||
data: {
|
||||
sessionID: string
|
||||
parentID: string
|
||||
boundary: SessionForkBoundary
|
||||
instructions?: { [x: string]: string }
|
||||
instructionEntries?: InstructionEntrySnapshot
|
||||
}
|
||||
export type SessionMessageToolStateCompleted1 = {
|
||||
status: "completed"
|
||||
input: { [x: string]: any }
|
||||
content: [ToolContent1, ...Array<ToolContent1>]
|
||||
metadata?: { [x: string]: JsonValue }
|
||||
}
|
||||
|
||||
export type SessionMessageToolStateError1 = {
|
||||
status: "error"
|
||||
input: { [x: string]: any }
|
||||
error: SessionStructuredError
|
||||
content?: [ToolContent1, ...Array<ToolContent1>]
|
||||
metadata?: { [x: string]: JsonValue }
|
||||
}
|
||||
|
||||
export type SessionToolSuccess = {
|
||||
@@ -1811,21 +1925,6 @@ export type SessionToolFailed = {
|
||||
}
|
||||
}
|
||||
|
||||
export type SessionMessageToolStateCompleted1 = {
|
||||
status: "completed"
|
||||
input: { [x: string]: any }
|
||||
content: [ToolContent1, ...Array<ToolContent1>]
|
||||
metadata?: { [x: string]: JsonValue }
|
||||
}
|
||||
|
||||
export type SessionMessageToolStateError1 = {
|
||||
status: "error"
|
||||
input: { [x: string]: any }
|
||||
error: SessionStructuredError
|
||||
content?: [ToolContent1, ...Array<ToolContent1>]
|
||||
metadata?: { [x: string]: JsonValue }
|
||||
}
|
||||
|
||||
export type ModelInfo = {
|
||||
id: string
|
||||
modelID: string
|
||||
@@ -2104,6 +2203,11 @@ export type SessionMessageCompaction =
|
||||
| SessionMessageCompactionCompleted
|
||||
| SessionMessageCompactionFailed
|
||||
|
||||
export type SessionMessageCompaction1 =
|
||||
| SessionMessageCompactionRunning1
|
||||
| SessionMessageCompactionCompleted1
|
||||
| SessionMessageCompactionFailed1
|
||||
|
||||
export type SessionMessageAssistantTool1 = {
|
||||
type: "tool"
|
||||
id: string
|
||||
@@ -2153,6 +2257,24 @@ export type SessionMessageAssistant = {
|
||||
retry?: SessionMessageAssistantRetry
|
||||
}
|
||||
|
||||
export type SessionMessageAssistant1 = {
|
||||
id: string
|
||||
metadata?: { [x: string]: any }
|
||||
time: { created: number; streamed?: number; completed?: number }
|
||||
type: "assistant"
|
||||
agent: string
|
||||
model: ModelRef
|
||||
content: Array<SessionMessageAssistantText1 | SessionMessageAssistantReasoning1 | SessionMessageAssistantTool1>
|
||||
snapshot?: { start?: string; end?: string; files?: Array<string> }
|
||||
finish?: "stop" | "length" | "tool-calls" | "content-filter" | "error" | "unknown"
|
||||
rawFinish?: string
|
||||
providerState?: SessionMessageProviderState1
|
||||
cost?: MoneyUSD
|
||||
tokens?: TokenUsageInfo
|
||||
error?: SessionStructuredError
|
||||
retry?: SessionMessageAssistantRetry
|
||||
}
|
||||
|
||||
export type SessionMessageAssistantContentEncoded =
|
||||
| SessionMessageAssistantText1
|
||||
| SessionMessageAssistantReasoning1
|
||||
@@ -2178,6 +2300,18 @@ export type SessionMessageInfo =
|
||||
| SessionMessageAssistant
|
||||
| SessionMessageCompaction
|
||||
|
||||
export type SessionMessageInfoEncoded =
|
||||
| SessionMessageAgentSelected1
|
||||
| SessionMessageModelSelected1
|
||||
| SessionMessageLocationSwitched1
|
||||
| SessionMessageUser1
|
||||
| SessionMessageSynthetic1
|
||||
| SessionMessageSystem1
|
||||
| SessionMessageSkill1
|
||||
| SessionMessageShell1
|
||||
| SessionMessageAssistant1
|
||||
| SessionMessageCompaction1
|
||||
|
||||
export type SessionMessageContentUpdated = {
|
||||
id: string
|
||||
created: number
|
||||
@@ -2210,6 +2344,31 @@ export type SessionMessagesResponse = {
|
||||
cursor: { previous?: string | null; next?: string | null }
|
||||
}
|
||||
|
||||
export type SessionForked = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
type: "session.forked"
|
||||
durable: { aggregateID: string; seq: number; version: 2 }
|
||||
location?: LocationRef
|
||||
data: {
|
||||
sessionID: string
|
||||
parentID: string
|
||||
boundary: SessionForkBoundary
|
||||
messages?: Array<SessionMessageInfoEncoded>
|
||||
instructions?: { [x: string]: string }
|
||||
instructionEntries?: InstructionEntrySnapshot
|
||||
}
|
||||
}
|
||||
|
||||
export type IntegrationInfo = {
|
||||
id: string
|
||||
name: string
|
||||
metadata?: { [x: string]: any }
|
||||
methods: Array<IntegrationMethod>
|
||||
connections: Array<ConnectionInfo>
|
||||
}
|
||||
|
||||
export type SessionEventDurable =
|
||||
| SessionCreated
|
||||
| SessionAgentSelected
|
||||
@@ -2255,14 +2414,6 @@ export type SessionEventDurable =
|
||||
| SessionMessageContentUpdated
|
||||
| SessionUsageRecorded
|
||||
|
||||
export type IntegrationInfo = {
|
||||
id: string
|
||||
name: string
|
||||
metadata?: { [x: string]: any }
|
||||
methods: Array<IntegrationMethod>
|
||||
connections: Array<ConnectionInfo>
|
||||
}
|
||||
|
||||
export type V2Event =
|
||||
| ModelsDevRefreshed
|
||||
| CredentialUpdated
|
||||
|
||||
@@ -91,6 +91,12 @@ runtime.execute(source) // Effect<CodeMode.Result, never, ToolServices>
|
||||
The Effect environment is inferred from the supplied tools. `onToolCallStart` observes admitted calls with decoded
|
||||
input; `onToolCallEnd` observes settled outcomes and duration. Both hooks return Effects and must not fail.
|
||||
|
||||
### `Values`
|
||||
|
||||
`Values` exports the runtime's non-JSON value classes: `Values.URL`, `Values.URLSearchParams`, `Values.Date`,
|
||||
`Values.RegExp`, `Values.Map`, `Values.Set`, and `Values.Promise`. The interpreter recognizes these by class; a
|
||||
program's `new URL(...)` is a `Values.URL` wrapping the host `URL`. `Values.isValue` narrows to the data-like kinds.
|
||||
|
||||
### OpenAPI tools
|
||||
|
||||
`OpenAPI.fromSpec` converts an OpenAPI 3.x document into one tool per supported operation. Dotted `operationId` values
|
||||
|
||||
@@ -2,5 +2,6 @@ export * as CodeMode from "./codemode.js"
|
||||
export * as Namespace from "./namespace.js"
|
||||
export * as Tool from "./tool.js"
|
||||
export * as OpenAPI from "./openapi/index.js"
|
||||
export { Values } from "./values.js"
|
||||
export { searchSignature, toolExpression } from "./codemode.js"
|
||||
export { ToolError, toolError } from "./tool-error.js"
|
||||
|
||||
@@ -16,16 +16,7 @@ import {
|
||||
} from "./model.js"
|
||||
import { containsOpaqueReference, isRuntimeReference, rejectCircularInsertion, typeofValue } from "./references.js"
|
||||
import { isBlockedMember, type SafeObject } from "../tool-runtime.js"
|
||||
import {
|
||||
CodeModeDate,
|
||||
CodeModeMap,
|
||||
CodeModePromise,
|
||||
CodeModeRegExp,
|
||||
CodeModeSet,
|
||||
CodeModeURL,
|
||||
CodeModeURLSearchParams,
|
||||
isCodeModeValue,
|
||||
} from "../values.js"
|
||||
import { Values } from "../values.js"
|
||||
import { dateSetterArgumentCount, invokeDateMethod, invokeDateStatic } from "../stdlib/date.js"
|
||||
import { invokeMathMethod } from "../stdlib/math.js"
|
||||
import { invokeNumberMethod, invokeNumberStatic } from "../stdlib/number.js"
|
||||
@@ -43,7 +34,7 @@ export type CallbackRunner<R> = {
|
||||
args: Array<unknown>,
|
||||
node: AstNode,
|
||||
) => Effect.Effect<unknown, unknown, R>
|
||||
readonly settlePromise: (promise: CodeModePromise) => Effect.Effect<unknown, unknown, never>
|
||||
readonly settlePromise: (promise: Values.Promise) => Effect.Effect<unknown, unknown, never>
|
||||
}
|
||||
|
||||
// The single acceptance list for callbacks: collections, sort, string replacers,
|
||||
@@ -100,7 +91,7 @@ export const invokeIntrinsic = <R>(
|
||||
if (Array.isArray(ref.receiver)) {
|
||||
return invokeArrayMethod(runner, ref.receiver, ref.name, args, node)
|
||||
}
|
||||
if (ref.receiver instanceof CodeModeDate) {
|
||||
if (ref.receiver instanceof Values.Date) {
|
||||
const target = ref.receiver
|
||||
const argumentCount = dateSetterArgumentCount(ref.name)
|
||||
if (argumentCount === undefined) return Effect.succeed(invokeDateMethod(target, ref.name, [], node))
|
||||
@@ -113,19 +104,19 @@ export const invokeIntrinsic = <R>(
|
||||
(values) => invokeDateMethod(target, ref.name, values, node, initialTime),
|
||||
)
|
||||
}
|
||||
if (ref.receiver instanceof CodeModeRegExp) {
|
||||
if (ref.receiver instanceof Values.RegExp) {
|
||||
return Effect.succeed(invokeRegExpMethod(ref.receiver, ref.name, args, node))
|
||||
}
|
||||
if (ref.receiver instanceof CodeModeMap) {
|
||||
if (ref.receiver instanceof Values.Map) {
|
||||
return invokeMapMethod(runner, ref.receiver, ref.name, args, node)
|
||||
}
|
||||
if (ref.receiver instanceof CodeModeSet) {
|
||||
if (ref.receiver instanceof Values.Set) {
|
||||
return invokeSetMethod(runner, ref.receiver, ref.name, args, node)
|
||||
}
|
||||
if (ref.receiver instanceof CodeModeURL) {
|
||||
if (ref.receiver instanceof Values.URL) {
|
||||
return Effect.succeed(invokeURLMethod(ref.receiver, ref.name, node))
|
||||
}
|
||||
if (ref.receiver instanceof CodeModeURLSearchParams) {
|
||||
if (ref.receiver instanceof Values.URLSearchParams) {
|
||||
return invokeURLSearchParamsMethod(runner, ref.receiver, ref.name, args, node)
|
||||
}
|
||||
throw new InterpreterRuntimeError(`Method '${ref.name}' is not available.`, node)
|
||||
@@ -136,7 +127,7 @@ const coerceNumericArgument = <R>(
|
||||
value: unknown,
|
||||
node: AstNode,
|
||||
): Effect.Effect<number, unknown, R> => {
|
||||
if (value === null || typeof value !== "object" || Array.isArray(value) || isCodeModeValue(value)) {
|
||||
if (value === null || typeof value !== "object" || Array.isArray(value) || Values.isValue(value)) {
|
||||
return Effect.succeed(coerceToNumber(value))
|
||||
}
|
||||
const object = value as Record<string, unknown>
|
||||
@@ -192,7 +183,7 @@ const invokeStringMethod = (value: string, name: string, args: Array<unknown>, n
|
||||
const optNum = (index: number): number | undefined => (args[index] === undefined ? undefined : num(index))
|
||||
const optStr = (index: number): string | undefined => (args[index] === undefined ? undefined : str(index))
|
||||
const rejectRegex = (): void => {
|
||||
if (args[0] instanceof CodeModeRegExp) {
|
||||
if (args[0] instanceof Values.RegExp) {
|
||||
throw new InterpreterRuntimeError(
|
||||
`String.${name} cannot take a regular expression; use regex.test(string) or String.search instead.`,
|
||||
node,
|
||||
@@ -241,7 +232,7 @@ const invokeStringMethod = (value: string, name: string, args: Array<unknown>, n
|
||||
result = requestedLimit !== undefined && requestedLimit >>> 0 === 0 ? [] : [value]
|
||||
break
|
||||
}
|
||||
if (args[0] instanceof CodeModeRegExp) {
|
||||
if (args[0] instanceof Values.RegExp) {
|
||||
result = value.split(args[0].regex, optNum(1))
|
||||
break
|
||||
}
|
||||
@@ -272,7 +263,7 @@ const invokeStringMethod = (value: string, name: string, args: Array<unknown>, n
|
||||
break
|
||||
case "replace":
|
||||
case "replaceAll": {
|
||||
if (args[0] instanceof CodeModeRegExp) {
|
||||
if (args[0] instanceof Values.RegExp) {
|
||||
const pattern = args[0].regex
|
||||
const replacement = str(1)
|
||||
if (name === "replaceAll" && !pattern.global) {
|
||||
@@ -368,7 +359,7 @@ const invokeArrayStatic = (name: string, args: Array<unknown>, node: AstNode): u
|
||||
}
|
||||
|
||||
const arrayLikeSource = (source: unknown, node: AstNode): { readonly length: number; readonly source: object } => {
|
||||
if (source instanceof CodeModePromise) {
|
||||
if (source instanceof Values.Promise) {
|
||||
throw new InterpreterRuntimeError(
|
||||
"Array.from received an un-awaited Promise; await it before creating the array.",
|
||||
node,
|
||||
@@ -445,7 +436,7 @@ export const invokeGroupBy = <R>(
|
||||
throw new InterpreterRuntimeError(`${namespace}.groupBy expects an iterable collection.`, node).as("TypeError")
|
||||
}
|
||||
if (namespace === "Map") {
|
||||
const result = new CodeModeMap()
|
||||
const result = new Values.Map()
|
||||
let index = 0
|
||||
while (true) {
|
||||
const step = yield* cursor.next
|
||||
@@ -488,10 +479,10 @@ const coerceGroupByPropertyKey = <R>(
|
||||
value: unknown,
|
||||
node: AstNode,
|
||||
): Effect.Effect<string, unknown, R> => {
|
||||
if (value === null || typeof value !== "object" || Array.isArray(value) || isCodeModeValue(value)) {
|
||||
if (value === null || typeof value !== "object" || Array.isArray(value) || Values.isValue(value)) {
|
||||
return Effect.succeed(coerceToString(value))
|
||||
}
|
||||
if (value instanceof CodeModePromise) return Effect.succeed("[object Promise]")
|
||||
if (value instanceof Values.Promise) return Effect.succeed("[object Promise]")
|
||||
if (isRuntimeReference(value)) {
|
||||
throw new InterpreterRuntimeError("Object.groupBy callback must return a data value.", node, "InvalidDataValue")
|
||||
}
|
||||
@@ -543,7 +534,7 @@ const invokeStringReplacer = <R>(
|
||||
}
|
||||
|
||||
const pattern = args[0]
|
||||
if (pattern instanceof CodeModeRegExp) {
|
||||
if (pattern instanceof Values.RegExp) {
|
||||
if (name === "replaceAll" && !pattern.regex.global) {
|
||||
throw new InterpreterRuntimeError(
|
||||
`String.replaceAll requires a regular expression with the global (g) flag: write /${pattern.regex.source}/${pattern.regex.flags}g, or use String.replace to replace only the first match.`,
|
||||
@@ -566,7 +557,7 @@ const invokeStringReplacer = <R>(
|
||||
// Error values are branded plain objects; boundedData would strip the brand before coercion.
|
||||
output.push(
|
||||
value.slice(end, match.offset),
|
||||
replacement instanceof CodeModePromise
|
||||
replacement instanceof Values.Promise
|
||||
? "[object Promise]"
|
||||
: errorBrandName(replacement)
|
||||
? coerceToString(replacement)
|
||||
@@ -599,7 +590,7 @@ export const applyCollectionCallback = <R>(
|
||||
|
||||
const invokeMapMethod = <R>(
|
||||
runner: CallbackRunner<R>,
|
||||
target: CodeModeMap,
|
||||
target: Values.Map,
|
||||
name: string,
|
||||
args: Array<unknown>,
|
||||
node: AstNode,
|
||||
@@ -641,7 +632,7 @@ const invokeMapMethod = <R>(
|
||||
|
||||
const invokeSetMethod = <R>(
|
||||
runner: CallbackRunner<R>,
|
||||
target: CodeModeSet,
|
||||
target: Values.Set,
|
||||
name: string,
|
||||
args: Array<unknown>,
|
||||
node: AstNode,
|
||||
@@ -688,7 +679,7 @@ const invokeSetMethod = <R>(
|
||||
|
||||
const invokeSetOperation = <R>(
|
||||
runner: CallbackRunner<R>,
|
||||
target: CodeModeSet,
|
||||
target: Values.Set,
|
||||
name: string,
|
||||
source: unknown,
|
||||
node: AstNode,
|
||||
@@ -701,7 +692,7 @@ const invokeSetOperation = <R>(
|
||||
return result
|
||||
}
|
||||
if (name === "intersection") {
|
||||
const result = new CodeModeSet()
|
||||
const result = new Values.Set()
|
||||
if (target.set.size <= other.size) {
|
||||
for (const item of target.set.values()) {
|
||||
if (yield* other.has(item)) result.set.add(item)
|
||||
@@ -758,28 +749,28 @@ const invokeSetOperation = <R>(
|
||||
return true
|
||||
})
|
||||
|
||||
const copySet = (source: CodeModeSet): CodeModeSet => {
|
||||
const result = new CodeModeSet()
|
||||
const copySet = (source: Values.Set): Values.Set => {
|
||||
const result = new Values.Set()
|
||||
for (const item of source.set.values()) result.set.add(item)
|
||||
return result
|
||||
}
|
||||
|
||||
const loadSetRecord = <R>(runner: CallbackRunner<R>, source: unknown, name: string, node: AstNode) => {
|
||||
if (source instanceof CodeModeSet) {
|
||||
if (source instanceof Values.Set) {
|
||||
return Effect.succeed({
|
||||
size: source.set.size,
|
||||
has: (item: unknown) => Effect.succeed(source.set.has(item)),
|
||||
keys: () => Effect.succeed(source.set.values()),
|
||||
})
|
||||
}
|
||||
if (source instanceof CodeModeMap) {
|
||||
if (source instanceof Values.Map) {
|
||||
return Effect.succeed({
|
||||
size: source.map.size,
|
||||
has: (item: unknown) => Effect.succeed(source.map.has(item)),
|
||||
keys: () => Effect.succeed(source.map.keys()),
|
||||
})
|
||||
}
|
||||
if (source === null || typeof source !== "object" || isCodeModeValue(source)) {
|
||||
if (source === null || typeof source !== "object" || Values.isValue(source)) {
|
||||
throw new InterpreterRuntimeError(`Set.${name} expects a Set-like object.`, node).as("TypeError")
|
||||
}
|
||||
const object = source as Record<string, unknown>
|
||||
@@ -809,7 +800,7 @@ const loadSetRecord = <R>(runner: CallbackRunner<R>, source: unknown, name: stri
|
||||
|
||||
const invokeURLSearchParamsMethod = <R>(
|
||||
runner: CallbackRunner<R>,
|
||||
target: CodeModeURLSearchParams,
|
||||
target: Values.URLSearchParams,
|
||||
name: string,
|
||||
args: Array<unknown>,
|
||||
node: AstNode,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { Effect } from "effect"
|
||||
import type { SafeObject } from "../tool-runtime.js"
|
||||
import type { CodeModePromise, CodeModeRegExp, CodeModeURL } from "../values.js"
|
||||
import type { Values } from "../values.js"
|
||||
|
||||
export type SourcePosition = {
|
||||
line: number
|
||||
@@ -36,7 +36,7 @@ export type StatementResult =
|
||||
| { kind: "continue"; label?: string }
|
||||
|
||||
export type MemberReference = {
|
||||
target: SafeObject | Array<unknown> | CodeModeRegExp | CodeModeURL
|
||||
target: SafeObject | Array<unknown> | Values.RegExp | Values.URL
|
||||
key: PropertyKey
|
||||
}
|
||||
|
||||
@@ -99,7 +99,7 @@ export type PromiseInstanceMethodName = "then" | "catch" | "finally"
|
||||
|
||||
export class PromiseInstanceMethodReference {
|
||||
constructor(
|
||||
readonly promise: CodeModePromise,
|
||||
readonly promise: Values.Promise,
|
||||
readonly name: PromiseInstanceMethodName,
|
||||
) {}
|
||||
}
|
||||
|
||||
@@ -14,25 +14,25 @@ import { caughtErrorValue, normalizeError } from "./errors.js"
|
||||
import { applyCollectionCallback, isSupportedCallback, type CallbackRunner, type SupportedCallback } from "./methods.js"
|
||||
import { typeofValue } from "./references.js"
|
||||
import { createAggregateErrorValue } from "../stdlib/value.js"
|
||||
import { CodeModePromise } from "../values.js"
|
||||
import { Values } from "../values.js"
|
||||
import type { SyncIteratorRunner } from "./iterator.js"
|
||||
|
||||
// Observation only controls rejection reporting; program completion interrupts all promise work.
|
||||
export class PromiseRuntime<R> {
|
||||
private readonly active = new Set<CodeModePromise>()
|
||||
private readonly ids = new WeakMap<CodeModePromise, number>()
|
||||
private readonly observed = new WeakSet<CodeModePromise>()
|
||||
private readonly active = new Set<Values.Promise>()
|
||||
private readonly ids = new WeakMap<Values.Promise, number>()
|
||||
private readonly observed = new WeakSet<Values.Promise>()
|
||||
private readonly failures = new Map<number, Diagnostic>()
|
||||
private nextID = 0
|
||||
|
||||
constructor(private readonly scope: Scope.Scope) {}
|
||||
|
||||
create(effect: Effect.Effect<unknown, unknown, R>): Effect.Effect<CodeModePromise, never, R> {
|
||||
create(effect: Effect.Effect<unknown, unknown, R>): Effect.Effect<Values.Promise, never, R> {
|
||||
return Effect.suspend(() => {
|
||||
// Allocate before forking so reruns get distinct IDs and diagnostics retain creation order.
|
||||
const id = this.nextID++
|
||||
return Effect.map(Effect.forkIn(effect, this.scope, { startImmediately: true }), (fiber) => {
|
||||
const promise = new CodeModePromise(fiber)
|
||||
const promise = new Values.Promise(fiber)
|
||||
this.active.add(promise)
|
||||
this.ids.set(promise, id)
|
||||
fiber.addObserver((exit) => {
|
||||
@@ -53,14 +53,14 @@ export class PromiseRuntime<R> {
|
||||
}
|
||||
|
||||
// Observation must be recorded when responsibility transfers, before the consumer fiber runs.
|
||||
markObserved(promise: CodeModePromise): void {
|
||||
markObserved(promise: Values.Promise): void {
|
||||
this.observed.add(promise)
|
||||
const id = this.ids.get(promise)
|
||||
this.ids.delete(promise)
|
||||
if (id !== undefined) this.failures.delete(id)
|
||||
}
|
||||
|
||||
await(promise: CodeModePromise): Effect.Effect<Exit.Exit<unknown, unknown>> {
|
||||
await(promise: Values.Promise): Effect.Effect<Exit.Exit<unknown, unknown>> {
|
||||
return Fiber.await(promise.fiber)
|
||||
}
|
||||
|
||||
@@ -91,10 +91,10 @@ export const resolvePromiseValue = <R>(
|
||||
runner: CallbackRunner<R>,
|
||||
value: unknown,
|
||||
node: AstNode,
|
||||
own?: { promise?: CodeModePromise },
|
||||
own?: { promise?: Values.Promise },
|
||||
): Effect.Effect<unknown, unknown, R> => {
|
||||
if (own?.promise !== undefined && value === own.promise) return Effect.fail(selfResolutionError(node))
|
||||
if (value instanceof CodeModePromise) return runner.settlePromise(value)
|
||||
if (value instanceof Values.Promise) return runner.settlePromise(value)
|
||||
if (value === null || typeof value !== "object" || !Object.hasOwn(value, "then")) return Effect.succeed(value)
|
||||
const then = (value as SafeObject).then
|
||||
if (typeofValue(then) !== "function") return Effect.succeed(value)
|
||||
@@ -123,9 +123,9 @@ export const resolvePromise = <R>(
|
||||
promises: PromiseRuntime<R>,
|
||||
value: unknown,
|
||||
node: AstNode,
|
||||
): Effect.Effect<CodeModePromise, never, R> => {
|
||||
if (value instanceof CodeModePromise) return Effect.succeed(value)
|
||||
const box: { promise?: CodeModePromise } = {}
|
||||
): Effect.Effect<Values.Promise, never, R> => {
|
||||
if (value instanceof Values.Promise) return Effect.succeed(value)
|
||||
const box: { promise?: Values.Promise } = {}
|
||||
return Effect.map(promises.create(resolvePromiseValue(runner, value, node, box)), (promise) => {
|
||||
box.promise = promise
|
||||
return promise
|
||||
@@ -155,7 +155,7 @@ export const invokePromiseMethod = <R>(
|
||||
node,
|
||||
).as("TypeError")
|
||||
}
|
||||
const items: Array<CodeModePromise> = []
|
||||
const items: Array<Values.Promise> = []
|
||||
while (true) {
|
||||
const step = yield* cursor.next
|
||||
if (step.done) break
|
||||
@@ -227,7 +227,7 @@ export const invokePromiseInstanceMethod = <R>(
|
||||
ref: PromiseInstanceMethodReference,
|
||||
args: Array<unknown>,
|
||||
node: AstNode,
|
||||
): Effect.Effect<CodeModePromise, never, R> => {
|
||||
): Effect.Effect<Values.Promise, never, R> => {
|
||||
const method = `Promise.prototype.${ref.name}`
|
||||
promises.markObserved(ref.promise)
|
||||
if (ref.name === "finally") {
|
||||
@@ -243,7 +243,7 @@ export const constructPromise = <R>(
|
||||
promises: PromiseRuntime<R>,
|
||||
executor: unknown,
|
||||
node: AstNode,
|
||||
): Effect.Effect<CodeModePromise, unknown, R> => {
|
||||
): Effect.Effect<Values.Promise, unknown, R> => {
|
||||
if (!(executor instanceof CodeModeFunction)) {
|
||||
throw new InterpreterRuntimeError(
|
||||
"new Promise(...) expects an executor function (e.g. new Promise((resolve, reject) => { ... })).",
|
||||
@@ -252,7 +252,7 @@ export const constructPromise = <R>(
|
||||
}
|
||||
return Effect.gen(function* () {
|
||||
const deferred = Deferred.makeUnsafe<unknown, unknown>()
|
||||
const box: { promise?: CodeModePromise } = {}
|
||||
const box: { promise?: Values.Promise } = {}
|
||||
const promise = yield* promises.create(
|
||||
Effect.flatMap(Deferred.await(deferred), (value) => resolvePromiseValue(runner, value, node, box)),
|
||||
)
|
||||
@@ -294,7 +294,7 @@ const reactionHandler = (value: unknown, method: string, node: AstNode): Support
|
||||
// Teardown bypasses handlers; settled reactions yield once so handlers never run inline.
|
||||
const reactionExit = <R>(
|
||||
promises: PromiseRuntime<R>,
|
||||
source: CodeModePromise,
|
||||
source: Values.Promise,
|
||||
): Effect.Effect<Exit.Exit<unknown, unknown>, unknown, R> =>
|
||||
Effect.gen(function* () {
|
||||
const exit = yield* promises.await(source)
|
||||
@@ -306,13 +306,13 @@ const reactionExit = <R>(
|
||||
const chainReaction = <R>(
|
||||
runner: CallbackRunner<R>,
|
||||
promises: PromiseRuntime<R>,
|
||||
source: CodeModePromise,
|
||||
source: Values.Promise,
|
||||
onFulfilled: SupportedCallback | undefined,
|
||||
onRejected: SupportedCallback | undefined,
|
||||
method: string,
|
||||
node: AstNode,
|
||||
): Effect.Effect<CodeModePromise, never, R> => {
|
||||
const box: { promise?: CodeModePromise } = {}
|
||||
): Effect.Effect<Values.Promise, never, R> => {
|
||||
const box: { promise?: Values.Promise } = {}
|
||||
const body = Effect.gen(function* () {
|
||||
const exit = yield* reactionExit(promises, source)
|
||||
const handler = Exit.isSuccess(exit) ? onFulfilled : onRejected
|
||||
@@ -330,11 +330,11 @@ const chainReaction = <R>(
|
||||
const chainFinally = <R>(
|
||||
runner: CallbackRunner<R>,
|
||||
promises: PromiseRuntime<R>,
|
||||
source: CodeModePromise,
|
||||
source: Values.Promise,
|
||||
cleanup: SupportedCallback | undefined,
|
||||
method: string,
|
||||
node: AstNode,
|
||||
): Effect.Effect<CodeModePromise, never, R> =>
|
||||
): Effect.Effect<Values.Promise, never, R> =>
|
||||
promises.create(
|
||||
Effect.gen(function* () {
|
||||
const exit = yield* reactionExit(promises, source)
|
||||
|
||||
@@ -21,7 +21,7 @@ import {
|
||||
UriFunction,
|
||||
} from "./model.js"
|
||||
import { ToolReference } from "../tool-runtime.js"
|
||||
import { isCodeModeValue, CodeModePromise } from "../values.js"
|
||||
import { Values } from "../values.js"
|
||||
|
||||
export const isRuntimeReference = (value: unknown): boolean =>
|
||||
value instanceof CodeModeFunction ||
|
||||
@@ -35,14 +35,14 @@ export const isRuntimeReference = (value: unknown): boolean =>
|
||||
value instanceof PromiseNamespace ||
|
||||
value instanceof PromiseMethodReference ||
|
||||
value instanceof PromiseInstanceMethodReference ||
|
||||
value instanceof CodeModePromise ||
|
||||
value instanceof Values.Promise ||
|
||||
value instanceof CoercionFunction ||
|
||||
value instanceof UriFunction ||
|
||||
value instanceof SearchFunction ||
|
||||
value instanceof PromiseCapabilityFunction ||
|
||||
value instanceof ErrorConstructorReference ||
|
||||
value instanceof SymbolNamespace ||
|
||||
isCodeModeValue(value)
|
||||
Values.isValue(value)
|
||||
|
||||
function* childValues(value: object): Generator {
|
||||
for (const key of Reflect.ownKeys(value)) {
|
||||
@@ -81,7 +81,7 @@ export const containsOpaqueReference = (value: unknown): boolean => {
|
||||
continue
|
||||
}
|
||||
const current = next.value
|
||||
if (isCodeModeValue(current)) continue
|
||||
if (Values.isValue(current)) continue
|
||||
if (isRuntimeReference(current)) return true
|
||||
if (current === null || typeof current !== "object" || seen.has(current)) continue
|
||||
seen.add(current)
|
||||
|
||||
@@ -98,16 +98,7 @@ import {
|
||||
invokeCoercion,
|
||||
valueConstructors,
|
||||
} from "../stdlib/value.js"
|
||||
import {
|
||||
isCodeModeValue,
|
||||
CodeModeDate,
|
||||
CodeModeMap,
|
||||
CodeModePromise,
|
||||
CodeModeRegExp,
|
||||
CodeModeSet,
|
||||
CodeModeURL,
|
||||
CodeModeURLSearchParams,
|
||||
} from "../values.js"
|
||||
import { Values } from "../values.js"
|
||||
|
||||
const globalStaticMembers: Partial<Record<GlobalNamespaceName, Set<string>>> = {
|
||||
Object: objectStatics,
|
||||
@@ -153,24 +144,24 @@ const instanceofValue = (lhs: unknown, rhs: unknown, node: AstNode): boolean =>
|
||||
if (rhs instanceof GlobalNamespace) {
|
||||
switch (rhs.name) {
|
||||
case "Date":
|
||||
return lhs instanceof CodeModeDate
|
||||
return lhs instanceof Values.Date
|
||||
case "RegExp":
|
||||
return lhs instanceof CodeModeRegExp
|
||||
return lhs instanceof Values.RegExp
|
||||
case "Map":
|
||||
return lhs instanceof CodeModeMap
|
||||
return lhs instanceof Values.Map
|
||||
case "Set":
|
||||
return lhs instanceof CodeModeSet
|
||||
return lhs instanceof Values.Set
|
||||
case "URL":
|
||||
return lhs instanceof CodeModeURL
|
||||
return lhs instanceof Values.URL
|
||||
case "URLSearchParams":
|
||||
return lhs instanceof CodeModeURLSearchParams
|
||||
return lhs instanceof Values.URLSearchParams
|
||||
case "Array":
|
||||
return Array.isArray(lhs)
|
||||
case "Object":
|
||||
return lhs !== null && (typeof lhs === "object" || typeofValue(lhs) === "function")
|
||||
}
|
||||
}
|
||||
if (rhs instanceof PromiseNamespace) return lhs instanceof CodeModePromise
|
||||
if (rhs instanceof PromiseNamespace) return lhs instanceof Values.Promise
|
||||
if (rhs instanceof CoercionFunction && (rhs.name === "Number" || rhs.name === "String" || rhs.name === "Boolean")) {
|
||||
return false
|
||||
}
|
||||
@@ -371,16 +362,16 @@ export class Interpreter<R> {
|
||||
private createToolCallPromise(
|
||||
path: ReadonlyArray<string>,
|
||||
args: Array<unknown>,
|
||||
): Effect.Effect<CodeModePromise, never, R> {
|
||||
): Effect.Effect<Values.Promise, never, R> {
|
||||
return this.createPromise(Effect.suspend(() => this.executeTool(path, args)))
|
||||
}
|
||||
|
||||
private createPromise(effect: Effect.Effect<unknown, unknown, R>): Effect.Effect<CodeModePromise, never, R> {
|
||||
private createPromise(effect: Effect.Effect<unknown, unknown, R>): Effect.Effect<Values.Promise, never, R> {
|
||||
return this.promises.create(effect)
|
||||
}
|
||||
|
||||
// Fiber exits make settlement idempotent; yielding prevents inline continuation.
|
||||
private settlePromise(promise: CodeModePromise): Effect.Effect<unknown, unknown, never> {
|
||||
private settlePromise(promise: Values.Promise): Effect.Effect<unknown, unknown, never> {
|
||||
const promises = this.promises
|
||||
return Effect.suspend(() => {
|
||||
promises.markObserved(promise)
|
||||
@@ -812,11 +803,11 @@ export class Interpreter<R> {
|
||||
? value[Symbol.iterator]()
|
||||
: typeof value === "string"
|
||||
? value[Symbol.iterator]()
|
||||
: value instanceof CodeModeMap
|
||||
: value instanceof Values.Map
|
||||
? value.map.entries()
|
||||
: value instanceof CodeModeSet
|
||||
: value instanceof Values.Set
|
||||
? value.set.values()
|
||||
: value instanceof CodeModeURLSearchParams
|
||||
: value instanceof Values.URLSearchParams
|
||||
? value.params.entries()
|
||||
: undefined
|
||||
if (iterator !== undefined) {
|
||||
@@ -1478,19 +1469,19 @@ export class Interpreter<R> {
|
||||
)
|
||||
}
|
||||
|
||||
private constructDate(args: Array<unknown>, node: AstNode): Effect.Effect<CodeModeDate, unknown, R> {
|
||||
if (args.length === 0) return Effect.succeed(new CodeModeDate(Date.now()))
|
||||
private constructDate(args: Array<unknown>, node: AstNode): Effect.Effect<Values.Date, unknown, R> {
|
||||
if (args.length === 0) return Effect.succeed(new Values.Date(Date.now()))
|
||||
if (args.length === 1) {
|
||||
const arg = args[0]
|
||||
if (arg instanceof CodeModeDate) return Effect.succeed(new CodeModeDate(arg.time))
|
||||
if (arg instanceof Values.Date) return Effect.succeed(new Values.Date(arg.time))
|
||||
return Effect.map(this.toDatePrimitive(arg, node), (value) =>
|
||||
typeof value === "string"
|
||||
? new CodeModeDate(Date.parse(value))
|
||||
: new CodeModeDate(new Date(coerceToNumber(value)).getTime()),
|
||||
? new Values.Date(Date.parse(value))
|
||||
: new Values.Date(new Date(coerceToNumber(value)).getTime()),
|
||||
)
|
||||
}
|
||||
const parts = args.map((arg) => coerceToNumber(arg))
|
||||
return Effect.succeed(new CodeModeDate(new Date(...(parts as [number, number])).getTime()))
|
||||
return Effect.succeed(new Values.Date(new Date(...(parts as [number, number])).getTime()))
|
||||
}
|
||||
|
||||
private toDatePrimitive(value: unknown, node: AstNode): Effect.Effect<unknown, unknown, R> {
|
||||
@@ -1511,10 +1502,10 @@ export class Interpreter<R> {
|
||||
})
|
||||
}
|
||||
|
||||
private constructRegExp(args: Array<unknown>, node: AstNode): CodeModeRegExp {
|
||||
private constructRegExp(args: Array<unknown>, node: AstNode): Values.RegExp {
|
||||
const first = args[0]
|
||||
const pattern =
|
||||
first instanceof CodeModeRegExp ? first.regex.source : first === undefined ? "" : coerceToString(first)
|
||||
first instanceof Values.RegExp ? first.regex.source : first === undefined ? "" : coerceToString(first)
|
||||
const flagsArg = args[1]
|
||||
if (flagsArg !== undefined && typeof flagsArg !== "string") {
|
||||
throw new InterpreterRuntimeError(
|
||||
@@ -1522,9 +1513,9 @@ export class Interpreter<R> {
|
||||
node,
|
||||
).as("SyntaxError")
|
||||
}
|
||||
const flags = flagsArg ?? (first instanceof CodeModeRegExp ? first.regex.flags : "")
|
||||
const flags = flagsArg ?? (first instanceof Values.RegExp ? first.regex.flags : "")
|
||||
try {
|
||||
return new CodeModeRegExp(pattern, flags)
|
||||
return new Values.RegExp(pattern, flags)
|
||||
} catch (error) {
|
||||
const reason = regexFailureReason(error)
|
||||
throw new InterpreterRuntimeError(
|
||||
@@ -1536,8 +1527,8 @@ export class Interpreter<R> {
|
||||
}
|
||||
}
|
||||
|
||||
private constructMap(init: unknown, node: AstNode): Effect.Effect<CodeModeMap, unknown, R> {
|
||||
const target = new CodeModeMap()
|
||||
private constructMap(init: unknown, node: AstNode): Effect.Effect<Values.Map, unknown, R> {
|
||||
const target = new Values.Map()
|
||||
if (init === undefined || init === null) return Effect.succeed(target)
|
||||
const self = this
|
||||
return Effect.gen(function* () {
|
||||
@@ -1566,8 +1557,8 @@ export class Interpreter<R> {
|
||||
})
|
||||
}
|
||||
|
||||
private constructSet(init: unknown, node: AstNode): Effect.Effect<CodeModeSet, unknown, R> {
|
||||
const target = new CodeModeSet()
|
||||
private constructSet(init: unknown, node: AstNode): Effect.Effect<Values.Set, unknown, R> {
|
||||
const target = new Values.Set()
|
||||
if (init === undefined || init === null) return Effect.succeed(target)
|
||||
const self = this
|
||||
return Effect.gen(function* () {
|
||||
@@ -1585,7 +1576,7 @@ export class Interpreter<R> {
|
||||
})
|
||||
}
|
||||
|
||||
private constructURL(args: Array<unknown>, node: AstNode): CodeModeURL {
|
||||
private constructURL(args: Array<unknown>, node: AstNode): Values.URL {
|
||||
if (args.length === 0) {
|
||||
throw new InterpreterRuntimeError("new URL(...) requires a URL string and an optional base URL.", node).as(
|
||||
"TypeError",
|
||||
@@ -1594,7 +1585,7 @@ export class Interpreter<R> {
|
||||
const input = urlArgument(args[0], "new URL input")
|
||||
const base = args[1] === undefined ? undefined : urlArgument(args[1], "new URL base")
|
||||
try {
|
||||
return new CodeModeURL(new URL(input, base))
|
||||
return new Values.URL(new URL(input, base))
|
||||
} catch {
|
||||
throw new InterpreterRuntimeError(
|
||||
`new URL(...) received an invalid URL${base === undefined ? "" : " or base URL"}.`,
|
||||
@@ -1603,14 +1594,14 @@ export class Interpreter<R> {
|
||||
}
|
||||
}
|
||||
|
||||
private constructURLSearchParams(init: unknown, node: AstNode): Effect.Effect<CodeModeURLSearchParams, unknown, R> {
|
||||
if (init === undefined) return Effect.succeed(new CodeModeURLSearchParams(new URLSearchParams()))
|
||||
if (init instanceof CodeModeURLSearchParams) {
|
||||
return Effect.succeed(new CodeModeURLSearchParams(new URLSearchParams(init.params)))
|
||||
private constructURLSearchParams(init: unknown, node: AstNode): Effect.Effect<Values.URLSearchParams, unknown, R> {
|
||||
if (init === undefined) return Effect.succeed(new Values.URLSearchParams(new URLSearchParams()))
|
||||
if (init instanceof Values.URLSearchParams) {
|
||||
return Effect.succeed(new Values.URLSearchParams(new URLSearchParams(init.params)))
|
||||
}
|
||||
if (typeof init === "string") return Effect.succeed(new CodeModeURLSearchParams(new URLSearchParams(init)))
|
||||
if (typeof init === "string") return Effect.succeed(new Values.URLSearchParams(new URLSearchParams(init)))
|
||||
if (init === null || typeof init === "number" || typeof init === "boolean") {
|
||||
return Effect.succeed(new CodeModeURLSearchParams(new URLSearchParams(coerceToString(init))))
|
||||
return Effect.succeed(new Values.URLSearchParams(new URLSearchParams(coerceToString(init))))
|
||||
}
|
||||
const self = this
|
||||
return Effect.gen(function* () {
|
||||
@@ -1626,7 +1617,7 @@ export class Interpreter<R> {
|
||||
node,
|
||||
).as("TypeError")
|
||||
}
|
||||
return new CodeModeURLSearchParams(
|
||||
return new Values.URLSearchParams(
|
||||
new URLSearchParams(entries.map((entry): [string, string] => [entry[0] ?? "", entry[1] ?? ""])),
|
||||
)
|
||||
}
|
||||
@@ -1639,7 +1630,7 @@ export class Interpreter<R> {
|
||||
node,
|
||||
).as("TypeError")
|
||||
}
|
||||
if (isCodeModeValue(init)) return new CodeModeURLSearchParams(new URLSearchParams())
|
||||
if (Values.isValue(init)) return new Values.URLSearchParams(new URLSearchParams())
|
||||
const data = boundedData(init, "new URLSearchParams input")
|
||||
if (data === null || typeof data !== "object") {
|
||||
throw new InterpreterRuntimeError(
|
||||
@@ -1647,7 +1638,7 @@ export class Interpreter<R> {
|
||||
node,
|
||||
).as("TypeError")
|
||||
}
|
||||
return new CodeModeURLSearchParams(
|
||||
return new Values.URLSearchParams(
|
||||
new URLSearchParams(
|
||||
Object.fromEntries(Object.entries(data).map(([key, value]) => [key, coerceToString(value)])),
|
||||
),
|
||||
@@ -1698,7 +1689,7 @@ export class Interpreter<R> {
|
||||
// Null-prototype data needs explicit primitive coercion; identity and `in` retain raw objects.
|
||||
// Dates use their default string hint for addition and loose equality, and epoch time elsewhere.
|
||||
const coerceOperand = (operand: unknown): unknown => {
|
||||
if (operand instanceof CodeModeDate) {
|
||||
if (operand instanceof Values.Date) {
|
||||
return operator === "+" || operator === "==" || operator === "!=" ? coerceToString(operand) : operand.time
|
||||
}
|
||||
return operand !== null && typeof operand === "object" ? coerceToString(operand) : operand
|
||||
@@ -1783,7 +1774,7 @@ export class Interpreter<R> {
|
||||
throw new InterpreterRuntimeError("Unary operators require data values.", node, "InvalidDataValue")
|
||||
}
|
||||
const operand =
|
||||
value instanceof CodeModeDate
|
||||
value instanceof Values.Date
|
||||
? value.time
|
||||
: value !== null && typeof value === "object"
|
||||
? coerceToString(value)
|
||||
@@ -2110,7 +2101,7 @@ export class Interpreter<R> {
|
||||
if (fn.generator) return Effect.succeed(this.createGenerator(invocation, run, fn.async))
|
||||
if (!fn.async) return run
|
||||
// The initial yield assigns the promise before the body can self-resolve.
|
||||
const box: { promise?: CodeModePromise } = {}
|
||||
const box: { promise?: Values.Promise } = {}
|
||||
return Effect.map(
|
||||
this.createPromise(Effect.flatMap(run, (value) => resolvePromiseValue(invocation.runner, value, fn.body, box))),
|
||||
(promise) => {
|
||||
@@ -2281,9 +2272,9 @@ export class Interpreter<R> {
|
||||
if (
|
||||
Array.isArray(value) ||
|
||||
typeof value === "string" ||
|
||||
value instanceof CodeModeMap ||
|
||||
value instanceof CodeModeSet ||
|
||||
value instanceof CodeModeURLSearchParams
|
||||
value instanceof Values.Map ||
|
||||
value instanceof Values.Set ||
|
||||
value instanceof Values.URLSearchParams
|
||||
) {
|
||||
const cursor = yield* self.syncIterator(value, node)
|
||||
if (!cursor) throw new InterpreterRuntimeError("Built-in iterator is unavailable.", node)
|
||||
@@ -2374,7 +2365,7 @@ export class Interpreter<R> {
|
||||
|
||||
if (property.type === "SpreadElement") {
|
||||
const spread = yield* self.evaluateExpression(getNode(property, "argument"))
|
||||
if (spread === null || spread === undefined || isCodeModeValue(spread)) continue
|
||||
if (spread === null || spread === undefined || Values.isValue(spread)) continue
|
||||
if (typeof spread !== "object" || Array.isArray(spread) || isRuntimeReference(spread)) {
|
||||
throw new InterpreterRuntimeError("Object spread requires a data object.", property, "InvalidDataValue")
|
||||
}
|
||||
@@ -2598,11 +2589,11 @@ export class Interpreter<R> {
|
||||
return new ComputedValue(undefined)
|
||||
}
|
||||
|
||||
if (objectValue instanceof CodeModeDate) {
|
||||
if (objectValue instanceof Values.Date) {
|
||||
if (typeof key === "string" && dateMethods.has(key)) return new IntrinsicReference(objectValue, key)
|
||||
return new ComputedValue(undefined)
|
||||
}
|
||||
if (objectValue instanceof CodeModeRegExp) {
|
||||
if (objectValue instanceof Values.RegExp) {
|
||||
if (key === "lastIndex") return { target: objectValue, key }
|
||||
if (typeof key === "string" && regexpProperties.has(key)) {
|
||||
return new ComputedValue((objectValue.regex as unknown as Record<string, unknown>)[key])
|
||||
@@ -2610,17 +2601,17 @@ export class Interpreter<R> {
|
||||
if (typeof key === "string" && regexpMethods.has(key)) return new IntrinsicReference(objectValue, key)
|
||||
return new ComputedValue(undefined)
|
||||
}
|
||||
if (objectValue instanceof CodeModeMap) {
|
||||
if (objectValue instanceof Values.Map) {
|
||||
if (key === "size") return new ComputedValue(objectValue.map.size)
|
||||
if (typeof key === "string" && mapMethods.has(key)) return new IntrinsicReference(objectValue, key)
|
||||
return new ComputedValue(undefined)
|
||||
}
|
||||
if (objectValue instanceof CodeModeSet) {
|
||||
if (objectValue instanceof Values.Set) {
|
||||
if (key === "size") return new ComputedValue(objectValue.set.size)
|
||||
if (typeof key === "string" && setMethods.has(key)) return new IntrinsicReference(objectValue, key)
|
||||
return new ComputedValue(undefined)
|
||||
}
|
||||
if (objectValue instanceof CodeModeURL) {
|
||||
if (objectValue instanceof Values.URL) {
|
||||
if (key === "searchParams") {
|
||||
return new ComputedValue(objectValue.searchParams)
|
||||
}
|
||||
@@ -2628,7 +2619,7 @@ export class Interpreter<R> {
|
||||
if (typeof key === "string" && urlProperties.has(key)) return { target: objectValue, key }
|
||||
return new ComputedValue(undefined)
|
||||
}
|
||||
if (objectValue instanceof CodeModeURLSearchParams) {
|
||||
if (objectValue instanceof Values.URLSearchParams) {
|
||||
if (key === "size") return new ComputedValue(objectValue.params.size)
|
||||
if (typeof key === "string" && urlSearchParamsMethods.has(key)) {
|
||||
return new IntrinsicReference(objectValue, key)
|
||||
@@ -2637,7 +2628,7 @@ export class Interpreter<R> {
|
||||
}
|
||||
|
||||
// Reject unknown promise properties so a missing await cannot hide.
|
||||
if (objectValue instanceof CodeModePromise) {
|
||||
if (objectValue instanceof Values.Promise) {
|
||||
if (key === "then" || key === "catch" || key === "finally") {
|
||||
return new PromiseInstanceMethodReference(objectValue, key)
|
||||
}
|
||||
@@ -2703,8 +2694,8 @@ export class Interpreter<R> {
|
||||
if (typeof reference.key === "string") return new IntrinsicReference(reference.target, reference.key)
|
||||
return Reflect.get(reference.target, reference.key)
|
||||
}
|
||||
if (reference.target instanceof CodeModeRegExp) return reference.target.lastIndex
|
||||
if (reference.target instanceof CodeModeURL) {
|
||||
if (reference.target instanceof Values.RegExp) return reference.target.lastIndex
|
||||
if (reference.target instanceof Values.URL) {
|
||||
return Reflect.get(reference.target.url, reference.key)
|
||||
}
|
||||
return Reflect.get(reference.target, reference.key)
|
||||
@@ -2726,11 +2717,11 @@ export class Interpreter<R> {
|
||||
reference instanceof ComputedValue ||
|
||||
reference === undefined ||
|
||||
isOpaqueMemberReference(reference) ||
|
||||
reference.target instanceof CodeModeURL
|
||||
reference.target instanceof Values.URL
|
||||
) {
|
||||
throw new InterpreterRuntimeError("Only data fields may be deleted.", target, "InvalidDataValue")
|
||||
}
|
||||
if (reference.target instanceof CodeModeRegExp) {
|
||||
if (reference.target instanceof Values.RegExp) {
|
||||
return Reflect.deleteProperty(reference.target.regex, reference.key)
|
||||
}
|
||||
return Reflect.deleteProperty(reference.target, reference.key)
|
||||
@@ -2767,10 +2758,10 @@ export class Interpreter<R> {
|
||||
}
|
||||
|
||||
private readReferenceValue(reference: MemberReference, key: PropertyKey): unknown {
|
||||
if (reference.target instanceof CodeModeURL) {
|
||||
if (reference.target instanceof Values.URL) {
|
||||
return Reflect.get(reference.target.url, key)
|
||||
}
|
||||
if (reference.target instanceof CodeModeRegExp) return reference.target.lastIndex
|
||||
if (reference.target instanceof Values.RegExp) return reference.target.lastIndex
|
||||
return Reflect.get(reference.target, key)
|
||||
}
|
||||
|
||||
@@ -2788,7 +2779,7 @@ export class Interpreter<R> {
|
||||
target[key] = next
|
||||
return
|
||||
}
|
||||
if (reference.target instanceof CodeModeURL) {
|
||||
if (reference.target instanceof Values.URL) {
|
||||
const property = key as string
|
||||
if (!urlWritableProperties.has(property)) {
|
||||
throw new InterpreterRuntimeError(`URL.${property} is read-only.`, node).as("TypeError")
|
||||
@@ -2802,7 +2793,7 @@ export class Interpreter<R> {
|
||||
throw new InterpreterRuntimeError(`URL.${property} received an invalid value.`, node).as("TypeError")
|
||||
}
|
||||
}
|
||||
if (reference.target instanceof CodeModeRegExp) {
|
||||
if (reference.target instanceof Values.RegExp) {
|
||||
reference.target.lastIndex = next
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1,15 +1,6 @@
|
||||
import { containsOpaqueReference, containsRuntimeReference, isRuntimeReference } from "../interpreter/references.js"
|
||||
import { copyIn, copyOut } from "../tool-runtime.js"
|
||||
import {
|
||||
isCodeModeValue,
|
||||
CodeModeDate,
|
||||
CodeModeMap,
|
||||
CodeModePromise,
|
||||
CodeModeRegExp,
|
||||
CodeModeSet,
|
||||
CodeModeURL,
|
||||
CodeModeURLSearchParams,
|
||||
} from "../values.js"
|
||||
import { Values } from "../values.js"
|
||||
import { boundedData, coerceToString } from "./value.js"
|
||||
|
||||
export const consoleMethods = new Set(["log", "info", "debug", "warn", "error", "dir", "table"])
|
||||
@@ -34,14 +25,14 @@ const formatConsoleValue = (value: unknown, seen: Set<object>, depth: number): s
|
||||
if (typeof value === "string") return JSON.stringify(value)
|
||||
if (typeof value === "number" || typeof value === "boolean") return String(value)
|
||||
if (typeof value !== "object") return String(value)
|
||||
if (value instanceof CodeModePromise) return "[Promise (await it to get its value)]"
|
||||
if (value instanceof CodeModeDate) return coerceToString(value)
|
||||
if (value instanceof CodeModeRegExp) return coerceToString(value)
|
||||
if (value instanceof CodeModeURL) return coerceToString(value)
|
||||
if (value instanceof CodeModeURLSearchParams) return coerceToString(value)
|
||||
if (value instanceof Values.Promise) return "[Promise (await it to get its value)]"
|
||||
if (value instanceof Values.Date) return coerceToString(value)
|
||||
if (value instanceof Values.RegExp) return coerceToString(value)
|
||||
if (value instanceof Values.URL) return coerceToString(value)
|
||||
if (value instanceof Values.URLSearchParams) return coerceToString(value)
|
||||
if (depth > MAX_CONSOLE_DEPTH) return "..."
|
||||
if (seen.has(value)) return "[Circular]"
|
||||
if (value instanceof CodeModeMap) {
|
||||
if (value instanceof Values.Map) {
|
||||
seen.add(value)
|
||||
try {
|
||||
const entries = Array.from(value.map.entries(), ([key, item]): Array<unknown> => [key, item])
|
||||
@@ -50,7 +41,7 @@ const formatConsoleValue = (value: unknown, seen: Set<object>, depth: number): s
|
||||
seen.delete(value)
|
||||
}
|
||||
}
|
||||
if (value instanceof CodeModeSet) {
|
||||
if (value instanceof Values.Set) {
|
||||
seen.add(value)
|
||||
try {
|
||||
return `Set(${value.set.size}) ${formatConsoleValue(Array.from(value.set.values()), seen, depth + 1)}`
|
||||
@@ -100,14 +91,14 @@ const consoleTableRows = (
|
||||
if (Array.isArray(data)) {
|
||||
return data.map((item, index) => ({ index: String(index), values: consoleTableValues(item, columns) }))
|
||||
}
|
||||
if (data !== null && typeof data === "object" && !isCodeModeValue(data)) {
|
||||
if (data !== null && typeof data === "object" && !Values.isValue(data)) {
|
||||
return Object.entries(data).map(([index, item]) => ({ index, values: consoleTableValues(item, columns) }))
|
||||
}
|
||||
return [{ index: "0", values: { Value: data } }]
|
||||
}
|
||||
|
||||
const consoleTableValues = (value: unknown, columns: ReadonlyArray<string> | undefined): Record<string, unknown> => {
|
||||
if (value !== null && typeof value === "object" && !Array.isArray(value) && !isCodeModeValue(value)) {
|
||||
if (value !== null && typeof value === "object" && !Array.isArray(value) && !Values.isValue(value)) {
|
||||
const source = value as Record<string, unknown>
|
||||
if (columns !== undefined) return Object.fromEntries(columns.map((column) => [column, source[column]]))
|
||||
return Object.fromEntries(Object.entries(source))
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user