feat(core): moving sessions (#30640)
This commit is contained in:
@@ -0,0 +1,129 @@
|
||||
import { useTerminalDimensions } from "@opentui/solid"
|
||||
import { createMemo, createResource, createSignal, onMount, Show } from "solid-js"
|
||||
import path from "path"
|
||||
import { DialogSelect, type DialogSelectOption } from "@tui/ui/dialog-select"
|
||||
import { useDialog } from "@tui/ui/dialog"
|
||||
import { useSDK } from "@tui/context/sdk"
|
||||
import { useTheme } from "@tui/context/theme"
|
||||
import { useKV } from "@tui/context/kv"
|
||||
import { useSync } from "@tui/context/sync"
|
||||
import { Global } from "@opencode-ai/core/global"
|
||||
import { Locale } from "@/util/locale"
|
||||
import "opentui-spinner/solid"
|
||||
|
||||
const REFRESH_FRAMES = ["■", "⬝"]
|
||||
|
||||
export type MoveSessionSelection = { type: "directory"; directory: string } | { type: "new" }
|
||||
|
||||
export function DialogMoveSession(props: { projectID: string; onSelect: (selection: MoveSessionSelection) => void }) {
|
||||
const dialog = useDialog()
|
||||
const sdk = useSDK()
|
||||
const dimensions = useTerminalDimensions()
|
||||
const { theme } = useTheme()
|
||||
const kv = useKV()
|
||||
const sync = useSync()
|
||||
const [refreshing, setRefreshing] = createSignal(false)
|
||||
|
||||
const [directories] = createResource(
|
||||
() => props.projectID,
|
||||
async (projectID) => {
|
||||
setRefreshing(true)
|
||||
const [, project] = await Promise.all([
|
||||
sdk.client.experimental.projectCopy
|
||||
.refresh({ projectID }, { throwOnError: true })
|
||||
.finally(() => setRefreshing(false)),
|
||||
sdk.client.project.current({}, { throwOnError: true }),
|
||||
])
|
||||
const directories = await sdk.client.project.directories({ projectID }, { throwOnError: true })
|
||||
return {
|
||||
directories: directories.data ?? [],
|
||||
main: project.data?.id === projectID ? project.data.worktree : undefined,
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
const options = createMemo<DialogSelectOption<string | undefined>[]>(() => {
|
||||
if (directories.loading) return [{ title: "Loading project directories...", value: undefined }]
|
||||
if (directories.error) return [{ title: "Failed to load project directories", value: undefined }]
|
||||
const data = directories()
|
||||
const roots = data ? [...new Set(data.main ? [data.main, ...data.directories] : data.directories)] : []
|
||||
if (roots.length === 0) return [{ title: "No project directories found", value: undefined }]
|
||||
const subdirectories = sync.data.session
|
||||
.filter((session) => session.projectID === props.projectID && session.path && ![".", "/"].includes(session.path))
|
||||
.map((session) => session.directory)
|
||||
.filter((directory) => !roots.includes(directory))
|
||||
.filter((directory, index, directories) => directories.indexOf(directory) === index)
|
||||
.map((location) => ({
|
||||
location,
|
||||
root: roots
|
||||
.filter((root) => {
|
||||
const relative = path.relative(root, location)
|
||||
return relative && relative !== ".." && !relative.startsWith(".." + path.sep) && !path.isAbsolute(relative)
|
||||
})
|
||||
.toSorted((a, b) => b.length - a.length)[0],
|
||||
}))
|
||||
.filter((item): item is { location: string; root: string } => item.root !== undefined)
|
||||
const list = [...roots.map((location) => ({ location, root: location })), ...subdirectories].toSorted((a, b) => {
|
||||
const root = roots.indexOf(a.root) - roots.indexOf(b.root)
|
||||
if (root !== 0) return root
|
||||
if (a.location === a.root) return -1
|
||||
if (b.location === b.root) return 1
|
||||
return a.location.localeCompare(b.location)
|
||||
})
|
||||
const titleWidth = Math.max(1, Math.min(116, dimensions().width - 2) - 12)
|
||||
return list.map((item) => {
|
||||
const title =
|
||||
Global.Path.home &&
|
||||
(item.location === Global.Path.home || item.location.startsWith(Global.Path.home + path.sep))
|
||||
? item.location.replace(Global.Path.home, "~")
|
||||
: item.location
|
||||
const suffix = item.location === item.root ? undefined : path.sep + path.relative(item.root, item.location)
|
||||
const visible = Locale.truncateLeft(title, titleWidth)
|
||||
const split = suffix ? Math.max(0, visible.length - suffix.length) : visible.length
|
||||
return {
|
||||
title,
|
||||
titleView: suffix ? (
|
||||
<>
|
||||
{visible.slice(0, split)}
|
||||
<span style={{ fg: theme.textMuted }}>{visible.slice(split)}</span>
|
||||
</>
|
||||
) : undefined,
|
||||
value: item.location,
|
||||
category: item.root === data?.main ? "Project" : "Working copies",
|
||||
titleWidth,
|
||||
truncateTitle: "left" as const,
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
onMount(() => dialog.setSize("xlarge"))
|
||||
|
||||
return (
|
||||
<box minHeight={Math.max(8, Math.min(16, dimensions().height - Math.floor(dimensions().height / 4) - 2))}>
|
||||
<DialogSelect
|
||||
title="Move session"
|
||||
options={options()}
|
||||
onSelect={(option) => {
|
||||
if (option.value) props.onSelect({ type: "directory", directory: option.value })
|
||||
}}
|
||||
actions={[
|
||||
{
|
||||
command: "dialog.move_session.new",
|
||||
title: "new",
|
||||
onTrigger: () => props.onSelect({ type: "new" }),
|
||||
},
|
||||
]}
|
||||
footer={
|
||||
<Show when={refreshing()}>
|
||||
<box flexDirection="row" gap={1}>
|
||||
<Show when={kv.get("animations_enabled", true)} fallback={<text fg={theme.textMuted}>⬝</text>}>
|
||||
<spinner color={theme.textMuted} frames={REFRESH_FRAMES} interval={160} />
|
||||
</Show>
|
||||
<text fg={theme.textMuted}>refreshing</text>
|
||||
</box>
|
||||
</Show>
|
||||
}
|
||||
/>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
@@ -103,7 +103,7 @@ export function DialogWorkspaceFileChanges(props: {
|
||||
</scrollbox>
|
||||
<box paddingLeft={2} paddingRight={2}>
|
||||
<text fg={theme.textMuted} wrapMode="word">
|
||||
Do you want to apply these changes after warping?
|
||||
Do you want to move these changes with the session?
|
||||
</text>
|
||||
</box>
|
||||
<box flexDirection="row" justifyContent="flex-end" paddingLeft={2} paddingRight={2} paddingBottom={1}>
|
||||
|
||||
@@ -51,18 +51,13 @@ import { useToast } from "../../ui/toast"
|
||||
import { useKV } from "../../context/kv"
|
||||
import { createFadeIn } from "../../util/signal"
|
||||
import { DialogSkill } from "../dialog-skill"
|
||||
import {
|
||||
confirmWorkspaceFileChanges,
|
||||
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 { type WorkspaceStatus } from "../workspace-label"
|
||||
import { OPENCODE_BASE_MODE, useBindings, useCommandShortcut, useLeaderActive, useOpencodeKeymap } from "../../keymap"
|
||||
import { useTuiConfig } from "../../context/tui-config"
|
||||
import { usePromptWorkspace } from "./workspace"
|
||||
import { usePromptMove } from "./move"
|
||||
|
||||
export type PromptProps = {
|
||||
sessionID?: string
|
||||
@@ -195,109 +190,12 @@ export function Prompt(props: PromptProps) {
|
||||
})
|
||||
const editorContextLabelState = createMemo(() => editor.labelState())
|
||||
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 workspace = usePromptWorkspace(props.sessionID)
|
||||
const move = usePromptMove({ projectID: project.project, sessionID: () => props.sessionID })
|
||||
const [cursorVersion, setCursorVersion] = createSignal(0)
|
||||
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)
|
||||
let result
|
||||
try {
|
||||
result = await sdk.client.experimental.workspace.create({ type: selection.workspaceType, branch: null })
|
||||
} catch (err) {
|
||||
selectWorkspace(undefined)
|
||||
setCreatingWorkspace(false)
|
||||
toast.show({
|
||||
title: "Creating workspace failed",
|
||||
message: errorMessage(err),
|
||||
variant: "error",
|
||||
})
|
||||
return
|
||||
}
|
||||
if (result.error || !result.data) {
|
||||
selectWorkspace(undefined)
|
||||
setCreatingWorkspace(false)
|
||||
toast.show({
|
||||
title: "Creating workspace failed",
|
||||
message: errorMessage(result.error ?? "no response"),
|
||||
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
|
||||
}
|
||||
const sourceWorkspaceID = project.workspace.current()
|
||||
const copyChanges = await confirmWorkspaceFileChanges({ dialog, sdk, sourceWorkspaceID })
|
||||
if (copyChanges === undefined) 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,
|
||||
sourceWorkspaceID,
|
||||
workspaceID: workspace.id,
|
||||
sessionID: props.sessionID,
|
||||
copyChanges,
|
||||
})
|
||||
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",
|
||||
@@ -623,16 +521,17 @@ export function Prompt(props: PromptProps) {
|
||||
enabled: Flag.OPENCODE_EXPERIMENTAL_WORKSPACES,
|
||||
slashName: "warp",
|
||||
run: () => {
|
||||
void openWorkspaceSelect({
|
||||
dialog,
|
||||
sdk,
|
||||
sync,
|
||||
project,
|
||||
toast,
|
||||
onSelect: (selection) => {
|
||||
void warpSession(selection)
|
||||
},
|
||||
})
|
||||
workspace.open()
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Move session",
|
||||
desc: "Move the session to another project directory",
|
||||
name: "session.move",
|
||||
category: "Session",
|
||||
slashName: "move",
|
||||
run: () => {
|
||||
move.open()
|
||||
},
|
||||
},
|
||||
].map((entry) => ({
|
||||
@@ -656,6 +555,7 @@ export function Prompt(props: PromptProps) {
|
||||
"prompt.stash.list",
|
||||
"session.interrupt",
|
||||
"workspace.set",
|
||||
"session.move",
|
||||
]),
|
||||
}))
|
||||
|
||||
@@ -1025,7 +925,7 @@ export function Prompt(props: PromptProps) {
|
||||
}
|
||||
|
||||
async function submitInner() {
|
||||
setWarpNotice(undefined)
|
||||
workspace.clearNotice()
|
||||
|
||||
// IME: double-defer may fire before onContentChange flushes the last
|
||||
// composed character (e.g. Korean hangul) to the store, so read
|
||||
@@ -1035,7 +935,7 @@ export function Prompt(props: PromptProps) {
|
||||
syncExtmarksWithPromptParts()
|
||||
}
|
||||
if (props.disabled) return false
|
||||
if (workspaceCreating()) return false
|
||||
if (workspace.creating() || move.creating()) return false
|
||||
if (auto()?.visible) return false
|
||||
if (!store.prompt.input) return false
|
||||
const agent = local.agent.current()
|
||||
@@ -1058,16 +958,7 @@ export function Prompt(props: PromptProps) {
|
||||
dialog.replace(() => (
|
||||
<DialogWorkspaceUnavailable
|
||||
onRestore={() => {
|
||||
void openWorkspaceSelect({
|
||||
dialog,
|
||||
sdk,
|
||||
sync,
|
||||
project,
|
||||
toast,
|
||||
onSelect: (selection) => {
|
||||
void warpSession(selection)
|
||||
},
|
||||
})
|
||||
workspace.open()
|
||||
return false
|
||||
}}
|
||||
/>
|
||||
@@ -1077,16 +968,22 @@ export function Prompt(props: PromptProps) {
|
||||
|
||||
const variant = local.model.variant.current()
|
||||
let sessionID = props.sessionID
|
||||
let finishMoveProgress = false
|
||||
if (sessionID == null) {
|
||||
const workspace = workspaceSelection()
|
||||
const selectedWorkspace = workspace.selection()
|
||||
const workspaceID = iife(() => {
|
||||
if (!workspace) return undefined
|
||||
if (workspace.type === "none") return undefined
|
||||
if (workspace.type === "existing") return workspace.workspaceID
|
||||
if (!selectedWorkspace) return undefined
|
||||
if (selectedWorkspace.type === "none") return undefined
|
||||
if (selectedWorkspace.type === "existing") return selectedWorkspace.workspaceID
|
||||
return undefined
|
||||
})
|
||||
|
||||
const directory = await move.getDirectory(store.prompt.input)
|
||||
if (move.pending() && !directory) return false
|
||||
finishMoveProgress = Boolean(move.progress())
|
||||
|
||||
const res = await sdk.client.session.create({
|
||||
directory,
|
||||
workspace: workspaceID,
|
||||
agent: agent.name,
|
||||
model: {
|
||||
@@ -1097,6 +994,7 @@ export function Prompt(props: PromptProps) {
|
||||
})
|
||||
|
||||
if (res.error) {
|
||||
if (finishMoveProgress) move.finishSubmit()
|
||||
console.log("Creating a session failed:", res.error)
|
||||
|
||||
toast.show({
|
||||
@@ -1146,6 +1044,7 @@ export function Prompt(props: PromptProps) {
|
||||
: []
|
||||
|
||||
if (store.mode === "shell") {
|
||||
move.startSubmit()
|
||||
void sdk.client.session.shell({
|
||||
sessionID,
|
||||
agent: agent.name,
|
||||
@@ -1164,6 +1063,7 @@ export function Prompt(props: PromptProps) {
|
||||
return sync.data.command.some((x) => x.name === command)
|
||||
})
|
||||
) {
|
||||
move.startSubmit()
|
||||
// Parse command from first line, preserve multi-line content in arguments
|
||||
const firstLineEnd = inputText.indexOf("\n")
|
||||
const firstLine = firstLineEnd === -1 ? inputText : inputText.slice(0, firstLineEnd)
|
||||
@@ -1187,6 +1087,7 @@ export function Prompt(props: PromptProps) {
|
||||
})),
|
||||
})
|
||||
} else {
|
||||
move.startSubmit()
|
||||
sdk.client.session
|
||||
.prompt({
|
||||
sessionID,
|
||||
@@ -1231,6 +1132,7 @@ export function Prompt(props: PromptProps) {
|
||||
}, 50)
|
||||
}
|
||||
input.clear()
|
||||
if (finishMoveProgress) move.finishSubmit()
|
||||
return true
|
||||
}
|
||||
const exit = useExit()
|
||||
@@ -1427,29 +1329,6 @@ 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 =
|
||||
status().type !== "idle"
|
||||
@@ -1474,6 +1353,7 @@ export function Prompt(props: PromptProps) {
|
||||
}
|
||||
})
|
||||
const maxHeight = createMemo(() => tuiConfig.prompt?.max_height ?? Math.max(6, Math.floor(dimensions().height / 3)))
|
||||
const moveLabelWidth = createMemo(() => Math.max(12, Math.min(44, dimensions().width - 48)))
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -1717,25 +1597,25 @@ export function Prompt(props: PromptProps) {
|
||||
</text>
|
||||
</box>
|
||||
</Match>
|
||||
<Match when={warpNotice()}>
|
||||
<Match when={workspace.notice()}>
|
||||
{(notice) => (
|
||||
<box paddingLeft={3}>
|
||||
<text fg={theme.accent}>{notice()}</text>
|
||||
</box>
|
||||
)}
|
||||
</Match>
|
||||
<Match when={workspaceLabel()}>
|
||||
{(workspace) => (
|
||||
<Match when={workspace.label()}>
|
||||
{(label) => (
|
||||
<box paddingLeft={3} flexDirection="row" gap={1}>
|
||||
<Show when={workspaceCreating()}>
|
||||
<Show when={workspace.creating()}>
|
||||
<Spinner color={theme.accent} />
|
||||
</Show>
|
||||
<text fg={workspaceCreating() ? theme.accent : theme.text}>
|
||||
<text fg={workspace.creating() ? theme.accent : theme.text}>
|
||||
{(() => {
|
||||
const item = workspace()
|
||||
const item = label()
|
||||
if (item.type === "new") {
|
||||
if (workspaceCreating())
|
||||
return `Creating ${item.workspaceType}${".".repeat(workspaceCreatingDots())}`
|
||||
if (workspace.creating())
|
||||
return `Creating ${item.workspaceType}${".".repeat(workspace.creatingDots())}`
|
||||
return (
|
||||
<>
|
||||
Workspace <span style={{ fg: theme.textMuted }}>(new {item.workspaceType})</span>
|
||||
@@ -1752,6 +1632,21 @@ export function Prompt(props: PromptProps) {
|
||||
</box>
|
||||
)}
|
||||
</Match>
|
||||
<Match when={move.progress()}>
|
||||
{(progress) => (
|
||||
<box paddingLeft={3}>
|
||||
<Spinner color={theme.accent}>
|
||||
{progress()}
|
||||
<span style={{ fg: theme.textMuted }}>{".".repeat(move.creatingDots())}</span>
|
||||
</Spinner>
|
||||
</box>
|
||||
)}
|
||||
</Match>
|
||||
<Match when={move.pendingNew()}>
|
||||
<box paddingLeft={3}>
|
||||
<text fg={theme.accent}>(new working copy)</text>
|
||||
</box>
|
||||
</Match>
|
||||
<Match when={true}>{props.hint ?? <text />}</Match>
|
||||
</Switch>
|
||||
<Show when={status().type !== "retry"}>
|
||||
|
||||
158
packages/opencode/src/cli/cmd/tui/component/prompt/move.tsx
Normal file
158
packages/opencode/src/cli/cmd/tui/component/prompt/move.tsx
Normal file
@@ -0,0 +1,158 @@
|
||||
import { createEffect, createMemo, createSignal, onCleanup } from "solid-js"
|
||||
import path from "path"
|
||||
import { Global } from "@opencode-ai/core/global"
|
||||
import { errorMessage } from "@/util/error"
|
||||
import { useDialog } from "@tui/ui/dialog"
|
||||
import { useSDK } from "@tui/context/sdk"
|
||||
import { useSync } from "@tui/context/sync"
|
||||
import { useToast } from "@tui/ui/toast"
|
||||
import { DialogMoveSession, type MoveSessionSelection } from "../dialog-move-session"
|
||||
import { DialogWorkspaceFileChanges } from "../dialog-workspace-file-changes"
|
||||
import { useHomeSessionDestination } from "../../routes/home/session-destination"
|
||||
|
||||
export function usePromptMove(input: { projectID: () => string | undefined; sessionID: () => string | undefined }) {
|
||||
const dialog = useDialog()
|
||||
const sdk = useSDK()
|
||||
const sync = useSync()
|
||||
const toast = useToast()
|
||||
const homeDestination = useHomeSessionDestination()
|
||||
const [creating, setCreating] = createSignal(false)
|
||||
const [creatingDots, setCreatingDots] = createSignal(3)
|
||||
const [progress, setProgress] = createSignal<string>()
|
||||
|
||||
async function create(context?: string) {
|
||||
const projectID = input.projectID()
|
||||
if (!projectID) return
|
||||
setCreating(true)
|
||||
setProgress("Creating copy")
|
||||
try {
|
||||
const result = await sdk.client.experimental.projectCopy.create(
|
||||
{
|
||||
projectID,
|
||||
strategy: "git_worktree",
|
||||
directory: path.join(Global.Path.data, "worktree", projectID.slice(0, 6)),
|
||||
context,
|
||||
},
|
||||
{ throwOnError: true },
|
||||
)
|
||||
const directory = result.data?.directory
|
||||
if (!directory) throw new Error("No project copy directory returned")
|
||||
setProgress("Creating session")
|
||||
return directory
|
||||
} catch (err) {
|
||||
homeDestination?.clear()
|
||||
setProgress(undefined)
|
||||
setCreating(false)
|
||||
toast.show({ title: "Creating workspace failed", message: errorMessage(err), variant: "error" })
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
function open() {
|
||||
const projectID = input.projectID()
|
||||
if (!projectID) return
|
||||
dialog.replace(() => (
|
||||
<DialogMoveSession
|
||||
projectID={projectID}
|
||||
onSelect={(selection) => {
|
||||
const sessionID = input.sessionID()
|
||||
if (!sessionID) {
|
||||
homeDestination?.setDestination(selection)
|
||||
dialog.clear()
|
||||
return
|
||||
}
|
||||
void moveExistingSession(sessionID, selection)
|
||||
}}
|
||||
/>
|
||||
))
|
||||
}
|
||||
|
||||
function sessionContext(sessionID: string) {
|
||||
const session = sync.session.get(sessionID)
|
||||
const messages = (sync.data.message[sessionID] ?? [])
|
||||
.slice(-6)
|
||||
.map((message) =>
|
||||
[
|
||||
message.role + ":",
|
||||
...(sync.data.part[message.id] ?? []).flatMap((part) => (part.type === "text" ? [part.text] : [])),
|
||||
].join(" "),
|
||||
)
|
||||
return [session?.title, ...messages].filter(Boolean).join("\n") || undefined
|
||||
}
|
||||
|
||||
async function moveExistingSession(sessionID: string, selection: MoveSessionSelection) {
|
||||
const session = sync.session.get(sessionID)
|
||||
const status = await sdk.client.vcs.status({ directory: session?.directory }).catch(() => undefined)
|
||||
const choice = status?.data?.length ? await DialogWorkspaceFileChanges.show(dialog, status.data) : "no"
|
||||
if (!choice) return
|
||||
dialog.clear()
|
||||
const directory = selection.type === "new" ? await create(sessionContext(sessionID)) : selection.directory
|
||||
if (!directory) {
|
||||
setProgress(undefined)
|
||||
dialog.clear()
|
||||
return
|
||||
}
|
||||
setProgress("Moving session")
|
||||
await sdk.client.experimental.controlPlane
|
||||
.moveSession(
|
||||
{
|
||||
sessionID,
|
||||
destination: { directory },
|
||||
moveChanges: choice === "yes",
|
||||
},
|
||||
{ throwOnError: true },
|
||||
)
|
||||
.then(() => dialog.clear())
|
||||
.catch((error) => {
|
||||
toast.error(error)
|
||||
dialog.clear()
|
||||
})
|
||||
.finally(() => {
|
||||
setProgress(undefined)
|
||||
setCreating(false)
|
||||
})
|
||||
}
|
||||
|
||||
const pending = createMemo(() => Boolean(homeDestination?.destination()))
|
||||
const pendingNew = createMemo(() => homeDestination?.destination()?.type === "new")
|
||||
|
||||
async function getDirectory(context?: string) {
|
||||
const value = homeDestination?.destination()
|
||||
if (!value) return
|
||||
if (value.type === "directory") {
|
||||
return value.directory
|
||||
}
|
||||
return await create(context)
|
||||
}
|
||||
|
||||
function startSubmit() {
|
||||
if (progress()) setProgress("Submitting prompt")
|
||||
}
|
||||
|
||||
function finishSubmit() {
|
||||
homeDestination?.clear()
|
||||
setProgress(undefined)
|
||||
setCreating(false)
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
if (!creating()) {
|
||||
setCreatingDots(3)
|
||||
return
|
||||
}
|
||||
const timer = setInterval(() => setCreatingDots((dots) => (dots % 3) + 1), 1000)
|
||||
onCleanup(() => clearInterval(timer))
|
||||
})
|
||||
|
||||
return {
|
||||
creating,
|
||||
creatingDots,
|
||||
finishSubmit,
|
||||
getDirectory,
|
||||
open,
|
||||
pending,
|
||||
pendingNew,
|
||||
progress,
|
||||
startSubmit,
|
||||
}
|
||||
}
|
||||
137
packages/opencode/src/cli/cmd/tui/component/prompt/workspace.tsx
Normal file
137
packages/opencode/src/cli/cmd/tui/component/prompt/workspace.tsx
Normal file
@@ -0,0 +1,137 @@
|
||||
import { createEffect, createMemo, createSignal, onCleanup } from "solid-js"
|
||||
import { useDialog } from "@tui/ui/dialog"
|
||||
import { useSDK } from "@tui/context/sdk"
|
||||
import { useProject } from "@tui/context/project"
|
||||
import { useSync } from "@tui/context/sync"
|
||||
import { useToast } from "@tui/ui/toast"
|
||||
import { errorMessage } from "@/util/error"
|
||||
import {
|
||||
confirmWorkspaceFileChanges,
|
||||
openWorkspaceSelect,
|
||||
warpWorkspaceSession,
|
||||
type WorkspaceSelection,
|
||||
} from "../dialog-workspace-create"
|
||||
import type { WorkspaceStatus } from "../workspace-label"
|
||||
|
||||
export function usePromptWorkspace(sessionID?: string) {
|
||||
const dialog = useDialog()
|
||||
const sdk = useSDK()
|
||||
const project = useProject()
|
||||
const sync = useSync()
|
||||
const toast = useToast()
|
||||
const [selection, setSelection] = createSignal<WorkspaceSelection>()
|
||||
const [creating, setCreating] = createSignal(false)
|
||||
const [creatingDots, setCreatingDots] = createSignal(3)
|
||||
const [notice, setNotice] = createSignal<string>()
|
||||
|
||||
async function create(selection: Extract<WorkspaceSelection, { type: "new" }>) {
|
||||
setCreating(true)
|
||||
let result
|
||||
try {
|
||||
result = await sdk.client.experimental.workspace.create({ type: selection.workspaceType, branch: null })
|
||||
} catch (err) {
|
||||
setSelection(undefined)
|
||||
setCreating(false)
|
||||
toast.show({ title: "Creating workspace failed", message: errorMessage(err), variant: "error" })
|
||||
return
|
||||
}
|
||||
if (result.error || !result.data) {
|
||||
setSelection(undefined)
|
||||
setCreating(false)
|
||||
toast.show({
|
||||
title: "Creating workspace failed",
|
||||
message: errorMessage(result.error ?? "no response"),
|
||||
variant: "error",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
await project.workspace.sync()
|
||||
const workspace = result.data
|
||||
setSelection({
|
||||
type: "existing",
|
||||
workspaceID: workspace.id,
|
||||
workspaceType: workspace.type,
|
||||
workspaceName: workspace.name,
|
||||
})
|
||||
setCreating(false)
|
||||
return workspace
|
||||
}
|
||||
|
||||
async function warp(selection: WorkspaceSelection) {
|
||||
if (!sessionID) {
|
||||
setSelection(selection)
|
||||
dialog.clear()
|
||||
if (selection.type === "new") void create(selection)
|
||||
return
|
||||
}
|
||||
const sourceWorkspaceID = project.workspace.current()
|
||||
const copyChanges = await confirmWorkspaceFileChanges({ dialog, sdk, sourceWorkspaceID })
|
||||
if (copyChanges === undefined) return
|
||||
setSelection(selection)
|
||||
dialog.clear()
|
||||
|
||||
const workspace =
|
||||
selection.type === "none"
|
||||
? { id: null, name: "local project" }
|
||||
: selection.type === "existing"
|
||||
? { id: selection.workspaceID, name: selection.workspaceName }
|
||||
: await create(selection)
|
||||
if (!workspace) return
|
||||
|
||||
const warped = await warpWorkspaceSession({
|
||||
dialog,
|
||||
sdk,
|
||||
sync,
|
||||
project,
|
||||
toast,
|
||||
sourceWorkspaceID,
|
||||
workspaceID: workspace.id,
|
||||
sessionID,
|
||||
copyChanges,
|
||||
})
|
||||
if (warped) showNotice(workspace.name)
|
||||
}
|
||||
|
||||
function showNotice(name: string) {
|
||||
setNotice(`Warped to ${name}`)
|
||||
setTimeout(() => setNotice(undefined), 4000)
|
||||
}
|
||||
|
||||
function clearNotice() {
|
||||
setNotice(undefined)
|
||||
}
|
||||
|
||||
function open() {
|
||||
void openWorkspaceSelect({ dialog, sdk, sync, project, toast, onSelect: warp })
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
if (!creating()) {
|
||||
setCreatingDots(3)
|
||||
return
|
||||
}
|
||||
const timer = setInterval(() => setCreatingDots((dots) => (dots % 3) + 1), 1000)
|
||||
onCleanup(() => clearInterval(timer))
|
||||
})
|
||||
|
||||
const label = createMemo<
|
||||
| { type: "new"; workspaceType: string }
|
||||
| { type: "existing"; workspaceType: string; workspaceName: string; status?: WorkspaceStatus }
|
||||
| undefined
|
||||
>(() => {
|
||||
const selected = selection()
|
||||
if (!selected) return
|
||||
if (selected.type === "none") return
|
||||
if (sessionID && !creating()) 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,
|
||||
}
|
||||
})
|
||||
|
||||
return { selection, creating, creatingDots, notice, label, open, warp, clearNotice }
|
||||
}
|
||||
@@ -204,6 +204,7 @@ export const Definitions = {
|
||||
"dialog.select.submit": keybind("return", "Submit selected dialog item"),
|
||||
"dialog.prompt.submit": keybind("return", "Submit dialog prompt"),
|
||||
"dialog.mcp.toggle": keybind("space", "Toggle MCP in MCP dialog"),
|
||||
"dialog.move_session.new": keybind("ctrl+w", "New project copy"),
|
||||
"prompt.autocomplete.prev": keybind("up,ctrl+p", "Move to previous autocomplete item"),
|
||||
"prompt.autocomplete.next": keybind("down,ctrl+n", "Move to next autocomplete item"),
|
||||
"prompt.autocomplete.hide": keybind("escape", "Hide autocomplete"),
|
||||
|
||||
@@ -253,6 +253,22 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
||||
break
|
||||
}
|
||||
|
||||
case "session.next.moved": {
|
||||
const result = Binary.search(store.session, event.properties.sessionID, (s) => s.id)
|
||||
if (!result.found) break
|
||||
setStore(
|
||||
"session",
|
||||
result.index,
|
||||
produce((session) => {
|
||||
session.directory = event.properties.location.directory
|
||||
session.path = event.properties.subdirectory
|
||||
session.workspaceID = event.properties.location.workspaceID
|
||||
session.time.updated = event.properties.timestamp
|
||||
}),
|
||||
)
|
||||
break
|
||||
}
|
||||
|
||||
case "session.status": {
|
||||
setStore("session_status", event.properties.sessionID, event.properties.status)
|
||||
break
|
||||
|
||||
@@ -2,12 +2,17 @@ import type { TuiPlugin, TuiPluginApi } from "@opencode-ai/plugin/tui"
|
||||
import type { InternalTuiPlugin } from "../../plugin/internal"
|
||||
import { createMemo, Match, Show, Switch } from "solid-js"
|
||||
import { Global } from "@opencode-ai/core/global"
|
||||
import { useHomeSessionDestination } from "../../routes/home/session-destination"
|
||||
|
||||
const id = "internal:home-footer"
|
||||
|
||||
function Directory(props: { api: TuiPluginApi }) {
|
||||
const theme = () => props.api.theme.current
|
||||
const destination = useHomeSessionDestination()
|
||||
const dir = createMemo(() => {
|
||||
const selected = destination?.destination()
|
||||
if (selected?.type === "new") return
|
||||
if (selected?.type === "directory") return selected.directory.replace(Global.Path.home, "~")
|
||||
const dir = props.api.state.path.directory || process.cwd()
|
||||
const out = dir.replace(Global.Path.home, "~")
|
||||
const branch = props.api.state.vcs?.branch
|
||||
@@ -15,7 +20,7 @@ function Directory(props: { api: TuiPluginApi }) {
|
||||
return out
|
||||
})
|
||||
|
||||
return <text fg={theme().textMuted}>{dir()}</text>
|
||||
return <Show when={dir()}>{(value) => <text fg={theme().textMuted}>{value()}</text>}</Show>
|
||||
}
|
||||
|
||||
function Mcp(props: { api: TuiPluginApi }) {
|
||||
|
||||
@@ -5,7 +5,7 @@ import { Global } from "@opencode-ai/core/global"
|
||||
|
||||
const id = "internal:sidebar-footer"
|
||||
|
||||
function View(props: { api: TuiPluginApi }) {
|
||||
function View(props: { api: TuiPluginApi; sessionID: string }) {
|
||||
const theme = () => props.api.theme.current
|
||||
const has = createMemo(() =>
|
||||
props.api.state.provider.some(
|
||||
@@ -15,9 +15,11 @@ function View(props: { api: TuiPluginApi }) {
|
||||
const done = createMemo(() => props.api.kv.get("dismissed_getting_started", false))
|
||||
const show = createMemo(() => !has() && !done())
|
||||
const path = createMemo(() => {
|
||||
const dir = props.api.state.path.directory || process.cwd()
|
||||
const session = props.api.state.session.get(props.sessionID)
|
||||
const dir = session?.directory || props.api.state.path.directory || process.cwd()
|
||||
const out = dir.replace(Global.Path.home, "~")
|
||||
const text = props.api.state.vcs?.branch ? out + ":" + props.api.state.vcs.branch : out
|
||||
const branch = session?.directory === props.api.state.path.directory ? props.api.state.vcs?.branch : undefined
|
||||
const text = branch ? out + ":" + branch : out
|
||||
const list = text.split("/")
|
||||
return {
|
||||
parent: list.slice(0, -1).join("/"),
|
||||
@@ -79,8 +81,8 @@ const tui: TuiPlugin = async (api) => {
|
||||
api.slots.register({
|
||||
order: 100,
|
||||
slots: {
|
||||
sidebar_footer() {
|
||||
return <View api={api} />
|
||||
sidebar_footer(_ctx, props) {
|
||||
return <View api={api} sessionID={props.session_id} />
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
@@ -11,6 +11,7 @@ import { TuiPluginRuntime } from "@/cli/cmd/tui/plugin/runtime"
|
||||
import { useEditorContext } from "@tui/context/editor"
|
||||
import { useTerminalDimensions } from "@opentui/solid"
|
||||
import { useTuiConfig } from "../context/tui-config"
|
||||
import { HomeSessionDestinationProvider } from "./home/session-destination"
|
||||
|
||||
let once = false
|
||||
const placeholder = {
|
||||
@@ -66,7 +67,7 @@ export function Home() {
|
||||
})
|
||||
|
||||
return (
|
||||
<>
|
||||
<HomeSessionDestinationProvider>
|
||||
<box flexGrow={1} alignItems="center" paddingLeft={2} paddingRight={2}>
|
||||
<box flexGrow={1} minHeight={0} />
|
||||
<box height={4} minHeight={0} flexShrink={1} />
|
||||
@@ -88,6 +89,6 @@ export function Home() {
|
||||
<box width="100%" flexShrink={0}>
|
||||
<TuiPluginRuntime.Slot name="home_footer" mode="single_winner" />
|
||||
</box>
|
||||
</>
|
||||
</HomeSessionDestinationProvider>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { createContext, createSignal, useContext, type Accessor, type ParentProps, type Setter } from "solid-js"
|
||||
|
||||
export type HomeSessionDestination = { type: "directory"; directory: string } | { type: "new" }
|
||||
|
||||
type Context = {
|
||||
destination: Accessor<HomeSessionDestination | undefined>
|
||||
setDestination: Setter<HomeSessionDestination | undefined>
|
||||
clear: () => void
|
||||
}
|
||||
|
||||
const HomeSessionDestinationContext = createContext<Context>()
|
||||
|
||||
export function HomeSessionDestinationProvider(props: ParentProps) {
|
||||
const [destination, setDestination] = createSignal<HomeSessionDestination>()
|
||||
return (
|
||||
<HomeSessionDestinationContext.Provider
|
||||
value={{ destination, setDestination, clear: () => setDestination(undefined) }}
|
||||
>
|
||||
{props.children}
|
||||
</HomeSessionDestinationContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function useHomeSessionDestination() {
|
||||
return useContext(HomeSessionDestinationContext)
|
||||
}
|
||||
@@ -23,6 +23,7 @@ import { formatKeyBindings, useBindings, useKeymapSelector } from "../keymap"
|
||||
export interface DialogSelectProps<T> {
|
||||
title: string
|
||||
placeholder?: string
|
||||
footer?: JSX.Element
|
||||
options: DialogSelectOption<T>[]
|
||||
flat?: boolean
|
||||
ref?: (ref: DialogSelectRef<T>) => void
|
||||
@@ -49,10 +50,13 @@ export interface DialogSelectProps<T> {
|
||||
|
||||
export interface DialogSelectOption<T = any> {
|
||||
title: string
|
||||
titleView?: JSX.Element
|
||||
value: T
|
||||
description?: string
|
||||
details?: string[]
|
||||
footer?: JSX.Element | string
|
||||
titleWidth?: number
|
||||
truncateTitle?: boolean | "left"
|
||||
category?: string
|
||||
categoryView?: JSX.Element
|
||||
disabled?: boolean
|
||||
@@ -472,7 +476,10 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
|
||||
</Show>
|
||||
<Option
|
||||
title={option.title}
|
||||
titleView={option.titleView}
|
||||
footer={flatten() ? (option.category ?? option.footer) : option.footer}
|
||||
titleWidth={option.titleWidth}
|
||||
truncateTitle={option.truncateTitle}
|
||||
description={option.description !== category ? option.description : undefined}
|
||||
active={active()}
|
||||
current={current()}
|
||||
@@ -498,7 +505,7 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
|
||||
</scrollbox>
|
||||
</Show>
|
||||
</box>
|
||||
<Show when={visibleActions().length} fallback={<box flexShrink={0} />}>
|
||||
<Show when={props.footer || visibleActions().length} fallback={<box flexShrink={0} />}>
|
||||
<box
|
||||
paddingRight={2}
|
||||
paddingLeft={4}
|
||||
@@ -508,6 +515,7 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
|
||||
paddingTop={1}
|
||||
>
|
||||
<box flexDirection="row" gap={2}>
|
||||
{props.footer}
|
||||
<For each={left()}>
|
||||
{(item) => (
|
||||
<text>
|
||||
@@ -539,10 +547,13 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
|
||||
|
||||
function Option(props: {
|
||||
title: string
|
||||
titleView?: JSX.Element
|
||||
description?: string
|
||||
active?: boolean
|
||||
current?: boolean
|
||||
footer?: JSX.Element | string
|
||||
titleWidth?: number
|
||||
truncateTitle?: boolean | "left"
|
||||
gutter?: () => JSX.Element
|
||||
onMouseOver?: () => void
|
||||
}) {
|
||||
@@ -569,7 +580,12 @@ function Option(props: {
|
||||
wrapMode="none"
|
||||
paddingLeft={3}
|
||||
>
|
||||
{Locale.truncate(props.title, 61)}
|
||||
{props.titleView ??
|
||||
(props.truncateTitle === false
|
||||
? props.title
|
||||
: props.truncateTitle === "left"
|
||||
? Locale.truncateLeft(props.title, props.titleWidth ?? 61)
|
||||
: Locale.truncate(props.title, props.titleWidth ?? 61))}
|
||||
<Show when={props.description}>
|
||||
<span style={{ fg: props.active ? fg : theme.textMuted }}> {props.description}</span>
|
||||
</Show>
|
||||
|
||||
Reference in New Issue
Block a user