diff --git a/packages/tui/src/app.tsx b/packages/tui/src/app.tsx
index 5ccdd817b6..d373657489 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 0000000000..bad3a3c35b
--- /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 4ce9b59dd3..0000000000
--- 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 5bf451b4a6..5d08e5590c 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 508099dc76..9787f9ad5d 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 df359f99a8..a93042927b 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 0feb3dda3b..a9d5a007fe 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" }])
})