Skip to content

Commit ccc72cf

Browse files
feat: add layout system for TUI
Replace hard-coded layout values with config system that allows users to customize spacing, padding, and UI element visibility. - Add LayoutProvider context following theme system pattern - Include two built-in layouts: default (preserves original behavior) and dense (optimized for small terminals) - Support custom user layouts via ~/.config/opencode/layout/*.jsonc - Add /layout command and dialog for switching layouts - Add layout field to config - Maintain 100% backward compatibility New layout system enables users to optimize TUI for different terminal sizes and personal preferences without modifying code.
1 parent dc32705 commit ccc72cf

10 files changed

Lines changed: 910 additions & 58 deletions

File tree

docs/features/layouts.md

Lines changed: 446 additions & 0 deletions
Large diffs are not rendered by default.

packages/opencode/src/cli/cmd/tui/app.tsx

Lines changed: 23 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -13,12 +13,14 @@ import { LocalProvider, useLocal } from "@tui/context/local"
1313
import { DialogModel } from "@tui/component/dialog-model"
1414
import { DialogStatus } from "@tui/component/dialog-status"
1515
import { DialogThemeList } from "@tui/component/dialog-theme-list"
16+
import { DialogLayoutList } from "@tui/component/dialog-layout-list"
1617
import { DialogHelp } from "./ui/dialog-help"
1718
import { CommandProvider, useCommandDialog } from "@tui/component/dialog-command"
1819
import { DialogAgent } from "@tui/component/dialog-agent"
1920
import { DialogSessionList } from "@tui/component/dialog-session-list"
2021
import { KeybindProvider } from "@tui/context/keybind"
2122
import { ThemeProvider, useTheme } from "@tui/context/theme"
23+
import { LayoutProvider } from "@tui/context/layout"
2224
import { Home } from "@tui/routes/home"
2325
import { Session } from "@tui/routes/session"
2426
import { PromptHistoryProvider } from "./component/prompt/history"
@@ -113,17 +115,19 @@ export function tui(input: { url: string; args: Args; onExit?: () => Promise<voi
113115
<SDKProvider url={input.url}>
114116
<SyncProvider>
115117
<ThemeProvider mode={mode}>
116-
<LocalProvider>
117-
<KeybindProvider>
118-
<DialogProvider>
119-
<CommandProvider>
120-
<PromptHistoryProvider>
121-
<App />
122-
</PromptHistoryProvider>
123-
</CommandProvider>
124-
</DialogProvider>
125-
</KeybindProvider>
126-
</LocalProvider>
118+
<LayoutProvider>
119+
<LocalProvider>
120+
<KeybindProvider>
121+
<DialogProvider>
122+
<CommandProvider>
123+
<PromptHistoryProvider>
124+
<App />
125+
</PromptHistoryProvider>
126+
</CommandProvider>
127+
</DialogProvider>
128+
</KeybindProvider>
129+
</LocalProvider>
130+
</LayoutProvider>
127131
</ThemeProvider>
128132
</SyncProvider>
129133
</SDKProvider>
@@ -292,6 +296,14 @@ function App() {
292296
},
293297
category: "System",
294298
},
299+
{
300+
title: "Switch layout",
301+
value: "layout.switch",
302+
onSelect: () => {
303+
dialog.replace(() => <DialogLayoutList />)
304+
},
305+
category: "System",
306+
},
295307
{
296308
title: "Connect provider",
297309
value: "provider.connect",
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
import { DialogSelect, type DialogSelectRef } from "../ui/dialog-select"
2+
import { useLayout } from "../context/layout"
3+
import { useDialog } from "../ui/dialog"
4+
import { createSignal, onCleanup, onMount } from "solid-js"
5+
6+
export function DialogLayoutList() {
7+
const layout = useLayout()
8+
const [options, setOptions] = createSignal(
9+
Object.keys(layout.all()).map((value) => ({
10+
title: value,
11+
value: value,
12+
})),
13+
)
14+
const dialog = useDialog()
15+
let confirmed = false
16+
let ref: DialogSelectRef<string>
17+
const initial = layout.selected
18+
19+
onMount(async () => {
20+
// Reload layouts when dialog opens
21+
await layout.reload()
22+
setOptions(
23+
Object.keys(layout.all()).map((value) => ({
24+
title: value,
25+
value: value,
26+
})),
27+
)
28+
})
29+
30+
onCleanup(() => {
31+
if (!confirmed) layout.set(initial)
32+
})
33+
34+
return (
35+
<DialogSelect
36+
title="Layouts"
37+
options={options()}
38+
current={initial}
39+
onMove={(opt) => {
40+
layout.set(opt.value)
41+
}}
42+
onSelect={(opt) => {
43+
layout.set(opt.value)
44+
confirmed = true
45+
dialog.clear()
46+
}}
47+
ref={(r) => {
48+
ref = r
49+
}}
50+
onFilter={(query) => {
51+
if (query.length === 0) {
52+
layout.set(initial)
53+
return
54+
}
55+
56+
const first = ref.filtered[0]
57+
if (first) layout.set(first.value)
58+
}}
59+
/>
60+
)
61+
}

packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -287,6 +287,11 @@ export function Autocomplete(props: {
287287
description: "toggle theme",
288288
onSelect: () => command.trigger("theme.switch"),
289289
},
290+
{
291+
display: "/layout",
292+
description: "change layout",
293+
onSelect: () => command.trigger("layout.switch"),
294+
},
290295
{
291296
display: "/editor",
292297
description: "open editor",

packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx

Lines changed: 42 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { createEffect, createMemo, type JSX, onMount, createSignal, onCleanup, S
33
import "opentui-spinner/solid"
44
import { useLocal } from "@tui/context/local"
55
import { useTheme } from "@tui/context/theme"
6+
import { useLayout } from "@tui/context/layout"
67
import { EmptyBorder } from "@tui/component/border"
78
import { useSDK } from "@tui/context/sdk"
89
import { useRoute } from "@tui/context/route"
@@ -55,6 +56,7 @@ export function Prompt(props: PromptProps) {
5556
const command = useCommandDialog()
5657
const renderer = useRenderer()
5758
const { theme, syntax } = useTheme()
59+
const layout = useLayout()
5860

5961
const textareaKeybindings = createMemo(() => {
6062
const newlineBindings = keybind.all.input_newline || []
@@ -635,7 +637,8 @@ export function Prompt(props: PromptProps) {
635637
<box
636638
paddingLeft={2}
637639
paddingRight={1}
638-
paddingTop={1}
640+
paddingTop={layout.current.inputBoxPaddingTop}
641+
paddingBottom={layout.current.inputBoxPaddingBottom}
639642
flexShrink={0}
640643
backgroundColor={theme.backgroundElement}
641644
flexGrow={1}
@@ -792,22 +795,25 @@ export function Prompt(props: PromptProps) {
792795
cursorColor={highlight()}
793796
syntaxStyle={syntax()}
794797
/>
795-
<box flexDirection="row" flexShrink={0} paddingTop={1} gap={1}>
796-
<text fg={highlight()}>
797-
{store.mode === "shell" ? "Shell" : Locale.titlecase(local.agent.current().name)}{" "}
798-
</text>
799-
<Show when={store.mode === "normal"}>
800-
<box flexDirection="row" gap={1}>
801-
<text fg={theme.textMuted}>{local.model.parsed().provider}</text>
802-
<text flexShrink={0} fg={theme.text}>
803-
{local.model.parsed().model}
804-
</text>
805-
</box>
806-
</Show>
807-
</box>
798+
<Show when={layout.current.showInputAgentInfo}>
799+
<box flexDirection="row" flexShrink={0} paddingTop={layout.current.inputAgentInfoPaddingTop} gap={1}>
800+
<text fg={highlight()}>
801+
{store.mode === "shell" ? "Shell" : Locale.titlecase(local.agent.current().name)}{" "}
802+
</text>
803+
<Show when={store.mode === "normal"}>
804+
<box flexDirection="row" gap={1}>
805+
<text fg={theme.textMuted}>{local.model.parsed().provider}</text>
806+
<text flexShrink={0} fg={theme.text}>
807+
{local.model.parsed().model}
808+
</text>
809+
</box>
810+
</Show>
811+
</box>
812+
</Show>
808813
</box>
809814
</box>
810-
<box
815+
<Show when={layout.current.showInputBorder}>
816+
<box
811817
height={1}
812818
border={["left"]}
813819
borderColor={highlight()}
@@ -834,8 +840,28 @@ export function Prompt(props: PromptProps) {
834840
}
835841
/>
836842
</box>
843+
</Show>
837844
<box flexDirection="row" justifyContent="space-between">
838-
<Show when={status().type !== "idle"} fallback={<text />}>
845+
<Show
846+
when={status().type !== "idle"}
847+
fallback={
848+
<Show when={!layout.current.showInputAgentInfo}>
849+
<box flexDirection="row" gap={1}>
850+
<text fg={highlight()}>
851+
{store.mode === "shell" ? "Shell" : Locale.titlecase(local.agent.current().name)}
852+
</text>
853+
<Show when={store.mode === "normal"}>
854+
<box flexDirection="row" gap={1}>
855+
<text fg={theme.textMuted}>{local.model.parsed().provider}</text>
856+
<text flexShrink={0} fg={theme.text}>
857+
{local.model.parsed().model}
858+
</text>
859+
</box>
860+
</Show>
861+
</box>
862+
</Show>
863+
}
864+
>
839865
<box
840866
flexDirection="row"
841867
gap={1}

0 commit comments

Comments
 (0)