Skip to content

Commit a65dc36

Browse files
committed
feat(tui): rail composer chrome for plugins
A bare left rail in the text colour (warning in shell mode), with no fill, padding or metadata row: the fx-style composer.
1 parent 8dfdc5f commit a65dc36

4 files changed

Lines changed: 48 additions & 8 deletions

File tree

‎packages/plugin/src/tui/context.ts‎

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -568,9 +568,14 @@ export interface Layout {
568568
/**
569569
* The composer's chrome. "bar" is the host default: a left accent bar and a
570570
* metadata row. "band" is a full-width band on raised.base with a blank row
571-
* above and below the editor and the given prefix before it.
571+
* above and below the editor and the given prefix before it. "rail" is a bare
572+
* left rail in the text colour (the warning colour in shell mode) with no
573+
* fill, padding or metadata row.
572574
*/
573-
readonly composer?: { readonly chrome: "bar" } | { readonly chrome: "band"; readonly prefix?: string }
575+
readonly composer?:
576+
| { readonly chrome: "bar" }
577+
| { readonly chrome: "band"; readonly prefix?: string }
578+
| { readonly chrome: "rail" }
574579
/**
575580
* Transcript defaults for users who have not chosen. The user's `session`
576581
* config wins over them. Fields merge across plugins one by one.

‎packages/tui/src/component/prompt/index.tsx‎

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1683,9 +1683,18 @@ export function Prompt(props: PromptProps) {
16831683
const composer = plugins.layout().composer
16841684
return composer?.chrome === "band" ? { prefix: composer.prefix } : undefined
16851685
})
1686+
// "rail" chrome: a bare left rail in the text colour (the warning colour in
1687+
// shell mode), no fill, padding or metadata row.
1688+
const rail = createMemo(() => plugins.layout().composer?.chrome === "rail")
1689+
const bare = () => band() !== undefined || rail()
16861690
const promptBg = createMemo(() =>
1687-
band() ? theme.background.raised.base : theme.decrease(theme.background.raised.base),
1691+
rail()
1692+
? RGBA.fromInts(0, 0, 0, 0)
1693+
: band()
1694+
? theme.background.raised.base
1695+
: theme.decrease(theme.background.raised.base),
16881696
)
1697+
const railColor = () => (store.mode === "shell" ? theme.text.feedback.warning.base : theme.text.base)
16891698

16901699
return (
16911700
<>
@@ -1696,17 +1705,17 @@ export function Prompt(props: PromptProps) {
16961705
// Workaround: an OpenTUI box keeps its border once styled (setting
16971706
// `border` back to false leaves the bar drawn), so the band paints the
16981707
// bar in the band colour instead of removing it.
1699-
borderColor={band() ? promptBg() : borderHighlight()}
1708+
borderColor={band() ? promptBg() : rail() ? railColor() : borderHighlight()}
17001709
backgroundColor={band() ? promptBg() : undefined}
17011710
customBorderChars={{
17021711
...SplitBorder.customBorderChars,
17031712
bottomLeft: "╹",
17041713
}}
17051714
>
17061715
<box
1707-
paddingLeft={band() ? 0 : dimensions().width < 44 ? 1 : 2}
1716+
paddingLeft={band() ? 0 : rail() ? 1 : dimensions().width < 44 ? 1 : 2}
17081717
paddingRight={dimensions().width < 44 ? 1 : 2}
1709-
paddingTop={1}
1718+
paddingTop={rail() ? 0 : 1}
17101719
paddingBottom={band() ? 1 : 0}
17111720
flexShrink={0}
17121721
backgroundColor={promptBg()}
@@ -1878,7 +1887,7 @@ export function Prompt(props: PromptProps) {
18781887
paddingTop={1}
18791888
gap={1}
18801889
justifyContent="space-between"
1881-
visible={!band()}
1890+
visible={!bare()}
18821891
>
18831892
<PromptMetadataRow
18841893
mode={store.mode}
@@ -1899,7 +1908,7 @@ export function Prompt(props: PromptProps) {
18991908
</box>
19001909
<box
19011910
height={1}
1902-
visible={!band()}
1911+
visible={!bare()}
19031912
border={["left"]}
19041913
borderColor={borderHighlight()}
19051914
customBorderChars={{
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import { Plugin } from "@opencode/plugin/tui"
2+
3+
// Picks the bare rail composer.
4+
export default Plugin.define({
5+
id: "fixture.composer-rail",
6+
setup(context) {
7+
context.ui.layout({ composer: { chrome: "rail" } })
8+
},
9+
})

‎packages/tui/test/plugin-session-hooks.test.tsx‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { rgbToHex } from "@opentui/core"
12
import { expect, test } from "bun:test"
23
import { createAppFixture } from "./fixture/app"
34
import { copyPlugin } from "./fixture/fixture"
@@ -188,3 +189,19 @@ test("a plugin claims the transcript's header, first and last rows, user prompt
188189
// The band composer puts its prefix before the editor. Its bar is still drawn, in the band's colour.
189190
expect(lines.some((line) => line.startsWith(" ┃> "))).toBe(true)
190191
})
192+
193+
test("a plugin picks the bare rail composer", async () => {
194+
await using app = await openSession("composer-rail")
195+
await app.ready
196+
const frame = await waitForHighlightedFrame(app, (frame) => frame.includes("The parser is fixed."))
197+
const lines = frame.split("\n")
198+
// An empty draft is a bare rail: no fill, padding or metadata row before the footer.
199+
const rail = lines.findLastIndex((line) => /^ *┃ *$/.test(line))
200+
expect(rail).toBeGreaterThan(-1)
201+
expect(lines[rail + 1]).toContain("ctrl+p commands")
202+
// The rail is drawn in the text colour, as the reply's prose is in the default theme.
203+
const spans = app.captureSpans().lines
204+
const color = (y: number, text: string) => spans[y]!.spans.find((span) => span.text.includes(text))!.fg
205+
const prose = spans.findIndex((line) => line.spans.some((span) => span.text.includes("is fixed")))
206+
expect(rgbToHex(color(rail, "┃"))).toBe(rgbToHex(color(prose, "is fixed")))
207+
})

0 commit comments

Comments
 (0)