refactor(tui): centralize active location

This commit is contained in:
Dax Raad
2026-07-14 16:18:30 -04:00
parent f4f761246a
commit a32c480d45
9 changed files with 218 additions and 249 deletions
+1
View File
@@ -172,6 +172,7 @@ export interface UI {
export interface Context { export interface Context {
readonly options: Readonly<Record<string, any>> readonly options: Readonly<Record<string, any>>
readonly location: LocationRef | undefined
readonly client: OpenCodeClient readonly client: OpenCodeClient
readonly data: Data readonly data: Data
readonly keymap: Keymap readonly keymap: Keymap
+2 -2
View File
@@ -333,6 +333,7 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
<PermissionProvider> <PermissionProvider>
<ProjectProvider> <ProjectProvider>
<DataProvider> <DataProvider>
<LocationProvider>
<ThemeProvider mode={mode}> <ThemeProvider mode={mode}>
<LocalProvider> <LocalProvider>
<PromptStashProvider> <PromptStashProvider>
@@ -341,7 +342,6 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
<PromptHistoryProvider> <PromptHistoryProvider>
<PromptRefProvider> <PromptRefProvider>
<EditorContextProvider> <EditorContextProvider>
<LocationProvider>
<PluginProvider packages={input.packages}> <PluginProvider packages={input.packages}>
<App <App
pair={ pair={
@@ -354,7 +354,6 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
} }
/> />
</PluginProvider> </PluginProvider>
</LocationProvider>
</EditorContextProvider> </EditorContextProvider>
</PromptRefProvider> </PromptRefProvider>
</PromptHistoryProvider> </PromptHistoryProvider>
@@ -363,6 +362,7 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
</PromptStashProvider> </PromptStashProvider>
</LocalProvider> </LocalProvider>
</ThemeProvider> </ThemeProvider>
</LocationProvider>
</DataProvider> </DataProvider>
</ProjectProvider> </ProjectProvider>
</PermissionProvider> </PermissionProvider>
+9 -10
View File
@@ -7,7 +7,6 @@ import { useClient } from "../../context/client"
import { useToast } from "../../ui/toast" import { useToast } from "../../ui/toast"
import { DialogMoveSession, type MoveSessionSelection } from "../dialog-move-session" import { DialogMoveSession, type MoveSessionSelection } from "../dialog-move-session"
import { DialogWorkspaceFileChanges } from "../dialog-workspace-file-changes" import { DialogWorkspaceFileChanges } from "../dialog-workspace-file-changes"
import { useHomeSessionDestination } from "../../routes/home/session-destination"
import { useProject } from "../../context/project" import { useProject } from "../../context/project"
import { useData } from "../../context/data" import { useData } from "../../context/data"
@@ -19,13 +18,13 @@ export function usePromptMove(input: { projectID: () => string | undefined; sess
const dialog = useDialog() const dialog = useDialog()
const client = useClient() const client = useClient()
const toast = useToast() const toast = useToast()
const homeDestination = useHomeSessionDestination()
const project = useProject() const project = useProject()
const data = useData() const data = useData()
const paths = useTuiPaths() const paths = useTuiPaths()
const [creating, setCreating] = createSignal(false) const [creating, setCreating] = createSignal(false)
const [creatingDots, setCreatingDots] = createSignal(3) const [creatingDots, setCreatingDots] = createSignal(3)
const [progress, setProgress] = createSignal<string>() const [progress, setProgress] = createSignal<string>()
const [destination, setDestination] = createSignal<MoveSessionSelection>()
async function create(name: string) { async function create(name: string) {
const projectID = await resolveProjectID() const projectID = await resolveProjectID()
@@ -49,7 +48,7 @@ export function usePromptMove(input: { projectID: () => string | undefined; sess
setProgress("Creating session") setProgress("Creating session")
return directory return directory
} catch (err) { } catch (err) {
homeDestination?.clear() setDestination(undefined)
setProgress(undefined) setProgress(undefined)
setCreating(false) setCreating(false)
toast.show({ title: "Creating workspace failed", message: errorMessage(err), variant: "error" }) toast.show({ title: "Creating workspace failed", message: errorMessage(err), variant: "error" })
@@ -69,7 +68,7 @@ export function usePromptMove(input: { projectID: () => string | undefined; sess
<DialogMoveSession <DialogMoveSession
projectID={projectID} projectID={projectID}
current={ current={
homeDestination?.destination() ?? destination() ??
(session (session
? { ? {
type: "directory", type: "directory",
@@ -82,11 +81,11 @@ export function usePromptMove(input: { projectID: () => string | undefined; sess
subdirectory: project.instance.directory() !== project.instance.path().worktree, subdirectory: project.instance.directory() !== project.instance.path().worktree,
}) })
} }
onCurrentChange={(selection) => homeDestination?.setDestination(selection)} onCurrentChange={setDestination}
onSelect={(selection) => { onSelect={(selection) => {
const sessionID = input.sessionID() const sessionID = input.sessionID()
if (!sessionID) { if (!sessionID) {
homeDestination?.setDestination(selection) setDestination(selection)
dialog.clear() dialog.clear()
return return
} }
@@ -144,11 +143,11 @@ export function usePromptMove(input: { projectID: () => string | undefined; sess
return data.session.get(sessionID) return data.session.get(sessionID)
} }
const pending = createMemo(() => Boolean(homeDestination?.destination())) const pending = createMemo(() => Boolean(destination()))
const pendingNew = createMemo(() => homeDestination?.destination()?.type === "new") const pendingNew = createMemo(() => destination()?.type === "new")
async function getDirectory() { async function getDirectory() {
const value = homeDestination?.destination() const value = destination()
if (!value) return if (!value) return
if (value.type === "directory") { if (value.type === "directory") {
return value.directory return value.directory
@@ -161,7 +160,7 @@ export function usePromptMove(input: { projectID: () => string | undefined; sess
} }
function finishSubmit() { function finishSubmit() {
homeDestination?.clear() setDestination(undefined)
setProgress(undefined) setProgress(undefined)
setCreating(false) setCreating(false)
} }
+15 -5
View File
@@ -1,14 +1,24 @@
import type { LocationRef } from "@opencode-ai/client" 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<Accessor<LocationRef | undefined>>() const context = createContext<{
current: Accessor<LocationRef | undefined>
set: Setter<LocationRef | undefined>
}>()
export function LocationProvider(props: ParentProps<{ location?: LocationRef }>) { export function LocationProvider(props: ParentProps) {
return <context.Provider value={() => props.location}>{props.children}</context.Provider> const [current, set] = createSignal<LocationRef>()
return <context.Provider value={{ current, set }}>{props.children}</context.Provider>
} }
export function useLocation() { export function useLocation() {
const value = useContext(context) const value = useContext(context)
if (!value) throw new Error("Location context must be used within a LocationProvider") 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
} }
@@ -4,19 +4,15 @@ import { createMemo, Match, Show, Switch } from "solid-js"
import { useTerminalDimensions } from "@opentui/solid" import { useTerminalDimensions } from "@opentui/solid"
import { useTuiPaths } from "../../context/runtime" import { useTuiPaths } from "../../context/runtime"
import { useTheme } from "../../context/theme" import { useTheme } from "../../context/theme"
import { useHomeSessionDestination } from "../../routes/home/session-destination"
import { abbreviateHome } from "../../runtime" import { abbreviateHome } from "../../runtime"
import { FilePath } from "../../ui/file-path" import { FilePath } from "../../ui/file-path"
function Directory(props: { context: Plugin.Context; maxWidth: number }) { function Directory(props: { context: Plugin.Context; maxWidth: number }) {
const { theme } = useTheme() const { theme } = useTheme()
const destination = useHomeSessionDestination()
const paths = useTuiPaths() const paths = useTuiPaths()
const directory = createMemo(() => { const directory = createMemo(() =>
const selected = destination?.destination() props.context.location ? abbreviateHome(props.context.location.directory, paths.home) : undefined,
if (!selected || selected.type === "new") return )
return abbreviateHome(selected.directory || props.context.data.location.default().directory, paths.home)
})
return ( return (
<Show when={directory()}> <Show when={directory()}>
@@ -27,7 +23,7 @@ function Directory(props: { context: Plugin.Context; maxWidth: number }) {
function Mcp(props: { context: Plugin.Context }) { function Mcp(props: { context: Plugin.Context }) {
const { theme } = useTheme() 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 failed = createMemo(() => list().some((item) => item.status.status === "failed"))
const count = createMemo(() => list().filter((item) => item.status.status === "connected").length) 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 { theme } = useTheme()
const dimensions = useTerminalDimensions() const dimensions = useTerminalDimensions()
const mcpWidth = createMemo(() => { 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 if (list.length === 0) return 0
const count = list.filter((item) => item.status.status === "connected").length const count = list.filter((item) => item.status.status === "connected").length
return Bun.stringWidth(`${count} MCP /status`) + 2 return Bun.stringWidth(`${count} MCP /status`) + 2
+5
View File
@@ -21,6 +21,7 @@ import { useData } from "../context/data"
import { Keymap } from "../context/keymap" import { Keymap } from "../context/keymap"
import { useRoute } from "../context/route" import { useRoute } from "../context/route"
import { useTuiLifecycle } from "../context/runtime" import { useTuiLifecycle } from "../context/runtime"
import { useLocation } from "../context/location"
import { builtins } from "./builtins" import { builtins } from "./builtins"
export interface PackageResolver { export interface PackageResolver {
@@ -63,6 +64,7 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver }>
const keymap = Keymap.use() const keymap = Keymap.use()
const shortcuts = Keymap.useShortcuts() const shortcuts = Keymap.useShortcuts()
const lifecycle = useTuiLifecycle() const lifecycle = useTuiLifecycle()
const location = useLocation()
const directory = config.path ? path.dirname(config.path) : process.cwd() const directory = config.path ? path.dirname(config.path) : process.cwd()
const [store, setStore] = createStore({ const [store, setStore] = createStore({
ready: false, ready: false,
@@ -82,6 +84,9 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver }>
const owned: Dispose[] = [] const owned: Dispose[] = []
const context: Context = { const context: Context = {
options: item.options ?? {}, options: item.options ?? {},
get location() {
return location()
},
client: client.api, client: client.api,
data, data,
keymap: { keymap: {
+6 -6
View File
@@ -8,9 +8,8 @@ import { usePromptRef } from "../context/prompt"
import { useLocal } from "../context/local" import { useLocal } from "../context/local"
import { usePluginRuntime } from "../plugin/runtime" import { usePluginRuntime } from "../plugin/runtime"
import { useEditorContext } from "../context/editor" import { useEditorContext } from "../context/editor"
import { HomeSessionDestinationProvider } from "./home/session-destination"
import { useData } from "../context/data" import { useData } from "../context/data"
import { LocationProvider } from "../context/location" import { useSetLocation } from "../context/location"
import { FormPrompt } from "./session/form" import { FormPrompt } from "./session/form"
import { PluginSlot } from "../plugin/context" import { PluginSlot } from "../plugin/context"
@@ -29,10 +28,13 @@ export function Home() {
const local = useLocal() const local = useLocal()
const editor = useEditorContext() const editor = useEditorContext()
const data = useData() const data = useData()
const setLocation = useSetLocation()
// Global MCP elicitations can arrive without a session route, so keep them reachable from Home. // 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()) ?? []) const forms = createMemo(() => data.session.form.list("global", data.location.default()) ?? [])
let sent = false let sent = false
createEffect(() => setLocation(data.location.default()))
onMount(() => { onMount(() => {
editor.clearSelection() editor.clearSelection()
}) })
@@ -64,8 +66,7 @@ export function Home() {
}) })
return ( return (
<LocationProvider location={data.location.default()}> <>
<HomeSessionDestinationProvider>
<box flexGrow={1} alignItems="center" paddingLeft={2} paddingRight={2}> <box flexGrow={1} alignItems="center" paddingLeft={2} paddingRight={2}>
<box flexGrow={1} minHeight={0} /> <box flexGrow={1} minHeight={0} />
<box height={4} minHeight={0} flexShrink={1} /> <box height={4} minHeight={0} flexShrink={1} />
@@ -104,7 +105,6 @@ export function Home() {
) : null ) : null
}} }}
</Show> </Show>
</HomeSessionDestinationProvider> </>
</LocationProvider>
) )
} }
@@ -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<HomeSessionDestination | undefined>
setDestination: Setter<HomeSessionDestination | undefined>
clear: () => void
}
const HomeSessionDestinationContext = createContext<Context>()
export function HomeSessionDestinationProvider(props: ParentProps) {
const project = useProject()
const paths = useTuiPaths()
const [selected, setDestination] = createSignal<HomeSessionDestination>()
const destination = createMemo<HomeSessionDestination>(
() => selected() ?? { type: "directory", directory: project.instance.directory() || paths.cwd, subdirectory: false },
)
return (
<HomeSessionDestinationContext.Provider
value={{ destination, setDestination, clear: () => setDestination(undefined) }}
>
{props.children}
</HomeSessionDestinationContext.Provider>
)
}
export function useHomeSessionDestination() {
return useContext(HomeSessionDestinationContext)
}
+5 -6
View File
@@ -71,7 +71,7 @@ import { collapseToolOutput } from "../../util/collapse-tool-output"
import { usePluginRuntime } from "../../plugin/runtime" import { usePluginRuntime } from "../../plugin/runtime"
import { OPENCODE_BASE_MODE, useBindings, useCommandShortcut } from "../../keymap" import { OPENCODE_BASE_MODE, useBindings, useCommandShortcut } from "../../keymap"
import { usePathFormatter } from "../../context/path-format" 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 { createSessionRows, resolvePart, type PartRef, type SessionRow } from "./rows"
import { switchLabel } from "../../util/model" import { switchLabel } from "../../util/model"
@@ -115,6 +115,9 @@ export function Session() {
const session = createMemo(() => data.session.get(route.sessionID)) const session = createMemo(() => data.session.get(route.sessionID))
const messages = () => data.session.message.list(route.sessionID) const messages = () => data.session.message.list(route.sessionID)
const location = createMemo(() => session()?.location) const location = createMemo(() => session()?.location)
const setLocation = useSetLocation()
createEffect(() => setLocation(location()))
createEffect(() => { createEffect(() => {
const title = Locale.truncate(session()?.title ?? "", 50) const title = Locale.truncate(session()?.title ?? "", 50)
@@ -823,7 +826,6 @@ export function Session() {
) )
return ( return (
<LocationProvider location={location()}>
<context.Provider <context.Provider
value={{ value={{
get width() { get width() {
@@ -949,7 +951,6 @@ export function Session() {
</Show> </Show>
</box> </box>
</context.Provider> </context.Provider>
</LocationProvider>
) )
} }
@@ -2500,9 +2501,7 @@ function Subagent(props: ToolProps) {
const { navigate } = useRoute() const { navigate } = useRoute()
const data = useData() const data = useData()
const input = createMemo(() => (typeof props.part.state.input === "string" ? {} : props.part.state.input)) const input = createMemo(() => (typeof props.part.state.input === "string" ? {} : props.part.state.input))
const metadata = createMemo(() => const metadata = createMemo(() => (props.part.state.status === "streaming" ? {} : props.part.state.structured))
props.part.state.status === "streaming" ? {} : props.part.state.structured,
)
const sessionID = createMemo(() => stringValue(metadata().sessionID) ?? stringValue(metadata().sessionId)) const sessionID = createMemo(() => stringValue(metadata().sessionID) ?? stringValue(metadata().sessionId))
const description = createMemo(() => stringValue(input().description)) const description = createMemo(() => stringValue(input().description))
const isRunning = createMemo(() => { const isRunning = createMemo(() => {