Compare commits

...

18 Commits

Author SHA1 Message Date
Kit Langton 5cc865a19f fix(tui): position composer top slot above queue 2026-08-12 22:12:36 -04:00
Kit Langton 20197c6b8a test(core): reuse project service stub (#42208) 2026-08-12 21:47:09 -04:00
James Long 230b2f8488 fix(tui): omit implicit cd autocomplete prefix (#42206) 2026-08-12 21:39:42 -04:00
Kit Langton a78c8d8972 test(core): reuse permission service stub (#42205) 2026-08-12 21:26:58 -04:00
Luke Parker bac3631ded refactor(app): align UI packages with solid best practices (#41977)
Co-authored-by: Brendan Allan <14191578+Brendonovich@users.noreply.github.com>
2026-08-13 01:23:40 +00:00
opencode-agent[bot] 664b0ba2d6 chore: generate 2026-08-13 01:22:11 +00:00
Luke Parker 9c8b50b989 feat(deskltop): use opencode2 in WSL (#42199) 2026-08-13 11:20:48 +10:00
opencode-agent[bot] 4358a02cf4 chore: generate 2026-08-13 01:11:25 +00:00
Kit Langton a31058d76c fix(core): skip shell parsing when permissions allow all (#42203) 2026-08-12 21:09:52 -04:00
Kit Langton 91ba9f2412 fix(tui): repair baseline unit failures (#42198) 2026-08-12 21:04:50 -04:00
opencode-agent[bot] 3125f67708 chore: generate 2026-08-13 00:52:58 +00:00
Kit Langton 550e9cc636 feat(catalog): auto-generated Open Graph cards per capture (#42201) 2026-08-12 20:51:32 -04:00
Luke Parker af127a643b refactor(desktop): run local server from source (#42194) 2026-08-13 10:44:45 +10:00
Kit Langton 77aa1cfede fix(tui): truncate queued prompt preview (#42196) 2026-08-12 20:42:41 -04:00
opencode-agent[bot] a2ed936283 chore: generate 2026-08-13 00:30:58 +00:00
Kit Langton b17fbf41e3 feat(catalog): click-to-annotate captures with GitHub issue handoff (#42183) 2026-08-12 20:29:32 -04:00
Kit Langton 9d6e05b6e4 fix(cli): inset update footer (#42189) 2026-08-12 20:15:56 -04:00
Kit Langton 9b805c140f feat(tui): render Mermaid GitGraph diagrams (#42179) 2026-08-12 20:10:52 -04:00
135 changed files with 3291 additions and 1615 deletions
+1
View File
@@ -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",
+8 -10
View File
@@ -86,8 +86,8 @@ function TargetServerRoute(props: ParentProps) {
return (
// Owns the server-identity remount. Session changes must not remount this subtree.
<Show when={requireServerKey(params.serverKey)} keyed>
<ServerSDKProvider server={conn}>
<ServerSyncProvider server={conn}>{props.children}</ServerSyncProvider>
<ServerSDKProvider server={conn()}>
<ServerSyncProvider server={conn()}>{props.children}</ServerSyncProvider>
</ServerSDKProvider>
</Show>
)
@@ -130,16 +130,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 (
<Show when={`${props.draft.server}\0${props.draft.directory}`} keyed>
<ServerSDKProvider server={conn}>
<ServerSyncProvider server={conn}>
<ModelsProvider directory={directory}>
<SDKProvider directory={directory}>
<DirectoryDataProvider directory={directory} server={serverKey}>
<ServerSDKProvider server={conn()}>
<ServerSyncProvider server={conn()}>
<ModelsProvider directory={props.draft.directory}>
<SDKProvider directory={props.draft.directory}>
<DirectoryDataProvider directory={props.draft.directory} server={props.draft.server}>
<DraftProviders>
<NewSession />
</DraftProviders>
@@ -237,7 +235,7 @@ function DesktopCommands() {
}
type ServerScopedShellProps = ParentProps<{
directory?: () => string | undefined
directory?: string
serverScoped?: JSX.Element
}>
+3 -3
View File
@@ -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 && (
<Show when={platform.setForceFocus}>
<ToggleCell
active={state.focus}
inline={props.inline}
@@ -580,7 +580,7 @@ export function DebugBar(props: { inline?: boolean } = {}) {
value={language.t(state.focus ? "debugBar.focus.on" : "debugBar.focus.off")}
onClick={() => void toggleFocus()}
/>
)}
</Show>
</div>
</aside>
)
@@ -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"
@@ -40,7 +40,7 @@ export function useProviderConnectController(options: { onBack?: () => void } =
}
export const DialogConnectProvider: Component<{
directory?: Accessor<string | undefined>
directory?: string
controller?: ReturnType<typeof useProviderConnectController>
}> = (props) => {
const fallback = useProviderConnectController()
@@ -136,15 +136,11 @@ export const DialogConnectProvider: Component<{
)
}
function ProviderPicker(props: {
directory?: Accessor<string | undefined>
onSelect: (provider: string) => void
onPrepare?: () => void
}) {
function ProviderPicker(props: { directory?: string; onSelect: (provider: string) => void; onPrepare?: () => void }) {
const settings = useSettings()
if (settings.general.newLayoutDesigns())
return <ProviderPickerV2 directory={props.directory} onSelect={props.onSelect} onPrepare={props.onPrepare} />
const providers = useProviders(() => props.directory?.())
const providers = useProviders(() => props.directory)
const language = useLanguage()
const popularGroup = () => language.t("dialog.provider.group.popular")
const otherGroup = () => language.t("dialog.provider.group.other")
@@ -211,12 +207,8 @@ function ProviderPicker(props: {
)
}
function ProviderPickerV2(props: {
directory?: Accessor<string | undefined>
onSelect: (provider: string) => void
onPrepare?: () => void
}) {
const providers = useProviders(() => props.directory?.())
function ProviderPickerV2(props: { directory?: string; onSelect: (provider: string) => void; onPrepare?: () => void }) {
const providers = useProviders(() => props.directory)
const language = useLanguage()
const [store, setStore] = createStore({
filter: "",
@@ -364,7 +356,7 @@ function ProviderPickerV2(props: {
function ProviderConnection(props: {
provider: string
directory?: Accessor<string | undefined>
directory?: string
onBack: () => void
setBack: (handler: () => void) => void
}) {
@@ -374,8 +366,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 provider = createMemo(
() => providers.all().get(props.provider) ?? serverSync().data.provider.all.get(props.provider)!,
@@ -31,7 +31,7 @@ export const DialogManageModels: Component = () => {
const directory = () => decode64(local.slug())
const handleConnectProvider = () => {
void dialog.show(() => <DialogConnectProvider directory={directory} />)
void dialog.show(() => <DialogConnectProvider directory={directory()} />)
}
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(() => <DialogConnectProvider directory={directory} />)
void dialog.show(() => <DialogConnectProvider directory={directory()} />)
}
const providerList = (providerID: string) => local.model.list().filter((x) => x.provider.id === providerID)
const providerVisible = (providerID: string) =>
@@ -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) */}
<div class="flex flex-col gap-12">
<div class="flex flex-col items-start gap-3">
{isLast() ? (
<Show
when={isLast()}
fallback={
<Button variant="secondary" size="large" onClick={handleNext}>
{language.t("dialog.releaseNotes.action.next")}
</Button>
}
>
<Button variant="primary" size="large" onClick={handleClose}>
{language.t("dialog.releaseNotes.action.getStarted")}
</Button>
) : (
<Button variant="secondary" size="large" onClick={handleNext}>
{language.t("dialog.releaseNotes.action.next")}
</Button>
)}
</Show>
<Button variant="ghost" size="small" onClick={handleDisable}>
{language.t("dialog.releaseNotes.action.hideFuture")}
</Button>
</div>
{paged() && (
<Show when={paged()}>
<div class="flex items-center gap-1.5 -my-2.5">
{props.highlights.map((_, i) => (
<button
type="button"
class="h-6 flex items-center cursor-pointer bg-transparent border-none p-0 transition-all duration-200"
classList={{
"w-8": i === index(),
"w-3": i !== index(),
}}
onClick={() => setIndex(i)}
>
<div
class="w-full h-0.5 rounded-[1px] transition-colors duration-200"
<Index each={props.highlights}>
{(_, i) => (
<button
type="button"
class="h-6 flex items-center cursor-pointer bg-transparent border-none p-0 transition-all duration-200"
classList={{
"bg-icon-strong-base": i === index(),
"bg-icon-weak-base": i !== index(),
"w-8": i === index(),
"w-3": i !== index(),
}}
/>
</button>
))}
onClick={() => setIndex(i)}
>
<div
class="w-full h-0.5 rounded-[1px] transition-colors duration-200"
classList={{
"bg-icon-strong-base": i === index(),
"bg-icon-weak-base": i !== index(),
}}
/>
</button>
)}
</Index>
</div>
)}
</Show>
</div>
</div>
{/* Right side - Media content (edge to edge) */}
{feature()?.media && (
<div class="flex-1 min-w-0 bg-surface-base overflow-hidden rounded-r-xl">
{feature()!.media!.type === "image" ? (
<img
src={feature()!.media!.src}
alt={feature()!.media!.alt ?? feature()?.title ?? language.t("dialog.releaseNotes.media.alt")}
class="w-full h-full object-cover"
/>
) : (
<video src={feature()!.media!.src} autoplay loop muted playsinline class="w-full h-full object-cover" />
)}
</div>
)}
<Show when={feature()?.media}>
{(media) => (
<div class="flex-1 min-w-0 bg-surface-base overflow-hidden rounded-r-xl">
<Show
when={media().type === "image"}
fallback={
<video src={media().src} autoplay loop muted playsinline class="w-full h-full object-cover" />
}
>
<img
src={media().src}
alt={media().alt ?? feature()?.title ?? language.t("dialog.releaseNotes.media.alt")}
class="w-full h-full object-cover"
/>
</Show>
</div>
)}
</Show>
</div>
</Dialog>
)
@@ -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(() => <x.DialogConnectProvider controller={controller} directory={directory} />)
void dialog.show(() => <x.DialogConnectProvider controller={controller} directory={directory()} />)
})
}
@@ -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(() => <x.DialogConnectProvider controller={controller} directory={directory} />)
void dialog.show(() => <x.DialogConnectProvider controller={controller} directory={directory()} />)
})
}
@@ -148,7 +148,7 @@ export function ModelSelectorPopover(props: {
const handleConnectProvider = () => {
close("provider")
void import("./dialog-connect-provider").then((x) => {
void dialog.show(() => <x.DialogConnectProvider directory={directory} />)
void dialog.show(() => <x.DialogConnectProvider directory={directory()} />)
})
}
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: {
<MenuV2.GroupLabel class="gap-2 px-3">
<span class="min-w-0 truncate">{group.items[0].provider.name}</span>
</MenuV2.GroupLabel>
<MenuV2.RadioGroup value={props.current()}>
<MenuV2.RadioGroup value={props.current}>
<For each={group.items}>
{(item) => (
<TooltipV2
@@ -473,7 +473,7 @@ function ModelSelectorPopoverV2View(props: {
<MenuV2.RadioItem
value={modelKey(item)}
data-option-key={modelKey(item)}
data-selected-model={props.current() === modelKey(item) ? true : undefined}
data-selected-model={props.current === modelKey(item) ? true : undefined}
class="scroll-my-6 w-full"
classList={{ "!bg-v2-overlay-simple-overlay-hover": store.active === modelKey(item) }}
onMouseEnter={() => {
@@ -529,7 +529,7 @@ export const DialogSelectModel: Component<{ provider?: string; model?: ModelStat
const provider = () => {
void import("./dialog-connect-provider").then((x) => {
void dialog.show(() => <x.DialogConnectProvider directory={directory} />)
void dialog.show(() => <x.DialogConnectProvider directory={directory()} />)
})
}
@@ -96,13 +96,16 @@ export function ServerRow(props: ServerRowProps) {
{(conn) => (
<div class="flex flex-row gap-3">
<span>
{conn().http.username ? (
<Show
when={conn().http.username}
fallback={<span class="text-text-weaker">{language.t("server.row.noUsername")}</span>}
>
<span class="text-text-weak">{conn().http.username}</span>
) : (
<span class="text-text-weaker">{language.t("server.row.noUsername")}</span>
)}
</Show>
</span>
{conn().http.password && <span class="text-text-weak"></span>}
<Show when={conn().http.password}>
<span class="text-text-weak"></span>
</Show>
</div>
)}
</Show>
@@ -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))
@@ -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({
@@ -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<KeybindGroup, string[]>
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: {
<div class="settings-v2-tab-header settings-v2-tab-header--stacked">
<div class="settings-v2-tab-header-row">
<h2 class="settings-v2-tab-title">{language.t("settings.shortcuts.title")}</h2>
<ButtonV2 variant="ghost" onClick={props.onReset} disabled={!props.hasOverrides()}>
<ButtonV2 variant="ghost" onClick={props.onReset} disabled={!props.hasOverrides}>
{language.t("settings.shortcuts.reset.button")}
</ButtonV2>
</div>
@@ -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)}
>
<Show
when={props.active() === id}
when={props.active === id}
fallback={props.keybind(id) || language.t("settings.shortcuts.unassigned")}
>
{language.t("settings.shortcuts.pressKeys")}
@@ -674,8 +675,6 @@ export const SettingsKeybinds: Component<{ v2?: boolean }> = (props) => {
</Show>
)
const List = props.v2 ? SettingsListV2 : SettingsList
const groups = (
<div
classList={{
@@ -700,7 +699,7 @@ export const SettingsKeybinds: Component<{ v2?: boolean }> = (props) => {
>
{language.t(groupKey[group])}
</h3>
<List>
<Dynamic component={props.v2 ? SettingsListV2 : SettingsList}>
<For each={filtered().get(group) ?? []}>
{(id) => (
<div class="flex items-center justify-between gap-4 py-3 border-b border-border-weak-base last:border-none">
@@ -735,7 +734,7 @@ export const SettingsKeybinds: Component<{ v2?: boolean }> = (props) => {
</div>
)}
</For>
</List>
</Dynamic>
</div>
</Show>
)}
@@ -103,7 +103,7 @@ export const DialogSettings: Component<{
<SettingsServersV2 />
</TabsV2.Content>
<TabsV2.Content value="providers" class="settings-v2-panel">
<SettingsProvidersV2 directory={directory} onBack={showProviders} />
<SettingsProvidersV2 directory={directory()} onBack={showProviders} />
</TabsV2.Content>
<TabsV2.Content value="models" class="settings-v2-panel">
<SettingsModelsV2 />
@@ -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<string | undefined>
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) => {
@@ -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<boolean> }) {
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<boolean> }) {
})
}
createEffect(() => {
if (!props.shown()) return
})
let dialogRun = 0
let dialogDead = false
onCleanup(() => {
@@ -147,7 +134,7 @@ export function StatusPopoverBody(props: { shown: Accessor<boolean> }) {
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 } })
@@ -74,7 +74,7 @@ export function StatusPopover() {
<div class="w-[360px] h-14 rounded-xl bg-background-strong shadow-[var(--shadow-lg-border-base)]" />
}
>
<Body shown={shown} />
<Body shown={shown()} />
</Suspense>
</Show>
</Popover>
@@ -114,7 +114,7 @@ function DirectoryStatusPopover() {
onOpenChange: setShown,
body: () => (
<StatusPopoverBody shown={shown()}>
<Body shown={shown} />
<Body shown={shown()} />
</StatusPopoverBody>
),
}))
+1
View File
@@ -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
@@ -22,14 +22,14 @@ export function TabNavItem(props: {
ref?: Ref<HTMLDivElement>
href: string
server: ServerConnection.Key
session: () => SessionInfo | undefined
session: SessionInfo | undefined
fallbackTitle?: string
onRename: (title: string) => Promise<void>
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]"
>
<span data-slot="project-avatar-slot" class="flex size-4 shrink-0 items-center justify-center">
<Show
when={props.session()}
when={props.session}
keyed
fallback={
<span class="block size-4 rounded-[3px] border border-v2-border-border-muted" aria-hidden="true" />
@@ -267,7 +267,7 @@ export function TabNavItem(props: {
}
if (event.key !== "Escape") return
event.preventDefault()
titleEl.textContent = props.session()?.title ?? ""
titleEl.textContent = props.session?.title ?? ""
void closeRename(false)
}}
onBlur={() => void closeRename(true)}
@@ -308,7 +308,7 @@ export function TabNavItem(props: {
}}
data={{
projectName: projectName(),
title: props.session()?.title,
title: props.session?.title,
path: previewPath(),
serverName: serverLabel(),
}}
@@ -323,7 +323,7 @@ export function DraftTabItem(props: {
active?: boolean
onNavigate: () => void
onClose: () => void
suppressNavigation?: () => boolean
suppressNavigation?: boolean
dragging?: boolean
pressed?: boolean
hidden?: boolean
@@ -366,14 +366,14 @@ export function DraftTabItem(props: {
onMouseDown={(event) => {
// Navigate on mousedown to shave the press-release delay off tab switches.
if (event.button !== 0) return
if (props.suppressNavigation?.()) return
if (props.suppressNavigation) return
props.onNavigate()
}}
onClick={(event) => {
event.preventDefault()
// Mouse navigation already happened on mousedown; detail 0 means keyboard activation.
if (event.detail > 0) 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 [-webkit-user-drag:none]"
@@ -24,10 +24,10 @@ import type { SessionInfo } from "@opencode-ai/client/promise"
function SessionTabSlot(props: {
tab: SessionTab
id: string
index: () => number
active: () => boolean
index: number
active: boolean
forceTruncate: boolean
session: () => SessionInfo | undefined
session: SessionInfo | undefined
fallbackTitle?: string
onRename: (title: string) => Promise<void>
onNavigate: (element: HTMLDivElement) => void
@@ -38,7 +38,7 @@ function SessionTabSlot(props: {
return props.id
},
get index() {
return props.index()
return props.index
},
})
let ref!: HTMLDivElement
@@ -48,7 +48,7 @@ function SessionTabSlot(props: {
ref={sortable.ref}
data-titlebar-tab-slot
data-tab-key={props.id}
data-active={props.active()}
data-active={props.active}
class="relative flex w-56 min-w-7 max-w-56 flex-shrink"
>
<TabNavItem
@@ -62,7 +62,7 @@ function SessionTabSlot(props: {
onRename={props.onRename}
onNavigate={() => props.onNavigate(ref)}
onClose={props.onClose}
active={props.active()}
active={props.active}
forceTruncate={props.forceTruncate}
dragging={sortable.isDragSource()}
/>
@@ -73,34 +73,34 @@ function SessionTabSlot(props: {
function SessionTabEntry(props: {
tab: SessionTab
id: string
index: () => number
active: () => boolean
index: number
active: boolean
forceTruncate: boolean
serverCtx: () => ServerCtx | undefined
serverCtx: ServerCtx | undefined
onVisibleChange: (visible: boolean) => void
onNavigate: (element: HTMLDivElement) => void
onClose: () => void
}) {
const tabs = useTabs()
const language = useLanguage()
const sdk = createMemo(() => props.serverCtx()?.sdk ?? null)
const cachedSession = createMemo(() => props.serverCtx()?.sync.session.peek(props.tab.sessionId))
const sdk = createMemo(() => props.serverCtx?.sdk ?? null)
const cachedSession = createMemo(() => props.serverCtx?.sync.session.peek(props.tab.sessionId))
const persisted = createMemo(() => tabs.info[props.id])
const [loadedSession] = createResource(
() => {
const ctx = props.serverCtx()
const ctx = props.serverCtx
return ctx ? { id: props.tab.sessionId, ctx } : null
},
({ id, ctx }) => ctx.sync.session.resolve(id).catch(() => undefined),
)
const session = createMemo(() => cachedSession() ?? loadedSession())
const missingSession = createMemo(() => !!props.serverCtx() && !loadedSession.loading && !session())
const missingSession = createMemo(() => !!props.serverCtx && !loadedSession.loading && !session())
const visible = createMemo(() => !!session() || missingSession() || !!persisted()?.title)
let prefetched = false
const rename = async (title: string) => {
const value = session()
const ctx = props.serverCtx()
const ctx = props.serverCtx
if (!value || !ctx) return
ctx.sync.session.remember({ ...value, title })
@@ -108,7 +108,7 @@ function SessionTabEntry(props: {
await ctx.sdk.api.session.rename({ sessionID: value.id, title })
} catch (err) {
const current = session()
const currentCtx = props.serverCtx()
const currentCtx = props.serverCtx
if (current && currentCtx) currentCtx.sync.session.remember({ ...current, title: value.title })
showToast({
title: language.t("common.requestFailed"),
@@ -120,7 +120,7 @@ function SessionTabEntry(props: {
createEffect(() => props.onVisibleChange(visible()))
createEffect(() => {
const ctx = props.serverCtx()
const ctx = props.serverCtx
const value = session()
if (!ctx || !value || prefetched) return
prefetched = true
@@ -157,7 +157,7 @@ function SessionTabEntry(props: {
index={props.index}
active={props.active}
forceTruncate={props.forceTruncate}
session={session}
session={session()}
fallbackTitle={persisted()?.title ?? (missingSession() ? language.t("session.tab.unknown") : undefined)}
onRename={rename}
onNavigate={props.onNavigate}
@@ -170,8 +170,8 @@ function SessionTabEntry(props: {
function DraftTabSlot(props: {
tab: Extract<Tab, { type: "draft" }>
id: string
index: () => number
active: () => boolean
index: number
active: boolean
title: string
onNavigate: (element: HTMLDivElement) => void
onClose: () => void
@@ -181,7 +181,7 @@ function DraftTabSlot(props: {
return props.id
},
get index() {
return props.index()
return props.index
},
})
let ref!: HTMLDivElement
@@ -191,7 +191,7 @@ function DraftTabSlot(props: {
ref={sortable.ref}
data-titlebar-tab-slot
data-tab-key={props.id}
data-active={props.active()}
data-active={props.active}
class="relative flex w-56 min-w-7 max-w-56 flex-shrink"
>
<DraftTabItem
@@ -202,7 +202,7 @@ function DraftTabSlot(props: {
title={props.title}
onNavigate={() => props.onNavigate(ref)}
onClose={props.onClose}
active={props.active()}
active={props.active}
dragging={sortable.isDragSource()}
/>
</div>
@@ -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: {
<SessionTabEntry
tab={tab}
id={id}
index={visibleIndex}
active={() => 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: {
<DraftTabSlot
tab={tab}
id={id}
index={visibleIndex}
active={() => props.currentTab() === tab}
index={visibleIndex()}
active={props.currentTab === tab}
title={language.t("command.session.new")}
onNavigate={(element) => {
ref = element
+10 -12
View File
@@ -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<TitlebarUpdatePillState>(() => {
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
<TitlebarTabStrip
tabs={tabsStore}
currentTab={currentTab}
currentTab={currentTab()}
forceTruncate={tabsAreOverflowing()}
onOverflowChange={setTabsAreOverflowing}
onNavigate={(tab, el) => {
@@ -657,12 +657,10 @@ function ChannelIndicator(props: { debugTools?: { visible: boolean; toggle: () =
}
return (
<>
{["local", "beta", "dev"].includes(channel) && (
<div class="bg-icon-interactive-base text-[#FFF] font-medium px-2 rounded-sm uppercase font-mono">
{channel.toUpperCase()}
</div>
)}
</>
<Show when={["local", "beta", "dev"].includes(channel)}>
<div class="bg-icon-interactive-base text-[#FFF] font-medium px-2 rounded-sm uppercase font-mono">
{channel.toUpperCase()}
</div>
</Show>
)
}
@@ -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 (
<DropdownMenu gutter={4} modal={false} placement="bottom-start">
{props.variant === "v2" ? (
<Show
when={props.variant === "v2"}
fallback={
<DropdownMenu.Trigger
as={IconButton}
icon="menu"
variant="ghost"
class="titlebar-icon rounded-md shrink-0"
aria-label={language.t("desktop.menu.ariaLabel")}
onPointerDown={rememberFocus}
onKeyDown={rememberFocus}
/>
}
>
<div
data-component="desktop-icon-button"
class="flex h-7 w-9 shrink-0 items-center justify-center rounded-[6px] px-1"
@@ -65,39 +78,31 @@ export function WindowsAppMenu(props: {
onKeyDown={rememberFocus}
/>
</div>
) : (
<DropdownMenu.Trigger
as={IconButton}
icon="menu"
variant="ghost"
class="titlebar-icon rounded-md shrink-0"
aria-label={language.t("desktop.menu.ariaLabel")}
onPointerDown={rememberFocus}
onKeyDown={rememberFocus}
/>
)}
</Show>
<DropdownMenu.Portal>
<DropdownMenu.Content class="desktop-app-menu">
<DropdownMenu.Group>
<DropdownMenu.GroupLabel class="desktop-app-menu-heading">OpenCode</DropdownMenu.GroupLabel>
{DESKTOP_MENU.filter((menu) => desktopMenuVisible(menu, "windows")).map((menu) => (
<DesktopMenuSubmenu label={language.t(menu.labelKey)}>
{menu.items
?.filter((entry) => desktopMenuVisible(entry, "windows"))
.map((entry) =>
entry.type === "separator" ? (
<DropdownMenu.Separator />
) : (
<DesktopMenuItem
label={entry.labelKey ? language.t(entry.labelKey) : ""}
keybind={entry.command ? props.command.keybind(entry.command) : entry.accelerator?.windows}
disabled={entry.command ? commandDisabled(entry.command) : false}
onSelect={() => runEntry(entry)}
/>
),
)}
</DesktopMenuSubmenu>
))}
<For each={DESKTOP_MENU.filter((menu) => desktopMenuVisible(menu, "windows"))}>
{(menu) => (
<DesktopMenuSubmenu label={language.t(menu.labelKey)}>
<For each={menu.items?.filter((entry) => desktopMenuVisible(entry, "windows"))}>
{(entry) => {
// Static menu data: an early return keeps the union narrowing a Show fallback would lose.
if (entry.type === "separator") return <DropdownMenu.Separator />
return (
<DesktopMenuItem
label={entry.labelKey ? language.t(entry.labelKey) : ""}
keybind={entry.command ? props.command.keybind(entry.command) : entry.accelerator?.windows}
disabled={entry.command ? commandDisabled(entry.command) : false}
onSelect={() => runEntry(entry)}
/>
)
}}
</For>
</DesktopMenuSubmenu>
)}
</For>
</DropdownMenu.Group>
</DropdownMenu.Content>
</DropdownMenu.Portal>
+3 -3
View File
@@ -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<string | undefined> } = {}) => {
const providers = useProviders(() => props.directory?.())
init: (props: { directory?: string } = {}) => {
const providers = useProviders(() => props.directory)
const [store, setStore, _, ready] = persisted(
Persist.global("model", ["model.v1"]),
+2 -5
View File
@@ -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<string> }) => {
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))
},
})
+3 -3
View File
@@ -3,7 +3,7 @@ import type { Event } from "@/types"
import { createSimpleContext } from "@opencode-ai/ui/context"
import { createGlobalEmitter } from "@solid-primitives/event-bus"
import { makeEventListener } from "@solid-primitives/event-listener"
import { type Accessor, batch, createMemo, onCleanup, onMount } from "solid-js"
import { batch, createMemo, onCleanup, onMount } from "solid-js"
import { createApiForServer, type ServerApi } from "@/utils/server"
import { useLanguage } from "./language"
import { usePlatform } from "./platform"
@@ -270,13 +270,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<ServerConnection.Any | undefined> }) => {
init: (props: { server?: ServerConnection.Any }) => {
const global = useGlobal()
const language = useLanguage()
const server = useServer()
return createMemo<ServerSDK>(() => {
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
})
+3 -3
View File
@@ -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, onMount, untrack } from "solid-js"
import { batch, createMemo, getOwner, onCleanup, onMount, untrack } from "solid-js"
import { createStore, produce, reconcile } from "solid-js/store"
import { useLanguage } from "@/context/language"
import type { InitError } from "../pages/error"
@@ -699,13 +699,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<ServerConnection.Any | undefined> }) => {
init: (props: { server?: ServerConnection.Any }) => {
const global = useGlobal()
const language = useLanguage()
const server = useServer()
return createMemo<ServerSync>(() => {
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
})
+6 -7
View File
@@ -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<string>
directory: string
draftID?: string
server?: Accessor<ServerConnection.Key | undefined>
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)
+2 -2
View File
@@ -23,8 +23,8 @@ export function Home() {
>
<ScrollView
class="h-full [container-type:size]"
thumbContainer={scroll.viewport.thumbTrack}
thumbHoverTarget={scroll.viewport.hoverTarget}
thumbContainer={scroll.viewport.thumbTrack()}
thumbHoverTarget={scroll.viewport.hoverTarget()}
viewportRef={scroll.viewport.setViewport}
onScroll={(event) => scroll.viewport.update(event.currentTarget.scrollTop)}
onWheel={scroll.viewport.containOuterWheel}
@@ -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<typeof useLanguage>
servers: Accessor<ServerConnection.Any[]>
projects: Accessor<LocalProject[]>
recentlyClosed: Accessor<LocalProject[]>
selection: Accessor<HomeProjectSelection>
homedir: Accessor<string>
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<boolean>
defaultServerKey: Accessor<ServerConnection.Key | null | undefined>
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) {
>
<div class="flex h-7 min-w-0 shrink-0 items-center justify-between pl-1.5 pr-3">
<div class="text-v2-text-text-muted [font-weight:530]">{props.language.t("home.projects")}</div>
<Show
when={props.servers().length === 1 && !(props.projects().length === 0 && props.recentlyClosed().length > 0)}
>
<Show when={props.servers.length === 1 && !(props.projects.length === 0 && props.recentlyClosed.length > 0)}>
<TooltipV2 placement="bottom" value={props.language.t("home.project.add")}>
<IconButtonV2
data-action="home-add-project"
@@ -91,8 +89,8 @@ export function HomeProjectsView(props: HomeProjectsViewProps) {
size="large"
class="titlebar-icon [&_[data-slot=icon-svg]]:text-v2-icon-icon-muted"
icon={<IconV2 name="folder-add-left" />}
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")}
/>
</TooltipV2>
@@ -100,25 +98,20 @@ export function HomeProjectsView(props: HomeProjectsViewProps) {
</div>
<ScrollView data-slot="home-projects-scroll" class="min-h-0 min-w-0 shrink">
<Show
when={props.servers().length > 1}
when={props.servers.length > 1}
fallback={
<div class="pr-3">
<Show
when={props.projects().length > 0}
fallback={<HomeProjectEmpty {...props} server={props.servers()[0]} items={props.recentlyClosed()} />}
when={props.projects.length > 0}
fallback={<HomeProjectEmpty {...props} server={props.servers[0]} items={props.recentlyClosed} />}
>
<HomeProjectList
{...props}
{...contextMenuProps}
server={props.servers()[0]}
items={props.projects()}
/>
<HomeProjectList {...props} {...contextMenuProps} server={props.servers[0]} items={props.projects} />
</Show>
</div>
}
>
<div class="flex min-w-0 flex-col gap-4 pr-3">
<For each={props.servers()}>
<For each={props.servers}>
{(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: {
<ServerRowMenuView
server={props.server}
labels={serverMenuLabels(props.language)}
canDefault={props.canDefaultServer()}
isDefault={props.defaultServerKey() === ServerConnection.key(props.server)}
canDefault={props.canDefaultServer}
isDefault={props.defaultServerKey === ServerConnection.key(props.server)}
canRemove={props.canRemoveServer(props.server)}
onEdit={props.onEditServer}
onSetDefault={() => 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. */}
<For each={props.items.map((project) => project.worktree)}>
{(worktree, index) => <HomeProjectSlot {...props} worktree={worktree} index={index} />}
{(worktree, index) => <HomeProjectSlot {...props} worktree={worktree} index={index()} />}
</For>
</div>
</DragDropProvider>
@@ -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
@@ -6,16 +6,16 @@ export function HomeProjects(props: { projects: HomeProjectsController; scroll:
return (
<HomeProjectsView
language={props.projects.copy.language}
servers={props.projects.server.list}
projects={props.projects.project.list}
recentlyClosed={props.projects.project.recentlyClosed}
selection={props.projects.selection.value}
homedir={props.projects.project.homedir}
servers={props.projects.server.list()}
projects={props.projects.project.list()}
recentlyClosed={props.projects.project.recentlyClosed()}
selection={props.projects.selection.value()}
homedir={props.projects.project.homedir()}
serverHealth={props.projects.server.health}
projectsForServer={props.projects.server.projects}
collapsed={props.projects.server.collapsed}
canDefaultServer={props.projects.server.canDefault}
defaultServerKey={props.projects.server.defaultKey}
canDefaultServer={props.projects.server.canDefault()}
defaultServerKey={props.projects.server.defaultKey()}
canRevealProject={props.projects.project.canReveal}
unseenCount={props.projects.project.unseenCount}
onWheel={props.scroll.viewport.containWheel}
@@ -295,13 +295,13 @@ function groupSessions(records: HomeSessionRecord[], language: ReturnType<typeof
export type HomeSessionsController = ReturnType<typeof createHomeSessionsController>
export function HomeSessionStatusController(props: {
server: Accessor<ServerConnection.Key>
server: ServerConnection.Key
record: HomeSessionRecord
isOpenTab: (record: HomeSessionRecord) => boolean
render: (state: { unread: Accessor<boolean>; loading: Accessor<boolean>; open: Accessor<boolean> }) => JSX.Element
}) {
const avatar = useSessionTabAvatarState(
props.server,
() => props.server,
() => props.record.session.location.directory,
() => props.record.session.id,
)
@@ -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<typeof useLanguage>
groups: Accessor<HomeSessionGroup[]>
showProjectName: Accessor<boolean>
server: Accessor<ServerConnection.Key>
canCreateSession: Accessor<boolean>
searchValue: Accessor<string>
searchPlaceholder: Accessor<string>
searchOpen: Accessor<boolean>
searchLoading: Accessor<boolean>
searchResults: Accessor<HomeSessionRecord[]>
searchActive: Accessor<string>
searchNoResultsLabel: Accessor<string>
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) {
<div class="sticky top-0 z-30 shrink-0 bg-v2-background-bg-base pb-3 pt-6 lg:pt-12" onWheel={props.onWheel}>
<HomeSessionSearch {...props} />
<Suspense>
<Show when={props.groups().length > 0 && props.canCreateSession()}>
<Show when={props.groups.length > 0 && props.canCreateSession}>
<div class="pointer-events-none absolute right-0 top-[84px] z-20 flex lg:top-[108px]">
<ButtonV2
data-action="home-new-session"
@@ -113,16 +113,16 @@ export function HomeSessionsView(props: HomeSessionsViewProps) {
}
>
<Show
when={props.groups().length > 0}
when={props.groups.length > 0}
fallback={
<HomeSessionsEmpty
onNewSession={props.canCreateSession() ? props.onCreateSession : undefined}
onNewSession={props.canCreateSession ? props.onCreateSession : undefined}
language={props.language}
/>
}
>
<div ref={props.onSetContent} class="flex flex-col pt-3 pr-3 pb-16">
<For each={props.groups()}>
<For each={props.groups}>
{(group, index) => (
<>
<HomeSessionGroupHeader
@@ -132,7 +132,7 @@ export function HomeSessionsView(props: HomeSessionsViewProps) {
elevated={index() === 0}
/>
<div
class={`flex min-w-0 flex-col gap-px pt-4 ${index() === props.groups().length - 1 ? "" : "mb-6"}`}
class={`flex min-w-0 flex-col gap-px pt-4 ${index() === props.groups.length - 1 ? "" : "mb-6"}`}
>
<For each={group.sessions}>{(record) => <HomeSessionRow {...props} record={record} />}</For>
</div>
@@ -205,7 +205,7 @@ function HomeSessionSearch(props: HomeSessionsViewProps) {
return (
<div class="w-full">
<div ref={props.onSetSearchRoot} data-component="home-session-search" class="relative z-30 w-full">
<Show when={props.searchOpen()}>
<Show when={props.searchOpen}>
<div
data-component="home-session-search-panel"
class={`
@@ -217,7 +217,7 @@ function HomeSessionSearch(props: HomeSessionsViewProps) {
<div class="flex flex-col pt-9">
<div id={HOME_SESSION_SEARCH_RESULTS_ID} role="listbox" class="flex flex-col gap-4 pt-4">
<Show
when={!props.searchLoading()}
when={!props.searchLoading}
fallback={
<div class="flex items-center justify-center px-4 py-3 text-v2-text-text-muted [font-weight:440]">
<Spinner class="size-4" />
@@ -225,7 +225,7 @@ function HomeSessionSearch(props: HomeSessionsViewProps) {
}
>
<Show
when={props.searchResults().length > 0}
when={props.searchResults.length > 0}
fallback={
<p
class={`
@@ -233,7 +233,7 @@ function HomeSessionSearch(props: HomeSessionsViewProps) {
text-v2-text-text-muted [font-weight:440]
`}
>
{props.searchNoResultsLabel()}
{props.searchNoResultsLabel}
</p>
}
>
@@ -248,12 +248,12 @@ function HomeSessionSearch(props: HomeSessionsViewProps) {
</p>
<ScrollView class="max-h-80" viewportRef={props.onSetSearchList}>
<div class="flex flex-col gap-px pb-2">
<For each={props.searchResults()}>
<For each={props.searchResults}>
{(record) => (
<HomeSessionSearchResultRow
{...props}
record={record}
selected={props.searchActive() === homeSessionSearchKey(record)}
selected={props.searchActive === homeSessionSearchKey(record)}
/>
)}
</For>
@@ -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) {
}
}}
/>
<Show when={props.searchValue()}>
<Show when={props.searchValue}>
<IconButtonV2
type="button"
variant="ghost-muted"
size="small"
class="relative z-20 shrink-0"
icon={<IconV2 name="close" size="large" class="text-v2-icon-icon-muted" />}
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 (
<div
+11 -11
View File
@@ -11,17 +11,17 @@ export function HomeSessions(props: {
return (
<HomeSessionsView
language={props.sessions.copy.language}
groups={props.sessions.data.groups}
showProjectName={props.sessions.session.showProjectName}
server={props.sessions.session.server}
canCreateSession={props.sessions.session.canCreate}
searchValue={props.search.query.value}
searchPlaceholder={props.search.query.placeholder}
searchOpen={props.search.query.open}
searchLoading={props.search.result.loading}
searchResults={props.search.result.list}
searchActive={props.search.result.active}
searchNoResultsLabel={props.search.result.noResultsLabel}
groups={props.sessions.data.groups()}
showProjectName={props.sessions.session.showProjectName()}
server={props.sessions.session.server()}
canCreateSession={props.sessions.session.canCreate()}
searchValue={props.search.query.value()}
searchPlaceholder={props.search.query.placeholder()}
searchOpen={props.search.query.open()}
searchLoading={props.search.result.loading()}
searchResults={props.search.result.list()}
searchActive={props.search.result.active()}
searchNoResultsLabel={props.search.result.noResultsLabel()}
titleOpacity={props.scroll.header.titleOpacity}
isOpenTab={props.sessions.tab.isOpen}
onCreateSession={props.sessions.session.create}
+10 -6
View File
@@ -1,4 +1,4 @@
import { createEffect, Suspense, type ParentProps } from "solid-js"
import { onMount, Show, Suspense, type ParentProps } from "solid-js"
import { createStore } from "solid-js/store"
import { DebugBar } from "@/components/debug-bar"
import { Titlebar, type TitlebarUpdate } from "@/components/titlebar"
@@ -9,15 +9,17 @@ export default function Layout(props: ParentProps) {
const platform = usePlatform()
const [state, setState] = createStore({ debugTools: true })
createEffect(() => 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) {
<main class="flex-1 min-h-0 min-w-0 overflow-x-hidden flex flex-col items-start contain-strict">
<Suspense>{props.children}</Suspense>
</main>
{import.meta.env.DEV && state.debugTools && <DebugBar inline />}
<Show when={import.meta.env.DEV && state.debugTools}>
<DebugBar inline />
</Show>
<ToastRegion v2 />
</div>
)
+1 -1
View File
@@ -40,7 +40,7 @@ export default function NewSessionPage() {
return (
<div class="relative size-full overflow-hidden flex flex-col">
{suspendUntilPromptReady()}
<NewSessionStatus mount={rightMount} visible={settings.visibility.status} />
<NewSessionStatus mount={rightMount()} visible={settings.visibility.status()} />
<div class="flex-1 min-h-0 flex flex-col gap-2 p-2">
<NewSessionView input={draft.input} project={project} workspace={workspace} />
</div>
@@ -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<HTMLElement | null>; visible: Accessor<boolean> }) {
export function NewSessionStatus(props: { mount: HTMLElement | null; visible: boolean }) {
const language = useLanguage()
return (
<Show when={props.mount()} keyed>
<Show when={props.mount} keyed>
{(mount) => (
<Portal mount={mount}>
<Show when={props.visible()}>
<Show when={props.visible}>
<Tooltip placement="bottom" value={language.t("status.popover.trigger")}>
<StatusPopoverV2 />
</Tooltip>
@@ -116,7 +116,7 @@ function ProviderTip() {
})
const openProviders = () => {
void import("@/components/dialog-connect-provider").then(({ DialogConnectProvider }) => {
void dialog.show(() => <DialogConnectProvider directory={() => sdk().directory} />)
void dialog.show(() => <DialogConnectProvider directory={sdk().directory} />)
})
}
+48 -44
View File
@@ -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.
<TargetServerScopedProviders directory={directory} sessionID={() => params.id}>
<TargetServerScopedProviders directory={directory()} sessionID={params.id}>
<TargetSessionSettingsCommand />
<SessionRouteErrorBoundary sessionID={params.id} serverKey={requireServerKey(params.serverKey)} padded>
<ResolvedTargetSessionRoute />
@@ -183,17 +183,15 @@ export function SessionRouteErrorBoundary(
const settings = useSettings()
return (
<ErrorBoundary
fallback={(error) =>
settings.general.newLayoutDesigns() ? (
fallback={(error) => (
<Show when={settings.general.newLayoutDesigns()} fallback={<ErrorPage error={error} />}>
<SessionRouteFrame padded={props.padded}>
<SessionPanelFrame newLayout raised={!!props.sessionID}>
<SessionErrorFallback error={error} sessionID={props.sessionID} serverKey={props.serverKey} />
</SessionPanelFrame>
</SessionRouteFrame>
) : (
<ErrorPage error={error} />
)
}
</Show>
)}
>
{props.children}
</ErrorBoundary>
@@ -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.
<Show when={directory()}>
<SDKProvider directory={targetDirectory}>
<DirectoryDataProvider directory={targetDirectory} server={serverKey}>
<TargetSessionPage />
</DirectoryDataProvider>
</SDKProvider>
{(dir) => (
<SDKProvider directory={dir()}>
<DirectoryDataProvider directory={dir()} server={serverKey()}>
<TargetSessionPage />
</DirectoryDataProvider>
</SDKProvider>
)}
</Show>
)
}
@@ -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 (
<>
<MarkSessionNotificationsViewed sessionID={props.sessionID} />
@@ -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() {
<SessionReviewTab
title={changesTitle()}
empty={reviewEmpty(input)}
diffs={reviewDiffs}
view={controller.layout.view}
diffs={reviewDiffs()}
view={controller.layout.view()}
diffStyle={input.diffStyle}
onDiffStyleChange={input.onDiffStyleChange}
onScrollRef={(el) => 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() ? (
<Show
when={settings.general.newLayoutDesigns()}
fallback={
<SessionPanelFrame newLayout={false} raised={!!controller.identity.params.id}>
{sessionPanelContent()}
</SessionPanelFrame>
}
>
<Show when={sessionPanelKey()} keyed>
{(_) => (
<SessionPanelFrame newLayout raised={!!controller.identity.params.id}>
@@ -2259,11 +2267,7 @@ export default function Page() {
</SessionPanelFrame>
)}
</Show>
) : (
<SessionPanelFrame newLayout={false} raised={!!controller.identity.params.id}>
{sessionPanelContent()}
</SessionPanelFrame>
)}
</Show>
<Show when={desktopSessionResizeOpen()}>
<div onPointerDown={() => size.start()}>
@@ -2287,13 +2291,13 @@ export default function Page() {
<Show when={!newSessionDesign() && desktopSidePanelOpen()}>
<Suspense>
<SessionSidePanel
canReview={canReview}
diffs={reviewDiffs}
diffsReady={reviewReady}
empty={reviewEmptyText}
hasReview={hasReview}
reviewHasFocusableContent={hasReview}
reviewCount={reviewCount}
canReview={canReview()}
diffs={reviewDiffs()}
diffsReady={reviewReady()}
empty={reviewEmptyText()}
hasReview={hasReview()}
reviewHasFocusableContent={hasReview()}
reviewCount={reviewCount()}
reviewPanel={reviewPanel}
activeDiff={activeReviewFile()}
focusReviewDiff={focusReviewDiff}
@@ -2309,13 +2313,13 @@ export default function Page() {
<div class="min-h-0 flex-1">
<Suspense>
<SessionSidePanel
canReview={canReview}
diffs={reviewDiffs}
diffsReady={reviewReady}
empty={reviewEmptyText}
hasReview={hasReview}
reviewHasFocusableContent={() => 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) => (
<SessionReviewV2SidebarToggle
@@ -7,7 +7,7 @@ import { useSpring } from "@opencode-ai/ui/motion-spring"
import { TextReveal } from "@opencode-ai/ui/text-reveal"
import { TextStrikethrough } from "@opencode-ai/ui/text-strikethrough"
import { createResizeObserver } from "@solid-primitives/resize-observer"
import { Index, createEffect, createMemo } from "solid-js"
import { Index, Match, Switch, createEffect, createMemo } from "solid-js"
import { Dynamic } from "solid-js/web"
import { createStore } from "solid-js/store"
import { useLanguage } from "@/context/language"
@@ -142,15 +142,16 @@ export function SessionTodoDock(props: {
}}
>
<Index each={progress()}>
{(item) =>
item() === doneToken ? (
<AnimatedNumber value={done()} />
) : item() === totalToken ? (
<AnimatedNumber value={total()} />
) : (
<span>{item()}</span>
)
}
{(item) => (
<Switch fallback={<span>{item()}</span>}>
<Match when={item() === doneToken}>
<AnimatedNumber value={done()} />
</Match>
<Match when={item() === totalToken}>
<AnimatedNumber value={total()} />
</Match>
</Switch>
)}
</Index>
</span>
<div
@@ -1,5 +1,5 @@
// @ts-nocheck
import { createEffect, createMemo, onCleanup } from "solid-js"
import { createEffect, createMemo, For, onCleanup } from "solid-js"
import { createStore } from "solid-js/store"
import type { Todo } from "@/types"
import { useServerSync } from "@/context/global-sync"
@@ -326,11 +326,13 @@ export const Playground = {
<button onClick={cycle} style={btn(step() > 0)}>
Cycle progress ({step()}/3 done)
</button>
{[0, 1, 2, 3].map((value) => (
<button onClick={() => setCfg("step", value)} style={btn(step() === value)}>
{value} done
</button>
))}
<For each={[0, 1, 2, 3]}>
{(value) => (
<button onClick={() => setCfg("step", value)} style={btn(step() === value)}>
{value} done
</button>
)}
</For>
</div>
<div style={{ display: "grid", gap: "10px", "max-width": "560px" }}>
@@ -19,8 +19,8 @@ type ReviewDiff = FileDiffInfo
export interface SessionReviewTabProps {
title?: JSX.Element
empty?: JSX.Element
diffs: () => ReviewDiff[]
view: () => ReturnType<ReturnType<typeof useLayout>["view"]>
diffs: ReviewDiff[]
view: ReturnType<ReturnType<typeof useLayout>["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}
@@ -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)
}}
>
<Show when={reviewTab() && props.canReview()}>
<Show when={reviewTab() && props.canReview}>
<Tabs.Trigger
value="review"
id={reviewTabID}
@@ -356,8 +356,8 @@ export function SessionSidePanel(props: {
>
<div class="flex items-center gap-1.5">
<div>{language.t("session.tab.review")}</div>
<Show when={props.hasReview()}>
<div>{props.reviewCount()}</div>
<Show when={props.hasReview}>
<div>{props.reviewCount}</div>
</Show>
</div>
</Tabs.Trigger>
@@ -463,12 +463,12 @@ export function SessionSidePanel(props: {
</Tabs.List>
</div>
<Show when={reviewTab() && props.canReview() && activeTab() === "review"}>
<Show when={reviewTab() && props.canReview && activeTab() === "review"}>
<div
id={reviewTabPanelID}
role="tabpanel"
aria-labelledby={reviewTabID}
tabIndex={props.reviewHasFocusableContent() ? undefined : 0}
tabIndex={props.reviewHasFocusableContent ? undefined : 0}
data-slot="tabs-content"
class="flex flex-col h-full overflow-hidden contain-strict"
>
@@ -559,14 +559,14 @@ export function SessionSidePanel(props: {
</div>
)}
</Show>
<Show when={reviewTab() && props.canReview()}>
<Show when={reviewTab() && props.canReview}>
<Tabs.Trigger
value="review"
id={reviewTabID}
aria-controls={activeTab() === "review" ? reviewTabPanelID : undefined}
>
{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")}
</Tabs.Trigger>
</Show>
@@ -611,7 +611,7 @@ export function SessionSidePanel(props: {
fallback={
<SortableTabV2
tab={tab}
index={() => 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: {
</div>
</div>
<Show when={reviewTab() && props.canReview() && activeTab() === "review"}>
<Show when={reviewTab() && props.canReview && activeTab() === "review"}>
<div
id={reviewTabPanelID}
role="tabpanel"
aria-labelledby={reviewTabID}
tabIndex={props.reviewHasFocusableContent() ? undefined : 0}
tabIndex={props.reviewHasFocusableContent ? undefined : 0}
data-slot="tabs-content"
class="flex flex-col h-full overflow-hidden contain-strict"
>
@@ -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 })}
</Show>
</Tabs.Trigger>
<Tabs.Trigger value="all" class="flex-1" classes={{ button: "w-full" }}>
@@ -799,9 +799,9 @@ export function SessionSidePanel(props: {
<Show when={fileTreeTab() === "changes"}>
<Tabs.Content value="changes" class="bg-background-stronger px-3 py-0">
<Switch>
<Match when={props.hasReview() || !props.diffsReady()}>
<Match when={props.hasReview || !props.diffsReady}>
<Show
when={props.diffsReady()}
when={props.diffsReady}
fallback={
<div class="px-2 py-2 text-12-regular text-text-weak">
{language.t("common.loading")}
@@ -273,7 +273,7 @@ export function TerminalPanelV2(props: { stacked?: boolean } = {}) {
>
<For each={all()}>
{(pty, index) => (
<SortableTerminalTabV2 terminal={pty} index={index} newLayout={newLayout()} onClose={close} />
<SortableTerminalTabV2 terminal={pty} index={index()} newLayout={newLayout()} onClose={close} />
)}
</For>
<div class="h-full flex items-center justify-center">
@@ -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<HTMLDivElement>()
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<FramedTimelineRow>; 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 (
<div
id={anchor() ? props.anchor(input.row().userMessageID) : undefined}
data-message-id={input.row().userMessageID}
data-timeline-row={input.row()._tag}
id={anchor() ? props.anchor(input.row.userMessageID) : undefined}
data-message-id={input.row.userMessageID}
data-timeline-row={input.row._tag}
classList={{
"min-w-0 w-full max-w-full": true,
"md:max-w-200 2xl:max-w-[1000px]": props.centered,
@@ -748,7 +748,7 @@ function MessageTimelineView(
getMsgParts(commentStripRow().userMessageID).flatMap((part) => MessageComment.fromPart(part) ?? []),
)
return (
<TimelineRowFrame row={commentStripRow}>
<TimelineRowFrame row={commentStripRow()}>
<div class="w-full px-4 md:px-5 pb-2">
<div class="ms-auto max-w-[82%] overflow-x-auto no-scrollbar">
<div class="flex w-max min-w-full justify-end gap-2">
@@ -797,7 +797,7 @@ function MessageTimelineView(
return getMsgParts(userMessageRow().userMessageID).flatMap((part) => MessageComment.fromPart(part) ?? [])
})
return (
<TimelineRowFrame row={userMessageRow}>
<TimelineRowFrame row={userMessageRow()}>
<Show when={message()}>
{(message) => (
<div data-slot="session-turn-message-container" class="w-full px-4 md:px-5">
@@ -819,7 +819,7 @@ function MessageTimelineView(
case "TurnDivider": {
const turnDividerRow = row as Accessor<TimelineRowByTag<"TurnDivider">>
return (
<TimelineRowFrame row={turnDividerRow}>
<TimelineRowFrame row={turnDividerRow()}>
<div data-slot="session-turn-message-container" class="w-full px-4 md:px-5">
<div data-slot="session-turn-compaction">
<MessageDivider
@@ -835,7 +835,7 @@ function MessageTimelineView(
case "AssistantPart": {
const assistantPartRow = row as Accessor<TimelineRowByTag<"AssistantPart">>
return (
<TimelineRowFrame row={assistantPartRow}>
<TimelineRowFrame row={assistantPartRow()}>
<div data-slot="session-turn-message-container" class="w-full px-4 md:px-5">
<div
data-slot="session-turn-assistant-content"
@@ -850,7 +850,7 @@ function MessageTimelineView(
case "Thinking": {
const thinkingRow = row as Accessor<TimelineRowByTag<"Thinking">>
return (
<TimelineRowFrame row={thinkingRow}>
<TimelineRowFrame row={thinkingRow()}>
<div data-slot="session-turn-message-container" class="w-full px-4 md:px-5">
<TimelineThinkingRow
reasoningHeading={thinkingRow().reasoningHeading}
@@ -863,7 +863,7 @@ function MessageTimelineView(
case "Retry": {
const retryRow = row as Accessor<TimelineRowByTag<"Retry">>
return (
<TimelineRowFrame row={retryRow}>
<TimelineRowFrame row={retryRow()}>
<div data-slot="session-turn-message-container" class="w-full px-4 md:px-5">
<SessionRetry status={sessionStatus()} show={activeMessageID() === retryRow().userMessageID} />
</div>
@@ -873,7 +873,7 @@ function MessageTimelineView(
case "DiffSummary": {
const diffSummaryRow = row as Accessor<TimelineRowByTag<"DiffSummary">>
return (
<TimelineRowFrame row={diffSummaryRow}>
<TimelineRowFrame row={diffSummaryRow()}>
<div data-slot="session-turn-message-container" class="w-full px-4 md:px-5">
<TimelineDiffSummaryRow diffs={diffSummaryRow().diffs} />
</div>
@@ -883,7 +883,7 @@ function MessageTimelineView(
case "Error": {
const errorRow = row as Accessor<TimelineRowByTag<"Error">>
return (
<TimelineRowFrame row={errorRow}>
<TimelineRowFrame row={errorRow()}>
<div data-slot="session-turn-message-container" class="w-full px-4 md:px-5">
<Card variant="error" class="error-card">
{errorRow().text}
@@ -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<RenderDiff | undefined>
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<typeof reviewDiffKinds>
activeDiff: () => string | undefined
diffs: RenderDiff[]
filteredFiles: string[]
searching: boolean
kinds: ReturnType<typeof reviewDiffKinds>
activeDiff: string | undefined
}) {
const language = useLanguage()
const [explicitHighlight, setExplicitHighlight] = createSignal<string | undefined>()
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={<DiffChanges changes={props.diffs()} />}
stats={<DiffChanges changes={props.diffs} />}
filter={props.state.filter()}
onFilterChange={props.state.setFilter}
onFilterKeyDown={onFilterKeyDown}
@@ -212,7 +212,7 @@ function ReviewPanelV2Sidebar(props: {
maxWidth={SESSION_REVIEW_V2_SIDEBAR_WIDTH_MAX}
>
<Show
when={props.diffsReady()}
when={props.diffsReady}
fallback={
<div class="px-2 py-2 text-12-regular text-text-weak">
{language.t("common.loading")}
@@ -221,25 +221,25 @@ function ReviewPanelV2Sidebar(props: {
}
>
<Show
when={props.searching()}
when={props.searching}
fallback={
<FileTreeV2
allowed={props.filteredFiles()}
kinds={props.kinds()}
allowed={props.filteredFiles}
kinds={props.kinds}
draggable={false}
active={props.activeDiff()}
active={props.activeDiff}
onFileClick={(node) => props.onSelectFile(node.path)}
/>
}
>
<Show
when={props.filteredFiles().length > 0}
when={props.filteredFiles.length > 0}
fallback={<div class="px-2 py-2 text-12-regular text-text-weak">{language.t("palette.empty")}</div>}
>
<SessionFileListV2
files={props.filteredFiles()}
kinds={props.kinds()}
active={props.activeDiff()}
files={props.filteredFiles}
kinds={props.kinds}
active={props.activeDiff}
highlighted={highlightedPath()}
onFileClick={(path) => {
setExplicitHighlight(path)
+25 -16
View File
@@ -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("-")
}
@@ -447,7 +447,7 @@ function UpdateFooter(props: {
})
return (
<box width="100%" height={4} flexDirection="row" gap={1} live={props.animating()}>
<box width="100%" height={4} flexDirection="row" gap={1} paddingLeft={1} live={props.animating()}>
<Monogram ink={monogramInk} />
<box flexDirection="column" flexGrow={1} overflow="hidden">
<CellLine cells={header()} />
+24 -1
View File
@@ -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<boolean, SessionErrors.NotFoundError>
readonly ask: (input: AssertInput) => Effect.Effect<AskResult, SessionErrors.NotFoundError>
readonly assert: (input: AssertInput) => Effect.Effect<void, Error | SessionErrors.NotFoundError>
readonly reply: (input: ReplyInput) => Effect.Effect<void, NotFoundError>
@@ -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 })
}),
)
+41 -27
View File
@@ -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}`)),
+8
View File
@@ -0,0 +1,8 @@
import { Permission } from "@opencode-ai/core/permission"
import { Effect, Layer } from "effect"
export const permissionLayer = (overrides: Partial<Permission.Interface> = {}) =>
Layer.mock(Permission.Service, {
allowsAll: () => Effect.succeed(false),
...overrides,
})
+11
View File
@@ -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([]),
}),
)
+25
View File
@@ -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" }])
+2 -9
View File
@@ -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],
],
),
+2 -9
View File
@@ -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],
],
),
@@ -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],
+3 -10
View File
@@ -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],
],
),
+3 -10
View File
@@ -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],
],
),
+3 -10
View File
@@ -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],
],
),
@@ -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" },
+2 -11
View File
@@ -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<Record<string, ToolInfo>>, options?: Tool.Options) =>
registry.transform((draft) =>
Object.entries(tools).forEach(([name, tool]) => draft.add({ ...tool, name, options: options ?? tool.options })),
+16 -23
View File
@@ -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<void> => Effect.void
let formatFile = (_target: string): Effect.Effect<boolean> => 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),
+20 -27
View File
@@ -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<void> => Effect.void
let formatFile = (_target: string): Effect.Effect<boolean> => 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),
+16 -23
View File
@@ -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({
+18 -25
View File
@@ -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(
+7 -14
View File
@@ -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 = <A, E, R>(
],
[
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)
}),
}),
],
]),
),
+44 -24
View File
@@ -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<string>()
let denyAction: string | undefined
let afterPermission = (_input: Permission.AssertInput): Effect.Effect<void> => 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",
() =>
+16 -23
View File
@@ -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({
+2 -11
View File
@@ -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],
+4 -11
View File
@@ -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({
+16 -23
View File
@@ -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<boolean> => 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),
+7 -14
View File
@@ -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[]) {
-28
View File
@@ -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") {
+39 -17
View File
@@ -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)
+6
View File
@@ -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
}
+50 -33
View File
@@ -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)
+14 -3
View File
@@ -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"))
+38
View File
@@ -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 })
}
}
-33
View File
@@ -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<string, WslDistroProbe>,
opencodeChecks: Record<string, WslOpencodeCheck>,
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}`)
}
+33 -16
View File
@@ -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<WslDistroProbe> {
@@ -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<void>((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, `'"'"'`)}'`
}
+100 -163
View File
@@ -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<typeof createWslServersController>[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<never>(() => 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<string, () => void>()
const opencode: string[] = []
const controller = createWslServersController("1.16.2", async () => new Promise<never>(() => undefined), {
...testControllerOptions(),
probeDistro: async (distro) => {
started.push(distro)
await new Promise<void>((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<void>((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<never>(() => 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> = {}): 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<void>((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<never>(() => undefined)
+104 -211
View File
@@ -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<void>
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<typeof createWslServersController>
@@ -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<string, RunningSidecar>()
const startAttempts = new Map<string, number>()
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 <T>(job: WslJob, runner: (abort: AbortController) => Promise<T>) => {
const abort = beginJob(job)
const runJob = async <T>(job: WslJob, runner: () => Promise<T>) => {
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<WslServerConfig> {
@@ -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,
}
+19 -8
View File
@@ -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<void>
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<WslSidecar> {
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<void>((resolve) => {
child.once("exit", () => resolve())
child.kill()
})
},
onExit: (cb) => child.once("exit", cb),
url,
username,
password,
}
}
async function pollWslHealth(check: () => Promise<boolean>, signal: AbortSignal) {
while (!signal.aborted) {
if (await check()) return
await new Promise((resolve) => setTimeout(resolve, 100))
}
}
function allocatePort() {
return new Promise<number>((resolve, reject) => {
const server = createServer()
-37
View File
@@ -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<boolean>, signal: AbortSignal, interval = 100) {
while (!signal.aborted) {
if (await check()) return
await abortableDelay(interval, signal)
}
}
function abortableDelay(duration: number, signal: AbortSignal) {
return new Promise<void>((resolve) => {
const done = () => {
clearTimeout(timeout)
signal.removeEventListener("abort", done)
resolve()
}
const timeout = setTimeout(done, duration)
signal.addEventListener("abort", done, { once: true })
})
}
+7 -3
View File
@@ -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())
+1 -1
View File
@@ -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: {
@@ -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("<summary>Annotation data</summary>")
expect(body).toContain('"row": 4')
})
})
+37 -1
View File
@@ -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"')
}
})
})
+2 -1
View File
@@ -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",
+209
View File
@@ -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<FrameSpan> }>
}
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`)
+6 -4
View File
@@ -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) {
</main>
{ui.viewerOpen && selectedScreen ? (
<Viewer
key={`${selectedScreen.id}:${activeVariant.id}`}
screen={selectedScreen}
identifier={
ui.mode === "flows" && activeFlow?.replayable ? `${activeFlow.id}/${selectedScreen.id}` : selectedScreen.id
+82
View File
@@ -0,0 +1,82 @@
export interface Annotation {
readonly id: string
readonly row: number
readonly column: number
readonly note: string
}
export interface AnnotationDocument {
readonly version: 1
readonly identifier: string
readonly variant: string
readonly annotations: ReadonlyArray<Annotation>
}
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<Annotation> {
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<Annotation> {
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<AnnotationDocument>
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<Annotation> {
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,
)
}
@@ -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<Annotation>
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<Draft>()
const textareaRef = useRef<HTMLTextAreaElement>(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 (
<>
<div
className="annotation-layer"
aria-label="Click the terminal to add an annotation"
onPointerDown={(event) => {
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) => (
<button
key={annotation.id}
type="button"
className={`annotation-pin${annotation.id === draft?.id ? " selected" : ""}`}
style={{
left: `${((annotation.column + 0.5) / props.cols) * 100}%`,
top: `${((annotation.row + 0.5) / props.rows) * 100}%`,
}}
aria-label={`Edit annotation ${index + 1}, row ${annotation.row + 1}, column ${annotation.column + 1}`}
onPointerDown={(event) => event.stopPropagation()}
onClick={() => edit(annotation)}
>
{index + 1}
</button>
))}
{draft ? (
<div
className={`annotation-composer${draft.row > 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()}
>
<header>
<span>{draft.id ? "Edit annotation" : "New annotation"}</span>
<small>
R{draft.row + 1} · C{draft.column + 1}
</small>
</header>
<textarea
ref={textareaRef}
name="annotation-note"
aria-label="Annotation note"
value={draft.note}
maxLength={2_000}
rows={2}
placeholder="What should change?"
onChange={(event) => setDraft({ ...draft, note: event.target.value })}
onKeyDown={(event) => {
if (event.nativeEvent.isComposing) return
if (event.key === "Enter" && !event.shiftKey) {
event.preventDefault()
save()
}
if (event.key === "Escape") {
setDraft(undefined)
}
}}
/>
<footer>
{draft.id ? (
<button
type="button"
className="annotation-composer-delete"
onClick={() => {
if (draft.id) props.onDelete(draft.id)
setDraft(undefined)
}}
>
Delete
</button>
) : (
<span />
)}
<button type="button" onClick={() => setDraft(undefined)}>
Cancel
</button>
<button type="button" className="annotation-composer-save" disabled={!draft.note.trim()} onClick={save}>
{draft.id ? "Save" : "Add"}
</button>
</footer>
</div>
) : undefined}
</div>
<aside className="annotation-panel" aria-label="Capture annotations">
<header>
<div>
<strong>Annotations</strong>
<span>
{props.annotations.length === 0 ? "Click anywhere on the terminal" : `${props.annotations.length} placed`}
</span>
</div>
<button type="button" onClick={props.onDone}>
Done
</button>
</header>
<div className="annotation-list">
{props.annotations.map((annotation, index) => (
<button key={annotation.id} type="button" className="annotation-list-row" onClick={() => edit(annotation)}>
<span className="annotation-list-pin">{index + 1}</span>
<span>
<small>
Row {annotation.row + 1} · Column {annotation.column + 1}
</small>
<strong>{annotation.note}</strong>
</span>
</button>
))}
</div>
<footer>
<a
className="annotation-issue"
href={complete.length === 0 ? undefined : props.issueLink}
target="_blank"
rel="noreferrer"
aria-disabled={complete.length === 0}
>
Open GitHub issue
</a>
<span>
{complete.length === 0
? "Add a note to continue"
: `${complete.length} note${complete.length === 1 ? "" : "s"} will be included`}
</span>
</footer>
</aside>
</>
)
}
@@ -8,8 +8,7 @@ interface CaptureSetSwitcherProps {
export function CaptureSetSwitcher({ sets, active, onSelect }: CaptureSetSwitcherProps) {
return (
<label className="variant-switcher" title={active.label}>
<span className="sr-only">Theme</span>
<label className="variant-switcher" title="Switch theme">
<select aria-label="Select theme" value={active.id} onChange={(event) => onSelect(event.target.value)}>
{sets.map((set) => (
<option key={set.id} value={set.id}>
@@ -17,8 +16,14 @@ export function CaptureSetSwitcher({ sets, active, onSelect }: CaptureSetSwitche
</option>
))}
</select>
<span className="variant-hint" aria-hidden="true">
Theme
</span>
<span className="variant-name" aria-hidden="true">
{active.label}
</span>
<span className="variant-chevron" aria-hidden="true">
</span>
</label>
)
+88 -1
View File
@@ -1,4 +1,4 @@
import { useEffect, useEffectEvent, useRef } from "react"
import { useEffect, useEffectEvent, useRef, useState } from "react"
import type { Facet, Filter, Screen, Taxonomy, TaxonomyGroup, Variant } from "../catalog"
import { facetValues, frameFor, label, taxonomyLabel } from "../catalog"
import { TerminalFrame } from "./TerminalFrame"
@@ -6,6 +6,14 @@ import { CaptureSetSwitcher } from "./CaptureSetSwitcher"
import { CaptureContextMenu } from "./CaptureContextMenu"
import { feedbackIssueUrl } from "../feedback"
import { CaptureActionsMenu } from "./CaptureActionsMenu"
import {
annotationUrl,
readAnnotationDraft,
readAnnotations,
type Annotation,
type AnnotationDocument,
} from "../annotations"
import { AnnotationEditor } from "./AnnotationEditor"
interface ViewerProps {
readonly screen: Screen
@@ -50,17 +58,65 @@ export function Viewer({
const frame = frameFor(screen, variant.id)
if (!frame) throw new Error(`Capture ${screen.id} is unavailable in set ${variant.id}`)
const issueLink = feedbackIssueUrl({ title: screen.title, identifier, deepLink, variant: variant.id })
const storageKey = `catalog-annotations:${identifier}:${variant.id}`
const [annotating, setAnnotating] = useState(() => window.location.hash.startsWith("#annotations="))
const [annotations, setAnnotations] = useState<ReadonlyArray<Annotation>>(() => {
const linked = readAnnotations(new URL(window.location.href), identifier, variant.id)
if (linked.length > 0)
return linked.filter((annotation) => annotation.row < frame.rows && annotation.column < frame.cols)
try {
const stored = localStorage.getItem(storageKey)
if (!stored) return []
return readAnnotationDraft(stored).filter(
(annotation) => annotation.row < frame.rows && annotation.column < frame.cols,
)
} catch {
return []
}
})
const document: AnnotationDocument = { version: 1, identifier, variant: variant.id, annotations }
const annotatedLink = annotationUrl(deepLink, document)
const completeAnnotations = annotations.filter((annotation) => annotation.note.trim() !== "")
const issueDocument = { ...document, annotations: completeAnnotations }
const annotationIssueLink = feedbackIssueUrl({
title: screen.title,
identifier,
deepLink: annotationUrl(deepLink, issueDocument),
variant: variant.id,
annotations: completeAnnotations,
document: issueDocument,
})
useEffect(() => {
localStorage.setItem(storageKey, JSON.stringify(annotations))
if (annotating) window.history.replaceState(null, "", annotations.length > 0 ? annotatedLink : deepLink)
}, [annotatedLink, annotating, annotations, deepLink, storageKey])
useEffect(() => {
dialogRef.current?.showModal()
}, [])
const handleKeyDown = useEffectEvent((event: KeyboardEvent) => {
const editing =
event.target instanceof HTMLInputElement ||
event.target instanceof HTMLTextAreaElement ||
(event.target instanceof HTMLElement && event.target.isContentEditable)
if (editing && event.key !== "Escape") return
if (event.key === "Escape") {
event.preventDefault()
if (annotating) {
setAnnotating(false)
return
}
onClose()
return
}
if (event.key.toLowerCase() === "a" && !event.metaKey && !event.ctrlKey && !event.altKey) {
event.preventDefault()
setAnnotating((value) => !value)
return
}
if (annotating) return
if (event.key === "ArrowLeft" || event.key === "ArrowRight") {
event.preventDefault()
onNavigate(event.key === "ArrowLeft" ? -1 : 1)
@@ -107,6 +163,15 @@ export function Viewer({
</button>
</span>
<div className="viewer-actions">
<button
type="button"
className={`viewer-button${annotating ? " active" : ""}`}
onClick={() => setAnnotating((value) => !value)}
title="Toggle annotation mode (A)"
>
Annotate
{annotations.length > 0 ? <span className="viewer-button-count">{annotations.length}</span> : undefined}
</button>
<CaptureActionsMenu identifier={identifier} deepLink={deepLink} issueLink={issueLink} />
<CaptureSetSwitcher sets={variants} active={variant} onSelect={onVariantSelect} />
</div>
@@ -117,6 +182,28 @@ export function Viewer({
<CaptureContextMenu identifier={identifier} deepLink={deepLink} issueLink={issueLink}>
<div className="viewer-image-wrap">
<TerminalFrame frame={frame} label={`${screen.title}, ${variant.label}`} />
{annotating ? (
<AnnotationEditor
cols={frame.cols}
rows={frame.rows}
annotations={annotations}
onAdd={(row, column, note) => {
if (annotations.length >= 24) return
const annotation = { id: crypto.randomUUID(), row, column, note }
setAnnotations([...annotations, annotation])
}}
onChange={(id, note) =>
setAnnotations(
annotations.map((annotation) => (annotation.id === id ? { ...annotation, note } : annotation)),
)
}
onDelete={(id) => setAnnotations(annotations.filter((annotation) => annotation.id !== id))}
onDone={() => {
setAnnotating(false)
}}
issueLink={annotationIssueLink}
/>
) : undefined}
</div>
</CaptureContextMenu>
<figcaption className="viewer-caption">
+24 -3
View File
@@ -1,8 +1,12 @@
import type { Annotation, AnnotationDocument } from "./annotations"
interface FeedbackIssue {
readonly title: string
readonly identifier: string
readonly deepLink: string
readonly variant: string
readonly annotations?: ReadonlyArray<Annotation>
readonly document?: AnnotationDocument
}
export function feedbackIssueUrl(issue: FeedbackIssue) {
@@ -12,15 +16,32 @@ export function feedbackIssueUrl(issue: FeedbackIssue) {
url.searchParams.set(
"body",
[
"## Feedback",
"",
"<!-- What looks wrong, confusing, or could be improved? -->",
...(issue.annotations?.length
? issue.annotations.flatMap((annotation, index) => [
`## ${index + 1}. Row ${annotation.row + 1}, column ${annotation.column + 1}`,
"",
annotation.note.trim(),
"",
])
: ["## Feedback", "", "<!-- What looks wrong, confusing, or could be improved? -->", ""]),
"",
"## Catalog state",
"",
`- Screen: \`${issue.identifier}\``,
`- Theme: \`${issue.variant}\``,
`- Link: ${issue.deepLink}`,
...(issue.document
? [
"",
"<details>",
"<summary>Annotation data</summary>",
"",
"```json",
JSON.stringify(issue.document, null, 2),
"```",
"</details>",
]
: []),
].join("\n"),
)
return url.href
+340 -14
View File
@@ -114,7 +114,7 @@ a {
}
:focus-visible {
outline: 1px solid var(--kit-accent);
outline: 1px solid var(--kit-fg-faint);
outline-offset: -1px;
}
@@ -222,12 +222,17 @@ kbd {
}
.catalog-tabs button:focus-visible,
.viewer-button:focus-visible,
.command-trigger:focus-visible {
outline: 1px solid var(--catalog-accent);
outline: 1px solid var(--kit-fg-faint);
outline-offset: -1px;
}
.viewer-button:focus-visible {
outline: 0;
background: var(--kit-bg-hover);
color: var(--kit-fg-strong);
}
.catalog-tools {
display: flex;
height: 100%;
@@ -692,20 +697,35 @@ kbd {
}
.variant-switcher select {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
appearance: none;
border: 0;
padding: 0;
background: transparent;
color: inherit;
font-family: inherit;
font-size: inherit;
opacity: 0;
outline: 0;
cursor: pointer;
}
.variant-switcher .variant-chevron {
.variant-switcher:has(select:focus-visible) {
outline: 1px solid var(--kit-fg-faint);
outline-offset: -1px;
}
.variant-switcher .variant-hint {
color: var(--kit-fg-faint);
font-size: 0.58rem;
}
.variant-switcher .variant-name {
color: inherit;
}
.variant-switcher .variant-chevron {
margin-left: -0.15rem;
color: var(--kit-fg-faint);
font-size: 0.55rem;
}
.capture-open:hover .capture-frame,
@@ -718,7 +738,7 @@ kbd {
}
.capture-open:focus-visible .capture-frame {
outline: 1px solid var(--catalog-accent);
outline: 1px solid var(--kit-fg-faint);
outline-offset: 0.3rem;
}
@@ -746,6 +766,11 @@ kbd {
letter-spacing: 0.08em;
list-style: none;
cursor: pointer;
user-select: none;
}
.capture-actions > summary:focus:not(:focus-visible) {
outline: 0;
}
.capture-actions > summary::-webkit-details-marker {
@@ -1071,7 +1096,7 @@ kbd {
}
.flow-open:focus-visible .flow-frame {
outline: 1px solid var(--catalog-accent);
outline: 1px solid var(--kit-fg-faint);
outline-offset: 0.3rem;
}
@@ -1160,7 +1185,7 @@ kbd {
.viewer-header > .viewer-button:first-child {
justify-self: start;
border-right: 1px solid var(--kit-line);
padding-inline: 1.1rem;
}
.viewer-position {
@@ -1175,11 +1200,12 @@ kbd {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 0.35rem;
padding-right: 0.75rem;
}
.viewer-actions .capture-actions {
align-self: center;
margin-inline: 0.4rem;
}
.viewer-button {
@@ -1188,8 +1214,36 @@ kbd {
gap: 0.5rem;
}
.viewer-actions .variant-switcher {
border-left: 0;
min-height: 1.9rem;
padding: 0 0.8rem;
}
.viewer-button kbd {
padding: 0;
border: 0;
background: transparent;
color: var(--kit-fg-faint);
}
.viewer-button-count {
display: inline-grid;
min-width: 1rem;
height: 1rem;
padding: 0 0.28rem;
border-radius: 999px;
place-items: center;
background: var(--catalog-mark);
color: var(--catalog-mark-ink);
font-size: 0.56rem;
font-weight: 700;
line-height: 1;
}
.viewer-actions .viewer-button {
border-left: 1px solid var(--kit-line);
align-self: center;
min-height: 1.9rem;
}
.viewer-body {
@@ -1235,6 +1289,259 @@ kbd {
-webkit-user-drag: none;
}
.annotation-layer {
position: absolute;
inset: 0;
cursor: crosshair;
}
.annotation-pin,
.annotation-list-pin {
display: grid;
place-items: center;
border: 2px solid #17120a;
border-radius: 999px;
background: var(--catalog-mark);
color: var(--catalog-mark-ink);
font-family: var(--kit-mono);
font-size: 0.65rem;
font-weight: 750;
line-height: 1;
box-shadow: 0 2px 10px rgb(0 0 0 / 60%);
}
.annotation-pin {
position: absolute;
width: 1.55rem;
height: 1.55rem;
transform: translate(-50%, -50%);
}
.annotation-pin:hover,
.annotation-pin:focus-visible,
.annotation-pin.selected {
outline: 2px solid #fff2d8;
outline-offset: 2px;
}
.annotation-composer {
position: absolute;
z-index: 6;
display: grid;
width: 18rem;
gap: 0.55rem;
padding: 0.75rem;
transform: translate(-50%, 1.2rem);
border-radius: 0.85rem;
background: #1a1a1a;
box-shadow:
0 12px 40px rgb(0 0 0 / 55%),
0 0 0 1px rgb(255 255 255 / 9%);
cursor: default;
animation: annotation-composer-in 150ms cubic-bezier(0.34, 1.56, 0.64, 1);
}
.annotation-composer.above {
transform: translate(-50%, calc(-100% - 1.2rem));
}
.annotation-composer header,
.annotation-composer footer {
display: flex;
align-items: center;
gap: 0.35rem;
}
.annotation-composer header {
justify-content: space-between;
color: var(--kit-fg-faint);
font-family: var(--kit-mono);
font-size: 0.62rem;
}
.annotation-composer header small {
font-size: 0.54rem;
}
.annotation-composer textarea {
width: 100%;
resize: none;
border: 1px solid rgb(255 255 255 / 14%);
border-radius: 0.5rem;
outline: none;
padding: 0.55rem 0.65rem;
background: rgb(255 255 255 / 5%);
color: var(--kit-fg-strong);
font-family: var(--kit-sans);
font-size: 0.8rem;
line-height: 1.45;
}
.annotation-composer textarea:focus {
border-color: var(--catalog-mark);
}
.annotation-composer footer {
justify-content: flex-end;
}
.annotation-composer footer button {
min-height: 1.8rem;
padding: 0 0.65rem;
border-radius: 0.45rem;
color: var(--kit-fg-muted);
font-family: var(--kit-sans);
font-size: 0.7rem;
}
.annotation-composer footer > :first-child {
margin-right: auto;
}
.annotation-composer .annotation-composer-delete {
color: #ff8585;
}
.annotation-composer .annotation-composer-save {
background: var(--catalog-mark);
color: var(--catalog-mark-ink);
font-weight: 700;
}
.annotation-composer .annotation-composer-save:disabled {
opacity: 0.4;
}
@keyframes annotation-composer-in {
from {
opacity: 0;
scale: 0.96;
}
}
.annotation-panel {
position: fixed;
z-index: 4;
top: 3rem;
right: 0;
bottom: 0;
display: grid;
width: min(22rem, 34vw);
grid-template-rows: auto minmax(0, 1fr) auto;
border-left: 1px solid var(--kit-line-strong);
background: #0b0b0b;
box-shadow: -24px 0 64px rgb(0 0 0 / 35%);
}
.annotation-panel > header,
.annotation-panel > footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding: 0.85rem 1rem;
}
.annotation-panel > header {
border-bottom: 1px solid var(--kit-line);
}
.annotation-panel > header div {
display: grid;
gap: 0.18rem;
}
.annotation-panel strong,
.annotation-panel > header button,
.annotation-panel > footer button {
font-family: var(--kit-mono);
font-size: 0.68rem;
}
.annotation-panel > header span,
.annotation-panel > footer span,
.annotation-list section > div > span {
color: var(--kit-fg-faint);
font-family: var(--kit-mono);
font-size: 0.56rem;
}
.annotation-panel > header button {
padding: 0.4rem 0.55rem;
color: var(--kit-fg-muted);
}
.annotation-list {
overflow-y: auto;
}
.annotation-list-row {
display: grid;
width: 100%;
grid-template-columns: 1.65rem minmax(0, 1fr);
align-items: center;
gap: 0.65rem;
padding: 0.9rem 1rem;
border-bottom: 1px solid var(--kit-line);
text-align: left;
}
.annotation-list-pin {
width: 1.55rem;
height: 1.55rem;
}
.annotation-list-row > span:last-child {
display: grid;
min-width: 0;
gap: 0.2rem;
}
.annotation-list-row small {
color: var(--kit-fg-faint);
font-family: var(--kit-mono);
font-size: 0.53rem;
}
.annotation-list-row strong {
overflow: hidden;
color: var(--kit-fg-muted);
font-size: 0.72rem;
font-weight: 450;
line-height: 1.45;
text-overflow: ellipsis;
white-space: nowrap;
}
.annotation-list-row:hover,
.annotation-list-row:focus-visible {
background: rgb(255 255 255 / 3%);
}
.annotation-panel > footer {
align-items: stretch;
flex-direction: column;
gap: 0.45rem;
border-top: 1px solid var(--kit-line);
}
.annotation-issue {
display: grid;
place-items: center;
min-height: 2.3rem;
padding: 0 0.85rem;
background: var(--catalog-mark);
color: var(--catalog-mark-ink);
font-weight: 700;
}
.annotation-issue[aria-disabled="true"] {
background: var(--kit-bg-hover);
color: var(--kit-fg-faint);
cursor: not-allowed;
pointer-events: none;
}
.viewer-variant {
display: flex;
align-items: center;
@@ -1494,6 +1801,25 @@ kbd {
}
@media (max-width: 760px) {
.annotation-panel {
top: auto;
width: 100%;
height: min(48dvh, 25rem);
border-top: 1px solid var(--kit-line-strong);
border-left: 0;
box-shadow: 0 -24px 64px rgb(0 0 0 / 45%);
}
.annotation-pin {
width: 1.9rem;
height: 1.9rem;
font-size: 0.75rem;
}
.annotation-composer textarea {
font-size: 1rem;
}
:root {
--catalog-header-height: 10.5rem;
}
+68 -2
View File
@@ -2,11 +2,43 @@ interface Env {
readonly ASSETS: { fetch(request: Request): Promise<Response> }
}
interface CatalogIndex {
readonly variants: ReadonlyArray<{ readonly id: string; readonly label: string }>
readonly screens: ReadonlyArray<{
readonly id: string
readonly title: string
readonly summary: string
readonly frames: ReadonlyArray<{ readonly variantId: string }>
}>
}
let catalogCache: Promise<CatalogIndex> | undefined
export default {
async fetch(request: Request, env: Env): Promise<Response> {
const url = new URL(request.url)
url.pathname = assetPath(url.pathname)
return env.ASSETS.fetch(new Request(url, request))
const path = assetPath(url.pathname)
const assetUrl = new URL(url)
assetUrl.pathname = path
const response = await env.ASSETS.fetch(new Request(assetUrl, request))
if (path !== "/index.html" || !response.ok) return response
catalogCache ??= env.ASSETS.fetch(new Request(new URL("/catalog.json", url.origin)))
.then((asset) => asset.json() as Promise<CatalogIndex>)
.catch((cause) => {
catalogCache = undefined
throw cause
})
const catalog = await catalogCache.catch(() => undefined)
if (!catalog) return response
const html = await response.text()
const headers = new Headers(response.headers)
headers.delete("content-length")
return new Response(html.replace("</head>", `${metaTags(url, catalog)}</head>`), {
status: response.status,
headers,
})
},
}
@@ -14,3 +46,37 @@ export function assetPath(pathname: string) {
const path = pathname.slice("/lab/catalog".length)
return path === "" || path === "/" || !path.includes(".") ? "/index.html" : path
}
export function metaTags(url: URL, catalog: CatalogIndex) {
const screen = catalog.screens.find((candidate) => candidate.id === url.searchParams.get("screen"))
const variantId = url.searchParams.get("set") ?? catalog.variants[0]?.id
const variant = catalog.variants.find((candidate) => candidate.id === variantId)
const captured = screen && variant && screen.frames.some((frame) => frame.variantId === variant.id)
const title = captured ? `${screen.title} — OpenCode Terminal Catalog` : "OpenCode Terminal Catalog"
const description = captured
? screen.summary || `The ${screen.title} state of the OpenCode TUI, captured live in the ${variant.label} theme.`
: "A visual catalog of OpenCode TUI states, captured from the live terminal."
const image = `${url.origin}/lab/catalog/og/${captured ? `${screen.id}--${variant.id}` : "default"}.png`
const tags: ReadonlyArray<readonly [string, string]> = [
["og:title", title],
["og:description", description],
["og:type", "website"],
["og:url", url.href],
["og:image", image],
["og:image:width", "1200"],
["og:image:height", "630"],
]
return [
...tags.map(([property, content]) => `<meta property="${property}" content="${escapeAttribute(content)}" />`),
`<meta name="twitter:card" content="summary_large_image" />`,
`<meta name="twitter:title" content="${escapeAttribute(title)}" />`,
`<meta name="twitter:image" content="${escapeAttribute(image)}" />`,
`<meta name="description" content="${escapeAttribute(description)}" />`,
].join("\n ")
}
function escapeAttribute(value: string) {
return value.replaceAll("&", "&amp;").replaceAll('"', "&quot;").replaceAll("<", "&lt;")
}
+2
View File
@@ -1,11 +1,13 @@
import type { MermaidDiagramKind } from "./diagnostics.js"
import { isMermaidFlowchartDiagram } from "./flowchart/parser.js"
import { isMermaidGitGraphDiagram } from "./gitgraph/parser.js"
import { isMermaidSequenceDiagram } from "./sequence/parser.js"
import { isMermaidStateDiagram } from "./state/parser.js"
import { isMermaidTimelineDiagram } from "./timeline/parser.js"
export function detectMermaidDiagram(content: string): MermaidDiagramKind | undefined {
if (isMermaidFlowchartDiagram(content)) return "flowchart"
if (isMermaidGitGraphDiagram(content)) return "gitGraph"
if (isMermaidSequenceDiagram(content)) return "sequence"
if (isMermaidStateDiagram(content)) return "state"
if (isMermaidTimelineDiagram(content)) return "timeline"

Some files were not shown because too many files have changed in this diff Show More