Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
95 changes: 53 additions & 42 deletions packages/tui/src/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,6 @@ import {
batch,
Show,
} from "solid-js"
import { createStore } from "solid-js/store"
import {
TuiLifecycleProvider,
TuiAppProvider,
Expand Down Expand Up @@ -77,7 +76,6 @@ import { clampSessionTabsWidth, sessionTabsFitVertically, SESSION_SIDEBAR_WIDTH
import { ThemeErrorToast } from "./component/theme-error-toast"
import { createThemeSource, ThemeProvider, useTheme, useThemes } from "./context/theme"
import { Home } from "./routes/home"
import { Session } from "./routes/session"
import { PromptHistoryProvider } from "./prompt/history"
import { FrecencyProvider } from "./prompt/frecency"
import { PromptStashProvider } from "./prompt/stash"
Expand All @@ -100,6 +98,8 @@ import { destroyRenderer } from "./util/renderer"
import { cliErrorMessage, errorFormat } from "./util/error"
import { AttentionProvider } from "./context/attention"
import { StorageProvider, useStorage } from "./context/storage"
import { PaneLayoutProvider } from "./context/pane-layout"
import { PaneWorkspace } from "./component/pane-workspace"
import { createTuiClipboard } from "./clipboard"

registerOpencodeSpinner()
Expand Down Expand Up @@ -218,7 +218,7 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
reconnect: async (signal: AbortSignal) => {
const endpoint = await managed.reconnect(signal)
const next = { baseUrl: endpoint.url, headers: Service.headers(endpoint) }
return { api: OpenCode.make(next) }
return { api: OpenCode.make(next), endpoint }
},
restart: managed.restart,
}
Expand Down Expand Up @@ -373,48 +373,54 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
: undefined
}
>
<ClientProvider api={api} service={service}>
<ClientProvider
api={api}
endpoint={input.server.endpoint}
service={service}
>
<PermissionProvider>
<DataProvider>
<LocationProvider>
<SessionTabsProvider>
<ThemeProvider
mode={mode}
source={createThemeSource(global.config)}
>
<ThemeErrorToast />
<LocalProvider>
<PromptStashProvider>
<DialogProvider>
<FrecencyProvider>
<PromptHistoryProvider>
<PromptRefProvider>
<EditorContextProvider>
<AttentionProvider>
<PluginProvider
packages={input.packages}
directories={pluginDirectories}
>
<App
pair={
input.server.endpoint.auth
? input.server.endpoint.auth
: {
username: "opencode",
password: "",
}
}
/>
</PluginProvider>
</AttentionProvider>
</EditorContextProvider>
</PromptRefProvider>
</PromptHistoryProvider>
</FrecencyProvider>
</DialogProvider>
</PromptStashProvider>
</LocalProvider>
</ThemeProvider>
<PaneLayoutProvider>
<ThemeProvider
mode={mode}
source={createThemeSource(global.config)}
>
<ThemeErrorToast />
<LocalProvider>
<PromptStashProvider>
<DialogProvider>
<FrecencyProvider>
<PromptHistoryProvider>
<PromptRefProvider>
<EditorContextProvider>
<AttentionProvider>
<PluginProvider
packages={input.packages}
directories={pluginDirectories}
>
<App
pair={
input.server.endpoint.auth
? input.server.endpoint.auth
: {
username: "opencode",
password: "",
}
}
/>
</PluginProvider>
</AttentionProvider>
</EditorContextProvider>
</PromptRefProvider>
</PromptHistoryProvider>
</FrecencyProvider>
</DialogProvider>
</PromptStashProvider>
</LocalProvider>
</ThemeProvider>
</PaneLayoutProvider>
</SessionTabsProvider>
</LocationProvider>
</DataProvider>
Expand Down Expand Up @@ -1316,7 +1322,12 @@ function App(props: { pair?: DialogPairCredentials }) {
</Match>
<Match when={route.data.type === "session"}>
<Show when={route.data.type === "session" ? route.data.sessionID : undefined} keyed>
{(_) => <Session verticalTabsWidth={verticalTabsVisible() ? verticalTabsWidth() : 0} />}
{(sessionID) => (
<PaneWorkspace
sessionID={sessionID}
verticalTabsWidth={verticalTabsVisible() ? verticalTabsWidth() : 0}
/>
)}
</Show>
</Match>
<Match when={route.data.type === "plugin"}>
Expand Down
175 changes: 175 additions & 0 deletions packages/tui/src/component/pane-workspace.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
import { CliRenderEvents, RGBA, TextAttributes, type BoxRenderable, type Renderable } from "@opentui/core"
import { useRenderer } from "@opentui/solid"
import { createResource, createSignal, Match, onCleanup, Show, Switch, type JSX } from "solid-js"
import { usePaneLayout } from "../context/pane-layout"
import type { PaneLayoutNode } from "../context/pane-layout-model"
import { useData } from "../context/data"
import { usePromptRef } from "../context/prompt"
import { useTheme } from "../context/theme"
import { Session } from "../routes/session"
import { PersistentTerminalPane } from "./persistent-terminal-pane"

export function PaneWorkspace(props: { sessionID: string; verticalTabsWidth: number }) {
const panes = usePaneLayout()
createResource(
() => props.sessionID,
(sessionID) => panes.load(sessionID).catch(() => undefined),
)
const workspace = () => panes.get(props.sessionID)
return (
<Show when={workspace()} fallback={<Session verticalTabsWidth={props.verticalTabsWidth} />}>
{(value) => (
<PaneNode node={value().layout} rootSessionID={props.sessionID} verticalTabsWidth={props.verticalTabsWidth} />
)}
</Show>
)
}

function PaneNode(props: { node: PaneLayoutNode; rootSessionID?: string; verticalTabsWidth: number }) {
const panes = usePaneLayout()
const prompt = usePromptRef()
const theme = useTheme()
const data = useData()
return (
<Switch>
<Match when={props.node.type === "item" ? props.node.item : undefined}>
{(item) => {
let focusTerminal: (() => void) | undefined
const focus = () => {
if (item().type === "session" && item().id === props.rootSessionID) {
prompt.current?.focus()
return
}
focusTerminal?.()
}
return (
<PaneSurface
focus={focus}
title={
item().type === "terminal"
? "Terminal"
: `Session · ${data.session.get(item().id)?.title ?? "Untitled"}`
}
>
<Switch>
<Match when={item().type === "session" && item().id === props.rootSessionID}>
<Session verticalTabsWidth={props.verticalTabsWidth} />
</Match>
<Match when={item().type === "session"}>
<UnavailablePane label={`Session ${item().id}`} />
</Match>
<Match when={item().type === "terminal"}>
<PersistentTerminalPane
ptyID={item().id}
autoFocus={!props.rootSessionID || panes.shouldFocus(item().id)}
onAutoFocus={() => panes.clearFocus(item().id)}
onFocusRequest={(value) => (focusTerminal = value)}
/>
</Match>
</Switch>
</PaneSurface>
)
}}
</Match>
<Match when={props.node.type === "split" ? props.node : undefined}>
{(node) => (
<box
flexGrow={1}
minWidth={0}
minHeight={0}
flexDirection={node().direction === "horizontal" ? "row" : "column"}
>
<box flexGrow={node().ratio} flexBasis={0} minWidth={0} minHeight={0}>
<PaneNode
node={node().first}
rootSessionID={props.rootSessionID}
verticalTabsWidth={props.verticalTabsWidth}
/>
</box>
<box flexGrow={1 - node().ratio} flexBasis={0} minWidth={0} minHeight={0}>
<PaneNode
node={node().second}
rootSessionID={props.rootSessionID}
verticalTabsWidth={props.verticalTabsWidth}
/>
</box>
</box>
)}
</Match>
</Switch>
)
}

function PaneSurface(props: { focus: () => void; title: string; children: JSX.Element }) {
const renderer = useRenderer()
const theme = useTheme()
const [focused, setFocused] = createSignal(false)
let pane: BoxRenderable | undefined
const contains = (current: Renderable | null) => {
while (current) {
if (current === pane) return true
current = current.parent
}
return false
}
const onFocused = (current: Renderable | null) => setFocused(contains(current))
renderer.on(CliRenderEvents.FOCUSED_RENDERABLE, onFocused)
onCleanup(() => renderer.off(CliRenderEvents.FOCUSED_RENDERABLE, onFocused))
return (
<box
ref={(value) => {
pane = value
setFocused(contains(renderer.currentFocusedRenderable))
}}
flexGrow={1}
minWidth={0}
minHeight={0}
position="relative"
flexDirection="column"
>
<box
height={1}
flexShrink={0}
paddingLeft={1}
paddingRight={1}
backgroundColor={
focused() ? theme.raise(theme.raise(theme.background.surface.offset)) : theme.background.surface.offset
}
>
<text
fg={focused() ? theme.text.default : theme.text.subdued}
attributes={focused() ? TextAttributes.BOLD : undefined}
wrapMode="none"
truncate
>
{props.title}
</text>
</box>
<box flexGrow={1} minWidth={0} minHeight={0} position="relative">
{props.children}
</box>
<Show when={!focused()}>
<box
position="absolute"
left={0}
top={0}
width="100%"
height="100%"
zIndex={1}
backgroundColor={RGBA.fromInts(0, 0, 0)}
opacity={0.3}
onMouseDown={props.focus}
/>
</Show>
</box>
)
}

function UnavailablePane(props: { label: string }) {
const theme = useTheme()
return (
<box flexGrow={1} alignItems="center" justifyContent="center">
<text fg={theme.text.subdued}>{props.label} is unavailable in this prototype.</text>
</box>
)
}
Loading
Loading