feat(core): session warping (#25768)
This commit is contained in:
@@ -2,7 +2,7 @@ import { useDialog } from "@tui/ui/dialog"
|
||||
import { DialogSelect } from "@tui/ui/dialog-select"
|
||||
import { useRoute } from "@tui/context/route"
|
||||
import { useSync } from "@tui/context/sync"
|
||||
import { createMemo, createResource, createSignal, onMount } from "solid-js"
|
||||
import { createMemo, createResource, createSignal, onMount, type JSX } from "solid-js"
|
||||
import { Locale } from "@/util/locale"
|
||||
import { useProject } from "@tui/context/project"
|
||||
import { useKeybind } from "../context/keybind"
|
||||
@@ -10,15 +10,13 @@ import { useTheme } from "../context/theme"
|
||||
import { useSDK } from "../context/sdk"
|
||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||
import { DialogSessionRename } from "./dialog-session-rename"
|
||||
import { Keybind } from "@/util/keybind"
|
||||
import { createDebouncedSignal } from "../util/signal"
|
||||
import { useToast } from "../ui/toast"
|
||||
import { DialogWorkspaceCreate, openWorkspaceSession, restoreWorkspaceSession } from "./dialog-workspace-create"
|
||||
import { openWorkspaceSelect, type WorkspaceSelection, warpWorkspaceSession } from "./dialog-workspace-create"
|
||||
import { Spinner } from "./spinner"
|
||||
import { errorMessage } from "@/util/error"
|
||||
import { DialogSessionDeleteFailed } from "./dialog-session-delete-failed"
|
||||
|
||||
type WorkspaceStatus = "connected" | "connecting" | "disconnected" | "error"
|
||||
import { WorkspaceLabel } from "./workspace-label"
|
||||
|
||||
export function DialogSessionList() {
|
||||
const dialog = useDialog()
|
||||
@@ -44,26 +42,39 @@ export function DialogSessionList() {
|
||||
const currentSessionID = createMemo(() => (route.data.type === "session" ? route.data.sessionID : undefined))
|
||||
const sessions = createMemo(() => searchResults() ?? sync.data.session)
|
||||
|
||||
function createWorkspace() {
|
||||
dialog.replace(() => (
|
||||
<DialogWorkspaceCreate
|
||||
onSelect={(workspaceID) =>
|
||||
openWorkspaceSession({
|
||||
dialog,
|
||||
route,
|
||||
sdk,
|
||||
sync,
|
||||
toast,
|
||||
workspaceID,
|
||||
})
|
||||
}
|
||||
/>
|
||||
))
|
||||
}
|
||||
|
||||
function recover(session: NonNullable<ReturnType<typeof sessions>[number]>) {
|
||||
const workspace = project.workspace.get(session.workspaceID!)
|
||||
const list = () => dialog.replace(() => <DialogSessionList />)
|
||||
const warp = async (selection: WorkspaceSelection) => {
|
||||
const workspaceID = await (async () => {
|
||||
if (selection.type === "none") return null
|
||||
if (selection.type === "existing") return selection.workspaceID
|
||||
const result = await sdk.client.experimental.workspace
|
||||
.create({ type: selection.workspaceType, branch: null })
|
||||
.catch(() => undefined)
|
||||
const workspace = result?.data
|
||||
if (!workspace) {
|
||||
toast.show({
|
||||
message: `Failed to create workspace: ${errorMessage(result?.error ?? "no response")}`,
|
||||
variant: "error",
|
||||
})
|
||||
return
|
||||
}
|
||||
await project.workspace.sync()
|
||||
return workspace.id
|
||||
})()
|
||||
if (workspaceID === undefined) return
|
||||
await warpWorkspaceSession({
|
||||
dialog,
|
||||
sdk,
|
||||
sync,
|
||||
project,
|
||||
toast,
|
||||
workspaceID,
|
||||
sessionID: session.id,
|
||||
done: list,
|
||||
})
|
||||
}
|
||||
dialog.replace(() => (
|
||||
<DialogSessionDeleteFailed
|
||||
session={session.title}
|
||||
@@ -90,22 +101,15 @@ export function DialogSessionList() {
|
||||
return true
|
||||
}}
|
||||
onRestore={() => {
|
||||
dialog.replace(() => (
|
||||
<DialogWorkspaceCreate
|
||||
onSelect={(workspaceID) =>
|
||||
restoreWorkspaceSession({
|
||||
dialog,
|
||||
sdk,
|
||||
sync,
|
||||
project,
|
||||
toast,
|
||||
workspaceID,
|
||||
sessionID: session.id,
|
||||
done: list,
|
||||
})
|
||||
}
|
||||
/>
|
||||
))
|
||||
void openWorkspaceSelect({
|
||||
dialog,
|
||||
sdk,
|
||||
sync,
|
||||
toast,
|
||||
onSelect: (selection) => {
|
||||
void warp(selection)
|
||||
},
|
||||
})
|
||||
return false
|
||||
}}
|
||||
/>
|
||||
@@ -124,30 +128,17 @@ export function DialogSessionList() {
|
||||
.map((x) => {
|
||||
const workspace = x.workspaceID ? project.workspace.get(x.workspaceID) : undefined
|
||||
|
||||
let workspaceStatus: WorkspaceStatus | null = null
|
||||
if (x.workspaceID) {
|
||||
workspaceStatus = project.workspace.status(x.workspaceID) || "error"
|
||||
}
|
||||
|
||||
let footer = ""
|
||||
let footer: JSX.Element | string = ""
|
||||
if (Flag.OPENCODE_EXPERIMENTAL_WORKSPACES) {
|
||||
if (x.workspaceID) {
|
||||
let desc = "unknown"
|
||||
if (workspace) {
|
||||
desc = `${workspace.type}: ${workspace.name}`
|
||||
}
|
||||
|
||||
footer = (
|
||||
<>
|
||||
{desc}{" "}
|
||||
<span
|
||||
style={{
|
||||
fg: workspaceStatus === "connected" ? theme.success : theme.error,
|
||||
}}
|
||||
>
|
||||
●
|
||||
</span>
|
||||
</>
|
||||
footer = workspace ? (
|
||||
<WorkspaceLabel
|
||||
type={workspace.type}
|
||||
name={workspace.name}
|
||||
status={project.workspace.status(x.workspaceID) ?? "error"}
|
||||
/>
|
||||
) : (
|
||||
<WorkspaceLabel type="unknown" name={x.workspaceID} status="error" />
|
||||
)
|
||||
}
|
||||
} else {
|
||||
@@ -250,15 +241,6 @@ export function DialogSessionList() {
|
||||
dialog.replace(() => <DialogSessionRename session={option.value} />)
|
||||
},
|
||||
},
|
||||
{
|
||||
keybind: Keybind.parse("ctrl+w")[0],
|
||||
title: "new workspace",
|
||||
side: "right",
|
||||
disabled: !Flag.OPENCODE_EXPERIMENTAL_WORKSPACES,
|
||||
onTrigger: () => {
|
||||
createWorkspace()
|
||||
},
|
||||
},
|
||||
]}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
import { createOpencodeClient } from "@opencode-ai/sdk/v2"
|
||||
import type { Workspace } from "@opencode-ai/sdk/v2"
|
||||
import { useDialog } from "@tui/ui/dialog"
|
||||
import { DialogSelect } from "@tui/ui/dialog-select"
|
||||
import { useRoute } from "@tui/context/route"
|
||||
import { DialogSelect, type DialogSelectOption } from "@tui/ui/dialog-select"
|
||||
import { useSync } from "@tui/context/sync"
|
||||
import { useProject } from "@tui/context/project"
|
||||
import { createMemo, createSignal, onMount } from "solid-js"
|
||||
import { setTimeout as sleep } from "node:timers/promises"
|
||||
import { errorMessage } from "@/util/error"
|
||||
import { useSDK } from "../context/sdk"
|
||||
import { useToast } from "../ui/toast"
|
||||
@@ -16,184 +14,212 @@ type Adapter = {
|
||||
description: string
|
||||
}
|
||||
|
||||
function scoped(sdk: ReturnType<typeof useSDK>, sync: ReturnType<typeof useSync>, workspaceID: string) {
|
||||
return createOpencodeClient({
|
||||
baseUrl: sdk.url,
|
||||
fetch: sdk.fetch,
|
||||
directory: sync.path.directory || sdk.directory,
|
||||
experimental_workspaceID: workspaceID,
|
||||
})
|
||||
}
|
||||
export type WorkspaceSelection =
|
||||
| {
|
||||
type: "none"
|
||||
}
|
||||
| {
|
||||
type: "new"
|
||||
workspaceType: string
|
||||
workspaceName: string
|
||||
}
|
||||
| {
|
||||
type: "existing"
|
||||
workspaceID: string
|
||||
workspaceType: string
|
||||
workspaceName: string
|
||||
}
|
||||
|
||||
export async function openWorkspaceSession(input: {
|
||||
dialog: ReturnType<typeof useDialog>
|
||||
route: ReturnType<typeof useRoute>
|
||||
type WorkspaceSelectValue = WorkspaceSelection | { type: "existing-list" }
|
||||
type ExistingWorkspaceSelectValue = { workspace: Workspace }
|
||||
|
||||
async function loadWorkspaceAdapters(input: {
|
||||
sdk: ReturnType<typeof useSDK>
|
||||
sync: ReturnType<typeof useSync>
|
||||
toast: ReturnType<typeof useToast>
|
||||
workspaceID: string
|
||||
}) {
|
||||
const client = scoped(input.sdk, input.sync, input.workspaceID)
|
||||
|
||||
while (true) {
|
||||
const result = await client.session.create({ workspace: input.workspaceID }).catch(() => undefined)
|
||||
if (!result) {
|
||||
input.toast.show({
|
||||
message: "Failed to create workspace session",
|
||||
variant: "error",
|
||||
})
|
||||
return
|
||||
}
|
||||
if (result.response?.status && result.response.status >= 500 && result.response.status < 600) {
|
||||
await sleep(1000)
|
||||
continue
|
||||
}
|
||||
if (!result.data) {
|
||||
input.toast.show({
|
||||
message: "Failed to create workspace session",
|
||||
variant: "error",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
input.route.navigate({
|
||||
type: "session",
|
||||
sessionID: result.data.id,
|
||||
})
|
||||
input.dialog.clear()
|
||||
return
|
||||
}
|
||||
const dir = input.sync.path.directory || input.sdk.directory
|
||||
const url = new URL("/experimental/workspace/adapter", input.sdk.url)
|
||||
if (dir) url.searchParams.set("directory", dir)
|
||||
const res = await input.sdk
|
||||
.fetch(url)
|
||||
.then((x) => x.json() as Promise<Adapter[]>)
|
||||
.catch(() => undefined)
|
||||
if (res) return res
|
||||
input.toast.show({
|
||||
message: "Failed to load workspace adapters",
|
||||
variant: "error",
|
||||
})
|
||||
}
|
||||
|
||||
export async function restoreWorkspaceSession(input: {
|
||||
export async function openWorkspaceSelect(input: {
|
||||
dialog: ReturnType<typeof useDialog>
|
||||
sdk: ReturnType<typeof useSDK>
|
||||
sync: ReturnType<typeof useSync>
|
||||
toast: ReturnType<typeof useToast>
|
||||
onSelect: (selection: WorkspaceSelection) => Promise<void> | void
|
||||
}) {
|
||||
input.dialog.clear()
|
||||
const adapters = await loadWorkspaceAdapters(input)
|
||||
if (!adapters) return
|
||||
input.dialog.replace(() => <DialogWorkspaceSelect adapters={adapters} onSelect={input.onSelect} />)
|
||||
}
|
||||
|
||||
export async function warpWorkspaceSession(input: {
|
||||
dialog: ReturnType<typeof useDialog>
|
||||
sdk: ReturnType<typeof useSDK>
|
||||
sync: ReturnType<typeof useSync>
|
||||
project: ReturnType<typeof useProject>
|
||||
toast: ReturnType<typeof useToast>
|
||||
workspaceID: string
|
||||
workspaceID: string | null
|
||||
sessionID: string
|
||||
done?: () => void
|
||||
}) {
|
||||
}): Promise<boolean> {
|
||||
const result = await input.sdk.client.experimental.workspace
|
||||
.sessionRestore({ id: input.workspaceID, sessionID: input.sessionID })
|
||||
.warp({
|
||||
id: input.workspaceID,
|
||||
sessionID: input.sessionID,
|
||||
})
|
||||
.catch(() => undefined)
|
||||
if (!result?.data) {
|
||||
input.toast.show({
|
||||
message: `Failed to restore session: ${errorMessage(result?.error ?? "no response")}`,
|
||||
message: `Failed to warp session: ${errorMessage(result?.error ?? "no response")}`,
|
||||
variant: "error",
|
||||
})
|
||||
return
|
||||
return false
|
||||
}
|
||||
|
||||
input.project.workspace.set(input.workspaceID)
|
||||
|
||||
await input.sync.bootstrap({ fatal: false }).catch(() => undefined)
|
||||
|
||||
await Promise.all([input.project.workspace.sync(), input.sync.session.sync(input.sessionID)])
|
||||
await Promise.all([input.project.workspace.sync(), input.sync.session.refresh()])
|
||||
|
||||
input.toast.show({
|
||||
message: "Session restored into the new workspace",
|
||||
variant: "success",
|
||||
})
|
||||
input.done?.()
|
||||
if (input.done) return
|
||||
if (input.done) return true
|
||||
input.dialog.clear()
|
||||
return true
|
||||
}
|
||||
|
||||
export function DialogWorkspaceCreate(props: { onSelect: (workspaceID: string) => Promise<void> | void }) {
|
||||
export function DialogWorkspaceSelect(props: {
|
||||
adapters?: Adapter[]
|
||||
onSelect: (selection: WorkspaceSelection) => Promise<void> | void
|
||||
}) {
|
||||
const dialog = useDialog()
|
||||
const sync = useSync()
|
||||
const project = useProject()
|
||||
const sync = useSync()
|
||||
const sdk = useSDK()
|
||||
const toast = useToast()
|
||||
const [creating, setCreating] = createSignal<string>()
|
||||
const [adapters, setAdapters] = createSignal<Adapter[]>()
|
||||
const [adapters, setAdapters] = createSignal<Adapter[] | undefined>(props.adapters)
|
||||
|
||||
onMount(() => {
|
||||
dialog.setSize("medium")
|
||||
void (async () => {
|
||||
const dir = sync.path.directory || sdk.directory
|
||||
const url = new URL("/experimental/workspace/adapter", sdk.url)
|
||||
if (dir) url.searchParams.set("directory", dir)
|
||||
const res = await sdk
|
||||
.fetch(url)
|
||||
.then((x) => x.json() as Promise<Adapter[]>)
|
||||
.catch(() => undefined)
|
||||
if (!res) {
|
||||
toast.show({
|
||||
message: "Failed to load workspace adapters",
|
||||
variant: "error",
|
||||
})
|
||||
return
|
||||
}
|
||||
if (adapters()) return
|
||||
const res = await loadWorkspaceAdapters({ sdk, sync, toast })
|
||||
if (!res) return
|
||||
setAdapters(res)
|
||||
})()
|
||||
})
|
||||
|
||||
const options = createMemo(() => {
|
||||
const type = creating()
|
||||
if (type) {
|
||||
return [
|
||||
{
|
||||
title: `Creating ${type} workspace...`,
|
||||
value: "creating" as const,
|
||||
description: "This can take a while for remote environments",
|
||||
},
|
||||
]
|
||||
}
|
||||
const options = createMemo<DialogSelectOption<WorkspaceSelectValue>[]>(() => {
|
||||
const list = adapters()
|
||||
if (!list) {
|
||||
return [
|
||||
{
|
||||
title: "Loading workspaces...",
|
||||
value: "loading" as const,
|
||||
description: "Fetching available workspace adapters",
|
||||
if (!list) return []
|
||||
const recent = sync.data.session
|
||||
.toSorted((a, b) => b.time.updated - a.time.updated)
|
||||
.flatMap((session) => (session.workspaceID ? [session.workspaceID] : []))
|
||||
.filter((workspaceID, index, list) => list.indexOf(workspaceID) === index)
|
||||
.slice(0, 3)
|
||||
.flatMap((workspaceID) => {
|
||||
const workspace = project.workspace.get(workspaceID)
|
||||
return workspace ? [workspace] : []
|
||||
})
|
||||
return [
|
||||
...list.map((adapter) => ({
|
||||
title: adapter.name,
|
||||
value: { type: "new" as const, workspaceType: adapter.type, workspaceName: adapter.name },
|
||||
description: adapter.description,
|
||||
category: "New workspace",
|
||||
})),
|
||||
{
|
||||
title: "None",
|
||||
value: { type: "none" as const },
|
||||
description: "Use the local project",
|
||||
category: "Choose workspace",
|
||||
},
|
||||
...recent.map((workspace: Workspace) => ({
|
||||
title: workspace.name,
|
||||
description: `(${workspace.type})`,
|
||||
value: {
|
||||
type: "existing" as const,
|
||||
workspaceID: workspace.id,
|
||||
workspaceType: workspace.type,
|
||||
workspaceName: workspace.name,
|
||||
},
|
||||
]
|
||||
}
|
||||
return list.map((item) => ({
|
||||
title: item.name,
|
||||
value: item.type,
|
||||
description: item.description,
|
||||
}))
|
||||
category: "Choose workspace",
|
||||
})),
|
||||
{
|
||||
title: "View all workspaces",
|
||||
value: { type: "existing-list" as const },
|
||||
description: "Choose from all workspaces",
|
||||
category: "Choose workspace",
|
||||
},
|
||||
]
|
||||
})
|
||||
|
||||
const create = async (type: string) => {
|
||||
if (creating()) return
|
||||
setCreating(type)
|
||||
|
||||
const result = await sdk.client.experimental.workspace.create({ type, branch: null }).catch(() => {
|
||||
toast.show({
|
||||
message: "Creating workspace failed",
|
||||
variant: "error",
|
||||
})
|
||||
return undefined
|
||||
})
|
||||
|
||||
const workspace = result?.data
|
||||
if (!workspace) {
|
||||
setCreating(undefined)
|
||||
toast.show({
|
||||
message: `Failed to create workspace: ${errorMessage(result?.error ?? "no response")}`,
|
||||
variant: "error",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
await project.workspace.sync()
|
||||
await props.onSelect(workspace.id)
|
||||
setCreating(undefined)
|
||||
}
|
||||
|
||||
if (!adapters()) return null
|
||||
return (
|
||||
<DialogSelect
|
||||
title={creating() ? "Creating Workspace" : "New Workspace"}
|
||||
<DialogSelect<WorkspaceSelectValue>
|
||||
title="Warp"
|
||||
skipFilter={true}
|
||||
renderFilter={false}
|
||||
options={options()}
|
||||
onSelect={(option) => {
|
||||
if (option.value === "creating" || option.value === "loading") return
|
||||
void create(option.value)
|
||||
if (!option.value) return
|
||||
if (option.value.type === "none") {
|
||||
void props.onSelect(option.value)
|
||||
return
|
||||
}
|
||||
if (option.value.type === "new") {
|
||||
void props.onSelect(option.value)
|
||||
return
|
||||
}
|
||||
if (option.value.type === "existing") {
|
||||
void props.onSelect(option.value)
|
||||
return
|
||||
}
|
||||
|
||||
dialog.replace(() => <DialogExistingWorkspaceSelect onSelect={props.onSelect} />)
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogExistingWorkspaceSelect(props: { onSelect: (selection: WorkspaceSelection) => Promise<void> | void }) {
|
||||
const project = useProject()
|
||||
|
||||
const options = createMemo<DialogSelectOption<ExistingWorkspaceSelectValue>[]>(() =>
|
||||
project.workspace
|
||||
.list()
|
||||
.filter((workspace) => project.workspace.status(workspace.id) === "connected")
|
||||
.map((workspace: Workspace) => ({
|
||||
title: workspace.name,
|
||||
description: `(${workspace.type})`,
|
||||
value: { workspace },
|
||||
})),
|
||||
)
|
||||
|
||||
return (
|
||||
<DialogSelect<ExistingWorkspaceSelectValue>
|
||||
title="Existing Workspace"
|
||||
options={options()}
|
||||
onSelect={(option) => {
|
||||
void props.onSelect({
|
||||
type: "existing",
|
||||
workspaceID: option.value.workspace.id,
|
||||
workspaceType: option.value.workspace.type,
|
||||
workspaceName: option.value.workspace.name,
|
||||
})
|
||||
}}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -7,6 +7,7 @@ import { Filesystem } from "@/util/filesystem"
|
||||
import { useLocal } from "@tui/context/local"
|
||||
import { tint, useTheme } from "@tui/context/theme"
|
||||
import { EmptyBorder, SplitBorder } from "@tui/component/border"
|
||||
import { Spinner } from "@tui/component/spinner"
|
||||
import { useSDK } from "@tui/context/sdk"
|
||||
import { useRoute } from "@tui/context/route"
|
||||
import { useProject } from "@tui/context/project"
|
||||
@@ -41,9 +42,11 @@ import { useKV } from "../../context/kv"
|
||||
import { createFadeIn } from "../../util/signal"
|
||||
import { useTextareaKeybindings } from "../textarea-keybindings"
|
||||
import { DialogSkill } from "../dialog-skill"
|
||||
import { DialogWorkspaceCreate, restoreWorkspaceSession } from "../dialog-workspace-create"
|
||||
import { openWorkspaceSelect, warpWorkspaceSession, type WorkspaceSelection } from "../dialog-workspace-create"
|
||||
import { DialogWorkspaceUnavailable } from "../dialog-workspace-unavailable"
|
||||
import { useArgs } from "@tui/context/args"
|
||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||
import { WorkspaceLabel, type WorkspaceStatus } from "../workspace-label"
|
||||
|
||||
export type PromptProps = {
|
||||
sessionID?: string
|
||||
@@ -173,9 +176,92 @@ export function Prompt(props: PromptProps) {
|
||||
const [editorContextHover, setEditorContextHover] = createSignal(false)
|
||||
let lastSubmittedEditorSelectionKey: string | undefined
|
||||
const [auto, setAuto] = createSignal<AutocompleteRef>()
|
||||
const [workspaceSelection, setWorkspaceSelection] = createSignal<WorkspaceSelection>()
|
||||
const [workspaceCreating, setWorkspaceCreating] = createSignal(false)
|
||||
const [workspaceCreatingDots, setWorkspaceCreatingDots] = createSignal(3)
|
||||
const [warpNotice, setWarpNotice] = createSignal<string>()
|
||||
const currentProviderLabel = createMemo(() => local.model.parsed().provider)
|
||||
const hasRightContent = createMemo(() => Boolean(props.right))
|
||||
|
||||
function selectWorkspace(selection: WorkspaceSelection | undefined) {
|
||||
setWorkspaceSelection(selection)
|
||||
}
|
||||
|
||||
function setCreatingWorkspace(creating: boolean) {
|
||||
setWorkspaceCreating(creating)
|
||||
}
|
||||
|
||||
function showWarpNotice(name: string) {
|
||||
setWarpNotice(`Warped to ${name}`)
|
||||
setTimeout(() => setWarpNotice(undefined), 4000)
|
||||
}
|
||||
|
||||
async function createWorkspace(selection: Extract<WorkspaceSelection, { type: "new" }>) {
|
||||
setCreatingWorkspace(true)
|
||||
const result = await sdk.client.experimental.workspace
|
||||
.create({ type: selection.workspaceType, branch: null })
|
||||
.catch(() => undefined)
|
||||
if (result == undefined || result.error || !result.data) {
|
||||
selectWorkspace(undefined)
|
||||
setCreatingWorkspace(false)
|
||||
toast.show({
|
||||
message: "Creating workspace failed",
|
||||
variant: "error",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
await project.workspace.sync()
|
||||
const workspace = result.data
|
||||
selectWorkspace({
|
||||
type: "existing",
|
||||
workspaceID: workspace.id,
|
||||
workspaceType: workspace.type,
|
||||
workspaceName: workspace.name,
|
||||
})
|
||||
setCreatingWorkspace(false)
|
||||
return workspace
|
||||
}
|
||||
|
||||
async function warpSession(selection: WorkspaceSelection) {
|
||||
if (!props.sessionID) {
|
||||
selectWorkspace(selection)
|
||||
dialog.clear()
|
||||
if (selection.type === "new") void createWorkspace(selection)
|
||||
return
|
||||
}
|
||||
selectWorkspace(selection)
|
||||
dialog.clear()
|
||||
|
||||
const workspace =
|
||||
selection.type === "none"
|
||||
? { id: null, name: "local project" }
|
||||
: selection.type === "existing"
|
||||
? { id: selection.workspaceID, name: selection.workspaceName }
|
||||
: await createWorkspace(selection)
|
||||
if (!workspace) return
|
||||
|
||||
const warped = await warpWorkspaceSession({
|
||||
dialog,
|
||||
sdk,
|
||||
sync,
|
||||
project,
|
||||
toast,
|
||||
workspaceID: workspace.id,
|
||||
sessionID: props.sessionID,
|
||||
})
|
||||
if (warped) showWarpNotice(workspace.name)
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
if (!workspaceCreating()) {
|
||||
setWorkspaceCreatingDots(3)
|
||||
return
|
||||
}
|
||||
const timer = setInterval(() => setWorkspaceCreatingDots((dots) => (dots % 3) + 1), 1000)
|
||||
onCleanup(() => clearInterval(timer))
|
||||
})
|
||||
|
||||
function promptModelWarning() {
|
||||
toast.show({
|
||||
variant: "warning",
|
||||
@@ -213,6 +299,7 @@ export function Prompt(props: PromptProps) {
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
if (!input || input.isDestroyed) return
|
||||
if (props.disabled) input.cursorColor = theme.backgroundElement
|
||||
if (!props.disabled) input.cursorColor = theme.text
|
||||
})
|
||||
@@ -489,6 +576,27 @@ export function Prompt(props: PromptProps) {
|
||||
))
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Warp",
|
||||
description: "Change the workspace for the session",
|
||||
value: "workspace.set",
|
||||
category: "Session",
|
||||
enabled: Flag.OPENCODE_EXPERIMENTAL_WORKSPACES,
|
||||
slash: {
|
||||
name: "warp",
|
||||
},
|
||||
onSelect: (dialog) => {
|
||||
void openWorkspaceSelect({
|
||||
dialog,
|
||||
sdk,
|
||||
sync,
|
||||
toast,
|
||||
onSelect: (selection) => {
|
||||
void warpSession(selection)
|
||||
},
|
||||
})
|
||||
},
|
||||
},
|
||||
]
|
||||
})
|
||||
|
||||
@@ -699,6 +807,8 @@ export function Prompt(props: PromptProps) {
|
||||
])
|
||||
|
||||
async function submit() {
|
||||
setWarpNotice(undefined)
|
||||
|
||||
// IME: double-defer may fire before onContentChange flushes the last
|
||||
// composed character (e.g. Korean hangul) to the store, so read
|
||||
// plainText directly and sync before any downstream reads.
|
||||
@@ -707,6 +817,7 @@ export function Prompt(props: PromptProps) {
|
||||
syncExtmarksWithPromptParts()
|
||||
}
|
||||
if (props.disabled) return false
|
||||
if (workspaceCreating()) return false
|
||||
if (autocomplete?.visible) return false
|
||||
if (!store.prompt.input) return false
|
||||
const agent = local.agent.current()
|
||||
@@ -729,21 +840,16 @@ export function Prompt(props: PromptProps) {
|
||||
dialog.replace(() => (
|
||||
<DialogWorkspaceUnavailable
|
||||
onRestore={() => {
|
||||
dialog.replace(() => (
|
||||
<DialogWorkspaceCreate
|
||||
onSelect={(nextWorkspaceID) =>
|
||||
restoreWorkspaceSession({
|
||||
dialog,
|
||||
sdk,
|
||||
sync,
|
||||
project,
|
||||
toast,
|
||||
workspaceID: nextWorkspaceID,
|
||||
sessionID: props.sessionID!,
|
||||
})
|
||||
}
|
||||
/>
|
||||
))
|
||||
void openWorkspaceSelect({
|
||||
dialog,
|
||||
sdk,
|
||||
sync,
|
||||
toast,
|
||||
onSelect: (selection) => {
|
||||
void warpSession(selection)
|
||||
},
|
||||
})
|
||||
return false
|
||||
}}
|
||||
/>
|
||||
))
|
||||
@@ -753,6 +859,14 @@ export function Prompt(props: PromptProps) {
|
||||
const variant = local.model.variant.current()
|
||||
let sessionID = props.sessionID
|
||||
if (sessionID == null) {
|
||||
const workspace = workspaceSelection()
|
||||
const workspaceID = iife(() => {
|
||||
if (!workspace) return undefined
|
||||
if (workspace.type === "none") return undefined
|
||||
if (workspace.type === "existing") return workspace.workspaceID
|
||||
return undefined
|
||||
})
|
||||
|
||||
const res = await sdk.client.session.create({
|
||||
workspace: props.workspaceID,
|
||||
agent: agent.name,
|
||||
@@ -1025,6 +1139,29 @@ export function Prompt(props: PromptProps) {
|
||||
return `Ask anything... "${list()[store.placeholder % list().length]}"`
|
||||
})
|
||||
|
||||
const workspaceLabel = createMemo<
|
||||
| { type: "new"; workspaceType: string }
|
||||
| { type: "existing"; workspaceType: string; workspaceName: string; status?: WorkspaceStatus }
|
||||
| undefined
|
||||
>(() => {
|
||||
const selected = workspaceSelection()
|
||||
if (!selected) return
|
||||
if (selected.type === "none") return
|
||||
if (props.sessionID && !workspaceCreating()) return
|
||||
if (selected.type === "new") {
|
||||
return {
|
||||
type: "new",
|
||||
workspaceType: selected.workspaceType,
|
||||
}
|
||||
}
|
||||
return {
|
||||
type: "existing",
|
||||
workspaceType: selected.workspaceType,
|
||||
workspaceName: selected.workspaceName,
|
||||
status: selected.type === "existing" ? "connected" : undefined,
|
||||
}
|
||||
})
|
||||
|
||||
const spinnerDef = createMemo(() => {
|
||||
const agent = local.agent.current()
|
||||
const color = agent ? local.agent.color(agent.name) : theme.border
|
||||
@@ -1281,7 +1418,7 @@ export function Prompt(props: PromptProps) {
|
||||
}}
|
||||
onMouseDown={(r: MouseEvent) => r.target?.focus()}
|
||||
focusedBackgroundColor={theme.backgroundElement}
|
||||
cursorColor={theme.text}
|
||||
cursorColor={props.disabled ? theme.backgroundElement : theme.text}
|
||||
syntaxStyle={syntax()}
|
||||
/>
|
||||
<box flexDirection="row" flexShrink={0} paddingTop={1} gap={1} justifyContent="space-between">
|
||||
@@ -1351,86 +1488,124 @@ export function Prompt(props: PromptProps) {
|
||||
/>
|
||||
</box>
|
||||
<box width="100%" flexDirection="row" justifyContent="space-between">
|
||||
<Show when={status().type !== "idle"} fallback={props.hint ?? <text />}>
|
||||
<box
|
||||
flexDirection="row"
|
||||
gap={1}
|
||||
flexGrow={1}
|
||||
justifyContent={status().type === "retry" ? "space-between" : "flex-start"}
|
||||
>
|
||||
<box flexShrink={0} flexDirection="row" gap={1}>
|
||||
<box marginLeft={1}>
|
||||
<Show when={kv.get("animations_enabled", true)} fallback={<text fg={theme.textMuted}>[⋯]</text>}>
|
||||
<spinner color={spinnerDef().color} frames={spinnerDef().frames} interval={40} />
|
||||
</Show>
|
||||
</box>
|
||||
<box flexDirection="row" gap={1} flexShrink={0}>
|
||||
{(() => {
|
||||
const retry = createMemo(() => {
|
||||
const s = status()
|
||||
if (s.type !== "retry") return
|
||||
return s
|
||||
})
|
||||
const message = createMemo(() => {
|
||||
const r = retry()
|
||||
if (!r) return
|
||||
if (r.message.includes("exceeded your current quota") && r.message.includes("gemini"))
|
||||
return "gemini is way too hot right now"
|
||||
if (r.message.length > 80) return r.message.slice(0, 80) + "..."
|
||||
return r.message
|
||||
})
|
||||
const isTruncated = createMemo(() => {
|
||||
const r = retry()
|
||||
if (!r) return false
|
||||
return r.message.length > 120
|
||||
})
|
||||
const [seconds, setSeconds] = createSignal(0)
|
||||
onMount(() => {
|
||||
const timer = setInterval(() => {
|
||||
const next = retry()?.next
|
||||
if (next) setSeconds(Math.round((next - Date.now()) / 1000))
|
||||
}, 1000)
|
||||
|
||||
onCleanup(() => {
|
||||
clearInterval(timer)
|
||||
<Switch>
|
||||
<Match when={status().type !== "idle"}>
|
||||
<box
|
||||
flexDirection="row"
|
||||
gap={1}
|
||||
flexGrow={1}
|
||||
justifyContent={status().type === "retry" ? "space-between" : "flex-start"}
|
||||
>
|
||||
<box flexShrink={0} flexDirection="row" gap={1}>
|
||||
<box marginLeft={1}>
|
||||
<Show when={kv.get("animations_enabled", true)} fallback={<text fg={theme.textMuted}>[⋯]</text>}>
|
||||
<spinner color={spinnerDef().color} frames={spinnerDef().frames} interval={40} />
|
||||
</Show>
|
||||
</box>
|
||||
<box flexDirection="row" gap={1} flexShrink={0}>
|
||||
{(() => {
|
||||
const retry = createMemo(() => {
|
||||
const s = status()
|
||||
if (s.type !== "retry") return
|
||||
return s
|
||||
})
|
||||
})
|
||||
const handleMessageClick = () => {
|
||||
const r = retry()
|
||||
if (!r) return
|
||||
if (isTruncated()) {
|
||||
void DialogAlert.show(dialog, "Retry Error", r.message)
|
||||
const message = createMemo(() => {
|
||||
const r = retry()
|
||||
if (!r) return
|
||||
if (r.message.includes("exceeded your current quota") && r.message.includes("gemini"))
|
||||
return "gemini is way too hot right now"
|
||||
if (r.message.length > 80) return r.message.slice(0, 80) + "..."
|
||||
return r.message
|
||||
})
|
||||
const isTruncated = createMemo(() => {
|
||||
const r = retry()
|
||||
if (!r) return false
|
||||
return r.message.length > 120
|
||||
})
|
||||
const [seconds, setSeconds] = createSignal(0)
|
||||
onMount(() => {
|
||||
const timer = setInterval(() => {
|
||||
const next = retry()?.next
|
||||
if (next) setSeconds(Math.round((next - Date.now()) / 1000))
|
||||
}, 1000)
|
||||
|
||||
onCleanup(() => {
|
||||
clearInterval(timer)
|
||||
})
|
||||
})
|
||||
const handleMessageClick = () => {
|
||||
const r = retry()
|
||||
if (!r) return
|
||||
if (isTruncated()) {
|
||||
void DialogAlert.show(dialog, "Retry Error", r.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const retryText = () => {
|
||||
const r = retry()
|
||||
if (!r) return ""
|
||||
const baseMessage = message()
|
||||
const truncatedHint = isTruncated() ? " (click to expand)" : ""
|
||||
const duration = formatDuration(seconds())
|
||||
const retryInfo = ` [retrying ${duration ? `in ${duration} ` : ""}attempt #${r.attempt}]`
|
||||
return baseMessage + truncatedHint + retryInfo
|
||||
}
|
||||
const retryText = () => {
|
||||
const r = retry()
|
||||
if (!r) return ""
|
||||
const baseMessage = message()
|
||||
const truncatedHint = isTruncated() ? " (click to expand)" : ""
|
||||
const duration = formatDuration(seconds())
|
||||
const retryInfo = ` [retrying ${duration ? `in ${duration} ` : ""}attempt #${r.attempt}]`
|
||||
return baseMessage + truncatedHint + retryInfo
|
||||
}
|
||||
|
||||
return (
|
||||
<Show when={retry()}>
|
||||
<box onMouseUp={handleMessageClick}>
|
||||
<text fg={theme.error}>{retryText()}</text>
|
||||
</box>
|
||||
</Show>
|
||||
)
|
||||
})()}
|
||||
return (
|
||||
<Show when={retry()}>
|
||||
<box onMouseUp={handleMessageClick}>
|
||||
<text fg={theme.error}>{retryText()}</text>
|
||||
</box>
|
||||
</Show>
|
||||
)
|
||||
})()}
|
||||
</box>
|
||||
</box>
|
||||
<text fg={store.interrupt > 0 ? theme.primary : theme.text}>
|
||||
esc{" "}
|
||||
<span style={{ fg: store.interrupt > 0 ? theme.primary : theme.textMuted }}>
|
||||
{store.interrupt > 0 ? "again to interrupt" : "interrupt"}
|
||||
</span>
|
||||
</text>
|
||||
</box>
|
||||
<text fg={store.interrupt > 0 ? theme.primary : theme.text}>
|
||||
esc{" "}
|
||||
<span style={{ fg: store.interrupt > 0 ? theme.primary : theme.textMuted }}>
|
||||
{store.interrupt > 0 ? "again to interrupt" : "interrupt"}
|
||||
</span>
|
||||
</text>
|
||||
</box>
|
||||
</Show>
|
||||
</Match>
|
||||
<Match when={warpNotice()}>
|
||||
{(notice) => (
|
||||
<box paddingLeft={3}>
|
||||
<text fg={theme.accent}>{notice()}</text>
|
||||
</box>
|
||||
)}
|
||||
</Match>
|
||||
<Match when={workspaceLabel()}>
|
||||
{(workspace) => (
|
||||
<box paddingLeft={3} flexDirection="row" gap={1}>
|
||||
<Show when={workspaceCreating()}>
|
||||
<Spinner color={theme.accent} />
|
||||
</Show>
|
||||
<text fg={workspaceCreating() ? theme.accent : theme.text}>
|
||||
{(() => {
|
||||
const item = workspace()
|
||||
if (item.type === "new") {
|
||||
if (workspaceCreating())
|
||||
return `Creating ${item.workspaceType}${".".repeat(workspaceCreatingDots())}`
|
||||
return (
|
||||
<>
|
||||
Workspace <span style={{ fg: theme.textMuted }}>(new {item.workspaceType})</span>
|
||||
</>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<>
|
||||
Workspace <span style={{ fg: theme.textMuted }}>{item.workspaceName}</span>
|
||||
</>
|
||||
)
|
||||
})()}
|
||||
</text>
|
||||
</box>
|
||||
)}
|
||||
</Match>
|
||||
<Match when={true}>{props.hint ?? <text />}</Match>
|
||||
</Switch>
|
||||
<Show when={status().type !== "retry"}>
|
||||
<box gap={2} flexDirection="row">
|
||||
<Show when={editorFileLabelDisplay()}>
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { useTheme } from "@tui/context/theme"
|
||||
|
||||
export type WorkspaceStatus = "connected" | "connecting" | "disconnected" | "error"
|
||||
|
||||
export function WorkspaceLabel(props: { type: string; name: string; status?: WorkspaceStatus; icon?: boolean }) {
|
||||
const { theme } = useTheme()
|
||||
const color = () => {
|
||||
if (props.status === "connected") return theme.success
|
||||
if (props.status === "error") return theme.error
|
||||
return theme.textMuted
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{props.icon ? <span style={{ fg: color() }}>● </span> : undefined}
|
||||
<span style={{ fg: theme.text }}>{props.name}</span> <span style={{ fg: theme.textMuted }}>({props.type})</span>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import { InstallationChannel, InstallationVersion } from "@opencode-ai/core/inst
|
||||
import { TuiPluginRuntime } from "@/cli/cmd/tui/plugin/runtime"
|
||||
|
||||
import { getScrollAcceleration } from "../../util/scroll"
|
||||
import { WorkspaceLabel } from "../../component/workspace-label"
|
||||
|
||||
export function Sidebar(props: { sessionID: string; overlay?: boolean }) {
|
||||
const project = useProject()
|
||||
@@ -14,17 +15,10 @@ export function Sidebar(props: { sessionID: string; overlay?: boolean }) {
|
||||
const { theme } = useTheme()
|
||||
const tuiConfig = useTuiConfig()
|
||||
const session = createMemo(() => sync.session.get(props.sessionID))
|
||||
const workspaceStatus = () => {
|
||||
const workspace = () => {
|
||||
const workspaceID = session()?.workspaceID
|
||||
if (!workspaceID) return "error"
|
||||
return project.workspace.status(workspaceID) ?? "error"
|
||||
}
|
||||
const workspaceLabel = () => {
|
||||
const workspaceID = session()?.workspaceID
|
||||
if (!workspaceID) return "unknown"
|
||||
const info = project.workspace.get(workspaceID)
|
||||
if (!info) return "unknown"
|
||||
return `${info.type}: ${info.name}`
|
||||
if (!workspaceID) return
|
||||
return project.workspace.get(workspaceID)
|
||||
}
|
||||
const scrollAcceleration = createMemo(() => getScrollAcceleration(tuiConfig))
|
||||
|
||||
@@ -67,8 +61,19 @@ export function Sidebar(props: { sessionID: string; overlay?: boolean }) {
|
||||
</Show>
|
||||
<Show when={session()!.workspaceID}>
|
||||
<text fg={theme.textMuted}>
|
||||
<span style={{ fg: workspaceStatus() === "connected" ? theme.success : theme.error }}>●</span>{" "}
|
||||
{workspaceLabel()}
|
||||
<Show
|
||||
when={workspace()}
|
||||
fallback={<WorkspaceLabel type="unknown" name={session()!.workspaceID!} status="error" icon />}
|
||||
>
|
||||
{(item) => (
|
||||
<WorkspaceLabel
|
||||
type={item().type}
|
||||
name={item().name}
|
||||
status={project.workspace.status(item().id) ?? "error"}
|
||||
icon
|
||||
/>
|
||||
)}
|
||||
</Show>
|
||||
</text>
|
||||
</Show>
|
||||
<Show when={session()!.share?.url}>
|
||||
|
||||
@@ -23,6 +23,7 @@ export interface DialogSelectProps<T> {
|
||||
onFilter?: (query: string) => void
|
||||
onSelect?: (option: DialogSelectOption<T>) => void
|
||||
skipFilter?: boolean
|
||||
renderFilter?: boolean
|
||||
keybind?: {
|
||||
keybind?: Keybind.Info
|
||||
title: string
|
||||
@@ -81,7 +82,7 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
|
||||
let input: InputRenderable
|
||||
|
||||
const filtered = createMemo(() => {
|
||||
if (props.skipFilter) return props.options.filter((x) => x.disabled !== true)
|
||||
if (props.skipFilter || props.renderFilter === false) return props.options.filter((x) => x.disabled !== true)
|
||||
const needle = store.filter.toLowerCase()
|
||||
const options = pipe(
|
||||
props.options,
|
||||
@@ -250,30 +251,32 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
|
||||
esc
|
||||
</text>
|
||||
</box>
|
||||
<box paddingTop={1}>
|
||||
<input
|
||||
onInput={(e) => {
|
||||
batch(() => {
|
||||
setStore("filter", e)
|
||||
props.onFilter?.(e)
|
||||
})
|
||||
}}
|
||||
focusedBackgroundColor={theme.backgroundPanel}
|
||||
cursorColor={theme.primary}
|
||||
focusedTextColor={theme.textMuted}
|
||||
ref={(r) => {
|
||||
input = r
|
||||
input.traits = { status: "FILTER" }
|
||||
setTimeout(() => {
|
||||
if (!input) return
|
||||
if (input.isDestroyed) return
|
||||
input.focus()
|
||||
}, 1)
|
||||
}}
|
||||
placeholder={props.placeholder ?? "Search"}
|
||||
placeholderColor={theme.textMuted}
|
||||
/>
|
||||
</box>
|
||||
<Show when={props.renderFilter !== false}>
|
||||
<box paddingTop={1}>
|
||||
<input
|
||||
onInput={(e) => {
|
||||
batch(() => {
|
||||
setStore("filter", e)
|
||||
props.onFilter?.(e)
|
||||
})
|
||||
}}
|
||||
focusedBackgroundColor={theme.backgroundPanel}
|
||||
cursorColor={theme.primary}
|
||||
focusedTextColor={theme.textMuted}
|
||||
ref={(r) => {
|
||||
input = r
|
||||
input.traits = { status: "FILTER" }
|
||||
setTimeout(() => {
|
||||
if (!input) return
|
||||
if (input.isDestroyed) return
|
||||
input.focus()
|
||||
}, 1)
|
||||
}}
|
||||
placeholder={props.placeholder ?? "Search"}
|
||||
placeholderColor={theme.textMuted}
|
||||
/>
|
||||
</box>
|
||||
</Show>
|
||||
</box>
|
||||
<Show
|
||||
when={grouped().length > 0}
|
||||
|
||||
Reference in New Issue
Block a user