Skip to content

Commit d5f0f8a

Browse files
committed
feat(tui): add persistent terminal workspaces
1 parent ab28ae2 commit d5f0f8a

8 files changed

Lines changed: 852 additions & 45 deletions

File tree

packages/tui/src/app.tsx

Lines changed: 53 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,6 @@ import {
3131
batch,
3232
Show,
3333
} from "solid-js"
34-
import { createStore } from "solid-js/store"
3534
import {
3635
TuiLifecycleProvider,
3736
TuiAppProvider,
@@ -77,7 +76,6 @@ import { clampSessionTabsWidth, sessionTabsFitVertically, SESSION_SIDEBAR_WIDTH
7776
import { ThemeErrorToast } from "./component/theme-error-toast"
7877
import { createThemeSource, ThemeProvider, useTheme, useThemes } from "./context/theme"
7978
import { Home } from "./routes/home"
80-
import { Session } from "./routes/session"
8179
import { PromptHistoryProvider } from "./prompt/history"
8280
import { FrecencyProvider } from "./prompt/frecency"
8381
import { PromptStashProvider } from "./prompt/stash"
@@ -100,6 +98,8 @@ import { destroyRenderer } from "./util/renderer"
10098
import { cliErrorMessage, errorFormat } from "./util/error"
10199
import { AttentionProvider } from "./context/attention"
102100
import { StorageProvider, useStorage } from "./context/storage"
101+
import { PaneLayoutProvider } from "./context/pane-layout"
102+
import { PaneWorkspace } from "./component/pane-workspace"
103103
import { createTuiClipboard } from "./clipboard"
104104

105105
registerOpencodeSpinner()
@@ -218,7 +218,7 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
218218
reconnect: async (signal: AbortSignal) => {
219219
const endpoint = await managed.reconnect(signal)
220220
const next = { baseUrl: endpoint.url, headers: Service.headers(endpoint) }
221-
return { api: OpenCode.make(next) }
221+
return { api: OpenCode.make(next), endpoint }
222222
},
223223
restart: managed.restart,
224224
}
@@ -373,48 +373,54 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
373373
: undefined
374374
}
375375
>
376-
<ClientProvider api={api} service={service}>
376+
<ClientProvider
377+
api={api}
378+
endpoint={input.server.endpoint}
379+
service={service}
380+
>
377381
<PermissionProvider>
378382
<DataProvider>
379383
<LocationProvider>
380384
<SessionTabsProvider>
381-
<ThemeProvider
382-
mode={mode}
383-
source={createThemeSource(global.config)}
384-
>
385-
<ThemeErrorToast />
386-
<LocalProvider>
387-
<PromptStashProvider>
388-
<DialogProvider>
389-
<FrecencyProvider>
390-
<PromptHistoryProvider>
391-
<PromptRefProvider>
392-
<EditorContextProvider>
393-
<AttentionProvider>
394-
<PluginProvider
395-
packages={input.packages}
396-
directories={pluginDirectories}
397-
>
398-
<App
399-
pair={
400-
input.server.endpoint.auth
401-
? input.server.endpoint.auth
402-
: {
403-
username: "opencode",
404-
password: "",
405-
}
406-
}
407-
/>
408-
</PluginProvider>
409-
</AttentionProvider>
410-
</EditorContextProvider>
411-
</PromptRefProvider>
412-
</PromptHistoryProvider>
413-
</FrecencyProvider>
414-
</DialogProvider>
415-
</PromptStashProvider>
416-
</LocalProvider>
417-
</ThemeProvider>
385+
<PaneLayoutProvider>
386+
<ThemeProvider
387+
mode={mode}
388+
source={createThemeSource(global.config)}
389+
>
390+
<ThemeErrorToast />
391+
<LocalProvider>
392+
<PromptStashProvider>
393+
<DialogProvider>
394+
<FrecencyProvider>
395+
<PromptHistoryProvider>
396+
<PromptRefProvider>
397+
<EditorContextProvider>
398+
<AttentionProvider>
399+
<PluginProvider
400+
packages={input.packages}
401+
directories={pluginDirectories}
402+
>
403+
<App
404+
pair={
405+
input.server.endpoint.auth
406+
? input.server.endpoint.auth
407+
: {
408+
username: "opencode",
409+
password: "",
410+
}
411+
}
412+
/>
413+
</PluginProvider>
414+
</AttentionProvider>
415+
</EditorContextProvider>
416+
</PromptRefProvider>
417+
</PromptHistoryProvider>
418+
</FrecencyProvider>
419+
</DialogProvider>
420+
</PromptStashProvider>
421+
</LocalProvider>
422+
</ThemeProvider>
423+
</PaneLayoutProvider>
418424
</SessionTabsProvider>
419425
</LocationProvider>
420426
</DataProvider>
@@ -1318,7 +1324,12 @@ function App(props: { pair?: DialogPairCredentials }) {
13181324
</Match>
13191325
<Match when={route.data.type === "session"}>
13201326
<Show when={route.data.type === "session" ? route.data.sessionID : undefined} keyed>
1321-
{(_) => <Session verticalTabsWidth={verticalTabsVisible() ? verticalTabsWidth() : 0} />}
1327+
{(sessionID) => (
1328+
<PaneWorkspace
1329+
sessionID={sessionID}
1330+
verticalTabsWidth={verticalTabsVisible() ? verticalTabsWidth() : 0}
1331+
/>
1332+
)}
13221333
</Show>
13231334
</Match>
13241335
<Match when={route.data.type === "plugin"}>
Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,175 @@
1+
import { CliRenderEvents, RGBA, TextAttributes, type BoxRenderable, type Renderable } from "@opentui/core"
2+
import { useRenderer } from "@opentui/solid"
3+
import { createResource, createSignal, Match, onCleanup, Show, Switch, type JSX } from "solid-js"
4+
import { usePaneLayout } from "../context/pane-layout"
5+
import type { PaneLayoutNode } from "../context/pane-layout-model"
6+
import { useData } from "../context/data"
7+
import { usePromptRef } from "../context/prompt"
8+
import { useTheme } from "../context/theme"
9+
import { Session } from "../routes/session"
10+
import { PersistentTerminalPane } from "./persistent-terminal-pane"
11+
12+
export function PaneWorkspace(props: { sessionID: string; verticalTabsWidth: number }) {
13+
const panes = usePaneLayout()
14+
createResource(
15+
() => props.sessionID,
16+
(sessionID) => panes.load(sessionID).catch(() => undefined),
17+
)
18+
const workspace = () => panes.get(props.sessionID)
19+
return (
20+
<Show when={workspace()} fallback={<Session verticalTabsWidth={props.verticalTabsWidth} />}>
21+
{(value) => (
22+
<PaneNode node={value().layout} rootSessionID={props.sessionID} verticalTabsWidth={props.verticalTabsWidth} />
23+
)}
24+
</Show>
25+
)
26+
}
27+
28+
function PaneNode(props: { node: PaneLayoutNode; rootSessionID?: string; verticalTabsWidth: number }) {
29+
const panes = usePaneLayout()
30+
const prompt = usePromptRef()
31+
const theme = useTheme()
32+
const data = useData()
33+
return (
34+
<Switch>
35+
<Match when={props.node.type === "item" ? props.node.item : undefined}>
36+
{(item) => {
37+
let focusTerminal: (() => void) | undefined
38+
const focus = () => {
39+
if (item().type === "session" && item().id === props.rootSessionID) {
40+
prompt.current?.focus()
41+
return
42+
}
43+
focusTerminal?.()
44+
}
45+
return (
46+
<PaneSurface
47+
focus={focus}
48+
title={
49+
item().type === "terminal"
50+
? "Terminal"
51+
: `Session · ${data.session.get(item().id)?.title ?? "Untitled"}`
52+
}
53+
>
54+
<Switch>
55+
<Match when={item().type === "session" && item().id === props.rootSessionID}>
56+
<Session verticalTabsWidth={props.verticalTabsWidth} />
57+
</Match>
58+
<Match when={item().type === "session"}>
59+
<UnavailablePane label={`Session ${item().id}`} />
60+
</Match>
61+
<Match when={item().type === "terminal"}>
62+
<PersistentTerminalPane
63+
ptyID={item().id}
64+
autoFocus={!props.rootSessionID || panes.shouldFocus(item().id)}
65+
onAutoFocus={() => panes.clearFocus(item().id)}
66+
onFocusRequest={(value) => (focusTerminal = value)}
67+
/>
68+
</Match>
69+
</Switch>
70+
</PaneSurface>
71+
)
72+
}}
73+
</Match>
74+
<Match when={props.node.type === "split" ? props.node : undefined}>
75+
{(node) => (
76+
<box
77+
flexGrow={1}
78+
minWidth={0}
79+
minHeight={0}
80+
flexDirection={node().direction === "horizontal" ? "row" : "column"}
81+
>
82+
<box flexGrow={node().ratio} flexBasis={0} minWidth={0} minHeight={0}>
83+
<PaneNode
84+
node={node().first}
85+
rootSessionID={props.rootSessionID}
86+
verticalTabsWidth={props.verticalTabsWidth}
87+
/>
88+
</box>
89+
<box flexGrow={1 - node().ratio} flexBasis={0} minWidth={0} minHeight={0}>
90+
<PaneNode
91+
node={node().second}
92+
rootSessionID={props.rootSessionID}
93+
verticalTabsWidth={props.verticalTabsWidth}
94+
/>
95+
</box>
96+
</box>
97+
)}
98+
</Match>
99+
</Switch>
100+
)
101+
}
102+
103+
function PaneSurface(props: { focus: () => void; title: string; children: JSX.Element }) {
104+
const renderer = useRenderer()
105+
const theme = useTheme()
106+
const [focused, setFocused] = createSignal(false)
107+
let pane: BoxRenderable | undefined
108+
const contains = (current: Renderable | null) => {
109+
while (current) {
110+
if (current === pane) return true
111+
current = current.parent
112+
}
113+
return false
114+
}
115+
const onFocused = (current: Renderable | null) => setFocused(contains(current))
116+
renderer.on(CliRenderEvents.FOCUSED_RENDERABLE, onFocused)
117+
onCleanup(() => renderer.off(CliRenderEvents.FOCUSED_RENDERABLE, onFocused))
118+
return (
119+
<box
120+
ref={(value) => {
121+
pane = value
122+
setFocused(contains(renderer.currentFocusedRenderable))
123+
}}
124+
flexGrow={1}
125+
minWidth={0}
126+
minHeight={0}
127+
position="relative"
128+
flexDirection="column"
129+
>
130+
<box
131+
height={1}
132+
flexShrink={0}
133+
paddingLeft={1}
134+
paddingRight={1}
135+
backgroundColor={
136+
focused() ? theme.raise(theme.raise(theme.background.surface.offset)) : theme.background.surface.offset
137+
}
138+
>
139+
<text
140+
fg={focused() ? theme.text.default : theme.text.subdued}
141+
attributes={focused() ? TextAttributes.BOLD : undefined}
142+
wrapMode="none"
143+
truncate
144+
>
145+
{props.title}
146+
</text>
147+
</box>
148+
<box flexGrow={1} minWidth={0} minHeight={0} position="relative">
149+
{props.children}
150+
</box>
151+
<Show when={!focused()}>
152+
<box
153+
position="absolute"
154+
left={0}
155+
top={0}
156+
width="100%"
157+
height="100%"
158+
zIndex={1}
159+
backgroundColor={RGBA.fromInts(0, 0, 0)}
160+
opacity={0.3}
161+
onMouseDown={props.focus}
162+
/>
163+
</Show>
164+
</box>
165+
)
166+
}
167+
168+
function UnavailablePane(props: { label: string }) {
169+
const theme = useTheme()
170+
return (
171+
<box flexGrow={1} alignItems="center" justifyContent="center">
172+
<text fg={theme.text.subdued}>{props.label} is unavailable in this prototype.</text>
173+
</box>
174+
)
175+
}

0 commit comments

Comments
 (0)