refactor(tui): centralize active location

This commit is contained in:
Dax Raad 2026-07-14 16:17:53 -04:00
parent f4f761246a
commit a32c480d45
9 changed files with 218 additions and 249 deletions

View file

@ -172,6 +172,7 @@ export interface UI {
export interface Context {
readonly options: Readonly<Record<string, any>>
readonly location: LocationRef | undefined
readonly client: OpenCodeClient
readonly data: Data
readonly keymap: Keymap

View file

@ -333,15 +333,15 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
<PermissionProvider>
<ProjectProvider>
<DataProvider>
<ThemeProvider mode={mode}>
<LocalProvider>
<PromptStashProvider>
<DialogProvider>
<FrecencyProvider>
<PromptHistoryProvider>
<PromptRefProvider>
<EditorContextProvider>
<LocationProvider>
<LocationProvider>
<ThemeProvider mode={mode}>
<LocalProvider>
<PromptStashProvider>
<DialogProvider>
<FrecencyProvider>
<PromptHistoryProvider>
<PromptRefProvider>
<EditorContextProvider>
<PluginProvider packages={input.packages}>
<App
pair={
@ -354,15 +354,15 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
}
/>
</PluginProvider>
</LocationProvider>
</EditorContextProvider>
</PromptRefProvider>
</PromptHistoryProvider>
</FrecencyProvider>
</DialogProvider>
</PromptStashProvider>
</LocalProvider>
</ThemeProvider>
</EditorContextProvider>
</PromptRefProvider>
</PromptHistoryProvider>
</FrecencyProvider>
</DialogProvider>
</PromptStashProvider>
</LocalProvider>
</ThemeProvider>
</LocationProvider>
</DataProvider>
</ProjectProvider>
</PermissionProvider>

View file

@ -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<string>()
const [destination, setDestination] = createSignal<MoveSessionSelection>()
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
<DialogMoveSession
projectID={projectID}
current={
homeDestination?.destination() ??
destination() ??
(session
? {
type: "directory",
@ -82,11 +81,11 @@ export function usePromptMove(input: { projectID: () => 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)
}

View file

@ -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<Accessor<LocationRef | undefined>>()
const context = createContext<{
current: Accessor<LocationRef | undefined>
set: Setter<LocationRef | undefined>
}>()
export function LocationProvider(props: ParentProps<{ location?: LocationRef }>) {
return <context.Provider value={() => props.location}>{props.children}</context.Provider>
export function LocationProvider(props: ParentProps) {
const [current, set] = createSignal<LocationRef>()
return <context.Provider value={{ current, set }}>{props.children}</context.Provider>
}
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
}

View file

@ -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 (
<Show when={directory()}>
@ -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

View file

@ -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: {

View file

@ -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 (
<LocationProvider location={data.location.default()}>
<HomeSessionDestinationProvider>
<box flexGrow={1} alignItems="center" paddingLeft={2} paddingRight={2}>
<box flexGrow={1} minHeight={0} />
<box height={4} minHeight={0} flexShrink={1} />
<box flexShrink={0}>
<pluginRuntime.Slot name="home_logo" mode="replace">
<Logo />
</pluginRuntime.Slot>
</box>
<box height={1} minHeight={0} flexShrink={1} />
<box width="100%" maxWidth={75} zIndex={1000} paddingTop={1} flexShrink={0}>
<pluginRuntime.Slot name="home_prompt" mode="replace" ref={bind}>
<Prompt
ref={bind}
right={<pluginRuntime.Slot name="home_prompt_right" />}
placeholders={placeholder}
disabled={forms().length > 0}
/>
</pluginRuntime.Slot>
</box>
<PluginSlot name="home.bottom" />
<box flexGrow={1} minHeight={0} />
<Toast />
<>
<box flexGrow={1} alignItems="center" paddingLeft={2} paddingRight={2}>
<box flexGrow={1} minHeight={0} />
<box height={4} minHeight={0} flexShrink={1} />
<box flexShrink={0}>
<pluginRuntime.Slot name="home_logo" mode="replace">
<Logo />
</pluginRuntime.Slot>
</box>
<box width="100%" flexShrink={0}>
<PluginSlot name="home.footer" />
<box height={1} minHeight={0} flexShrink={1} />
<box width="100%" maxWidth={75} zIndex={1000} paddingTop={1} flexShrink={0}>
<pluginRuntime.Slot name="home_prompt" mode="replace" ref={bind}>
<Prompt
ref={bind}
right={<pluginRuntime.Slot name="home_prompt_right" />}
placeholders={placeholder}
disabled={forms().length > 0}
/>
</pluginRuntime.Slot>
</box>
<Show when={forms()[0]?.id} keyed>
{(_) => {
const form = forms()[0]
return form ? (
<box position="absolute" zIndex={2000} left={0} right={0} bottom={1} paddingLeft={2} paddingRight={2}>
<box width="100%">
<FormPrompt form={form} />
</box>
<PluginSlot name="home.bottom" />
<box flexGrow={1} minHeight={0} />
<Toast />
</box>
<box width="100%" flexShrink={0}>
<PluginSlot name="home.footer" />
</box>
<Show when={forms()[0]?.id} keyed>
{(_) => {
const form = forms()[0]
return form ? (
<box position="absolute" zIndex={2000} left={0} right={0} bottom={1} paddingLeft={2} paddingRight={2}>
<box width="100%">
<FormPrompt form={form} />
</box>
) : null
}}
</Show>
</HomeSessionDestinationProvider>
</LocationProvider>
</box>
) : null
}}
</Show>
</>
)
}

View file

@ -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)
}

View file

@ -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 (
<LocationProvider location={location()}>
<context.Provider
value={{
get width() {
return contentWidth()
},
sessionID: route.sessionID,
thinkingMode,
showThinking,
markdownMode,
groupExploration,
diffWrapMode,
models,
config,
}}
>
<box flexDirection="row" flexGrow={1} minHeight={0}>
<box flexGrow={1} minHeight={0} paddingBottom={1} paddingLeft={2} paddingRight={2} gap={1}>
<Show when={session()}>
<scrollbox
ref={(r) => (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()}
>
<For each={rows}>
{(row) => (
<SessionRowView
row={row}
message={(messageID) => data.session.message.get(route.sessionID, messageID)}
/>
)}
</For>
<BackgroundToolHint messages={messages()} />
<Show when={session()?.revert?.messageID}>
<RevertMessage
count={
messages().filter(
(message) => message.id >= session()!.revert!.messageID && message.type === "user",
).length
}
files={session()!.revert!.files ?? []}
<context.Provider
value={{
get width() {
return contentWidth()
},
sessionID: route.sessionID,
thinkingMode,
showThinking,
markdownMode,
groupExploration,
diffWrapMode,
models,
config,
}}
>
<box flexDirection="row" flexGrow={1} minHeight={0}>
<box flexGrow={1} minHeight={0} paddingBottom={1} paddingLeft={2} paddingRight={2} gap={1}>
<Show when={session()}>
<scrollbox
ref={(r) => (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()}
>
<For each={rows}>
{(row) => (
<SessionRowView
row={row}
message={(messageID) => data.session.message.get(route.sessionID, messageID)}
/>
</Show>
</scrollbox>
<box flexShrink={0}>
<Composer
sessionID={route.sessionID}
open={composer.open || (!!session()?.parentID && forms().length === 0)}
defaultTab={composer.tab ?? (session()?.parentID ? "subagents" : undefined)}
onClose={() => setComposer("open", false)}
)}
</For>
<BackgroundToolHint messages={messages()} />
<Show when={session()?.revert?.messageID}>
<RevertMessage
count={
messages().filter(
(message) => message.id >= session()!.revert!.messageID && message.type === "user",
).length
}
files={session()!.revert!.files ?? []}
/>
<Switch>
<Match when={composer.open || (!!session()?.parentID && forms().length === 0)}>{null}</Match>
<Match when={permissions().length > 0}>
<PermissionPrompt request={permissions()[0]} directory={session()?.location.directory} />
</Match>
<Match when={forms().length > 0}>
<Show when={forms()[0]?.id} keyed>
{(_) => {
const form = forms()[0]
return form ? <FormPrompt form={form} /> : null
}}
</Show>
</Match>
<Match when={!disabled()}>
<pluginRuntime.Slot
name="session_prompt"
mode="replace"
session_id={route.sessionID}
</Show>
</scrollbox>
<box flexShrink={0}>
<Composer
sessionID={route.sessionID}
open={composer.open || (!!session()?.parentID && forms().length === 0)}
defaultTab={composer.tab ?? (session()?.parentID ? "subagents" : undefined)}
onClose={() => setComposer("open", false)}
/>
<Switch>
<Match when={composer.open || (!!session()?.parentID && forms().length === 0)}>{null}</Match>
<Match when={permissions().length > 0}>
<PermissionPrompt request={permissions()[0]} directory={session()?.location.directory} />
</Match>
<Match when={forms().length > 0}>
<Show when={forms()[0]?.id} keyed>
{(_) => {
const form = forms()[0]
return form ? <FormPrompt form={form} /> : null
}}
</Show>
</Match>
<Match when={!disabled()}>
<pluginRuntime.Slot
name="session_prompt"
mode="replace"
session_id={route.sessionID}
visible={true}
disabled={false}
on_submit={toBottom}
ref={bind}
>
<Prompt
visible={true}
disabled={false}
on_submit={toBottom}
ref={bind}
>
<Prompt
visible={true}
ref={bind}
disabled={false}
onSubmit={() => {
toBottom()
}}
sessionID={route.sessionID}
right={<pluginRuntime.Slot name="session_prompt_right" session_id={route.sessionID} />}
/>
</pluginRuntime.Slot>
</Match>
</Switch>
</box>
</Show>
<Toast />
</box>
<Show when={sidebarVisible()}>
<Switch>
<Match when={wide()}>
<Sidebar sessionID={route.sessionID} />
</Match>
<Match when={!wide()}>
<box
position="absolute"
top={0}
left={0}
right={0}
bottom={0}
alignItems="flex-end"
backgroundColor={RGBA.fromInts(0, 0, 0, 70)}
>
<Sidebar sessionID={route.sessionID} />
</box>
</Match>
</Switch>
disabled={false}
onSubmit={() => {
toBottom()
}}
sessionID={route.sessionID}
right={<pluginRuntime.Slot name="session_prompt_right" session_id={route.sessionID} />}
/>
</pluginRuntime.Slot>
</Match>
</Switch>
</box>
</Show>
<Toast />
</box>
</context.Provider>
</LocationProvider>
<Show when={sidebarVisible()}>
<Switch>
<Match when={wide()}>
<Sidebar sessionID={route.sessionID} />
</Match>
<Match when={!wide()}>
<box
position="absolute"
top={0}
left={0}
right={0}
bottom={0}
alignItems="flex-end"
backgroundColor={RGBA.fromInts(0, 0, 0, 70)}
>
<Sidebar sessionID={route.sessionID} />
</box>
</Match>
</Switch>
</Show>
</box>
</context.Provider>
)
}
@ -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(() => {