From 4bba7886eaf634b0128cfdd30b904938d3f85b46 Mon Sep 17 00:00:00 2001 From: Akshar Patel Date: Fri, 16 Jan 2026 18:21:24 -0500 Subject: [PATCH 1/4] feat: add directory navigation to /session dialog in TUI --- packages/opencode/src/cli/cmd/tui/app.tsx | 63 ++-- .../cmd/tui/component/dialog-session-list.tsx | 284 ++++++++++++++++-- .../src/cli/cmd/tui/context/directory.ts | 24 +- .../src/cli/cmd/tui/context/keybind.tsx | 4 + .../opencode/src/cli/cmd/tui/context/sdk.tsx | 39 ++- packages/opencode/src/cli/cmd/tui/thread.ts | 5 + .../src/cli/cmd/tui/ui/dialog-select.tsx | 87 ++++-- packages/opencode/src/cli/cmd/tui/worker.ts | 3 + packages/opencode/src/config/config.ts | 2 + packages/sdk/js/src/gen/types.gen.ts | 8 + packages/sdk/js/src/v2/gen/types.gen.ts | 8 + 11 files changed, 450 insertions(+), 77 deletions(-) diff --git a/packages/opencode/src/cli/cmd/tui/app.tsx b/packages/opencode/src/cli/cmd/tui/app.tsx index 2ec1fb703f9..e8079a08504 100644 --- a/packages/opencode/src/cli/cmd/tui/app.tsx +++ b/packages/opencode/src/cli/cmd/tui/app.tsx @@ -10,6 +10,7 @@ import { DialogProvider as DialogProviderList } from "@tui/component/dialog-prov import { SDKProvider, useSDK } from "@tui/context/sdk" import { SyncProvider, useSync } from "@tui/context/sync" import { LocalProvider, useLocal } from "@tui/context/local" +import { DirectoryProvider, useDirectoryState } from "@tui/context/directory" import { DialogModel, useConnected } from "@tui/component/dialog-model" import { DialogMcp } from "@tui/component/dialog-mcp" import { DialogStatus } from "@tui/component/dialog-status" @@ -106,6 +107,7 @@ export function tui(input: { fetch?: typeof fetch events?: EventSource onExit?: () => Promise + switchDirectory?: (directory: string) => Promise }) { // promise to prevent immediate exit return new Promise(async (resolve) => { @@ -126,34 +128,9 @@ export function tui(input: { - - - - - - - - - - - - - - - - - - - - - - - + + + @@ -180,6 +157,36 @@ export function tui(input: { }) } +function DirectoryShell(props: { url: string; fetch?: typeof fetch; events?: EventSource; mode: "dark" | "light" }) { + const directoryState = useDirectoryState() + + return ( + + + + + + + + + + + + + + + + + + + + + + + + ) +} + function App() { const route = useRoute() const dimensions = useTerminalDimensions() diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-session-list.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-session-list.tsx index 85c174c1dcb..119bd558abf 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-session-list.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-session-list.tsx @@ -1,7 +1,9 @@ import { useDialog } from "@tui/ui/dialog" -import { DialogSelect } from "@tui/ui/dialog-select" +import { DialogSelect, type DialogSelectRef } from "@tui/ui/dialog-select" +import { DialogConfirm } from "@tui/ui/dialog-confirm" import { useRoute } from "@tui/context/route" import { useSync } from "@tui/context/sync" +import { useKeyboard } from "@opentui/solid" import { createMemo, createSignal, createResource, onMount, Show } from "solid-js" import { Locale } from "@/util/locale" import { useKeybind } from "../context/keybind" @@ -10,9 +12,24 @@ import { useSDK } from "../context/sdk" import { DialogSessionRename } from "./dialog-session-rename" import { useKV } from "../context/kv" import { createDebouncedSignal } from "../util/signal" +import { useDirectoryState } from "../context/directory" +import { Global } from "@/global" +import path from "path" import "opentui-spinner/solid" -export function DialogSessionList() { +type SessionOptionValue = { + id: string + directory: string +} + +export function DialogSessionList( + props: { + directory?: string + initialSearch?: string + initialSelection?: SessionOptionValue + initialScrollTop?: number + } = {}, +) { const dialog = useDialog() const route = useRoute() const sync = useSync() @@ -20,21 +37,80 @@ export function DialogSessionList() { const { theme } = useTheme() const sdk = useSDK() const kv = useKV() + const directoryState = useDirectoryState() + const clientCache = new Map>() + const sessionCache = new Map() + let dialogRef: DialogSelectRef | undefined const [toDelete, setToDelete] = createSignal() - const [search, setSearch] = createDebouncedSignal("", 150) + const [search, setSearch] = createDebouncedSignal(props.initialSearch ?? "", 150) + + const currentDirectory = createMemo(() => sync.data.path.directory || directoryState.current || process.cwd()) + const activeDirectory = createMemo(() => props.directory ?? currentDirectory()) + const isCurrentDirectory = createMemo(() => activeDirectory() === currentDirectory()) + + const clientFor = (directory: string) => { + const current = currentDirectory() + if (directory === current) return sdk.client + const cached = clientCache.get(directory) + if (cached) return cached + const client = sdk.createClient(directory) + clientCache.set(directory, client) + return client + } - const [searchResults] = createResource(search, async (query) => { - if (!query) return undefined - const result = await sdk.client.session.list({ search: query, limit: 30 }) - return result.data ?? [] + const [searchResults] = createResource( + () => { + const query = search() + if (!query) return undefined + return { query, directory: activeDirectory() } + }, + async (input) => { + if (!input) return undefined + const client = clientFor(input.directory) + const result = await client.session.list({ search: input.query, limit: 30, directory: input.directory }) + return result.data ?? [] + }, + ) + + const [directorySessions] = createResource(activeDirectory, async (directory) => { + const current = currentDirectory() + if (!directory) return undefined + if (directory === current) return undefined + const cached = sessionCache.get(directory) + if (cached) return cached + const client = clientFor(directory) + const result = await client.session.list({ limit: 200, directory }) + const data = result.data ?? [] + sessionCache.set(directory, data) + return data }) + const directoryKeybind = createMemo(() => keybind.all.session_directory?.[0]) + const currentSessionID = createMemo(() => (route.data.type === "session" ? route.data.sessionID : undefined)) - const spinnerFrames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"] + const sessions = createMemo(() => { + const searched = searchResults() + if (searched !== undefined) return searched + if (!isCurrentDirectory()) { + const scoped = directorySessions() + if (scoped !== undefined) return scoped + return sessionCache.get(activeDirectory()) ?? [] + } + const current = currentDirectory() + return sync.data.session.filter((item) => item.directory === current) + }) - const sessions = createMemo(() => searchResults() ?? sync.data.session) + const currentSession = createMemo(() => { + const sessionID = currentSessionID() + if (!sessionID) return undefined + const session = sessions().find((item) => item.id === sessionID) + if (!session) return undefined + return { id: session.id, directory: session.directory } + }) + + const spinnerFrames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"] const options = createMemo(() => { const today = new Date().toDateString() @@ -50,10 +126,11 @@ export function DialogSessionList() { const isDeleting = toDelete() === x.id const status = sync.data.session_status?.[x.id] const isWorking = status?.type === "busy" + const value: SessionOptionValue = { id: x.id, directory: x.directory } return { title: isDeleting ? `Press ${keybind.print("session_delete")} again to confirm` : x.title, bg: isDeleting ? theme.error : undefined, - value: x.id, + value, category, footer: Locale.time(x.time.updated), gutter: isWorking ? ( @@ -65,47 +142,210 @@ export function DialogSessionList() { }) }) + useKeyboard((evt) => { + if (options().length > 0) return + if (!keybind.match("session_directory", evt)) return + evt.preventDefault() + evt.stopPropagation() + showDirectorySelect() + }) + + const showDirectorySelect = () => { + const current = activeDirectory() + dialog.replace(() => ( + { + dialog.replace(() => ) + }} + onCancel={() => { + dialog.replace(() => ) + }} + /> + )) + } + + const openSession = async (option: { value: SessionOptionValue }) => { + const sessionID = option.value.id + const directory = option.value.directory + const current = currentDirectory() + if (directory !== current) { + const display = directory.replace(Global.Path.home, "~") + const restore = { + directory: activeDirectory(), + search: dialogRef?.filter ?? search(), + selection: option.value, + scrollTop: dialogRef?.scrollTop ?? 0, + } + const confirmed = await DialogConfirm.show(dialog, "Switch directory", `Switch to ${display} to open this session?`) + if (!confirmed) { + setTimeout(() => { + dialog.replace(() => ( + + )) + }, 1) + return + } + await directoryState.switchTo(directory) + await sync.bootstrap() + route.navigate({ + type: "session", + sessionID, + }) + dialog.clear() + return + } + route.navigate({ + type: "session", + sessionID, + }) + dialog.clear() + } + onMount(() => { dialog.setSize("large") }) return ( (dialogRef = ref)} title="Sessions" options={options()} skipFilter={true} - current={currentSessionID()} + current={currentSession()} + selected={props.initialSelection} + initialFilter={props.initialSearch} + initialScrollTop={props.initialScrollTop} onFilter={setSearch} onMove={() => { setToDelete(undefined) }} - onSelect={(option) => { - route.navigate({ - type: "session", - sessionID: option.value, - }) - dialog.clear() - }} + onSelect={openSession} keybind={[ { keybind: keybind.all.session_delete?.[0], title: "delete", + disabled: !isCurrentDirectory(), onTrigger: async (option) => { - if (toDelete() === option.value) { + if (toDelete() === option.value.id) { sdk.client.session.delete({ - sessionID: option.value, + sessionID: option.value.id, }) setToDelete(undefined) return } - setToDelete(option.value) + setToDelete(option.value.id) }, }, { keybind: keybind.all.session_rename?.[0], title: "rename", + disabled: !isCurrentDirectory(), onTrigger: async (option) => { - dialog.replace(() => ) + dialog.replace(() => ) + }, + }, + { + keybind: directoryKeybind(), + title: "directory", + onTrigger: showDirectorySelect, + }, + ]} + /> + ) +} + +function DialogDirectorySelect(props: { + current: string + onSelect: (directory: string) => void + onCancel: () => void +}) { + const dialog = useDialog() + const sdk = useSDK() + const keybind = useKeybind() + + const [directories, { refetch }] = createResource(async () => { + const projects = (await sdk.client.project.list().catch(() => ({ data: [] }))).data ?? [] + if (projects.length === 0) return [] + const lists = await Promise.all( + projects.map(async (project) => { + const client = sdk.createClient(project.worktree) + return (await client.session.list({ limit: 200 }).catch(() => ({ data: [] }))).data ?? [] + }), + ) + + const map = new Map() + for (const sessions of lists) { + for (const session of sessions) { + const existing = map.get(session.directory) + if (!existing) { + map.set(session.directory, session.time.updated) + continue + } + if (session.time.updated > existing) { + map.set(session.directory, session.time.updated) + } + } + } + + return Array.from(map.entries()) + .map(([directory, updated]) => ({ directory, updated })) + .toSorted((a, b) => b.updated - a.updated) + }) + + const options = createMemo(() => { + const list = directories() + if (!list) return [] + return list.map((entry) => { + const display = entry.directory.replace(Global.Path.home, "~") + const title = path.basename(entry.directory) || display + return { + title, + value: entry.directory, + description: Locale.truncate(display, 60), + footer: directories.loading ? "Refreshing…" : Locale.time(entry.updated), + } + }) + }) + + const currentSelection = createMemo(() => { + const list = directories() + if (!list) return undefined + if (list.some((entry) => entry.directory === props.current)) return props.current + return undefined + }) + + onMount(() => { + dialog.setSize("large") + }) + + useKeyboard((evt) => { + if (evt.name !== "escape") return + evt.preventDefault() + evt.stopPropagation() + props.onCancel() + }) + + return ( + { + props.onSelect(option.value) + }} + keybind={[ + { + keybind: keybind.all.session_directory_refresh?.[0], + title: "refresh", + onTrigger: () => { + refetch() }, }, ]} diff --git a/packages/opencode/src/cli/cmd/tui/context/directory.ts b/packages/opencode/src/cli/cmd/tui/context/directory.ts index 17e5c180a19..d2c0500b970 100644 --- a/packages/opencode/src/cli/cmd/tui/context/directory.ts +++ b/packages/opencode/src/cli/cmd/tui/context/directory.ts @@ -1,6 +1,7 @@ -import { createMemo } from "solid-js" +import { createMemo, createSignal } from "solid-js" import { useSync } from "./sync" import { Global } from "@/global" +import { createSimpleContext } from "./helper" export function useDirectory() { const sync = useSync() @@ -11,3 +12,24 @@ export function useDirectory() { return result }) } + +export const { use: useDirectoryState, provider: DirectoryProvider } = createSimpleContext({ + name: "DirectoryState", + init: (props: { directory?: string; onSwitch?: (directory: string) => Promise }) => { + const [current, setCurrent] = createSignal(props.directory ?? process.cwd()) + + const switchTo = async (directory: string) => { + if (directory === current()) return true + await props.onSwitch?.(directory) + setCurrent(directory) + return true + } + + return { + get current() { + return current() + }, + switchTo, + } + }, +}) diff --git a/packages/opencode/src/cli/cmd/tui/context/keybind.tsx b/packages/opencode/src/cli/cmd/tui/context/keybind.tsx index 4c82e594c3e..8bd72cf5efc 100644 --- a/packages/opencode/src/cli/cmd/tui/context/keybind.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/keybind.tsx @@ -77,6 +77,10 @@ export const { use: useKeybind, provider: KeybindProvider } = createSimpleContex if (evt.name === "\x1F") { return Keybind.fromParsedKey({ ...evt, name: "_", ctrl: true }, store.leader) } + // Handle Ctrl+G (bell) which some terminals emit as \x07 + if (evt.name === "\x07") { + return Keybind.fromParsedKey({ ...evt, name: "g", ctrl: true }, store.leader) + } return Keybind.fromParsedKey(evt, store.leader) }, match(key: keyof KeybindsConfig, evt: ParsedKey) { diff --git a/packages/opencode/src/cli/cmd/tui/context/sdk.tsx b/packages/opencode/src/cli/cmd/tui/context/sdk.tsx index 3339e7b00d2..70f7aee0712 100644 --- a/packages/opencode/src/cli/cmd/tui/context/sdk.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/sdk.tsx @@ -1,7 +1,7 @@ import { createOpencodeClient, type Event } from "@opencode-ai/sdk/v2" import { createSimpleContext } from "./helper" import { createGlobalEmitter } from "@solid-primitives/event-bus" -import { batch, onCleanup, onMount } from "solid-js" +import { batch, createEffect, createSignal, on, onCleanup, onMount } from "solid-js" export type EventSource = { on: (handler: (event: Event) => void) => () => void @@ -11,12 +11,26 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({ name: "SDK", init: (props: { url: string; directory?: string; fetch?: typeof fetch; events?: EventSource }) => { const abort = new AbortController() - const sdk = createOpencodeClient({ - baseUrl: props.url, - signal: abort.signal, - directory: props.directory, - fetch: props.fetch, - }) + const createClient = (directory?: string) => { + return createOpencodeClient({ + baseUrl: props.url, + signal: abort.signal, + directory, + fetch: props.fetch, + }) + } + + const [sdk, setSdk] = createSignal(createClient(props.directory)) + + createEffect( + on( + () => props.directory, + (directory) => { + setSdk(createClient(directory)) + }, + { defer: true }, + ), + ) const emitter = createGlobalEmitter<{ [key in Event["type"]]: Extract @@ -65,7 +79,7 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({ // Fall back to SSE while (true) { if (abort.signal.aborted) break - const events = await sdk.event.subscribe( + const events = await sdk().event.subscribe( {}, { signal: abort.signal, @@ -89,6 +103,13 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({ if (timer) clearTimeout(timer) }) - return { client: sdk, event: emitter, url: props.url } + return { + get client() { + return sdk() + }, + createClient, + event: emitter, + url: props.url, + } }, }) diff --git a/packages/opencode/src/cli/cmd/tui/thread.ts b/packages/opencode/src/cli/cmd/tui/thread.ts index 05714268545..3a361447845 100644 --- a/packages/opencode/src/cli/cmd/tui/thread.ts +++ b/packages/opencode/src/cli/cmd/tui/thread.ts @@ -140,6 +140,10 @@ export const TuiThreadCommand = cmd({ events = createEventSource(client) } + const switchDirectory = async (directory: string) => { + await client.call("switchDirectory", { directory }) + } + const tuiPromise = tui({ url, fetch: customFetch, @@ -154,6 +158,7 @@ export const TuiThreadCommand = cmd({ onExit: async () => { await client.call("shutdown", undefined) }, + switchDirectory, }) setTimeout(() => { diff --git a/packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx b/packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx index f1cdaaa5292..e82eb9c55ed 100644 --- a/packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx +++ b/packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx @@ -1,7 +1,7 @@ import { InputRenderable, RGBA, ScrollBoxRenderable, TextAttributes } from "@opentui/core" import { useTheme, selectedForeground } from "@tui/context/theme" import { entries, filter, flatMap, groupBy, pipe, take } from "remeda" -import { batch, createEffect, createMemo, For, Show, type JSX, on } from "solid-js" +import { batch, createEffect, createMemo, createSignal, For, Show, type JSX, on } from "solid-js" import { createStore } from "solid-js/store" import { useKeyboard, useTerminalDimensions } from "@opentui/solid" import * as fuzzysort from "fuzzysort" @@ -27,6 +27,9 @@ export interface DialogSelectProps { onTrigger: (option: DialogSelectOption) => void }[] current?: T + selected?: T + initialFilter?: string + initialScrollTop?: number } export interface DialogSelectOption { @@ -44,6 +47,7 @@ export interface DialogSelectOption { export type DialogSelectRef = { filter: string filtered: DialogSelectOption[] + scrollTop: number } export function DialogSelect(props: DialogSelectProps) { @@ -51,13 +55,14 @@ export function DialogSelect(props: DialogSelectProps) { const { theme } = useTheme() const [store, setStore] = createStore({ selected: 0, - filter: "", + filter: props.initialFilter ?? "", }) createEffect( on( () => props.current, (current) => { + if (props.selected) return if (current) { const currentIndex = flat().findIndex((opt) => isDeepEqual(opt.value, current)) if (currentIndex >= 0) { @@ -68,6 +73,36 @@ export function DialogSelect(props: DialogSelectProps) { ), ) + const [selectedApplied, setSelectedApplied] = createSignal(false) + const [scrollTopApplied, setScrollTopApplied] = createSignal(false) + + createEffect( + on( + () => props.selected, + (selected) => { + if (!selected) { + setSelectedApplied(false) + return + } + setSelectedApplied(false) + }, + ), + ) + + createEffect( + on([() => props.selected, () => flat().length], ([selected]) => { + if (!selected || selectedApplied()) return + const selectedIndex = flat().findIndex((opt) => isDeepEqual(opt.value, selected)) + if (selectedIndex < 0) return + setStore("selected", selectedIndex) + setSelectedApplied(true) + setTimeout(() => { + if (!scroll) return + moveTo(selectedIndex) + }, 0) + }), + ) + let input: InputRenderable const filtered = createMemo(() => { @@ -112,11 +147,12 @@ export function DialogSelect(props: DialogSelectProps) { setTimeout(() => { if (filter.length > 0) { moveTo(0, true) - } else if (current) { - const currentIndex = flat().findIndex((opt) => isDeepEqual(opt.value, current)) - if (currentIndex >= 0) { - moveTo(currentIndex, true) - } + return + } + if (!current) return + const currentIndex = flat().findIndex((opt) => isDeepEqual(opt.value, current)) + if (currentIndex >= 0) { + moveTo(currentIndex, true) } }, 0) }), @@ -142,16 +178,19 @@ export function DialogSelect(props: DialogSelectProps) { if (center) { const centerOffset = Math.floor(scroll.height / 2) scroll.scrollBy(y - centerOffset) - } else { - if (y >= scroll.height) { - scroll.scrollBy(y - scroll.height + 1) - } - if (y < 0) { - scroll.scrollBy(y) - if (isDeepEqual(flat()[0].value, selected()?.value)) { - scroll.scrollTo(0) - } + if (isDeepEqual(flat()[0].value, selected()?.value)) { + scroll.scrollTo(0) } + return + } + if (y >= scroll.height) { + scroll.scrollBy(y - scroll.height + 1) + } + if (y < 0) { + scroll.scrollBy(y) + } + if (isDeepEqual(flat()[0].value, selected()?.value)) { + scroll.scrollTo(0) } } @@ -193,6 +232,9 @@ export function DialogSelect(props: DialogSelectProps) { get filtered() { return filtered() }, + get scrollTop() { + return scroll?.scrollTop ?? 0 + }, } props.ref?.(ref) @@ -220,6 +262,9 @@ export function DialogSelect(props: DialogSelectProps) { focusedTextColor={theme.textMuted} ref={(r) => { input = r + if (props.initialFilter) { + input.value = props.initialFilter + } setTimeout(() => input.focus(), 1) }} placeholder={props.placeholder ?? "Search"} @@ -238,7 +283,15 @@ export function DialogSelect(props: DialogSelectProps) { paddingLeft={1} paddingRight={1} scrollbarOptions={{ visible: false }} - ref={(r: ScrollBoxRenderable) => (scroll = r)} + ref={(r: ScrollBoxRenderable) => { + scroll = r + if (props.initialScrollTop === undefined || props.selected || scrollTopApplied()) return + setTimeout(() => { + if (!scroll || props.initialScrollTop === undefined) return + scroll.scrollTop = props.initialScrollTop + setScrollTopApplied(true) + }, 1) + }} maxHeight={height()} > diff --git a/packages/opencode/src/cli/cmd/tui/worker.ts b/packages/opencode/src/cli/cmd/tui/worker.ts index e63f10ba80c..fed3bec8383 100644 --- a/packages/opencode/src/cli/cmd/tui/worker.ts +++ b/packages/opencode/src/cli/cmd/tui/worker.ts @@ -121,6 +121,9 @@ export const rpc = { server = Server.listen(input) return { url: server.url.toString() } }, + async switchDirectory(input: { directory: string }) { + startEventStream(input.directory) + }, async checkUpgrade(input: { directory: string }) { await Instance.provide({ directory: input.directory, diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 355b3ba0017..1ad3c806514 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -644,6 +644,8 @@ export namespace Config { session_export: z.string().optional().default("x").describe("Export session to editor"), session_new: z.string().optional().default("n").describe("Create a new session"), session_list: z.string().optional().default("l").describe("List all sessions"), + session_directory: z.string().optional().default("ctrl+g").describe("Open session directory selector"), + session_directory_refresh: z.string().optional().default("ctrl+r").describe("Refresh session directory list"), session_timeline: z.string().optional().default("g").describe("Show session timeline"), session_fork: z.string().optional().default("none").describe("Fork session from message"), session_rename: z.string().optional().default("ctrl+r").describe("Rename session"), diff --git a/packages/sdk/js/src/gen/types.gen.ts b/packages/sdk/js/src/gen/types.gen.ts index 32f33f66219..63e80537915 100644 --- a/packages/sdk/js/src/gen/types.gen.ts +++ b/packages/sdk/js/src/gen/types.gen.ts @@ -814,6 +814,14 @@ export type KeybindsConfig = { * List all sessions */ session_list?: string + /** + * Open session directory selector + */ + session_directory?: string + /** + * Refresh session directory list + */ + session_directory_refresh?: string /** * Show session timeline */ diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 32321a7dfd8..3f0ff60de70 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -967,6 +967,14 @@ export type KeybindsConfig = { * List all sessions */ session_list?: string + /** + * Open session directory selector + */ + session_directory?: string + /** + * Refresh session directory list + */ + session_directory_refresh?: string /** * Show session timeline */ From 11ca8c95daf2beee7acf226bfa5f39096391926d Mon Sep 17 00:00:00 2001 From: Akshar Patel Date: Sat, 17 Jan 2026 14:13:14 -0500 Subject: [PATCH 2/4] change from process.cwd() to active session directory, to fix @ing --- .../src/cli/cmd/tui/component/prompt/autocomplete.tsx | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx b/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx index 601eb82bc48..6295c7af55c 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx @@ -12,6 +12,8 @@ import { useTerminalDimensions } from "@opentui/solid" import { Locale } from "@/util/locale" import type { PromptInfo } from "./history" import { useFrecency } from "./frecency" +import { useDirectoryState } from "@tui/context/directory" +import path from "path" function removeLineRange(input: string) { const hashIndex = input.lastIndexOf("#") @@ -77,6 +79,7 @@ export function Autocomplete(props: { const sdk = useSDK() const sync = useSync() const command = useCommandDialog() + const directoryState = useDirectoryState() const { theme } = useTheme() const dimensions = useTerminalDimensions() const frecency = useFrecency() @@ -225,9 +228,11 @@ export function Autocomplete(props: { }) const width = props.anchor().width - 4 + const root = sync.data.path.directory || directoryState.current || process.cwd() options.push( ...sortedFiles.map((item): AutocompleteOption => { - let url = `file://${process.cwd()}/${item}` + const urlPath = path.join(root, item) + let url = `file://${urlPath}` let filename = item if (lineRange && !item.endsWith("/")) { filename = `${item}#${lineRange.startLine}${lineRange.endLine ? `-${lineRange.endLine}` : ""}` From 7cee8293fe0f3dc671f55d605719533484be7b2a Mon Sep 17 00:00:00 2001 From: Akshar Patel Date: Sat, 17 Jan 2026 14:22:47 -0500 Subject: [PATCH 3/4] change UI copy from directory to project --- .../src/cli/cmd/tui/component/dialog-session-list.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-session-list.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-session-list.tsx index 119bd558abf..b00fabd7151 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-session-list.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-session-list.tsx @@ -177,7 +177,7 @@ export function DialogSessionList( selection: option.value, scrollTop: dialogRef?.scrollTop ?? 0, } - const confirmed = await DialogConfirm.show(dialog, "Switch directory", `Switch to ${display} to open this session?`) + const confirmed = await DialogConfirm.show(dialog, "Switch project", `Switch to ${display} to open this session?`) if (!confirmed) { setTimeout(() => { dialog.replace(() => ( @@ -252,7 +252,7 @@ export function DialogSessionList( }, { keybind: directoryKeybind(), - title: "directory", + title: "project", onTrigger: showDirectorySelect, }, ]} @@ -333,8 +333,8 @@ function DialogDirectorySelect(props: { return ( { From a3b5ade3732a7938f772b36226569d4f3dd5ce69 Mon Sep 17 00:00:00 2001 From: Akshar Patel Date: Sat, 17 Jan 2026 15:08:28 -0500 Subject: [PATCH 4/4] change from directory to project for vars/fields --- packages/opencode/src/cli/cmd/tui/app.tsx | 16 +-- .../cmd/tui/component/dialog-session-list.tsx | 114 +++++++++--------- .../cmd/tui/component/prompt/autocomplete.tsx | 6 +- .../src/cli/cmd/tui/context/directory.ts | 16 +-- packages/opencode/src/cli/cmd/tui/thread.ts | 6 +- packages/opencode/src/cli/cmd/tui/worker.ts | 4 +- packages/opencode/src/config/config.ts | 4 +- packages/sdk/js/src/gen/types.gen.ts | 8 +- packages/sdk/js/src/v2/gen/types.gen.ts | 8 +- 9 files changed, 89 insertions(+), 93 deletions(-) diff --git a/packages/opencode/src/cli/cmd/tui/app.tsx b/packages/opencode/src/cli/cmd/tui/app.tsx index e8079a08504..266f1dfcb99 100644 --- a/packages/opencode/src/cli/cmd/tui/app.tsx +++ b/packages/opencode/src/cli/cmd/tui/app.tsx @@ -10,7 +10,7 @@ import { DialogProvider as DialogProviderList } from "@tui/component/dialog-prov import { SDKProvider, useSDK } from "@tui/context/sdk" import { SyncProvider, useSync } from "@tui/context/sync" import { LocalProvider, useLocal } from "@tui/context/local" -import { DirectoryProvider, useDirectoryState } from "@tui/context/directory" +import { ProjectProvider, useProjectState } from "@tui/context/directory" import { DialogModel, useConnected } from "@tui/component/dialog-model" import { DialogMcp } from "@tui/component/dialog-mcp" import { DialogStatus } from "@tui/component/dialog-status" @@ -107,7 +107,7 @@ export function tui(input: { fetch?: typeof fetch events?: EventSource onExit?: () => Promise - switchDirectory?: (directory: string) => Promise + switchProject?: (project: string) => Promise }) { // promise to prevent immediate exit return new Promise(async (resolve) => { @@ -128,9 +128,9 @@ export function tui(input: { - - - + + + @@ -157,11 +157,11 @@ export function tui(input: { }) } -function DirectoryShell(props: { url: string; fetch?: typeof fetch; events?: EventSource; mode: "dark" | "light" }) { - const directoryState = useDirectoryState() +function ProjectShell(props: { url: string; fetch?: typeof fetch; events?: EventSource; mode: "dark" | "light" }) { + const projectState = useProjectState() return ( - + diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-session-list.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-session-list.tsx index b00fabd7151..a7c9cc477a0 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-session-list.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-session-list.tsx @@ -12,7 +12,7 @@ import { useSDK } from "../context/sdk" import { DialogSessionRename } from "./dialog-session-rename" import { useKV } from "../context/kv" import { createDebouncedSignal } from "../util/signal" -import { useDirectoryState } from "../context/directory" +import { useProjectState } from "../context/directory" import { Global } from "@/global" import path from "path" import "opentui-spinner/solid" @@ -24,7 +24,7 @@ type SessionOptionValue = { export function DialogSessionList( props: { - directory?: string + project?: string initialSearch?: string initialSelection?: SessionOptionValue initialScrollTop?: number @@ -37,7 +37,7 @@ export function DialogSessionList( const { theme } = useTheme() const sdk = useSDK() const kv = useKV() - const directoryState = useDirectoryState() + const projectState = useProjectState() const clientCache = new Map>() const sessionCache = new Map() let dialogRef: DialogSelectRef | undefined @@ -45,17 +45,17 @@ export function DialogSessionList( const [toDelete, setToDelete] = createSignal() const [search, setSearch] = createDebouncedSignal(props.initialSearch ?? "", 150) - const currentDirectory = createMemo(() => sync.data.path.directory || directoryState.current || process.cwd()) - const activeDirectory = createMemo(() => props.directory ?? currentDirectory()) - const isCurrentDirectory = createMemo(() => activeDirectory() === currentDirectory()) + const currentProject = createMemo(() => sync.data.path.directory || projectState.current || process.cwd()) + const activeProject = createMemo(() => props.project ?? currentProject()) + const isCurrentProject = createMemo(() => activeProject() === currentProject()) - const clientFor = (directory: string) => { - const current = currentDirectory() - if (directory === current) return sdk.client - const cached = clientCache.get(directory) + const clientFor = (project: string) => { + const current = currentProject() + if (project === current) return sdk.client + const cached = clientCache.get(project) if (cached) return cached - const client = sdk.createClient(directory) - clientCache.set(directory, client) + const client = sdk.createClient(project) + clientCache.set(project, client) return client } @@ -63,7 +63,7 @@ export function DialogSessionList( () => { const query = search() if (!query) return undefined - return { query, directory: activeDirectory() } + return { query, directory: activeProject() } }, async (input) => { if (!input) return undefined @@ -73,32 +73,32 @@ export function DialogSessionList( }, ) - const [directorySessions] = createResource(activeDirectory, async (directory) => { - const current = currentDirectory() - if (!directory) return undefined - if (directory === current) return undefined - const cached = sessionCache.get(directory) + const [projectSessions] = createResource(activeProject, async (project) => { + const current = currentProject() + if (!project) return undefined + if (project === current) return undefined + const cached = sessionCache.get(project) if (cached) return cached - const client = clientFor(directory) - const result = await client.session.list({ limit: 200, directory }) + const client = clientFor(project) + const result = await client.session.list({ limit: 200, directory: project }) const data = result.data ?? [] - sessionCache.set(directory, data) + sessionCache.set(project, data) return data }) - const directoryKeybind = createMemo(() => keybind.all.session_directory?.[0]) + const projectKeybind = createMemo(() => keybind.all.session_project?.[0]) const currentSessionID = createMemo(() => (route.data.type === "session" ? route.data.sessionID : undefined)) const sessions = createMemo(() => { const searched = searchResults() if (searched !== undefined) return searched - if (!isCurrentDirectory()) { - const scoped = directorySessions() + if (!isCurrentProject()) { + const scoped = projectSessions() if (scoped !== undefined) return scoped - return sessionCache.get(activeDirectory()) ?? [] + return sessionCache.get(activeProject()) ?? [] } - const current = currentDirectory() + const current = currentProject() return sync.data.session.filter((item) => item.directory === current) }) @@ -144,22 +144,22 @@ export function DialogSessionList( useKeyboard((evt) => { if (options().length > 0) return - if (!keybind.match("session_directory", evt)) return + if (!keybind.match("session_project", evt)) return evt.preventDefault() evt.stopPropagation() - showDirectorySelect() + showProjectSelect() }) - const showDirectorySelect = () => { - const current = activeDirectory() + const showProjectSelect = () => { + const current = activeProject() dialog.replace(() => ( - { - dialog.replace(() => ) + onSelect={(project: string) => { + dialog.replace(() => ) }} onCancel={() => { - dialog.replace(() => ) + dialog.replace(() => ) }} /> )) @@ -167,12 +167,12 @@ export function DialogSessionList( const openSession = async (option: { value: SessionOptionValue }) => { const sessionID = option.value.id - const directory = option.value.directory - const current = currentDirectory() - if (directory !== current) { - const display = directory.replace(Global.Path.home, "~") + const project = option.value.directory + const current = currentProject() + if (project !== current) { + const display = project.replace(Global.Path.home, "~") const restore = { - directory: activeDirectory(), + project: activeProject(), search: dialogRef?.filter ?? search(), selection: option.value, scrollTop: dialogRef?.scrollTop ?? 0, @@ -182,7 +182,7 @@ export function DialogSessionList( setTimeout(() => { dialog.replace(() => ( { if (toDelete() === option.value.id) { sdk.client.session.delete({ @@ -245,36 +245,32 @@ export function DialogSessionList( { keybind: keybind.all.session_rename?.[0], title: "rename", - disabled: !isCurrentDirectory(), + disabled: !isCurrentProject(), onTrigger: async (option) => { dialog.replace(() => ) }, }, { - keybind: directoryKeybind(), + keybind: projectKeybind(), title: "project", - onTrigger: showDirectorySelect, + onTrigger: showProjectSelect, }, ]} /> ) } -function DialogDirectorySelect(props: { - current: string - onSelect: (directory: string) => void - onCancel: () => void -}) { +function DialogProjectSelect(props: { current: string; onSelect: (project: string) => void; onCancel: () => void }) { const dialog = useDialog() const sdk = useSDK() const keybind = useKeybind() - const [directories, { refetch }] = createResource(async () => { - const projects = (await sdk.client.project.list().catch(() => ({ data: [] }))).data ?? [] - if (projects.length === 0) return [] + const [projects, { refetch }] = createResource(async () => { + const entries = (await sdk.client.project.list().catch(() => ({ data: [] }))).data ?? [] + if (entries.length === 0) return [] const lists = await Promise.all( - projects.map(async (project) => { - const client = sdk.createClient(project.worktree) + entries.map(async (entry) => { + const client = sdk.createClient(entry.worktree) return (await client.session.list({ limit: 200 }).catch(() => ({ data: [] }))).data ?? [] }), ) @@ -299,7 +295,7 @@ function DialogDirectorySelect(props: { }) const options = createMemo(() => { - const list = directories() + const list = projects() if (!list) return [] return list.map((entry) => { const display = entry.directory.replace(Global.Path.home, "~") @@ -308,13 +304,13 @@ function DialogDirectorySelect(props: { title, value: entry.directory, description: Locale.truncate(display, 60), - footer: directories.loading ? "Refreshing…" : Locale.time(entry.updated), + footer: projects.loading ? "Refreshing…" : Locale.time(entry.updated), } }) }) const currentSelection = createMemo(() => { - const list = directories() + const list = projects() if (!list) return undefined if (list.some((entry) => entry.directory === props.current)) return props.current return undefined @@ -342,7 +338,7 @@ function DialogDirectorySelect(props: { }} keybind={[ { - keybind: keybind.all.session_directory_refresh?.[0], + keybind: keybind.all.session_project_refresh?.[0], title: "refresh", onTrigger: () => { refetch() diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx b/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx index 6295c7af55c..fb266926fd9 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx @@ -12,7 +12,7 @@ import { useTerminalDimensions } from "@opentui/solid" import { Locale } from "@/util/locale" import type { PromptInfo } from "./history" import { useFrecency } from "./frecency" -import { useDirectoryState } from "@tui/context/directory" +import { useProjectState } from "@tui/context/directory" import path from "path" function removeLineRange(input: string) { @@ -79,7 +79,7 @@ export function Autocomplete(props: { const sdk = useSDK() const sync = useSync() const command = useCommandDialog() - const directoryState = useDirectoryState() + const projectState = useProjectState() const { theme } = useTheme() const dimensions = useTerminalDimensions() const frecency = useFrecency() @@ -228,7 +228,7 @@ export function Autocomplete(props: { }) const width = props.anchor().width - 4 - const root = sync.data.path.directory || directoryState.current || process.cwd() + const root = sync.data.path.directory || projectState.current || process.cwd() options.push( ...sortedFiles.map((item): AutocompleteOption => { const urlPath = path.join(root, item) diff --git a/packages/opencode/src/cli/cmd/tui/context/directory.ts b/packages/opencode/src/cli/cmd/tui/context/directory.ts index d2c0500b970..0d862c5d1be 100644 --- a/packages/opencode/src/cli/cmd/tui/context/directory.ts +++ b/packages/opencode/src/cli/cmd/tui/context/directory.ts @@ -13,15 +13,15 @@ export function useDirectory() { }) } -export const { use: useDirectoryState, provider: DirectoryProvider } = createSimpleContext({ - name: "DirectoryState", - init: (props: { directory?: string; onSwitch?: (directory: string) => Promise }) => { - const [current, setCurrent] = createSignal(props.directory ?? process.cwd()) +export const { use: useProjectState, provider: ProjectProvider } = createSimpleContext({ + name: "ProjectState", + init: (props: { project?: string; onSwitch?: (project: string) => Promise }) => { + const [current, setCurrent] = createSignal(props.project ?? process.cwd()) - const switchTo = async (directory: string) => { - if (directory === current()) return true - await props.onSwitch?.(directory) - setCurrent(directory) + const switchTo = async (project: string) => { + if (project === current()) return true + await props.onSwitch?.(project) + setCurrent(project) return true } diff --git a/packages/opencode/src/cli/cmd/tui/thread.ts b/packages/opencode/src/cli/cmd/tui/thread.ts index 3a361447845..3dc958a0395 100644 --- a/packages/opencode/src/cli/cmd/tui/thread.ts +++ b/packages/opencode/src/cli/cmd/tui/thread.ts @@ -140,8 +140,8 @@ export const TuiThreadCommand = cmd({ events = createEventSource(client) } - const switchDirectory = async (directory: string) => { - await client.call("switchDirectory", { directory }) + const switchProject = async (project: string) => { + await client.call("switchProject", { project }) } const tuiPromise = tui({ @@ -158,7 +158,7 @@ export const TuiThreadCommand = cmd({ onExit: async () => { await client.call("shutdown", undefined) }, - switchDirectory, + switchProject, }) setTimeout(() => { diff --git a/packages/opencode/src/cli/cmd/tui/worker.ts b/packages/opencode/src/cli/cmd/tui/worker.ts index fed3bec8383..064e6e6e248 100644 --- a/packages/opencode/src/cli/cmd/tui/worker.ts +++ b/packages/opencode/src/cli/cmd/tui/worker.ts @@ -121,8 +121,8 @@ export const rpc = { server = Server.listen(input) return { url: server.url.toString() } }, - async switchDirectory(input: { directory: string }) { - startEventStream(input.directory) + async switchProject(input: { project: string }) { + startEventStream(input.project) }, async checkUpgrade(input: { directory: string }) { await Instance.provide({ diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index dedcff64e48..5e6683a7c4b 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -640,8 +640,8 @@ export namespace Config { session_export: z.string().optional().default("x").describe("Export session to editor"), session_new: z.string().optional().default("n").describe("Create a new session"), session_list: z.string().optional().default("l").describe("List all sessions"), - session_directory: z.string().optional().default("ctrl+g").describe("Open session directory selector"), - session_directory_refresh: z.string().optional().default("ctrl+r").describe("Refresh session directory list"), + session_project: z.string().optional().default("ctrl+g").describe("Open session project selector"), + session_project_refresh: z.string().optional().default("ctrl+r").describe("Refresh session project list"), session_timeline: z.string().optional().default("g").describe("Show session timeline"), session_fork: z.string().optional().default("none").describe("Fork session from message"), session_rename: z.string().optional().default("ctrl+r").describe("Rename session"), diff --git a/packages/sdk/js/src/gen/types.gen.ts b/packages/sdk/js/src/gen/types.gen.ts index 63e80537915..d3d8e2b9cc7 100644 --- a/packages/sdk/js/src/gen/types.gen.ts +++ b/packages/sdk/js/src/gen/types.gen.ts @@ -815,13 +815,13 @@ export type KeybindsConfig = { */ session_list?: string /** - * Open session directory selector + * Open session project selector */ - session_directory?: string + session_project?: string /** - * Refresh session directory list + * Refresh session project list */ - session_directory_refresh?: string + session_project_refresh?: string /** * Show session timeline */ diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index e303ab85a9d..ef9727d2300 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -968,13 +968,13 @@ export type KeybindsConfig = { */ session_list?: string /** - * Open session directory selector + * Open session project selector */ - session_directory?: string + session_project?: string /** - * Refresh session directory list + * Refresh session project list */ - session_directory_refresh?: string + session_project_refresh?: string /** * Show session timeline */