Compare commits

...

2 Commits

Author SHA1 Message Date
Kit Langton 00bb8291ea fix(tui): refine open worktree flow 2026-08-14 15:58:32 -04:00
Kit Langton 6633529389 feat(tui): make open location-first 2026-08-14 15:44:56 -04:00
5 changed files with 334 additions and 43 deletions
+1 -1
View File
@@ -722,7 +722,7 @@ function App(props: { pair?: DialogPairCredentials }) {
},
{
name: "open.menu",
title: "Open session or project",
title: "Open session or worktree",
category: "Session",
slash: { name: "open", aliases: ["projects", "project"] },
run: async () => {
@@ -19,6 +19,7 @@ import { DialogWorkspaceFileChanges } from "./dialog-workspace-file-changes"
import type { WorktreeListOutput } from "@opencode-ai/client"
import { useRoute } from "../context/route"
import { DialogWorktreeName } from "./dialog-worktree-name"
import { Slug } from "@opencode-ai/core/util/slug"
export type MoveSessionSelection =
| { type: "directory"; directory: string; subdirectory: boolean }
@@ -27,6 +28,9 @@ type ProjectDirectory = WorktreeListOutput[number]
type DialogMoveSessionProps = {
projectID: string
title?: string
compact?: boolean
randomWorktree?: boolean
current?: MoveSessionSelection
onSelect: (selection: MoveSessionSelection) => void
onCurrentChange?: (selection: MoveSessionSelection) => void
@@ -51,7 +55,8 @@ export function DialogMoveSession(props: DialogMoveSessionProps) {
const [removing, setRemoving] = createSignal(props.initialRemoving)
const [replacementCurrent, setReplacementCurrent] = createSignal<string>()
const [loadError, setLoadError] = createSignal<unknown>()
onMount(() => dialog.setSize("xlarge"))
const randomWorktree = Slug.create()
onMount(() => dialog.setSize(props.compact ? "large" : "xlarge"))
function reopen(initialRemoving?: string) {
dialog.replace(() => (
@@ -122,8 +127,6 @@ export function DialogMoveSession(props: DialogMoveSessionProps) {
if (!a.strategy && !b.strategy) return a.directory.length - b.directory.length
return 0
})
if (roots.length === 0) return []
const subdirectories = sessionData.session
.list()
.filter(
@@ -150,7 +153,7 @@ export function DialogMoveSession(props: DialogMoveSessionProps) {
})
const titleWidth = Math.max(1, dialogSelectContentWidth(Math.min(dialogWidth("xlarge"), dimensions().width - 2)))
return list.map((item) => {
const options: DialogSelectOption<MoveSessionSelection | undefined>[] = list.map((item) => {
const title = abbreviateHome(item.location, paths.home)
const suffix =
item.location === item.root.directory ? undefined : path.sep + path.relative(item.root.directory, item.location)
@@ -183,6 +186,19 @@ export function DialogMoveSession(props: DialogMoveSessionProps) {
truncateTitle: "left" as const,
}
})
if (props.randomWorktree) {
return [
{
title: "+ New random worktree",
footer: randomWorktree,
value: { type: "new", name: randomWorktree },
category: "Create",
titleWidth,
},
...options,
]
}
return options
})
const current = createMemo(() => {
@@ -300,11 +316,11 @@ export function DialogMoveSession(props: DialogMoveSessionProps) {
return (
<box minHeight={showError() ? 5 : fullHeight()}>
<DialogSelect
title="Move session"
title={props.title ?? "Move session"}
titleView={
<box flexDirection="row" gap={1}>
<text fg={theme.text.default} attributes={TextAttributes.BOLD}>
Move session
{props.title ?? "Move session"}
</text>
<Show when={working() || directories.loading || loadedProject.loading}>
<Spinner />
+166 -33
View File
@@ -1,5 +1,6 @@
import { createMemo, createResource, createSignal } from "solid-js"
import type { SessionInfo } from "@opencode-ai/client"
import path from "path"
import { useTerminalDimensions } from "@opentui/solid"
import type { RGBA } from "@opentui/core"
import { dialogWidth, useDialog } from "../ui/dialog"
@@ -19,11 +20,17 @@ import { stringWidth } from "../util/string-width"
import { withTimestampedFallback } from "@opencode-ai/util/session-title-fallback"
import { Spinner } from "./spinner"
import { projectName } from "../util/project"
import { DialogMoveSession } from "./dialog-move-session"
import { DialogPrompt } from "../ui/dialog-prompt"
import { useToast } from "../ui/toast"
import { errorMessage } from "../util/error"
const RECENT_LIMIT = 8
const RECENT_LIMIT = 3
export const DialogOpenKey = Symbol("DialogOpen")
type OpenTarget = { type: "session"; sessionID: string } | { type: "project"; directory: string }
type OpenTarget =
| { type: "session"; sessionID: string }
| { type: "location"; directory: string; projectID?: string; vcs?: "git" | "hg" }
export async function loadDialogOpen(data: ReturnType<typeof useData>, client: ReturnType<typeof useClient>) {
const [, sessions] = await Promise.all([
@@ -49,8 +56,10 @@ export function DialogOpen(props: { sessions: SessionInfo[] }) {
const paths = useTuiPaths()
const dimensions = useTerminalDimensions()
const shortcuts = Keymap.useShortcuts()
const toast = useToast()
const [filter, setFilter] = createSignal("")
const [selectionMoved, setSelectionMoved] = createSignal(false)
const [selected, setSelected] = createSignal<OpenTarget>()
const [matched] = createResource(
() => {
@@ -84,14 +93,12 @@ export function DialogOpen(props: { sessions: SessionInfo[] }) {
const options = createMemo(() => {
const tabs = openTabs()
// With an empty query the menu shows what is not already one keystroke away: open tabs are
// visible in the strip, so recents exclude them. Typing widens the pool to every session so
// matching a loaded tab by name still switches to it.
const recent = filter().trim()
? sessions()
: sessions()
.filter((session) => !tabs.has(session.id))
.slice(0, RECENT_LIMIT)
const exact = matched()
const recent = sessions()
.filter((session) => !tabs.has(session.id))
.slice(0, RECENT_LIMIT)
.concat(exact && !tabs.has(exact.id) ? [exact] : [])
.filter((session, index, items) => items.findIndex((item) => item.id === session.id) === index)
const sessionOptions = recent.map((session) => {
const project = data.project.get(session.projectID)
const name = projectName(project)
@@ -102,7 +109,7 @@ export function DialogOpen(props: { sessions: SessionInfo[] }) {
title: withTimestampedFallback(session),
searchText: session.id,
value: { type: "session", sessionID: session.id } as OpenTarget,
category: "Sessions",
category: "Recent sessions",
footer: `${name ? `${Locale.truncate(name, 20)} · ` : ""}${timeAgo(session.time.updated)}`,
onSelect: () => location.set(session.location),
gutter: running
@@ -113,47 +120,171 @@ export function DialogOpen(props: { sessions: SessionInfo[] }) {
}
})
const current = location.current?.project
const seen = new Set<string>()
const projectOptions = data.project
.list()
.filter((project) => {
if (project.canonical === "/" || seen.has(project.canonical)) return false
seen.add(project.canonical)
return true
const current = location.current
const locations = new Map<
string,
{
directory: string
title: string
updated: number
category: "Recent worktrees" | "Recent folders"
projectID?: string
vcs?: "git" | "hg"
}
>()
for (const project of data.project.list()) {
if (project.canonical === "/" || isDisposableLocation(project.canonical) || locations.has(project.canonical))
continue
locations.set(project.canonical, {
directory: project.canonical,
title: projectName(project) ?? project.canonical,
updated: project.time.updated,
category: "Recent worktrees",
projectID: project.id,
vcs: project.vcs,
})
.map((project) => {
const title = projectName(project) ?? project.canonical
const footer = abbreviateHome(project.canonical, paths.home)
}
for (const session of sessions()) {
const project = data.project.get(session.projectID)
const managedProject = project && project.canonical !== "/" ? project : undefined
const worktree = managedProject && !session.subpath
const directory = worktree ? session.location.directory : (managedProject?.canonical ?? session.location.directory)
if (isDisposableLocation(directory)) continue
const existing = locations.get(directory)
if (existing) {
existing.updated = Math.max(existing.updated, session.time.updated)
continue
}
locations.set(directory, {
directory,
title:
worktree && directory !== managedProject.canonical
? [projectName(managedProject), path.basename(directory)].filter(Boolean).join(" · ")
: (projectName(project) ?? (path.basename(directory) || directory)),
updated: session.time.updated,
category: managedProject ? "Recent worktrees" : "Recent folders",
projectID: managedProject?.id,
vcs: managedProject?.vcs,
})
}
const locationOptions = [...locations.values()]
.toSorted((a, b) => b.updated - a.updated)
.map((item) => {
const footer = abbreviateHome(item.directory, paths.home)
const width =
dialogSelectContentWidth(Math.min(dialogWidth("large"), dimensions().width - 2)) - stringWidth(title)
dialogSelectContentWidth(Math.min(dialogWidth("large"), dimensions().width - 2)) - stringWidth(item.title)
return {
title,
title: item.title,
footer: truncateFilePath(footer, width),
searchText: footer,
value: { type: "project", directory: project.canonical } as OpenTarget,
category: "Projects",
value: {
type: "location",
directory: item.directory,
projectID: item.projectID,
vcs: item.vcs,
} as OpenTarget,
category: item.category,
gutter:
project.canonical === current?.canonical
item.directory === current?.directory || item.directory === current?.project.canonical
? () => <text fg={theme.text.formfield.selected}></text>
: undefined,
}
})
return [...sessionOptions, ...projectOptions]
return [
...sessionOptions,
...locationOptions.filter((item) => item.category === "Recent worktrees"),
...locationOptions.filter((item) => item.category === "Recent folders"),
]
})
function openLocation(directory: string) {
dialog.clear()
const target = { directory }
route.navigate({ type: "home", location: target })
location.set(target)
}
function openWorktrees(target: OpenTarget | undefined) {
if (target?.type !== "location" || !target.projectID || target.vcs !== "git") return
const projectID = target.projectID
dialog.replace(() => (
<DialogMoveSession
projectID={projectID}
title="Open worktree"
compact={true}
randomWorktree={true}
onSelect={(selection) => {
if (selection.type === "directory") {
openLocation(selection.directory)
return
}
void client.api.worktree
.create({
projectID,
strategy: "git",
directory: path.join(paths.worktree, projectID.slice(0, 6)),
name: selection.name,
})
.then((result) => openLocation(result.directory))
.catch((error) =>
toast.show({ variant: "error", title: "Creating worktree failed", message: errorMessage(error) }),
)
}}
/>
))
}
function browse() {
dialog.replace(() => (
<DialogPrompt
title="Open folder"
placeholder="Absolute path"
value={location.current?.directory ?? paths.home}
onConfirm={(value) => {
const directory = value.trim().replace(/^~(?=$|[\\/])/, paths.home)
if (!directory) return
void client.api.file
.list({ location: { directory } })
.then(() => openLocation(directory))
.catch((error) =>
toast.show({ variant: "error", title: "Could not open folder", message: errorMessage(error) }),
)
}}
/>
))
}
return (
<DialogSelect
title="Open"
placeholder="Search sessions and projects…"
placeholder="Search sessions and worktrees…"
options={options()}
current={currentSessionID() ? ({ type: "session", sessionID: currentSessionID()! } as OpenTarget) : undefined}
focusCurrent={false}
sectionNavigation={true}
preserveSelection={selectionMoved()}
onMove={() => setSelectionMoved(true)}
onMove={(option) => {
setSelectionMoved(true)
setSelected(option.value)
}}
onFilter={setFilter}
footer={
<text fg={theme.text.default}>
enter <span style={{ fg: theme.text.subdued }}>open</span>
{" "} <span style={{ fg: theme.text.subdued }}>worktrees</span>
{" "}/ <span style={{ fg: theme.text.subdued }}>browse</span>
</text>
}
bindings={[
{
bind: "right",
title: "Open worktrees",
group: "Dialog",
run: () => openWorktrees(selected() ?? options()[0]?.value),
},
{ bind: "/", title: "Browse folders", group: "Dialog", run: browse },
]}
noMatchView={
<box paddingLeft={4} paddingRight={4}>
<text fg={theme.text.subdued}>
@@ -169,9 +300,7 @@ export function DialogOpen(props: { sessions: SessionInfo[] }) {
route.navigate({ type: "session", sessionID: option.value.sessionID })
return
}
const target = { directory: option.value.directory }
route.navigate({ type: "home", location: target })
location.set(target)
openLocation(option.value.directory)
}}
/>
)
@@ -189,3 +318,7 @@ function timeAgo(timestamp: number) {
if (months < 12) return `${months}mo`
return `${Math.floor(days / 365)}y`
}
function isDisposableLocation(directory: string) {
return /^opencode-(?:test|e2e-project)-/.test(path.basename(directory))
}
+1 -1
View File
@@ -96,7 +96,7 @@ export const Definitions = {
"session.move": keybind("none", "Move session"),
"session.new": keybind("<leader>n", "Create a new session"),
"session.list": keybind("<leader>l", "List all sessions"),
"open.menu": keybind("ctrl+o", "Open recent sessions and projects"),
"open.menu": keybind("ctrl+o", "Open recent sessions and worktrees"),
"session.tab.next": keybind("ctrl+tab,alt+down", "Switch to next open session tab"),
"session.tab.previous": keybind("ctrl+shift+tab,alt+up", "Switch to previous open session tab"),
"session.tab.history.back": keybind("none", "Go back in session tab history"),
+144 -2
View File
@@ -75,7 +75,7 @@ test("finds and opens an exact session ID outside the recent list", async () =>
})
try {
await fixture.app.waitForFrame((frame) => frame.includes("Search sessions and projects"))
await fixture.app.waitForFrame((frame) => frame.includes("Search sessions and worktrees"))
await fixture.app.mockInput.typeText(sessionID)
await fixture.app.waitForFrame((frame) => frame.includes("TUI plugin slot API v2"))
@@ -158,7 +158,7 @@ test("waits for sessions before showing the populated picker", async () => {
try {
await fixture.app.renderOnce()
expect(fixture.app.captureCharFrame()).not.toContain("Search sessions and projects")
expect(fixture.app.captureCharFrame()).not.toContain("Search sessions and worktrees")
resolveSessions(
json({
@@ -276,6 +276,148 @@ test("option arrows stay in the only visible section", async () => {
}
})
test("search keeps sessions limited to recents", async () => {
const fixture = await renderOpen((url) => {
if (url.pathname === "/api/project") return json([])
if (url.pathname !== "/api/session") return undefined
return json({
data: Array.from({ length: 9 }, (_, index) => ({
id: `ses_${index}`,
projectID: `proj_${index}`,
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: 10 - index, updated: 10 - index },
title: index === 8 ? "Ancient hidden session" : `Recent session ${index}`,
location: { directory: `/tmp/location-${index}` },
})),
cursor: {},
})
})
try {
await fixture.app.waitForFrame((frame) => frame.includes("Recent sessions"))
await fixture.app.mockInput.typeText("Ancient hidden session")
await fixture.app.waitForFrame(
(frame) => frame.includes("No matches") && frame.split("Ancient hidden session").length === 2,
)
} finally {
await fixture.dispose()
}
})
test("opens worktrees with right and creates a random worktree", async () => {
const fixture = await renderOpen((url) => {
if (url.pathname === "/api/session") return json({ data: [], cursor: {} })
if (url.pathname !== "/api/project") return undefined
return json([
{
id: "proj_test",
canonical: "/tmp/opencode",
vcs: "git",
name: "OpenCode",
time: { created: 1, updated: 2 },
sandboxes: [],
},
])
})
try {
await fixture.app.waitForFrame((frame) => frame.includes("OpenCode") && frame.includes("Recent worktrees"))
fixture.app.mockInput.pressArrow("right")
await fixture.app.waitForFrame((frame) => frame.includes("Open worktree") && frame.includes("New random worktree"))
fixture.app.mockInput.pressEnter()
await fixture.app.waitFor(() => fixture.route.data.type === "home")
expect(fixture.route.data).toEqual({ type: "home", location: { directory: "/tmp/opencode/created" } })
} finally {
await fixture.dispose()
}
})
test("opens the folder prompt with slash", async () => {
const fixture = await renderOpen((url) => {
if (url.pathname === "/api/session") return json({ data: [], cursor: {} })
if (url.pathname === "/api/project") return json([])
return undefined
})
try {
await fixture.app.waitForFrame((frame) => frame.includes("Search sessions and worktrees"))
await fixture.app.mockInput.typeText("/")
await fixture.app.waitForFrame((frame) => frame.includes("Open folder") && frame.includes("/tmp/opencode/home"))
} finally {
await fixture.dispose()
}
})
test("hides disposable test projects from recent locations", async () => {
const fixture = await renderOpen((url) => {
if (url.pathname === "/api/session") return json({ data: [], cursor: {} })
if (url.pathname !== "/api/project") return undefined
return json([
{
id: "proj_real",
canonical: "/workspace/opencode",
name: "OpenCode",
time: { created: 1, updated: 2 },
sandboxes: [],
},
{
id: "proj_test",
canonical: "/tmp/opencode-e2e-project-BX8Aug",
time: { created: 1, updated: 3 },
sandboxes: [],
},
])
})
try {
const frame = await fixture.app.waitForFrame((value) => value.includes("OpenCode"))
expect(frame).not.toContain("opencode-e2e-project-BX8Aug")
} finally {
await fixture.dispose()
}
})
test("shows a session checkout as a recent worktree", async () => {
const fixture = await renderOpen((url) => {
if (url.pathname === "/api/session")
return json({
data: [
{
id: "ses_worktree",
projectID: "proj_opencode",
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: 2, updated: 3 },
title: "Refine Open screen",
location: { directory: "/workspace/worktrees/open-screen" },
},
],
cursor: {},
})
if (url.pathname !== "/api/project") return undefined
return json([
{
id: "proj_opencode",
canonical: "/workspace/opencode",
vcs: "git",
name: "OpenCode",
time: { created: 1, updated: 2 },
sandboxes: [],
},
])
})
try {
const frame = await fixture.app.waitForFrame(
(value) => value.includes("Recent worktrees") && value.includes("OpenCode · open-screen"),
)
expect(frame).toContain("/workspace/worktrees/open-screen")
} finally {
await fixture.dispose()
}
})
async function renderOpen(
handler: FetchHandler,
beforeOpen?: (contexts: {