Skip to content

Commit 804fbf5

Browse files
committed
feat(plugin): a layout rule's rowGap sees the message the row draws
Every transcript row is a box with marginTop={gap()}, drawn whatever its slot renders. A plugin that replaces session.message.user and draws nothing for a message (Prime hides a prompt the runner has not taken, showing it above the composer instead) still left that row's gap: a blank row per hidden message. The rule saw only the row's kind, not which message it is. Why the fork: (1) the plugin API gives the rule no row identity, and a slot cannot remove its wrapper's margin; (2) there is no built-in to disable, since the gap is the host's row wrapper; drawing the gap inside the slot instead moves the row's top onto the blank line, so a jump to the message lands on the blank row. (3) is this: one optional field on the rule's input, generic and upstreamable. It moves out once OpenCode's layout rule names the row's message, or skips the gap of a row that draws nothing.
1 parent 226b844 commit 804fbf5

4 files changed

Lines changed: 48 additions & 2 deletions

File tree

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

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -547,8 +547,17 @@ export type TranscriptRowKind = "user" | "text" | "reasoning" | "tool" | "group"
547547
* last-enabled plugin wins.
548548
*/
549549
export interface Layout {
550-
/** Blank rows above a transcript row. The host default is 1. */
551-
readonly rowGap?: (row: { readonly kind: TranscriptRowKind; readonly previous?: TranscriptRowKind }) => number
550+
/**
551+
* Blank rows above a transcript row. The host default is 1. `messageID` is
552+
* the message the row draws (a message, one of its parts or its footer;
553+
* none for a group), so a plugin whose slot draws nothing for a message
554+
* can give its row no gap either.
555+
*/
556+
readonly rowGap?: (row: {
557+
readonly kind: TranscriptRowKind
558+
readonly previous?: TranscriptRowKind
559+
readonly messageID?: string
560+
}) => number
552561
/** Horizontal padding of the transcript and the dock, in cells. The host default is 2. */
553562
readonly inset?: number
554563
/**

‎packages/tui/src/routes/session/index.tsx‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1735,6 +1735,13 @@ function transcriptRowKind(
17351735
return "other"
17361736
}
17371737

1738+
// The message a row draws, as layout rules see it: none for a group, which spans several.
1739+
function transcriptRowMessageID(row: SessionRow) {
1740+
if (row.type === "message" || row.type === "assistant-footer") return row.messageID
1741+
if (row.type === "part") return row.ref.messageID
1742+
return undefined
1743+
}
1744+
17381745
function SessionRowView(props: SessionRowViewProps) {
17391746
const plugins = usePlugin()
17401747
const [target, setTarget] = createSignal<BoxRenderable>()
@@ -1748,6 +1755,7 @@ function SessionRowView(props: SessionRowViewProps) {
17481755
return rule({
17491756
kind: transcriptRowKind(props.row, props.message),
17501757
previous: props.previous ? transcriptRowKind(props.previous, props.message) : undefined,
1758+
messageID: transcriptRowMessageID(props.row),
17511759
})
17521760
})
17531761
return (
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import { Plugin } from "@opencode/plugin/tui"
2+
3+
// Draws nothing for the prompt `msg_ask` and, through the layout rule's
4+
// `messageID`, gives its row no gap either: the hidden prompt takes no space.
5+
export default Plugin.define({
6+
id: "fixture.hidden-message",
7+
setup(context) {
8+
context.ui.layout({ rowGap: (row) => (row.messageID === "msg_ask" ? 0 : 1) })
9+
context.ui.slot({ append: "session.transcript.start", render: () => <text>Transcript start claimed</text> })
10+
context.ui.slot({
11+
replace: "session.message.user",
12+
render: (input) => (input.messageID === "msg_ask" ? null : <text>User band</text>),
13+
})
14+
},
15+
})

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

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -156,6 +156,20 @@ test("a plugin claims the transcript's header, first and last rows, user prompt
156156
expect(lines[user - 1]?.trim()).toBe("")
157157
})
158158

159+
test("a layout rule sees the row's message, so a message a slot hides takes no space", async () => {
160+
await using app = await openSession("hidden-message")
161+
await app.ready
162+
const frame = await waitForHighlightedFrame(
163+
app,
164+
(frame) => frame.includes("Transcript start claimed") && frame.includes("The parser is fixed."),
165+
)
166+
const lines = frame.split("\n")
167+
const start = lines.findIndex((line) => line.includes("Transcript start claimed"))
168+
// The hidden prompt adds no row: one blank row (the reply's own gap), then the reply.
169+
expect(lines[start + 1]?.trim()).toBe("")
170+
expect(lines[start + 2]?.trim()).not.toBe("")
171+
})
172+
159173
test("a plugin picks the bare rail composer", async () => {
160174
await using app = await openSession("composer-rail")
161175
await app.ready

0 commit comments

Comments
 (0)