From 146fdb9de1398f4db4dd20342b9754f6fea355e8 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Thu, 30 Jul 2026 20:08:12 -0400 Subject: [PATCH] feat(tui): add open menu for sessions and projects (#39752) --- packages/tui/src/app.tsx | 30 +--- packages/tui/src/component/dialog-open.tsx | 164 ++++++++++++++++++ packages/tui/src/component/dialog-project.tsx | 77 -------- .../tui/src/component/dialog-session-list.tsx | 8 +- packages/tui/src/config/v1/keybind.ts | 6 +- packages/tui/src/context/session-tabs.tsx | 6 - packages/tui/test/config.test.tsx | 2 - 7 files changed, 178 insertions(+), 115 deletions(-) create mode 100644 packages/tui/src/component/dialog-open.tsx delete mode 100644 packages/tui/src/component/dialog-project.tsx diff --git a/packages/tui/src/app.tsx b/packages/tui/src/app.tsx index 5ccdd817b60..d3736574893 100644 --- a/packages/tui/src/app.tsx +++ b/packages/tui/src/app.tsx @@ -66,7 +66,7 @@ import { DialogThemeList } from "./component/dialog-theme-list" import { DialogHelp } from "./ui/dialog-help" import { DialogAgent } from "./component/dialog-agent" import { DialogSessionList } from "./component/dialog-session-list" -import { DialogProject } from "./component/dialog-project" +import { DialogOpen } from "./component/dialog-open" import { SessionTabs } from "./component/session-tabs" import { ThemeErrorToast } from "./component/theme-error-toast" import { ThemeProvider, useTheme, useThemes } from "./context/theme" @@ -95,13 +95,11 @@ import { StorageProvider } from "./context/storage" registerOpencodeSpinner() -const appGlobalBindingCommands = ["session.list", "session.new"] as const +const appGlobalBindingCommands = ["session.list", "session.new", "open.menu"] as const const sessionTabBindingCommands = [ "session.tab.next", "session.tab.previous", - "session.tab.history.back", - "session.tab.history.forward", "session.tab.next_unread", "session.tab.previous_unread", "session.tab.close", @@ -653,12 +651,12 @@ function App(props: { pair?: DialogPairCredentials }) { }, }, { - name: "project.switch", - title: "Switch project", + name: "open.menu", + title: "Open session or project", category: "Session", - slash: { name: "projects", aliases: ["project"] }, + slash: { name: "open", aliases: ["projects", "project"] }, run: () => { - dialog.replace(() => ) + dialog.replace(() => ) }, }, ...Array.from({ length: 9 }, (_, i) => ({ @@ -685,22 +683,6 @@ function App(props: { pair?: DialogPairCredentials }) { enabled: sessionTabs.enabled, run: () => sessionTabs.cycle(-1), }, - { - name: "session.tab.history.back", - title: "Back in tab history", - category: "Session", - palette: undefined, - enabled: sessionTabs.enabled, - run: () => sessionTabs.history(-1), - }, - { - name: "session.tab.history.forward", - title: "Forward in tab history", - category: "Session", - palette: undefined, - enabled: sessionTabs.enabled, - run: () => sessionTabs.history(1), - }, { name: "session.tab.next_unread", title: "Next unread tab", diff --git a/packages/tui/src/component/dialog-open.tsx b/packages/tui/src/component/dialog-open.tsx new file mode 100644 index 00000000000..bad3a3c35b4 --- /dev/null +++ b/packages/tui/src/component/dialog-open.tsx @@ -0,0 +1,164 @@ +import path from "path" +import { createMemo, createResource, createSignal, onMount } from "solid-js" +import type { SessionInfo } from "@opencode-ai/client" +import { useTerminalDimensions } from "@opentui/solid" +import { useDialog } from "../ui/dialog" +import { DialogSelect } from "../ui/dialog-select" +import { useRoute } from "../context/route" +import { useData } from "../context/data" +import { useClient } from "../context/client" +import { useLocation } from "../context/location" +import { useSessionTabs } from "../context/session-tabs" +import { useTheme, useThemes } from "../context/theme" +import { Keymap } from "../context/keymap" +import { Locale } from "../util/locale" +import { abbreviateHome } from "../runtime" +import { useTuiPaths } from "../context/runtime" +import { truncateFilePath } from "../ui/file-path" +import { stringWidth } from "../util/string-width" +import { Spinner } from "./spinner" + +const RECENT_LIMIT = 8 + +type OpenTarget = { type: "session"; sessionID: string } | { type: "project"; directory: string } + +export function DialogOpen() { + const dialog = useDialog() + const route = useRoute() + const data = useData() + const client = useClient() + const location = useLocation() + const sessionTabs = useSessionTabs() + const themes = useThemes() + const theme = useTheme("elevated") + const mode = themes.mode + const paths = useTuiPaths() + const dimensions = useTerminalDimensions() + const shortcuts = Keymap.useShortcuts() + const [filter, setFilter] = createSignal("") + + data.project.invalidate() + void data.project.sync().catch(() => {}) + + // One background fetch fills in recent sessions from other projects; the menu renders + // immediately from the local store and never blocks on the network. + const [fetched] = createResource( + () => + client.api.session + .list({ limit: 50, order: "desc", parentID: null }) + .then((response) => response.data) + .catch(() => [] as SessionInfo[]), + { initialValue: [] }, + ) + + const openTabs = createMemo(() => new Set(sessionTabs.tabs().map((tab) => tab.sessionID))) + const currentSessionID = createMemo(() => (route.data.type === "session" ? route.data.sessionID : undefined)) + const sessions = createMemo(() => { + const seen = new Set() + return [...data.session.list(), ...fetched()] + .filter((session) => { + if (session.parentID || seen.has(session.id)) return false + seen.add(session.id) + return true + }) + .toSorted((a, b) => b.time.updated - a.time.updated) + }) + + 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 tab by name still switches to it. + const recent = filter().trim() + ? sessions() + : sessions() + .filter((session) => !tabs.has(session.id)) + .slice(0, RECENT_LIMIT) + const sessionOptions = recent.map((session) => { + const project = data.project.get(session.projectID) + const name = project?.name || path.basename(project?.canonical ?? session.location.directory) + const running = data.session.family(session.id).some((id) => data.session.status(id) === "running") + return { + title: session.title, + value: { type: "session", sessionID: session.id } as OpenTarget, + category: "Sessions", + footer: `${Locale.truncate(name, 20)} · ${timeAgo(session.time.updated)}`, + gutter: running + ? () => + : tabs.has(session.id) + ? () => + : undefined, + } + }) + + const current = location.current?.project + const seen = new Set() + const projectOptions = data.project + .list() + .filter((project) => { + if (project.canonical === "/" || project.id === current?.id || seen.has(project.canonical)) return false + seen.add(project.canonical) + return true + }) + .map((project) => { + const title = project.name ?? path.basename(project.canonical) + const description = abbreviateHome(project.canonical, paths.home) + // Dialog padding, the gutter column, title padding, and the separating space use nine columns. + const width = Math.min(60, dimensions().width - 2) - 9 - stringWidth(title) + return { + title, + description: truncateFilePath(description, width), + searchText: description, + value: { type: "project", directory: project.canonical } as OpenTarget, + category: "Projects", + } + }) + + return [...sessionOptions, ...projectOptions] + }) + + onMount(() => dialog.setSize("large")) + + return ( + + + {shortcuts.get("session.list") + ? `No matches · search all sessions with ${shortcuts.get("session.list")}` + : "No matches"} + + + } + onSelect={(option) => { + dialog.clear() + if (option.value.type === "session") { + route.navigate({ type: "session", sessionID: option.value.sessionID }) + return + } + const target = { directory: option.value.directory } + route.navigate({ type: "home", location: target }) + location.set(target) + }} + /> + ) +} + +function timeAgo(timestamp: number) { + const minutes = Math.floor((Date.now() - timestamp) / 60_000) + if (minutes < 1) return "now" + if (minutes < 60) return `${minutes}m` + const hours = Math.floor(minutes / 60) + if (hours < 24) return `${hours}h` + const days = Math.floor(hours / 24) + if (days < 30) return `${days}d` + const months = Math.floor(days / 30) + if (months < 12) return `${months}mo` + return `${Math.floor(days / 365)}y` +} diff --git a/packages/tui/src/component/dialog-project.tsx b/packages/tui/src/component/dialog-project.tsx deleted file mode 100644 index 4ce9b59dd34..00000000000 --- a/packages/tui/src/component/dialog-project.tsx +++ /dev/null @@ -1,77 +0,0 @@ -import path from "path" -import { createMemo } from "solid-js" -import { DialogSelect } from "../ui/dialog-select" -import { useDialog } from "../ui/dialog" -import { useData } from "../context/data" -import { useRoute } from "../context/route" -import { abbreviateHome } from "../runtime" -import { useTuiPaths } from "../context/runtime" -import { useLocation } from "../context/location" -import { useToast } from "../ui/toast" -import { useTerminalDimensions } from "@opentui/solid" -import { truncateFilePath } from "../ui/file-path" -import { stringWidth } from "../util/string-width" - -export function DialogProject() { - const dialog = useDialog() - const data = useData() - const route = useRoute() - const paths = useTuiPaths() - const location = useLocation() - const toast = useToast() - const dimensions = useTerminalDimensions() - - data.project.invalidate() - void data.project.sync().catch(toast.error) - - const current = () => location.current?.project - - const options = createMemo(() => { - const seen = new Set() - return data.project - .list() - .filter((project) => { - if (project.canonical === "/" || seen.has(project.canonical)) return false - seen.add(project.canonical) - return true - }) - .toSorted((a, b) => { - if (a.id === current()?.id) return -1 - if (b.id === current()?.id) return 1 - return 0 - }) - .map((project) => { - const title = project.name ?? path.basename(project.canonical) - const description = abbreviateHome(project.canonical, paths.home) - // Dialog padding, the current marker, title padding, and the separating space use nine columns. - const width = Math.min(60, dimensions().width - 2) - 9 - stringWidth(title) - return { - title, - description: truncateFilePath(description, width), - searchText: description, - value: project.canonical, - } - }) - }) - - return ( - - No projects found - - } - onSelect={(option) => { - dialog.clear() - if (option.value === current()?.canonical) return - const target = { directory: option.value } - route.navigate({ type: "home", location: target }) - location.set(target) - }} - /> - ) -} diff --git a/packages/tui/src/component/dialog-session-list.tsx b/packages/tui/src/component/dialog-session-list.tsx index 5bf451b4a6a..5d08e5590c4 100644 --- a/packages/tui/src/component/dialog-session-list.tsx +++ b/packages/tui/src/component/dialog-session-list.tsx @@ -17,6 +17,7 @@ import { DialogSessionRename } from "./dialog-session-rename" import { Spinner } from "./spinner" import { errorMessage } from "../util/error" import { useSessionTabs } from "../context/session-tabs" +import { useStorage } from "../context/storage" export function DialogSessionList() { const dialog = useDialog() @@ -33,7 +34,8 @@ export function DialogSessionList() { const shortcuts = Keymap.useShortcuts() const [search, setSearch] = createDebouncedSignal("", 150) const [toDelete, setToDelete] = createSignal() - const [allProjects, setAllProjects] = createSignal(false) + const [prefs, updatePrefs] = useStorage().store("session-list", { initial: { allProjects: false } }) + const allProjects = () => prefs.allProjects const [searchResults, { mutate: setSearchResults }] = createResource( () => ({ query: search().trim(), allProjects: allProjects() }), @@ -189,7 +191,9 @@ export function DialogSessionList() { title: allProjects() ? "Show current directory sessions" : "Show all project sessions", group: "Dialog", run: () => { - setAllProjects((value) => !value) + void updatePrefs((draft) => { + draft.allProjects = !draft.allProjects + }).catch(() => {}) }, }, ]} diff --git a/packages/tui/src/config/v1/keybind.ts b/packages/tui/src/config/v1/keybind.ts index 508099dc760..9787f9ad5d5 100644 --- a/packages/tui/src/config/v1/keybind.ts +++ b/packages/tui/src/config/v1/keybind.ts @@ -87,10 +87,9 @@ export const Definitions = { session_move: keybind("none", "Move session"), session_new: keybind("n", "Create a new session"), session_list: keybind("l", "List all sessions"), + open_menu: keybind("ctrl+o", "Open recent sessions and projects"), session_tab_next: keybind("ctrl+tab,right,alt+shift+]", "Switch to next open tab"), session_tab_previous: keybind("ctrl+shift+tab,left,alt+shift+[", "Switch to previous open tab"), - session_tab_history_back: keybind("ctrl+o", "Go back in tab history"), - session_tab_history_forward: keybind("ctrl+i", "Go forward in tab history"), session_tab_next_unread: keybind("down", "Switch to next unread tab"), session_tab_previous_unread: keybind("up", "Switch to previous unread tab"), session_tab_close: keybind("w", "Close current tab"), @@ -291,10 +290,9 @@ export const CommandMap = { session_move: "session.move", session_new: "session.new", session_list: "session.list", + open_menu: "open.menu", session_tab_next: "session.tab.next", session_tab_previous: "session.tab.previous", - session_tab_history_back: "session.tab.history.back", - session_tab_history_forward: "session.tab.history.forward", session_tab_next_unread: "session.tab.next_unread", session_tab_previous_unread: "session.tab.previous_unread", session_tab_close: "session.tab.close", diff --git a/packages/tui/src/context/session-tabs.tsx b/packages/tui/src/context/session-tabs.tsx index df359f99a89..a93042927b8 100644 --- a/packages/tui/src/context/session-tabs.tsx +++ b/packages/tui/src/context/session-tabs.tsx @@ -296,12 +296,6 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp ) if (tab) route.navigate({ type: "session", sessionID: tab.sessionID }) }, - history(direction: 1 | -1) { - if (!enabled()) return - const next = moveSessionTabHistory(history, state().tabs, current(), direction) - history = next.history - if (next.sessionID) route.navigate({ type: "session", sessionID: next.sessionID }) - }, selectIndex(index: number) { if (!enabled()) return const tab = state().tabs[index] diff --git a/packages/tui/test/config.test.tsx b/packages/tui/test/config.test.tsx index 0feb3dda3b7..a9d5a007fef 100644 --- a/packages/tui/test/config.test.tsx +++ b/packages/tui/test/config.test.tsx @@ -110,8 +110,6 @@ test("navigates session tabs with leader arrows", () => { expect(config.keybinds.get("session.tab.previous")).toMatchObject([ { key: "ctrl+shift+tab,left,alt+shift+[" }, ]) - expect(config.keybinds.get("session.tab.history.back")).toMatchObject([{ key: "ctrl+o" }]) - expect(config.keybinds.get("session.tab.history.forward")).toMatchObject([{ key: "ctrl+i" }]) expect(config.keybinds.get("session.tab.next_unread")).toMatchObject([{ key: "down" }]) expect(config.keybinds.get("session.tab.previous_unread")).toMatchObject([{ key: "up" }]) })