diff --git a/packages/tui/src/app.tsx b/packages/tui/src/app.tsx index 947fb00577..b1b3e9e744 100644 --- a/packages/tui/src/app.tsx +++ b/packages/tui/src/app.tsx @@ -42,14 +42,13 @@ import { DialogProvider, useDialog } from "./ui/dialog" import { DialogIntegration } from "./component/dialog-integration" import { ErrorComponent } from "./component/error-component" import { PluginRouteMissing } from "./component/plugin-route-missing" -import { ProjectProvider, useProject } from "./context/project" import { EditorContextProvider } from "./context/editor" import { useEvent } from "./context/event" import { ClientProvider, useClient } from "./context/client" import { StartupLoading } from "./component/startup-loading" import { Reconnecting } from "./component/reconnecting" import { DataProvider, useData } from "./context/data" -import { LocationProvider } from "./context/location" +import { LocationProvider, useLocation } from "./context/location" import { LocalProvider, useLocal } from "./context/local" import { PermissionProvider } from "./context/permission" import { DialogModel } from "./component/dialog-model" @@ -327,40 +326,38 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) { - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + @@ -413,7 +410,7 @@ function App(props: { pair?: DialogPairCredentials }) { const themeState = useTheme() const { theme, mode, setMode, locked, lock, unlock } = themeState const data = useData() - const project = useProject() + const location = useLocation() const exit = useExit() const promptRef = usePromptRef() const pluginRuntime = usePluginRuntime() @@ -988,12 +985,12 @@ function App(props: { pair?: DialogPairCredentials }) { })) event.on("tui.command.execute", (evt, { workspace }) => { - if (workspace !== project.workspace.current()) return + if (workspace !== (location.current?.workspaceID ?? data.location.default().workspaceID)) return keymap.dispatchCommand(evt.data.command) }) event.on("tui.toast.show", (evt, { workspace }) => { - if (workspace !== project.workspace.current()) return + if (workspace !== (location.current?.workspaceID ?? data.location.default().workspaceID)) return toast.show({ title: evt.data.title, message: evt.data.message, @@ -1003,13 +1000,14 @@ function App(props: { pair?: DialogPairCredentials }) { }) event.on("plugin.updated", (_evt, { directory, workspace }) => { - if (directory !== project.instance.directory()) return - if (workspace !== project.workspace.current()) return + const current = location.current ?? data.location.default() + if (directory !== current.directory) return + if (workspace !== current.workspaceID) return toast.show({ variant: "success", message: "Plugins reloaded" }) }) event.on("tui.session.select", (evt, { workspace }) => { - if (workspace !== project.workspace.current()) return + if (workspace !== (location.current?.workspaceID ?? data.location.default().workspaceID)) return route.navigate({ type: "session", sessionID: evt.data.sessionID, @@ -1027,7 +1025,7 @@ function App(props: { pair?: DialogPairCredentials }) { }) event.on("session.error", (evt, { workspace }) => { - if (workspace !== project.workspace.current()) return + if (workspace !== (location.current?.workspaceID ?? data.location.default().workspaceID)) return const error = evt.data.error if (error && typeof error === "object" && error.name === "MessageAbortedError") return const message = errorMessage(error) diff --git a/packages/tui/src/component/dialog-move-session.tsx b/packages/tui/src/component/dialog-move-session.tsx index 6ff94d690c..9f6c165e17 100644 --- a/packages/tui/src/component/dialog-move-session.tsx +++ b/packages/tui/src/component/dialog-move-session.tsx @@ -14,7 +14,6 @@ import { Locale } from "../util/locale" import { errorMessage } from "../util/error" import { isRecord } from "../util/record" import { useToast } from "../ui/toast" -import { useProject } from "../context/project" import { Spinner } from "./spinner" import { DialogWorkspaceFileChanges } from "./dialog-workspace-file-changes" import type { ProjectDirectoriesOutput } from "@opencode-ai/client" @@ -41,11 +40,11 @@ export function DialogMoveSession(props: DialogMoveSessionProps) { const dimensions = useTerminalDimensions() const { theme } = useTheme() const sessionData = useData() - const projectContext = useProject() const route = useRoute() const toast = useToast() const paths = useTuiPaths() const shortcuts = Keymap.useShortcuts() + const location = createMemo(() => sessionData.location.info()) const [working, setWorking] = createSignal(Boolean(props.initialRemoving)) const [toDelete, setToDelete] = createSignal() const [removing, setRemoving] = createSignal(props.initialRemoving) @@ -63,15 +62,15 @@ export function DialogMoveSession(props: DialogMoveSessionProps) { // swallow it and let the directory list render without a current marker. // Once the current project is known, a mismatch is a guaranteed miss. const [loadedProject] = createResource( - () => (projectContext.project() === undefined ? props.projectID : undefined), + () => (location()?.project.id === props.projectID ? undefined : props.projectID), (projectID) => client.api.project - .current({ location: { directory: projectContext.instance.directory() || paths.cwd } }) + .current({ location: { directory: location()?.directory || paths.cwd } }) .then((project) => (project.id === projectID ? project.directory : undefined)) .catch(() => undefined), ) const currentCheckout = createMemo(() => { - if (projectContext.project() === props.projectID) return projectContext.instance.path().worktree + if (location()?.project.id === props.projectID) return location()?.project.directory return loadedProject() }) @@ -79,14 +78,14 @@ export function DialogMoveSession(props: DialogMoveSessionProps) { () => (props.initialRemoving ? undefined : props.projectID), async (projectID, info): Promise | undefined> => { try { - const location = { directory: projectContext.instance.directory() || paths.cwd } + const requestLocation = { directory: location()?.directory || paths.cwd } await client.api.projectCopy.refresh({ projectID, - location, + location: requestLocation, }) const directories = await client.api.project.directories({ projectID, - location, + location: requestLocation, }) setLoadError(undefined) return directories @@ -204,7 +203,7 @@ export function DialogMoveSession(props: DialogMoveSessionProps) { async function removedCurrent(current: boolean) { if (!current) return false - const fallback = projectContext.data.project.mainDir + const fallback = directoryData()?.findLast((item) => item.strategy === undefined)?.directory if (fallback) setReplacementCurrent(fallback) if (route.data.type === "session") { route.navigate({ type: "home" }) @@ -236,7 +235,7 @@ export function DialogMoveSession(props: DialogMoveSessionProps) { const error = await client.api.projectCopy .remove({ projectID: props.projectID, - location: { directory: projectContext.instance.directory() || paths.cwd }, + location: { directory: location()?.directory || paths.cwd }, directory: selected.directory, force: false, }) @@ -263,7 +262,7 @@ export function DialogMoveSession(props: DialogMoveSessionProps) { const forcedError = await client.api.projectCopy .remove({ projectID: props.projectID, - location: { directory: projectContext.instance.directory() || paths.cwd }, + location: { directory: location()?.directory || paths.cwd }, directory: selected.directory, force: true, }) diff --git a/packages/tui/src/component/dialog-session-list.tsx b/packages/tui/src/component/dialog-session-list.tsx index c7eb69ce37..04783790e4 100644 --- a/packages/tui/src/component/dialog-session-list.tsx +++ b/packages/tui/src/component/dialog-session-list.tsx @@ -7,7 +7,6 @@ import { useRoute } from "../context/route" import { useData } from "../context/data" import { Keymap } from "../context/keymap" import { Locale } from "../util/locale" -import { useProject } from "../context/project" import { useTheme } from "../context/theme" import { useClient } from "../context/client" import { useLocal } from "../context/local" @@ -21,7 +20,6 @@ export function DialogSessionList() { const dialog = useDialog() const route = useRoute() const data = useData() - const project = useProject() const { theme } = useTheme() const client = useClient() const local = useLocal() @@ -33,15 +31,16 @@ export function DialogSessionList() { const [searchResults] = createResource(search, async (query) => { if (!query) return - const location = data.location.default() try { + if (!data.location.info()) await data.location.sync() + const current = data.location.info() + if (!current) throw new Error("Location unavailable") const response = await client.api.session.list({ + project: current.project.id, search: query, limit: 50, order: "desc", parentID: null, - directory: location.directory, - workspace: location.workspaceID, }) return { query, sessions: response.data, error: undefined } } catch (error) { @@ -101,7 +100,8 @@ export function DialogSessionList() { const option = (session: SessionInfo, category: string) => { const directory = session.location.directory - const footer = directory !== project.data.project.mainDir ? Locale.truncate(path.basename(directory), 20) : "" + const footer = + directory !== data.location.info()?.project.directory ? Locale.truncate(path.basename(directory), 20) : "" const slot = slotByID.get(session.id) const deleting = toDelete() === session.id return { diff --git a/packages/tui/src/component/dialog-tag.tsx b/packages/tui/src/component/dialog-tag.tsx index 69d0c51de1..4c1fceb75b 100644 --- a/packages/tui/src/component/dialog-tag.tsx +++ b/packages/tui/src/component/dialog-tag.tsx @@ -1,14 +1,14 @@ import { createMemo, createResource } from "solid-js" import { DialogSelect } from "../ui/dialog-select" import { useDialog } from "../ui/dialog" -import { useProject } from "../context/project" import { useClient } from "../context/client" +import { useData } from "../context/data" import { createStore } from "solid-js/store" export function DialogTag(props: { onSelect?: (value: string) => void }) { const client = useClient() const dialog = useDialog() - const project = useProject() + const data = useData() const [store] = createStore({ filter: "", @@ -22,7 +22,10 @@ export function DialogTag(props: { onSelect?: (value: string) => void }) { query: store.filter, type: "file", limit: 5, - location: { workspace: project.workspace.current() }, + location: { + directory: data.location.default().directory, + workspace: data.location.default().workspaceID, + }, }) .catch(() => undefined) return result?.data.map((item) => item.path) ?? [] diff --git a/packages/tui/src/component/prompt/autocomplete.tsx b/packages/tui/src/component/prompt/autocomplete.tsx index 5b14926896..d046833653 100644 --- a/packages/tui/src/component/prompt/autocomplete.tsx +++ b/packages/tui/src/component/prompt/autocomplete.tsx @@ -6,7 +6,6 @@ import { firstBy } from "remeda" import { createMemo, createResource, createEffect, onMount, onCleanup, Index, Show, createSignal } from "solid-js" import { createStore } from "solid-js/store" import { useEditorContext } from "../../context/editor" -import { useProject } from "../../context/project" import { useClient } from "../../context/client" import { useData } from "../../context/data" import { getScrollAcceleration } from "../../util/scroll" @@ -87,7 +86,6 @@ export function Autocomplete(props: { const editor = useEditorContext() const client = useClient() const data = useData() - const project = useProject() const slashes = useCommandSlashes() const keymap = Keymap.use() const { theme } = useTheme() @@ -284,7 +282,7 @@ export function Autocomplete(props: { }) function normalizeMentionPath(filePath: string) { - const baseDir = location()?.directory || project.instance.directory() || paths.cwd + const baseDir = location.current?.directory || data.location.info()?.directory || paths.cwd const absolute = path.resolve(filePath) const relative = path.relative(baseDir, absolute) @@ -310,7 +308,7 @@ export function Autocomplete(props: { } const [files] = createResource( - () => ({ query: search(), location: location(), visible: store.visible }), + () => ({ query: search(), location: location.current, visible: store.visible }), async (input) => { if (!input.visible || input.visible === "/") return { options: [], failed: false } if (referenceMatch()) return { options: [], failed: false } @@ -322,7 +320,7 @@ export function Autocomplete(props: { limit: 20, location: { directory: input.location?.directory, - workspace: input.location?.workspaceID ?? project.workspace.current(), + workspace: input.location?.workspaceID ?? data.location.default().workspaceID, }, }) .then( @@ -365,7 +363,7 @@ export function Autocomplete(props: { const options: AutocompleteOption[] = [] const width = props.anchor().width - 4 - for (const res of data.location.mcp.resource.list(location()) ?? []) { + for (const res of data.location.mcp.resource.list(location.current) ?? []) { options.push({ display: Locale.truncateMiddle(res.name, width), // Match the name only; matching the URI caused unrelated fuzzy hits. @@ -433,7 +431,7 @@ export function Autocomplete(props: { const results: AutocompleteOption[] = [...slashes()] const commandNames = new Set() - for (const serverCommand of data.location.command.list(location()) ?? []) { + for (const serverCommand of data.location.command.list(location.current) ?? []) { commandNames.add(serverCommand.name) results.push({ display: "/" + serverCommand.name, @@ -449,7 +447,7 @@ export function Autocomplete(props: { } for (const skill of data.location.skill - .list(location()) + .list(location.current) ?.filter((skill) => skill.slash === true && !commandNames.has(skill.id)) ?? []) { results.push({ display: "/" + skill.id, diff --git a/packages/tui/src/component/prompt/index.tsx b/packages/tui/src/component/prompt/index.tsx index 4a66a03ef1..1efa642afe 100644 --- a/packages/tui/src/component/prompt/index.tsx +++ b/packages/tui/src/component/prompt/index.tsx @@ -23,7 +23,6 @@ import { useClipboard } from "../../context/clipboard" import { Spinner } from "../spinner" import { useClient } from "../../context/client" import { useRoute } from "../../context/route" -import { useProject } from "../../context/project" import { useEvent } from "../../context/event" import { editorSelectionKey, useEditorContext, type EditorSelection } from "../../context/editor" import { normalizePromptContent, openEditor } from "../../editor" @@ -151,7 +150,6 @@ export function Prompt(props: PromptProps) { const client = useClient() const editor = useEditorContext() const route = useRoute() - const project = useProject() const data = useData() const currentLocation = useLocation() const config = useConfig().data @@ -165,7 +163,8 @@ export function Prompt(props: PromptProps) { .filter((id) => id !== props.sessionID && data.session.status(id) === "running").length }) const runningShells = createMemo( - () => data.shell.list(currentLocation()).filter((shell) => shell.metadata.sessionID === props.sessionID).length, + () => + data.shell.list(currentLocation.current).filter((shell) => shell.metadata.sessionID === props.sessionID).length, ) const history = usePromptHistory() const stash = usePromptStash() @@ -214,7 +213,8 @@ export function Prompt(props: PromptProps) { const editorContextLabelState = createMemo(() => editor.labelState()) const [auto, setAuto] = createSignal() const move = usePromptMove({ - projectID: () => (props.sessionID ? data.session.get(props.sessionID)?.projectID : undefined) ?? project.project(), + projectID: () => + (props.sessionID ? data.session.get(props.sessionID)?.projectID : undefined) ?? data.location.info()?.project.id, sessionID: () => props.sessionID, }) const [cursorVersion, setCursorVersion] = createSignal(0) @@ -244,7 +244,7 @@ export function Prompt(props: PromptProps) { const event = useEvent() event.on("tui.prompt.append", (evt, { workspace }) => { - if (workspace !== project.workspace.current()) return + if (workspace !== (currentLocation.current?.workspaceID ?? data.location.default().workspaceID)) return if (!input || input.isDestroyed) return input.insertText(evt.data.text) setTimeout(() => { @@ -465,8 +465,8 @@ export function Prompt(props: PromptProps) { renderer, value, cwd: - (project.instance.path().worktree === "/" ? undefined : project.instance.path().worktree) || - project.instance.directory() || + (data.location.info()?.project.directory === "/" ? undefined : data.location.info()?.project.directory) || + data.location.default().directory || paths.cwd, }) if (!content) return @@ -502,7 +502,7 @@ export function Prompt(props: PromptProps) { run: () => { dialog.replace(() => ( { input.setText(`/${skill} `) setStore("prompt", { @@ -1016,7 +1016,7 @@ export function Prompt(props: PromptProps) { setStore("mode", "normal") } else if ( inputText.startsWith("/") && - (data.location.command.list(currentLocation()) ?? []).some( + (data.location.command.list(currentLocation.current) ?? []).some( (command) => command.name === inputText.split("\n")[0].split(" ")[0].slice(1), ) ) { @@ -1043,7 +1043,7 @@ export function Prompt(props: PromptProps) { }) } else if ( inputText.startsWith("/") && - (data.location.skill.list(currentLocation()) ?? []).some( + (data.location.skill.list(currentLocation.current) ?? []).some( (skill) => skill.slash === true && skill.id === inputText.split("\n")[0].split(" ")[0].slice(1), ) ) { diff --git a/packages/tui/src/component/prompt/move.tsx b/packages/tui/src/component/prompt/move.tsx index a1c0471e94..ec9c920bca 100644 --- a/packages/tui/src/component/prompt/move.tsx +++ b/packages/tui/src/component/prompt/move.tsx @@ -7,7 +7,6 @@ import { useClient } from "../../context/client" import { useToast } from "../../ui/toast" import { DialogMoveSession, type MoveSessionSelection } from "../dialog-move-session" import { DialogWorkspaceFileChanges } from "../dialog-workspace-file-changes" -import { useProject } from "../../context/project" import { useData } from "../../context/data" function moveReminderText(directory: string) { @@ -18,7 +17,6 @@ export function usePromptMove(input: { projectID: () => string | undefined; sess const dialog = useDialog() const client = useClient() const toast = useToast() - const project = useProject() const data = useData() const paths = useTuiPaths() const [creating, setCreating] = createSignal(false) @@ -34,7 +32,7 @@ export function usePromptMove(input: { projectID: () => string | undefined; sess try { const result = await client.api.projectCopy.create({ projectID, - location: { directory: project.instance.directory() || paths.cwd }, + location: { directory: data.location.info()?.directory || paths.cwd }, strategy: "git_worktree", directory: path.join(paths.worktree, projectID.slice(0, 6)), name, @@ -77,8 +75,8 @@ export function usePromptMove(input: { projectID: () => string | undefined; sess } : { type: "directory", - directory: project.instance.directory(), - subdirectory: project.instance.directory() !== project.instance.path().worktree, + directory: data.location.default().directory, + subdirectory: data.location.default().directory !== data.location.info()?.project.directory, }) } onCurrentChange={setDestination} @@ -130,8 +128,10 @@ export function usePromptMove(input: { projectID: () => string | undefined; sess if (projectID) return projectID const sessionID = input.sessionID() if (sessionID) return (await resolveSession(sessionID))?.projectID + const current = data.location.info() + if (current) return current.project.id return client.api.project - .current({ location: { directory: project.instance.directory() || paths.cwd } }) + .current({ location: { directory: data.location.default().directory || paths.cwd } }) .then((project) => project.id) .catch(() => undefined) } diff --git a/packages/tui/src/context/data.tsx b/packages/tui/src/context/data.tsx index 04ec20f38c..393ac73fb5 100644 --- a/packages/tui/src/context/data.tsx +++ b/packages/tui/src/context/data.tsx @@ -9,6 +9,7 @@ import type { FormInfo, IntegrationInfo, LocationRef, + LocationGetOutput, McpResource, McpServer, ModelInfo, @@ -43,6 +44,7 @@ const messageIDFromEvent = (eventID: string) => eventID.replace(/^evt_/, "msg_") export type FormWithLocation = FormInfo & { readonly location?: LocationRef } type LocationData = { + info?: LocationGetOutput agent?: AgentInfo[] command?: CommandInfo[] integration?: IntegrationInfo[] @@ -1058,6 +1060,9 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ }, }, location: { + info(ref?: LocationRef) { + return store.location[locationKey(ref ?? defaultLocation())]?.info + }, default() { return defaultLocation() }, @@ -1067,7 +1072,10 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ const location = await client.api.location.get({ location: locationQuery(current) }) const key = locationKey(location) if (!store.location[key]) setStore("location", key, {}) - if (!ref) setDefaultLocation({ directory: location.directory, workspaceID: location.workspaceID }) + setStore("location", key, "info", location) + if (!ref) { + setDefaultLocation({ directory: location.directory, workspaceID: location.workspaceID }) + } }) const location = ref ?? defaultLocation() await Promise.all([ @@ -1275,12 +1283,16 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ ) }) .catch(() => undefined) - void client.api.session - .list({ - limit: 50, - order: "desc", - directory: defaultLocation().directory, - workspace: defaultLocation().workspaceID, + void client.api.location + .get({ location: locationQuery(defaultLocation()) }) + .then((location) => { + const key = locationKey(location) + setStore("location", key, { ...store.location[key], info: location }) + return client.api.session.list({ + project: location.project.id, + limit: 50, + order: "desc", + }) }) .then((response) => { setStore( diff --git a/packages/tui/src/context/directory.ts b/packages/tui/src/context/directory.ts index 2c566f3373..5c4152812b 100644 --- a/packages/tui/src/context/directory.ts +++ b/packages/tui/src/context/directory.ts @@ -1,13 +1,13 @@ import { createMemo } from "solid-js" -import { useProject } from "./project" +import { useData } from "./data" import { abbreviateHome } from "../runtime" import { useTuiPaths } from "./runtime" export function useDirectory() { - const project = useProject() + const data = useData() const paths = useTuiPaths() return createMemo(() => { - const directory = project.instance.path().directory || paths.cwd + const directory = data.location.info()?.directory ?? data.location.default().directory ?? paths.cwd return abbreviateHome(directory, paths.home) }) } diff --git a/packages/tui/src/context/location.tsx b/packages/tui/src/context/location.tsx index 0bde553381..c539d95013 100644 --- a/packages/tui/src/context/location.tsx +++ b/packages/tui/src/context/location.tsx @@ -1,17 +1,18 @@ -import type { LocationRef } from "@opencode-ai/client" -import { createContext, createSignal, onCleanup, useContext, type Accessor, type ParentProps } from "solid-js" +import type { LocationGetOutput, LocationRef } from "@opencode-ai/client" +import { createContext, createMemo, createSignal, onCleanup, useContext, type ParentProps } from "solid-js" import { useClient } from "./client" import { useData } from "./data" const context = createContext<{ - current: Accessor + readonly current: LocationGetOutput | undefined set: (location?: LocationRef) => void }>() export function LocationProvider(props: ParentProps) { const client = useClient() const data = useData() - const [current, setCurrent] = createSignal() + const [ref, setRef] = createSignal() + const current = createMemo(() => data.location.info(ref())) function sync(location?: LocationRef) { if (!location) return @@ -24,23 +25,28 @@ export function LocationProvider(props: ParentProps) { } function set(location?: LocationRef) { - setCurrent(location) + setRef(location) if (client.connection.status() === "connected") sync(location) } - onCleanup(client.event.on("server.connected", () => sync(current()))) + onCleanup(client.event.on("server.connected", () => sync(ref()))) - return {props.children} + return ( + + {props.children} + + ) } export function useLocation() { const value = useContext(context) if (!value) throw new Error("Location context must be used within a LocationProvider") - return value.current -} - -export function useSetLocation() { - const value = useContext(context) - if (!value) throw new Error("Location context must be used within a LocationProvider") - return value.set + return value } diff --git a/packages/tui/src/context/path-format.tsx b/packages/tui/src/context/path-format.tsx index 52b1bee897..d162145e55 100644 --- a/packages/tui/src/context/path-format.tsx +++ b/packages/tui/src/context/path-format.tsx @@ -7,8 +7,8 @@ export function usePathFormatter() { const paths = useTuiPaths() const location = useLocation() return { - path: () => location()?.directory || paths.cwd, - format: (input?: string) => formatPath(input, location()?.directory || paths.cwd, paths.home), + path: () => location.current?.directory || paths.cwd, + format: (input?: string) => formatPath(input, location.current?.directory || paths.cwd, paths.home), } } diff --git a/packages/tui/src/context/project.tsx b/packages/tui/src/context/project.tsx deleted file mode 100644 index 466e663ab1..0000000000 --- a/packages/tui/src/context/project.tsx +++ /dev/null @@ -1,76 +0,0 @@ -import { batch } from "solid-js" -import { createStore, reconcile } from "solid-js/store" -import { createSimpleContext } from "./helper" -import { useClient } from "./client" - -export const { use: useProject, provider: ProjectProvider } = createSimpleContext({ - name: "Project", - init: () => { - const client = useClient() - - const defaultPath = { - home: "", - state: "", - config: "", - worktree: "", - directory: process.cwd(), - } - - const [store, setStore] = createStore({ - project: { - id: undefined as string | undefined, - worktree: undefined as string | undefined, - mainDir: undefined as string | undefined, - }, - instance: { - path: defaultPath, - }, - workspace: { - current: undefined as string | undefined, - }, - }) - - async function sync() { - const workspace = store.workspace.current - const location = { workspace } - const current = await client.api.location.get({ location }) - const directories = await client.api.project.directories({ projectID: current.project.id, location }) - batch(() => { - setStore( - "instance", - "path", - reconcile({ ...defaultPath, worktree: current.project.directory, directory: current.directory }), - ) - setStore("project", "id", current.project.id) - setStore("project", "worktree", current.project.directory) - setStore("project", "mainDir", directories.findLast((item) => item.strategy === undefined)?.directory) - }) - } - - return { - data: store, - project() { - return store.project.id - }, - instance: { - path() { - return store.instance.path - }, - directory() { - return store.instance.path.directory - }, - }, - workspace: { - current() { - return store.workspace.current - }, - set(next?: string | null) { - const workspace = next ?? undefined - if (store.workspace.current === workspace) return - setStore("workspace", "current", workspace) - }, - }, - sync, - } - }, -}) diff --git a/packages/tui/src/feature-plugins/sidebar/footer.tsx b/packages/tui/src/feature-plugins/sidebar/footer.tsx index bea5afa9b5..12e6b965a3 100644 --- a/packages/tui/src/feature-plugins/sidebar/footer.tsx +++ b/packages/tui/src/feature-plugins/sidebar/footer.tsx @@ -1,14 +1,22 @@ import { Plugin } from "@opencode-ai/plugin/v2/tui" +import { createMemo, Show } from "solid-js" +import { useTuiPaths } from "../../context/runtime" import { useTheme } from "../../context/theme" +import { abbreviateHome } from "../../runtime" +import { FilePath } from "../../ui/file-path" -function View() { +function View(props: { context: Plugin.Context }) { const { theme } = useTheme() - return Sidebar footer unavailable + const paths = useTuiPaths() + const directory = createMemo(() => + props.context.location ? abbreviateHome(props.context.location.directory, paths.home) : undefined, + ) + return {(value) => } } export default Plugin.define({ id: "opencode.sidebar-footer", setup(context) { - context.ui.slot("sidebar.footer", () => ) + context.ui.slot("sidebar.footer", () => ) }, }) diff --git a/packages/tui/src/plugin/context.tsx b/packages/tui/src/plugin/context.tsx index 838c72563b..29777342c8 100644 --- a/packages/tui/src/plugin/context.tsx +++ b/packages/tui/src/plugin/context.tsx @@ -85,7 +85,7 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver }> const context: Context = { options: item.options ?? {}, get location() { - return location() + return location.current }, client: client.api, data, diff --git a/packages/tui/src/routes/home.tsx b/packages/tui/src/routes/home.tsx index 389989ab1e..a8cd946a6b 100644 --- a/packages/tui/src/routes/home.tsx +++ b/packages/tui/src/routes/home.tsx @@ -9,7 +9,7 @@ import { useLocal } from "../context/local" import { usePluginRuntime } from "../plugin/runtime" import { useEditorContext } from "../context/editor" import { useData } from "../context/data" -import { useSetLocation } from "../context/location" +import { useLocation } from "../context/location" import { FormPrompt } from "./session/form" import { PluginSlot } from "../plugin/context" @@ -28,12 +28,12 @@ export function Home() { const local = useLocal() const editor = useEditorContext() const data = useData() - const setLocation = useSetLocation() + const location = useLocation() // Global MCP elicitations can arrive without a session route, so keep them reachable from Home. const forms = createMemo(() => data.session.form.list("global", data.location.default()) ?? []) let sent = false - createEffect(() => setLocation(data.location.default())) + createEffect(() => location.set(data.location.default())) onMount(() => { editor.clearSelection() diff --git a/packages/tui/src/routes/session/composer/shell-tab.tsx b/packages/tui/src/routes/session/composer/shell-tab.tsx index 097125092e..956d7715e1 100644 --- a/packages/tui/src/routes/session/composer/shell-tab.tsx +++ b/packages/tui/src/routes/session/composer/shell-tab.tsx @@ -18,9 +18,7 @@ export function ShellTab(props: { sessionID: string }) { const shortcuts = Keymap.useShortcuts() const entries = createMemo(() => - data.shell - .list() - .filter((shell) => shell.metadata.sessionID === props.sessionID && shell.status === "running"), + data.shell.list().filter((shell) => shell.metadata.sessionID === props.sessionID && shell.status === "running"), ) const [store, setStore] = createStore({ selected: 0 }) @@ -47,8 +45,7 @@ export function ShellTab(props: { sessionID: string }) { const cleanup = composer.register({ id: "shell", label: "Shell", - hints: () => - selectedEntry() ? [{ label: "kill", shortcut: shortcuts.get("composer.shell.kill") ?? "" }] : [], + hints: () => (selectedEntry() ? [{ label: "kill", shortcut: shortcuts.get("composer.shell.kill") ?? "" }] : []), }) onCleanup(cleanup) }) @@ -87,7 +84,7 @@ export function ShellTab(props: { sessionID: string }) { run() { const entry = selectedEntry() if (!entry) return - const ref = location() + const ref = location.current void client.api.shell.remove({ id: entry.id, location: ref ? { directory: ref.directory, workspace: ref.workspaceID } : undefined, @@ -99,11 +96,7 @@ export function ShellTab(props: { sessionID: string }) { return ( - (scroll = r)} - > + (scroll = r)}> 0} fallback={ No shell commands}> {(shell, index) => { diff --git a/packages/tui/src/routes/session/index.tsx b/packages/tui/src/routes/session/index.tsx index 69e5644016..7ea96232b3 100644 --- a/packages/tui/src/routes/session/index.tsx +++ b/packages/tui/src/routes/session/index.tsx @@ -18,7 +18,6 @@ import { EOL, tmpdir } from "node:os" import { mkdir, writeFile } from "node:fs/promises" import { useRoute, useRouteData } from "../../context/route" import { createStore } from "solid-js/store" -import { useProject } from "../../context/project" import { useData } from "../../context/data" import { SplitBorder } from "../../ui/border" import { useTuiPaths, useTuiTerminalEnvironment } from "../../context/runtime" @@ -71,7 +70,7 @@ import { collapseToolOutput } from "../../util/collapse-tool-output" import { usePluginRuntime } from "../../plugin/runtime" import { OPENCODE_BASE_MODE, useBindings, useCommandShortcut } from "../../keymap" import { usePathFormatter } from "../../context/path-format" -import { useSetLocation } from "../../context/location" +import { useLocation } from "../../context/location" import { createSessionRows, resolvePart, type PartRef, type SessionRow } from "./rows" import { switchLabel } from "../../util/model" @@ -106,7 +105,6 @@ export function Session() { const route = useRouteData("session") const { navigate } = useRoute() const data = useData() - const project = useProject() const paths = useTuiPaths() const configState = useConfig() const config = configState.data @@ -115,9 +113,9 @@ export function Session() { const session = createMemo(() => data.session.get(route.sessionID)) const messages = () => data.session.message.list(route.sessionID) const location = createMemo(() => session()?.location) - const setLocation = useSetLocation() + const currentLocation = useLocation() - createEffect(() => setLocation(location())) + createEffect(() => currentLocation.set(location())) createEffect(() => { const title = Locale.truncate(session()?.title ?? "", 50) @@ -211,7 +209,6 @@ export function Session() { navigate({ type: "home" }) return } - project.workspace.set(info.location.workspaceID) editor.reconnect(info.location.directory) if (route.sessionID === sessionID && scroll) scroll.scrollBy(100_000) })().catch((error) => { @@ -2348,9 +2345,7 @@ function Shell(props: ToolProps) { .output({ id, limit: 1024 * 1024, - location: location - ? { directory: location.directory, workspace: location.workspaceID } - : undefined, + location: location ? { directory: location.directory, workspace: location.workspaceID } : undefined, }) .then((response) => setBackgroundOutput(stripAnsi(response.data.output.trim()))) .catch(() => undefined) diff --git a/packages/tui/test/cli/tui/data.test.tsx b/packages/tui/test/cli/tui/data.test.tsx index 53c51d6d14..d6e902afbf 100644 --- a/packages/tui/test/cli/tui/data.test.tsx +++ b/packages/tui/test/cli/tui/data.test.tsx @@ -5,10 +5,9 @@ import type { OpenCodeEvent } from "@opencode-ai/client" import { SessionMessage } from "@opencode-ai/core/session/message" import { EventV2 } from "@opencode-ai/core/event" import { createEffect, onMount, type ParentProps } from "solid-js" -import { ProjectProvider } from "../../../src/context/project" import { ClientProvider, useClient } from "../../../src/context/client" import { DataProvider as DataProviderBase, useData } from "../../../src/context/data" -import { LocationProvider, useSetLocation } from "../../../src/context/location" +import { LocationProvider, useLocation } from "../../../src/context/location" import { createSessionRows, type SessionRow } from "../../../src/routes/session/rows" import { createApi, createEventStream, createFetch, directory, json } from "../../fixture/tui-client" import { TestTuiContexts } from "../../fixture/tui-environment" @@ -44,10 +43,14 @@ function DataProvider(props: ParentProps) { ) } +function ProjectProvider(props: ParentProps) { + return props.children +} + function SyncLocation() { const data = useData() - const setLocation = useSetLocation() - createEffect(() => setLocation(data.location.default())) + const location = useLocation() + createEffect(() => location.set(data.location.default())) return null } diff --git a/packages/tui/test/cli/tui/use-event.test.tsx b/packages/tui/test/cli/tui/use-event.test.tsx index 542f33b3d1..42949ce5d0 100644 --- a/packages/tui/test/cli/tui/use-event.test.tsx +++ b/packages/tui/test/cli/tui/use-event.test.tsx @@ -4,7 +4,6 @@ import type { OpenCodeClient, OpenCodeEvent } from "@opencode-ai/client" import type { Service } from "@opencode-ai/client/effect" import { testRender } from "@opentui/solid" import { onMount } from "solid-js" -import { ProjectProvider, useProject } from "../../../src/context/project" import { ClientProvider, useClient } from "../../../src/context/client" import { useEvent } from "../../../src/context/event" import { createApi, createEventStream, createFetch } from "../../fixture/tui-client" @@ -61,7 +60,6 @@ async function mount( const calls = createFetch(undefined, events) const seen: OpenCodeEvent[] = [] const workspaces: Array = [] - let project!: ReturnType let client!: ReturnType let done!: () => void const ready = new Promise((resolve) => { @@ -71,32 +69,27 @@ async function mount( const app = await testRender(() => ( - - { - project = ctx.project - client = ctx.client - await project.sync() - done() - }} - seen={seen} - workspaces={workspaces} - /> - + { + client = ctx.client + done() + }} + seen={seen} + workspaces={workspaces} + /> )) await ready - return { app, events, emit: events.emit, project, client, seen, workspaces } + return { app, events, emit: events.emit, client, seen, workspaces } } function Probe(props: { seen: OpenCodeEvent[] workspaces: Array - onReady: (ctx: { project: ReturnType; client: ReturnType }) => void + onReady: (ctx: { client: ReturnType }) => void }) { - const project = useProject() const client = useClient() const event = useEvent() @@ -105,7 +98,7 @@ function Probe(props: { props.seen.push(evt) props.workspaces.push(workspace) }) - props.onReady({ project, client }) + props.onReady({ client }) }) return @@ -161,10 +154,9 @@ describe("useEvent", () => { }) test("delivers current project events regardless of active workspace", async () => { - const { app, emit, project, seen } = await mount() + const { app, emit, seen } = await mount() try { - project.workspace.set("ws_a") emit(event(vcs("ws"), { directory: "/tmp/other", project: projectID, workspace: "ws_b" })) await wait(() => seen.length === 1) @@ -176,10 +168,9 @@ describe("useEvent", () => { }) test("delivers truly global events even when a workspace is active", async () => { - const { app, emit, project, seen } = await mount() + const { app, emit, seen } = await mount() try { - project.workspace.set("ws_a") emit(event(update("1.2.3"), { directory: "global" })) await wait(() => seen.length === 1) @@ -256,9 +247,7 @@ describe("useEvent", () => { return new Response( new ReadableStream({ start(controller) { - controller.enqueue( - encoder.encode('data: {"id":"evt_connected","type":"server.connected","data":{}}\n\n'), - ) + controller.enqueue(encoder.encode('data: {"id":"evt_connected","type":"server.connected","data":{}}\n\n')) controller.close() }, }),