Merge remote-tracking branch 'origin/v2' into mcp-attachments

# Conflicts:
#	packages/tui/test/cli/tui/data.test.tsx
This commit is contained in:
Aiden Cline 2026-07-07 14:00:51 -05:00
commit cc3ddb5bb0
22 changed files with 671 additions and 289 deletions

View file

@ -19,8 +19,9 @@ import { Spinner } from "./spinner"
import { DialogWorkspaceFileChanges } from "./dialog-workspace-file-changes"
import type { ProjectDirectoriesOutput } from "@opencode-ai/client/promise"
import { useRoute } from "../context/route"
import { DialogProjectCopyName } from "./dialog-project-copy-name"
export type MoveSessionSelection = { type: "directory"; directory: string; subdirectory: boolean } | { type: "new" }
export type MoveSessionSelection = { type: "directory"; directory: string; subdirectory: boolean } | { type: "new"; name: string }
type ProjectDirectory = ProjectDirectoriesOutput[number]
type DialogMoveSessionProps = {
@ -291,6 +292,12 @@ export function DialogMoveSession(props: DialogMoveSessionProps) {
if (await removedCurrent(deletingCurrent)) return
}
async function create() {
const name = await DialogProjectCopyName.show(dialog)
if (name === null) return
props.onSelect({ type: "new", name })
}
const fullHeight = createMemo(() =>
Math.max(8, Math.min(16, dimensions().height - Math.floor(dimensions().height / 4) - 2)),
)
@ -334,7 +341,7 @@ export function DialogMoveSession(props: DialogMoveSessionProps) {
{
command: "dialog.move_session.new",
title: "new",
onTrigger: () => props.onSelect({ type: "new" }),
onTrigger: () => void create(),
},
{
command: "dialog.move_session.delete",

View file

@ -0,0 +1,95 @@
import { InputRenderable, TextAttributes } from "@opentui/core"
import { Slug } from "@opencode-ai/core/util/slug"
import { createSignal, onMount } from "solid-js"
import { useTuiConfig } from "../config"
import { useTheme } from "../context/theme"
import { useBindings, useCommandShortcut } from "../keymap"
import { useDialog, type DialogContext } from "../ui/dialog"
export function DialogProjectCopyName(props: { onConfirm: (name: string) => void }) {
const dialog = useDialog()
const { theme } = useTheme()
const tuiConfig = useTuiConfig()
const generateShortcut = useCommandShortcut("dialog.project_copy.generate")
const [inputTarget, setInputTarget] = createSignal<InputRenderable>()
let input: InputRenderable
function generate() {
input.value = Slug.create()
input.gotoLineEnd()
}
function confirm() {
props.onConfirm(slugify(input.value) || Slug.create())
}
useBindings(() => ({
target: inputTarget,
enabled: inputTarget() !== undefined,
priority: 1,
commands: [
{
name: "dialog.project_copy.generate",
title: "Generate project copy name",
category: "Dialog",
run: generate,
},
],
bindings: tuiConfig.keybinds.get("dialog.project_copy.generate"),
}))
onMount(() => {
dialog.setSize("medium")
setTimeout(() => {
if (!input || input.isDestroyed) return
input.focus()
}, 1)
})
return (
<box paddingLeft={2} paddingRight={2} gap={1}>
<box flexDirection="row" justifyContent="space-between">
<text attributes={TextAttributes.BOLD} fg={theme.text}>
Name project copy
</text>
<text fg={theme.textMuted} onMouseUp={() => dialog.clear()}>
esc
</text>
</box>
<input
ref={(value: InputRenderable) => {
input = value
setInputTarget(value)
}}
onSubmit={confirm}
placeholder="Project copy name"
placeholderColor={theme.textMuted}
textColor={theme.text}
focusedTextColor={theme.text}
cursorColor={theme.text}
/>
<box paddingBottom={1} flexDirection="row" gap={2}>
<text fg={theme.text}>
enter <span style={{ fg: theme.textMuted }}>submit</span>
</text>
<text fg={theme.text}>
{generateShortcut()} <span style={{ fg: theme.textMuted }}>generate one</span>
</text>
</box>
</box>
)
}
DialogProjectCopyName.show = (dialog: DialogContext) =>
new Promise<string | null>((resolve) => {
dialog.replace(() => <DialogProjectCopyName onConfirm={resolve} />, () => resolve(null))
})
function slugify(input: string) {
return input
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+/, "")
.replace(/-+$/, "")
}

View file

@ -218,7 +218,10 @@ export function Prompt(props: PromptProps) {
const editorContextLabelState = createMemo(() => editor.labelState())
const [auto, setAuto] = createSignal<AutocompleteRef>()
const workspace = usePromptWorkspace(props.sessionID)
const move = usePromptMove({ projectID: project.project, sessionID: () => props.sessionID })
const move = usePromptMove({
projectID: () => (props.sessionID ? data.session.get(props.sessionID)?.projectID : undefined) ?? project.project(),
sessionID: () => props.sessionID,
})
const [cursorVersion, setCursorVersion] = createSignal(0)
const currentProviderLabel = createMemo(() => local.model.parsed().provider)
const connected = useConnected()
@ -955,13 +958,13 @@ export function Prompt(props: PromptProps) {
if (workspace.creating() || move.creating()) return false
if (auto()?.visible) return false
if (!store.prompt.text) return false
const agent = local.agent.current()
if (!agent) return false
const trimmed = store.prompt.text.trim()
if (trimmed === "exit" || trimmed === "quit" || trimmed === ":q") {
void exit()
return true
}
const agent = local.agent.current()
if (!agent) return false
const selectedModel = local.model.current()
if (!selectedModel) {
void promptModelWarning()
@ -991,7 +994,7 @@ export function Prompt(props: PromptProps) {
const selectedWorkspace = workspace.selection()
const workspaceID = selectedWorkspace?.type === "existing" ? selectedWorkspace.workspaceID : undefined
const directory = await move.getDirectory(store.prompt.text)
const directory = await move.getDirectory()
if (move.pending() && !directory) return false
finishMoveProgress = Boolean(move.progress())
const location = data.location.default()

View file

@ -4,12 +4,12 @@ import { useTuiPaths } from "../../context/runtime"
import { errorMessage } from "../../util/error"
import { useDialog } from "../../ui/dialog"
import { useSDK } from "../../context/sdk"
import { useSync } from "../../context/sync"
import { useToast } from "../../ui/toast"
import { DialogMoveSession, type MoveSessionSelection } from "../dialog-move-session"
import { DialogWorkspaceFileChanges } from "../dialog-workspace-file-changes"
import { useHomeSessionDestination } from "../../routes/home/session-destination"
import { useProject } from "../../context/project"
import { useData } from "../../context/data"
function moveReminderText(directory: string) {
return `<system-reminder>The user has changed the current working directory to "${directory}". This is still the same project but at a possibly new location; take this into account when working with any files from now on.</system-reminder>`
@ -18,38 +18,33 @@ function moveReminderText(directory: string) {
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 project = useProject()
const data = useData()
const paths = useTuiPaths()
const [creating, setCreating] = createSignal(false)
const [creatingDots, setCreatingDots] = createSignal(3)
const [progress, setProgress] = createSignal<string>()
async function create(context?: string) {
const projectID = input.projectID()
async function create(name: string) {
const projectID = await resolveProjectID()
if (!projectID) return
setCreating(true)
setProgress("Creating copy")
try {
const generated = await sdk.client.experimental.projectCopy.generateName(
{ projectID, context },
{ throwOnError: true },
)
const result = await sdk.api.projectCopy.create({
projectID,
location: { directory: project.instance.directory() || paths.cwd },
strategy: "git_worktree",
directory: path.join(paths.worktree, projectID.slice(0, 6)),
name: generated.data.name,
name,
})
const directory = result.directory
if (!directory) throw new Error("No project copy directory returned")
// Call a location-based route to make sure it's bootstrapped
// before moving on
await sdk.client.path.get({ directory }, { throwOnError: true })
// Call a location-based route to make sure it's bootstrapped before moving on.
await sdk.api.location.get({ location: { directory } })
setProgress("Creating session")
return directory
@ -62,11 +57,14 @@ export function usePromptMove(input: { projectID: () => string | undefined; sess
}
}
function open() {
const projectID = input.projectID()
if (!projectID) return
async function open() {
const projectID = await resolveProjectID()
if (!projectID) {
toast.show({ message: "Unable to determine current project", variant: "error" })
return
}
const sessionID = input.sessionID()
const session = sessionID ? sync.session.get(sessionID) : undefined
const session = sessionID ? await resolveSession(sessionID) : undefined
dialog.replace(() => (
<DialogMoveSession
projectID={projectID}
@ -75,8 +73,8 @@ export function usePromptMove(input: { projectID: () => string | undefined; sess
(session
? {
type: "directory",
directory: session.directory,
subdirectory: !!session.path,
directory: session.location.directory,
subdirectory: !!session.subpath,
}
: {
type: "directory",
@ -98,26 +96,13 @@ export function usePromptMove(input: { projectID: () => string | undefined; sess
))
}
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 session = await resolveSession(sessionID)
const status = await sdk.client.vcs.status({ directory: session?.location.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
const directory = selection.type === "new" ? await create(selection.name) : selection.directory
if (!directory) {
setProgress(undefined)
dialog.clear()
@ -125,27 +110,9 @@ export function usePromptMove(input: { projectID: () => string | undefined; sess
}
setProgress("Moving session")
try {
await sdk.client.experimental.controlPlane.moveSession(
{
sessionID,
destination: { directory },
moveChanges: choice === "yes",
},
{ throwOnError: true },
)
await sdk.client.session
.promptAsync({
sessionID,
directory,
noReply: true,
parts: [
{
type: "text",
text: moveReminderText(directory),
synthetic: true,
},
],
})
await sdk.api.session.move({ sessionID, destination: { directory }, moveChanges: choice === "yes" })
await sdk.api.session
.synthetic({ sessionID, text: moveReminderText(directory), resume: false })
.catch(() => undefined)
dialog.clear()
} catch (error) {
@ -157,16 +124,34 @@ export function usePromptMove(input: { projectID: () => string | undefined; sess
}
}
async function resolveProjectID() {
const projectID = input.projectID()
if (projectID) return projectID
const sessionID = input.sessionID()
if (sessionID) return (await resolveSession(sessionID))?.projectID
return sdk.api.project
.current({ location: { directory: project.instance.directory() || paths.cwd } })
.then((project) => project.id)
.catch(() => undefined)
}
async function resolveSession(sessionID: string) {
const session = data.session.get(sessionID)
if (session) return session
await data.session.refresh(sessionID).catch(() => undefined)
return data.session.get(sessionID)
}
const pending = createMemo(() => Boolean(homeDestination?.destination()))
const pendingNew = createMemo(() => homeDestination?.destination()?.type === "new")
async function getDirectory(context?: string) {
async function getDirectory() {
const value = homeDestination?.destination()
if (!value) return
if (value.type === "directory") {
return value.directory
}
return await create(context)
return await create(value.name)
}
function startSubmit() {

View file

@ -207,6 +207,7 @@ export const Definitions = {
"dialog.select.end": keybind("end", "Move to last dialog item"),
"dialog.select.submit": keybind("return", "Submit selected dialog item"),
"dialog.prompt.submit": keybind("return", "Submit dialog prompt"),
"dialog.project_copy.generate": keybind("tab", "Generate project copy name"),
"dialog.mcp.toggle": keybind("space", "Toggle MCP in MCP dialog"),
"dialog.move_session.new": keybind("ctrl+m", "New project copy"),
"dialog.move_session.delete": keybind("ctrl+d", "Delete project copy"),

View file

@ -308,6 +308,12 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
if (store.session.info[event.data.sessionID])
setStore("session", "info", event.data.sessionID, "title", event.data.title)
break
case "session.moved":
if (store.session.info[event.data.sessionID]) {
setStore("session", "info", event.data.sessionID, "location", mutable(event.data.location))
setStore("session", "info", event.data.sessionID, "subpath", event.data.subpath)
}
break
case "session.prompt.promoted": {
message.update(event.data.sessionID, (draft, index) => {
const position = index.get(event.data.inputID)

View file

@ -6,7 +6,7 @@ import { useTuiPaths } from "../../context/runtime"
const id = "internal:sidebar-footer"
function View(props: { api: TuiPluginApi; sessionID: string }) {
function View(props: { api: TuiPluginApi; directory: string }) {
const paths = useTuiPaths()
const theme = () => props.api.theme.current
const has = createMemo(() =>
@ -17,10 +17,8 @@ function View(props: { api: TuiPluginApi; sessionID: string }) {
const done = createMemo(() => props.api.kv.get("dismissed_getting_started", false))
const show = createMemo(() => !has() && !done())
const path = createMemo(() => {
const session = props.api.state.session.get(props.sessionID)
const dir = session?.directory || props.api.state.path.directory || paths.cwd
const out = abbreviateHome(dir, paths.home)
const branch = session?.directory === props.api.state.path.directory ? props.api.state.vcs?.branch : undefined
const out = abbreviateHome(props.directory, paths.home)
const branch = props.directory === props.api.state.path.directory ? props.api.state.vcs?.branch : undefined
const text = branch ? out + ":" + branch : out
const list = text.split("/")
return {
@ -84,7 +82,7 @@ const tui: TuiPlugin = async (api) => {
order: 100,
slots: {
sidebar_footer(_ctx, props) {
return <View api={api} sessionID={props.session_id} />
return <View api={api} directory={props.directory} />
},
},
})

View file

@ -10,7 +10,7 @@ import {
import { useSync } from "../../context/sync"
import { useTuiPaths } from "../../context/runtime"
export type HomeSessionDestination = { type: "directory"; directory: string; subdirectory: boolean } | { type: "new" }
export type HomeSessionDestination = { type: "directory"; directory: string; subdirectory: boolean } | { type: "new"; name: string }
type Context = {
destination: Accessor<HomeSessionDestination | undefined>

View file

@ -85,7 +85,12 @@ export function Sidebar(props: { sessionID: string; overlay?: boolean }) {
</scrollbox>
<box flexShrink={0} gap={1} paddingTop={1}>
<pluginRuntime.Slot name="sidebar_footer" mode="single_winner" session_id={props.sessionID}>
<pluginRuntime.Slot
name="sidebar_footer"
mode="single_winner"
session_id={props.sessionID}
directory={session()?.location.directory ?? ""}
>
<text fg={theme.textMuted}>
<span style={{ fg: theme.success }}></span> <b>Open</b>
<span style={{ fg: theme.text }}>

View file

@ -176,6 +176,68 @@ test("refreshes MCP resource catalogs after change events", async () => {
}
})
test("updates session location when moved", async () => {
const events = createEventStream()
const destination = "/tmp/opencode-moved"
const calls = createFetch((url) => {
if (url.pathname === "/api/session/ses_test")
return json({
data: {
id: "ses_test",
projectID: "proj_test",
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: 0, updated: 0 },
title: "Test session",
location: { directory },
},
})
}, events)
let data!: ReturnType<typeof useData>
let ready!: () => void
const mounted = new Promise<void>((resolve) => {
ready = resolve
})
function Probe() {
data = useData()
onMount(ready)
return <box />
}
const app = await testRender(() => (
<TestTuiContexts>
<SDKProvider client={createClient(calls.fetch)} api={createApi(calls.fetch)}>
<ProjectProvider>
<DataProvider>
<Probe />
</DataProvider>
</ProjectProvider>
</SDKProvider>
</TestTuiContexts>
))
try {
await mounted
await data.session.refresh("ses_test")
emitEvent(events, {
id: "evt_moved_1",
created: 1,
type: "session.moved",
durable: durable("ses_test"),
data: {
sessionID: "ses_test",
location: { directory: destination },
subpath: "packages/cli",
},
})
await wait(() => data.session.get("ses_test")?.location.directory === destination)
expect(data.session.get("ses_test")?.subpath).toBe("packages/cli")
} finally {
app.renderer.destroy()
}
})
test("restores running manual compaction before applying live deltas", async () => {
const events = createEventStream()
const calls = createFetch((url) => {