From a32c480d45a485af7df121422eefaee472d0b582 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Tue, 14 Jul 2026 16:17:53 -0400 Subject: [PATCH] refactor(tui): centralize active location --- packages/plugin/src/v2/tui/context.ts | 1 + packages/tui/src/app.tsx | 36 +-- packages/tui/src/component/prompt/move.tsx | 19 +- packages/tui/src/context/location.tsx | 20 +- .../tui/src/feature-plugins/home/footer.tsx | 14 +- packages/tui/src/plugin/context.tsx | 5 + packages/tui/src/routes/home.tsx | 82 +++--- .../src/routes/home/session-destination.tsx | 41 --- packages/tui/src/routes/session/index.tsx | 249 +++++++++--------- 9 files changed, 218 insertions(+), 249 deletions(-) delete mode 100644 packages/tui/src/routes/home/session-destination.tsx diff --git a/packages/plugin/src/v2/tui/context.ts b/packages/plugin/src/v2/tui/context.ts index fba5f934cc6..5e33b030792 100644 --- a/packages/plugin/src/v2/tui/context.ts +++ b/packages/plugin/src/v2/tui/context.ts @@ -172,6 +172,7 @@ export interface UI { export interface Context { readonly options: Readonly> + readonly location: LocationRef | undefined readonly client: OpenCodeClient readonly data: Data readonly keymap: Keymap diff --git a/packages/tui/src/app.tsx b/packages/tui/src/app.tsx index 9672bba5401..db970e3ef72 100644 --- a/packages/tui/src/app.tsx +++ b/packages/tui/src/app.tsx @@ -333,15 +333,15 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) { - - - - - - - - - + + + + + + + + + - - - - - - - - - + + + + + + + + + diff --git a/packages/tui/src/component/prompt/move.tsx b/packages/tui/src/component/prompt/move.tsx index 94219163749..cc63f5ed5f1 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 { useHomeSessionDestination } from "../../routes/home/session-destination" import { useProject } from "../../context/project" import { useData } from "../../context/data" @@ -19,13 +18,13 @@ export function usePromptMove(input: { projectID: () => string | undefined; sess const dialog = useDialog() const client = useClient() const toast = useToast() - const homeDestination = useHomeSessionDestination() const project = useProject() const data = useData() const paths = useTuiPaths() const [creating, setCreating] = createSignal(false) const [creatingDots, setCreatingDots] = createSignal(3) const [progress, setProgress] = createSignal() + const [destination, setDestination] = createSignal() async function create(name: string) { const projectID = await resolveProjectID() @@ -49,7 +48,7 @@ export function usePromptMove(input: { projectID: () => string | undefined; sess setProgress("Creating session") return directory } catch (err) { - homeDestination?.clear() + setDestination(undefined) setProgress(undefined) setCreating(false) toast.show({ title: "Creating workspace failed", message: errorMessage(err), variant: "error" }) @@ -69,7 +68,7 @@ export function usePromptMove(input: { projectID: () => string | undefined; sess string | undefined; sess subdirectory: project.instance.directory() !== project.instance.path().worktree, }) } - onCurrentChange={(selection) => homeDestination?.setDestination(selection)} + onCurrentChange={setDestination} onSelect={(selection) => { const sessionID = input.sessionID() if (!sessionID) { - homeDestination?.setDestination(selection) + setDestination(selection) dialog.clear() return } @@ -144,11 +143,11 @@ export function usePromptMove(input: { projectID: () => string | undefined; sess return data.session.get(sessionID) } - const pending = createMemo(() => Boolean(homeDestination?.destination())) - const pendingNew = createMemo(() => homeDestination?.destination()?.type === "new") + const pending = createMemo(() => Boolean(destination())) + const pendingNew = createMemo(() => destination()?.type === "new") async function getDirectory() { - const value = homeDestination?.destination() + const value = destination() if (!value) return if (value.type === "directory") { return value.directory @@ -161,7 +160,7 @@ export function usePromptMove(input: { projectID: () => string | undefined; sess } function finishSubmit() { - homeDestination?.clear() + setDestination(undefined) setProgress(undefined) setCreating(false) } diff --git a/packages/tui/src/context/location.tsx b/packages/tui/src/context/location.tsx index 52f73c0bbbe..0714ffdb7e1 100644 --- a/packages/tui/src/context/location.tsx +++ b/packages/tui/src/context/location.tsx @@ -1,14 +1,24 @@ import type { LocationRef } from "@opencode-ai/client" -import { createContext, useContext, type Accessor, type ParentProps } from "solid-js" +import { createContext, createSignal, useContext, type Accessor, type ParentProps, type Setter } from "solid-js" -const context = createContext>() +const context = createContext<{ + current: Accessor + set: Setter +}>() -export function LocationProvider(props: ParentProps<{ location?: LocationRef }>) { - return props.location}>{props.children} +export function LocationProvider(props: ParentProps) { + const [current, set] = createSignal() + 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 + 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 } diff --git a/packages/tui/src/feature-plugins/home/footer.tsx b/packages/tui/src/feature-plugins/home/footer.tsx index 6956ad86786..8a1fc02cb2a 100644 --- a/packages/tui/src/feature-plugins/home/footer.tsx +++ b/packages/tui/src/feature-plugins/home/footer.tsx @@ -4,19 +4,15 @@ import { createMemo, Match, Show, Switch } from "solid-js" import { useTerminalDimensions } from "@opentui/solid" import { useTuiPaths } from "../../context/runtime" import { useTheme } from "../../context/theme" -import { useHomeSessionDestination } from "../../routes/home/session-destination" import { abbreviateHome } from "../../runtime" import { FilePath } from "../../ui/file-path" function Directory(props: { context: Plugin.Context; maxWidth: number }) { const { theme } = useTheme() - const destination = useHomeSessionDestination() const paths = useTuiPaths() - const directory = createMemo(() => { - const selected = destination?.destination() - if (!selected || selected.type === "new") return - return abbreviateHome(selected.directory || props.context.data.location.default().directory, paths.home) - }) + const directory = createMemo(() => + props.context.location ? abbreviateHome(props.context.location.directory, paths.home) : undefined, + ) return ( @@ -27,7 +23,7 @@ function Directory(props: { context: Plugin.Context; maxWidth: number }) { function Mcp(props: { context: Plugin.Context }) { const { theme } = useTheme() - const list = createMemo(() => props.context.data.location.mcp.server.list() ?? []) + const list = createMemo(() => props.context.data.location.mcp.server.list(props.context.location) ?? []) const failed = createMemo(() => list().some((item) => item.status.status === "failed")) const count = createMemo(() => list().filter((item) => item.status.status === "connected").length) @@ -55,7 +51,7 @@ function View(props: { context: Plugin.Context }) { const { theme } = useTheme() const dimensions = useTerminalDimensions() const mcpWidth = createMemo(() => { - const list = props.context.data.location.mcp.server.list() ?? [] + const list = props.context.data.location.mcp.server.list(props.context.location) ?? [] if (list.length === 0) return 0 const count = list.filter((item) => item.status.status === "connected").length return Bun.stringWidth(`⊙ ${count} MCP /status`) + 2 diff --git a/packages/tui/src/plugin/context.tsx b/packages/tui/src/plugin/context.tsx index a9f95776602..838c72563bc 100644 --- a/packages/tui/src/plugin/context.tsx +++ b/packages/tui/src/plugin/context.tsx @@ -21,6 +21,7 @@ import { useData } from "../context/data" import { Keymap } from "../context/keymap" import { useRoute } from "../context/route" import { useTuiLifecycle } from "../context/runtime" +import { useLocation } from "../context/location" import { builtins } from "./builtins" export interface PackageResolver { @@ -63,6 +64,7 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver }> const keymap = Keymap.use() const shortcuts = Keymap.useShortcuts() const lifecycle = useTuiLifecycle() + const location = useLocation() const directory = config.path ? path.dirname(config.path) : process.cwd() const [store, setStore] = createStore({ ready: false, @@ -82,6 +84,9 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver }> const owned: Dispose[] = [] const context: Context = { options: item.options ?? {}, + get location() { + return location() + }, client: client.api, data, keymap: { diff --git a/packages/tui/src/routes/home.tsx b/packages/tui/src/routes/home.tsx index 4c59d20e688..389989ab1ef 100644 --- a/packages/tui/src/routes/home.tsx +++ b/packages/tui/src/routes/home.tsx @@ -8,9 +8,8 @@ import { usePromptRef } from "../context/prompt" import { useLocal } from "../context/local" import { usePluginRuntime } from "../plugin/runtime" import { useEditorContext } from "../context/editor" -import { HomeSessionDestinationProvider } from "./home/session-destination" import { useData } from "../context/data" -import { LocationProvider } from "../context/location" +import { useSetLocation } from "../context/location" import { FormPrompt } from "./session/form" import { PluginSlot } from "../plugin/context" @@ -29,10 +28,13 @@ export function Home() { const local = useLocal() const editor = useEditorContext() const data = useData() + const setLocation = useSetLocation() // 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())) + onMount(() => { editor.clearSelection() }) @@ -64,47 +66,45 @@ export function Home() { }) return ( - - - - - - - - - - - - - - } - placeholders={placeholder} - disabled={forms().length > 0} - /> - - - - - + <> + + + + + + + - - + + + + } + placeholders={placeholder} + disabled={forms().length > 0} + /> + - - {(_) => { - const form = forms()[0] - return form ? ( - - - - + + + + + + + + + {(_) => { + const form = forms()[0] + return form ? ( + + + - ) : null - }} - - - + + ) : null + }} + + ) } diff --git a/packages/tui/src/routes/home/session-destination.tsx b/packages/tui/src/routes/home/session-destination.tsx deleted file mode 100644 index bf1e1c1db52..00000000000 --- a/packages/tui/src/routes/home/session-destination.tsx +++ /dev/null @@ -1,41 +0,0 @@ -import { - createContext, - createMemo, - createSignal, - useContext, - type Accessor, - type ParentProps, - type Setter, -} from "solid-js" -import { useTuiPaths } from "../../context/runtime" -import { useProject } from "../../context/project" - -export type HomeSessionDestination = { type: "directory"; directory: string; subdirectory: boolean } | { type: "new"; name: string } - -type Context = { - destination: Accessor - setDestination: Setter - clear: () => void -} - -const HomeSessionDestinationContext = createContext() - -export function HomeSessionDestinationProvider(props: ParentProps) { - const project = useProject() - const paths = useTuiPaths() - const [selected, setDestination] = createSignal() - const destination = createMemo( - () => selected() ?? { type: "directory", directory: project.instance.directory() || paths.cwd, subdirectory: false }, - ) - return ( - setDestination(undefined) }} - > - {props.children} - - ) -} - -export function useHomeSessionDestination() { - return useContext(HomeSessionDestinationContext) -} diff --git a/packages/tui/src/routes/session/index.tsx b/packages/tui/src/routes/session/index.tsx index 7118df30d22..9a3456c7a9e 100644 --- a/packages/tui/src/routes/session/index.tsx +++ b/packages/tui/src/routes/session/index.tsx @@ -71,7 +71,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 { LocationProvider } from "../../context/location" +import { useSetLocation } from "../../context/location" import { createSessionRows, resolvePart, type PartRef, type SessionRow } from "./rows" import { switchLabel } from "../../util/model" @@ -115,6 +115,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() + + createEffect(() => setLocation(location())) createEffect(() => { const title = Locale.truncate(session()?.title ?? "", 50) @@ -823,133 +826,131 @@ export function Session() { ) return ( - - - - - - (scroll = r)} - viewportOptions={{ - paddingRight: showScrollbar() ? 1 : 0, - }} - verticalScrollbarOptions={{ - paddingLeft: 1, - visible: showScrollbar(), - trackOptions: { - backgroundColor: theme.backgroundElement, - foregroundColor: theme.border, - }, - }} - stickyScroll={true} - stickyStart="bottom" - flexGrow={1} - scrollAcceleration={scrollAcceleration()} - > - - {(row) => ( - data.session.message.get(route.sessionID, messageID)} - /> - )} - - - - message.id >= session()!.revert!.messageID && message.type === "user", - ).length - } - files={session()!.revert!.files ?? []} + + + + + (scroll = r)} + viewportOptions={{ + paddingRight: showScrollbar() ? 1 : 0, + }} + verticalScrollbarOptions={{ + paddingLeft: 1, + visible: showScrollbar(), + trackOptions: { + backgroundColor: theme.backgroundElement, + foregroundColor: theme.border, + }, + }} + stickyScroll={true} + stickyStart="bottom" + flexGrow={1} + scrollAcceleration={scrollAcceleration()} + > + + {(row) => ( + data.session.message.get(route.sessionID, messageID)} /> - - - - setComposer("open", false)} + )} + + + + message.id >= session()!.revert!.messageID && message.type === "user", + ).length + } + files={session()!.revert!.files ?? []} /> - - {null} - 0}> - - - 0}> - - {(_) => { - const form = forms()[0] - return form ? : null - }} - - - - + + + setComposer("open", false)} + /> + + {null} + 0}> + + + 0}> + + {(_) => { + const form = forms()[0] + return form ? : null + }} + + + + + - { - toBottom() - }} - sessionID={route.sessionID} - right={} - /> - - - - - - - - - - - - - - - - - - + disabled={false} + onSubmit={() => { + toBottom() + }} + sessionID={route.sessionID} + right={} + /> + + + + + - - + + + + + + + + + + + + + + ) } @@ -2500,9 +2501,7 @@ function Subagent(props: ToolProps) { const { navigate } = useRoute() const data = useData() const input = createMemo(() => (typeof props.part.state.input === "string" ? {} : props.part.state.input)) - const metadata = createMemo(() => - props.part.state.status === "streaming" ? {} : props.part.state.structured, - ) + const metadata = createMemo(() => (props.part.state.status === "streaming" ? {} : props.part.state.structured)) const sessionID = createMemo(() => stringValue(metadata().sessionID) ?? stringValue(metadata().sessionId)) const description = createMemo(() => stringValue(input().description)) const isRunning = createMemo(() => {