diff --git a/packages/drive/test/simulation/direct-cli.test.ts b/packages/drive/test/simulation/direct-cli.test.ts deleted file mode 100644 index 1e463da8685..00000000000 --- a/packages/drive/test/simulation/direct-cli.test.ts +++ /dev/null @@ -1,129 +0,0 @@ -import { expect, test } from "vitest" -import { mkdtemp, rm } from "node:fs/promises" -import { tmpdir } from "node:os" -import { join, resolve } from "node:path" - -const state = { - focused: { renderable: 1, editor: true }, - elements: [], -} - -test.sequential("CLI drives an externally owned OpenCode endpoint on the default port", async () => { - const root = await mkdtemp(join(tmpdir(), "opencode-drive-direct-test-")) - const requests: unknown[] = [] - const server = Bun.serve({ - hostname: "127.0.0.1", - port: 40900, - fetch(request, server) { - if (server.upgrade(request)) return - return new Response("external OpenCode simulation endpoint", { - status: 426, - }) - }, - websocket: { - message(socket, message) { - const request = JSON.parse(String(message)) as { - readonly id: number - readonly method: string - } - if (request.method === "simulation.handshake") { - socket.send( - JSON.stringify({ - jsonrpc: "2.0", - id: request.id, - error: { code: -32601, message: "method not found" }, - }), - ) - return - } - requests.push(request) - socket.send( - JSON.stringify({ - jsonrpc: "2.0", - id: request.id, - result: request.method === "ui.screenshot" ? "/tmp/home.png" : state, - }), - ) - }, - }, - }) - - try { - const first = await sendState(root) - expect(first.status).toBe(0) - expect(JSON.parse(first.stdout)).toEqual(state) - - const second = await sendState(root) - expect(second.status).toBe(0) - expect(JSON.parse(second.stdout)).toEqual(state) - - const screenshot = await send(root, ["--command.ui.screenshot", '{"name":"home"}']) - expect(screenshot.status).toBe(0) - expect(screenshot.stdout.trim()).toBe("/tmp/home.png") - - const ctrlTab = await send(root, ["--command.ui.press", '{"key":"tab","modifiers":{"ctrl":true}}']) - expect(ctrlTab.status).toBe(0) - - const right = await send(root, ["--command.ui.press", '{"key":"right"}']) - expect(right.status).toBe(0) - - const altDown = await send(root, ["--command.ui.press", '{"key":"down","modifiers":{"meta":true}}']) - expect(altDown.status).toBe(0) - - const invalidAlt = await send(root, ["--command.ui.press", '{"key":"down","modifiers":{"alt":true}}']) - expect(invalidAlt.status).toBe(1) - expect(invalidAlt.stderr).toContain("alt") - expect(invalidAlt.stderr).toContain("Unexpected key with value true") - - expect(requests).toEqual([ - { jsonrpc: "2.0", id: 1, method: "ui.state" }, - { jsonrpc: "2.0", id: 1, method: "ui.state" }, - { jsonrpc: "2.0", id: 1, method: "ui.screenshot", params: { name: "home" } }, - { - jsonrpc: "2.0", - id: 1, - method: "ui.press", - params: { key: "\u001b[9;5u" }, - }, - { - jsonrpc: "2.0", - id: 1, - method: "ui.press", - params: { key: "\u001b[C" }, - }, - { - jsonrpc: "2.0", - id: 1, - method: "ui.press", - params: { key: "\u001b[1;3B" }, - }, - ]) - } finally { - await server.stop(true) - await rm(root, { recursive: true, force: true }) - } -}) - -async function sendState(root: string) { - return send(root, ["--command.ui.state"]) -} - -async function send(root: string, args: string[]) { - const child = Bun.spawn([process.execPath, resolve("src/cli/index.ts"), "send", ...args], { - cwd: resolve("."), - env: { - ...process.env, - DRIVE_REGISTRY_DIR: join(root, "registry"), - TMPDIR: root, - }, - stdin: "ignore", - stdout: "pipe", - stderr: "pipe", - }) - const [status, stdout, stderr] = await Promise.all([ - child.exited, - new Response(child.stdout).text(), - new Response(child.stderr).text(), - ]) - return { status, stdout, stderr } -} diff --git a/packages/tui/src/component/dialog-move-session.tsx b/packages/tui/src/component/dialog-move-session.tsx index 5e20da6f001..285a85a5259 100644 --- a/packages/tui/src/component/dialog-move-session.tsx +++ b/packages/tui/src/component/dialog-move-session.tsx @@ -31,6 +31,7 @@ type DialogMoveSessionProps = { onSelect: (selection: MoveSessionSelection) => void onCurrentChange?: (selection: MoveSessionSelection) => void initialDirectories?: ReadonlyArray + fixture?: boolean initialRemoving?: string } @@ -75,7 +76,7 @@ export function DialogMoveSession(props: DialogMoveSessionProps) { }) const [directories, { refetch }] = createResource( - () => (props.initialRemoving ? undefined : props.projectID), + () => (props.fixture || props.initialRemoving ? undefined : props.projectID), async (projectID, info): Promise | undefined> => { try { const requestLocation = { directory: location()?.directory || paths.cwd } @@ -110,11 +111,9 @@ export function DialogMoveSession(props: DialogMoveSessionProps) { if (showError()) return const directory = currentDirectory() if (!directory) return - return ( - directoryData() - ?.filter((root) => contains(root.directory, directory)) - .toSorted((a, b) => b.directory.length - a.directory.length)[0] ?? { directory } - ) + return directoryData() + ?.filter((root) => contains(root.directory, directory)) + .toSorted((a, b) => b.directory.length - a.directory.length)[0] }) const options = createMemo[]>(() => { @@ -123,7 +122,6 @@ export function DialogMoveSession(props: DialogMoveSessionProps) { const current = currentRoot()?.directory if (directories.loading && !data && !current) return [] const roots = [...(data ?? [])] - if (current && !roots.some((item) => item.directory === current)) roots.unshift({ directory: current }) roots.sort((a, b) => { if (a.directory === current) return -1 if (b.directory === current) return 1 @@ -139,15 +137,13 @@ export function DialogMoveSession(props: DialogMoveSessionProps) { (session) => session.projectID === props.projectID && session.subpath && ![".", "/"].includes(session.subpath), ) .map((session) => session.location.directory) + .filter((directory) => currentRoot() || directory !== currentDirectory()) .filter((directory) => !roots.some((root) => root.directory === directory)) .filter((directory, index, directories) => directories.indexOf(directory) === index) .map((location) => ({ location, root: roots - .filter((root) => { - const relative = path.relative(root.directory, location) - return relative && relative !== ".." && !relative.startsWith(".." + path.sep) && !path.isAbsolute(relative) - }) + .filter((root) => contains(root.directory, location)) .toSorted((a, b) => b.directory.length - a.directory.length)[0], })) .filter((item): item is { location: string; root: ProjectDirectory } => item.root !== undefined) @@ -325,6 +321,7 @@ export function DialogMoveSession(props: DialogMoveSessionProps) { } renderFilter={!showError()} + flat={true} options={options()} emptyView={ showError() ? ( @@ -357,7 +354,7 @@ export function DialogMoveSession(props: DialogMoveSessionProps) { }} onMove={() => setToDelete(undefined)} actions={ - showError() + showError() || props.fixture ? [] : [ { diff --git a/packages/tui/src/context/location.tsx b/packages/tui/src/context/location.tsx index 141d4b89d94..54ae4b34057 100644 --- a/packages/tui/src/context/location.tsx +++ b/packages/tui/src/context/location.tsx @@ -7,6 +7,7 @@ const context = createContext<{ readonly current: LocationGetOutput | undefined // The target location as set, available before the server-synced info in `current` arrives. readonly ref: LocationRef | undefined + readonly error: { readonly location: LocationRef; readonly cause: unknown } | undefined set: (location?: LocationRef) => void }>() @@ -14,16 +15,29 @@ export function LocationProvider(props: ParentProps) { const client = useClient() const data = useData() const [ref, setRef] = createSignal() + const [error, setError] = createSignal<{ readonly location: LocationRef; readonly cause: unknown }>() + let generation = 0 const current = createMemo(() => data.location.info(ref())) function sync(location?: LocationRef) { if (!location) return + const attempt = ++generation const defaultLocation = data.location.default() const target = location.directory === defaultLocation.directory && location.workspaceID === defaultLocation.workspaceID ? undefined : location - void data.location.sync(target).catch(() => undefined) + setError(undefined) + void data.location.sync(target).catch((cause) => { + const current = ref() + if ( + generation !== attempt || + current?.directory !== location.directory || + current.workspaceID !== location.workspaceID + ) + return + setError({ location, cause }) + }) } function set(location?: LocationRef) { @@ -42,6 +56,9 @@ export function LocationProvider(props: ParentProps) { get ref() { return ref() }, + get error() { + return error() + }, set, }} > diff --git a/packages/tui/src/feature-plugins/system/storybook/index.tsx b/packages/tui/src/feature-plugins/system/storybook/index.tsx index b2f35e4874a..f6c38ecfc48 100644 --- a/packages/tui/src/feature-plugins/system/storybook/index.tsx +++ b/packages/tui/src/feature-plugins/system/storybook/index.tsx @@ -3,6 +3,7 @@ import { useTerminalDimensions } from "@opentui/solid" import { createSignal, For, type JSX } from "solid-js" import { StoryFooter } from "./footer" import { sessionTabsStory } from "./session-tabs" +import { sessionLocationMissingStory } from "./session-location-missing" /** * A story is a full-screen, fixture-driven simulation of a real production component. Stories own @@ -14,7 +15,7 @@ export type Story = { render: (context: Plugin.Context) => JSX.Element } -const stories: Story[] = [sessionTabsStory] +const stories: Story[] = [sessionTabsStory, sessionLocationMissingStory] function Commands(props: { context: Plugin.Context }) { props.context.keymap.layer(() => ({ diff --git a/packages/tui/src/feature-plugins/system/storybook/session-location-missing.tsx b/packages/tui/src/feature-plugins/system/storybook/session-location-missing.tsx new file mode 100644 index 00000000000..6b6e9908c57 --- /dev/null +++ b/packages/tui/src/feature-plugins/system/storybook/session-location-missing.tsx @@ -0,0 +1,80 @@ +import type { Plugin } from "@opencode-ai/plugin/tui" +import { useTerminalDimensions } from "@opentui/solid" +import { TextAttributes } from "@opentui/core" +import { createSignal } from "solid-js" +import { DialogMoveSession } from "../../../component/dialog-move-session" +import { SessionLocationUnavailable } from "../../../routes/session/location-missing" +import type { Story } from "./index" +import { StoryFooter } from "./footer" + +const directory = "/Users/kit/code/open-source/opencode-workerd-profile" + +function SessionLocationMissingStory(props: { context: Plugin.Context }) { + const dimensions = useTerminalDimensions() + const theme = props.context.theme.contextual.elevated + const [message, setMessage] = createSignal("Choose another directory to continue") + const open = () => + props.context.ui.dialog.show(() => ( + { + if (selection.type !== "directory") return + setMessage(`Selected ${selection.directory}`) + props.context.ui.dialog.clear() + }} + /> + )) + + props.context.keymap.layer(() => ({ + commands: [ + { + bind: "escape", + title: "Back to storybook", + group: "Storybook", + run: () => props.context.ui.router.navigate({ type: "plugin", name: "storybook" }), + }, + ], + })) + + return ( + + + + Workerd Modal workspace driver + + build · GPT-5.6 Sol (high) + + You + Test the mounted workspace and verify the deployment. + + Build · GPT-5.6 Sol (high) + The deployment is verified and the worktree is clean. + + + + + + ) +} + +export const sessionLocationMissingStory: Story = { + id: "session-location-missing", + title: "Missing session directory", + render: (context) => , +} diff --git a/packages/tui/src/routes/session/index.tsx b/packages/tui/src/routes/session/index.tsx index 886a5736e21..46628c15e1b 100644 --- a/packages/tui/src/routes/session/index.tsx +++ b/packages/tui/src/routes/session/index.tsx @@ -108,6 +108,7 @@ import { createSingleFlight } from "../../util/single-flight" import type { SessionInbox } from "@opencode-ai/schema/session-inbox" import { generateThinkingSyntax } from "./thinking-syntax" import { createDelayedPresence } from "../../util/delayed-presence" +import { SessionLocationMissing } from "./location-missing" addDefaultParsers(parsers.parsers) @@ -1145,6 +1146,19 @@ export function Session() { }} + + + props.projectID, sessionID: () => props.sessionID }) + return +} + +export function SessionLocationUnavailable(props: { directory: string; onMove: () => void }) { + const paths = useTuiPaths() + const theme = useTheme("elevated") + const directory = createMemo(() => Locale.truncateMiddle(abbreviateHome(props.directory, paths.home), 72)) + + return ( + + {directory()} + Choose another directory to continue this session. + + } + options={{ move: "Choose directory" }} + onSelect={props.onMove} + /> + ) +} diff --git a/packages/tui/src/routes/session/permission.tsx b/packages/tui/src/routes/session/permission.tsx index 149a1c7753e..a4ef6e3cf08 100644 --- a/packages/tui/src/routes/session/permission.tsx +++ b/packages/tui/src/routes/session/permission.tsx @@ -141,7 +141,7 @@ export function PermissionPrompt(props: { request: PermissionRequest; directory? return ( - >(props: { +export function SessionQuestion>(props: { title: string semanticLabel?: string instance: string + id?: string + group?: string + choicesLabel?: string header?: JSX.Element body: JSX.Element options: T @@ -431,86 +434,65 @@ function Prompt>(props: { }) const narrow = createMemo(() => dimensions().width < 80) const shortcuts = Keymap.useShortcuts() + const id = () => props.id ?? "session.permission" + const group = () => props.group ?? "Permission" Keymap.createLayer(() => ({ mode: "base", commands: [ - { - id: "app.exit", - title: "Reject permission", - group: "Permission", - bind: false, - run() { - if (!props.escapeKey) return - props.onSelect(props.escapeKey) - }, - }, - { - id: "permission.prompt.fullscreen", - title: "Toggle permission fullscreen", - group: "Permission", - bind: false, - run() { - if (!props.fullscreen) return - setStore("expanded", (v) => !v) - }, - }, - { - bind: "left", - title: "Previous permission option", - group: "Permission", - run: () => { - const idx = keys.indexOf(store.selected) - const next = keys[(idx - 1 + keys.length) % keys.length] - setStore("selected", next) - }, - }, - { - bind: "h", - title: "Previous permission option", - group: "Permission", - run: () => { - const idx = keys.indexOf(store.selected) - const next = keys[(idx - 1 + keys.length) % keys.length] - setStore("selected", next) - }, - }, - { - bind: "right", - title: "Next permission option", - group: "Permission", - run: () => { - const idx = keys.indexOf(store.selected) - const next = keys[(idx + 1) % keys.length] - setStore("selected", next) - }, - }, - { - bind: "l", - title: "Next permission option", - group: "Permission", - run: () => { - const idx = keys.indexOf(store.selected) - const next = keys[(idx + 1) % keys.length] - setStore("selected", next) - }, - }, - { - bind: "return", - title: "Select permission option", - group: "Permission", - run: () => props.onSelect(store.selected), - }, ...(props.escapeKey ? [ { - bind: "escape", + id: "app.exit", title: "Reject permission", - group: "Permission", + group: group(), + bind: false as const, run: () => props.onSelect(props.escapeKey!), }, ] : []), + ...(props.fullscreen + ? [ + { + id: "permission.prompt.fullscreen", + title: "Toggle permission fullscreen", + group: group(), + bind: false as const, + run: () => setStore("expanded", (value) => !value), + }, + ] + : []), + ...(keys.length > 1 + ? [ + { + bind: "left,h", + title: "Previous option", + group: group(), + run: () => { + const index = keys.indexOf(store.selected) + setStore("selected", keys[(index - 1 + keys.length) % keys.length]) + }, + }, + { + bind: "right,l", + title: "Next option", + group: group(), + run: () => { + const index = keys.indexOf(store.selected) + setStore("selected", keys[(index + 1) % keys.length]) + }, + }, + ] + : []), + { + bind: "return", + title: "Select option", + group: group(), + run: () => props.onSelect(store.selected), + }, + ...(props.escapeKey + ? [{ bind: "escape", title: "Reject permission", group: group(), run: () => props.onSelect(props.escapeKey!) }] + : []), ], bindings: [...(props.escapeKey ? ["app.exit"] : []), ...(props.fullscreen ? ["permission.prompt.fullscreen"] : [])], })) @@ -520,7 +502,7 @@ function Prompt>(props: { const content = () => ( ({ instance: props.instance, role: "dialog", @@ -571,11 +553,11 @@ function Prompt>(props: { alignItems={narrow() ? "flex-start" : "center"} > ({ instance: props.instance, role: "listbox", - label: "Permission choices", + label: props.choicesLabel ?? "Permission choices", }))} flexDirection="row" gap={1} @@ -584,7 +566,7 @@ function Prompt>(props: { {(option) => ( ({ instance: props.instance, role: "option", @@ -621,9 +603,11 @@ function Prompt>(props: { {shortcuts.get("permission.prompt.fullscreen")} {hint()} - - {"⇆"} select - + 1}> + + {"⇆"} select + + enter confirm