mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-21 19:43:32 +00:00
feat(tui): prototype missing location recovery (#42353)
This commit is contained in:
parent
3a41ee8817
commit
7b89f06402
8 changed files with 221 additions and 221 deletions
|
|
@ -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 }
|
||||
}
|
||||
|
|
@ -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
|
||||
? []
|
||||
: [
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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(() => ({
|
||||
|
|
|
|||
|
|
@ -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} />,
|
||||
}
|
||||
|
|
@ -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
packages/tui/src/routes/session/location-missing.tsx
Normal file
36
packages/tui/src/routes/session/location-missing.tsx
Normal file
|
|
@ -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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue