소스 검색

feat(tui): prototype missing location recovery (#42353)

Kit Langton 4 일 전
부모
커밋
7b89f06402

+ 0 - 129
packages/drive/test/simulation/direct-cli.test.ts

@@ -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 }
-}

+ 9 - 12
packages/tui/src/component/dialog-move-session.tsx

@@ -31,6 +31,7 @@ type DialogMoveSessionProps = {
   onSelect: (selection: MoveSessionSelection) => void
   onCurrentChange?: (selection: MoveSessionSelection) => void
   initialDirectories?: ReadonlyArray<ProjectDirectory>
+  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<ReadonlyArray<ProjectDirectory> | 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<DialogSelectOption<MoveSessionSelection | undefined>[]>(() => {
@@ -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) {
           </box>
         }
         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
             ? []
             : [
                 {

+ 18 - 1
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<LocationRef>()
+  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,
       }}
     >

+ 2 - 1
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(() => ({

+ 80 - 0
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(() => (
+      <DialogMoveSession
+        projectID="fixture-project"
+        initialDirectories={[
+          { directory: "/Users/kit/code/open-source/opencode" },
+          {
+            directory: "/Users/kit/code/open-source/opencode-instruction-rename",
+            strategy: "git_worktree",
+          },
+        ]}
+        fixture
+        onSelect={(selection) => {
+          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 (
+    <box width={dimensions().width} height={dimensions().height} backgroundColor={theme.background.default}>
+      <box paddingLeft={2} paddingRight={2} paddingTop={1} flexGrow={1}>
+        <text fg={theme.text.default} attributes={TextAttributes.BOLD}>
+          Workerd Modal workspace driver
+        </text>
+        <text fg={theme.text.subdued}>build · GPT-5.6 Sol (high)</text>
+        <box height={1} />
+        <text fg={theme.text.default}>You</text>
+        <text fg={theme.text.subdued}>Test the mounted workspace and verify the deployment.</text>
+        <box height={1} />
+        <text fg={theme.text.default}>Build · GPT-5.6 Sol (high)</text>
+        <text fg={theme.text.subdued}>The deployment is verified and the worktree is clean.</text>
+        <box flexGrow={1} />
+        <SessionLocationUnavailable directory={directory} onMove={open} />
+      </box>
+      <StoryFooter
+        context={props.context}
+        title="storybook / missing session directory"
+        status={message()}
+        controls={[
+          { shortcut: "enter", label: "confirm" },
+          { shortcut: "esc", label: "back" },
+        ]}
+      />
+    </box>
+  )
+}
+
+export const sessionLocationMissingStory: Story = {
+  id: "session-location-missing",
+  title: "Missing session directory",
+  render: (context) => <SessionLocationMissingStory context={context} />,
+}

+ 14 - 0
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() {
                     }}
                   </Show>
                 </Match>
+                <Match
+                  when={
+                    session() &&
+                    currentLocation.error?.location.directory === session()!.location.directory &&
+                    currentLocation.error?.location.workspaceID === session()!.location.workspaceID
+                  }
+                >
+                  <SessionLocationMissing
+                    directory={session()!.location.directory}
+                    projectID={session()!.projectID}
+                    sessionID={route.sessionID}
+                  />
+                </Match>
                 <Match when={!disabled()}>
                   <Prompt
                     visible={true}

+ 36 - 0
packages/tui/src/routes/session/location-missing.tsx

@@ -0,0 +1,36 @@
+import { createMemo } from "solid-js"
+import { useTuiPaths } from "../../context/runtime"
+import { useTheme } from "../../context/theme"
+import { Locale } from "../../util/locale"
+import { abbreviateHome } from "../../util/path-format"
+import { SessionQuestion } from "./permission"
+import { usePromptMove } from "../../component/prompt/move"
+
+export function SessionLocationMissing(props: { directory: string; projectID: string; sessionID: string }) {
+  const move = usePromptMove({ projectID: () => props.projectID, sessionID: () => props.sessionID })
+  return <SessionLocationUnavailable directory={props.directory} onMove={move.open} />
+}
+
+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 (
+    <SessionQuestion
+      id="session.location-missing"
+      group="Session recovery"
+      choicesLabel="Recovery actions"
+      instance={props.directory}
+      title="Session location unavailable"
+      body={
+        <box paddingLeft={1} gap={1}>
+          <text fg={theme.text.subdued}>{directory()}</text>
+          <text fg={theme.text.default}>Choose another directory to continue this session.</text>
+        </box>
+      }
+      options={{ move: "Choose directory" }}
+      onSelect={props.onMove}
+    />
+  )
+}

+ 62 - 78
packages/tui/src/routes/session/permission.tsx

@@ -141,7 +141,7 @@ export function PermissionPrompt(props: { request: PermissionRequest; directory?
   return (
     <Switch>
       <Match when={store.stage === "always"}>
-        <Prompt
+        <SessionQuestion
           title="Always allow"
           semanticLabel={`Always allow ${props.request.action}`}
           instance={props.request.id}
@@ -235,7 +235,7 @@ export function PermissionPrompt(props: { request: PermissionRequest; directory?
           )
 
           const body = (
-            <Prompt
+            <SessionQuestion
               title="Permission required"
               semanticLabel={permissionSemanticLabel(props.request.action, current.title)}
               instance={props.request.id}
@@ -411,10 +411,13 @@ function RejectPrompt(props: {
   )
 }
 
-function Prompt<const T extends Record<string, string>>(props: {
+export function SessionQuestion<const T extends Record<string, string>>(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<const T extends Record<string, string>>(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<const T extends Record<string, string>>(props: {
 
   const content = () => (
     <box
-      id="session.permission"
+      id={id()}
       ref={SimulationSemantics.bind(() => ({
         instance: props.instance,
         role: "dialog",
@@ -571,11 +553,11 @@ function Prompt<const T extends Record<string, string>>(props: {
         alignItems={narrow() ? "flex-start" : "center"}
       >
         <box
-          id="session.permission.actions"
+          id={`${id()}.actions`}
           ref={SimulationSemantics.bind(() => ({
             instance: props.instance,
             role: "listbox",
-            label: "Permission choices",
+            label: props.choicesLabel ?? "Permission choices",
           }))}
           flexDirection="row"
           gap={1}
@@ -584,7 +566,7 @@ function Prompt<const T extends Record<string, string>>(props: {
           <For each={keys}>
             {(option) => (
               <box
-                id={`session.permission.action.${String(option)}`}
+                id={`${id()}.action.${String(option)}`}
                 ref={SimulationSemantics.bind(() => ({
                   instance: props.instance,
                   role: "option",
@@ -621,9 +603,11 @@ function Prompt<const T extends Record<string, string>>(props: {
               {shortcuts.get("permission.prompt.fullscreen")} <span style={{ fg: theme.text.subdued }}>{hint()}</span>
             </text>
           </Show>
-          <text fg={theme.text.default}>
-            {"⇆"} <span style={{ fg: theme.text.subdued }}>select</span>
-          </text>
+          <Show when={keys.length > 1}>
+            <text fg={theme.text.default}>
+              {"⇆"} <span style={{ fg: theme.text.subdued }}>select</span>
+            </text>
+          </Show>
           <text fg={theme.text.default}>
             enter <span style={{ fg: theme.text.subdued }}>confirm</span>
           </text>