Compare commits

...

1 Commits

Author SHA1 Message Date
Kit Langton 32849741cc fix(tui): isolate missing-location story data 2026-08-13 23:03:50 +00:00
3 changed files with 163 additions and 17 deletions
@@ -0,0 +1,129 @@
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 }
}
@@ -25,13 +25,18 @@ export type MoveSessionSelection =
| { type: "new"; name: string }
type ProjectDirectory = WorktreeListOutput[number]
export type MoveSessionWorktrees = {
list: (projectID: string) => Promise<ReadonlyArray<ProjectDirectory>>
remove: (input: { projectID: string; directory: string; force: boolean }) => Promise<unknown>
}
type DialogMoveSessionProps = {
projectID: string
current?: MoveSessionSelection
onSelect: (selection: MoveSessionSelection) => void
onCurrentChange?: (selection: MoveSessionSelection) => void
initialDirectories?: ReadonlyArray<ProjectDirectory>
fixture?: boolean
worktrees?: MoveSessionWorktrees
initialRemoving?: string
}
@@ -45,6 +50,13 @@ export function DialogMoveSession(props: DialogMoveSessionProps) {
const toast = useToast()
const paths = useTuiPaths()
const shortcuts = Keymap.useShortcuts()
const worktrees: MoveSessionWorktrees = props.worktrees ?? {
list: async (projectID) => {
await client.api.worktree.refresh({ projectID })
return client.api.worktree.list({ projectID })
},
remove: (input) => client.api.worktree.remove(input),
}
const location = createMemo(() => sessionData.location.info())
const [working, setWorking] = createSignal(Boolean(props.initialRemoving))
const [toDelete, setToDelete] = createSignal<string>()
@@ -63,7 +75,8 @@ export function DialogMoveSession(props: DialogMoveSessionProps) {
// swallow it and let the directory list render without a current marker.
// Once the current project is known, a mismatch is a guaranteed miss.
const [loadedProject] = createResource(
() => (location()?.project.id === props.projectID ? undefined : props.projectID),
() =>
props.current?.type === "directory" || location()?.project.id === props.projectID ? undefined : props.projectID,
(projectID) =>
client.api.project
.current({ location: { directory: location()?.directory || paths.cwd } })
@@ -76,11 +89,10 @@ export function DialogMoveSession(props: DialogMoveSessionProps) {
})
const [directories, { refetch }] = createResource(
() => (props.fixture || props.initialRemoving ? undefined : props.projectID),
() => (props.initialRemoving ? undefined : props.projectID),
async (projectID, info): Promise<ReadonlyArray<ProjectDirectory> | undefined> => {
try {
await client.api.worktree.refresh({ projectID })
const directories = await client.api.worktree.list({ projectID })
const directories = await worktrees.list(projectID)
setLoadError(undefined)
return directories
} catch (error) {
@@ -223,7 +235,7 @@ export function DialogMoveSession(props: DialogMoveSessionProps) {
setToDelete(undefined)
setRemoving(selected.directory)
setWorking(true)
const error = await client.api.worktree
const error = await worktrees
.remove({
projectID: props.projectID,
directory: selected.directory,
@@ -249,7 +261,7 @@ export function DialogMoveSession(props: DialogMoveSessionProps) {
return
}
reopen(selected.directory)
const forcedError = await client.api.worktree
const forcedError = await worktrees
.remove({
projectID: props.projectID,
directory: selected.directory,
@@ -345,7 +357,7 @@ export function DialogMoveSession(props: DialogMoveSessionProps) {
}}
onMove={() => setToDelete(undefined)}
actions={
showError() || props.fixture
showError()
? []
: [
{
@@ -2,12 +2,23 @@ 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 { DialogMoveSession, type MoveSessionWorktrees } 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"
const directories = [
{ directory: "/Users/kit/code/open-source/opencode" },
{
directory: "/Users/kit/code/open-source/opencode-instruction-rename",
strategy: "git_worktree" as const,
},
]
const worktrees: MoveSessionWorktrees = {
list: async () => directories,
remove: async () => undefined,
}
function SessionLocationMissingStory(props: { context: Plugin.Context }) {
const dimensions = useTerminalDimensions()
@@ -17,14 +28,8 @@ function SessionLocationMissingStory(props: { context: Plugin.Context }) {
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
current={{ type: "directory", directory: directories[0].directory, subdirectory: false }}
worktrees={worktrees}
onSelect={(selection) => {
if (selection.type !== "directory") return
setMessage(`Selected ${selection.directory}`)