diff --git a/bun.lock b/bun.lock index 2380111d1d..02ed7a56e4 100644 --- a/bun.lock +++ b/bun.lock @@ -591,6 +591,7 @@ "react-dom": "^19.2.7", }, "devDependencies": { + "@napi-rs/canvas": "1.0.2", "@types/bun": "catalog:", "@types/react": "^19.2.17", "@types/react-dom": "^19.2.3", diff --git a/packages/app/src/app.tsx b/packages/app/src/app.tsx index e5c7a13de2..0b5e7092b5 100644 --- a/packages/app/src/app.tsx +++ b/packages/app/src/app.tsx @@ -86,8 +86,8 @@ function TargetServerRoute(props: ParentProps) { return ( // Owns the server-identity remount. Session changes must not remount this subtree. - - {props.children} + + {props.children} ) @@ -135,16 +135,14 @@ function DraftRoute() { function ResolvedDraftRoute(props: { draft: DraftTab }) { const global = useGlobal() const conn = createMemo(() => global.servers.list().find((item) => ServerConnection.key(item) === props.draft.server)) - const directory = () => props.draft.directory - const serverKey = () => props.draft.server return ( - - - - - + + + + + @@ -242,7 +240,7 @@ function DesktopCommands() { } type ServerScopedShellProps = ParentProps<{ - directory?: () => string | undefined + directory?: string serverScoped?: JSX.Element }> diff --git a/packages/app/src/components/debug-bar.tsx b/packages/app/src/components/debug-bar.tsx index 9465374cbb..67cd0da0e9 100644 --- a/packages/app/src/components/debug-bar.tsx +++ b/packages/app/src/components/debug-bar.tsx @@ -1,5 +1,5 @@ import { useIsRouting, useLocation } from "@solidjs/router" -import { batch, createEffect, onCleanup, onMount } from "solid-js" +import { batch, createEffect, onCleanup, onMount, Show } from "solid-js" import { createStore } from "solid-js/store" import { makeEventListener } from "@solid-primitives/event-listener" import { Tooltip } from "@opencode-ai/ui/tooltip" @@ -571,7 +571,7 @@ export function DebugBar(props: { inline?: boolean } = {}) { value={language.t(`debugBar.direction.${language.direction()}`)} onClick={() => language.setDirection(language.direction() === "rtl" ? "ltr" : "rtl")} /> - {platform.setForceFocus && ( + void toggleFocus()} /> - )} + ) diff --git a/packages/app/src/components/dialog-connect-provider.tsx b/packages/app/src/components/dialog-connect-provider.tsx index 8a860ac29d..eb2c8f5ac8 100644 --- a/packages/app/src/components/dialog-connect-provider.tsx +++ b/packages/app/src/components/dialog-connect-provider.tsx @@ -12,7 +12,7 @@ import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2" import { DialogBody, DialogHeader, DialogTitle, DialogV2 } from "@opencode-ai/ui/v2/dialog-v2" import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2" import { showToast } from "@/utils/toast" -import { type Accessor, type Component, createMemo, createUniqueId, For, Match, onMount, Show, Switch } from "solid-js" +import { type Component, createMemo, createUniqueId, For, Match, onMount, Show, Switch } from "solid-js" import { createStore } from "solid-js/store" import { useParams } from "@solidjs/router" import { ExternalLink } from "@/components/external-link" @@ -41,7 +41,7 @@ export function useProviderConnectController(options: { onBack?: () => void } = } export const DialogConnectProvider: Component<{ - directory?: Accessor + directory?: string controller?: ReturnType }> = (props) => { const fallback = useProviderConnectController() @@ -137,13 +137,9 @@ export const DialogConnectProvider: Component<{ ) } -function ProviderPicker(props: { - directory?: Accessor - onSelect: (provider: string) => void - onPrepare?: () => void -}) { +function ProviderPicker(props: { directory?: string; onSelect: (provider: string) => void; onPrepare?: () => void }) { const settings = useSettings() - const integrations = useIntegrations(() => props.directory?.()) + const integrations = useIntegrations(() => props.directory) if (settings.general.newLayoutDesigns()) return const language = useLanguage() @@ -364,7 +360,7 @@ function ProviderPickerV2(props: { function ProviderConnection(props: { provider: string - directory?: Accessor + directory?: string onBack: () => void setBack: (handler: () => void) => void }) { @@ -374,8 +370,8 @@ function ProviderConnection(props: { const language = useLanguage() const settings = useSettings() const newLayout = settings.general.newLayoutDesigns - const providers = useProviders(() => props.directory?.()) - const directory = () => props.directory?.() ?? decode64(params.dir) + const providers = useProviders(() => props.directory) + const directory = () => props.directory ?? decode64(params.dir) const controller = createProviderConnectionController({ provider: () => props.provider, diff --git a/packages/app/src/components/dialog-manage-models.tsx b/packages/app/src/components/dialog-manage-models.tsx index d6c5e99186..660d5d4f21 100644 --- a/packages/app/src/components/dialog-manage-models.tsx +++ b/packages/app/src/components/dialog-manage-models.tsx @@ -31,7 +31,7 @@ export const DialogManageModels: Component = () => { const directory = () => decode64(local.slug()) const handleConnectProvider = () => { - void dialog.show(() => ) + void dialog.show(() => ) } const providerRank = (id: string) => popularProviders.indexOf(id) const providerList = (providerID: string) => local.model.list().filter((x) => x.provider.id === providerID) @@ -123,7 +123,7 @@ export const DialogManageModelsV2: Component = () => { const directory = () => decode64(local.slug()) const handleConnectProvider = () => { - void dialog.show(() => ) + void dialog.show(() => ) } const providerList = (providerID: string) => local.model.list().filter((x) => x.provider.id === providerID) const providerVisible = (providerID: string) => diff --git a/packages/app/src/components/dialog-release-notes.tsx b/packages/app/src/components/dialog-release-notes.tsx index d0a35b71be..3827b9e9e7 100644 --- a/packages/app/src/components/dialog-release-notes.tsx +++ b/packages/app/src/components/dialog-release-notes.tsx @@ -1,4 +1,4 @@ -import { createSignal } from "solid-js" +import { createSignal, Index, Show } from "solid-js" import { Dialog } from "@opencode-ai/ui/dialog" import { Button } from "@opencode-ai/ui/button" import { useDialog } from "@opencode-ai/ui/context/dialog" @@ -83,61 +83,71 @@ export function DialogReleaseNotes(props: { highlights: Highlight[] }) { {/* Bottom section - buttons and indicators (fixed position) */}
- {isLast() ? ( + + {language.t("dialog.releaseNotes.action.next")} + + } + > - ) : ( - - )} +
- {paged() && ( +
- {props.highlights.map((_, i) => ( - - ))} + onClick={() => setIndex(i)} + > +
+ + )} +
- )} +
{/* Right side - Media content (edge to edge) */} - {feature()?.media && ( -
- {feature()!.media!.type === "image" ? ( - {feature()!.media!.alt - ) : ( -
- )} + + {(media) => ( +
+ + } + > + {media().alt + +
+ )} +
) diff --git a/packages/app/src/components/dialog-select-model-unpaid-v2.tsx b/packages/app/src/components/dialog-select-model-unpaid-v2.tsx index c3a2aca031..e63ee35a80 100644 --- a/packages/app/src/components/dialog-select-model-unpaid-v2.tsx +++ b/packages/app/src/components/dialog-select-model-unpaid-v2.tsx @@ -37,7 +37,7 @@ export const DialogSelectModelUnpaidV2: Component<{ model?: ModelState }> = (pro void import("./dialog-connect-provider").then((x) => { const controller = x.useProviderConnectController() controller.select(provider) - void dialog.show(() => ) + void dialog.show(() => ) }) } diff --git a/packages/app/src/components/dialog-select-model-unpaid.tsx b/packages/app/src/components/dialog-select-model-unpaid.tsx index 4611a36c95..00ca16cb66 100644 --- a/packages/app/src/components/dialog-select-model-unpaid.tsx +++ b/packages/app/src/components/dialog-select-model-unpaid.tsx @@ -26,7 +26,7 @@ export const DialogSelectModelUnpaid: Component<{ model?: ModelState }> = (props void import("./dialog-connect-provider").then((x) => { const controller = x.useProviderConnectController() controller.select(provider) - void dialog.show(() => ) + void dialog.show(() => ) }) } diff --git a/packages/app/src/components/dialog-select-model.tsx b/packages/app/src/components/dialog-select-model.tsx index 9066f72434..d01ac91aa4 100644 --- a/packages/app/src/components/dialog-select-model.tsx +++ b/packages/app/src/components/dialog-select-model.tsx @@ -148,7 +148,7 @@ export function ModelSelectorPopover(props: { const handleConnectProvider = () => { close("provider") void import("./dialog-connect-provider").then((x) => { - void dialog.show(() => ) + void dialog.show(() => ) }) } const language = useLanguage() @@ -240,7 +240,7 @@ export function ModelSelectorPopoverV2(props: { trigger={props.trigger} models={controller.models} groups={controller.groups} - current={controller.current} + current={controller.current()} select={controller.select} onManage={() => { void import("./dialog-manage-models").then((module) => { @@ -295,7 +295,7 @@ function ModelSelectorPopoverV2View(props: { trigger: ModelSelectorTrigger models: (search: string) => ModelItem[] groups: (models: ModelItem[]) => { category: string; items: ModelItem[] }[] - current: () => string | undefined + current: string | undefined select: (item: ModelItem) => void onManage: () => void onClose: () => void @@ -310,7 +310,7 @@ function ModelSelectorPopoverV2View(props: { const groups = createMemo(() => props.groups(models())) const keys = () => [...models().map(modelKey), manageKey] const initialActive = () => { - const selected = props.current() + const selected = props.current const options = keys() if (selected && options.includes(selected)) return selected return options[0] ?? "" @@ -453,7 +453,7 @@ function ModelSelectorPopoverV2View(props: { {group.items[0].provider.name} - + {(item) => ( { @@ -529,7 +529,7 @@ export const DialogSelectModel: Component<{ provider?: string; model?: ModelStat const provider = () => { void import("./dialog-connect-provider").then((x) => { - void dialog.show(() => ) + void dialog.show(() => ) }) } diff --git a/packages/app/src/components/server/server-row.tsx b/packages/app/src/components/server/server-row.tsx index 8060b0a708..0c98bc9c18 100644 --- a/packages/app/src/components/server/server-row.tsx +++ b/packages/app/src/components/server/server-row.tsx @@ -96,13 +96,16 @@ export function ServerRow(props: ServerRowProps) { {(conn) => (
- {conn().http.username ? ( + {language.t("server.row.noUsername")}} + > {conn().http.username} - ) : ( - {language.t("server.row.noUsername")} - )} + - {conn().http.password && ••••••••} + + •••••••• +
)}
diff --git a/packages/app/src/components/session/session-sortable-tab-v2.tsx b/packages/app/src/components/session/session-sortable-tab-v2.tsx index e109e5d2ec..a78c286cf0 100644 --- a/packages/app/src/components/session/session-sortable-tab-v2.tsx +++ b/packages/app/src/components/session/session-sortable-tab-v2.tsx @@ -12,7 +12,7 @@ import { FileVisual } from "./session-sortable-tab" export function SortableTabV2(props: { tab: string - index: () => number + index: number temporary?: boolean onTabClose: (tab: string) => void onTabDoubleClick?: (tab: string) => void @@ -26,7 +26,7 @@ export function SortableTabV2(props: { return props.tab }, get index() { - return props.index() + return props.index }, }) const path = createMemo(() => file.pathFromTab(props.tab)) diff --git a/packages/app/src/components/session/session-sortable-terminal-tab-v2.tsx b/packages/app/src/components/session/session-sortable-terminal-tab-v2.tsx index a27b29668e..8a695b9d4f 100644 --- a/packages/app/src/components/session/session-sortable-terminal-tab-v2.tsx +++ b/packages/app/src/components/session/session-sortable-terminal-tab-v2.tsx @@ -14,7 +14,7 @@ import { focusTerminalById } from "@/pages/session/helpers" export function SortableTerminalTabV2(props: { terminal: LocalPTY - index: () => number + index: number newLayout: boolean onClose?: () => void }): JSX.Element { @@ -25,7 +25,7 @@ export function SortableTerminalTabV2(props: { return props.terminal.id }, get index() { - return props.index() + return props.index }, }) const [store, setStore] = createStore({ diff --git a/packages/app/src/components/settings-keybinds.tsx b/packages/app/src/components/settings-keybinds.tsx index ce74be4586..638565f535 100644 --- a/packages/app/src/components/settings-keybinds.tsx +++ b/packages/app/src/components/settings-keybinds.tsx @@ -1,5 +1,6 @@ import { Component, For, Show, createMemo, lazy, onCleanup, onMount } from "solid-js" import { createStore } from "solid-js/store" +import { Dynamic } from "solid-js/web" import { makeEventListener } from "@solid-primitives/event-listener" import { Button } from "@opencode-ai/ui/button" import { Icon } from "@opencode-ai/ui/icon" @@ -424,9 +425,9 @@ function SettingsKeybindsV2() { filtered={controller.catalog.filtered} title={controller.catalog.title} keybind={controller.catalog.keybind} - active={controller.capture.active} + active={controller.capture.active()} onCapture={controller.capture.toggle} - hasOverrides={controller.settings.hasOverrides} + hasOverrides={controller.settings.hasOverrides()} onReset={controller.settings.reset} /> ) @@ -437,9 +438,9 @@ function SettingsKeybindsV2View(props: { filtered: (query: string) => Map title: (id: string) => string keybind: (id: string) => string - active: () => string | null + active: string | null onCapture: (id: string) => void - hasOverrides: () => boolean + hasOverrides: boolean onReset: () => void }) { const language = useLanguage() @@ -452,7 +453,7 @@ function SettingsKeybindsV2View(props: {

{language.t("settings.shortcuts.title")}

- + {language.t("settings.shortcuts.reset.button")}
@@ -498,12 +499,12 @@ function SettingsKeybindsV2View(props: { data-keybind-id={id} classList={{ "settings-v2-keybind-button": true, - "settings-v2-keybind-button--active": props.active() === id, + "settings-v2-keybind-button--active": props.active === id, }} onClick={() => props.onCapture(id)} > {language.t("settings.shortcuts.pressKeys")} @@ -674,8 +675,6 @@ export const SettingsKeybinds: Component<{ v2?: boolean }> = (props) => { ) - const List = props.v2 ? SettingsListV2 : SettingsList - const groups = (
= (props) => { > {language.t(groupKey[group])} - + {(id) => (
@@ -735,7 +734,7 @@ export const SettingsKeybinds: Component<{ v2?: boolean }> = (props) => {
)}
-
+
)} diff --git a/packages/app/src/components/settings-v2/dialog-settings-v2.tsx b/packages/app/src/components/settings-v2/dialog-settings-v2.tsx index 8507846f87..8e84c786d7 100644 --- a/packages/app/src/components/settings-v2/dialog-settings-v2.tsx +++ b/packages/app/src/components/settings-v2/dialog-settings-v2.tsx @@ -103,7 +103,7 @@ export const DialogSettings: Component<{ - + diff --git a/packages/app/src/components/settings-v2/providers.tsx b/packages/app/src/components/settings-v2/providers.tsx index bde39ec271..452cdb7723 100644 --- a/packages/app/src/components/settings-v2/providers.tsx +++ b/packages/app/src/components/settings-v2/providers.tsx @@ -4,7 +4,7 @@ import { useDialog } from "@opencode-ai/ui/context/dialog" import { ProviderIcon } from "@opencode-ai/ui/provider-icon" import { showToast } from "@/utils/toast" import { popularProviders, useProviders } from "@/hooks/use-providers" -import { createMemo, type Accessor, type Component, For, Show } from "solid-js" +import { createMemo, type Component, For, Show } from "solid-js" import { useLanguage } from "@/context/language" import { useServerSDK } from "@/context/server-sdk" import { useServerSync } from "@/context/server-sync" @@ -30,14 +30,14 @@ const PROVIDER_NOTES = [ const PROVIDER_ICON_SIZE = 16 export const SettingsProvidersV2: Component<{ - directory: Accessor + directory: string | undefined onBack?: () => void }> = (props) => { const dialog = useDialog() const language = useLanguage() const serverSdk = useServerSDK() const serverSync = useServerSync() - const providers = useProviders(props.directory) + const providers = useProviders(() => props.directory) const providerConnect = useProviderConnectController({ onBack: props.onBack }) const connect = (provider?: string) => { @@ -116,7 +116,7 @@ export const SettingsProvidersV2: Component<{ } const disconnect = async (providerID: string, name: string) => { - const location = props.directory() ? { directory: props.directory() } : undefined + const location = props.directory ? { directory: props.directory } : undefined await serverSdk() .api.integration.get({ integrationID: providerID, location }) .then(async (integration) => { diff --git a/packages/app/src/components/status-popover-body.tsx b/packages/app/src/components/status-popover-body.tsx index 1a65bd38d8..cfb1232c67 100644 --- a/packages/app/src/components/status-popover-body.tsx +++ b/packages/app/src/components/status-popover-body.tsx @@ -1,16 +1,7 @@ import { Switch } from "@opencode-ai/ui/switch" import { Tabs } from "@opencode-ai/ui/tabs" import { showToast } from "@/utils/toast" -import { - type Accessor, - createEffect, - createMemo, - createResource, - For, - type JSXElement, - onCleanup, - Show, -} from "solid-js" +import { createEffect, createMemo, createResource, For, type JSXElement, onCleanup, Show } from "solid-js" import { createStore } from "solid-js/store" import { useLanguage } from "@/context/language" import { usePlatform } from "@/context/platform" @@ -109,7 +100,7 @@ type ServerStatusItem = { onSelect: () => void } -export function StatusPopoverBody(props: { shown: Accessor }) { +export function StatusPopoverBody(props: { shown: boolean }) { const sync = useSync() const sdk = useSDK() const global = useGlobal() @@ -125,10 +116,6 @@ export function StatusPopoverBody(props: { shown: Accessor }) { }) } - createEffect(() => { - if (!props.shown()) return - }) - let dialogRun = 0 let dialogDead = false onCleanup(() => { @@ -147,7 +134,7 @@ export function StatusPopoverBody(props: { shown: Accessor }) { const lspItems = createMemo(() => sync().data.lsp ?? []) const lspCount = createMemo(() => lspItems().length) const [pluginList] = createResource( - () => (props.shown() ? sdk().directory : undefined), + () => (props.shown ? sdk().directory : undefined), (directory) => sdk() .api.plugin.list({ location: { directory } }) diff --git a/packages/app/src/components/status-popover.tsx b/packages/app/src/components/status-popover.tsx index 3d3b03fa38..189be5ce2d 100644 --- a/packages/app/src/components/status-popover.tsx +++ b/packages/app/src/components/status-popover.tsx @@ -74,7 +74,7 @@ export function StatusPopover() {
} > - + @@ -114,7 +114,7 @@ function DirectoryStatusPopover() { onOpenChange: setShown, body: () => ( - + ), })) diff --git a/packages/app/src/components/terminal.tsx b/packages/app/src/components/terminal.tsx index d603614689..b6bc7ebfe1 100644 --- a/packages/app/src/components/terminal.tsx +++ b/packages/app/src/components/terminal.tsx @@ -175,6 +175,7 @@ export const Terminal = (props: TerminalProps) => { const settings = useSettings() const theme = useTheme() const language = useLanguage() + // Intentional mount-time capture: the imperative xterm/WebSocket lifecycle needs stable values, and Terminal remounts when the SDK scope changes. const directory = sdk().directory const url = sdk().url let container!: HTMLDivElement diff --git a/packages/app/src/components/titlebar-tab-nav.tsx b/packages/app/src/components/titlebar-tab-nav.tsx index 2862a79353..6e36dc53de 100644 --- a/packages/app/src/components/titlebar-tab-nav.tsx +++ b/packages/app/src/components/titlebar-tab-nav.tsx @@ -22,14 +22,14 @@ export function TabNavItem(props: { ref?: Ref href: string server: ServerConnection.Key - session: () => SessionInfo | undefined + session: SessionInfo | undefined fallbackTitle?: string onRename: (title: string) => Promise onClose: () => void onNavigate: () => void active?: boolean forceTruncate?: boolean - suppressNavigation?: () => boolean + suppressNavigation?: boolean dragging?: boolean pressed?: boolean hidden?: boolean @@ -52,22 +52,22 @@ export function TabNavItem(props: { if (conn) return global.ensureServerCtx(conn) }) const project = createMemo(() => { - const session = props.session() + const session = props.session if (!session) return return projectForSession(session, serverCtx()?.projects.list() ?? []) }) const title = createMemo(() => { - const session = props.session() + const session = props.session return session ? sessionLabel(session) : props.fallbackTitle }) const projectName = createMemo(() => { - const session = props.session() + const session = props.session if (!session) return return displayName(project() ?? { worktree: session.location.directory }) }) const previewPath = createMemo(() => { - const session = props.session() + const session = props.session if (!session) return const home = serverCtx()?.sync.data.path.home return home ? session.location.directory.replace(home, "~") : session.location.directory @@ -80,7 +80,7 @@ export function TabNavItem(props: { }) const [popoverOpen, setPopoverOpen] = createSignal(false) - const previewBlocked = () => !!props.dragging || editing() || !!props.pressed || !props.session() + const previewBlocked = () => !!props.dragging || editing() || !!props.pressed || !props.session const measureTitleOverflow = () => { if (!titleEl || editing()) { @@ -121,7 +121,7 @@ export function TabNavItem(props: { const closeRename = async (save: boolean) => { if (rename.isPending || !editing()) return - const original = props.session()?.title ?? "" + const original = props.session?.title ?? "" const next = (titleEl.textContent ?? "").trim() titleEl.scrollLeft = 0 @@ -146,7 +146,7 @@ export function TabNavItem(props: { event.preventDefault() event.stopPropagation() if (!canOpenTabRename(props.dragging, editing(), rename.isPending)) return - const session = props.session() + const session = props.session if (!session) return titleEl.textContent = session.title ?? "" setEditing(true) @@ -213,7 +213,7 @@ export function TabNavItem(props: { // Navigate on mousedown to shave the press-release delay off tab switches. if (event.button !== 0) return if (editing()) return - if (props.suppressNavigation?.()) return + if (props.suppressNavigation) return props.onNavigate() }} onClick={(event) => { @@ -221,14 +221,14 @@ export function TabNavItem(props: { // Mouse navigation already happened on mousedown; detail 0 means keyboard activation. if (event.detail > 0) return if (editing()) return - if (props.suppressNavigation?.()) return + if (props.suppressNavigation) return props.onNavigate() }} class="flex h-full min-w-0 flex-1 flex-row items-center gap-1.5 text-[13px] font-medium text-v2-text-text-faint group-data-[active='true']:text-v2-text-text-base group-data-[editing='true']:text-v2-text-text-base [-webkit-user-drag:none]" >
@@ -211,7 +211,7 @@ function DraftTabSlot(props: { export function TitlebarTabStrip(props: { tabs: Tab[] - currentTab: () => Tab | undefined + currentTab: Tab | undefined forceTruncate: boolean onNavigate: (tab: Tab, el?: HTMLDivElement) => void onClose: (tab: Tab) => void @@ -248,7 +248,7 @@ export function TitlebarTabStrip(props: { ]) function selectAdjacentTab(offset: -1 | 1) { - const current = props.currentTab() + const current = props.currentTab const key = adjacentTabKey(visibleTabIds(), current ? tabKey(current) : undefined, offset) const next = props.tabs.find((tab) => tabKey(tab) === key) if (next) props.onNavigate(next) @@ -350,10 +350,10 @@ export function TitlebarTabStrip(props: { props.currentTab() === tab} + index={visibleIndex()} + active={props.currentTab === tab} forceTruncate={props.forceTruncate} - serverCtx={serverCtx} + serverCtx={serverCtx()} onVisibleChange={(visible) => setVisibility(id, visible)} onNavigate={(element) => { ref = element @@ -368,8 +368,8 @@ export function TitlebarTabStrip(props: { props.currentTab() === tab} + index={visibleIndex()} + active={props.currentTab === tab} title={language.t("command.session.new")} onNavigate={(element) => { ref = element diff --git a/packages/app/src/components/titlebar.tsx b/packages/app/src/components/titlebar.tsx index 3bb2951ea5..7b0a1ae93b 100644 --- a/packages/app/src/components/titlebar.tsx +++ b/packages/app/src/components/titlebar.tsx @@ -46,8 +46,8 @@ const windowsControlsBaseWidth = 138 // 3 native Windows caption buttons at 46px const macTrafficLightsBaseWidth = 84 export type TitlebarUpdate = { - version: () => string | undefined - installing: () => boolean + version: string | undefined + installing: boolean install: () => void } @@ -121,8 +121,8 @@ export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visibl const hasProjects = createMemo(() => layout.projects.list().length > 0) const nav = createMemo(() => (useV2Titlebar() ? settings.general.showNavigation() : true)) const updateState = createMemo(() => { - const installing = props.update?.installing() ?? false - const version = props.update?.version() + const installing = props.update?.installing ?? false + const version = props.update?.version return { visible: version !== undefined || installing, installing, @@ -392,7 +392,7 @@ export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visibl { @@ -657,12 +657,10 @@ function ChannelIndicator(props: { debugTools?: { visible: boolean; toggle: () = } return ( - <> - {["local", "beta", "dev"].includes(channel) && ( -
- {channel.toUpperCase()} -
- )} - + +
+ {channel.toUpperCase()} +
+
) } diff --git a/packages/app/src/components/windows-app-menu.tsx b/packages/app/src/components/windows-app-menu.tsx index b6088c57f4..57d8f9269d 100644 --- a/packages/app/src/components/windows-app-menu.tsx +++ b/packages/app/src/components/windows-app-menu.tsx @@ -1,4 +1,4 @@ -import { Show, type JSX } from "solid-js" +import { For, Show, type JSX } from "solid-js" import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu" import { Icon } from "@opencode-ai/ui/icon" import { IconButton } from "@opencode-ai/ui/icon-button" @@ -50,7 +50,20 @@ export function WindowsAppMenu(props: { return ( - {props.variant === "v2" ? ( + + } + >
- ) : ( - - )} +
OpenCode - {DESKTOP_MENU.filter((menu) => desktopMenuVisible(menu, "windows")).map((menu) => ( - - {menu.items - ?.filter((entry) => desktopMenuVisible(entry, "windows")) - .map((entry) => - entry.type === "separator" ? ( - - ) : ( - runEntry(entry)} - /> - ), - )} - - ))} + desktopMenuVisible(menu, "windows"))}> + {(menu) => ( + + desktopMenuVisible(entry, "windows"))}> + {(entry) => { + // Static menu data: an early return keeps the union narrowing a Show fallback would lose. + if (entry.type === "separator") return + return ( + runEntry(entry)} + /> + ) + }} + + + )} + diff --git a/packages/app/src/context/models.tsx b/packages/app/src/context/models.tsx index a80cf2e580..4eb18f38bf 100644 --- a/packages/app/src/context/models.tsx +++ b/packages/app/src/context/models.tsx @@ -1,4 +1,4 @@ -import { type Accessor, createMemo, createResource } from "solid-js" +import { createMemo, createResource } from "solid-js" import { createStore } from "solid-js/store" import { DateTime } from "luxon" import { filter, firstBy, flat, groupBy, mapValues, pipe, uniqueBy, values } from "remeda" @@ -25,8 +25,8 @@ function modelKey(model: ModelKey) { export const { use: useModels, provider: ModelsProvider } = createSimpleContext({ name: "Models", gate: false, - init: (props: { directory?: Accessor } = {}) => { - const providers = useProviders(() => props.directory?.()) + init: (props: { directory?: string } = {}) => { + const providers = useProviders(() => props.directory) const [store, setStore, _, ready] = persisted( Persist.global("model", ["model.v1"]), diff --git a/packages/app/src/context/sdk.tsx b/packages/app/src/context/sdk.tsx index d9051f5fbe..880691ce5f 100644 --- a/packages/app/src/context/sdk.tsx +++ b/packages/app/src/context/sdk.tsx @@ -6,12 +6,9 @@ export type { DirectorySDK } from "./server-sdk" const context = createSimpleContext({ name: "SDK", // Resolves the directory-scoped SDK reactively from the (possibly changing) server. - init: (props: { directory: string | Accessor }) => { + init: (props: { directory: string }) => { const serverSDK = useServerSDK() - return createMemo(() => { - const directory = typeof props.directory === "function" ? props.directory() : props.directory - return serverSDK().ensureDirSdkContext(directory) - }) + return createMemo(() => serverSDK().ensureDirSdkContext(props.directory)) }, }) diff --git a/packages/app/src/context/server-sdk.tsx b/packages/app/src/context/server-sdk.tsx index e95f9e204f..a5195f8097 100644 --- a/packages/app/src/context/server-sdk.tsx +++ b/packages/app/src/context/server-sdk.tsx @@ -329,13 +329,13 @@ export const { use: useServerSDK, provider: ServerSDKProvider } = createSimpleCo name: "ServerSDK", // Returns an accessor so the resolved server can change reactively (e.g. a // /new-session draft retargeting its server) without re-instantiating the subtree. - init: (props: { server?: Accessor }) => { + init: (props: { server?: ServerConnection.Any }) => { const global = useGlobal() const language = useLanguage() const server = useServer() return createMemo(() => { - const conn = props.server?.() ?? server.current + const conn = props.server ?? server.current if (!conn) throw new Error(language.t("error.serverSDK.noServerAvailable")) return global.ensureServerCtx(conn).sdk }) diff --git a/packages/app/src/context/server-sync.tsx b/packages/app/src/context/server-sync.tsx index b7e9d93fc0..f67d202865 100644 --- a/packages/app/src/context/server-sync.tsx +++ b/packages/app/src/context/server-sync.tsx @@ -1,7 +1,7 @@ import type { Config, Path, Project, ProviderAuthResponse } from "@/types" import { showToast } from "@/utils/toast" import { getFilename } from "@opencode-ai/core/util/path" -import { type Accessor, batch, createMemo, getOwner, onCleanup, untrack } from "solid-js" +import { batch, createMemo, getOwner, onCleanup, untrack } from "solid-js" import { createStore, produce, reconcile } from "solid-js/store" import { useLanguage } from "@/context/language" import type { InitError } from "../pages/error" @@ -746,13 +746,13 @@ export const { use: useServerSync, provider: ServerSyncProvider } = createSimple name: "ServerSync", // Returns an accessor so the resolved server can change reactively without // re-instantiating the subtree (mirrors useServerSDK). - init: (props: { server?: Accessor }) => { + init: (props: { server?: ServerConnection.Any }) => { const global = useGlobal() const language = useLanguage() const server = useServer() return createMemo(() => { - const conn = props.server?.() ?? server.current + const conn = props.server ?? server.current if (!conn) throw new Error(language.t("error.serverSDK.noServerAvailable")) return global.ensureServerCtx(conn).sync }) diff --git a/packages/app/src/pages/directory-layout.tsx b/packages/app/src/pages/directory-layout.tsx index a0b3576396..7df092aceb 100644 --- a/packages/app/src/pages/directory-layout.tsx +++ b/packages/app/src/pages/directory-layout.tsx @@ -2,7 +2,7 @@ import { DataProvider } from "@opencode-ai/session-ui/context" import { showToast } from "@/utils/toast" import { base64Encode } from "@opencode-ai/core/util/encode" import { useLocation, useNavigate, useParams } from "@solidjs/router" -import { type Accessor, createEffect, createMemo, createResource, onCleanup, type ParentProps, Show } from "solid-js" +import { createEffect, createMemo, createResource, onCleanup, type ParentProps, Show } from "solid-js" import { useLanguage } from "@/context/language" import { LocalProvider } from "@/context/local" import { SDKProvider } from "@/context/sdk" @@ -15,9 +15,9 @@ import { useServerSync } from "@/context/server-sync" export function DirectoryDataProvider( props: ParentProps<{ - directory: string | Accessor + directory: string draftID?: string - server?: Accessor + server?: ServerConnection.Key }>, ) { const location = useLocation() @@ -25,17 +25,16 @@ export function DirectoryDataProvider( const params = useParams() const sync = useSync() const serverSync = useServerSync() - const directory = () => (typeof props.directory === "function" ? props.directory() : props.directory) + const directory = () => props.directory const slug = createMemo(() => base64Encode(directory())) const href = (sessionID: string) => { - const server = props.server?.() - if (server) return sessionHref(server, sessionID) + if (props.server) return sessionHref(props.server, sessionID) return `/${slug()}/session/${sessionID}` } createEffect(() => { // A draft lives at /new-session?draftId=… and has no directory segment to normalize. - if (props.draftID || props.server?.()) return + if (props.draftID || props.server) return const next = sync().data.path.directory if (!next || next === directory()) return const path = location.pathname.slice(slug().length + 1) diff --git a/packages/app/src/pages/home.tsx b/packages/app/src/pages/home.tsx index 443169aa0e..27e62d5030 100644 --- a/packages/app/src/pages/home.tsx +++ b/packages/app/src/pages/home.tsx @@ -23,8 +23,8 @@ export function Home() { > scroll.viewport.update(event.currentTarget.scrollTop)} onWheel={scroll.viewport.containOuterWheel} diff --git a/packages/app/src/pages/home/home-projects-view.tsx b/packages/app/src/pages/home/home-projects-view.tsx index a7dd6ee907..270bba49cb 100644 --- a/packages/app/src/pages/home/home-projects-view.tsx +++ b/packages/app/src/pages/home/home-projects-view.tsx @@ -1,4 +1,4 @@ -import { type Accessor, createMemo, For, type JSX, onCleanup, Show, splitProps } from "solid-js" +import { createMemo, For, type JSX, onCleanup, Show, splitProps } from "solid-js" import { createStore } from "solid-js/store" import { DragDropProvider, PointerSensor } from "@dnd-kit/solid" import { isSortable, useSortable } from "@dnd-kit/solid/sortable" @@ -29,16 +29,16 @@ const projectContextMenuID = (server: ServerConnection.Any, directory: string) = export type HomeProjectsViewProps = { language: ReturnType - servers: Accessor - projects: Accessor - recentlyClosed: Accessor - selection: Accessor - homedir: Accessor + servers: ServerConnection.Any[] + projects: LocalProject[] + recentlyClosed: LocalProject[] + selection: HomeProjectSelection + homedir: string serverHealth: (server: ServerConnection.Any) => ServerHealth | undefined projectsForServer: (server: ServerConnection.Any) => LocalProject[] collapsed: (server: ServerConnection.Any) => boolean - canDefaultServer: Accessor - defaultServerKey: Accessor + canDefaultServer: boolean + defaultServerKey: ServerConnection.Key | null | undefined canRevealProject: (server: ServerConnection.Any) => boolean unseenCount: (server: ServerConnection.Any, project: LocalProject) => number onWheel: (event: WheelEvent) => void @@ -81,9 +81,7 @@ export function HomeProjectsView(props: HomeProjectsViewProps) { >
{props.language.t("home.projects")}
- 0)} - > + 0)}> } - disabled={props.serverHealth(props.servers()[0])?.healthy === false} - onClick={() => props.onChooseProject(props.servers()[0])} + disabled={props.serverHealth(props.servers[0])?.healthy === false} + onClick={() => props.onChooseProject(props.servers[0])} aria-label={props.language.t("home.project.add")} /> @@ -100,25 +98,20 @@ export function HomeProjectsView(props: HomeProjectsViewProps) {
1} + when={props.servers.length > 1} fallback={
0} - fallback={} + when={props.projects.length > 0} + fallback={} > - +
} >
- + {(item) => { const projects = () => props.projectsForServer(item) const healthy = () => !!props.serverHealth(item)?.healthy @@ -130,7 +123,7 @@ export function HomeProjectsView(props: HomeProjectsViewProps) { server={item} {...props} {...contextMenuProps} - selected={props.selection().server === ServerConnection.key(item) && !props.selection().directory} + selected={props.selection.server === ServerConnection.key(item) && !props.selection.directory} collapsed={collapsed()} health={props.serverHealth(item)} /> @@ -277,8 +270,8 @@ function HomeServerRow(props: { props.onSetDefaultServer(props.server)} @@ -339,7 +332,7 @@ function HomeProjectList(props: HomeProjectListProps) { const source = event.operation.source if (event.canceled || !isSortable(source)) return if (source.initialIndex !== source.index) props.onMoveProject(props.server, source.id.toString(), source.index) - if (props.selection().server !== ServerConnection.key(props.server)) + if (props.selection.server !== ServerConnection.key(props.server)) props.onSelectProject(props.server, source.id.toString()) }} > @@ -350,7 +343,7 @@ function HomeProjectList(props: HomeProjectListProps) { row's sortable unregisters on unmount) and discarding animations. String keys keep row elements alive and move them on reorder. */} project.worktree)}> - {(worktree, index) => } + {(worktree, index) => }
@@ -360,7 +353,7 @@ function HomeProjectList(props: HomeProjectListProps) { function HomeProjectSlot( props: HomeProjectListProps & { worktree: string - index: () => number + index: number }, ) { const initial = props.items.find((item) => item.worktree === props.worktree) @@ -376,10 +369,9 @@ function HomeProjectSlot( project={project()} server={props.server} index={props.index} - serverSelected={props.selection().server === ServerConnection.key(props.server)} + serverSelected={props.selection.server === ServerConnection.key(props.server)} selected={ - props.selection().server === ServerConnection.key(props.server) && - props.selection().directory === props.worktree + props.selection.server === ServerConnection.key(props.server) && props.selection.directory === props.worktree } unseen={props.unseenCount(props.server, project())} /> @@ -425,7 +417,7 @@ function HomeRecentlyClosedRow( ) { const unreachable = () => props.serverHealth(props.server)?.healthy === false const path = () => { - const home = props.homedir() + const home = props.homedir const worktree = props.project.worktree if (home && (worktree === home || worktree.startsWith(`${home}/`))) return `~${worktree.slice(home.length)}` return worktree @@ -451,7 +443,7 @@ function HomeProjectRow( HomeProjectsContextMenuProps & { project: LocalProject server: ServerConnection.Any - index: () => number + index: number serverSelected: boolean selected: boolean unseen: number @@ -464,7 +456,7 @@ function HomeProjectRow( return props.project.worktree }, get index() { - return props.index() + return props.index }, }) let pointerDownSelected: boolean | undefined diff --git a/packages/app/src/pages/home/home-projects.tsx b/packages/app/src/pages/home/home-projects.tsx index 32495403bc..34c02a7847 100644 --- a/packages/app/src/pages/home/home-projects.tsx +++ b/packages/app/src/pages/home/home-projects.tsx @@ -6,16 +6,16 @@ export function HomeProjects(props: { projects: HomeProjectsController; scroll: return ( export function HomeSessionStatusController(props: { - server: Accessor + server: ServerConnection.Key record: HomeSessionRecord isOpenTab: (record: HomeSessionRecord) => boolean render: (state: { unread: Accessor; loading: Accessor; open: Accessor }) => JSX.Element }) { const avatar = useSessionTabAvatarState( - props.server, + () => props.server, () => props.record.session.location.directory, () => props.record.session.id, ) diff --git a/packages/app/src/pages/home/home-sessions-view.tsx b/packages/app/src/pages/home/home-sessions-view.tsx index eef36843b3..062cb60608 100644 --- a/packages/app/src/pages/home/home-sessions-view.tsx +++ b/packages/app/src/pages/home/home-sessions-view.tsx @@ -1,5 +1,5 @@ import type { SessionInfo } from "@opencode-ai/client/promise" -import { type Accessor, createMemo, For, Show, Suspense } from "solid-js" +import { createMemo, For, Show, Suspense } from "solid-js" import { Spinner } from "@opencode-ai/ui/spinner" import { ScrollView } from "@opencode-ai/ui/scroll-view" import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2" @@ -38,17 +38,17 @@ function isBackgroundOpen(event: MouseEvent) { export type HomeSessionsViewProps = { language: ReturnType - groups: Accessor - showProjectName: Accessor - server: Accessor - canCreateSession: Accessor - searchValue: Accessor - searchPlaceholder: Accessor - searchOpen: Accessor - searchLoading: Accessor - searchResults: Accessor - searchActive: Accessor - searchNoResultsLabel: Accessor + groups: HomeSessionGroup[] + showProjectName: boolean + server: ServerConnection.Key + canCreateSession: boolean + searchValue: string + searchPlaceholder: string + searchOpen: boolean + searchLoading: boolean + searchResults: HomeSessionRecord[] + searchActive: string + searchNoResultsLabel: string titleOpacity: (id: HomeSessionGroup["id"]) => number isOpenTab: (record: HomeSessionRecord) => boolean onCreateSession: () => void @@ -81,7 +81,7 @@ export function HomeSessionsView(props: HomeSessionsViewProps) {
- 0 && props.canCreateSession()}> + 0 && props.canCreateSession}>
0} + when={props.groups.length > 0} fallback={ } >
- + {(group, index) => ( <>
{(record) => }
@@ -205,7 +205,7 @@ function HomeSessionSearch(props: HomeSessionsViewProps) { return (
- +
@@ -225,7 +225,7 @@ function HomeSessionSearch(props: HomeSessionsViewProps) { } > 0} + when={props.searchResults.length > 0} fallback={

- {props.searchNoResultsLabel()} + {props.searchNoResultsLabel}

} > @@ -248,12 +248,12 @@ function HomeSessionSearch(props: HomeSessionsViewProps) {

- + {(record) => ( )} @@ -280,16 +280,14 @@ function HomeSessionSearch(props: HomeSessionsViewProps) { relative z-20 min-w-0 flex-1 border-0 bg-transparent outline-0 text-v2-text-text-base [font-weight:440] placeholder:text-v2-text-text-faint `} - value={props.searchValue()} - placeholder={props.searchPlaceholder()} - aria-label={props.searchPlaceholder()} - aria-expanded={props.searchOpen()} + value={props.searchValue} + placeholder={props.searchPlaceholder} + aria-label={props.searchPlaceholder} + aria-expanded={props.searchOpen} aria-controls={HOME_SESSION_SEARCH_RESULTS_ID} aria-autocomplete="list" aria-activedescendant={ - props.searchActive() && props.searchOpen() - ? `home-session-search-option-${props.searchActive()}` - : undefined + props.searchActive && props.searchOpen ? `home-session-search-option-${props.searchActive}` : undefined } onFocus={props.onSearchFocus} onInput={(event) => props.onSearchInput(event.currentTarget.value)} @@ -300,7 +298,7 @@ function HomeSessionSearch(props: HomeSessionsViewProps) { event.currentTarget.blur() return } - if (!props.searchOpen() || props.searchResults().length === 0) return + if (!props.searchOpen || props.searchResults.length === 0) return if (event.altKey || event.metaKey) return if (event.key === "ArrowDown") { event.preventDefault() @@ -318,14 +316,14 @@ function HomeSessionSearch(props: HomeSessionsViewProps) { } }} /> - + } - aria-label={props.searchPlaceholder()} + aria-label={props.searchPlaceholder} onClick={() => { props.onSearchClose() props.onSearchFocus() @@ -345,7 +343,7 @@ function HomeSessionSearchResultRow( }, ) { const title = createMemo(() => sessionLabel(props.record.session)) - const showProjectName = () => props.showProjectName() && props.record.projectName + const showProjectName = () => props.showProjectName && props.record.projectName const key = () => homeSessionSearchKey(props.record) return ( @@ -416,7 +414,7 @@ function HomeSessionGroupHeader(props: { function HomeSessionRow(props: HomeSessionsViewProps & { record: HomeSessionRecord }) { const title = createMemo(() => sessionLabel(props.record.session)) - const showProjectName = () => props.showProjectName() && props.record.projectName + const showProjectName = () => props.showProjectName && props.record.projectName return (
setV2Toast(true)) + onMount(() => setV2Toast(true)) const update: TitlebarUpdate = { - version: () => { + get version() { const state = platform.updater?.state() - if (state?.status !== "ready") return + if (state?.status !== "ready") return undefined return state.version }, - installing: () => platform.updater?.state().status === "installing", + get installing() { + return platform.updater?.state().status === "installing" + }, install: () => void platform.updater?.install(), } @@ -40,7 +42,9 @@ export default function Layout(props: ParentProps) {
{props.children}
- {import.meta.env.DEV && state.debugTools && } + + +
) diff --git a/packages/app/src/pages/new-session.tsx b/packages/app/src/pages/new-session.tsx index 7c7b89f589..807b6e36f9 100644 --- a/packages/app/src/pages/new-session.tsx +++ b/packages/app/src/pages/new-session.tsx @@ -40,7 +40,7 @@ export default function NewSessionPage() { return (
{suspendUntilPromptReady()} - +
diff --git a/packages/app/src/pages/new-session/new-session-view.tsx b/packages/app/src/pages/new-session/new-session-view.tsx index 5960e64335..3f15cbb34c 100644 --- a/packages/app/src/pages/new-session/new-session-view.tsx +++ b/packages/app/src/pages/new-session/new-session-view.tsx @@ -3,7 +3,7 @@ import { Tooltip } from "@opencode-ai/ui/tooltip" import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon" import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2" import { WordmarkV2 } from "@opencode-ai/ui/v2/wordmark-v2" -import { Show, createMemo, createSignal, type Accessor } from "solid-js" +import { Show, createMemo, createSignal } from "solid-js" import { createStore } from "solid-js/store" import { Portal } from "solid-js/web" import createPresence from "solid-presence" @@ -74,14 +74,14 @@ export function NewSessionView(props: { ) } -export function NewSessionStatus(props: { mount: Accessor; visible: Accessor }) { +export function NewSessionStatus(props: { mount: HTMLElement | null; visible: boolean }) { const language = useLanguage() return ( - + {(mount) => ( - + @@ -116,7 +116,7 @@ function ProviderTip() { }) const openProviders = () => { void import("@/components/dialog-connect-provider").then(({ DialogConnectProvider }) => { - void dialog.show(() => sdk().directory} />) + void dialog.show(() => ) }) } diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index 9baee93b5e..8c5dd5b148 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -163,7 +163,7 @@ export function TargetSessionRouteContent() { return ( // Settings must keep the target-server SDK, sync, and models context and remain registered // when session content falls back to the route error boundary. - params.id}> + @@ -183,17 +183,15 @@ export function SessionRouteErrorBoundary( const settings = useSettings() return ( - settings.general.newLayoutDesigns() ? ( + fallback={(error) => ( + }> - ) : ( - - ) - } + + )} > {props.children} @@ -253,7 +251,6 @@ function ResolvedTargetSessionRoute() { () => sync().session.lineage, ) const directory = createMemo(() => current()?.session.location.directory) - const targetDirectory = () => directory()! createEffect(() => { const session = current() @@ -270,11 +267,13 @@ function ResolvedTargetSessionRoute() { // the terminal. Same-workspace tab switches keep it open because warm // targets resolve synchronously from the sync cache. - - - - - + {(dir) => ( + + + + + + )} ) } @@ -292,9 +291,7 @@ function TargetSessionPage() { ) } -function TargetServerScopedProviders( - props: ParentProps<{ directory?: () => string | undefined; sessionID?: () => string | undefined }>, -) { +function TargetServerScopedProviders(props: ParentProps<{ directory?: string; sessionID?: string }>) { return ( <> @@ -303,10 +300,10 @@ function TargetServerScopedProviders( ) } -function MarkSessionNotificationsViewed(props: { sessionID?: () => string | undefined }) { +function MarkSessionNotificationsViewed(props: { sessionID?: string }) { const notification = useNotification() createEffect(() => { - const sessionID = props.sessionID?.() + const sessionID = props.sessionID if (!notification.ready() || !sessionID) return if (notification.session.unseenCount(sessionID) === 0) return notification.session.markViewed(sessionID) @@ -1238,8 +1235,8 @@ export default function Page() { setTree("reviewScroll", el)} @@ -1272,8 +1269,12 @@ export default function Page() { get empty() { return reviewEmptyV2() }, - diffs: reviewDiffs, - diffsReady: reviewReady, + get diffs() { + return reviewDiffs() + }, + get diffsReady() { + return reviewReady() + }, get diffVersion() { return vcsQuery.dataUpdatedAt }, @@ -2072,11 +2073,11 @@ export default function Page() { onScheduleScrollState={scheduleScrollState} onAutoScrollHandleScroll={autoScroll.handleScroll} onMarkScrollGesture={markScrollGesture} - hasScrollGesture={hasScrollGesture} + hasScrollGesture={hasScrollGesture()} onUserScroll={markUserScroll} onHistoryScroll={onHistoryScroll} onAutoScrollInteraction={autoScroll.handleInteraction} - shouldAnchorBottom={() => + shouldAnchorBottom={ !location.hash && !store.messageId && !ui.pendingMessage && !autoScroll.userScrolled() } centered={centered()} @@ -2251,7 +2252,14 @@ export default function Page() { width: sessionPanelWidth(), }} > - {settings.general.newLayoutDesigns() ? ( + + {sessionPanelContent()} + + } + > {(_) => ( @@ -2259,11 +2267,7 @@ export default function Page() { )} - ) : ( - - {sessionPanelContent()} - - )} +
size.start()}> @@ -2287,13 +2291,13 @@ export default function Page() { hasReview() || reviewV2State.sidebarOpened()} - reviewCount={reviewCount} + canReview={canReview()} + diffs={reviewDiffs()} + diffsReady={reviewReady()} + empty={reviewEmptyText()} + hasReview={hasReview()} + reviewHasFocusableContent={hasReview() || reviewV2State.sidebarOpened()} + reviewCount={reviewCount()} reviewPanel={reviewPanelV2} reviewSidebarToggle={(disabled) => ( - {(item) => - item() === doneToken ? ( - - ) : item() === totalToken ? ( - - ) : ( - {item()} - ) - } + {(item) => ( + {item()}}> + + + + + + + + )}
0)}> Cycle progress ({step()}/3 done) - {[0, 1, 2, 3].map((value) => ( - - ))} + + {(value) => ( + + )} +
diff --git a/packages/app/src/pages/session/review-tab.tsx b/packages/app/src/pages/session/review-tab.tsx index 5b175eccb9..e4d6f9a0f1 100644 --- a/packages/app/src/pages/session/review-tab.tsx +++ b/packages/app/src/pages/session/review-tab.tsx @@ -19,8 +19,8 @@ type ReviewDiff = FileDiffInfo export interface SessionReviewTabProps { title?: JSX.Element empty?: JSX.Element - diffs: () => ReviewDiff[] - view: () => ReturnType["view"]> + diffs: ReviewDiff[] + view: ReturnType["view"]> diffStyle: DiffStyle onDiffStyleChange?: (style: DiffStyle) => void onViewFile?: (file: string) => void @@ -77,7 +77,7 @@ export function SessionReviewTab(props: SessionReviewTabProps) { if (!el || !layout.ready() || userInteracted) return if (el.clientHeight === 0 || el.clientWidth === 0) return - const s = props.view().scroll("review") + const s = props.view.scroll("review") if (!s || (s.x === 0 && s.y === 0)) return const maxY = Math.max(0, el.scrollHeight - el.clientHeight) @@ -111,14 +111,14 @@ export function SessionReviewTab(props: SessionReviewTabProps) { if (!layout.ready()) return if (el.clientHeight === 0 || el.clientWidth === 0) return - props.view().setScroll("review", { + props.view.setScroll("review", { x: el.scrollLeft, y: el.scrollTop, }) } createEffect(() => { - props.diffs().length + props.diffs.length props.diffStyle if (!layout.ready()) return queueRestore() @@ -145,14 +145,14 @@ export function SessionReviewTab(props: SessionReviewTabProps) { }} onScroll={handleScroll} onDiffRendered={queueRestore} - open={props.view().review.open()} - onOpenChange={props.view().review.setOpen} + open={props.view.review.open()} + onOpenChange={props.view.review.setOpen} classes={{ root: props.classes?.root ?? "pr-3", header: props.classes?.header ?? "px-3", container: props.classes?.container ?? "pl-3", }} - diffs={props.diffs()} + diffs={props.diffs} diffStyle={props.diffStyle} onDiffStyleChange={props.onDiffStyleChange} onViewFile={props.onViewFile} diff --git a/packages/app/src/pages/session/session-side-panel.tsx b/packages/app/src/pages/session/session-side-panel.tsx index 0bf642ea5a..f25fbf6f57 100644 --- a/packages/app/src/pages/session/session-side-panel.tsx +++ b/packages/app/src/pages/session/session-side-panel.tsx @@ -65,13 +65,13 @@ function renderDiff(value: ReviewDiff): value is RenderDiff { } export function SessionSidePanel(props: { - canReview: () => boolean - diffs: () => ReviewDiff[] - diffsReady: () => boolean - empty: () => string - hasReview: () => boolean - reviewHasFocusableContent: () => boolean - reviewCount: () => number + canReview: boolean + diffs: ReviewDiff[] + diffsReady: boolean + empty: string + hasReview: boolean + reviewHasFocusableContent: boolean + reviewCount: number reviewPanel: () => JSX.Element reviewSidebarToggle?: (disabled: boolean) => JSX.Element fileBrowserState?: SessionFileBrowserState @@ -113,7 +113,7 @@ export function SessionSidePanel(props: { }) const treeWidth = createMemo(() => (fileOpen() ? `${fileTreeWidth()}px` : "0px")) - const diffs = createMemo(() => props.diffs().filter(renderDiff)) + const diffs = createMemo(() => props.diffs.filter(renderDiff)) const diffFiles = createMemo(() => diffs().map((d) => d.file)) const kinds = createMemo(() => { const merge = (a: "add" | "del" | "mix" | undefined, b: "add" | "del" | "mix") => { @@ -177,7 +177,7 @@ export function SessionSidePanel(props: { pathFromTab: file.pathFromTab, normalizeTab, review: reviewTab, - hasReview: props.canReview, + hasReview: () => props.canReview, fileBrowser: () => !!props.fileBrowserState, }) const contextOpen = tabState.contextOpen @@ -348,7 +348,7 @@ export function SessionSidePanel(props: { onCleanup(stop) }} > - +
{language.t("session.tab.review")}
- -
{props.reviewCount()}
+ +
{props.reviewCount}
@@ -463,12 +463,12 @@ export function SessionSidePanel(props: {
- +
@@ -559,14 +559,14 @@ export function SessionSidePanel(props: {
)}
- + - {props.hasReview() - ? language.t("session.review.filesChanged", { count: props.reviewCount() }) + {props.hasReview + ? language.t("session.review.filesChanged", { count: props.reviewCount }) : language.t("session.tab.review")} @@ -611,7 +611,7 @@ export function SessionSidePanel(props: { fallback={ tabs().all().indexOf(tab)} + index={tabs().all().indexOf(tab)} temporary={temporaryTab() === tab} onTabClose={tabs().close} onTabDoubleClick={temporaryTab() === tab ? openTab : undefined} @@ -691,12 +691,12 @@ export function SessionSidePanel(props: {
- +
@@ -782,14 +782,14 @@ export function SessionSidePanel(props: { when={settings.general.newLayoutDesigns()} fallback={ <> - {props.reviewCount()}{" "} + {props.reviewCount}{" "} {language.t( - props.reviewCount() === 1 ? "session.review.change.one" : "session.review.change.other", + props.reviewCount === 1 ? "session.review.change.one" : "session.review.change.other", )} } > - {language.t("session.review.filesChanged", { count: props.reviewCount() })} + {language.t("session.review.filesChanged", { count: props.reviewCount })} @@ -799,9 +799,9 @@ export function SessionSidePanel(props: { - + {language.t("common.loading")} diff --git a/packages/app/src/pages/session/terminal-panel-v2.tsx b/packages/app/src/pages/session/terminal-panel-v2.tsx index 6a92e24ea6..9622525aaa 100644 --- a/packages/app/src/pages/session/terminal-panel-v2.tsx +++ b/packages/app/src/pages/session/terminal-panel-v2.tsx @@ -273,7 +273,7 @@ export function TerminalPanelV2(props: { stacked?: boolean } = {}) { > {(pty, index) => ( - + )}
diff --git a/packages/app/src/pages/session/timeline/message-timeline.tsx b/packages/app/src/pages/session/timeline/message-timeline.tsx index c281c2ecc8..bc3298380f 100644 --- a/packages/app/src/pages/session/timeline/message-timeline.tsx +++ b/packages/app/src/pages/session/timeline/message-timeline.tsx @@ -210,11 +210,11 @@ type MessageTimelineProps = { onScheduleScrollState: (el: HTMLDivElement) => void onAutoScrollHandleScroll: () => void onMarkScrollGesture: (target?: EventTarget | null) => void - hasScrollGesture: () => boolean + hasScrollGesture: boolean onUserScroll: () => void onHistoryScroll: () => void onAutoScrollInteraction: (event: MouseEvent) => void - shouldAnchorBottom: () => boolean + shouldAnchorBottom: boolean centered: boolean setContentRef: (el: HTMLDivElement) => void userMessages: UserMessage[] @@ -243,7 +243,7 @@ function MessageTimelineView( const ownerSessionKey = props.data.sessionKey() const cached = timelineCache.get(ownerSessionKey) const initialMeasurements = cached?.measurements - const coldBottomMount = !initialMeasurements?.length && props.shouldAnchorBottom() + const coldBottomMount = !initialMeasurements?.length && props.shouldAnchorBottom const [listRoot, setListRoot] = createSignal() const sessionID = props.data.sessionID @@ -338,7 +338,7 @@ function MessageTimelineView( }, getScrollElement: () => listRoot() ?? null, observeElementOffset: observeElementOffsetReconnectAware, - initialOffset: () => (props.shouldAnchorBottom() ? Number.MAX_SAFE_INTEGER : 0), + initialOffset: () => (props.shouldAnchorBottom ? Number.MAX_SAFE_INTEGER : 0), initialMeasurementsCache: initialMeasurements, estimateSize: () => timelineFallbackItemSize, scrollToFn: (offset, options, instance) => { @@ -376,11 +376,11 @@ function MessageTimelineView( const resizeItem = virtualizer.resizeItem let resizeAnchorScheduled = false const anchorResizedBottom = () => { - if (resizeAnchorScheduled || props.hasScrollGesture()) return + if (resizeAnchorScheduled || props.hasScrollGesture) return resizeAnchorScheduled = true queueMicrotask(() => { resizeAnchorScheduled = false - if (!props.shouldAnchorBottom() || props.hasScrollGesture()) return + if (!props.shouldAnchorBottom || props.hasScrollGesture) return virtualizer.scrollToEnd() }) } @@ -405,10 +405,10 @@ function MessageTimelineView( }) } resizeItem(index, size) - if (root && props.shouldAnchorBottom()) anchorResizedBottom() + if (root && props.shouldAnchorBottom) anchorResizedBottom() } virtualizer.shouldAdjustScrollPositionOnItemSizeChange = (item) => { - if (props.shouldAnchorBottom()) return false + if (props.shouldAnchorBottom) return false const first = virtualizer.range?.startIndex return first !== undefined && item.index < first } @@ -429,18 +429,18 @@ function MessageTimelineView( let overscanFrame: number | undefined onMount(() => { overscanFrame = requestAnimationFrame(() => { - if (props.shouldAnchorBottom()) virtualizer.scrollToEnd() + if (props.shouldAnchorBottom) virtualizer.scrollToEnd() overscanFrame = requestAnimationFrame(() => { overscanFrame = undefined if (renderOverscan() < 20) setRenderOverscan(20) - if (props.shouldAnchorBottom()) virtualizer.scrollToEnd() + if (props.shouldAnchorBottom) virtualizer.scrollToEnd() }) }) }) const maybeAnchorBottom = () => { if (timelineRows().length === 0) return - if (!props.shouldAnchorBottom() || props.hasScrollGesture()) return + if (!props.shouldAnchorBottom || props.hasScrollGesture) return if (resizePinFrame !== undefined) cancelAnimationFrame(resizePinFrame) clearPrependAnchor() if (prependAnchorFrame !== undefined) cancelAnimationFrame(prependAnchorFrame) @@ -552,7 +552,7 @@ function MessageTimelineView( if (prependLoading) updatePrependAnchor() props.onScheduleScrollState(event.currentTarget) props.onHistoryScroll() - if (!props.hasScrollGesture()) return + if (!props.hasScrollGesture) return props.onUserScroll() props.onAutoScrollHandleScroll() props.onMarkScrollGesture(event.currentTarget) @@ -709,21 +709,21 @@ function MessageTimelineView( ) } - function TimelineRowFrame(input: { row: Accessor; children: JSX.Element }) { + function TimelineRowFrame(input: { row: FramedTimelineRow; children: JSX.Element }) { const anchor = () => { - const row = input.row() + const row = input.row return row._tag === "CommentStrip" || (row._tag === "UserMessage" && row.anchor) } const previousAssistantPart = () => { - const row = input.row() + const row = input.row return row._tag === "AssistantPart" && row.previousAssistantPart } return (
MessageComment.fromPart(part) ?? []), ) return ( - +
@@ -797,7 +797,7 @@ function MessageTimelineView( return getMsgParts(userMessageRow().userMessageID).flatMap((part) => MessageComment.fromPart(part) ?? []) }) return ( - + {(message) => (
@@ -819,7 +819,7 @@ function MessageTimelineView( case "TurnDivider": { const turnDividerRow = row as Accessor> return ( - +
> return ( - +
> return ( - +
> return ( - +
@@ -873,7 +873,7 @@ function MessageTimelineView( case "DiffSummary": { const diffSummaryRow = row as Accessor> return ( - +
@@ -883,7 +883,7 @@ function MessageTimelineView( case "Error": { const errorRow = row as Accessor> return ( - +
{errorRow().text} diff --git a/packages/app/src/pages/session/v2/review-panel-v2.tsx b/packages/app/src/pages/session/v2/review-panel-v2.tsx index b29afdd6e8..3b890c6fd8 100644 --- a/packages/app/src/pages/session/v2/review-panel-v2.tsx +++ b/packages/app/src/pages/session/v2/review-panel-v2.tsx @@ -35,8 +35,8 @@ type ReviewDiff = FileDiffInfo export type ReviewPanelV2Props = { title?: JSX.Element empty?: JSX.Element - diffs: () => ReviewDiff[] - diffsReady: () => boolean + diffs: ReviewDiff[] + diffsReady: boolean diffVersion?: number loadDiff?: (path: string, version?: number) => Promise activeFile?: string @@ -56,7 +56,7 @@ export type ReviewPanelV2Props = { export function ReviewPanelV2(props: ReviewPanelV2Props) { const sdk = useSDK() - const diffs = createMemo(() => props.diffs().filter(filterRenderableDiff)) + const diffs = createMemo(() => props.diffs.filter(filterRenderableDiff)) const filteredFiles = createMemo(() => filterReviewFiles( diffs().map((diff) => diff.file), @@ -122,11 +122,11 @@ export function ReviewPanelV2(props: ReviewPanelV2Props) { state={props.state} diffsReady={props.diffsReady} onSelectFile={props.onSelectFile} - diffs={diffs} - filteredFiles={filteredFiles} - searching={searching} - kinds={treeKinds} - activeDiff={activeDiff} + diffs={diffs()} + filteredFiles={filteredFiles()} + searching={searching()} + kinds={treeKinds()} + activeDiff={activeDiff()} /> } activeFile={activeDiff()} @@ -170,19 +170,19 @@ export function ReviewPanelV2(props: ReviewPanelV2Props) { function ReviewPanelV2Sidebar(props: { title?: JSX.Element state: ReviewPanelV2State - diffsReady: () => boolean + diffsReady: boolean onSelectFile: (path: string) => void - diffs: () => RenderDiff[] - filteredFiles: () => string[] - searching: () => boolean - kinds: () => ReturnType - activeDiff: () => string | undefined + diffs: RenderDiff[] + filteredFiles: string[] + searching: boolean + kinds: ReturnType + activeDiff: string | undefined }) { const language = useLanguage() const [explicitHighlight, setExplicitHighlight] = createSignal() const highlightedPath = createMemo(() => { - if (!props.searching()) return undefined - const files = props.filteredFiles() + if (!props.searching) return undefined + const files = props.filteredFiles if (files.length === 0) return undefined const explicit = explicitHighlight() if (explicit && files.includes(explicit)) return explicit @@ -190,8 +190,8 @@ function ReviewPanelV2Sidebar(props: { }) const onFilterKeyDown = (event: KeyboardEvent & { currentTarget: HTMLInputElement }) => { - if (!props.searching()) return - applyFileListKeyDown(event, props.filteredFiles(), highlightedPath(), { + if (!props.searching) return + applyFileListKeyDown(event, props.filteredFiles, highlightedPath(), { onHighlight: setExplicitHighlight, onSelect: props.onSelectFile, }) @@ -202,7 +202,7 @@ function ReviewPanelV2Sidebar(props: { open={props.state.sidebarOpened()} transition={props.state.sidebarTransition()} title={props.title} - stats={} + stats={} filter={props.state.filter()} onFilterChange={props.state.setFilter} onFilterKeyDown={onFilterKeyDown} @@ -212,7 +212,7 @@ function ReviewPanelV2Sidebar(props: { maxWidth={SESSION_REVIEW_V2_SIDEBAR_WIDTH_MAX} > {language.t("common.loading")} @@ -221,25 +221,25 @@ function ReviewPanelV2Sidebar(props: { } > props.onSelectFile(node.path)} /> } > 0} + when={props.filteredFiles.length > 0} fallback={
{language.t("palette.empty")}
} > { setExplicitHighlight(path) diff --git a/packages/cli/script/build.ts b/packages/cli/script/build.ts index af764ab7f6..918c430ab9 100755 --- a/packages/cli/script/build.ts +++ b/packages/cli/script/build.ts @@ -22,6 +22,7 @@ await rm(outdir, { recursive: true, force: true }) const singleFlag = process.argv.includes("--single") const baselineFlag = process.argv.includes("--baseline") +const requestedTarget = process.argv.find((arg) => arg.startsWith("--target="))?.slice("--target=".length) const skipInstall = process.argv.includes("--skip-install") const skipWebUi = process.argv.includes("--skip-web-ui") const solidPlugin = createSolidTransformPlugin() @@ -46,13 +47,17 @@ const allTargets: { { os: "win32", arch: "x64", avx2: false }, ] -const targets = singleFlag - ? allTargets.filter((item) => { - if (item.os !== process.platform || item.arch !== process.arch) return false - if (item.avx2 === false) return baselineFlag - return item.abi === undefined - }) - : allTargets +const targets = + requestedTarget !== undefined + ? allTargets.filter((item) => targetName(item) === requestedTarget) + : singleFlag + ? allTargets.filter((item) => { + if (item.os !== process.platform || item.arch !== process.arch) return false + if (item.avx2 === false) return baselineFlag + return item.abi === undefined + }) + : allTargets +if (!targets.length) throw new Error(`Unknown build target: ${requestedTarget}`) if (!skipInstall) await $`bun install --os="*" --cpu="*" @opentui/core@${pkg.dependencies["@opentui/core"]}` const appArchive = await buildAppArchive(Script.channel, { skipBuild: skipWebUi }) @@ -81,15 +86,7 @@ for (const item of targets) { })) }, } - const target = [ - binary, - item.os === "win32" ? "windows" : item.os, - item.arch, - item.avx2 === false ? "baseline" : undefined, - item.abi, - ] - .filter(Boolean) - .join("-") + const target = targetName(item) const name = target.replace(binary, "cli") console.log(`building ${name}`) const result = await Bun.build({ @@ -143,3 +140,15 @@ for (const item of targets) { ), ) } + +function targetName(item: (typeof allTargets)[number]) { + return [ + binary, + item.os === "win32" ? "windows" : item.os, + item.arch, + item.avx2 === false ? "baseline" : undefined, + item.abi, + ] + .filter(Boolean) + .join("-") +} diff --git a/packages/cli/src/services/update-preflight.tsx b/packages/cli/src/services/update-preflight.tsx index e104245095..f18f4a8ff3 100644 --- a/packages/cli/src/services/update-preflight.tsx +++ b/packages/cli/src/services/update-preflight.tsx @@ -447,7 +447,7 @@ function UpdateFooter(props: { }) return ( - + diff --git a/packages/core/src/permission.ts b/packages/core/src/permission.ts index 22e8d6e395..d7c6bf34ea 100644 --- a/packages/core/src/permission.ts +++ b/packages/core/src/permission.ts @@ -99,6 +99,11 @@ export function merge(...rulesets: Permission.Ruleset[]): Permission.Ruleset { } export interface Interface { + readonly allowsAll: (input: { + readonly sessionID: SessionSchema.ID + readonly action: string + readonly agent?: Agent.ID + }) => Effect.Effect readonly ask: (input: AssertInput) => Effect.Effect readonly assert: (input: AssertInput) => Effect.Effect readonly reply: (input: ReplyInput) => Effect.Effect @@ -154,6 +159,24 @@ const layer = Layer.effect( return agent?.permissions ?? missingAgentPermissions }) + const allowsAll = Effect.fn("Permission.allowsAll")(function* (input: { + readonly sessionID: SessionSchema.ID + readonly action: string + readonly agent?: Agent.ID + }) { + const rules = yield* configured(input.sessionID, input.agent) + const relevant = rules.filter((rule) => Wildcard.match(input.action, rule.action)) + for (let index = relevant.length - 1; index >= 0; index--) { + const rule = relevant[index] + if (rule.resource !== "*") { + if (rule.effect !== "allow") return false + continue + } + return rule.effect === "allow" + } + return false + }) + function denied(input: AssertInput, rules: Permission.Ruleset) { return input.resources.some((resource) => evaluate(input.action, resource, rules).effect === "deny") } @@ -315,7 +338,7 @@ const layer = Layer.effect( return Array.from(pending.values(), (item) => item.request).filter((request) => request.sessionID === sessionID) }) - return Service.of({ ask, assert, reply, get, forSession, list }) + return Service.of({ allowsAll, ask, assert, reply, get, forSession, list }) }), ) diff --git a/packages/core/src/tool/plugin/shell.ts b/packages/core/src/tool/plugin/shell.ts index 367e9eee51..6ec202f7bc 100644 --- a/packages/core/src/tool/plugin/shell.ts +++ b/packages/core/src/tool/plugin/shell.ts @@ -149,36 +149,50 @@ export const Plugin = { (invocation) => Effect.gen(function* () { const target = yield* mutation.resolve({ path: invocation.cwd, kind: "directory" }) - const parsed = yield* ShellParse.scan(invocation.command, invocation.shell, target.absolute) - const directories = yield* Effect.forEach(parsed.directories, (directory) => - mutation.resolve({ path: path.resolve(target.absolute, directory), kind: "directory" }), - ) + const unrestricted = + (yield* permission.allowsAll({ + sessionID: context.sessionID, + action: name, + agent: context.agent, + })) && + (yield* permission.allowsAll({ + sessionID: context.sessionID, + action: "external_directory", + agent: context.agent, + })) invocation.cwd = target.absolute finalTimeout = invocation.timeout - const external = [target, ...directories] - .map((item) => item.externalDirectory) - .filter((item) => item !== undefined) - .filter( - (item, index, items) => items.findIndex((other) => other.resource === item.resource) === index, + if (!unrestricted) { + const parsed = yield* ShellParse.scan(invocation.command, invocation.shell, target.absolute) + const directories = yield* Effect.forEach(parsed.directories, (directory) => + mutation.resolve({ path: path.resolve(target.absolute, directory), kind: "directory" }), ) - if (external.length > 0) - yield* permission.assert({ - action: "external_directory", - resources: external.map((item) => item.resource), - save: external.map((item) => item.save), - sessionID: context.sessionID, - agent: context.agent, - source, - }) - if (parsed.commands.length > 0) - yield* permission.assert({ - action: name, - resources: parsed.commands.map((command) => command.resource), - save: parsed.commands.map((command) => command.save), - sessionID: context.sessionID, - agent: context.agent, - source, - }) + const external = [target, ...directories] + .map((item) => item.externalDirectory) + .filter((item) => item !== undefined) + .filter( + (item, index, items) => + items.findIndex((other) => other.resource === item.resource) === index, + ) + if (external.length > 0) + yield* permission.assert({ + action: "external_directory", + resources: external.map((item) => item.resource), + save: external.map((item) => item.save), + sessionID: context.sessionID, + agent: context.agent, + source, + }) + if (parsed.commands.length > 0) + yield* permission.assert({ + action: name, + resources: parsed.commands.map((command) => command.resource), + save: parsed.commands.map((command) => command.save), + sessionID: context.sessionID, + agent: context.agent, + source, + }) + } const workdir = yield* Environment.typeFollowing(environment.files, target.absolute).pipe( Effect.catchTag("Environment.NotFound", () => Effect.fail(new Error(`Working directory does not exist: ${target.absolute}`)), diff --git a/packages/core/test/lib/permission.ts b/packages/core/test/lib/permission.ts new file mode 100644 index 0000000000..292d118112 --- /dev/null +++ b/packages/core/test/lib/permission.ts @@ -0,0 +1,8 @@ +import { Permission } from "@opencode-ai/core/permission" +import { Effect, Layer } from "effect" + +export const permissionLayer = (overrides: Partial = {}) => + Layer.mock(Permission.Service, { + allowsAll: () => Effect.succeed(false), + ...overrides, + }) diff --git a/packages/core/test/lib/project.ts b/packages/core/test/lib/project.ts new file mode 100644 index 0000000000..0905cd8c09 --- /dev/null +++ b/packages/core/test/lib/project.ts @@ -0,0 +1,11 @@ +import { Project } from "@opencode-ai/core/project" +import { Effect, Layer } from "effect" + +export const globalProjectLayer = Layer.succeed( + Project.Service, + Project.Service.of({ + list: () => Effect.succeed([]), + resolve: (directory) => Effect.succeed({ id: Project.ID.global, directory, canonical: directory }), + directories: () => Effect.succeed([]), + }), +) diff --git a/packages/core/test/permission.test.ts b/packages/core/test/permission.test.ts index e8565abe23..ac83bf7f28 100644 --- a/packages/core/test/permission.test.ts +++ b/packages/core/test/permission.test.ts @@ -112,6 +112,31 @@ describe("Permission", () => { }), ) + it.effect("proves only unconditional configured allows", () => + Effect.gen(function* () { + const service = yield* Permission.Service + const input = { sessionID: Session.ID.make("ses_test"), action: "shell" } + + yield* setup([{ action: "shell", resource: "*", effect: "allow" }]) + expect(yield* service.allowsAll(input)).toBe(true) + + yield* setRules([ + { action: "shell", resource: "*", effect: "allow" }, + { action: "shell", resource: "rm *", effect: "deny" }, + ]) + expect(yield* service.allowsAll(input)).toBe(false) + + yield* setRules([{ action: "shell", resource: "git *", effect: "allow" }]) + expect(yield* service.allowsAll(input)).toBe(false) + + yield* setRules([ + { action: "shell", resource: "rm *", effect: "deny" }, + { action: "shell", resource: "*", effect: "allow" }, + ]) + expect(yield* service.allowsAll(input)).toBe(true) + }), + ) + it.effect("evaluates against an explicit provider-turn agent", () => Effect.gen(function* () { yield* setup([{ action: "read", resource: "*", effect: "allow" }]) diff --git a/packages/core/test/session-compact.test.ts b/packages/core/test/session-compact.test.ts index 8f8da70b21..7f4d7c1146 100644 --- a/packages/core/test/session-compact.test.ts +++ b/packages/core/test/session-compact.test.ts @@ -22,6 +22,7 @@ import { SessionRunnerModel } from "@opencode-ai/core/session/runner/model" import { SessionStore } from "@opencode-ai/core/session/store" import { DateTime, Effect, Layer, LayerMap, Stream } from "effect" import { testEffect } from "./lib/effect" +import { globalProjectLayer } from "./lib/project" const location = Location.Ref.make({ directory: AbsolutePath.make("/project") }) const model = LanguageModel.make({ @@ -29,14 +30,6 @@ const model = LanguageModel.make({ provider: "test", route: OpenAIChat.route.with({ limits: { context: 10_000, output: 1_000 } }), }) -const projects = Layer.succeed( - Project.Service, - Project.Service.of({ - list: () => Effect.succeed([]), - resolve: (directory) => Effect.succeed({ id: Project.ID.global, directory, canonical: directory }), - directories: () => Effect.succeed([]), - }), -) let requests: LLMRequest[] = [] const client = Layer.mock(LLMClient.Service)({ stream: (request: LLMRequest) => { @@ -73,7 +66,7 @@ const it = testEffect( LayerNode.group([Database.node, Bus.node, SessionProjector.node, SessionStore.node, Session.node]), [ [LocationServiceMap.node, locations], - [Project.node, projects], + [Project.node, globalProjectLayer], [SessionExecution.node, SessionExecution.noopLayer], ], ), diff --git a/packages/core/test/session-create.test.ts b/packages/core/test/session-create.test.ts index 57ebf17d0e..8342c4f6cf 100644 --- a/packages/core/test/session-create.test.ts +++ b/packages/core/test/session-create.test.ts @@ -29,16 +29,9 @@ import { SessionStore } from "@opencode-ai/core/session/store" import { SessionTransfer } from "@opencode-ai/core/session/transfer" import { Workspace } from "@opencode-ai/core/workspace" import { testEffect } from "./lib/effect" +import { globalProjectLayer } from "./lib/project" import { tmpdir } from "./fixture/tmpdir" -const projects = Layer.succeed( - Project.Service, - Project.Service.of({ - list: () => Effect.succeed([]), - resolve: (directory) => Effect.succeed({ id: Project.ID.global, directory, canonical: directory }), - directories: () => Effect.succeed([]), - }), -) const it = testEffect( AppNodeBuilder.build( LayerNode.group([ @@ -51,7 +44,7 @@ const it = testEffect( ]), [ [Bus.node, Bus.configured({ persist: true })], - [Project.node, projects], + [Project.node, globalProjectLayer], [SessionExecution.node, SessionExecution.noopLayer], ], ), diff --git a/packages/core/test/session-instructions.test.ts b/packages/core/test/session-instructions.test.ts index a738499a60..e17204e194 100644 --- a/packages/core/test/session-instructions.test.ts +++ b/packages/core/test/session-instructions.test.ts @@ -33,6 +33,8 @@ import { Tool } from "@opencode-ai/core/tool" import { tempLocationLayer } from "./fixture/location" import { makeLocationNode } from "@opencode-ai/util/effect/app-node" import { testEffect } from "./lib/effect" +import { permissionLayer } from "./lib/permission" +import { globalProjectLayer } from "./lib/project" import { executeTool, registerToolPlugin } from "./lib/tool" const readToolNode = makeLocationNode({ @@ -50,25 +52,7 @@ const readToolNode = makeLocationNode({ ], }) -const projects = Layer.succeed( - Project.Service, - Project.Service.of({ - list: () => Effect.succeed([]), - resolve: (directory) => Effect.succeed({ id: Project.ID.global, directory, canonical: directory }), - directories: () => Effect.succeed([]), - }), -) -const permission = Layer.succeed( - Permission.Service, - Permission.Service.of({ - assert: () => Effect.void, - ask: () => Effect.die("unused"), - reply: () => Effect.die("unused"), - get: () => Effect.die("unused"), - forSession: () => Effect.die("unused"), - list: () => Effect.die("unused"), - }), -) +const permission = permissionLayer({ assert: () => Effect.void }) const config = Config.testLayer() const imageLayer = AppNodeBuilder.build(Image.node, [[Config.node, config]]) @@ -92,7 +76,7 @@ const testLayer = AppNodeBuilder.build( Image.node, ]), [ - [Project.node, projects], + [Project.node, globalProjectLayer], [SessionExecution.node, SessionExecution.noopLayer], [Location.node, tempLocationLayer], [Permission.node, permission], diff --git a/packages/core/test/session-log.test.ts b/packages/core/test/session-log.test.ts index e72ff388e0..1a7dc4a4c7 100644 --- a/packages/core/test/session-log.test.ts +++ b/packages/core/test/session-log.test.ts @@ -1,5 +1,5 @@ import { describe, expect } from "bun:test" -import { Effect, Fiber, Layer, Schema, Stream } from "effect" +import { Effect, Fiber, Schema, Stream } from "effect" import { Database } from "@opencode-ai/core/database/database" import { Agent } from "@opencode-ai/core/agent" import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" @@ -16,21 +16,14 @@ import { SessionExecution } from "@opencode-ai/core/session/execution" import { SessionStore } from "@opencode-ai/core/session/store" import { SessionTable } from "@opencode-ai/core/session/sql" import { testEffect } from "./lib/effect" +import { globalProjectLayer } from "./lib/project" -const projects = Layer.succeed( - Project.Service, - Project.Service.of({ - list: () => Effect.succeed([]), - resolve: (directory) => Effect.succeed({ id: Project.ID.global, directory, canonical: directory }), - directories: () => Effect.succeed([]), - }), -) const it = testEffect( AppNodeBuilder.build( LayerNode.group([Database.node, Bus.node, SessionProjector.node, SessionStore.node, Session.node]), [ [Bus.node, Bus.configured({ persist: true })], - [Project.node, projects], + [Project.node, globalProjectLayer], [SessionExecution.node, SessionExecution.noopLayer], ], ), diff --git a/packages/core/test/session-move.test.ts b/packages/core/test/session-move.test.ts index fba3649687..0240f85ad5 100644 --- a/packages/core/test/session-move.test.ts +++ b/packages/core/test/session-move.test.ts @@ -1,6 +1,6 @@ import { describe, expect } from "bun:test" import path from "path" -import { Effect, Layer } from "effect" +import { Effect } from "effect" import { Event } from "@opencode-ai/schema/project-directories" import { Bus } from "@opencode-ai/core/bus" import { Database } from "@opencode-ai/core/database/database" @@ -16,20 +16,13 @@ import { SessionStore } from "@opencode-ai/core/session/store" import { LayerNode } from "@opencode-ai/util/effect/layer-node" import { tmpdir } from "./fixture/tmpdir" import { testEffect } from "./lib/effect" +import { globalProjectLayer } from "./lib/project" -const projects = Layer.succeed( - Project.Service, - Project.Service.of({ - list: () => Effect.succeed([]), - resolve: (directory) => Effect.succeed({ id: Project.ID.global, directory, canonical: directory }), - directories: () => Effect.succeed([]), - }), -) const it = testEffect( AppNodeBuilder.build( LayerNode.group([Database.node, Bus.node, SessionProjector.node, SessionStore.node, Session.node]), [ - [Project.node, projects], + [Project.node, globalProjectLayer], [SessionExecution.node, SessionExecution.noopLayer], ], ), diff --git a/packages/core/test/session-remove.test.ts b/packages/core/test/session-remove.test.ts index ec7c532bc7..c160701e4e 100644 --- a/packages/core/test/session-remove.test.ts +++ b/packages/core/test/session-remove.test.ts @@ -1,5 +1,5 @@ import { describe, expect } from "bun:test" -import { Effect, Layer } from "effect" +import { Effect } from "effect" import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { LayerNode } from "@opencode-ai/util/effect/layer-node" import { Database } from "@opencode-ai/core/database/database" @@ -12,20 +12,13 @@ import { SessionExecution } from "@opencode-ai/core/session/execution" import { SessionProjector } from "@opencode-ai/core/session/projector" import { SessionStore } from "@opencode-ai/core/session/store" import { testEffect } from "./lib/effect" +import { globalProjectLayer } from "./lib/project" -const projects = Layer.succeed( - Project.Service, - Project.Service.of({ - list: () => Effect.succeed([]), - resolve: (directory) => Effect.succeed({ id: Project.ID.global, directory, canonical: directory }), - directories: () => Effect.succeed([]), - }), -) const it = testEffect( AppNodeBuilder.build( LayerNode.group([Database.node, Bus.node, SessionProjector.node, SessionStore.node, Session.node]), [ - [Project.node, projects], + [Project.node, globalProjectLayer], [SessionExecution.node, SessionExecution.noopLayer], ], ), diff --git a/packages/core/test/session-runner-recorded.test.ts b/packages/core/test/session-runner-recorded.test.ts index 74613a505f..1533d8e20a 100644 --- a/packages/core/test/session-runner-recorded.test.ts +++ b/packages/core/test/session-runner-recorded.test.ts @@ -44,6 +44,7 @@ import { Effect, Layer, Stream } from "effect" import { HttpClient, HttpClientResponse } from "effect/unstable/http" import path from "node:path" import { testEffect } from "./lib/effect" +import { permissionLayer } from "./lib/permission" import { agentHost, catalogHost, host } from "./plugin/host" const cassetteName = "session-runner/openai-chat-streams-text" @@ -55,17 +56,7 @@ if (process.env.RECORD === "true") { const cassette = HttpRecorder.layerFetch(cassetteName, { directory: cassetteDirectory }) const executor = RequestExecutor.layer.pipe(Layer.provide(cassette)) const client = LLMClient.layer.pipe(Layer.provide(executor)) -const permission = Layer.succeed( - Permission.Service, - Permission.Service.of({ - assert: () => Effect.die("unused"), - ask: () => Effect.die("unused"), - reply: () => Effect.die("unused"), - get: () => Effect.die("unused"), - forSession: () => Effect.die("unused"), - list: () => Effect.die("unused"), - }), -) +const permission = permissionLayer() const model = OpenAIChat.route .with({ endpoint: { baseURL: "https://api.openai.com/v1" }, diff --git a/packages/core/test/session-runner.test.ts b/packages/core/test/session-runner.test.ts index 592ac806fb..a5cdbe1efc 100644 --- a/packages/core/test/session-runner.test.ts +++ b/packages/core/test/session-runner.test.ts @@ -74,6 +74,7 @@ import { Cause, DateTime, Deferred, Effect, Exit, Fiber, Layer, Schema, Scope, S import { TestClock } from "effect/testing" import { asc, desc, eq } from "drizzle-orm" import { testEffect } from "./lib/effect" +import { permissionLayer } from "./lib/permission" import { agentHost, catalogHost, host } from "./plugin/host" import PROMPT_DEFAULT from "../src/session/runner/prompt/base.txt" import { CodeModeInstructions } from "@opencode-ai/core/codemode/instructions" @@ -218,17 +219,7 @@ const permissionFail = { }), }), } -const permission = Layer.succeed( - Permission.Service, - Permission.Service.of({ - assert: () => Effect.die("unused"), - ask: () => Effect.die("unused"), - reply: () => Effect.die("unused"), - get: () => Effect.die("unused"), - forSession: () => Effect.die("unused"), - list: () => Effect.die("unused"), - }), -) +const permission = permissionLayer() const transformTools = (registry: Tool.Interface, tools: Readonly>, options?: Tool.Options) => registry.transform((draft) => Object.entries(tools).forEach(([name, tool]) => draft.add({ ...tool, name, options: options ?? tool.options })), diff --git a/packages/core/test/tool-edit.test.ts b/packages/core/test/tool-edit.test.ts index 10bb94f817..80b5e4ee80 100644 --- a/packages/core/test/tool-edit.test.ts +++ b/packages/core/test/tool-edit.test.ts @@ -19,6 +19,7 @@ import { location } from "./fixture/location" import { tmpdir } from "./fixture/tmpdir" import { makeLocationNode } from "@opencode-ai/util/effect/app-node" import { testEffect } from "./lib/effect" +import { permissionLayer } from "./lib/permission" import { toolIdentity, executeTool, registerToolPlugin, toolDefinitions } from "./lib/tool" const editToolNode = makeLocationNode({ @@ -43,30 +44,22 @@ let denyAction: string | undefined let afterRead = (_target: string, _content: Uint8Array): Effect.Effect => Effect.void let formatFile = (_target: string): Effect.Effect => Effect.succeed(false) -const permission = Layer.succeed( - Permission.Service, - Permission.Service.of({ - assert: (input) => - Effect.sync(() => assertions.push(input)).pipe( - Effect.andThen( - input.action === denyAction - ? Effect.fail( - new Permission.BlockedError({ - rules: [], - permission: input.action, - resources: input.resources, - }), - ) - : Effect.void, - ), +const permission = permissionLayer({ + assert: (input) => + Effect.sync(() => assertions.push(input)).pipe( + Effect.andThen( + input.action === denyAction + ? Effect.fail( + new Permission.BlockedError({ + rules: [], + permission: input.action, + resources: input.resources, + }), + ) + : Effect.void, ), - ask: () => Effect.die("unused"), - reply: () => Effect.die("unused"), - get: () => Effect.die("unused"), - forSession: () => Effect.die("unused"), - list: () => Effect.die("unused"), - }), -) + ), +}) const formatter = Layer.mock(Formatter.Service, { file: (target) => formatFile(target), diff --git a/packages/core/test/tool-patch.test.ts b/packages/core/test/tool-patch.test.ts index 9c1cc59090..959ddd7a12 100644 --- a/packages/core/test/tool-patch.test.ts +++ b/packages/core/test/tool-patch.test.ts @@ -19,6 +19,7 @@ import { location } from "./fixture/location" import { tmpdir } from "./fixture/tmpdir" import { makeLocationNode } from "@opencode-ai/util/effect/app-node" import { testEffect } from "./lib/effect" +import { permissionLayer } from "./lib/permission" import { toolIdentity, executeTool, registerToolPlugin, toolDefinitions } from "./lib/tool" const patchToolNode = makeLocationNode({ @@ -38,34 +39,26 @@ let editApproved = false let afterEditApproval = (): Effect.Effect => Effect.void let formatFile = (_target: string): Effect.Effect => Effect.succeed(false) -const permission = Layer.succeed( - Permission.Service, - Permission.Service.of({ - assert: (input) => - Effect.sync(() => { - assertions.push(input) - if (input.action === "edit") editApproved = true - }).pipe( - Effect.andThen(input.action === "edit" ? Effect.suspend(afterEditApproval) : Effect.void), - Effect.andThen( - input.action === denyAction - ? Effect.fail( - new Permission.BlockedError({ - rules: [], - permission: input.action, - resources: input.resources, - }), - ) - : Effect.void, - ), +const permission = permissionLayer({ + assert: (input) => + Effect.sync(() => { + assertions.push(input) + if (input.action === "edit") editApproved = true + }).pipe( + Effect.andThen(input.action === "edit" ? Effect.suspend(afterEditApproval) : Effect.void), + Effect.andThen( + input.action === denyAction + ? Effect.fail( + new Permission.BlockedError({ + rules: [], + permission: input.action, + resources: input.resources, + }), + ) + : Effect.void, ), - ask: () => Effect.die("unused"), - reply: () => Effect.die("unused"), - get: () => Effect.die("unused"), - forSession: () => Effect.die("unused"), - list: () => Effect.die("unused"), - }), -) + ), +}) const formatter = Layer.mock(Formatter.Service, { file: (target) => formatFile(target), diff --git a/packages/core/test/tool-question.test.ts b/packages/core/test/tool-question.test.ts index 8fe3d6ab01..d8a78fe3d7 100644 --- a/packages/core/test/tool-question.test.ts +++ b/packages/core/test/tool-question.test.ts @@ -10,6 +10,7 @@ import { QuestionTool } from "@opencode-ai/core/tool/plugin/question" import { Image } from "@opencode-ai/core/image" import { testEffect } from "./lib/effect" import { imagePassthrough } from "./lib/image" +import { permissionLayer } from "./lib/permission" import { makeLocationNode } from "@opencode-ai/util/effect/app-node" import { toolIdentity, executeTool, registerToolPlugin, toolDefinitions } from "./lib/tool" @@ -28,30 +29,22 @@ const questionInput = { }, ], } -const permission = Layer.succeed( - Permission.Service, - Permission.Service.of({ - assert: (input) => - Effect.sync(() => assertions.push(input)).pipe( - Effect.andThen( - deny - ? Effect.fail( - new Permission.BlockedError({ - rules: [], - permission: input.action, - resources: input.resources, - }), - ) - : Effect.void, - ), +const permission = permissionLayer({ + assert: (input) => + Effect.sync(() => assertions.push(input)).pipe( + Effect.andThen( + deny + ? Effect.fail( + new Permission.BlockedError({ + rules: [], + permission: input.action, + resources: input.resources, + }), + ) + : Effect.void, ), - ask: () => Effect.die("unused"), - reply: () => Effect.die("unused"), - get: () => Effect.die("unused"), - forSession: () => Effect.die("unused"), - list: () => Effect.die("unused"), - }), -) + ), +}) const form = Layer.succeed( Form.Service, Form.Service.of({ diff --git a/packages/core/test/tool-read.test.ts b/packages/core/test/tool-read.test.ts index de490f57eb..f00c1711e4 100644 --- a/packages/core/test/tool-read.test.ts +++ b/packages/core/test/tool-read.test.ts @@ -23,6 +23,7 @@ import { makeLocationNode } from "@opencode-ai/util/effect/app-node" import { SessionInstructions } from "@opencode-ai/core/session/instructions" import { Environment } from "@opencode-ai/core/environment/index" import { testEffect } from "./lib/effect" +import { permissionLayer } from "./lib/permission" import { toolIdentity, executeTool, registerToolPlugin, toolDefinitions } from "./lib/tool" const readToolNode = makeLocationNode({ @@ -70,32 +71,24 @@ const reader = Layer.succeed( }), ) let allow = true -const permission = Layer.succeed( - Permission.Service, - Permission.Service.of({ - assert: (input) => - Effect.sync(() => { - assertions.push(input) - }).pipe( - Effect.andThen( - allow - ? Effect.void - : Effect.fail( - new Permission.BlockedError({ - rules: [], - permission: input.action, - resources: input.resources, - }), - ), - ), +const permission = permissionLayer({ + assert: (input) => + Effect.sync(() => { + assertions.push(input) + }).pipe( + Effect.andThen( + allow + ? Effect.void + : Effect.fail( + new Permission.BlockedError({ + rules: [], + permission: input.action, + resources: input.resources, + }), + ), ), - ask: () => Effect.die("unused"), - reply: () => Effect.die("unused"), - get: () => Effect.die("unused"), - forSession: () => Effect.die("unused"), - list: () => Effect.die("unused"), - }), -) + ), +}) const config = Config.testLayer() const imageLayer = AppNodeBuilder.build(Image.node, [[Config.node, config]]) const testFileSystem = Layer.effect( diff --git a/packages/core/test/tool-search.test.ts b/packages/core/test/tool-search.test.ts index a87c45cd88..5c04f81573 100644 --- a/packages/core/test/tool-search.test.ts +++ b/packages/core/test/tool-search.test.ts @@ -19,6 +19,7 @@ import { Tool } from "@opencode-ai/core/tool" import { location } from "./fixture/location" import { tmpdir } from "./fixture/tmpdir" import { testEffect } from "./lib/effect" +import { permissionLayer } from "./lib/permission" import { executeTool, registerToolPlugin, toolIdentity } from "./lib/tool" const globToolNode = makeLocationNode({ @@ -49,20 +50,12 @@ const withTools = ( ], [ Permission.node, - Layer.succeed( - Permission.Service, - Permission.Service.of({ - assert: (input) => - Effect.sync(() => { - assertions?.push(input) - }), - ask: () => Effect.die("unused"), - reply: () => Effect.die("unused"), - get: () => Effect.die("unused"), - forSession: () => Effect.die("unused"), - list: () => Effect.die("unused"), - }), - ), + permissionLayer({ + assert: (input) => + Effect.sync(() => { + assertions?.push(input) + }), + }), ], ]), ), diff --git a/packages/core/test/tool-shell.test.ts b/packages/core/test/tool-shell.test.ts index 0e6ac46cb2..d203479e89 100644 --- a/packages/core/test/tool-shell.test.ts +++ b/packages/core/test/tool-shell.test.ts @@ -39,42 +39,38 @@ import { Tool } from "@opencode-ai/core/tool" import { tmpdir } from "./fixture/tmpdir" import { tempGlobalLayer } from "./fixture/global" import { testEffect } from "./lib/effect" +import { permissionLayer } from "./lib/permission" import { toolIdentity, executeTool, registerToolPlugin, toolDefinitions } from "./lib/tool" const sessionID = Session.ID.make("ses_shell_tool_test") const sessionModel = Model.Ref.make({ id: Model.ID.make("test"), providerID: Provider.ID.make("test") }) const assertions: Permission.AssertInput[] = [] +const allowedActions = new Set() let denyAction: string | undefined let afterPermission = (_input: Permission.AssertInput): Effect.Effect => Effect.void -const permission = Layer.succeed( - Permission.Service, - Permission.Service.of({ - assert: (input) => - Effect.sync(() => assertions.push(input)).pipe( - Effect.andThen(Effect.suspend(() => afterPermission(input))), - Effect.andThen( - input.action === denyAction - ? Effect.fail( - new Permission.BlockedError({ - rules: [], - permission: input.action, - resources: input.resources, - }), - ) - : Effect.void, - ), +const permission = permissionLayer({ + allowsAll: (input) => Effect.succeed(allowedActions.has(input.action)), + assert: (input) => + Effect.sync(() => assertions.push(input)).pipe( + Effect.andThen(Effect.suspend(() => afterPermission(input))), + Effect.andThen( + input.action === denyAction + ? Effect.fail( + new Permission.BlockedError({ + rules: [], + permission: input.action, + resources: input.resources, + }), + ) + : Effect.void, ), - ask: () => Effect.die("unused"), - reply: () => Effect.die("unused"), - get: () => Effect.die("unused"), - forSession: () => Effect.die("unused"), - list: () => Effect.die("unused"), - }), -) + ), +}) const reset = () => { assertions.length = 0 + allowedActions.clear() denyAction = undefined afterPermission = () => Effect.void } @@ -337,6 +333,30 @@ describe("ShellTool", () => { { timeout: 15_000 }, ) + it.live( + "skips command decomposition when shell and external directories are unrestricted", + () => + Effect.acquireUseRelease( + Effect.promise(() => tmpdir()), + (tmp) => { + reset() + allowedActions.add("shell") + allowedActions.add("external_directory") + return withSession(tmp.path, (registry) => + executeTool(registry, call({ command: "printf one && printf two" }, "call-unrestricted")), + ).pipe( + Effect.andThen( + Effect.sync(() => { + expect(assertions).toEqual([]) + }), + ), + ) + }, + (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]().then(() => undefined)), + ), + { timeout: 15_000 }, + ) + it.live( "captures stderr-only and mixed stdout/stderr output", () => diff --git a/packages/core/test/tool-skill.test.ts b/packages/core/test/tool-skill.test.ts index 7872d0fef1..c3c65a1415 100644 --- a/packages/core/test/tool-skill.test.ts +++ b/packages/core/test/tool-skill.test.ts @@ -14,6 +14,7 @@ import { tmpdir } from "./fixture/tmpdir" import { Image } from "@opencode-ai/core/image" import { it } from "./lib/effect" import { imagePassthrough } from "./lib/image" +import { permissionLayer } from "./lib/permission" import { makeLocationNode } from "@opencode-ai/util/effect/app-node" import { FSUtil } from "@opencode-ai/util/fs-util" import { toolIdentity, executeTool, registerToolPlugin, toolDefinitions } from "./lib/tool" @@ -52,30 +53,22 @@ describe("SkillTool", () => { let current = [info] const assertions: Permission.AssertInput[] = [] let deny = false - const permission = Layer.succeed( - Permission.Service, - Permission.Service.of({ - assert: (input) => - Effect.sync(() => assertions.push(input)).pipe( - Effect.andThen( - deny - ? Effect.fail( - new Permission.BlockedError({ - rules: [], - permission: input.action, - resources: input.resources, - }), - ) - : Effect.void, - ), + const permission = permissionLayer({ + assert: (input) => + Effect.sync(() => assertions.push(input)).pipe( + Effect.andThen( + deny + ? Effect.fail( + new Permission.BlockedError({ + rules: [], + permission: input.action, + resources: input.resources, + }), + ) + : Effect.void, ), - ask: () => Effect.die("unused"), - reply: () => Effect.die("unused"), - get: () => Effect.die("unused"), - forSession: () => Effect.die("unused"), - list: () => Effect.die("unused"), - }), - ) + ), + }) const skills = Layer.succeed( Skill.Service, Skill.Service.of({ diff --git a/packages/core/test/tool-webfetch.test.ts b/packages/core/test/tool-webfetch.test.ts index 315272d42d..bac467da1f 100644 --- a/packages/core/test/tool-webfetch.test.ts +++ b/packages/core/test/tool-webfetch.test.ts @@ -13,6 +13,7 @@ import { makeLocationNode } from "@opencode-ai/util/effect/app-node" import { Image } from "@opencode-ai/core/image" import { testEffect } from "./lib/effect" import { imagePassthrough } from "./lib/image" +import { permissionLayer } from "./lib/permission" import { toolIdentity, executeTool, registerToolPlugin, toolDefinitions } from "./lib/tool" const webFetchToolNode = makeLocationNode({ @@ -36,17 +37,7 @@ const http = Layer.succeed( ), ), ) -const permission = Layer.succeed( - Permission.Service, - Permission.Service.of({ - assert: (input) => Effect.sync(() => assertions.push(input)), - ask: () => Effect.die("unused"), - reply: () => Effect.die("unused"), - get: () => Effect.die("unused"), - forSession: () => Effect.die("unused"), - list: () => Effect.die("unused"), - }), -) +const permission = permissionLayer({ assert: (input) => Effect.sync(() => assertions.push(input)) }) const toolLayer = (replacements: LayerNode.Replacements = []) => AppNodeBuilder.build(LayerNode.group([Tool.node, webFetchToolNode]), [ [Permission.node, permission], diff --git a/packages/core/test/tool-websearch.test.ts b/packages/core/test/tool-websearch.test.ts index 8275a62826..61ecbe7efe 100644 --- a/packages/core/test/tool-websearch.test.ts +++ b/packages/core/test/tool-websearch.test.ts @@ -15,6 +15,7 @@ import { makeLocationNode } from "@opencode-ai/util/effect/app-node" import { Image } from "@opencode-ai/core/image" import { testEffect } from "./lib/effect" import { imagePassthrough } from "./lib/image" +import { permissionLayer } from "./lib/permission" import { toolIdentity, executeTool, registerToolPlugin, toolDefinitions } from "./lib/tool" import { webSearchHost } from "./plugin/host" @@ -66,17 +67,9 @@ beforeEach(() => { }) }) -const permission = Layer.succeed( - Permission.Service, - Permission.Service.of({ - assert: (input) => Effect.sync(() => assertions.push(input)), - ask: () => Effect.die("unused"), - reply: () => Effect.die("unused"), - get: () => Effect.die("unused"), - forSession: () => Effect.die("unused"), - list: () => Effect.die("unused"), - }), -) +const permission = permissionLayer({ + assert: (input) => Effect.sync(() => assertions.push(input)), +}) const websearch = Layer.succeed( WebSearch.Service, WebSearch.Service.of({ diff --git a/packages/core/test/tool-write.test.ts b/packages/core/test/tool-write.test.ts index 46d2ea6fab..563e02d2e1 100644 --- a/packages/core/test/tool-write.test.ts +++ b/packages/core/test/tool-write.test.ts @@ -19,6 +19,7 @@ import { location } from "./fixture/location" import { tmpdir } from "./fixture/tmpdir" import { makeLocationNode } from "@opencode-ai/util/effect/app-node" import { testEffect } from "./lib/effect" +import { permissionLayer } from "./lib/permission" import { toolIdentity, executeTool, registerToolPlugin, toolDefinitions } from "./lib/tool" const writeToolNode = makeLocationNode({ @@ -33,30 +34,22 @@ const writes: string[] = [] let formatFile = (_target: string): Effect.Effect => Effect.succeed(false) let denyAction: string | undefined -const permission = Layer.succeed( - Permission.Service, - Permission.Service.of({ - assert: (input) => - Effect.sync(() => assertions.push(input)).pipe( - Effect.andThen( - input.action === denyAction - ? Effect.fail( - new Permission.BlockedError({ - rules: [], - permission: input.action, - resources: input.resources, - }), - ) - : Effect.void, - ), +const permission = permissionLayer({ + assert: (input) => + Effect.sync(() => assertions.push(input)).pipe( + Effect.andThen( + input.action === denyAction + ? Effect.fail( + new Permission.BlockedError({ + rules: [], + permission: input.action, + resources: input.resources, + }), + ) + : Effect.void, ), - ask: () => Effect.die("unused"), - reply: () => Effect.die("unused"), - get: () => Effect.die("unused"), - forSession: () => Effect.die("unused"), - list: () => Effect.die("unused"), - }), -) + ), +}) const formatter = Layer.mock(Formatter.Service, { file: (target) => formatFile(target), diff --git a/packages/desktop/AGENTS.md b/packages/desktop/AGENTS.md index 5040a3d090..8704205d05 100644 --- a/packages/desktop/AGENTS.md +++ b/packages/desktop/AGENTS.md @@ -1,5 +1,6 @@ # Desktop package notes +- Follow Solid best practices, leave a comment when violating this: https://www.brenelz.com/posts/solid-js-best-practices/ - Renderer process should only call `window.api` from `src/preload`. - Main process should register IPC handlers in `src/main/ipc.ts`. - NEVER hardcode user-visible English strings in production code. ALWAYS use an i18n key for native menus, picker titles, dialogs, buttons, accessible labels, and displayed errors. diff --git a/packages/desktop/scripts/dev.ts b/packages/desktop/scripts/dev.ts index b4173ca980..398894a955 100644 --- a/packages/desktop/scripts/dev.ts +++ b/packages/desktop/scripts/dev.ts @@ -1,7 +1,6 @@ import { $ } from "bun" -import { homedir } from "node:os" import { join } from "node:path" -import { buildCliToResources, downloadCliToResources, windowsify } from "./utils" +import { downloadCliToResources, windowsify } from "./utils" type ServerSource = { type: "build" } | { type: "download"; version: string } type DevOptions = { server: ServerSource; electron: string[] } @@ -38,18 +37,12 @@ function selectOptions(): DevOptions { } async function prepareServer(source: ServerSource) { - const destination = windowsify("resources/opencode-cli-dev") - if (source.type === "download") return downloadCliToResources(source.version, destination) - return buildCliToResources(destination, developmentStateHome()) -} - -function developmentStateHome() { - const appData = (() => { - if (process.platform === "darwin") return join(homedir(), "Library", "Application Support") - if (process.platform === "win32") return process.env.APPDATA ?? join(homedir(), "AppData", "Roaming") - return process.env.XDG_CONFIG_HOME ?? join(homedir(), ".config") - })() - return join(appData, "ai.opencode.desktop.dev") + if (source.type === "download") + return downloadCliToResources(source.version, windowsify("resources/opencode-cli-dev")) + process.env.OPENCODE_DESKTOP_CLI_DEV = join(import.meta.dirname, "../../cli") + if (process.platform !== "win32") return + process.env.OPENCODE_DESKTOP_WSL_CLI_BUILD = join(import.meta.dirname, "../../cli/script/build.ts") + process.env.OPENCODE_DESKTOP_WSL_CLI_OUTPUT = join(import.meta.dirname, "../resources/opencode-cli-wsl") } async function startDesktop(args: string[]) { diff --git a/packages/desktop/scripts/utils.ts b/packages/desktop/scripts/utils.ts index 34a215327c..a5f85e11f5 100644 --- a/packages/desktop/scripts/utils.ts +++ b/packages/desktop/scripts/utils.ts @@ -86,34 +86,6 @@ export async function downloadCliToResources(version = CLI_VERSION, dest = windo console.log(`Copied ${cli.package}@${version} to ${dest}`) } -export async function buildCliToResources(dest = windowsify("resources/opencode-cli"), stateHome?: string) { - const directory = await mkdtemp(join(tmpdir(), "opencode-cli-")) - const target = `cli-${process.platform === "win32" ? "windows" : process.platform}-${process.arch}` - try { - await $`bun ${join(import.meta.dirname, "../../cli/script/build.ts")} --single --skip-install --skip-web-ui --outdir=${directory}`.env( - { - ...process.env, - OPENCODE_VERSION: process.env.OPENCODE_VERSION, - }, - ) - if (stateHome && (await Bun.file(dest).exists())) { - const child = Bun.spawn([dest, "service", "stop"], { - env: { ...process.env, XDG_STATE_HOME: stateHome }, - stdout: "inherit", - stderr: "inherit", - }) - const exitCode = await child.exited - if (exitCode !== 0) throw new Error(`Failed to stop development service: ${exitCode}`) - } - await copyFile(join(directory, target, "bin", windowsify("opencode2")), dest) - } finally { - await rm(directory, { recursive: true, force: true }) - } - await prepareCli(dest) - - console.log(`Built local CLI at ${dest}`) -} - async function prepareCli(dest: string) { if (process.platform !== "win32") await chmod(dest, 0o755) if (process.platform === "win32" && process.env.GITHUB_ACTIONS === "true") { diff --git a/packages/desktop/src/main/background-cli.ts b/packages/desktop/src/main/background-cli.ts index 9e739bf15c..bfb0e0eaa6 100644 --- a/packages/desktop/src/main/background-cli.ts +++ b/packages/desktop/src/main/background-cli.ts @@ -6,6 +6,7 @@ import { dirname, join } from "node:path" import { fileURLToPath } from "node:url" import { promisify } from "node:util" import { app } from "electron" +import { parseCliVersion } from "./cli-version" const execFileAsync = promisify(execFile) const root = dirname(fileURLToPath(import.meta.url)) @@ -17,36 +18,64 @@ type Logger = { export async function startBackgroundCli(logger: Logger) { const isolated = !app.isPackaged && process.env.OPENCODE_DESKTOP_ISOLATED_SERVER === "1" - const bundled = app.isPackaged - ? join(process.resourcesPath, executableName()) - : join(root, "../../resources", isolated ? developmentExecutableName() : executableName()) - logger.log("v2 CLI executable resolved", { bundled, packaged: app.isPackaged }) - const version = parseVersion(await run(bundled, ["--version"], logger)) - const binary = app.isPackaged || isolated ? await installCli(bundled, version, logger) : bundled + const development = !app.isPackaged && process.env.OPENCODE_DESKTOP_CLI_DEV + const developmentVersion = process.env.OPENCODE_VERSION ?? "local" + const cli = development + ? { + version: developmentVersion, + command: [ + "bun", + "run", + "--cwd", + development, + `--define=OPENCODE_VERSION=${JSON.stringify(developmentVersion)}`, + "src/index.ts", + ], + binary: undefined, + } + : await resolveBundledCli(isolated, logger) if (isolated) process.env.XDG_STATE_HOME = app.getPath("userData") const service = await Service.ensure({ file: isolated && process.env.OPENCODE_DESKTOP_SERVER_CHANNEL === "local" ? join(app.getPath("userData"), "opencode", "service-local.json") : undefined, - version, - command: [binary, "serve", "--service"], + version: cli.version, + command: [...cli.command, "serve", "--service"], onStart: (reason, previousVersion) => logger.log("v2 CLI background service starting", { reason, previousVersion }), }) if (service.auth?.type !== "basic") throw new Error("V2 CLI background service did not provide authentication") logger.log("v2 CLI background service ready", { username: service.auth.username, - version, + version: cli.version, ...endpoint(service.url), }) - if (isolated) await cleanCliStages(binary, logger) + if (isolated && cli.binary) await cleanCliStages(cli.binary, logger) return { url: service.url, username: service.auth.username, password: service.auth.password, + version: cli.version, + wslBuild: + app.isPackaged || !process.env.OPENCODE_DESKTOP_WSL_CLI_BUILD || !process.env.OPENCODE_DESKTOP_WSL_CLI_OUTPUT + ? undefined + : { + script: process.env.OPENCODE_DESKTOP_WSL_CLI_BUILD, + output: process.env.OPENCODE_DESKTOP_WSL_CLI_OUTPUT, + }, } } +async function resolveBundledCli(isolated: boolean, logger: Logger) { + const bundled = app.isPackaged + ? join(process.resourcesPath, executableName()) + : join(root, "../../resources", isolated ? developmentExecutableName() : executableName()) + logger.log("v2 CLI executable resolved", { bundled, packaged: app.isPackaged }) + const version = parseCliVersion(await run(bundled, ["--version"], logger)) + const binary = app.isPackaged || isolated ? await installCli(bundled, version, logger) : bundled + return { version, binary, command: [binary] } +} + async function cleanCliStages(binary: string, logger: Logger) { const current = dirname(binary) const root = dirname(current) @@ -103,13 +132,6 @@ async function run(binary: string, args: string[], logger: Logger) { ) } -function parseVersion(output: string) { - const marker = output.lastIndexOf(" v") - const version = marker === -1 ? output : output.slice(marker + 2) - if (!version) throw new Error("V2 CLI did not provide a version") - return version -} - function endpoint(url: string | undefined) { if (!url || !URL.canParse(url)) return {} const parsed = new URL(url) diff --git a/packages/desktop/src/main/cli-version.ts b/packages/desktop/src/main/cli-version.ts new file mode 100644 index 0000000000..4e70636d79 --- /dev/null +++ b/packages/desktop/src/main/cli-version.ts @@ -0,0 +1,6 @@ +export function parseCliVersion(output: string) { + const marker = output.lastIndexOf(" v") + const version = marker === -1 ? output : output.slice(marker + 2) + if (!version) throw new Error("V2 CLI did not provide a version") + return version +} diff --git a/packages/desktop/src/main/index.ts b/packages/desktop/src/main/index.ts index 91d4035446..1176ec3d36 100644 --- a/packages/desktop/src/main/index.ts +++ b/packages/desktop/src/main/index.ts @@ -36,9 +36,7 @@ import { setDockIcon, restoreMainWindows, } from "./windows" -import { createWslServersController } from "./wsl/servers" import { registerWslIpcHandlers } from "./wsl/ipc" -import { spawnWslSidecar } from "./wsl/sidecar" import { migrate } from "./migrate" import { cleanupStoreFiles } from "./store-cleanup" import { startBackgroundCli } from "./background-cli" @@ -134,25 +132,10 @@ const main = Effect.gen(function* () { logger = initLogging() initCrashReporter() - const wslServers = createWslServersController( - VERSION, - async (distro) => { - logger.log("spawning wsl sidecar", { distro }) - return spawnWslSidecar(distro, { - onLine: (line) => logger.log("wsl sidecar", { distro, stream: line.stream, text: line.text }), - }) - }, - { - logger: { - log: (message, meta) => logger.log(message, meta), - error: (message, meta) => logger.error(message, meta), - }, - }, - ) - const stopSidecars = async () => wslServers.stopAll() + let stopWslServers = async () => {} const relaunch = () => { setAppQuitting() - void stopSidecars().finally(() => { + void stopWslServers().finally(() => { app.relaunch() app.quit() }) @@ -205,12 +188,12 @@ const main = Effect.gen(function* () { app.on("before-quit", () => { setAppQuitting() - void stopSidecars() + void stopWslServers() }) app.on("will-quit", () => { setAppQuitting() - void stopSidecars() + void stopWslServers() }) app.on("child-process-gone", (_event, details) => { @@ -228,7 +211,7 @@ const main = Effect.gen(function* () { for (const signal of ["SIGINT", "SIGTERM"] as const) { process.on(signal, () => { setAppQuitting() - void stopSidecars().finally(() => app.quit()) + void stopWslServers().finally(() => app.quit()) }) } @@ -253,7 +236,7 @@ const main = Effect.gen(function* () { app.setAsDefaultProtocolClient("opencode") registerRendererProtocol() setDockIcon() - const updater = setupAutoUpdater(stopSidecars) + const updater = setupAutoUpdater(() => stopWslServers()) const menuDeps = { trigger: (id: string) => { const win = getLastFocusedWindow() @@ -293,7 +276,6 @@ const main = Effect.gen(function* () { }, }) registerUpdaterIpc(updater) - registerWslIpcHandlers(wslServers) void updater.start() const updateTimer = setInterval(() => void updater.check(), 10 * 60 * 1000) updateTimer.unref() @@ -311,16 +293,14 @@ const main = Effect.gen(function* () { useEnvProxy() logger.log("starting v2 background service") - const sidecar = yield* Effect.promise(() => startBackgroundCli(logger)) - yield* Deferred.succeed(serverReady, { - url: sidecar.url, - username: sidecar.username, - password: sidecar.password, - }) + const background = yield* Effect.promise(() => startBackgroundCli(logger)) + stopWslServers = yield* Effect.promise(() => startWslServers(background)) - if (process.platform === "win32") { - void wslServers.initialize().catch((error) => logger.error("wsl server initialization failed", error)) - } + yield* Deferred.succeed(serverReady, { + url: background.url, + username: background.username, + password: background.password, + }) logger.log("loading task finished") }).pipe(forwardInitializationFailure(serverReady), Effect.forkChild) @@ -340,4 +320,41 @@ const main = Effect.gen(function* () { if (windows.length) createMenu(menuDeps) }) +async function startWslServers(cli: { version: string; wslBuild?: { script: string; output: string } }) { + if (process.platform !== "win32") { + registerWslIpcHandlers() + return async () => {} + } + + const { createWslServersController } = await import("./wsl/servers") + const { spawnWslSidecar } = await import("./wsl/sidecar") + const local = cli.wslBuild + const controller = createWslServersController({ + cli: { version: cli.version }, + installCli: local + ? async (distro) => { + const { buildLocalWslCli } = await import("./wsl/local") + const { installWslCli } = await import("./wsl/runtime") + await installWslCli(distro, { + version: cli.version, + binary: await buildLocalWslCli({ ...local, version: cli.version }), + }) + } + : undefined, + spawnSidecar: async (distro) => { + logger.log("spawning wsl sidecar", { distro }) + return spawnWslSidecar(distro, { + onLine: (line) => logger.log("wsl sidecar", { distro, stream: line.stream, text: line.text }), + }) + }, + logger: { + log: (message, meta) => logger.log(message, meta), + error: (message, meta) => logger.error(message, meta), + }, + }) + registerWslIpcHandlers(controller) + controller.startConfiguredServers() + return async () => controller.stopServers() +} + Effect.runFork(main) diff --git a/packages/desktop/src/main/wsl/ipc.ts b/packages/desktop/src/main/wsl/ipc.ts index 839efad585..ac7e1ac581 100644 --- a/packages/desktop/src/main/wsl/ipc.ts +++ b/packages/desktop/src/main/wsl/ipc.ts @@ -1,12 +1,11 @@ import { app, ipcMain } from "electron" import type { IpcMainInvokeEvent } from "electron" import type { WslServersController } from "./servers" -import { requireWslIpcString, requireWslIpcStrings } from "./policy" import type { WslServersState } from "../../preload/types" import { nativeT } from "../native-translations" -export function registerWslIpcHandlers(controller: WslServersController) { - if (process.platform !== "win32") { +export function registerWslIpcHandlers(controller?: WslServersController) { + if (!controller) { registerUnavailableWslIpcHandlers() return } @@ -67,6 +66,18 @@ export function registerWslIpcHandlers(controller: WslServersController) { ) } +function requireWslIpcString(name: string, value: unknown) { + if (typeof value === "string" && value.length > 0) return value + throw new Error(`Invalid ${name}`) +} + +function requireWslIpcStrings(name: string, value: unknown) { + if (!Array.isArray(value)) throw new Error(`Invalid ${name}`) + const values = value.map((item) => requireWslIpcString(name, item)) + if (values.length) return values + throw new Error(`Invalid ${name}`) +} + function registerUnavailableWslIpcHandlers() { const unavailable = () => { throw new Error(nativeT("desktop.wsl.error.windowsOnly")) diff --git a/packages/desktop/src/main/wsl/local.ts b/packages/desktop/src/main/wsl/local.ts new file mode 100644 index 0000000000..e1ec23eb0d --- /dev/null +++ b/packages/desktop/src/main/wsl/local.ts @@ -0,0 +1,38 @@ +import { execFile } from "node:child_process" +import { copyFile, mkdtemp, readFile, rm } from "node:fs/promises" +import { tmpdir } from "node:os" +import { dirname, join } from "node:path" +import { promisify } from "node:util" + +const execFileAsync = promisify(execFile) + +export async function buildLocalWslCli(input: { version: string; script: string; output: string }) { + const directory = await mkdtemp(join(tmpdir(), "opencode-wsl-cli-")) + const root = join(dirname(input.script), "../../..") + const packageManager = (JSON.parse(await readFile(join(root, "package.json"), "utf8")) as { packageManager: string }) + .packageManager + const target = `linux-${process.arch}` + try { + await execFileAsync("bunx", [packageManager, "install", "--os=*", "--cpu=*", "--frozen-lockfile"], { + cwd: root, + env: process.env, + windowsHide: true, + }) + await execFileAsync( + "bunx", + [ + packageManager, + input.script, + `--target=opencode2-${target}`, + "--skip-install", + "--skip-web-ui", + `--outdir=${directory}`, + ], + { cwd: root, env: { ...process.env, OPENCODE_VERSION: input.version }, windowsHide: true }, + ) + await copyFile(join(directory, `cli-${target}`, "bin", "opencode2"), input.output) + return input.output + } finally { + await rm(directory, { recursive: true, force: true }) + } +} diff --git a/packages/desktop/src/main/wsl/policy.ts b/packages/desktop/src/main/wsl/policy.ts deleted file mode 100644 index 4ceee26c6b..0000000000 --- a/packages/desktop/src/main/wsl/policy.ts +++ /dev/null @@ -1,33 +0,0 @@ -import type { WslDistroProbe, WslOpencodeCheck, WslServerItem } from "../../preload/types" - -export function wslServerIdToRestart(servers: WslServerItem[], distro: string) { - return servers.find((item) => item.config.distro === distro)?.config.id -} - -export function clearWslDistroState( - distroProbes: Record, - opencodeChecks: Record, - distro: string, -) { - const nextDistroProbes = { ...distroProbes } - const nextOpencodeChecks = { ...opencodeChecks } - delete nextDistroProbes[distro] - delete nextOpencodeChecks[distro] - return { distroProbes: nextDistroProbes, opencodeChecks: nextOpencodeChecks } -} - -export function wslTerminalArgs(distro?: string | null) { - return ["/c", "start", "", "wsl", ...(distro ? ["-d", distro] : [])] -} - -export function requireWslIpcString(name: string, value: unknown) { - if (typeof value === "string" && value.length > 0) return value - throw new Error(`Invalid ${name}`) -} - -export function requireWslIpcStrings(name: string, value: unknown) { - if (!Array.isArray(value)) throw new Error(`Invalid ${name}`) - const values = value.map((item) => requireWslIpcString(name, item)) - if (values.length > 0) return values - throw new Error(`Invalid ${name}`) -} diff --git a/packages/desktop/src/main/wsl/runtime.ts b/packages/desktop/src/main/wsl/runtime.ts index ffbc91fa30..95b24711c7 100644 --- a/packages/desktop/src/main/wsl/runtime.ts +++ b/packages/desktop/src/main/wsl/runtime.ts @@ -3,7 +3,7 @@ import { existsSync } from "node:fs" import { join } from "node:path" import * as pty from "@lydell/node-pty" import type { WslDistroProbe, WslInstalledDistro, WslOnlineDistro, WslRuntimeCheck } from "../../preload/types" -import { wslTerminalArgs } from "./policy" +import { parseCliVersion } from "../cli-version" import { nativeT } from "../native-translations" export type WslCommandLine = { @@ -31,6 +31,11 @@ export type RunWslOptions = { timeoutMs?: number } +export type WslCliBuild = { + version: string + binary?: string +} + const DEFAULT_WSL_TIMEOUT_MS = 20_000 const DEFAULT_WSL_INSTALL_TIMEOUT_MS = 15 * 60_000 @@ -252,28 +257,34 @@ export async function installWslRuntimeElevated(opts?: RunWslOptions) { "$process = Start-Process -FilePath 'wsl.exe' -Verb RunAs -ArgumentList @('--install','--no-distribution') -Wait -PassThru", "if ($null -ne $process.ExitCode) { exit $process.ExitCode }", ].join("; ") - return runPowerShell(script, withTimeout(opts, DEFAULT_WSL_INSTALL_TIMEOUT_MS)) + const result = await runPowerShell(script, withTimeout(opts, DEFAULT_WSL_INSTALL_TIMEOUT_MS)) + requireSuccess(result, nativeT("desktop.wsl.error.installWsl")) } -export async function installWslDistro(name: string, opts?: RunWslOptions) { - return runInteractiveCommand( +export async function installWslDistro(distro: string, opts?: RunWslOptions) { + const result = await runInteractiveCommand( resolveSystem32Command("wsl.exe"), - ["--install", "-d", name, "--web-download", "--no-launch"], + ["--install", "-d", distro, "--web-download", "--no-launch"], withTimeout(opts, DEFAULT_WSL_INSTALL_TIMEOUT_MS), DEFAULT_WSL_INSTALL_TIMEOUT_MS, ) + requireSuccess(result, nativeT("desktop.wsl.error.installDistro", { distro })) } -export async function installWslOpencode(version: string, distro: string, opts?: RunWslOptions) { - return runInteractiveCommand( +export async function installWslCli(distro: string, cli: WslCliBuild, opts?: RunWslOptions) { + const result = await runInteractiveCommand( resolveSystem32Command("wsl.exe"), - wslArgs( - ["bash", "-lc", `curl -fsSL https://opencode.ai/install | bash -s -- --version ${shellEscape(version)}`], - distro, - ), + wslArgs(["bash", "-lc", wslCliInstallCommand(cli)], distro), withTimeout(opts, DEFAULT_WSL_INSTALL_TIMEOUT_MS), DEFAULT_WSL_INSTALL_TIMEOUT_MS, ) + requireSuccess(result, nativeT("desktop.wsl.error.installOpencode")) +} + +export function wslCliInstallCommand(cli: WslCliBuild) { + const installer = "curl -fsSL https://raw.githubusercontent.com/anomalyco/opencode/v2/install | bash -s --" + if (!cli.binary) return `${installer} --version ${shellEscape(cli.version)}` + return `${installer} --binary "$(wslpath -a ${shellEscape(cli.binary)})"` } export async function probeWslDistro(name: string, opts?: RunWslOptions): Promise { @@ -307,11 +318,11 @@ export async function probeWslDistro(name: string, opts?: RunWslOptions): Promis } } -export async function resolveWslOpencode(distro: string, opts?: RunWslOptions) { +export async function resolveWslCli(distro: string, opts?: RunWslOptions) { return firstLine( ( await runWslSh( - 'if [ -x "$HOME/.opencode/bin/opencode" ]; then printf "%s\\n" "$HOME/.opencode/bin/opencode"; fi', + 'if [ -x "$HOME/.opencode/bin/opencode2" ]; then printf "%s\\n" "$HOME/.opencode/bin/opencode2"; fi', distro, opts, ) @@ -319,14 +330,15 @@ export async function resolveWslOpencode(distro: string, opts?: RunWslOptions) { ) } -export async function readWslCommandVersion(command: string, distro: string, opts?: RunWslOptions) { +export async function readWslCliVersion(command: string, distro: string, opts?: RunWslOptions) { const result = await runWslSh(`${shellEscape(command)} --version 2>/dev/null || true`, distro, opts) - return firstLine(result.stdout) + const output = firstLine(result.stdout) + return output ? parseCliVersion(output) : null } export function openWslTerminal(distro?: string | null) { return new Promise((resolve, reject) => { - const child = spawn("cmd.exe", wslTerminalArgs(distro), { + const child = spawn("cmd.exe", ["/c", "start", "", "wsl", ...(distro ? ["-d", distro] : [])], { detached: true, stdio: "ignore", windowsHide: true, @@ -386,6 +398,11 @@ export function summarize(value: string) { .join("\n") } +function requireSuccess(result: WslCommandResult, fallback: string) { + if (result.code === 0) return + throw new Error(summarize(result.stderr || result.stdout) || fallback) +} + export function shellEscape(value: string) { return `'${value.replace(/'/g, `'"'"'`)}'` } diff --git a/packages/desktop/src/main/wsl/servers.test.ts b/packages/desktop/src/main/wsl/servers.test.ts index 35b688d3c8..ba6c6a01bf 100644 --- a/packages/desktop/src/main/wsl/servers.test.ts +++ b/packages/desktop/src/main/wsl/servers.test.ts @@ -1,153 +1,80 @@ import { expect, test } from "bun:test" -import { - clearWslDistroState, - requireWslIpcString, - requireWslIpcStrings, - wslServerIdToRestart, - wslTerminalArgs, -} from "./policy" -import { - expectOpencodeVersion, - pendingRestartAfterWslInstall, - pollWslHealth, - wslServerIdsToStartOnInitialize, -} from "./startup" -import { createWslServersController, type WslServerConfig } from "./servers" +import type { WslServerConfig } from "../../preload/types" +import { wslCliInstallCommand } from "./runtime" +import { createWslServersController } from "./servers" + +type ControllerOptions = Parameters[0] let persistedServers: WslServerConfig[] = [] -let releaseOpencodeResolve: (() => void) | undefined -test("starts every configured WSL server on initialization", () => { - expect( - wslServerIdsToStartOnInitialize([ - { id: "wsl:Debian", distro: "Debian" }, - { id: "wsl:Ubuntu-24.04", distro: "Ubuntu-24.04" }, - ]), - ).toEqual(["wsl:Debian", "wsl:Ubuntu-24.04"]) -}) - -test("rejects an update that did not install the desktop version", () => { - expect(() => expectOpencodeVersion("1.16.2", "1.16.2")).not.toThrow() - expect(() => expectOpencodeVersion("1.14.35", "1.16.2")).toThrow( - "OpenCode update finished but Debian still reports 1.14.35; expected 1.16.2", +test("passes a local CLI path directly to the V2 installer", () => { + expect(wslCliInstallCommand({ version: "local", binary: "C:\\build\\opencode2" })).toBe( + `curl -fsSL https://raw.githubusercontent.com/anomalyco/opencode/v2/install | bash -s -- --binary "$(wslpath -a 'C:\\build\\opencode2')"`, ) }) -test("restarts an existing distro server after updating OpenCode", () => { - expect( - wslServerIdToRestart( - [ - { - config: { id: "wsl:Debian", distro: "Debian" }, - runtime: { kind: "ready", url: "", username: null, password: null }, - }, - ], - "Debian", - ), - ).toBe("wsl:Debian") - expect(wslServerIdToRestart([], "Debian")).toBeUndefined() -}) - -test("clears cached distro probes when removing a WSL server", () => { - expect( - clearWslDistroState( - { Debian: { name: "Debian", canExecute: true, hasBash: true, hasCurl: true, error: null } }, - { - Debian: { - distro: "Debian", - resolvedPath: "/home/luke/.opencode/bin/opencode", - version: "1.16.2", - expectedVersion: "1.16.2", - matchesDesktop: true, - error: null, - }, - }, - "Debian", - ), - ).toEqual({ distroProbes: {}, opencodeChecks: {} }) -}) - -test("opens terminals for distro names containing spaces", () => { - expect(wslTerminalArgs("Ubuntu Preview")).toEqual(["/c", "start", "", "wsl", "-d", "Ubuntu Preview"]) -}) - -test("stops health polling when sidecar startup settles", async () => { - const abort = new AbortController() - let checks = 0 - const polling = pollWslHealth( - async () => { - checks++ - return false - }, - abort.signal, - 1, - ) - - await new Promise((resolve) => setTimeout(resolve, 5)) - abort.abort() - await polling - const settled = checks - await new Promise((resolve) => setTimeout(resolve, 5)) - expect(checks).toBe(settled) -}) - -test("validates WSL IPC identifiers at the module boundary", () => { - expect(requireWslIpcString("distro", "Debian")).toBe("Debian") - expect(requireWslIpcStrings("distro", ["Debian", "Ubuntu"])).toEqual(["Debian", "Ubuntu"]) - expect(() => requireWslIpcString("distro", "")).toThrow("Invalid distro") - expect(() => requireWslIpcString("server id", undefined)).toThrow("Invalid server id") - expect(() => requireWslIpcStrings("distro", [])).toThrow("Invalid distro") -}) - -test("derives a required Windows restart from the post-install runtime probe", () => { - expect(pendingRestartAfterWslInstall({ available: false, version: null, error: "WSL unavailable" })).toBe(true) - expect(pendingRestartAfterWslInstall({ available: true, version: "WSL version: 2.6.1", error: null })).toBe(false) -}) - -test("ignores stale background OpenCode checks after removing a WSL server", async () => { +test("installs and verifies the bundled CLI version", async () => { persistedServers = [] - releaseOpencodeResolve = undefined + const installs: string[][] = [] const controller = createWslServersController( - "1.16.2", - async () => ({ - listener: { - stop: () => undefined, - onExit: () => undefined, + testControllerOptions({ + installCli: async (distro, cli) => { + installs.push([distro, cli.version]) }, - url: "http://127.0.0.1:4096", - username: "opencode", - password: "secret", + resolveCli: async () => "/home/me/.opencode/bin/opencode2", }), - testControllerOptions(), ) - await controller.addServer("Debian") - await waitFor(() => !!releaseOpencodeResolve) - await controller.removeServer("wsl:Debian") - releaseOpencodeResolve?.() - await new Promise((resolve) => setTimeout(resolve, 0)) + await controller.installOpencode("Debian") - expect(controller.getState().servers).toEqual([]) - expect(controller.getState().opencodeChecks).toEqual({}) + expect(installs).toEqual([["Debian", "0.0.0-next-16365"]]) + expect(controller.getState().opencodeChecks.Debian?.matchesDesktop).toBe(true) }) -test("ignores stale startup OpenCode checks after removing a WSL server", async () => { - persistedServers = [{ id: "wsl:Debian", distro: "Debian" }] - releaseOpencodeResolve = undefined +test("rejects a WSL CLI version that differs from the bundled version", async () => { + persistedServers = [] const controller = createWslServersController( - "1.16.2", - async () => new Promise(() => undefined), - testControllerOptions(), + testControllerOptions({ + installCli: async () => undefined, + resolveCli: async () => "/home/me/.opencode/bin/opencode2", + readCliVersion: async () => "0.0.0-next-older", + }), ) - await controller.initialize() - await waitFor(() => !!releaseOpencodeResolve) - await controller.removeServer("wsl:Debian") - releaseOpencodeResolve?.() - await new Promise((resolve) => setTimeout(resolve, 0)) + await expect(controller.installOpencode("Debian")).rejects.toThrow( + "OpenCode update finished but Debian still reports 0.0.0-next-older; expected 0.0.0-next-16365", + ) +}) - expect(controller.getState().servers).toEqual([]) - expect(controller.getState().opencodeChecks).toEqual({}) +test("stops a running WSL server before replacing its CLI", async () => { + persistedServers = [{ id: "wsl:Debian", distro: "Debian" }] + const events: string[] = [] + const controller = createWslServersController( + testControllerOptions({ + spawnSidecar: async () => { + events.push("start") + return { + stop: async () => { + events.push("stop") + }, + onExit: () => undefined, + url: "http://127.0.0.1:4096", + username: "opencode", + password: "secret", + } + }, + installCli: async () => { + events.push("install") + }, + }), + ) + controller.startConfiguredServers() + await waitFor(() => controller.getState().servers[0]?.runtime.kind === "ready") + + await controller.installOpencode("Debian") + + expect(events).toEqual(["start", "stop", "install", "start"]) + await controller.stopServers() }) test("probes addable distros in parallel before checking OpenCode", async () => { @@ -155,18 +82,20 @@ test("probes addable distros in parallel before checking OpenCode", async () => const started: string[] = [] const release = new Map void>() const opencode: string[] = [] - const controller = createWslServersController("1.16.2", async () => new Promise(() => undefined), { - ...testControllerOptions(), - probeDistro: async (distro) => { - started.push(distro) - await new Promise((resolve) => release.set(distro, resolve)) - return { name: distro, canExecute: true, hasBash: true, hasCurl: true, error: null } - }, - resolveOpencode: async (distro) => { - opencode.push(distro) - return "/home/me/.opencode/bin/opencode" - }, - }) + const controller = createWslServersController( + testControllerOptions({ + spawnSidecar: pendingSidecar, + probeDistro: async (distro) => { + started.push(distro) + await new Promise((resolve) => release.set(distro, resolve)) + return { name: distro, canExecute: true, hasBash: true, hasCurl: true, error: null } + }, + resolveCli: async (distro) => { + opencode.push(distro) + return "/home/me/.opencode/bin/opencode2" + }, + }), + ) const task = controller.probeAddable(["Debian", "Ubuntu"]) await waitFor(() => started.length === 2) @@ -184,20 +113,22 @@ test("probes addable distros in parallel before checking OpenCode", async () => test("does not check OpenCode in addable distros that cannot execute commands", async () => { persistedServers = [] const opencode: string[] = [] - const controller = createWslServersController("1.16.2", async () => new Promise(() => undefined), { - ...testControllerOptions(), - probeDistro: async (distro) => ({ - name: distro, - canExecute: distro === "Debian", - hasBash: distro === "Debian", - hasCurl: distro === "Debian", - error: distro === "Debian" ? null : "Open Ubuntu once to finish setup", + const controller = createWslServersController( + testControllerOptions({ + spawnSidecar: pendingSidecar, + probeDistro: async (distro) => ({ + name: distro, + canExecute: distro === "Debian", + hasBash: distro === "Debian", + hasCurl: distro === "Debian", + error: distro === "Debian" ? null : "Open Ubuntu once to finish setup", + }), + resolveCli: async (distro) => { + opencode.push(distro) + return "/home/me/.opencode/bin/opencode2" + }, }), - resolveOpencode: async (distro) => { - opencode.push(distro) - return "/home/me/.opencode/bin/opencode" - }, - }) + ) await controller.probeAddable(["Debian", "Ubuntu"]) @@ -214,18 +145,24 @@ async function waitFor(check: () => boolean) { throw new Error("Timed out waiting for condition") } -function testControllerOptions() { +function testControllerOptions(overrides: Partial = {}): ControllerOptions { return { + cli: { version: "0.0.0-next-16365" }, + spawnSidecar: async () => ({ + stop: async () => undefined, + onExit: () => undefined, + url: "http://127.0.0.1:4096", + username: "opencode", + password: "secret", + }), readServers: () => persistedServers, writeServers: (servers: WslServerConfig[]) => { persistedServers = servers }, - readCommandVersion: async () => "1.16.2", - resolveOpencode: async () => { - await new Promise((resolve) => { - releaseOpencodeResolve = resolve - }) - return "/home/me/.opencode/bin/opencode" - }, + readCliVersion: async () => "0.0.0-next-16365", + resolveCli: async () => "/home/me/.opencode/bin/opencode2", + ...overrides, } } + +const pendingSidecar = async () => new Promise(() => undefined) diff --git a/packages/desktop/src/main/wsl/servers.ts b/packages/desktop/src/main/wsl/servers.ts index 749f51431f..fddd6dd673 100644 --- a/packages/desktop/src/main/wsl/servers.ts +++ b/packages/desktop/src/main/wsl/servers.ts @@ -1,10 +1,7 @@ import type { WslDistroProbe, - WslInstalledDistro, WslJob, - WslOnlineDistro, WslOpencodeCheck, - WslRuntimeCheck, WslServerConfig, WslServerItem, WslServerRuntime, @@ -13,25 +10,24 @@ import type { } from "../../preload/types" import { WSL_SERVERS_KEY } from "../store-keys" import { getStore } from "../store" -import { expectOpencodeVersion, pendingRestartAfterWslInstall, wslServerIdsToStartOnInitialize } from "./startup" -import { clearWslDistroState, wslServerIdToRestart } from "./policy" import { nativeT } from "../native-translations" import { + installWslCli, installWslDistro, - installWslOpencode, installWslRuntimeElevated, listInstalledWslDistros, listOnlineWslDistros, openWslTerminal, probeWslDistro, probeWslRuntime, - readWslCommandVersion, - resolveWslOpencode, - summarize, + readWslCliVersion, + resolveWslCli, + type WslCliBuild, } from "./runtime" type RunningSidecar = { - listener: { stop: () => void; onExit: (cb: (code: number | null, signal: NodeJS.Signals | null) => void) => void } + stop: () => Promise + onExit: (cb: (code: number | null, signal: NodeJS.Signals | null) => void) => void url: string username: string | null password: string @@ -45,12 +41,15 @@ type ControllerLogger = { } type WslServersControllerOptions = { + cli: WslCliBuild + spawnSidecar: SpawnSidecar logger?: ControllerLogger readServers?: () => WslServerConfig[] writeServers?: (servers: WslServerConfig[]) => void + installCli?: typeof installWslCli probeDistro?: typeof probeWslDistro - resolveOpencode?: typeof resolveWslOpencode - readCommandVersion?: typeof readWslCommandVersion + resolveCli?: typeof resolveWslCli + readCliVersion?: typeof readWslCliVersion } export type WslServersController = ReturnType @@ -59,20 +58,13 @@ export function wslServerIdForDistro(distro: string) { return `wsl:${distro}` } -export function createWslServersController( - appVersion: string, - spawnSidecar: SpawnSidecar, - options?: WslServersControllerOptions, -) { +export function createWslServersController(options: WslServersControllerOptions) { let state: WslServersState = initialState() const listeners = new Set<(event: WslServersEvent) => void>() const sidecars = new Map() - const startAttempts = new Map() - let jobAbort: AbortController | undefined - const logger = options?.logger - const readServers = options?.readServers ?? readPersistedServers - const writeServers = options?.writeServers ?? writePersistedServers - const probeDistro = options?.probeDistro ?? probeWslDistro + const readServers = options.readServers ?? readPersistedServers + const writeServers = options.writeServers ?? writePersistedServers + const probeDistro = options.probeDistro ?? probeWslDistro const emit = () => { for (const listener of listeners) listener({ type: "state", state }) @@ -83,29 +75,11 @@ export function createWslServersController( emit() } - const persistServers = (servers: WslServerConfig[]) => { - writeServers(servers) - } - const updateServer = (id: string, update: (item: WslServerItem) => WslServerItem) => { const next = state.servers.map((item) => (item.config.id === id ? update(item) : item)) setState({ servers: next }) } - const beginJob = (job: WslJob): AbortController => { - jobAbort?.abort() - const abort = new AbortController() - jobAbort = abort - setState({ job }) - return abort - } - - const endJob = (abort: AbortController) => { - if (jobAbort !== abort) return - jobAbort = undefined - setState({ job: null }) - } - const refreshFromStore = () => { const persisted = readServers() const items: WslServerItem[] = persisted.map((config) => { @@ -122,7 +96,7 @@ export function createWslServersController( updateServer(id, (item) => ({ ...item, runtime })) } - const setOpencodeCheck = (distro: string, check: WslOpencodeCheck) => { + const setCliCheck = (distro: string, check: WslOpencodeCheck) => { setState({ opencodeChecks: { ...state.opencodeChecks, @@ -131,24 +105,24 @@ export function createWslServersController( }) } - const checkOpencode = async (distro: string, opts?: { signal?: AbortSignal }) => { - const resolved = await (options?.resolveOpencode ?? resolveWslOpencode)(distro, opts) - const version = resolved - ? await (options?.readCommandVersion ?? readWslCommandVersion)(resolved, distro, opts) - : null - return opencodeCheck(distro, resolved, version, appVersion) + const inspectCli = async (distro: string) => { + const resolved = await (options.resolveCli ?? resolveWslCli)(distro) + const version = resolved ? await (options.readCliVersion ?? readWslCliVersion)(resolved, distro) : null + return cliCheck(distro, resolved, version, options.cli.version) } - const refreshOpencodeCheck = async (distro: string, opts?: { signal?: AbortSignal }) => { - setOpencodeCheck(distro, await checkOpencode(distro, opts)) + const refreshCliCheck = async (distro: string) => { + const check = await inspectCli(distro) + setCliCheck(distro, check) + return check } - const probeAddableDistros = async (distros: string[], opts?: { signal?: AbortSignal }) => { + const probeAddableDistros = async (distros: string[]) => { const unique = [...new Set(distros)] const distroProbes = await Promise.all( unique .filter((distro) => !state.distroProbes[distro]) - .map(async (distro) => [distro, await probeDistro(distro, opts)] as const), + .map(async (distro) => [distro, await probeDistro(distro)] as const), ) if (distroProbes.length) { setState({ distroProbes: { ...state.distroProbes, ...Object.fromEntries(distroProbes) } }) @@ -158,86 +132,37 @@ export function createWslServersController( unique .filter((distro) => distroProbeReady(state.distroProbes[distro])) .filter((distro) => !state.opencodeChecks[distro]) - .map(async (distro) => [distro, await checkOpencode(distro, opts)] as const), + .map(async (distro) => [distro, await inspectCli(distro)] as const), ) if (opencodeChecks.length) { setState({ opencodeChecks: { ...state.opencodeChecks, ...Object.fromEntries(opencodeChecks) } }) } } - const hasServer = (id: string, distro: string) => { - return state.servers.some((item) => item.config.id === id && item.config.distro === distro) + const refreshCliCheckSafely = (id: string, distro: string) => { + return refreshCliCheck(distro).catch((error) => { + const message = error instanceof Error ? error.message : String(error) + options.logger?.error("wsl CLI check failed", { id, distro, message }) + }) } - const refreshOpencodeCheckBackground = (id: string, distro: string) => { - void checkOpencode(distro) - .then((check) => { - if (!hasServer(id, distro)) return - setOpencodeCheck(distro, check) - }) - .catch((error) => { - const message = error instanceof Error ? error.message : String(error) - logger?.error("wsl opencode check failed", { id, distro, message }) - }) + const refreshCliChecks = async () => { + await Promise.all(state.servers.map((item) => refreshCliCheckSafely(item.config.id, item.config.distro))) } - const refreshOpencodeChecks = async () => { - await Promise.all( - state.servers.map((item) => - checkOpencode(item.config.distro) - .then((check) => { - if (!hasServer(item.config.id, item.config.distro)) return - setOpencodeCheck(item.config.distro, check) - }) - .catch((error) => { - const message = error instanceof Error ? error.message : String(error) - logger?.error("wsl opencode check failed", { - id: item.config.id, - distro: item.config.distro, - message, - }) - }), - ), - ) - } - - const refreshDistroLists = async (opts: { signal?: AbortSignal }) => { - const [installed, online] = await Promise.all([listInstalledWslDistros(opts), listOnlineWslDistros(opts)]) + const refreshDistroLists = async () => { + const [installed, online] = await Promise.all([listInstalledWslDistros(), listOnlineWslDistros()]) return { installed, online } } - const nextStartAttempt = (id: string) => { - const next = (startAttempts.get(id) ?? 0) + 1 - startAttempts.set(id, next) - return next - } - - const invalidateStartAttempt = (id: string) => { - startAttempts.set(id, (startAttempts.get(id) ?? 0) + 1) - } - - const isCurrentStartAttempt = (id: string, attempt: number) => { - return startAttempts.get(id) === attempt && state.servers.some((item) => item.config.id === id) - } - const startServer = async (id: string) => { const item = state.servers.find((x) => x.config.id === id) if (!item) return - const attempt = nextStartAttempt(id) - await stopServerInternal(id) - if (!isCurrentStartAttempt(id, attempt)) return + await stopServer(id) setRuntime(id, { kind: "starting" }) - logger?.log("wsl sidecar starting", { id, distro: item.config.distro }) + options.logger?.log("wsl sidecar starting", { id, distro: item.config.distro }) try { - const sidecar = await spawnSidecar(item.config.distro) - if (!isCurrentStartAttempt(id, attempt)) { - try { - sidecar.listener.stop() - } catch { - // ignore stop errors for stale sidecars - } - return - } + const sidecar = await options.spawnSidecar(item.config.distro) sidecars.set(id, sidecar) setRuntime(id, { kind: "ready", @@ -245,51 +170,36 @@ export function createWslServersController( username: sidecar.username, password: sidecar.password, }) - sidecar.listener.onExit((code, signal) => { + sidecar.onExit((code, signal) => { if (sidecars.get(id) !== sidecar) return sidecars.delete(id) const message = startupFailure(code, signal) setRuntime(id, { kind: "failed", message }) - logger?.error("wsl sidecar exited", { id, distro: item.config.distro, code, signal }) + options.logger?.error("wsl sidecar exited", { id, distro: item.config.distro, code, signal }) }) - refreshOpencodeCheckBackground(id, item.config.distro) - logger?.log("wsl sidecar ready", { id, distro: item.config.distro, url: sidecar.url }) + void refreshCliCheckSafely(id, item.config.distro) + options.logger?.log("wsl sidecar ready", { id, distro: item.config.distro, url: sidecar.url }) } catch (error) { const message = error instanceof Error ? error.message : String(error) - if (!isCurrentStartAttempt(id, attempt)) return setRuntime(id, { kind: "failed", message }) - // Without this, an Ubuntu-style silent failure leaves no trace in - // main.log — the controller captures the message in its state but - // nothing surfaces unless the user opens the WSL servers dialog. - logger?.error("wsl sidecar failed to start", { id, distro: item.config.distro, message }) + options.logger?.error("wsl sidecar failed to start", { id, distro: item.config.distro, message }) } } - const stopServerInternal = async (id: string) => { + const stopServer = async (id: string) => { const existing = sidecars.get(id) if (!existing) return sidecars.delete(id) - try { - existing.listener.stop() - } catch { - // ignore stop errors - } + await existing.stop() + setRuntime(id, { kind: "stopped" }) } - const runJob = async (job: WslJob, runner: (abort: AbortController) => Promise) => { - const abort = beginJob(job) + const runJob = async (job: WslJob, runner: () => Promise) => { + setState({ job }) try { - const value = await runner(abort) - endJob(abort) - return value - } catch (error) { - if (error instanceof Error && error.name === "AbortError") { - endJob(abort) - return undefined - } - const err = error instanceof Error ? error : new Error(String(error)) - endJob(abort) - throw err + return await runner() + } finally { + setState({ job: null }) } } @@ -302,15 +212,15 @@ export function createWslServersController( return () => listeners.delete(listener) }, - async initialize() { + startConfiguredServers() { refreshFromStore() - void refreshOpencodeChecks() - for (const id of wslServerIdsToStartOnInitialize(state.servers.map((item) => item.config))) void startServer(id) + void refreshCliChecks() + state.servers.forEach((item) => void startServer(item.config.id)) }, async probeRuntime() { - await runJob({ kind: "runtime", startedAt: Date.now() }, async (abort) => { - const runtime = await probeWslRuntime({ signal: abort.signal }) + await runJob({ kind: "runtime", startedAt: Date.now() }, async () => { + const runtime = await probeWslRuntime() setState({ runtime, pendingRestart: state.pendingRestart && !runtime.available ? state.pendingRestart : false, @@ -319,62 +229,48 @@ export function createWslServersController( }, async refreshDistros() { - await runJob({ kind: "distros", startedAt: Date.now() }, async (abort) => { - setState(await refreshDistroLists({ signal: abort.signal })) + await runJob({ kind: "distros", startedAt: Date.now() }, async () => { + setState(await refreshDistroLists()) }) }, async installWsl() { - await runJob({ kind: "install-wsl", startedAt: Date.now() }, async (abort) => { - const result = await installWslRuntimeElevated({ signal: abort.signal }) - if (result.code !== 0) { - const message = summarize(result.stderr || result.stdout) || nativeT("desktop.wsl.error.installWsl") - throw new Error(message) - } - const runtime = await probeWslRuntime({ signal: abort.signal }) - setState({ runtime, pendingRestart: pendingRestartAfterWslInstall(runtime) }) + await runJob({ kind: "install-wsl", startedAt: Date.now() }, async () => { + await installWslRuntimeElevated() + const runtime = await probeWslRuntime() + setState({ runtime, pendingRestart: !runtime.available }) }) }, - async installDistro(name: string) { - await runJob({ kind: "install-distro", distro: name, startedAt: Date.now() }, async (abort) => { - const result = await installWslDistro(name, { signal: abort.signal }) - if (result.code !== 0) { - const message = - summarize(result.stderr || result.stdout) || nativeT("desktop.wsl.error.installDistro", { distro: name }) - throw new Error(message) - } - const distros = await refreshDistroLists({ signal: abort.signal }) - const probe = await probeDistro(name, { signal: abort.signal }) + async installDistro(distro: string) { + await runJob({ kind: "install-distro", distro, startedAt: Date.now() }, async () => { + await installWslDistro(distro) + const distros = await refreshDistroLists() + const probe = await probeDistro(distro) setState({ ...distros, - distroProbes: { ...state.distroProbes, [name]: probe }, + distroProbes: { ...state.distroProbes, [distro]: probe }, }) }) }, async probeAddable(distros: string[]) { if (!distros.length) return - await runJob({ kind: "probe-addable", distros, startedAt: Date.now() }, async (abort) => { - await probeAddableDistros(distros, { signal: abort.signal }) - }) + await runJob({ kind: "probe-addable", distros, startedAt: Date.now() }, () => probeAddableDistros(distros)) }, - async installOpencode(name: string) { - await runJob({ kind: "install-opencode", distro: name, startedAt: Date.now() }, async (abort) => { - const result = await installWslOpencode(appVersion, name, { signal: abort.signal }) - if (result.code !== 0) { - throw new Error(summarize(result.stderr || result.stdout) || nativeT("desktop.wsl.error.installOpencode")) - } - await refreshOpencodeCheck(name, { signal: abort.signal }) - expectOpencodeVersion(state.opencodeChecks[name]?.version ?? null, appVersion, name) - const id = wslServerIdToRestart(state.servers, name) + async installOpencode(distro: string) { + await runJob({ kind: "install-opencode", distro, startedAt: Date.now() }, async () => { + const id = state.servers.find((item) => item.config.distro === distro)?.config.id + if (id) await stopServer(id) + await (options.installCli ?? installWslCli)(distro, options.cli) + requireMatchingCli(await refreshCliCheck(distro), options.cli.version) if (id) await startServer(id) }) }, - async openTerminal(name: string) { - await openWslTerminal(name) + async openTerminal(distro: string) { + await openWslTerminal(distro) }, async addServer(distro: string): Promise { @@ -386,7 +282,7 @@ export function createWslServersController( id, distro, } - persistServers([...readServers(), config]) + writeServers([...readServers(), config]) setState({ servers: [...state.servers, { config, runtime: { kind: "starting" } }], }) @@ -396,27 +292,19 @@ export function createWslServersController( async removeServer(id: string) { const distro = state.servers.find((item) => item.config.id === id)?.config.distro - invalidateStartAttempt(id) - await stopServerInternal(id) + await stopServer(id) const remaining = readServers().filter((item) => item.id !== id) - persistServers(remaining) + writeServers(remaining) setState({ servers: state.servers.filter((item) => item.config.id !== id), - ...(distro ? clearWslDistroState(state.distroProbes, state.opencodeChecks, distro) : {}), + ...(distro ? removeDistroState(state, distro) : {}), }) }, startServer, - stopAll() { - for (const item of state.servers) invalidateStartAttempt(item.config.id) - for (const existing of sidecars.values()) { - try { - existing.listener.stop() - } catch { - // ignore - } - } + async stopServers() { + await Promise.all([...sidecars.values()].map((sidecar) => sidecar.stop())) sidecars.clear() }, } @@ -464,7 +352,7 @@ function normalizePersistedServer(value: unknown): WslServerConfig[] { ] } -function opencodeCheck( +function cliCheck( distro: string, resolvedPath: string | null, version: string | null, @@ -500,6 +388,25 @@ function opencodeCheck( } } +function requireMatchingCli(check: WslOpencodeCheck, expected: string) { + if (check.version === expected) return + throw new Error( + nativeT("desktop.wsl.error.updateVersion", { + distro: check.distro, + installed: check.version ?? nativeT("desktop.wsl.error.noVersion"), + expected, + }), + ) +} + +function removeDistroState(state: WslServersState, distro: string) { + const distroProbes = { ...state.distroProbes } + const opencodeChecks = { ...state.opencodeChecks } + delete distroProbes[distro] + delete opencodeChecks[distro] + return { distroProbes, opencodeChecks } +} + function distroProbeReady(probe: WslDistroProbe | undefined) { return !!probe?.canExecute && probe.hasBash && probe.hasCurl } @@ -507,17 +414,3 @@ function distroProbeReady(probe: WslDistroProbe | undefined) { function startupFailure(code: number | null, signal: NodeJS.Signals | null) { return nativeT("desktop.wsl.error.serverExited", { code: code ?? "null", signal: signal ?? "null" }) } - -// Re-export types used by callers -export type { - WslInstalledDistro, - WslOnlineDistro, - WslRuntimeCheck, - WslDistroProbe, - WslOpencodeCheck, - WslServerConfig, - WslServerItem, - WslServerRuntime, - WslServersEvent, - WslServersState, -} diff --git a/packages/desktop/src/main/wsl/sidecar.ts b/packages/desktop/src/main/wsl/sidecar.ts index bd052dd846..29d76f081d 100644 --- a/packages/desktop/src/main/wsl/sidecar.ts +++ b/packages/desktop/src/main/wsl/sidecar.ts @@ -3,12 +3,12 @@ import { randomUUID } from "node:crypto" import { createServer } from "node:net" import { app } from "electron" import { checkHealth } from "../server" -import { type WslCommandLine, resolveWslOpencode, shellEscape, wslArgs } from "./runtime" -import { pollWslHealth } from "./startup" +import { type WslCommandLine, resolveWslCli, shellEscape, wslArgs } from "./runtime" import { nativeT } from "../native-translations" export type WslSidecar = { - listener: { stop: () => void; onExit: (cb: (code: number | null, signal: NodeJS.Signals | null) => void) => void } + stop: () => Promise + onExit: (cb: (code: number | null, signal: NodeJS.Signals | null) => void) => void url: string username: string | null password: string @@ -18,7 +18,7 @@ export async function spawnWslSidecar( distro: string, opts: { onLine?: (line: WslCommandLine) => void; healthTimeoutMs?: number } = {}, ): Promise { - const opencode = await resolveWslOpencode(distro) + const opencode = await resolveWslCli(distro) if (!opencode) throw new Error(nativeT("desktop.wsl.error.opencodeNotInstalled", { distro })) const port = await allocatePort() @@ -35,7 +35,7 @@ export async function spawnWslSidecar( `export OPENCODE_SERVER_USERNAME=${shellEscape(username)}`, `export OPENCODE_SERVER_PASSWORD=${shellEscape(password)}`, 'export XDG_STATE_HOME="$HOME/.local/state"', - `exec ${shellEscape(opencode)} --print-logs --log-level ${app.isPackaged ? "WARN" : "INFO"} serve --hostname 0.0.0.0 --port ${port}`, + `exec ${shellEscape(opencode)} --log-level ${app.isPackaged ? "warn" : "info"} serve --hostname 0.0.0.0 --port ${port}`, ].join("\n") const child = spawn("wsl", wslArgs(["bash", "-se"], distro), { stdio: ["pipe", "pipe", "pipe"], @@ -80,16 +80,27 @@ export async function spawnWslSidecar( startup.abort() }) return { - listener: { - stop: () => child.kill(), - onExit: (cb) => child.once("exit", cb), + stop: async () => { + if (child.exitCode !== null || child.signalCode !== null) return + await new Promise((resolve) => { + child.once("exit", () => resolve()) + child.kill() + }) }, + onExit: (cb) => child.once("exit", cb), url, username, password, } } +async function pollWslHealth(check: () => Promise, signal: AbortSignal) { + while (!signal.aborted) { + if (await check()) return + await new Promise((resolve) => setTimeout(resolve, 100)) + } +} + function allocatePort() { return new Promise((resolve, reject) => { const server = createServer() diff --git a/packages/desktop/src/main/wsl/startup.ts b/packages/desktop/src/main/wsl/startup.ts deleted file mode 100644 index 7a80c3d613..0000000000 --- a/packages/desktop/src/main/wsl/startup.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { nativeT } from "../native-translations" - -export function wslServerIdsToStartOnInitialize(servers: { id: string }[]) { - return servers.map((server) => server.id) -} - -export function expectOpencodeVersion(installed: string | null, expected: string, distro = "Debian") { - if (installed === expected) return - throw new Error( - nativeT("desktop.wsl.error.updateVersion", { - distro, - installed: installed ?? nativeT("desktop.wsl.error.noVersion"), - expected, - }), - ) -} - -export const pendingRestartAfterWslInstall = (runtime: { available: boolean }) => !runtime.available - -export async function pollWslHealth(check: () => Promise, signal: AbortSignal, interval = 100) { - while (!signal.aborted) { - if (await check()) return - await abortableDelay(interval, signal) - } -} - -function abortableDelay(duration: number, signal: AbortSignal) { - return new Promise((resolve) => { - const done = () => { - clearTimeout(timeout) - signal.removeEventListener("abort", done) - resolve() - } - const timeout = setTimeout(done, duration) - signal.addEventListener("abort", done, { once: true }) - }) -} diff --git a/packages/drive/test/cli/integration.test.ts b/packages/drive/test/cli/integration.test.ts index 5bdf18dff1..7573cabbdb 100644 --- a/packages/drive/test/cli/integration.test.ts +++ b/packages/drive/test/cli/integration.test.ts @@ -120,7 +120,9 @@ describe("opencode-drive", () => { expect(initStatus).toBe(0) const artifacts = initOutput.trim() expect(await Bun.file(join(artifacts, "files", ".opencode", "opencode.jsonc")).exists()).toBe(true) - expect(await Bun.file(join(artifacts, "files", ".opencode", "opencode.jsonc")).json()).toMatchObject({ + expect( + Bun.JSONC.parse(await Bun.file(join(artifacts, "files", ".opencode", "opencode.jsonc")).text()), + ).toMatchObject({ model: "simulation/gpt-sim-model", snapshots: false, permissions: [{ action: "*", resource: "*", effect: "allow" }], @@ -765,13 +767,15 @@ describe("opencode-drive", () => { gitWriteError: expect.stringContaining("must not modify Git metadata"), matches: true, }) - expect(await Bun.file(join(artifacts, "files", ".opencode", "opencode.jsonc")).json()).toMatchObject({ + expect( + Bun.JSONC.parse(await Bun.file(join(artifacts, "files", ".opencode", "opencode.jsonc")).text()), + ).toMatchObject({ autoupdate: false, model: "simulation/gpt-sim-model", providers: { simulation: { models: { "gpt-sim-model": {} } } }, test: { declared: true, setup: true }, }) - expect(await Bun.file(join(artifacts, "files", ".opencode", "tui.jsonc")).json()).toEqual({ + expect(Bun.JSONC.parse(await Bun.file(join(artifacts, "files", ".opencode", "tui.jsonc")).text())).toEqual({ test: { declared: true, setup: true }, }) const backendEvents = (await Bun.file(join(artifacts, "backend-events.jsonl")).text()) diff --git a/packages/drive/test/instance/config.test.ts b/packages/drive/test/instance/config.test.ts index d1263b485e..cd6d187fe8 100644 --- a/packages/drive/test/instance/config.test.ts +++ b/packages/drive/test/instance/config.test.ts @@ -15,7 +15,7 @@ describe("instance configuration", () => { const root = await initializeInstance() artifacts.push(root) - expect(await Bun.file(join(root, "files", ".opencode", "opencode.jsonc")).json()).toMatchObject({ + expect(Bun.JSONC.parse(await Bun.file(join(root, "files", ".opencode", "opencode.jsonc")).text())).toMatchObject({ model: "simulation/gpt-sim-model", providers: { simulation: { diff --git a/packages/lab/catalog/.wrangler/state/v3/cache/miniflare-CacheObject/metadata.sqlite b/packages/lab/catalog/.wrangler/state/v3/cache/miniflare-CacheObject/metadata.sqlite new file mode 100644 index 0000000000..0b58f0dca7 Binary files /dev/null and b/packages/lab/catalog/.wrangler/state/v3/cache/miniflare-CacheObject/metadata.sqlite differ diff --git a/packages/lab/catalog/.wrangler/state/v3/cache/miniflare-CacheObject/metadata.sqlite-shm b/packages/lab/catalog/.wrangler/state/v3/cache/miniflare-CacheObject/metadata.sqlite-shm new file mode 100644 index 0000000000..b64af81122 Binary files /dev/null and b/packages/lab/catalog/.wrangler/state/v3/cache/miniflare-CacheObject/metadata.sqlite-shm differ diff --git a/packages/lab/catalog/.wrangler/state/v3/cache/miniflare-CacheObject/metadata.sqlite-wal b/packages/lab/catalog/.wrangler/state/v3/cache/miniflare-CacheObject/metadata.sqlite-wal new file mode 100644 index 0000000000..ab1364dae8 Binary files /dev/null and b/packages/lab/catalog/.wrangler/state/v3/cache/miniflare-CacheObject/metadata.sqlite-wal differ diff --git a/packages/lab/catalog/catalog/feedback.test.ts b/packages/lab/catalog/catalog/feedback.test.ts index b614e12993..cb50a2a691 100644 --- a/packages/lab/catalog/catalog/feedback.test.ts +++ b/packages/lab/catalog/catalog/feedback.test.ts @@ -1,5 +1,6 @@ import { describe, expect, test } from "bun:test" import { feedbackIssueUrl } from "../src/feedback" +import { annotationUrl, readAnnotations } from "../src/annotations" describe("catalog feedback", () => { test("opens a prefilled issue for an exact capture", () => { @@ -18,4 +19,39 @@ describe("catalog feedback", () => { expect(url.searchParams.get("body")).toContain("`skill-picker`") expect(url.searchParams.get("body")).toContain("screen=skill-picker&set=opencode") }) + + test("round-trips a capture annotation document through the URL fragment", () => { + const document = { + version: 1 as const, + identifier: "skill-picker", + variant: "opencode", + annotations: [{ id: "one", row: 4, column: 12, note: "This label needs more contrast." }], + } + const url = new URL(annotationUrl("https://dev.opencode.ai/lab/catalog?screen=skill-picker", document)) + + expect(url.hash).toStartWith("#annotations=") + expect(readAnnotations(url, "skill-picker", "opencode")).toEqual(document.annotations) + expect(readAnnotations(url, "other-screen", "opencode")).toEqual([]) + }) + + test("includes human and machine-readable annotations in the issue", () => { + const annotations = [{ id: "one", row: 4, column: 12, note: "This label needs more contrast." }] + const document = { version: 1 as const, identifier: "skill-picker", variant: "opencode", annotations } + const url = new URL( + feedbackIssueUrl({ + title: "Skill picker", + identifier: "skill-picker", + deepLink: annotationUrl("https://dev.opencode.ai/lab/catalog?screen=skill-picker", document), + variant: "opencode", + annotations, + document, + }), + ) + const body = url.searchParams.get("body") ?? "" + + expect(body).toContain("## 1. Row 5, column 13") + expect(body).toContain("This label needs more contrast.") + expect(body).toContain("Annotation data") + expect(body).toContain('"row": 4') + }) }) diff --git a/packages/lab/catalog/catalog/worker.test.ts b/packages/lab/catalog/catalog/worker.test.ts index 2cf2fec2e1..ec90f71cd0 100644 --- a/packages/lab/catalog/catalog/worker.test.ts +++ b/packages/lab/catalog/catalog/worker.test.ts @@ -1,6 +1,21 @@ import { describe, expect, test } from "bun:test" import wrangler from "../wrangler.jsonc" -import { assetPath } from "../worker" +import { assetPath, metaTags } from "../worker" + +const catalog = { + variants: [ + { id: "opencode", label: "Opencode" }, + { id: "tokyonight", label: "Tokyo Night" }, + ], + screens: [ + { + id: "home", + title: "Home", + summary: "", + frames: [{ variantId: "opencode" }, { variantId: "tokyonight" }], + }, + ], +} describe("catalog worker", () => { test("serves the app shell for catalog routes", () => { @@ -17,4 +32,25 @@ describe("catalog worker", () => { test("leaves HTML routing to the worker", () => { expect(wrangler.assets.html_handling).toBe("none") }) + + test("injects a per-capture Open Graph card for deep links", () => { + const tags = metaTags(new URL("https://dev.opencode.ai/lab/catalog?screen=home&set=tokyonight"), catalog) + expect(tags).toContain('content="Home — OpenCode Terminal Catalog"') + expect(tags).toContain('content="https://dev.opencode.ai/lab/catalog/og/home--tokyonight.png"') + expect(tags).toContain("Tokyo Night theme") + expect(tags).toContain('name="twitter:card" content="summary_large_image"') + }) + + test("defaults the theme to the first variant", () => { + const tags = metaTags(new URL("https://dev.opencode.ai/lab/catalog?screen=home"), catalog) + expect(tags).toContain('content="https://dev.opencode.ai/lab/catalog/og/home--opencode.png"') + }) + + test("falls back to the default card for unknown or missing captures", () => { + for (const search of ["", "?screen=missing", "?screen=home&set=missing"]) { + const tags = metaTags(new URL(`https://dev.opencode.ai/lab/catalog${search}`), catalog) + expect(tags).toContain('content="https://dev.opencode.ai/lab/catalog/og/default.png"') + expect(tags).toContain('content="OpenCode Terminal Catalog"') + } + }) }) diff --git a/packages/lab/catalog/package.json b/packages/lab/catalog/package.json index ffedb0f188..c5d720129f 100644 --- a/packages/lab/catalog/package.json +++ b/packages/lab/catalog/package.json @@ -10,7 +10,7 @@ "dev": "bun ./server.ts", "lint": "cd ../../.. && bun run lint -- packages/lab/catalog/src packages/lab/catalog/catalog packages/lab/catalog/scripts packages/lab/catalog/scenarios packages/lab/catalog/server.ts packages/lab/catalog/worker.ts", "typecheck": "tsc --noEmit", - "build": "rm -rf dist && bun build ./src/index.html --outdir dist --public-path=/lab/catalog/ --minify && cp -R public/captures public/catalog.json public/drive-captures.json dist/", + "build": "rm -rf dist && bun build ./src/index.html --outdir dist --public-path=/lab/catalog/ --minify && cp -R public/captures public/catalog.json public/drive-captures.json dist/ && bun ./scripts/generate-og.ts", "deploy": "bun run generate && bun run build && bunx wrangler deploy", "doctor": "bunx -y react-doctor@latest .", "bench:capture-flow": "bun ./scripts/bench-capture-flow.ts", @@ -28,6 +28,7 @@ "react-dom": "^19.2.7" }, "devDependencies": { + "@napi-rs/canvas": "1.0.2", "@types/bun": "catalog:", "@types/react": "^19.2.17", "@types/react-dom": "^19.2.3", diff --git a/packages/lab/catalog/scripts/generate-og.ts b/packages/lab/catalog/scripts/generate-og.ts new file mode 100644 index 0000000000..b6cbde2011 --- /dev/null +++ b/packages/lab/catalog/scripts/generate-og.ts @@ -0,0 +1,209 @@ +/** + * Generates Open Graph cards for the catalog: one 1200x630 PNG per captured + * screen/theme pair plus a default card, written to dist/og. Runs after the + * production build so the images deploy as plain static assets. + */ +import { fileURLToPath } from "node:url" +import { GlobalFonts, createCanvas } from "@napi-rs/canvas" +import type { SKRSContext2D, Canvas } from "@napi-rs/canvas" +import { + CellHeight, + CellWidth, + DimAlpha, + FontSize, + StrikethroughOffset, + TextStyle, + UnderlineOffset, + baselineOffset, + drawBlockGlyph, +} from "opencode-drive/frame" + +const CardWidth = 1200 +const CardHeight = 630 +const Pad = 56 + +const FontStack = `"OpenCode Mono", "OpenCode Symbols", "OpenCode Symbols 2", "OpenCode Math"` + +const driveRoot = new URL("../../", Bun.pathToFileURL(Bun.resolveSync("opencode-drive/frame", import.meta.dir)).href) +for (const [file, family] of [ + ["commit-mono/CommitMono-400-Regular.otf", "OpenCode Mono"], + ["commit-mono/CommitMono-700-Regular.otf", "OpenCode Mono"], + ["commit-mono/CommitMono-400-Italic.otf", "OpenCode Mono"], + ["commit-mono/CommitMono-700-Italic.otf", "OpenCode Mono"], + ["noto/NotoSansSymbols.ttf", "OpenCode Symbols"], + ["noto/NotoSansSymbols2-Regular.ttf", "OpenCode Symbols 2"], + ["noto/NotoSansMath-Regular.ttf", "OpenCode Math"], +] as const) { + const path = fileURLToPath(new URL(`assets/fonts/${file}`, driveRoot)) + if (!GlobalFonts.registerFromPath(path, family)) throw new Error(`Failed to register OG font: ${path}`) +} + +interface FrameSpan { + readonly text: string + readonly fg: readonly [number, number, number, number] + readonly bg: readonly [number, number, number, number] + readonly attributes: number + readonly width: number +} + +interface FrameArtifact { + readonly cols: number + readonly rows: number + readonly lines: ReadonlyArray<{ readonly spans: ReadonlyArray }> +} + +function color([red, green, blue, alpha]: FrameSpan["fg"], opacity = 1) { + return `rgba(${red}, ${green}, ${blue}, ${(alpha / 255) * opacity})` +} + +/** Mirrors the browser canvas renderer in src/components/TerminalFrame.tsx. */ +function renderFrame(frame: FrameArtifact) { + const canvas = createCanvas(frame.cols * CellWidth, frame.rows * CellHeight) + const context = canvas.getContext("2d") + context.fillStyle = "#080808" + context.fillRect(0, 0, canvas.width, canvas.height) + context.textBaseline = "alphabetic" + context.textAlign = "center" + + frame.lines.forEach((line, row) => { + let column = 0 + line.spans.forEach((span) => { + const attributes = span.attributes & 0xff + const inverse = Boolean(attributes & TextStyle.inverse) + const hidden = Boolean(attributes & TextStyle.invisible) + const foreground = inverse ? span.bg : span.fg + const background = inverse ? span.fg : span.bg + const chars = [...span.text] + let remaining = span.width + + chars.forEach((char, index) => { + const cells = Math.max(1, remaining - (chars.length - index - 1)) + const x = column * CellWidth + const y = row * CellHeight + if (background[3]) { + context.fillStyle = color(background) + context.fillRect(x, y, cells * CellWidth, CellHeight) + } + if (!hidden && char.codePointAt(0) !== 0x0a00) { + context.fillStyle = color(foreground, attributes & TextStyle.dim ? DimAlpha : 1) + if (!drawBlockGlyph(context, char, x, y, cells)) { + const font = `${attributes & TextStyle.italic ? "italic " : ""}${attributes & TextStyle.bold ? "700 " : "400 "}${FontSize}px ${FontStack}` + context.font = font + context.fillText(char, x + (cells * CellWidth) / 2, y + baselineOffset(context, font), cells * CellWidth) + } + if (attributes & TextStyle.underline) context.fillRect(x, y + UnderlineOffset, cells * CellWidth, 1) + if (attributes & TextStyle.strikethrough) context.fillRect(x, y + StrikethroughOffset, cells * CellWidth, 1) + } + column += cells + remaining -= cells + }) + while (remaining-- > 0) { + if (background[3]) { + context.fillStyle = color(background) + context.fillRect(column * CellWidth, row * CellHeight, CellWidth, CellHeight) + } + column++ + } + }) + }) + return canvas +} + +function drawChrome(context: SKRSContext2D, kicker: string, title: string, chip: string) { + context.fillStyle = "#0a0a0a" + context.fillRect(0, 0, CardWidth, CardHeight) + + context.textAlign = "left" + context.textBaseline = "alphabetic" + context.font = `700 20px ${FontStack}` + context.fillStyle = "#6f6e69" + context.fillText(kicker.toUpperCase(), Pad, Pad + 16) + + context.font = `700 46px ${FontStack}` + context.fillStyle = "#ededed" + context.fillText(title, Pad - 2, Pad + 74, CardWidth - Pad * 2 - 220) + + if (chip !== "") { + context.font = `400 20px ${FontStack}` + const width = context.measureText(chip).width + 36 + const x = CardWidth - Pad - width + context.strokeStyle = "#2c2c2c" + context.lineWidth = 1 + context.beginPath() + context.roundRect(x, Pad + 38, width, 40, 20) + context.stroke() + context.fillStyle = "#9c9b96" + context.fillText(chip, x + 18, Pad + 65) + } +} + +/** Frame peeks from the bottom edge like a window, cropped by the card. */ +function drawFramePeek(context: SKRSContext2D, frame: Canvas) { + const top = 182 + const width = CardWidth - Pad * 2 + const scale = width / frame.width + const visible = CardHeight - top + const radius = 10 + + context.save() + context.beginPath() + context.roundRect(Pad, top, width, visible + radius, radius) + context.clip() + context.drawImage( + frame, + 0, + 0, + frame.width, + Math.min(frame.height, (visible + radius) / scale), + Pad, + top, + width, + Math.min(frame.height * scale, visible + radius), + ) + context.restore() + + context.strokeStyle = "#2c2c2c" + context.lineWidth = 2 + context.beginPath() + context.roundRect(Pad + 1, top + 1, width - 2, visible + radius, radius) + context.stroke() +} + +const root = new URL("../", import.meta.url) +const outDir = new URL("dist/og/", root) +const catalog = await Bun.file(new URL("dist/catalog.json", root)).json() + +const variants = catalog.variants as ReadonlyArray<{ id: string; label: string }> +const screens = catalog.screens as ReadonlyArray<{ + id: string + title: string + frames: ReadonlyArray<{ variantId: string; src: string }> +}> + +let generated = 0 +for (const screen of screens) { + for (const frame of screen.frames) { + const variant = variants.find((candidate) => candidate.id === frame.variantId) + if (!variant) continue + const artifact = (await Bun.file(new URL(frame.src, new URL("dist/", root))).json()) as FrameArtifact + const card = createCanvas(CardWidth, CardHeight) + const context = card.getContext("2d") + drawChrome(context, "OpenCode · Terminal Catalog", screen.title, variant.label) + drawFramePeek(context, renderFrame(artifact)) + await Bun.write(new URL(`${screen.id}--${variant.id}.png`, outDir), card.toBuffer("image/png")) + generated++ + } +} + +const home = screens.find((screen) => screen.id === "home") +const homeFrame = home?.frames.find((frame) => frame.variantId === variants[0]?.id) ?? home?.frames[0] +const card = createCanvas(CardWidth, CardHeight) +const context = card.getContext("2d") +drawChrome(context, "OpenCode", "Terminal Catalog", `${screens.length} screens · ${variants.length} themes`) +if (homeFrame) { + const artifact = (await Bun.file(new URL(homeFrame.src, new URL("dist/", root))).json()) as FrameArtifact + drawFramePeek(context, renderFrame(artifact)) +} +await Bun.write(new URL("default.png", outDir), card.toBuffer("image/png")) + +console.log(`generated ${generated + 1} Open Graph cards in dist/og`) diff --git a/packages/lab/catalog/src/App.tsx b/packages/lab/catalog/src/App.tsx index 407487711f..b40e876f68 100644 --- a/packages/lab/catalog/src/App.tsx +++ b/packages/lab/catalog/src/App.tsx @@ -334,6 +334,7 @@ export function App({ catalog }: AppProps) { }, []) useEffect(() => { + if (ui.viewerOpen) return window.history.replaceState( null, "", @@ -349,18 +350,18 @@ export function App({ catalog }: AppProps) { states: ui.facets.state, }), ) - }, [activeVariant.id, ui.facets, ui.mode, ui.query, ui.screenLabels, ui.uiElements]) + }, [activeVariant.id, ui.facets, ui.mode, ui.query, ui.screenLabels, ui.uiElements, ui.viewerOpen]) useEffect(() => { if (!ui.viewerOpen || !selectedScreen) return - window.history.replaceState( - null, - "", + const url = new URL( catalogDeepLink(selectedScreen.id, { flowId: ui.mode === "flows" ? activeFlow?.id : undefined, variantId: activeVariant.id, }), ) + if (window.location.hash.startsWith("#annotations=")) url.hash = window.location.hash + window.history.replaceState(null, "", url) }, [activeVariant.id, activeFlow?.id, selectedScreen, ui.mode, ui.viewerOpen]) useEffect(() => { @@ -483,6 +484,7 @@ export function App({ catalog }: AppProps) { {ui.viewerOpen && selectedScreen ? ( +} + +const FragmentKey = "annotations" +const MaxAnnotations = 24 +const MaxNoteLength = 2_000 + +export function annotationUrl(deepLink: string, document: AnnotationDocument) { + const url = new URL(deepLink) + url.hash = `${FragmentKey}=${encode(document)}` + return url.href +} + +export function readAnnotations(url: URL, identifier: string, variant: string): ReadonlyArray { + const params = new URLSearchParams(url.hash.slice(1)) + const encoded = params.get(FragmentKey) + if (!encoded) return [] + const value = decode(encoded) + if (!isDocument(value) || value.identifier !== identifier || value.variant !== variant) return [] + return value.annotations +} + +export function readAnnotationDraft(value: string): ReadonlyArray { + try { + const annotations: unknown = JSON.parse(value) + return isAnnotations(annotations) ? annotations : [] + } catch { + return [] + } +} + +function encode(value: AnnotationDocument) { + const bytes = new TextEncoder().encode(JSON.stringify(value)) + return btoa(Array.from(bytes, (byte) => String.fromCharCode(byte)).join("")) + .replaceAll("+", "-") + .replaceAll("/", "_") + .replace(/=+$/, "") +} + +function decode(value: string): unknown { + try { + const binary = atob(value.replaceAll("-", "+").replaceAll("_", "/")) + return JSON.parse(new TextDecoder().decode(Uint8Array.from(binary, (character) => character.charCodeAt(0)))) + } catch { + return undefined + } +} + +function isDocument(value: unknown): value is AnnotationDocument { + if (!value || typeof value !== "object") return false + const document = value as Partial + if (document.version !== 1 || typeof document.identifier !== "string" || typeof document.variant !== "string") + return false + return isAnnotations(document.annotations) +} + +function isAnnotations(value: unknown): value is ReadonlyArray { + if (!Array.isArray(value) || value.length > MaxAnnotations) return false + return value.every( + (annotation) => + annotation && + typeof annotation === "object" && + typeof annotation.id === "string" && + Number.isInteger(annotation.row) && + annotation.row >= 0 && + Number.isInteger(annotation.column) && + annotation.column >= 0 && + typeof annotation.note === "string" && + annotation.note.length <= MaxNoteLength, + ) +} diff --git a/packages/lab/catalog/src/components/AnnotationEditor.tsx b/packages/lab/catalog/src/components/AnnotationEditor.tsx new file mode 100644 index 0000000000..a787335333 --- /dev/null +++ b/packages/lab/catalog/src/components/AnnotationEditor.tsx @@ -0,0 +1,185 @@ +import { useEffect, useRef, useState } from "react" +import type { Annotation } from "../annotations" + +interface AnnotationEditorProps { + readonly cols: number + readonly rows: number + readonly annotations: ReadonlyArray + readonly onAdd: (row: number, column: number, note: string) => void + readonly onChange: (id: string, note: string) => void + readonly onDelete: (id: string) => void + readonly issueLink: string + readonly onDone: () => void +} + +interface Draft { + readonly id?: string + readonly row: number + readonly column: number + readonly note: string +} + +export function AnnotationEditor(props: AnnotationEditorProps) { + const [draft, setDraft] = useState() + const textareaRef = useRef(null) + const complete = props.annotations.filter((annotation) => annotation.note.trim() !== "") + + useEffect(() => { + if (!draft) return + const frame = requestAnimationFrame(() => { + textareaRef.current?.focus() + textareaRef.current?.setSelectionRange(draft.note.length, draft.note.length) + }) + return () => cancelAnimationFrame(frame) + }, [draft?.id, draft?.row, draft?.column]) + + const save = () => { + if (!draft?.note.trim()) return + if (draft.id) props.onChange(draft.id, draft.note.trim()) + else props.onAdd(draft.row, draft.column, draft.note.trim()) + setDraft(undefined) + } + + const edit = (annotation: Annotation) => + setDraft({ id: annotation.id, row: annotation.row, column: annotation.column, note: annotation.note }) + + return ( + <> +
{ + if (event.target !== event.currentTarget) return + const bounds = event.currentTarget.getBoundingClientRect() + const column = Math.min( + props.cols - 1, + Math.max(0, Math.floor(((event.clientX - bounds.left) / bounds.width) * props.cols)), + ) + const row = Math.min( + props.rows - 1, + Math.max(0, Math.floor(((event.clientY - bounds.top) / bounds.height) * props.rows)), + ) + setDraft({ row, column, note: "" }) + }} + > + {props.annotations.map((annotation, index) => ( + + ))} + {draft ? ( +
props.rows / 2 ? " above" : ""}`} + style={{ + left: `clamp(9rem, ${((draft.column + 0.5) / props.cols) * 100}%, calc(100% - 9rem))`, + top: `${((draft.row + 0.5) / props.rows) * 100}%`, + }} + onPointerDown={(event) => event.stopPropagation()} + > +
+ {draft.id ? "Edit annotation" : "New annotation"} + + R{draft.row + 1} · C{draft.column + 1} + +
+