Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 6 additions & 24 deletions packages/tui/src/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -651,12 +649,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(() => <DialogProject />)
dialog.replace(() => <DialogOpen />)
},
},
...Array.from({ length: 9 }, (_, i) => ({
Expand All @@ -683,22 +681,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",
Expand Down
164 changes: 164 additions & 0 deletions packages/tui/src/component/dialog-open.tsx
Original file line number Diff line number Diff line change
@@ -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<string>()
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
? () => <Spinner />
: tabs.has(session.id)
? () => <text fg={theme.hue.accent[mode() === "light" ? 800 : 200]}>▪</text>
: undefined,
}
})

const current = location.current?.project
const seen = new Set<string>()
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 (
<DialogSelect
title="Open"
placeholder="Search sessions and projects…"
options={options()}
current={currentSessionID() ? ({ type: "session", sessionID: currentSessionID()! } as OpenTarget) : undefined}
focusCurrent={false}
onFilter={setFilter}
noMatchView={
<box paddingLeft={4} paddingRight={4}>
<text fg={theme.text.subdued}>
{shortcuts.get("session.list")
? `No matches · search all sessions with ${shortcuts.get("session.list")}`
: "No matches"}
</text>
</box>
}
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`
}
77 changes: 0 additions & 77 deletions packages/tui/src/component/dialog-project.tsx

This file was deleted.

8 changes: 6 additions & 2 deletions packages/tui/src/component/dialog-session-list.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -33,7 +34,8 @@ export function DialogSessionList() {
const shortcuts = Keymap.useShortcuts()
const [search, setSearch] = createDebouncedSignal("", 150)
const [toDelete, setToDelete] = createSignal<string>()
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() }),
Expand Down Expand Up @@ -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(() => {})
},
},
]}
Expand Down
6 changes: 2 additions & 4 deletions packages/tui/src/config/v1/keybind.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,10 +87,9 @@ 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"),
session_tab_next: keybind("ctrl+tab,<leader>right,alt+shift+]", "Switch to next open tab"),
session_tab_previous: keybind("ctrl+shift+tab,<leader>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("<leader>down", "Switch to next unread tab"),
session_tab_previous_unread: keybind("<leader>up", "Switch to previous unread tab"),
session_tab_close: keybind("<leader>w", "Close current tab"),
Expand Down Expand Up @@ -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",
Expand Down
6 changes: 0 additions & 6 deletions packages/tui/src/context/session-tabs.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
2 changes: 0 additions & 2 deletions packages/tui/test/config.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -110,8 +110,6 @@ test("navigates session tabs with leader arrows", () => {
expect(config.keybinds.get("session.tab.previous")).toMatchObject([
{ key: "ctrl+shift+tab,<leader>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: "<leader>down" }])
expect(config.keybinds.get("session.tab.previous_unread")).toMatchObject([{ key: "<leader>up" }])
})
Expand Down
Loading